Commit Graph

152 Commits

Author SHA1 Message Date
Sylpheed RE agent
6a192682e3 re: the Turret-biased run was void -- the guest was frozen from t=0
SYLPH_PREFER=Turret, escort at 100%, 1070s, phase field 0 throughout, 4029
Turret-targeting pilot samples. It looked like a clean negative -- 'hunting the
objective squadrons does not advance the phase' -- and it is worthless:
frozen.py reports max_pixel_delta=0, and the pilot's first sample at t=0.0
already has spd=0 with the same yaw/pitch/target/d=7186 it still had at 1070s.
The mission froze on entry to flight and nothing was ever shot, while screen_id
said 'flight' the whole time.

I was one step from writing this up as a fact about the game; running the freeze
test rather than trusting a plausible log is what caught it.

phase_probe.py now calls frozen.py every 60s and prints a GUEST FROZEN banner
inline, so a dead-world reading is labelled in the data instead of discovered
later. Note frozen.frozen() returns a TUPLE (is_frozen, max_delta) -- testing it
directly is always truthy and would have made the witness fire on every check.
Verified against the frozen guest.

Also recorded: the pilot log is itself a freeze witness -- identical
yaw/pitch/target across thousands of samples is a dead world, not patience.
2026-08-25 13:42:54 +00:00
Sylpheed RE agent
dcf37bcf93 re: a Stage 02 clear condition read end to end, with squadron names
Resolving symbol-table-2 indices turns the bytecode into mission logic. At
0xF524 Stage02.ssb polls unit_state on ADN110, ADN111 and ADN112, updates each
one's objective marker, then latches set_flag(8) -- exactly the
trigger/predicate/set_flag/END_PHASE shape predicted from the disassembly, now
observed in the mission's own code with names the roster tables already gave.

The 12 END_PHASE sites are outro sequences (wait_cmds_drained / fade_sound(3) /
builtin85(3) / wait_s(3) / END_PHASE / yield) -- the terminator, not the
decision.

Fixes a decode bug that hid every argument: the tracker only followed
local[i] = special[0], but the common form is an immediate written straight into
local[i] (k=01,03), so every unit predicate printed with NO arguments. The
disassembly looked complete while being empty exactly where it mattered.

Also records the live probe result: the phase mirror at [*(0x828F35F8)+236]
stayed 0 for ~530s of actively-hunting flight, no advance observed -- which is
what the static analysis predicts for phase 1, since ChangePhase only posts once
the ordinal exceeds 1.
2026-08-25 12:42:50 +00:00
Sylpheed RE agent
70cff9ca21 re: the 147 ISL built-ins characterised; Stage 02 gates on units, not counts
Table at 0x8227226C is 147 big-endian absolute VAs (verified structurally: it
ends exactly where the first handler begins, all targets inside sub_82272220).
Arguments are not in the instruction -- every handler does c_str() on
[phase+20], a packed blob, which is what the local[] staging fills. Return 2 =
yield; five built-ins block by skipping the pc advance.

Recovered the ScriptPhase state layout: 32-entry float and flag register files,
int/double result registers, the timer block, and the runtime unit array at
+324 indexed by symbol-table-2 index -- a direct hook from bytecode call sites
to the two .ssb symbol tables.

Spot-checked two claims against the disassembly rather than trusting them: id 4
loads a DOUBLE into the thread countdown and returns 2 (wait_s), and id 24 reads
current/initial squadron member counts (squad_survival_pct). Both exact.

Counting Stage02.ssb: unit_state 255, hp_pct_test 167, dist_lt 92, unit_alive
71, unit_relation 52 -- and squad_survival_pct, group_ratio_pct and the two
global counters are NOT called at all. So Stage 02's phases are gated on named
units (destroyed / HP / proximity), never on an aggregate count, even though the
kill-counter primitives exist in the VM. That answers the standing 'next wave
after N kills or after an event?' question for this stage: specific units, not a
number.

isl.py now names the built-ins, so the run-up to the first END_PHASE reads
wait_cmds_drained / fade_sound(3) / builtin85(3) / wait_s(3) / END_PHASE.

Not settled: 3 handlers unresolved (55, 75, 105); the 1024-slot interpreter
command table is only partly recovered.
2026-08-25 12:36:02 +00:00
Sylpheed RE agent
3f13a8ceef re: ISL operand kinds decoded; arguments are staged through local[]
Resolver table 0x82271D74 gives four kinds: 0 global[i], 1 immediate,
2 special[i] ([phase+164]/[phase+168]), 3 local[i] ([phase+20+i]). Byte[0] is
the rvalue kind, byte[1] the lvalue kind, so the recurring instruction pair is
argument staging -- values land in local[] at offsets 0,4,8,0xC and the next
call consumes them. A built-in's arguments are not in its own instruction.

Fixed a decode that would have been believed: immediates in set.f are DOUBLES
carried as two words (op 1 stores with stfd). Reading the high word as a float
gives 2.125 where the script means 3.0.

isl.py now tracks staging and prints call arguments, so the run-up to the first
END PHASE in Stage02 reads as builtin=64(0x42,2,1,9,1,-1) / 120 / 59(3) / 85(3)
/ 4(3) / 6. Three built-ins taking 3 just before the phase ends look like a
wait-seconds family -- flagged as unconfirmed until the built-in table is read.
2026-08-25 12:28:04 +00:00
Sylpheed RE agent
7b445916bc re: ISL bytecode encoding decoded; phase-end call sites located in Stage02
Read the encoding off the interpreter rather than guessing: instruction is a
big-endian u32 whose LOW byte is the opcode (25 of them, table 0x822635FC),
byte[2] is the instruction length -- every handler advances the pc by it -- and
bytes[0..1] are operand kinds. Op 12 is a jump whose operand is relative to the
code base [phase+232], which settles that offsets are code-base-relative for
this opcode. Op 19 is the built-in call: id in word@+4, and word@+8 is a
monotonically increasing STATEMENT id (0x245, 0x248, 0x24A, ...).

Confirmed by disassembling Stage02.ssb: the stream decodes cleanly from the code
base and routines terminate on ret exactly where expected.

Scanning the code region on the call encoding: 2846 call sites, 73 of the 147
built-ins used. The phase-control ones are located -- built-in 6 (end phase) at
12 sites, 62 at 3, 39 (mark last phase) at 8 -- so a phase has several exit
paths, as a mission with win and lose branches should.

New tool tools/re-capture/isl.py with --calls and --to (resync-into-target,
needed because instructions are variable-length so you cannot walk backwards).

Not settled: the 147 built-ins are uncharacterised, so this is structure without
meaning -- we can see THAT a phase ends, not WHAT was tested.
2026-08-25 12:21:44 +00:00
Sylpheed RE agent
fba1cf1043 backlog: phase-advance solved, hashes located, nav bug fixed, tooling corrections 2026-08-25 12:01:53 +00:00
Sylpheed RE agent
f6508dd8ac re: locate both guest hash routines; IXUD solved; two corrections
Found the routines in the disassembly DB rather than guessing from data:
  sub_82447DF0  IDXD tag hash  (lbz+extsb, modulus 0x00FFFFDF, magic 0x2101)
  sub_82447E70  IXUD tag hash  (lhz, 64-bit, modulus 0xFFFFFF67 then 0x00FFFFDF)
Both transcribed instruction-for-instruction into Python and Rust.

IXUD SOLVED. It defeated every single-modulus search because it chains TWO
exact moduli -- the loop reduces mod 2^32-153 in 64-bit arithmetic and only the
result is folded mod 2^24-33. A polynomial mod M1 folded through M2 is not a
polynomial mod anything, which is exactly why the gcd test returned 1. Verified
independently: 86/86 record keys and 108,261/108,261 field tags in
GP_MAIN_GAME_E.pak, and NoRecord -> 0x1c6d9c96.

CORRECTION 1: tag_hash must SIGN-EXTEND each byte (extsb). My reconstruction
used unsigned bytes and matched all 1.27M disc names -- every one is ASCII --
while disagreeing on ~90% of random inputs with a byte >= 0x80 (verified:
18096/20000). The disc could never have caught this; only the disassembly did.

CORRECTION 2: name_hash's reduction is EXACT, not lossy. The module doc claimed
the missing conditional subtract made it something other than %. rlwinm r6,r6,
9,23,31 is just hi>>23, and with RECIP = floor(2^55/M)+1 that is Granlund-
Montgomery magic division -- 0 wrong at every quotient boundary across the full
32-bit domain. Retracted.

cargo test -p sylpheed-formats --lib hash: 10/10.
2026-08-25 11:28:35 +00:00
Sylpheed RE agent
cbf52ba9f9 re: recover the IDXD record-key / field-tag hash (8643/8643)
Closes the 4-byte record key. tag_hash is name_hash's shape -- byte-sum
checksum in the top byte over a 24-bit modular polynomial -- with two different
constants: modulus 0x00FFFFDF (2^24-33, prime) instead of 0x00FFF9D7, and no
lowercasing, so tags are case-sensitive. name_hash explains 0 of 8643.

Recovered from the tables rather than the executable: every inline field name
is a known (name -> tag) pair, and comparing names differing in one character
gives the per-position weights 1, 0x100, 0x10000, 0x21, 0x2100, ... -- a byte
leaving bit 24 re-enters as 33, i.e. reduction mod 2^24-33. Holds where it is
easy to get wrong (distance 8 and 9 carry correctly).

A record's key is the tag of its own name: FormationSet rosters 362/362,
UnitGroup rosters 281/281, S02 squadron names 111/111 -- so records can be
addressed by name without reading the roster first.

Implemented in Python (unitgroup.tag_hash) and Rust
(sylpheed_formats::hash::tag_hash) with 3 new unit tests carrying disc-derived
vectors; cargo test -p sylpheed-formats --lib hash is 8/8 green.

Not settled: the guest routine is unlocated, so this uses exact modular
arithmetic where the game may use a Barrett step without final fixup.
2026-08-25 10:38:18 +00:00
Sylpheed RE agent
de42fbd742 re: UnitGroup member field n is a unit count, bounded by the formation
FormationSet_S<NN>.tbl records are slot lists -- 1 + 8*FrameCount fields,
exactly. Resolving every squadron's FormationID and comparing gives
sum(n) <= FrameCount holding 1159/1160 across all 28 stages, 0 unresolved, with
539 filling the formation exactly. The single violation is a debug leftover
(S20, AI_Test / MessageSet_test, Formation_1_only with n=2) and is recorded.

The old 'n is not the _NN suffix of FormationID' observation was right but drew
the wrong conclusion: the suffix IS FrameCount, so n=9 against _30 just means 9
units in 9 of 30 slots.

Also: FormationID does not hash into its table (0/16). FormationSet carries a
name roster record -- no FrameCount, fields are (tag, name, '') with the tags
being the record keys -- the same convention as Enumerate_Squadrons. Second
occurrence of 'keys are resolved by an in-table roster, not by hashing'.

Does not close the 387-vs-300 gap, and the key derivation stays open.
2026-08-25 10:04:20 +00:00
Sylpheed RE agent
6900ebe5ca re: the frozen wait-object capture, and screen_id was never a freeze test
Caught the freeze by waiting for the event (frozen.py + in_flight) instead of
sleeping a guessed interval; freeze_waitobj.sh splits into boot/watch so the
wait is not capped by one Bash call. Verified hard: a frame minutes later is
byte-identical to the capture.

Healthy vs frozen, same run: 20 -> 24 wait frames, XEvent 19 -> 23,
XSemaphore 8 -> 7. The signature is per-thread -- 17 of 24 threads sit on the
exact object they were on, four previously-running threads park, and T74/T75
move off a semaphore onto an event. So the freeze is not a whole-emulator stall.

Also corrects the previous entry's test: screen_id reads 'flight' during a
freeze by design, which is why frozen.py exists. Re-testing the saved frames
says that run was genuinely healthy, but it was right by luck.

heavy_read.py added to test whether the instrument provokes the freeze: I/O is
free (371 MB in 0.1s, page cache), the cost is Python-level CPU. One data point
-- 670s clean, then frozen 54s after the inducer started -- recorded as n=1, not
as causation.
2026-08-25 09:02:48 +00:00
Sylpheed RE agent
a9526749dc re: WaitMultiple read confirmed on a live capture; XTimer is a third type
23 wait frames, 30 objects, nothing unresolved -- the second deref turns every
former miss into a resolved object, as predicted. XEvent 20 / XSemaphore 9 /
XTimer 1; every WaitMultiple thread waits on a pair, and 78/79/80 and 64/65 are
worker groups sharing a handle.

%ebp does not survive as the count -- WaitMultiple reuses it at 8fc158 -- so the
array is bounded by reading until an entry stops resolving instead.

The frozen capture is still not taken: screen_id reads 'flight' at the second
capture and out to ~470s, so the mission never black-screened. The diff in the
data file is two healthy captures and is recorded as such.
2026-08-25 08:34:46 +00:00
Sylpheed RE agent
6f6ace5ec7 re: withdraw the 'unrestorable rbx' claim — the misses are WaitMultiple
The 8 threads whose [rbx] did not resolve to a vtable were never in
XObject::Wait. The backtrace grep matched WaitMultiple as a substring, and
there %rbx is the XObject** array (mov %rsi,%rbx) with the count in %ebp, so
[rbx] is objects[0] -- an object pointer, needing a second deref -- not a
vtable. The unwind restored rbx correctly for all 18.

freeze_waitobj.sh now takes the function and frame index from the backtrace and
applies the matching read, and captures twice in one run (healthy and after the
~270s black-screen) so the comparison is within-run. waitobj_report.py tabulates
both and diffs them, discarding any value info symbol cannot resolve.
2026-08-25 08:20:21 +00:00
Sylpheed RE agent
bfd4d55ce9 re: two wait object types, and the read validates itself
Re-extracting the same gdb capture per thread rather than by grep qualifies the
previous entry. Of eighteen threads whose frame 3 is XObject::Wait, eight have
[rbx] equal to the XEvent vtable plus sixteen, two equal to the XSemaphore
vtable plus sixteen, and eight hold a pointer into the mmap region that is not a
vtable at all. So the waits are on two distinct kernel types, XEvent and
XSemaphore, and the earlier claim that the object is an XEvent was right for the
majority but not the whole picture.

The eight non-vtable readings are the method checking itself rather than a
failure. A polymorphic object's first word is always a vtable pointer, so those
values are simply not this: rbx could not be restored for those frames and the
unwind returned whatever the register held. A reading counts only if [rbx] lands
in the binary's vtable range and resolves to a "vtable for" symbol; anything
else is discarded rather than interpreted. Ten of eighteen resolve and the rest
are honestly unknown.

That also settles the previous entry's worry that the 0x7ffc and 0x7ffd
addresses looked like stack. They are the shared mmap region, which holds thread
stacks and large allocations alike, so the vtable check rather than the address
range is what separates an object from a stack slot.

The follow-up run that would have added /proc/<pid>/maps classification and a
wider object dump never booted -- EMULATOR GONE at 0s, skip_intro exit 4 --
most likely a stale emulator or lockfile from the preceding gdb session, whose
process tree is parented differently and escaped the usual cleanup. So the map
classification, the multi-word object dump and the frozen-state capture are all
still unrun.
2026-08-25 08:10:56 +00:00
Sylpheed RE agent
a153c392f5 re: the waited object reads as an xe::kernel::XEvent
Executed the wait-object read on a live run under gdb. For every thread whose
frame 3 is XObject::Wait, rbx gives a pointer whose first quadword is
0x5555562db8f0, which is the PIE base plus 0xd878f0, and the symbol table has
vtable for xe::kernel::XEvent at 0xd878e0. The stored pointer is the vtable
symbol plus sixteen, since offset-to-top and RTTI come first, so it matches
exactly. The same vtable appears on every sampled waiting thread while the this
pointers differ, meaning many threads waiting on different XEvent instances. The
whole chain needs no DWARF and no rebuild, as the static groundwork predicted.

Two caveats, both recorded rather than smoothed over.

This is a healthy-play snapshot, not the freeze. The capture landed at 195 s of
flight with screen_id reporting flight and a non-black mean, because boot under
gdb costs about 300 s and the entire experiment has to fit inside one call --
a timeout kills the process group and takes the emulator with it, which lost an
earlier attempt outright. So this describes what threads wait on during normal
play, which is the control the frozen capture never had, but it is not the
frozen case.

And the this addresses look like host stack rather than heap. Either xenia
places these objects somewhere unusual, or rbx at frame 3 is not Wait's this
after the unwind and the vtable match is coincidence. An exact plus-sixteen
match on a known symbol is hard to get by accident, but the address range is not
what was expected. The settling check is to dump a few words at rbx and see
whether they look like an XEvent -- vtable, KernelState pointer, handle and type
fields -- or like saved registers.
2026-08-25 08:04:33 +00:00
Sylpheed RE agent
925006d38f re: the mission black-screens at ~4.5 min — that is what ends every run
A two-segment OB watch made the mechanism visible. Segment 1 ran clean, with the
witness reporting zero stalled samples of fifty and OB steady at 4 for 250
seconds. Segment 2, attaching to the same still-alive emulator, got "NOT IN A
MISSION (no unit definitions)".

The emulator was alive; the mission was gone. The pilot's hull reads 1500 in
every line, so the player never died. Its log stops at t=267 s with consecutive
byte-identical lines, the freeze signature. And the screen is now entirely
black, mean (0,0,0). This is the pre-existing mission-end black-screen freeze
already recorded in the corpus, caught in the act. Segment 1's clean witness is
not a contradiction, since it sampled to about 250 s and the freeze came at 267.

That corrects an earlier claim. mission-arrival-watch.md records the blocker as
removed because the emulator survives between calls, which is true, but it was
stated as though observation could be extended indefinitely within a turn. The
emulator surviving is not the mission surviving. The binding limit is the game's
own black-screen at roughly four and a half minutes and no amount of chaining
crosses it. The earlier chained run that reached 435 s of cumulative
verified-live observation did so across two segments of one mission, which fits:
its second segment froze at t=135 s.

The consequence is worth stating plainly. Any experiment needing more than about
four and a half minutes of one continuous mission is not doable on this build,
chained or not, and that includes clearing sixteen marked fighters at two kills
per five minutes. The freeze is therefore now the highest-value target in its own
right, since fixing it would unblock the arrival question, the phase-advance
question and the OB series together. It is also a long-standing known defect
rather than anything this work introduced.
2026-08-25 07:23:27 +00:00
Sylpheed RE agent
70b1e453a7 re: OB address is run-dependent; watcher hunts it, HUD reader gated on confidence
ob_watch.py verifies the address against a confidence-gated HUD reading before
reporting anything, and two consecutive fresh launches settle the question:
0xbdb59668 held 3165285888 against a HUD of 4 on one, and exactly 4 on the next.
The address is not stable across launches, the old note that it recurs in about
five runs of seven was right, and the gate did its job by refusing to report a
series from an address that did not describe that run. The watcher now hunts the
address on the current run when confirmation fails, using the same intersection
method, so it no longer depends on a lucky launch.

The HUD reader is also gated now. ob_read returns a best and second score per
digit and those were printed but never checked, which is how one misread
poisoned an intersection and produced a wrong refutation of big-endian u32. A
reading is accepted only if every digit scores at least 0.80 with a margin of at
least 0.05, the rule ob_read's own docstring states.

The measurement itself is a negative. With a clean witness, zero stalled samples
of fifty, OB held at 4 for 250 seconds while the pilot targeted e010 for 1964
ticks and fired on 1635 of them. Constant fire at the marked attackers and not
one decrement, so it destroyed none, which matches the roughly two marked kills
per five minutes measured earlier. The fire rate itself rose from 4.6 % of ticks
in an earlier diagnosis to 83 % here without producing more kills.

Recorded as unreproduced rather than explained away: the run that found the
address saw the counter rise 4, 8, 12 over five minutes, and that reading was
confirmed against the HUD. This run was flat over a comparable window. Both
observations are sound and they disagree, so the rise is not a stable property
of the mission's first five minutes and presumably depends on progress this run
never reached.
2026-08-25 06:02:33 +00:00
Sylpheed RE agent
d1154d7ca6 re: REMAINING OB found and verified — big-endian u32 at 0xbdb59668
Ported onto the shared harness, the HUD changed from 4 to 8 and the intersection
collapsed in a single step: one u32be survivor at 0xbdb59668, with the u16be and
u8 hits at 0xbdb5966a and 0xbdb5966b being the low half and low byte of that same
word.

Verified live rather than asserted. Reading screenshot and memory together three
times, the one legible HUD frame showed 012 against mem@0xbdb59668 = 12; the
other two frames were unreadable rather than mismatched. This independently
rediscovers the address the earliest sessions found by digit-transition hunting,
by a completely different method.

It also corrects an earlier conclusion. A previous run intersected HUD readings
of 4 and then 11, got zero survivors in every encoding, and that was written up
as eliminating big-endian u32 for the whole region. This run shows u32be holds
the counter, so the refutation was wrong. The likely cause is the input:
ob_digits.png has templates for 0 1 2 4 8 only, so values containing other
digits are misread rather than rejected, and "11" was probably one of those. A
single bad reading poisons an intersection permanently, because it removes the
true address and nothing later can restore it. The lesson is that an
intersection method needs individually verifiable inputs -- the reader's
confidence scores were printed but never gated on.

One observation reopens the arrival question in a useful way: the counter
increases, 4 then 8 then 12 across about five minutes, measured in memory so not
a digit misread. A count of remaining marked targets that rises means targets
are being added during the mission. That does not contradict the deployment
finding, since the roster is fixed at load, but it does mean the game marks new
objective targets as the mission proceeds. Watching this one address across a
whole mission is now the obvious next experiment and costs almost nothing.
2026-08-25 05:40:30 +00:00
Sylpheed RE agent
feb535a8fb re: build a shared probe harness so the same lessons stop being re-learned
Four probes were written from a blank file and each re-learned the same lessons
by losing a run: that a flat run cannot be told from a frozen guest without a
stall witness, that results held to the end of a run are destroyed by a turn
timeout, that a roster count which is not the stage's member count means a
different stage loaded and must be discarded, and that a run's witness state has
to be read before its numbers. Writing each lesson down did not stop the next
probe repeating it, because each probe started from nothing.

probeharness.py makes them structural. Probe(baseline=N) discovers the roster,
rescans up to five times and refuses to start if the count never reaches the
baseline. The witness is calibrated on construction, sampled by tick() and
reported by status() and summary(), so a probe cannot forget it, and when no
witness is found it reports UNVALIDATED rather than zero stalls. emit() flushes
on every line. craft(), strengths(), alive() and heap() supply the
roster-to-craft link, per-record liveness and the raw heap, so a new probe
writes only its own logic.

Verified rather than asserted: deploy_probe.py reimplements the per-record
deployment watch on top of it in about forty lines against wave7_probe's
hundred and fifty, and its first live run was clean -- 116 roster records, 32
witnesses at 10/s, zero stalled samples, seven losses tracked, and the TSV
written incrementally. Nothing about the result is new, which is the point: the
harness reproduces a known-good measurement.

The existing probes are deliberately not ported. They work, and rewriting them
would risk changing results other documents cite. New probes should use the
harness; old ones should be ported when they next need a change.
2026-08-25 05:22:24 +00:00
Sylpheed RE agent
738df50803 re: widen the OB scan to seven encodings; run inconclusive, probe lacked a witness
ob_by_hud.py now scans seven readings of the same bytes and keeps a separate
candidate set for each, as byte offsets: u32 big and little endian, u16 both
endiannesses at both alignments, and u8. The big-endian u32 reading had been
refuted, so widening rather than assuming is the point.

u32le is much the tightest at 154 candidates against u32be's 4452. That is a
hint about the encoding rather than a result, since a rarer bit pattern narrows
faster regardless of meaning.

The run is inconclusive. The HUD read 4 at every sample, so there was no second
value to collapse the sets against, and from t=136 the candidate counts are
byte-identical across five samples in all seven encodings, which is what a
frozen guest looks like -- nothing in 32 MB changed at all. The probe had no
stall witness, so the run cannot prove it either way. One is added now.

Worth stating plainly: this is the fourth probe written without a witness and
the third whose flat output could not be distinguished from a freeze. Each time
the fix gets applied to that one script. The durable fix is the shared probe
harness already noted in this file, and the lesson recurring four times is
itself the argument for building it.

What the hunt needs is unchanged: two HUD readings at different values in
non-stalled samples. The counter moves on kills, which lands back on the combat
limit, though the earlier 4 to 11 observation shows it does move.
2026-08-24 23:14:59 +00:00
Sylpheed RE agent
6fc082ca55 re: kill-free HUD route for REMAINING OB; big-endian u32 assumption refuted
The correlation route is gated on marked-fighter kills, which the pilot manages
at about two per five minutes. ob_read.py already reads the counter off the
screen, so ob_by_hud.py matches the displayed value against memory directly and
needs no kills at all: screenshot, read the digits, keep heap words equal to that
value, intersect across readings.

Four readings at value 4 narrowed 6156 candidates to 4312, the expected slow
drift. Then the HUD read 11 and the intersection collapsed to zero. A word
holding this counter must equal 4 at the first four samples and 11 at the last,
and none does, so within the entity heap read as big-endian u32 the counter does
not exist. It may be u16, u8, little-endian, or outside that region. Both
previous hunts assumed big-endian u32 there, so this eliminates the assumption
rather than merely failing to find anything.

The displayed value also went up, from 4 to 11 over about 340 seconds. A pure
countdown of remaining marked targets should not rise, and the deployment work
says phase 1 gains no new participants. Three readings are possible and none is
tested: the cell being read is not REMAINING OB, the digits are misread, or the
counter genuinely counts something that can increase. The two clean readings
scored 0.95 to 0.98 against their templates, but 4 and 11 use only digits that
are in the strip, which is exactly the selection effect that would hide a wrong
reading -- the template set covers 0 1 2 4 8 only, and most samples came back
unreadable.

Next is widening the scan to u16 and u8 and to little-endian, and beyond the
entity heap, which is a change to one function and costs no combat. Extending
ob_digits.png with the missing digits would also raise the sample yield, since
only two of eleven readings in a 480 s run were usable.
2026-08-24 22:58:55 +00:00
Sylpheed RE agent
95535d09da re: OB hunt second attempt — saving verified, filter fixed, attach was frozen
The incremental-save fix is verified. A fresh mission caught one e010 event at
t=241 s and wrote 1187 candidates to disk immediately; the turn timeout then
fired exactly as before, but this time the data survived. The session also
clears the candidate file at launch, since candidate offsets are only meaningful
within one emulator instance and resuming across launches would intersect
unrelated addresses.

The correlation itself was wrong though. It matched on the delta alone, so any
two float bit patterns whose integer representations differ by the loss count
qualified, and in a heap full of positions and velocities that is thousands of
words. The 1187 survivors were things like 1044450858, about 0.1f, and
3212461993, a negative float. Candidates must now also look like a counter --
a small non-negative integer in both samples -- which removes the noise by
construction instead of hoping the intersection washes it out.

The follow-up attach logged zero events across 520 s, which reads like the
combat-effectiveness limit again. It was not: 25 of its 26 samples were flagged
GUEST STALLED, so the guest was frozen for essentially the whole window. The
witness added last iteration did its job, and the lesson is about reading it --
the run summary quoted "0 events" first and the stall count only surfaced on a
deliberate check. A run's witness result should be the first thing looked at,
before any interpretation of what the run showed.

Still unfinished, with no address identified. What is needed is unchanged, two
or three e010 kill events in non-stalled samples, and the two obstacles are now
clearly separate: the freeze rate, and a pilot that manages about two
marked-fighter kills per five minutes.
2026-08-24 22:15:15 +00:00
Sylpheed RE agent
f6f8930ee7 re: REMAINING OB by correlation — method works, run unfinished, two repeat defects
The objective text settles what the counter is, so the hunt can be a correlation
rather than a value scan: keep every word in the 32 MB heap that fell by the same
amount, in the same interval, as a named e010 loss. One event cut roughly eight
million words to 1056, so two or three more should leave a handful.

The run did not get them. The turn's timeout fired at t=219 s and the probe saved
its candidate set only at the end, so the 1056 were discarded and the follow-up
attach started from nothing. That is the same mistake already recorded in
guest-stalls.md, where an earlier probe deferred all analysis to the end and a
timeout killed it with 240 s of data in memory and nothing written. The lesson
was written down and then repeated in a new script four iterations later.

The attach had a second gap: 535 s with zero losses of any kind, which is
indistinguishable from a freeze, and ob_probe2 carried no stall witness so the
run cannot say which it was.

Both are fixed. Candidates are written after every event and SYLPH_OB_RESUME=1
reloads them so a chained attach keeps intersecting on the same mission, and the
witness from wave7_probe is carried here.

The underlying pattern is worth naming: each new probe starts from scratch and
re-earns the same lessons about saving incrementally and validating liveness. A
shared probe harness would stick where written-down lessons have not.

The hunt itself is unfinished. No address is identified, and finishing needs a
run that catches two or three marked-fighter kills, which is the same
combat-effectiveness limit already recorded -- about two per five minutes against
a dozen turrets.
2026-08-24 21:50:10 +00:00
Sylpheed RE agent
be6207822d re: target preference works; the blocker is now combat effectiveness, not RE
pilot.py gained SYLPH_PREFER, a unit-name substring whose matches get their
target score multiplied by 0.05 while everything else is multiplied by 4.0. With
SYLPH_PREFER=e010 a clean 320 s run, zero stalls by the witness, killed eight
turrets and two Attacker_S.

The preference is real -- e010 kills went from roughly one across all previous
runs to two in a single run -- but it is weak. Turrets still outnumber attackers
four to one in the kill log, because target commitment and simple proximity keep
pulling the nose back to them, and phase 1 fields 108 turret craft against 16
attackers. Deployed stayed at 41 throughout, so no phase advance.

That quantifies the blocker. Clearing the marked attackers means destroying 16
craft, and at two per 320 s that is about 2560 seconds, roughly 43 minutes of
continuous verified-live flight across many chained attaches, against a freeze
rate of about two runs in five.

This is no longer a reverse-engineering problem. Everything needed to observe
the phase advance is built and validated -- the roster-to-craft link, the
liveness read, the stall witness, chained attaches and the discard rule. What is
missing is a pilot good enough to complete the mission objective, which is
game-playing work with an uncertain payoff.

The choice is recorded rather than made, because it is about how much effort one
confirmation is worth rather than a technical unknown: invest in the pilot,
accept the static answer where only the trigger is inferred rather than
observed, or attempt one very long chained run betting against the freeze rate.
2026-08-24 21:18:15 +00:00
Sylpheed RE agent
9daf9dc592 re: sample from the first moment — still flat, and the wait variant caught the ready room
early_probe defers everything expensive: no witness calibration, no per-record
labelling, and the heap scan uses bytes.find. Setup now completes in 0.5 to 0.8
seconds instead of about 25, so the first sample lands essentially at flight
detection.

It is still flat. Deployed reads 41 at flight+0.8 s and every sample after, with
the only change in 252 s being 41 to 40 when one squadron was wiped out. No
climb at any point. Under the frames reading that is expected, since the whole
phase-1 schedule would be finished within eight seconds of mission start.

The attempt to get ahead of flight detection did not work, and the reason is
worth recording. A second run started the probe before the launch and waited for
the roster to appear. It appeared with 116 records, but the numbers were
deployed 39 and craft 276, flat for the whole window. That is the ready room:
the roster is built before take-off, so waiting for it catches the pre-flight
scene, and the probe's window expired around the time flight actually began.
Waiting for the roster is not the same as catching mission start, and the test
as designed does not do what it claims.

The two runs together do suggest something, held at 🟡 because they are
different runs. The ready room shows 39 deployed and 276 craft while flight
shows 41 and 300, so two records and twenty-four craft appear between them. That
points at deployment being a single step at take-off rather than a schedule
unfolding during the mission.

Next is one run of about 400 s with the probe waiting for the roster, long
enough to span ready room, take-off and flight in a single continuous series,
which would show the 39 to 41 step directly or refute it.
2026-08-24 20:28:15 +00:00
Sylpheed RE agent
222f35a7a7 re: blocker removed — chained attaches extend the observation window
The previous iteration declared route entries t=210 and t=240 unreachable
because a shell call is capped at 595 s. That was wrong, and it rested on an
assumption I never checked: launch_mission.sh leaves the emulator running and it
survives between Bash calls within a turn. Verified directly -- 611 s elapsed
and still running after the launching call had returned.

attach_session.sh attaches a pilot and probe to an already-running mission, and
attaches chain, so the window is bounded by the turn rather than by one call.

On one continuous mission: 300 s of clean observation followed by an attach that
ran 135 s before freezing, giving 435 s of cumulative verified-live wall-clock.
The craft population fell from 300 to 258, so 42 were destroyed, and deployed
fell 41 to 38. Zero confirmed arrivals throughout -- the longest verified-live
observation so far. That covers the first four phase-1 route entries, t=90, 120,
170 and 210; t=240 needs 436 s and was missed by a second.

The coverage claim is qualified rather than asserted. The wall-to-game
conversion uses 55 %, from a 16.5/s frame rate against an assumed 30 Hz tick,
but the witness has measured 8, 11, 11, 21 and 24 per second across runs. At the
low end the factor is 0.27 and the same 435 s covers only about 117
game-seconds, reaching t=90 alone. So what is established is that nothing
arrived in 435 s of verified-live phase-1 flight with 42 kills; how many
scheduled arrival times that spans depends on a tick rate that is not yet
pinned, and pinning it is what would make the result sharp.

Also refuted: SYLPH_HZ=3, a lower pilot poll rate, produced the lowest
calibrated frame rate of any run at 8/s while landing the most kills at 26. The
pilot's polling is not the throttle and lowering it does not buy game time.
2026-08-24 19:54:32 +00:00
Sylpheed RE agent
d4f6b7bf03 re: freezes are stochastic, not eliminated; calibration vectorised
Correcting the previous iteration's wording. It claimed on n=1 that disabling
the periodic rescan "removes the freeze". With more runs that is too strong: the
configuration is now clean at 210, 240 and 300 seconds and frozen at 60 on a
fourth run.

The tally across configurations: the heavy probe froze at 27, 45, 83, 183 and
255 seconds; the cheap probe with a 90 s rescan froze at 183; the cheap probe
with no rescan is clean in three runs of four past 200 s. That is a real and
large improvement in survival, but it is a change in probability rather than a
fix, and the documentation now says so.

The witness handled the frozen run correctly -- losses stop at t=45 and stalls
are flagged from t=60 onward, with no contradiction between them.

Separately, the candidate search was the last Python loop over eight million
words and most of the remaining startup cost. Replaced with two numpy vector
operations; 7250 candidates found and startup is no longer the bottleneck. Boot
now dominates: about 190 s of title movie plus 35 s to flight against a 595 s
turn cap leaves roughly 350 s of observation, so boot is the only remaining
lever if longer windows are needed.

Practical rule recorded: do not treat a single frozen run as evidence. Run,
check the witness, discard the frozen ones and keep the clean ones -- about
three in four are usable now, which is workable where it previously was not.
2026-08-24 19:13:06 +00:00
Sylpheed RE agent
14385ae170 re: clean run reproduces; startup was eating a third of the window
Second consecutive run with no stall flag on any sample, which confirms that
disabling the periodic rescan is what fixed the freezes. Nine losses, four
flicker increases all correctly rejected, and zero confirmed arrivals. The
trustworthy negative now extends to 240 s of verified-live flight, roughly 132
game-seconds, so nothing arrives past the route table's t=90 or t=120 entries
either.

The flicker rate is worth noting: about one spurious increase per minute. That
is the rate at which the old rule would have been manufacturing arrivals.

The arithmetic of the run exposed a waste. Boot finished at 249 s, the probe ran
240 s, and the turn's 595 s cap fired, leaving about 100 s unaccounted for
between them -- the witness calibration and the initial craft enumeration.

enumerate_craft was iterating every 4-byte word of 32 MB in Python, eight
million steps, to find fourteen fixed needles. Replaced with bytes.find() per
definition VA, which is the same search at C speed and is what the vtable scan
already did. Not yet run.

If that recovers most of the hundred seconds the observation window grows from
about 240 s to about 340 s, roughly 187 game-seconds, which would finally reach
the t=170 route entry -- the first of the schedule's later arrivals that no run
has yet been able to observe.
2026-08-24 18:31:51 +00:00
Sylpheed RE agent
d674fee457 re: the periodic rescan was the freeze driver; first trustworthy negative
Two changes this iteration, one failed and reverted, one that worked.

Trimming the witness calibration to eight spread 512 KB windows instead of the
full region found 17 candidates, none of them frame counters, and the witness
then reported 0 of 17 stalled on every sample of a run that recorded 13 losses.
That is a total contradiction, caught by the same internal check that exposed
the previous three witness failures. The frame-rate cluster is sparse and spread
sampling misses it. Reverted: two 32 MB reads once at startup is simply the
price of a witness that works.

The recurring cost was the periodic rescan, a 32 MB read every 90 to 180
seconds, and it exists only to catch craft appearing from nowhere -- which the
roster work already established does not happen, since every participant is
allocated at mission load and an arrival is a state change rather than an
allocation. Disabled.

The result is the first fully clean probed run: 3875 candidates, 32 witnesses at
11/s, no stall flag on any sample from t=0 to t=210, and eight losses spread
across it. Previous probed runs froze at 27, 45, 83, 183 and 255 seconds. This
one ended on the turn timeout. One run, so not proven, but together with the
clean no-probe control it points at recurring heavy reads rather than at memory
reading as such.

That also produces the first arrival result that means what it says. Every
earlier one carried a caveat -- a stalled guest, an unvalidated witness, a probe
degrading what it measured. This one has a validated witness reporting no
stalls, a demonstrably live guest, and a clean end: zero confirmed arrivals over
210 s of verified-live Stage 02 flight, roughly 115 game-seconds, while the
player destroyed eight craft.

It does not settle the question. The route table's t = 170, 210 and 240 entries
remain out of reach in a single turn. But it does establish that nothing arrives
in the first ~115 game-seconds of phase 1 under those conditions, which none of
the previous fifteen runs could honestly claim.
2026-08-24 18:16:49 +00:00
Sylpheed RE agent
f4561600c5 re: the stall witness is validated; the freeze is now the blocker
Fourth attempt at the witness, and it passes the check the previous three
failed. Searching the whole 32 MB region rather than a 4 MB window gives 6500
candidates and 32 witnesses in a clean 21/s cluster matching the measured frame
rate, and the flags have zero contradictions with the loss data: the single loss
falls in an unflagged sample and every flagged sample has none. The guest
genuinely froze at about 45 s and the instrument says so.

That leaves the freeze itself as the dominant problem. Onset across runs is 27,
45, 83, 183 and 255 seconds, median around 83, so the usable window is one to
four minutes and unpredictable. This run produced exactly one loss and is
useless for the arrival question even though the instrument finally works.
Fifteen runs have now gone into that question without an answer, largely for
this reason.

Worth being honest about the probe: "cheap" was never that cheap and the witness
made it worse. It still reads 32 MB to enumerate at startup and another 32 MB
every 90 s to rescan, and the full-region calibration added two more 32 MB
reads. The 45 s freeze came immediately after that calibration. Calibration now
samples eight spread windows of 512 KB -- 4 MB total, still covering the whole
span -- instead of reading everything twice, and the rescan interval is doubled
to 180 s. Not yet run.

Also this iteration: the 42-record anomaly recurred, the discard rule correctly
refused to interpret the run, and a rescan-until-baseline retry is now in place
so the next occurrence shows whether the count climbs toward 116 or sits at 42.
2026-08-24 17:50:58 +00:00
Sylpheed RE agent
1afe1b7134 re: fix the bind failure; witness search window was too narrow
Two harness problems, one fixed and verified, one diagnosed.

Bind: three consecutive attempts failed and the session aborted rather than
flying an unattended craft, which is last iteration's guard working as intended.
The cause is that entities2 self finds the player by motion between two samples,
so a craft sitting still at mission start is invisible. The session now holds the
throttle for three seconds before each attempt. Verified: the next run bound
immediately and produced 11 losses with zero confirmed arrivals, making fourteen
runs without one. One increase, 10 to 11, was surfaced and correctly not counted.

Witness: the frame-rate-band selection is still unreliable. It found only five
candidates in band and reported 0 of 5 moved in samples where craft were being
destroyed, and a counter genuinely running at 24/s cannot fail to advance across
a 15 s sample -- it would gain about 360. They are bursty counters that moved
during the 3 s calibration and then stopped.

The cause is mine. When the witness was bolted into wave7_probe the candidate
search was narrowed to a 4 MB window, while timer_probe searched the whole 32 MB
region and found 286 candidates with a clean cluster near 17/s. The narrowing
was never justified, it was just cheaper to write. Now searches the full region
once at startup, which is a one-off cost rather than per sample, and warns
explicitly when fewer than eight witnesses are available so a weak vote is
visible instead of silently trusted. Not yet run.

Worth recording: this is the third attempt at this witness, and all three
failures share one pattern -- a shortcut in selecting the witness rather than in
the voting logic. Each was caught only because the flagged samples contradicted
the loss events in the same output. That internal contradiction is the real
check and should survive into any future version.
2026-08-24 17:30:03 +00:00
Sylpheed RE agent
2b98105ac0 re: multi-witness vote works, its threshold did not, and freezes persist
The graded output is the improvement. Instead of a single bit it reports how
many witnesses agree, and the sequence tells a coherent story: 11, 9, 7, 4, 1,
then 0 of 31, with the drop to zero at t=183 s coinciding exactly with the last
loss and 106 s of nothing after it. That is a real freeze, identified.

The threshold was wrong though. Flagging a stall at "fewer than half" marked the
entire run stalled, including samples in which craft were destroyed, so 11 of 31
advancing is a healthy guest rather than a stalled one. The cause is the cluster
choice: the modal rate was 93/s, far above the ~16.5/s frame rate timer_probe
measured, and those are subsystem counters that tick in bursts and sit idle in
most 15 s windows even while the game runs. Picking the modal cluster was
convenient rather than principled.

Fixed to prefer the cluster whose rate falls in the frame-rate band of 8-40/s,
falling back to modal only if none exists, and to flag a stall only when zero
witnesses advance, which is the signal the data actually supports. Not yet run.

The uncomfortable part: this run used the cheap probe and still froze, at about
183 s. The previous iteration's "0 stalled samples" came from the unreliable
single-word witness and cannot stand as validation. What the evidence supports
now is that the no-probe control ran 300 s clean, the heavy probe froze at 27 to
255 s, and the cheap probe froze at 183 s -- one run on each arm. Cheap sampling
plausibly helps but does not remove the freeze, and it is equally possible the
freeze is stochastic and the control was lucky. Recorded as unresolved rather
than resolved in the probe's favour.

Practical consequence: the usable window is roughly three minutes per run,
sometimes less, whether or not the probe is cheap. Experiments needing longer
have to survive a freeze or be redesigned around one.
2026-08-24 17:09:43 +00:00
Sylpheed RE agent
015fb7d21e re: persistence rule works; the stall witness gives false positives
Run 12, cheap probe with a bound pilot: 16 losses over 290 s and zero confirmed
arrivals, making twelve runs without one. The persistence rule earned its place
immediately -- an increase of 13 to 15 was surfaced and correctly not counted,
since it does not start from zero. Under the previous rule it would have been
invisible, and a similar flicker straddling zero was nearly written up last
iteration as the first arrival.

The stall witness, on the other hand, is unreliable. Thirteen samples were
flagged GUEST STALLED while recording losses in those same samples, and a frozen
guest cannot destroy craft, so they are false positives and the run was healthy.

The cause is the selection rule: it took the first word in a 4 MB window whose
rate fell in a plausible band, and plenty of counters advance intermittently
without saying anything about whether frames are being rendered. timer_probe had
already solved this properly -- 286 candidates, a rate histogram with a dominant
cluster near 17/s -- and that lesson was not carried over when the witness was
bolted onto the probe.

Now fixed to a majority vote: collect every candidate, keep the modal-rate
cluster, sample up to 32 of them, and report a stall only when fewer than half
advance. It also prints RUN UNVALIDATED when no witness is found, because an
earlier run printed "stalled samples=0" alongside "tick witness: NONE", and a
witness that does not exist cannot report zero stalls. Not yet run.

Consequence worth flagging: the "0 stalled samples" that validated the cheap
probe last iteration came from this same unreliable witness and should be
re-confirmed under the majority rule. The pilot-log speed analysis that
established the stalls in the first place is unaffected.

Also fixed: the entity bind now retries three times and aborts if it never
takes, instead of silently flying an unattended craft -- one run was wasted that
way this iteration, producing no kills and no information.
2026-08-24 16:54:52 +00:00
Sylpheed RE agent
ce235e153b re: cheap probe verified; its one "arrival" is flicker
First run of wave7_probe: zero stalled samples across the whole run, against
three consecutive heavy-probe runs that stalled at roughly 27, 83 and 255 s. The
fix works. The guest is also visibly healthier -- 19 losses against 8 in a
heavy-probe run of comparable length -- so starving the emulator had been
suppressing the very activity the probe existed to watch.

It also reported the first arrival of the whole line of work, and that arrival
does not survive inspection. A record went 1 -> 0 at t=229, 0 -> 2 at t=259 and
2 -> 0 at t=274. Two craft appearing and vanishing within fifteen seconds is not
a wave.

The same log contains the giveaway: at t=60 a record read 13 and at t=75 the
same record read 14, an increase, with nothing printed, because the probe only
surfaced decreases. The hull-based liveness read flickers, and a flicker that
straddles zero was indistinguishable from an arrival under the old rule. The
count therefore stands at zero confirmed arrivals in eleven runs.

Two changes, neither yet exercised: every increase is printed rather than only
those from zero, and an increase from zero counts only if it persists across two
consecutive samples, with a candidate that returns to zero discarded as flicker.

Recorded because it was close: under the old rule this run would have been
written up as "first arrival observed", which would have been the
strongest-looking result so far and wrong.
2026-08-24 16:28:59 +00:00
Sylpheed RE agent
f92d60483a re: confirmed — the probe was causing the guest stalls
The control needed no new decoding: hunting pilot, keep-out 1400, 300 s, and no
memory probe at all, judged only from the pilot's own log. It ran healthy the
whole way -- 211, 257, 250, 241 and 200 distinct speed values in successive 60 s
windows, with no decline in the last one -- against probed runs that stalled at
roughly 27, 83 and 255 s and ended with a single distinct speed across 400 lines.

So the instrument was degrading the thing it measured. AGENT.md warned that a
full scan competes with the emulator for every core under lavapipe, and the
probes grew heavier each iteration until wave6 was reading the entire 32 MB
entity heap plus about 300 extra preads every 12 seconds while the game
rendered.

The control is n=1. The contrast is stark and the mechanism was predicted in
advance, so this is recorded as confirmed enough to act on rather than proven; a
second clean control would settle it.

What it costs is worth stating plainly. Every "no arrival" result so far was
collected while the probe was running, under conditions the game was struggling
with and in some runs after it had frozen. That does not make them wrong -- the
elimination test at t=163 s sits in a window the witness later showed was live --
but none of them is as strong as written, and the arrival question deserves
re-running with cheap sampling before silence is read as evidence.

wave7_probe.py is the fix: one full enumeration, then polling only the hull word
at each known craft base, about 1.2 KB per sample instead of 32 MB, with a full
rescan every 90 s to catch anything genuinely new. Three or four heavy scans per
run instead of twenty-five. Implemented but not yet run, so the claim that it
stops the stalling is untested and the next run must report the witness first.
2026-08-24 16:13:04 +00:00
Sylpheed RE agent
932691b80d re: eliminate an enemy squadron — no wave follows; add a stall witness
SYLPH_KEEPOUT makes the hunt keep-out a knob instead of a hard-coded 600. At
1400 the pilot both kills and survives: hull 1500 and escorted asset 100 % for
the whole run, ENGAGE throughout, eight loss events against seven in the run
where it died at 83 s.

That let the elimination test actually run. An e007 Turret squadron was ground
18 -> 10 -> 8 -> 4 -> 2 -> 0, reaching zero at t=163 s with deployed falling 41
to 40 -- an enemy squadron destroyed outright for the first time in nine runs.

No arrival followed. ARRIVALS=0 at every sample including all those after t=163.
The hypothesis that a wave is released when a squadron is wiped out rather than
merely damaged does not survive its first test. Two further losses occurred at
t=176 and t=202, so the mission was demonstrably still live and still processing
kills; it simply produced no arrival. This refutes elimination-of-one-squadron
as the trigger. It does not refute event-gating generally -- a threshold across
several squadrons, an objective completion, or a specific squadron could all
still be the gate.

The valid window is smaller than the log suggests, about 90 s after the
elimination rather than 143 s, because the guest stalled around t=255 s. The
pilot's telemetry gives it away: the last 400 log lines contain one distinct
speed value against 236 in the first 400, with no timestamp gaps -- the process
kept logging while the game stopped advancing. Nothing in the probe output
distinguished a stall from a quiet mission, and the same ambiguity affects the
trailing flat samples of earlier runs.

wave6_probe now locates a counter advancing at frame rate, samples it each tick
and prints GUEST STALLED when it fails to advance, so future runs validate
themselves. Implemented but not yet exercised in a run.
2026-08-24 15:41:21 +00:00
Sylpheed RE agent
0578e8e72f re: player death bounds every run; fix a harness bug that shortened the windows
Correction first. The sed used to derive each session script from the last
stripped the probe's arguments, so wave5, census and wave6 sessions invoked
their probes with no arguments and every derived probe has been running on its
own defaults. The previous iteration's claim that the run was "cut at 240 s by
the turn timeout, not the planned 330 s" is therefore wrong: the probe used its
default of 240. The pilot got the requested duration while the probe watched for
a different one, and the numbers were plausible enough that it went unnoticed.
No earlier conclusion is invalidated -- the windows were real, just shorter than
intended and misattributed. All three sessions now pass SECS and EVERY.

First n -> 0 ever observed: the player's own record went 2 -> 0 at t=83 s and
deployed fell 41 to 40. The signal does register elimination, not just damage.
No arrival followed, which is weak evidence against the squadron-elimination
trigger since the record eliminated was the player rather than an enemy
squadron. Two other turret records dropped from 18 in the same sample; noted
without interpretation.

The important finding is what came after. For the remaining 220 seconds the
mission was frozen -- exactly 288 craft, zero losses, zero arrivals, across 18
consecutive samples. So the usable observation window is not the probe duration
but however long the player survives. A 340 s probe that loses its pilot at 83 s
yields 83 s of evidence and 257 s of nothing, and several earlier "nothing over
240 s" results may have been much shorter in practice than they look.

That also explains why pilot.py was written to survive rather than to shoot. The
SYLPH_HUNT mode added two iterations ago drops TURRET_KEEPOUT from 2500 to 600,
buying kills at the cost of exactly the survival the run depends on.

The elimination test itself did not run: the squadron reached 14, not 0, before
the pilot died. What is needed is a pilot that kills and survives -- hunt turrets
but keep the evade and retire behaviour, or a keep-out between the two extremes.
That is tuning, not discovery.
2026-08-24 15:25:17 +00:00
Sylpheed RE agent
cc5631a2f3 re: the mission clock is running — refute the stopped-clock explanation
The prime suspect from the previous iteration was that the phase scheduler
simply is not running, which would have made every arrival result meaningless.
It is refuted.

timer_probe.py takes three equally-spaced snapshots of the 32 MB game heap with
no pilot -- exactly the condition where nothing had been observed to change --
and keeps words whose two successive deltas are both positive and agree within
12 %, so linear rather than merely noisy. 286 words qualify, with a large
cluster advancing in lockstep at 16.5 per second.

That rate is not a coincidence: the existing performance notes put Canary
playback on this box at roughly 14-19 fps, and the dominant counter sits inside
that band, so these read as per-frame counters.

Which gives a mundane explanation for six arrival-free runs. If the scheduler is
frame-driven and the title targets 30 Hz, game time advances at about 55 % of
wall-clock here, so the 168, 190 and 240 second runs covered roughly 92, 105 and
132 seconds of game time. Route_S02 schedules phase-1 arrivals at 90, 120, 170,
210 and 240, so the longest run passed the first two and came nowhere near the
last three. No model has to be wrong for the observations to be empty.

Kept at 🟡, not promoted: two links are assumed rather than measured -- that
these counters are frame counters, and that the game's tick is 30 Hz. The values
also do not fit a naive frame count, since the cluster read 14193 about 255 s
after emulator start, which matches neither 16.5 nor 30 per second, so their
origin is genuinely unknown and no counter is claimed to be the mission clock.

Next is one long run, ~350 s of probe, watching for a 0 -> n transition near
t = 163 s and 218 s wall, where the t = 90 and t = 120 route entries land at 55 %
speed. If 350 s still yields nothing, the frame-rate explanation is itself
refuted and the event-gated model returns as the front-runner. The ~210 s title
movie at boot is the binding constraint on how much game time one turn can
observe.
2026-08-24 14:54:57 +00:00
Sylpheed RE agent
171f682b50 re: six runs with no arrival, and an accidental no-pilot control
wave6_probe refuses to interpret a run whose roster count is not the reproduced
baseline of 116, per the discard rule. This run passed and its first sample is
identical to the earlier link run: 116 records, 300 craft, 41 deployed, strength
histogram 2x24, 4x1, 8x4, 18x12. The deployment is deterministic at mission
start.

The pilot failed to bind, which accidentally supplied the control condition the
kill-versus-no-kill experiment needed. With nobody flying, not one craft was
destroyed in four minutes -- exactly 300 across all 22 samples -- against 16-20
losses in each piloted run. So losses are attributable to the player being in
the fight, and NPC crossfire destroys nothing by itself. That was an open
question two iterations ago.

Still no arrival. Zero 0 -> n transitions in either condition, across roughly
fifteen minutes of cumulative Stage 02 flight and windows up to 240 s. The 75
records holding no craft at mission start still hold none at the end. Against a
route table scheduling phase-1 arrivals at t = 90/120/170/210/240, that is now a
strong negative rather than a null result.

Three readings survive: the timetable's t is not seconds (at 30 Hz the whole
phase-1 schedule finishes inside 8 s, before any first sample); arrivals are
event-gated and no run supplied the trigger; or the mission is not advancing its
phase clock at all.

The third has never been checked and is the cheapest to eliminate, so it is now
the prime suspect. Nothing in six runs has confirmed that mission time advances:
the craft count freezes without a pilot, REMAINING OB has never read as a
counter, and no clock has been located. Every "no arrival" observation is
consistent with a scheduler that simply is not running, in which case the
arrival results so far are measuring a stopped clock.
2026-08-24 14:42:36 +00:00
Sylpheed RE agent
1280df7ce0 re: the 42-vs-116 outlier does not reproduce; losses are observable
Two further runs sampled the counts from the instant flight is detected. Both
give 116 roster records in the very first sample, flat thereafter, with 14 unit
definitions throughout. That is not a load race: a race would climb toward 116
from below, and it never does.

The leading explanation was arithmetic rather than behavioural -- census_probe
counts raw aligned vtable hits while wave5_probe counted distinct primary_va
values, which are different quantities if an offset can alias to a VA another
offset also claims. Measuring both in the same run refutes it: 116 raw hits and
116 distinct VAs in every sample.

So the outlier stands unexplained. Four runs now read 116/300, 116/300, 116/296
and one 42/170, with save drift, freezing, load race and VA aliasing each
individually refuted. Recorded rather than dropped, because a one-in-four
anomaly in the measurement apparatus is what later turns into a wrong
conclusion. Practical rule adopted: discard a run that disagrees with 116 rather
than interpreting it, and reproduce any finding in at least two runs.

Useful new fact: craft declines steadily and monotonically in both runs, 300 to
288 and 296 to 280, roughly 16-20 losses over 168 s under the hunting pilot.
Whether the player or NPC crossfire is responsible is not determined here, but
destruction is visible in this signal, which is half of what the kill-versus-
no-kill experiment needs.

Still not observed in any run: an arrival.
2026-08-24 14:27:31 +00:00
Sylpheed RE agent
f137d3eae3 re: per-record strength measured, but the run does not reproduce
The link from the previous iteration gives a per-record live strength, and the
measurement is internally sound: 11 records at 2, 2 at 8 and 11 at 12 sums to
exactly the 170 craft counted, with 24 of 42 records deployed.

It does not reproduce. The previous run saw 116 roster records and 300 craft
with strengths 2/4/8/18; this one sees 42 and 170 with 2/8/12 -- same disc, same
save slot, same launch script, same stage.

Two explanations were checked and both fail. The save has not drifted:
game01/savedata is unmodified since 2026-08-23 and only the profile .gpd files
were written today. The guest was not frozen: the pilot's telemetry over the
same 190 s shows speed varying across dozens of values and a live engagement
with an e007 Turret at 259-680 m.

So the discrepancy is real and unexplained, and until it is understood
per-record counts cannot be used as a time series. Recorded as the blocker
rather than worked around. The same within-run-only discipline already noted for
the global craft count now applies one level down.

Also recorded: zero arrivals and zero losses across 190 s. That is weak evidence
against clock-driven arrivals at t = 90/120/170 seconds, since a 0 -> n
transition should have appeared and none did. It is no test of the event-gated
model at all, because nothing was killed -- fc=0, no record lost strength, and
the player's hull never moved off 1500. The pilot closes to 259 m and misses, so
the gap is accuracy rather than engagement.
2026-08-24 14:04:24 +00:00
Sylpheed RE agent
46e511e791 re: find the roster-to-craft link — a pointer at roster_base + 0x08
The previous iteration was right that neither structure points at the other's
base, and wrong to stop there. Craft point eight bytes into a roster record.

Resolving the 5101 "pointers into the roster range" by distance to the nearest
roster base gives a distribution that is neither flat nor diffuse: +0x08 appears
exactly 300 times, matching the 300 live craft, while every other delta appears
at most twice and 4780 of 5104 land more than 0x4000 from any base.

A count matching a count is the coincidence that has already misled this corpus
three times, so the link was tested on something a coincidence cannot survive.
Each side's unit type is resolved by a different chain -- the craft's from its
definition pointer at +0x130, the record's from its +0x04 name chain -- and they
must agree. They do, on all 300, with zero disagreements, and the fan-out
histogram is [(1, 300)]: every craft has exactly one such pointer, none has zero
or two.

The fan-in is the useful half. Only 41 of the 116 roster records are referenced
at all; the other 75 have no craft. Craft per referenced record is discrete:
24 records with 2, one with 4, four with 8, and twelve with 18 -- summing to
exactly 300. The twelve holding 18 are all e007 Turret.

This is the observable four iterations have been hunting. A member with zero
craft is not deployed; a member's craft count is its live strength; so an
arrival is craft appearing for a record that had none and a kill is that count
falling. Both are attributable to a named squadron, because the record is a
UnitGroup member and UnitGroup names the squadron that Route_S02.tbl ties to a
phase and an arrival time. The chain from a memory observation to a static route
entry is complete.

Not claimed: that the 75 undeployed members are waiting on phases 2 and 3. That
is a plausible reading, but the run only shows they have no craft.
2026-08-24 13:48:39 +00:00
Sylpheed RE agent
176af18381 re: refute a direct pointer link between roster records and live craft
Fourth failed attempt to explain how 116 roster members become ~300 live craft,
recorded so it is not retried. Count (116), the member field n (387) and
formation slots (630) were rejected earlier; this rejects a head pointer.

link_probe.py scans the first 0x400 bytes of every object for an address of the
other kind. Roster record to craft base: 0 of 116. Craft to roster base: 0 of
300. Both directions empty.

The scan also measured the address spaces, which is the useful part. The two
structures live in different regions about 19 MB apart -- roster records at
0xbc372c00..0xbc9bc720, craft at 0xbdb2fd80..0xbdcd1d80 -- and the 14 unit
definitions match Stage 02's 14 distinct unit types exactly. The craft count is
not fixed: three runs at comparable mission times gave 296, 298 and 300, so the
population must be compared within a run and never across runs.

One thread is left open but explicitly not counted as evidence. Craft objects
hold 5101 words pointing somewhere into the roster VA range, which sounds like a
lot until you notice that range spans 6.5 MB and holds many allocations besides
the 116 records. The test that would settle it is the distance from each such
pointer to the nearest roster-record base: a spike at a single delta means a
link at base+X, a flat distribution kills the thread. Not run yet.

The association may not be a pointer at all -- an index, a hash, or a third
object such as a squadron instance would all look like this. Since an arrival is
already known to be a state change rather than an allocation, finding that
mediating structure matters more than finding a flag: it is what would let an
observed change be attributed to a named squadron and hence to a route.
2026-08-24 13:31:13 +00:00
Sylpheed RE agent
a7cc4d6408 re: sites are entities 1:1 — withdraw "n is craft-per-member"
The suspected confound turned out not to exist. Gaps between consecutive
same-unit definition-pointer sites are all >= 0x1000, with 274 of them exactly
0x1000, so entities are page-spaced and there are no near-adjacent pairs to
merge. Clustering at any threshold below 0x1000 gives ratio 1.00 for every unit
type, and hull is plausible on 298 of 298 clustered bases at delta 0x130. The
player shows two objects because there are two, not because one holds two
pointers.

That removes the excuse the previous iteration had used to keep the reading
alive, and the reading does not survive: sum(n) fits the turret row well (216
against 214, with kills already recorded), but DeltaSaber_T, Player and
Acropolis all come out at exactly twice their sum(n). An undershoot can be
blamed on phases 2-3 not having started; an overshoot cannot. n goes back to 
and the previous 🟡 is withdrawn. All the turret row establishes is that a
roster member expands into many craft, not that n is the factor.

Formation slot count was tested as the alternative and rejected outright: 630
turret slots against 214 live.

Side result worth keeping: a FormationSet record's FrameCount is its slot count,
and the name suffix usually agrees -- Turret07_30 -> 30, ArrowHead03_64 -> 64,
4_Bird -> 4 -- with one exception, AttackerS03_12 having 14 slots, so the suffix
is a label and not a guarantee.

Also recorded: the 298 live entities are not the 116 roster records. Both
structures exist at once, and the rule mapping one onto the other is the real
open question.

Probe caveat noted in the doc: entities2.moving() found no movers this run, so
the delta spectrum was empty and the clustering threshold was a fallback rather
than a measurement. It does not change the conclusion, since every gap exceeds
any threshold below 0x1000.
2026-08-24 13:13:43 +00:00
Sylpheed RE agent
356c4e70f8 re: motion-independent liveness probe; n looks like craft-per-member
entities2.moving() finds entities by displacement between two samples, so
anything stationary is invisible -- the entire explanation for the +/-10 swing
that made the previous run's count useless. liveness_probe.py enumerates by
definition pointer over the entity heap instead, moving or not, and reads hull
as f32 at position+0x154. The series is monotone rather than oscillating:
298 -> 280 over 164 s, with the decline matching the 18 disappearance events
exactly.

The hunting pilot does kill: one hull crossing caught directly, an
e010_ADAN_Attacker_S at t=57 s. The previous run's worry that SYLPH_HUNT shoots
but never destroys anything is settled.

Recorded as a non-result so the next run does not misread it: zero births in
164 s does NOT favour either wave model. The roster finding already established
that every participant is allocated at mission load, so neither a clock nor an
event model would produce an allocation. An arrival must be a state change on an
existing entity.

The mystery member field n now has a candidate meaning: the number of craft a
roster member spawns. Static sum(n) for turrets is 216 against 214 sites found,
with the count already falling before the first sample, where Count alone
predicts 21 -- off by an order of magnitude.

Not promoted, and the reason is a confound in my own measurement rather than the
data: the probe counts definition-pointer SITES, not entities. The player is one
member and yields two sites, and DeltaSaber_T yields exactly double its sum(n),
so some entity types hold several pointers to their definition. Until sites are
collapsed into distinct entities the turret match could be a coincidence between
a x1 multiplicity and a x1 ratio. The capital-ship rows undershoot for a
separate and expected reason: phases 2 and 3 have not started.
2026-08-24 13:01:19 +00:00
Sylpheed RE agent
08352d09ac re: add a hunting pilot; withdraw the "10 of 116 records are dynamic" result
The user asked for an actively hunting pilot, since a player who kills nothing
cannot trigger an event-gated wave and both previous runs used the survival
pilot.

pilot.py gains SYLPH_HUNT=1. The substantive change is which contacts ENGAGE may
shoot: it previously skipped every "hard" target -- "turrets and hulls are not
the objective" -- and stood off 2500 units from turrets, on the assumption that
an e007 Turret is an AA mount on a capital ship. It is a craft, one of the main
enemy types of the first six missions, and at 100 HP the cheapest kill on the
field. Under SYLPH_HUNT it is a target and the keep-out drops to 600. The run
confirms the pilot engages: steady ENGAGE, fire=1, committed to an
e010_ADAN_Attacker_S at ~2.2 km, hull and escorted asset untouched over 160 s.

Withdrawn: "only 10 of 116 records ever changed a byte in 170 s". This run
measured 41-56 records changing in every 10 s tick. The old figure does not
reproduce.

I cannot say why, because I changed two variables at once -- the record bound
(fixed 0x200 to bounded-by-next-record) and the pilot (survival to hunting).
Either explains it. That is a design error, and the honest outcome is a
retraction without a replacement explanation rather than a story that fits.
The conclusion it had supported is unaffected: the roster identity now rests on
the exact 10-of-10 unit-composition match measured independently.

Still open, and explicitly not concluded: the pilot's own entity scan shows ADAN
drifting 147 -> 129 -> 142, and the late rise has the shape of an arrival, but
the sample-to-sample swing is +/-10, the same size as the effect. AGENT.md warns
that polling faster than the guest updates manufactures a curve out of noise, so
no wave conclusion is drawn. The run probably did not kill anything either
(fc=0, asset at 100%), so it does not test the event-gated model. A stable
per-record liveness field and a working kill counter are both needed first;
REMAINING OB at 0xbdb59668 still does not read as a counter.
2026-08-24 12:45:43 +00:00
Sylpheed RE agent
2030324419 re: fix record labelling and measure the stride; confirm 116 records == roster
Both defects from the previous iteration are fixed by measuring instead of
assuming, and the fix immediately promotes a 🟡 result to .

Labelling: the previous probe assumed object+0x04 -> name_record+0x10 -> char*
and resolved 0 of 116. wave3_probe.py searches for the chain per record instead,
the way unit_discover.py does, and resolves 116 of 116 -- every one through the
pointer at +0x04 with the string at delta 0x00, not 0x10. The 0x10 belongs to
the definition object (vtable 0x820af844); the spawned-entity record
(0x820af030) uses 0x00. Carrying one over to the other cost the last run.

Stride: measured, not assumed. Gaps between consecutive records are min 32,
median 800, with common values 800, 640, 608, 576, 416 and 32. There is no fixed
record size, so the old RECLEN=0x200 window truncated large records and overran
small ones -- which is why its busiest fields were the last words of the window.
Future diffs must bound each record by the next record's address.

With labels available, the "116 records == 116 roster members" claim was tested
properly and is promoted from 🟡 to . The multiset of unit types matches the
static roster exactly: Turret 21/21, e106 Destroyer 19/19, f106 Destroyer 14/14,
f105 Cruiser 11/11, ASFrigate 9/9, ISCMissile 9/9, Attacker_S 9/9, e105 Cruiser
7/7, DeltaSaber_T 7/7, ArrowHead 6/6 -- 10 of 10 exact. A coincidental total is
possible; a coincidental distribution over ten unit types is not. The game
allocates one record per roster member at mission load.

Not settled: REMAINING OB at 0xbdb59668 held 95748078 unchanged all run. That
address is known to be run-dependent, and this was one of the misses, so the run
cannot say whether the pilot killed anything. Re-hunting it is a precondition
for the kill-versus-no-kill test, not an optional extra.
2026-08-24 12:35:02 +00:00
Sylpheed RE agent
8cc0b22967 re: diff inside the 116 entity records — no arrival flag found, two probe defects
Since the record count is flat, an arrival would have to flip a field inside a
record. This diffed all 116 records every 5 s for 170 s of Stage 02 flight.

The prediction under test -- groups of 3, 3, 3, 2, 1 records changing state at
t = 90, 120, 170, 210, 240 s -- did not appear. Changes are spread evenly across
ticks with no cluster at any predicted time and no field that transitions once
for exactly three records. Three explanations survive and this run cannot
separate them: the timetable's t is not seconds (at 30 Hz the whole phase-1
schedule finishes inside 8 s, before the first sample); arrival is not marked in
these records; or the mission was not in phase 1.

Supporting, not conclusive: only 10 of 116 records ever changed a byte, and 106
never changed at all. Live entities would be moving, so that fits the
pre-allocated roster reading -- but Stage 02's roster is turret-heavy and a
turret does not move while alive, so "inert" and "not yet arrived" are not
distinguishable here.

Two defects in my own probe, recorded rather than quietly fixed:

  - label() resolved to '?' for all 116 records, so nothing could be tied back
    to a squadron. That association is what would have made the result
    decisive -- "the three records that changed at t=90 are ADN110, ADN111,
    ADN112" is evidence; "records 18, 32, 99" is not. unit_discover.py already
    solves this and should be reused.
  - RECLEN=0x200 was assumed, not measured. The busiest fields are the last
    eight words of the window, which is what spilling into the next object
    looks like.

Method error kept: the first attempt deferred all analysis to the end and the
turn timeout killed it with 240 s of data in memory and nothing written. The
probe now streams transitions to disk and prints a partial ranking every 60 s.
With a 219 s cold-boot title movie, an end-only report has ~300 s of budget and
one bad estimate loses the whole run.
2026-08-24 12:21:48 +00:00
Sylpheed RE agent
27e0b6ebc1 re: find the arrival timetable; refute the entity-count proxy for waves
The routes' first-keyframe time is the arrival schedule. It is not always zero,
and grouping Stage 02's 120 route records by phase and first-frame time gives a
timetable: phase 1 releases 25 routes at t=0 then 3, 3, 3, 2, 1 at t = 90, 120,
170, 210, 240. Phase 2 has every one of its 37 routes at t=0, which is what
pins the meaning: t is measured from the start of its phase, not of the mission.
Entering a phase releases that phase's t=0 group and the rest follow on the
offsets.

That completes the data side of the question this line of work started from --
the schedule is data, split across UnitGroup (who) and Route (when, and the path
flown in), with no fixed enemy count anywhere.

Refuted: counting spawned-entity records does not reveal arrivals. One Stage 02
flight, 210 s sampled every 15 s, counting aligned 0x820af030 in an 8.3 MB span:
flat at 116 throughout, no step at 90, 120, 170, 210 or anywhere.

The reason looks more useful than the refutation. UnitGroup_S02's Count fields
sum to exactly 116 members, and there are exactly 116 records from the first
sample on, so the game most likely allocates one record per roster member at
mission load and a route arrival activates an existing record rather than
creating one. Kept at 🟡, not promoted: n=1, and the obvious refutation -- check
another stage's record count against its member sum (S01=42, S16=2, S29=95) --
needs a save for another stage, and only slot 01 / Stage 02 exists. Noted as the
blocker rather than worked around.

Not settled: whether the timetable's t is frames or seconds (at 30 Hz t=240 is
8 s; as seconds it is 4 min), and where an arrival is observable in memory. The
live flag is presumably a field inside those 116 records, which is a well-scoped
next probe now that the record set is bounded and located.

Operational note recorded: cold boot spent 204 s in the title movie, so a 300 s
probe overran the turn and the first attempt died with its output still in the
pipe. Log to a file rather than piping to tail.
2026-08-24 11:57:16 +00:00
Sylpheed RE agent
79253bc042 re: probe the runtime phase state — tables are resident, phase counter is not there
Static reading had gone as far as it could: the stage record splits a mission
into Phase_1..3 and every arrival route is phase-tagged, but nothing in the data
says what ends a phase. So this took it to the oracle -- one Stage 02 flight,
160 s under the survival pilot.

Confirmed, and this is the useful half: every string the static decode predicts
is present in live guest memory -- Phase_1, Phase_2, Route_ADN101_p1F,
SUBOBJ_010, AI_ADAN_CraftSquadron_Veteran, UnitGroup_S02.tbl. The game loads
exactly the tables the stage record names, under exactly the names we resolved,
and they can be located in RAM by content. That is the first dynamic
confirmation of the whole static table layer.

Refuted: the phase state is not adjacent to those strings. The probe reported
862 changed words around the anchors, which looks like a signal until you read
the values -- each word takes its predecessor's previous value and every value
points into the same region. It is one block shifted down four bytes, a single
memmove in a pointer list, occurring once between t=66s and t=89s. Diffing
around a string anchor was the cheap thing to try and it did not work.

Also recorded: a defect in my own probe. It scraped hit addresses with
0x([0-9a-f]{8}), but gmem.py find prints both the backing-file offset and the
guest VA, so half the anchors were file offsets read as addresses. Fixed to
match the va column only. It did not change the conclusion -- the anchor that
produced the shift was a real VA -- but a negative result from one of those
junk anchors would have been worthless.

Not settled: what advances a phase. Next handles are watching Route_ADN101_p1F
fire against entity positions, or working back from the SUBOBJ_*_Mes_L1 HUD
strings; the phase state is more likely near the known mutable REMAINING OB
counter than near the tables.
2026-08-24 11:31:20 +00:00
Sylpheed RE agent
cdb9e5a001 re: decode the stage table set — phases, routes, sub-objectives, AI parameters
Following the real stage record (not the _Test template dumped earlier) reaches
the whole mission-parameter layer, all of it in the same self-describing IDXD
container as the squadron roster.

The big one: a stage is divided into Phase_N blocks -- three for Stage 02, each
with its own map path, map mesh, asteroid definition and background -- and
Route_S<NN>.tbl holds the arrival paths, with records named

    Route_<squadron>_p<phase><kind>

tying a UnitGroup squadron id to a phase and to a time-stamped keyframed path of
(time, quat x4, pos x3). Route_ADN101_p1F is 3 frames at t = 0, 20, 30. The
identity len(fields) == FrameCount * 8 + 1 holds for 1449 of 1449 route records
across the 28 stages that have one, and 16/16 for FormationSet_S02.

Also decoded: SUBObjectiveSettings (per-objective bonus points by difficulty,
unlock item id, HUD strings) and AIParams (34 profiles, firing/guard/muster/
counter ranges plus 14 manoeuvre weights for Squad-type AI). The AIParams
numbers are exact original values from static RE and are portable as they are.

Adds tools/re-capture/stagetbl.py, which resolves a stage record by content and
can --follow every table it names, and commits two dumps as evidence.

Refuted and kept: the eight-value keyframe is the common case, not universal.
Formation_Fleet_01 has FrameCount=1 with 136 positional fields and
Formation_Fleet_02 has FrameCount=8 with 32, so a parser must not assume the
stride.

Corrects stage-definition-table.md, which was written from the _Test template
and is missing EnumerateSubobjective, EnumerateAIParams, BackGroundID and the
WingmanIconID fields the real record carries.

Not settled: what advances a phase -- the stage declares Phase_1..3 and routes
are phase-tagged, but nothing static says what ends one. That is a question for
the oracle, not for more static reading. Also open: the route-name kind letters
F/S/A/M/B, what activates a sub-objective, and StageMessageSet_S<NN>.tbl, which
does not resolve in GP_MAIN_GAME_E.pak.
2026-08-24 11:15:34 +00:00