Files
Sylpheed/docs/re/BACKLOG.md
Sylpheed RE agent aa60d14ae7 re: value classes resolve by KIND; the 344-name residual drops from 31 to 4
Generalised the "does the referent exist?" sweep that cracked S14. The value
classes resolve as three different kinds:

  AIID                   31 values   31 records    0 files    0 unresolved
  FormationID           126 values  126 records    0 files    0 unresolved
  CollisionMeshes        18 values    0 records   18 files    0 unresolved
  EnumerateSubobjective  17 values    0 records   17 files    0 unresolved
  NamePlate              52 values   27 records   24 files    1 (empty string)
  BackGroundID           10 values    0 records    0 files   10 unresolved

FormationID 126/126 re-derives unit-group-table's own "every FormationID
resolves" from a different direction, as the control.

The 27 NamePlate values that stage-definition-table called "non-file plate ids"
are record names - ACROPOLIS x114, NP_ArrowHead x84, NP_ASFrigate x78 - and the
split 24 files + 27 records + 1 empty = 52 matches the two-vocabularies count
already recorded there. So the census residual shrinks: 313 resolve as files
plus 27 as records = 340 of 344, leaving the 4 never-shipped _Test tables as the
entire residual. The old reading was a KIND error, not a missing-file error.

BackGroundID is a genuinely unresolved third kind: the ten sky names, used 6-36
times each, are neither record names nor pak keys under 10 prefixes x 9 suffix
forms.

Recorded as powerless so it is not re-run: the image holds 0 exact strings for
all ten backgrounds, but also 0 for Formation_4_Bird, AI_Structure,
NP_ArrowHead, ACROPOLIS and CollisionSet_S03.bin, every one of which does
resolve in data. The executable names no data value at all, so the negative
discriminates nothing.

All fifteen artefacts byte-identical.
2026-08-28 05:55:52 +00:00

405 KiB
Raw Blame History

RE backlog

Open items that are not being worked right now. Each entry says what is wrong or unknown, what evidence exists, and what the first step would be. Move an item into INDEX.md (with a structures/…md or a parser + test) once it is actually settled.


  • (2026-08-27) The trigger-queue appender is IDENTIFIED — isl-trigger-queue. isl-builtins.md left "what actually appends a node is still unidentified" and proposed a gdb watchpoint; it is static after all. The container embeds its inner list at +12, so the appender must pass container+12 to the generic helper sub_8226EAB8 — only 2 of its 16 callers do, and one of them sets addi r29, r24, 272 first. That is sub_8226A0D8 = ScriptPhase vtable slot 28 = BUILT-IN 25, with ZERO real bls in the image, so it is reached only as a built-in: the SCRIPT appends triggers, not the engine. 🔴 REFUTED in the same pass: built-in 25 does NOT carry the coroutine entry a trigger fires — 0 % of its staged operands land on an instruction boundary against a 38.7 % chance control, and it has only 2 call sites in Stage 02, far too few for ~15 % of the code. So what starts the 389 unreachable routines is still open. 🟡 A trigger node's layout and its consumer are unread; built-in 25 stays unnamed.

  • (2026-08-27) EVERY PHASE EXIT IS NOW REACHABLE — isl-stream-entry-points. data/isl-phase-guards-all.txt: 5 of 177 unreachable exits → 0. The cheap first step FAILED usefully: an unreached routine's entry offset does not appear as a word in the file in any encoding (phase-relative 6.6 % vs an 11.5 % control; absolute 1.6 % vs 3.3 %; /4 forms 01.6 % vs 6.68.2 %) — all at or below chance, ruling out "some operand points at them". Nor is it dead code: the 3069 unreached instructions in Stage 02 hold 485 calls (start_coroutine ×75, squadron_attack ×59, objective_marker ×13). The answer is the mission-level stream: each 0x1883 record is 0x1883, base, size, 0, entry_a, entry_b with the entries phase-relative82 of 88 land on a valid instruction (93.2 %) vs a 38.6 % control — and entry_b is the phase's FORCE_END_PHASE handler (Stage 02: 0x1482C, 0x249F0, 0x34A10). Those exits now show 0 necessary conditions, which is what an engine-entered abort handler should look like. ⚠️ First seeding attempt moved NOTHING (85.0 %→85.0 %, 5→5) because dominating_conditions() builds its own entry set — fix-the-instance again. (2026-08-27) entry_a IDENTIFIED — and it is NOT an entry point. It is the offset where the phase's CODE ends and a trailing DATA table begins: 8-byte typed records, tag 0x19 = int, tag 0x1A = IEEE float, and the dispatcher only has 25 opcodes (cmplwi 0x18). 44 of 44 phases: the first opcode > 0x18 is exactly entry_a, zero exceptions; only tags 0x19 (1394) and 0x1A (675) occur. So the record is 0x1883, base, size, 0, code_end, force_end_handler. ⚠️ This also retires the earlier "93.2 % land on a valid instruction" test as TOO WEAK — a data record has length 8 and passes "nonzero, even". 🔧 isl.linear_offsets was decoding all 2069 data records as instructions (1.23 %); now stops per phase at the boundary: 168251 → 166182, op>0x18 2069 → 0, call-site coverage held at 25705/25705, exits still 0 unreachable, conditions unchanged at 400. ⚠️ The first attempt at that fix cost 36 % of the stream (168251→107596, exits 0→74) because the walk is global and stopped at phase 1's table — it must SKIP and resume per phase. 🟡 Still open: the table's contents are undecoded (its int values hit the stream 46/51 vs 29.5 % chance but 0 are unreached run-starts), and what starts the other ~15 % of code is still unknown.

  • (2026-08-27) THE TRAILING DATA TABLE IS A TIMELINE — structures/isl-schedule. Layout int N; N x [int offset; float t; int kind]; 1 + 3N matches the record count in every phase. 675 entries disc-wide — exactly the independently counted number of 0x1A float records — and 675/675 offsets land on the instruction stream vs a 33.3 % control. The floats are SECONDS (0, 0.5, 1, 4, 30, 60, 90, 120, 150, 180, 210, 240, 300, 420, 1020 …) and the targets are small one-shot coroutines (set args; call builtinN; end_coroutine). kind is 0 (556) or 5 (119) — unidentified. 🟡 Runtime cross-check, consistent not conclusive: the closed REMAINING OB work measured Stage 02 arrivals at t = 0, 120, 210 s (n=5 emulator runs); all three are in phase 1's static schedule and 120 and 210 each appear TWICE — but the times are round numbers, so presence alone is weak; the doubling is the sharper detail. Artefacts data/isl-stage02-schedule.txt and data/isl-schedule-all.txt; the four earlier artefacts regenerate byte-identical. (2026-08-27) THE CONSUMER IS FOUND — sub_822748D0, called from ScriptPhase::Update (sub_82263408). The phase initialiser stores [phase+240] = code_base + entry_a (add r10,r26,r21; stw r10,240(r30)), and +240 has exactly two readers in the ISL region. The walker confirms EVERY field independently of my structural inference: count at table+4, kind at group+20, float t at group+12, offset at group+4, stride 24. 🔑 kind is a CLOCK INDEX, not a flagrlwinm kind,2,0,29 + lfsx into float arrays at [phase+104] and [phase+88]; an entry fires when its t lies between the two readings (the "which events did this frame cross" test). Both arrays are phase fields, so the clock is PER PHASE — previously an inference from layout, now a read. 🟡 Still open: what the six clocks are (only indices 0 and 5 are used); sub_822737C8, which actually starts the routine, is unread. 🟡 Still NOT what starts the unreachable code — 0 of the 675 targets are unreached run-starts, so the ~15 % gap stands.

  • (2026-08-27) sub_822737C8 IS THE COROUTINE SPAWNER — isl-coroutine-spawner. spawn(phase, base, offset); 7 call sites that between them name the whole start-up path: the phase initialiser, start_coroutine's stub, the timeline walker, TWICE inside sub_8226D740 (the per-frame engine→script drain), and two unread (sub_82273910, sub_82264058). 🔴 CORRECTION: the 0x1883 record's third word is the phase's MAIN ENTRY, not a "size" — my label was wrong. The initialiser passes it straight to the spawner (or r5,r22,r22; or r4,r26,r26; bl 0x822737C8), 44/44 land on the instruction stream vs a 25.0 % control, and all three Stage-02 targets share the prologue special[0]=0; local[0]=0; call builtin116(0). Record = 0x1883, base, MAIN_ENTRY, 0, code_end, force_end_handler. 🟡 Seeding it moves no coverage — every main entry was already an entry point by another route. 🔑 LEAD: the drain's two spawns take their offset from [node+112] — the first field of a drained node located, and the best remaining angle on the ~15 % of code nothing appears to start. 🟡 NOT shown that those nodes come from the trigger queue at phase+272; that is the exact over-reach the corpus already withdrew once, so it is not asserted.

  • 🔴 (2026-08-27) THE TRIGGER NODE IS DECODED — and two of my own claims are withdrawn. structures/isl-trigger-node. The reader is sub_8226E220, called by the drain sub_8226D740 as (phase+272, …6 out-params). Writer (built-in 25) and reader touch the same seven payload offsets — +0 local[4] (the unit), +4 and +28 local[12], +8 computed, +16 a double from local[16], +24 the constant 1, +32 — neither derived from the other. Container: +16 head, +20 pending count (matching the count isl-builtins.md watched live at phase+272+20), +24 cursor. 🔴 WITHDRAWN: "the drain spawns from [node+112]"r31 = r1 256, the STACK FRAME; +112 is an output slot. 🔴 REFUTED: the trigger carries a routine offset. payload+28 = local[12], and across all 25 sites disc-wide those are the small integers 1…12: 1/25 = 4.0 % land on the instruction stream vs a 16.0 % control, 0 are unreached run-starts. ⚠️ My first version of that test was n=2 (Stage 02 only) — same answer, but unsupportable either way. And one correction TO the corpus: isl-builtins.md withdrew sub_8226E458's link to the trigger queue by reading its SECOND argument ([phase+324]); the FIRST is r26 = phase + 272, set twelve instructions earlier. The drain does operate on the container — what sub_8226E458 does to it stays unread. 🟡 What starts the ~15 % of unreached code: still open, not this.

  • (2026-08-27) RESOLVED — TWO trigger appenders, and built-in 19's carries the handler routine. structures/isl-trigger-node. Last iteration's puzzle (payload+28 is a 112 index, yet the drain spawns base + payload+28) had a simple cause: built-in 25 is not the only appender. Searching for functions writing a double at +16 AND a word at +28 of the same register finds exactly two — sub_8226A0D8 (built-in 25, slot 28) and sub_8226E160, whose sole caller sub_82269ED0 is vtable slot 68 = built-in 19. Built-in 19 passes local[4]→+0, local[12]→+4, local[24]→+16 (double), local[32]→+28. 🔑 Those ARE code offsets: 79/79 land on the instruction stream vs a 27.8 % control, values 12 164111 080, 73 distinct. So built-in 19 registers a trigger whose handler is a routine, and the drain's spawn is that handler. Observed: built-in 25 writes +24 = 1, built-in 19 writes +24 = 0 — kind-tag shaped, 🟡 but the drain was not shown branching on it. 🟡 Still does not explain the unreached code — 0 of the 79 handler offsets are unreached run-starts.

  • 🟡 (2026-08-27) THE UNREACHED CODE — five mechanisms eliminated, nothing in the file names it. isl-unreached-code. Inverting the question (read what an unreached routine DOES) sharpened it. Metric partly inflated: of 24901 unreached instructions in 564 runs, 458 are length-1 (111 a lone ret stranded after end_coroutine) — but the gap is real: 24443 instructions in 106 runs, up to 1526 long. It is live logic: Stage 26's biggest run calls builtin57(ADN110,1) and set_group_speed(ADN110,2,0). 🔑 NEW: kill_coroutine (built-in 5) confirmed as a code-offset carrier — 150/150 on-stream vs a 34.0 % control — yet 0 unreached run-starts, the fifth such result. Across 2757 code references from five mechanisms (start_coroutine 1765, timeline 675, built-in 19 triggers 79, kill_coroutine 150, 0x1883 88), not ONE lands on an unreached run-start. Not a tool artefact: 1765/1765 start_coroutine entries resolve, 0 outside a phase region, 0 dropped by the on-stream filter. 🟡 With the earlier "the offset appears nowhere as a word in any encoding", these routines are referenced by nothing inside the .ssb — either unused in this build, or entered from outside it. ▶️ No static test I have devised can choose; a runtime probe on sub_822737C8's third argument would.

  • (2026-08-27) +24 IS the trigger kind tag, and built-in 25's payload is a ROUTE. structures/isl-trigger-node. Read straight off the drain after the pop: lwz r11,92(r31) (payload+24), cmpli 0x1, bc lt → 0x8226D89C (+24 == 0 → the SPAWN path), bc ne → exit (+24 > 1 → bail), else fall through (+24 == 1 → a NON-spawn path). So built-in 19 (writes 0) → spawn, condition sub_8226DAF8, +28 = a code offset (79/79 vs 27.8 % control); built-in 25 (writes 1) → no spawn, condition sub_8226DC80, and the path resolves +28 through [phase+244] = symbol table 1 (lwz r10,112(r31); lwz r11,244(r29); lwzx). 🔑 All 25 of built-in 25's local[12] values resolve to symtab-1 entries, and every one is a Route_* (Route_ADS101_p1F, Route_TCN004_p1S, …) — so the puzzling 112 values are small symbol indices, and this independently confirms SYM1_SLOTS[12] ∋ 25, derived from operand ranges alone. Two trigger kinds: built-in 19 registers a HANDLER ROUTINE, built-in 25 registers a ROUTE. 🟡 Unread: both condition testers, what the kind-1 path does with the route, and the drain's second spawn site 0x8226DA10.

  • (2026-08-27) BOTH trigger kinds watch a ROUTE. structures/isl-trigger-node. The kind-0 condition sub_8226DAF8 resolves a symbol from payload+4 through [phase+244] = symtab 1 (rlwinm r10,r5,2,0,29; lwz r11,244(r30); lwzx), and both appenders write local[12] into the slot their own path reads. local[12] resolves 79/79 (built-in 19) and 25/25 (built-in 25), every one symbol TYPE 1 = Route_* (Route_ADN106_p1F, Route_ADS101_p1F, Route_TCN004_p1S). So both register a trigger on a UNIT reaching a ROUTE (payload+0 = the unit); they differ only in the payoff — built-in 19 also carries a handler routine (local[32]+28, kind 0 → spawn), built-in 25 carries none (kind 1 → no spawn). Third independent agreement with SYM1_SLOTS, which lists slot 12 for both. 🔑 Joins up with the closed REMAINING OB result (the counter rises at a squadron's route arrival time) — 🟡 stated as a connection, not a demonstration. 🟡 Still unread: what the conditions COMPARE (only sub_8226DAF8's first ~34 instructions are read, up to the name lookup; sub_8226DC80 entirely), and payload+8 (a computed value passed to both testers — waypoint-index shaped, which is why it is not being called one).

  • (2026-08-27) THE KIND-0 TRIGGER CONDITION IS A POINT-TO-SEGMENT PROXIMITY TEST. structures/isl-trigger-node. sub_8226DAF8 resolves the route name (sub_823012D8), requires the unit record's rec+100 != 0, extracts two 3-double vectorsrec+32/+40/+48 and rec+64/+72/+80 — and makes a VIRTUAL call to ScriptPhase vtable slot 60 = sub_82268068 (used by no built-in, so engine-internal) with those two vectors, the route point, and the node's double. Slot 60's arithmetic is unambiguous: per-axis fsub, fmul + two fmadd, fsqrt|BA|; reject if |BA| < 0.1 (degenerate); reject if |PA| > f1 or |PB| > f1; then t = (PA)·(BA) / |BA|², reject if t < 0. So it is a point-to-segment distance test and the node's double (local[24]) is a RADIUS. 🟡 What A and B are is NOT established — a previous/current position pair would make this the standard frame-rate-robust waypoint test, which is exactly the tidy reading this corpus insists on proving. 🟡 Still unread: sub_8226DC80 (kind 1) entirely, and slot 60 past the t < 0 rejection.

  • (2026-08-27) THE TRIGGER MECHANISM IS READ END TO END — the kind-1 condition is the SAME test with a pre-filter. structures/isl-trigger-node. sub_8226DC80 is sub_8226DAF8's twin: diffing instruction by instruction, 33 of the first 86 differ and every difference before index 65 is a register rename or a branch target. Both resolve the route name (sub_8217FA08sub_823012D8), index [phase+324] by payload+0, require rec+100 != 0, and copy the two 3-double vectors out of the unit record. Then kind 0 calls vtable slot 60 directly, while kind 1 first computes an INLINE point-to-point distance (three fsub against the route point, fmul + two fmadd, fsqrt) and returns 0 if it exceeds f31, the same radius — before calling the same slot 60. So both kinds run the identical point-to-segment test; kind 1 only adds a cheap early-out and does not spawn. ▶️ Trigger work remaining is peripheral: which unit-record fields A and B are, slot 60 past the t < 0 rejection, payload+8, and the kind-1 path in the drain.

  • 🔎🔴 (2026-08-27) A/B in the trigger test: SEARCHED, NOT SETTLED — and the tidy reading is EXCLUDED. structures/isl-trigger-node. The "who writes this" move found 99 sites in 40 functions writing +32/+40/+48 and 16 in 13 writing +64/+72/+80; six functions write both. The inviting one, sub_821AD218, widens exactly 16 floats to 16 doubles (lfs r4+0…60stfd r3+0…120) — a 4×4 matrix, which would make A and B rows 2 and 1, i.e. BASIS VECTORS not positions. 🔴 Wrong for the trigger's record, disproved by evidence already in hand: sub_8226DAF8 reads lwz r10, 100(r11), a WORD at +100, inside where a 16-double matrix's row 3 (+96…+120) would be. sub_8226A348 (same built-in shape) confirms a MIXED layout — words at +0/+4/+8/+12/+16 and +80, doubles only at +32/+40/+48. So these offsets recur across several unrelated object layouts and nothing ties a writer to the trigger's object. A and B stay unnamed — the second tidy reading rejected for them, after previous/current-position.

  • 🔴 (2026-08-27) read_freg READS THE TIMELINE'S CLOCK ARRAY — and my "six clocks" framing is corrected. Built-in 9 is inline in its stub (no vtable slot): it bounds-checks 0 <= local[0] < 32 then lwz r10, 88(r31) / rlwinm i,2,0,29 / lfsx — a FLOAT from [phase+88][i], which isl-builtins.md already recorded. 🔑 That is exactly the array the TIMELINE's kind indexes, so kind is not a selector over six private clocks but an index into the same 32-entry float register file the script reads with read_freg(i)read_freg(0) < 1200 in a clear condition and a timeline entry with kind = 0 read the same register. 🟡 NUANCE, not asserted as a correction: clear_flag (93) clears a DIFFERENT 32-entry array — [phase+120], looping to 128 in steps of 4 — not [phase+88]; set_flag (8) does bounds-check against [phase+88], but only its first 13 instructions were read. 🟡 This does NOT settle the unit. read_freg returns a float from a register file; that seconds is the unit is still INFERRED from the 210/300/600/1200 gate values, exactly as before. 🟡 Also open: what writes [phase+88] — 73 writers in the ISL region alone.

  • 🔴 (2026-08-27) THE UNIT IS SETTLED — read_freg RETURNS SECONDS, and the three "flag" built-ins are a STOPWATCH BANK. structures/isl-timers. Last iteration found what read_freg reads and said plainly it had not answered the item, which was the unit. Following the writers does. [phase+88], [phase+104], [phase+120] are three parallel 32-entry arrays, cleared together by the phase initialiser in one unrolled loop (bases 12/44/76/108, offsets 0…127). sub_822710D0(phase, dt) — called from ScriptPhase::Update at 0x82263480 with Update's own float passed straight through — does prev[i] = cur[i] then if running[i] == 1: cur[i] += dt. So [phase+104] is last frame's value and [phase+120] the running flag, and the walker's prev <= t < cur is "did timer kind cross t this frame". 🔑 dt is SECONDS, by a round trip that is not circular: the frame loop sub_821AA1B0 computes frames × (1/60) (0x8289A0F8 = 0.016666668), turns it into integer ticks with × 10000.0 (0x820A13B4), clamps at 3200, and sub_821A49A8 scales back by 1e-4 (0x8289A0FC) into the timing singleton at [0x828F35B4]+8 for the script VM, +16 for the flight code. Produced as seconds × 10⁴, consumed as × 10⁻⁴; the clamp is 0.32 s, a frame ceiling. Refutation test, 675 / 675 vs an 11.2 % control: if a timer only runs once started, every timeline kind must be an index its own phase starts — and every one is. That is also why kind is only ever 0 or 5: every phase opens set_flag(5); set_flag(0). Artefact data/isl-timers.txt; all seven existing artefacts regenerate byte-identical. 🔴 Two corrections to isl-builtins.md. set_flag(i) writes 0.0, not 1.0 — the constant at 0x8209FD28 is literally zero, the same one the initialiser clears with; and clear_flag(i) does not zero the value, it clears [phase+120][i] only (all 133 sites disc-wide pass 1 = all 32, in the teardown reset_phase_threads ; timer_stop ; clear_flag(-1)). So the family is start / read / stop, and the earlier "latch" reading was a timeout all along. The corpus's grouping of 8/9/93 is confirmed[phase+120] is this family's running column, not a different array as last iteration's note allowed. ⚠️ The first run of the test scored 433/675 and looked like a real refutation (misses all kind = 0, in a coherent block of stages). Cause: isl.dis defaults to stop_at_ret=True, so a set_flag at the start of a coroutine lost its operand staging to the preceding ret. Checked before believing — the corpus's own trap, paid again. 🟡 Not settled: the names (set_flag/read_freg/clear_flag describe none of this, but 127/123/124 already hold timer_set/timer_resume/ timer_stop for a different timer, so renaming is its own change); one exception, read_freg(15) in Stage 06 phase 1 has no set_flag(15) in that phase; the three scale fields s24/s40/s44 (all 1.0 at construction) have no writer searched for — a game-speed setter would be one; and 31 read_freg sites index by a computed value rather than an immediate.

  • 🔴 (2026-08-27) timer_set's SECOND ARGUMENT IS A SECOND COUNTDOWN — the "warning threshold" reading is REFUTED. structures/isl-mission-timer. mission-phase-timers.md left it open as "a limit and a warning threshold is the obvious reading, but it is not established". Reading the updater settles it the other way. The mission timer is five scalars on the ScriptPhase — [+304] elapsed (up), [+308] countdown A, [+312] countdown B, [+316] running, [+320] armed — zeroed together by sub_8225FEF8, and driven by sub_822639B8, called from ScriptPhase::Update at 0x82263528 with fmr f1, f30 — the SAME dt the stopwatch bank gets, so it counts the same seconds. Its body: if running: [+304] += dt, and if armed: [+308] -= dt while [+308] > 0, ELSE [+312] -= dt clamped at 0. 🔑 A threshold would be compared; [+312] is never compared with [+308] anywhere — it is decremented, and the bc 12, gt at 0x822639FC jumps away to the A arm, so B only runs once A is spent. Two sequential countdowns. Disc-wide, 29 timer_set sites: first argument 600 ×19 / 1200 ×8 / 900 / 1800, second argument 180 in all 29 — a per-mission limit then a fixed 3-minute stage. (Stated as consistent-with, not as the proof; the disassembly is the proof.) The five built-ins are vtable slots 9094 (0x8226C690): 127 timer_set(a,b) arms and loads A/B (as doubles from local[0]/local[1]), 123 timer_resume starts the elapsed counter, 124 timer_stop stops it and disarms, 125 timer_reset zeroes all three floats, 126 timer_elapsed returns [+304]. 🔑 125 and 126 have ZERO call sites in all 28 scripts — the script arms, starts and stops this clock but never reads it. Its only consumer read here is a 32-byte message (0xAB03E5BA → the bus at [0x828F35DC]) carrying elapsed, B, running, and expired = armed && A <= 0. 🔴 CORRECTION to mission-phase-timers.md: "timer 0 is started by timer_set(1200,180) then timer_resume, with a 1200-second limit" merges two clocks. timer_set/timer_resume arm and start THIS object; set_flag(0), one instruction later, starts stopwatch 0, which is what the timeline's kind = 0 reads. They start together and so read almost the same value — which is why the merge was invisible. The 1200 s is the mission timer's, not stopwatch 0's. This also unblocks the naming collision noted in isl-timers: 123127 act on five scalars, 8/9/93 on a 32-entry bank. Not renamed here — that is its own change and its own artefact diff; everything regenerates byte-identical as it stands. 🟡 Not settled: who subscribes to 0xAB03E5BA — the literal appears at exactly one site, the producer, but 0xAB03 is the high half of many message tags here and dispatch is not by literal compare, so one occurrence is not evidence of no consumer. So whether running out of time ends the mission is unread, as is what B reaching zero does, and whether all 170 timer_stop sites are phase teardown.

  • 🔎🔴 (2026-08-27) THE TIMER MESSAGE'S CONSUMER: LOCATED, NOT READ — and the argument I was about to make is REFUTED BY ITS OWN CONTROL. structures/isl-mission-timer. The tempting move was "0xAB03E5BA is built at exactly one site, so nothing consumes the timer message". The control kills it: 25 of the 40 distinct 0xAB03xxxx tags in the image are built exactly once (62.5 %) — single-site construction is the majority, not an anomaly. And the tag appears nowhere in the image as a 4-byte word — in fact no 4-aligned word anywhere begins 0xAB03 — so there is no static table of subscribed tags to find either. Two structural reads that do stand. (1) The post is a queue push, not a dispatch: sub_82175C20(bus+4, &msg) is a ring-buffer append (capacity +8, head +12, count +16, array +4, grown via sub_8228E208) and never looks at the message. (2) The message class has a one-method vtable: 0x820A8D180x820A8DA0 is a run of 2-word records { sub_82301C20, 0x8210Exxx }; the timer message's vptr 0x820A8D68 gives vtable[0] = sub_82301C20 (a scalar deleting destructor — stores 0x820A7014 over the vptr, conditionally frees) and vtable[-1] = 0x8210E288, an MSVC RTTI locator (0,0,0, &typeDesc 0x8289D094, &classHierarchy 0x8210E29C, numBaseClasses = 3) — data, not code, and below sylpheed.db's 0x82150000 floor. So the message carries no handler of its own, and dispatch is neither a literal compare nor virtual on the message. 🟡 Located: the bus at [0x828F35DC] carries a map at +8216, looked up through sub_82254A08 from sub_823001E8, whose only caller is sub_8230C398 — the per-frame message pump, called from the frame loop (0x821A6544) with the frame dt. So the question reduces to what inserts into bus+8216 and with which key. That path is unread, so the item is NOT settled: whether running out of time ends the mission is still unknown.

  • 🔴 (2026-08-27) THE STOPWATCH BUILT-INS ARE RENAMED — and my own "message pump" label from the previous pass is WITHDRAWN. Rename landed. isl.py emits stopwatch_start (8) / stopwatch_elapsed (9) / stopwatch_stop (93); 123127 keep timer_* for the mission timer, which is five scalars rather than a 32-entry bank. Artefact check: 84 lines changed across 5 files, and all 84 pair exactly with their old-name partners once column padding is normalised — 0 removed lines without an old name, 0 added lines without a new one. data/isl-timers.txt regenerates to the same 675/675, 11.2 % control, 82/1 and identical argument histograms, which is the check that the rename is cosmetic. 🔴 WITHDRAWN: "sub_8230C398 is the per-frame message pump." It is called every frame (sub_821A6470 at 0x821A654C, sub_821AA1B0 at 0x821AA7DC, both with dt) but it drains nothing — its body is a state machine on [0x828E1F8C], and over 466 instructions it calls only an allocator sub_8230C160, string building sub_8217FA08, the lookup sub_823001E8 and a push sub_8225FEA0. A boot/loading sequencer that POSTS messages. 🔴 And bus+8216 is weaker evidence than I wrote. sub_82254A08 is a generic map find with ~120 call sites image-wide, and +8216 is a common offset (50+ addi …, 8216 sites); worse, the key sub_823001E8 looks up is a POINTER (lwz r4, 13780(r11), stored and passed by address), not a message tag — so that map may not be the subscriber registry at all. ▶️ The item is still open and the handle is now different: find what drains the ring buffer at bus+4 (capacity +8, head +12, count +16), not what reads bus+8216. Whether running out of time ends the mission is unanswered.

  • (2026-08-27) BUILT-IN 104 IS NAMED — request_next, from the game's own log string. structures/isl-condition-builtins. Built-in 104 returns [phase+10160], whose only writer in the image is one site in the mission frame loop sub_821AA1B0. Following that writer instead of the reader: r29 is obj->slot2() on an object fetched from the registry at [r30+2424] by id 0x20FFFF02, published to the phase, and then the same object is slot1(0)read, publish, clear, every frame. 🔑 The id namespace has exactly three members (0x20FFFF00/01/02), each built at exactly 4 sites image-wide — a closed family, so this is not the coincidence the last two iterations kept tripping on. Two of those sites are in sub_821D5178, which gets both 0x20FFFF01 and 0x20FFFF02 and passes their values to a log call whose format string (0x820A4968) is: "silph::GamePart_ReadyRoom::Impl::OnCommand - Wait() command is requested. Check flow control valiables. WAIT_MODE : %d, REQUEST_NEXT : %d". Argument order settles it — r4 (first %d) is 0x20FFFF01 = WAIT_MODE, r5 is 0x20FFFF02 = REQUEST_NEXT. ⇒ [phase+10160] is REQUEST_NEXT and built-in 104 is request_next. Corroborated by PrepareScript, which sets WAIT_MODE = 1 and REQUEST_NEXT = 0 before an ISL script runs. So the six tutorial stages' single dominating condition request_next() != 1 is the script waiting on the surrounding game part's proceed flag. ⚠️ Three other hits on offset 10160 are lfs on unrelated objects — the recurring-offset trap, excluded. Renamed in isl.py; 6 artefact lines changed and all 6 pair exactly. 🟡 Not settled: what the (16, 32] gate on [r30+104]'s +12 selects; the third id 0x20FFFF00 (used by the GRAPH_PATH/EX_FONT/SYSTEM code in sub_821D6350/sub_821D6A40); and builtin103's [phase+10152] / [phase+10156], which are neighbours of REQUEST_NEXT but have 14 and 11 touching sites rather than one — a different, busier mechanism.

  • ⚠️ (2026-08-27) [phase+10152] / [phase+10156] ARE READ — an ANSWER and a request STATE, and built-ins 102/103/130 are one trio. structures/isl-condition-builtins. The backlog carried these as "9 and 7 writers, unread", and warned they are not request_next's siblings just because they are adjacent. Correct — they belong to four consecutive vtable stubs: 102 → slot 70 sub_8226BF48, 103 → slot 71 sub_8226BFA8, 104 → slot 72 (request_next), 130 → slot 97 sub_8226BFD0. Built-in 102's body: state==1 → return 2 (pending); state==2 → special[0] = (answer==1); state=0; return 0; else → state=1; answer=0; return 2. 2 and 0 are the dispatcher's thread codes, so 102 is a blocking built-in that yields until an answer arrives; 103 is the non-blocking form (state != 0 && answer == 1) and 130 clears the answer. ⇒ [phase+10156] = request state (0 idle / 1 pending / 2 answered), [phase+10152] = the answer, tested against 1. The answer is published by sub_821A9DC8 ([10152] = [r6+4], [10156] = 2) under the same (16,32] gate on [r30+104]+12 that request_next's writer uses — one engine→script publish path serves both. ⚠️ An inherited name I could not verify: prompt_yes_no (102). Grepping the corpus, it appears only in a list of built-ins Stage 02 does not call, with no derivation recorded anywhere. The mechanism is consistent with a modal yes/no prompt but does not establish it, so 103 and 130 are left unnamed rather than named off an unverified premise. 🟡 Not settled: what question is asked. sub_821A9DC8 has no strings and exactly one reference in the image (a tail b from 0x821AC064), so the string recipe finds nothing there; and the (16,32] gate is still unread. 📎 Reference picked up on the way: the image carries the full GamePart id table as silph::GamePartTask::RegisterToFactory<N,class silph::GamePart_X> strings — Title 0, SaveLoad 3/4, Extras 5, MovieTheater 6, MissionSelect 7, Options 8, Movie 9, Bunk 10, ReadyRoom 11, Hangar 12, Arsenal 13, PilotLog 14, System 15, MainGame 17, PauseMenu 19, StageClear 20, MissionLog 21, GameOver 22, Debriefing 23, Dialog 24, Tutorial 25, ChallengeMission 26, Leaderboard 27.

  • 🔴 (2026-08-27) THE (16,32] GATE IS READ — it is != 16 && <= 32, a VALIDITY CHECK on a load-time parameter, and my own GamePart-id lead is killed by its control. structures/isl-condition-builtins. 🔴 The corpus's own notation was wrong. (16, 32] implies a lower bound; there is none — everything below 16 passes. The test is kind != 16 && kind <= 32. X+12 is a constructor argument, not engine state. sub_8225EC78(X, kind, …) — the function carrying "script load cancel" — does stw r4, 12(r30) and then applies those same two tests to r4, bailing out of the load. Every later site re-tests the field it just stored, so the gate is the object's invariant, not a selector between behaviours. Control: 42 sites image-wide have this exact cmpi 16cmpi 32 shape, 34 in one region (sub_821A9DC8, sub_821AA1B0, sub_821AB570, sub_821AB650) plus 3 in sub_8225EC78 — one object, guarded everywhere it is touched. The object chain is read. X is installed at 0x821A78EC (stw r25, 104(r30)), the previous instance torn down first via sub_8225EB60 — so X = [GamePart+104] is the game part's CURRENT SCRIPT INSTANCE. X+4Y; Y+4 → the ScriptPhase; Y+72 = the answer, and [ScriptPhase+10152] gets the same value. X+12 arrives as [r21+8], r21 being sub_821A6CF0's second argument. 🔴 REFUTED, my own lead, in the same pass. GamePart ids run 0…27 and 16 is not among them, which made "X+12 is a GamePart id, 16 being the unregistered one" very inviting. Its control kills it: 1, 2 and 18 are also missing from the RegisterToFactory list, so 16 is one of four gaps, not a unique one. Not adopted. 🟡 Not settled: what the kind means. The gate is read; the value's domain is not, and chasing it needs another hop up (sub_821A6CF0's caller).

  • 🟡 (2026-08-27) THE CODE REGION IS NAMED — GamePart_MainGame — and the kind's domain has a better-controlled lead that I am NOT adopting. structures/isl-condition-builtins. sub_821A6CF0 has no bl callers, only a tail b from 0x821AC04C, which sits in a run of adjustor thunks fed by a vtable at 0x820A319C (RTTI at 0x820A3198). Slot 1 is addi r3, r0, 17; blr — the factory id, and RegisterToFactory<17, class silph::GamePart_MainGame> names it. Every thunk does lwz r3, 8(r3) first, so slots 410 are GamePart_MainGame::Impl methods: 4 → sub_821A6CF0, 5 → sub_821A82A0, 6 → sub_821A8428, 7 → sub_821A9DC8, 8 → sub_821A9CF0, 9 → sub_821AA1B0 (the per-frame Update), 10 → sub_821AB570. The region several iterations have been reading is now named rather than inferred. 🟡 The lead, with its control stated. The disc carries 28 mission scripts numbered Stage01Stage16 and Stage18Stage29 — exactly one gap, Stage17 — and the gate excludes exactly one value, 16. Under 0-based indexing 16 ↔ Stage17, with <= 32 bounding indices 0…28. Against the refuted GamePart-id reading (4 values missing from its domain, gate excludes 1 → 1-of-4, worthless) this is 1-of-1. Much tighter — and still coincidence-shaped, so not adopted. ▶️ What would settle it: the Stage%02d construction site. The image has a generic '%s%02d' formatter at 0x820A9C8C; whether it is fed index or index + 1 decides it outright. Not found this pass, and grepping the extracted disc does not help — the pak names are hashed, so neither Stage16 nor Stage17 appears as a plain token.

  • 🔴 (2026-08-27) THE VTABLE IS 113 SLOTS, THE CLASS IS silph::SilphScriptPhase, THERE IS A SECOND ONE — and the test I proposed last iteration does not exist. structures/isl-builtin-dispatch. 🔴 Dropped first, honestly: the plan was to settle the stage-index lead at the Stage%02d construction site. There isn't one. The '%s%02d' at 0x820A9C8C has one xref into a generic string helper; listing every short %d string in the image (43) finds no stage pattern; and the name_hash of Stage_S00…30 / Stage00…30 / UnitGroup_S00…30 appears nowhere as a 4-byte word. The executable never builds a stage script name — the mapping is on the data side. Do not retry this route. Vtable LENGTH settled: 113 slots (0…112). The RTTI locator at vtable[-1] = 0x820A84B8 → type descriptor 0x8289CD18 = .?AVSilphScriptPhase@silph@@. ⚠️ The earlier "≥200 slots, no non-code word in the first 200" was a bad terminator — the scan ran into the next vtable. The real terminator is the next class's COL at 0x820A8680. Independent cross-check: the 147 built-in stubs use 109 distinct slots, min 0, max 110 — all inside 0…112. A SECOND class: silph::SilphScriptPhaseDemo (0x8289CCC0), vtable 0x820A8684, also 113 slots, overriding 109 of them — nearly all pointing at one shared stub 0x8226C160. The Demo phase implements almost none of the script surface, keeping slot 15 → sub_82391BA8, slot 111 Updatesub_82275800, slot 112 → sub_8237EF08. 🔑 That closes a loose end from the timers work: sub_822710D0 (the stopwatch advance) has two callers, sub_82263408 and sub_82275800 — they are the two classes' Update, same slot 111, base and derived. And both "unread spawner callers" are placedsub_82264058 is SilphScriptPhase slot 0 and sub_82273910 is SilphScriptPhaseDemo slot 0: the two scalar deleting destructors, a matched pair rather than two mysteries. 🟡 Why a destructor reaches the spawner is not read. 🟡 Still open: X+12's domain — the one remaining static handle is another hop, to whatever the GamePartTask manager passes into GamePart_MainGame slot 4.

  • ⚠️ (2026-08-27) A SCRIPT LOAD BUILDS TWO PHASE OBJECTS — and the tidy "every phase is a Demo" reading is killed by reading the constructor. structures/isl-builtin-dispatch. Each vtable constant is materialised at exactly two sites (ctor + dtor), and vptr_writes agrees: SilphScriptPhasesub_8225FEF8 / sub_82261B60; SilphScriptPhaseDemosub_82260568 / sub_82260FF8. ⚠️ The wrong turn. sub_8225FEF8's only caller is sub_82260568 — which looks exactly like a derived ctor calling its base, i.e. every phase is a Demo. That would make the whole built-in dispatch inert, since the Demo vtable no-ops nearly everything. Reading the constructor refutes it: sub_82260568 allocates and builds two SEPARATE objectssub_82150EF8 for 10216 bytes then sub_8225FEF8 on object A, and sub_822700C0 + stw 0x820A8684, 0(r29) on object B — stored at Y+4 (main) and Y+8 (demo). That is exactly why the load path resolves the ScriptPhase through Y+4. 📏 10216 bytes is consistent with the highest field read here, [phase+10160]. 🔑 Method note: a constructor calling another class's constructor is not proof of inheritance — check which object each vptr lands on. The Demo overrides are no-ops. All 109 point at three shared stubs, and every one does nothing: 0x8226C160 (special[0] = 0), 0x822748B8 (same, return 0), 0x822748A8 ([phase+176] = 0.0). Only four real methods survive: slot 15 sub_82391BA8, slot 111 Update sub_82275800, slot 112 sub_8237EF08, and the destructor. 🟡 What "Demo" means — a reading from the corpus's own vocabulary. DEMO is one of the eight cutscene text families already decoded here (MSG_DEMO_<id>_<page>_<line>), so a second phase built from the same load with every gameplay built-in stubbed and only Update alive reads as the cutscene script runner. Nothing here shows it executing cutscene bytecode, so it stays a reading. 🔑 New handle on the stalled X+12 domain: the Demo class's single construction site is in sub_8225EC78sub_82260568(Y, r26, [X+12]) at 0x8225ED5C. The kind goes straight into the constructor as arg 3.

  • (2026-08-27) SETTLED — X+12 IS A 0-BASED STAGE INDEX. The gate is a bounds check on a 33-entry table, and != 16 is Stage17. structures/isl-condition-builtins. Three iterations circled this. Following the value rather than the filename does it in two hops. (1) SilphScriptPhase's ctor sub_8225FEF8 never touches r7 — zero mentions; the base ctor sub_822700C0 keeps it: or r26, r7, r7stw r26, 152(r30). (2) sub_82261F70 builds a string-pointer table on its stack and indexes it with exactly that field (lwz r11, 152(r21); rlwinm ×4; lwzx r4, r11, r10 where r10 = r31+144). The table is contiguous at 0x820A8880, 20 bytes/entry: index 0 = STAGE01_UNIT_MAX … index 16 = STAGE17_UNIT_MAX … index 32 = STAGE33_UNIT_MAX, then PLANE at 33. ⇒ X+12 = [phase+152] = a 0-based stage index, N → STAGE(N+1) — and the gate is now explained rather than described: <= 32 is the array bound (33 entries, 0…32) and != 16 is STAGE17, the one stage number with no .ssb on the disc. Retracts my own "not adopted". Two iterations ago the stage-index reading scored 1-of-1 but was coincidence-shaped and deliberately not believed. It is now read off the game's own strings, and 0-based is PROVED by index 5 → STAGE06, not assumed. ⚠️ False positive caught: 0x82272D88 lwz r11, 152(r11) in the built-in switch is not this field — r11 had just been loaded from 0(r31), so it is the vptr, and the instruction is a virtual call to slot 38. Offset 152 recurs; the base register decides. And a THIRD classstructures/isl-builtin-dispatch. sub_822700C0 stamps its own vptr 0x820A8E44 before sub_8225FEF8 overwrites it: .?AVSilphScriptPhaseBase@silph@@, also 113 slots. So the hierarchy is SilphScriptPhaseBaseSilphScriptPhase and ← SilphScriptPhaseDemo, and the Demo object is built by calling the base ctor then stamping the Demo vptr — which is why sub_822700C0 shows up on both construction paths. 🟡 Not settled: what STAGENN_UNIT_MAX resolves to (the lookup goes through sub_824480D0 / sub_82448AA0), and whether Stage30Stage33 exist on the disc at all — the table has 33 slots but only 28 scripts were extracted.

  • (2026-08-27) STAGENN_UNIT_MAX IS TWO PER-STAGE UNIT CAPS — PLANE and VESSEL. structures/isl-condition-builtins. Reading sub_82261F70 past the table lookup finishes the mechanism the stage index feeds. The section name is built from the 0-based stage index, the section is looked up, and two ints land in the phase: [phase+356] = PLANE (default 200) and [phase+360] = VESSEL (default 20), the defaults taken when a stage has no entry (stw r11(=20), 360(r21) / stw r10(=200), 356(r21)). Key strings are 0x820A8B14 = "PLANE" and 0x820A8B1C = "VESSEL". All three names — the section and both keys — are the game's own. So a stage declares how many fighters and how many capital ships it may hold. Surrounding reads name the config: GP_SCRIPT, SCRIPTS, Resource2D, TABLE, and the pak census counts 40 <?xm entries — an XML config reader (sub_824480D0 open, sub_82448AA0 find-section, sub_824482D0 read-int). ⚠️ Register discipline mattered twice here. In sub_82261F70 r21 is the phase and r31 is the stack framelwz r4, 356(r31) at 0x822624DC is a stack read, not a phase field. And 0x82273400 lwz r11, 360(r11) in the built-in switch is a vtable slot 90 call (built-in 123), not a read of [phase+360]. 🟡 Not settled: the values. A crude Z1+zlib scan of dat/*.pak found no UNIT_MAX, but it did not use the corpus's own pak reader (unitgroup.py), so that is not evidence of absence. 🟡 No consumer of [phase+356]/[phase+360] identified — the offsets recur and the plausible hit was the vtable call above. 🟡 Whether Stage30Stage33 exist on disc (33 table slots, 28 scripts) is still open.

  • 🔴 (2026-08-27) THE CONFIG IS AN INI, IT SHIPS NEARLY EMPTY, AND EVERY STAGE TAKES PLANE = 200 / VESSEL = 20. structures/isl-condition-builtins. 🔴 Correction to my own label. I called sub_824480D0/sub_82448AA0/ sub_824482D0 an XML config reader on the strength of the pak census's 40 <?xml entries. Those 40 are XPR2 resource manifests (<RDF Version="XPR2">, <XBGMesh>, <Texture … D3DFMT_DXT1_SRGB>) — mesh/texture declarations, not config. The real config is a loose config.ini at the disc root, header comment 「アプリケーション/ゲームパート初期設定テーブル」 — "application / game-part initial-settings table", noting that SYSTEM holds what the game and every game part share. That matches every key this code touches (SYSTEM, GP_SCRIPT, SCRIPTS, Resource2D, TABLE, GRAPH_PATH, EX_FONT, SCRIPT_ID, SCRIPT_PATH, BASE_INFO, PLANE, VESSEL, STAGENN_UNIT_MAX) — they are INI section and key names. 🔑 The shipped file is 400 bytes: [SYSTEM] (empty) and [LANGUAGE] (= eng default plus #0x01…#0x06). No [STAGENN_UNIT_MAX] anywhere, and UNIT_MAX/VESSEL appear in 0 pak entries. The absence is CONTROLLED. The same scan over all 41 paks decompressed 26 443 entries and found MSG_DEMO 192×, mapmesh_box_500km 162× and <?xml exactly 40× — the census number. ⚠️ Last iteration's null was worthless: that hand-rolled scan used s[8:] instead of s[10:] for the Z1 header and searched the index file rather than the .pNN data. Using the corpus's own reader (unitgroup.py) was the whole difference.The lookup always misses, so every stage runs on the defaults: PLANE = 200, VESSEL = 20. For the port these are hardcoded fallbacks, not per-stage data. 🟡 A hash search (in case the section were name-hashed like a pak entry) found 0 of name_hash("STAGE01…33_UNIT_MAX"/"PLANE"/"VESSEL") as pak entry keys; 8 of 35 appeared as 4-byte words inside entries, seven in the 1.1 GB sound.pak — the chance rate for a 32-bit needle at that size, not a finding. 🟡 Still open: who CONSUMES [phase+356]/[phase+360], and whether Stage30Stage33 exist on disc.

  • 🔴 (2026-08-27) THE SECTION READER IS NOT INI-ONLY — it is a generic accessor over the IDXD tables — and UNIT_MAX's absence now has a REAL control. structures/isl-condition-builtins, artefact data/config-keys.txt. 🔴 Correction to yesterday's label. Resolving the r4 string at every call site of sub_82448AA0 and sub_824482D0 yields 65 section names and 54 int keysMISSIONS, FONTS, StageResource, EPILOGUE_MOVIES, WEAPONS, UNITS, SOUNDS, ACHIEVEMENTS_REQUIREMENTS, LINE_PITCH, MSG_FONT_SIZE, SUBTITLE_Y, … far more than the 400-byte config.ini holds. Decisive: BASE_INFO, SYSTEM, MISSIONS, FONTS are all present as IDXD record keys on the disc. So the pair is a generic named-section accessor over the IDXD .tbl containers, with config.ini as one small extra input — not "the INI reader". ⚠️ My first re-test was INVALID and only its control caught it. IDXD record keys are tag_hash, not name_hash (case-sensitive, modulus 0x00FFFFDF; unitgroup.py documents the difference). Scanning 190 782 records with name_hash returned 0 for STAGENN_UNIT_MAX and 0 for every control name — which is exactly how a bad test announces itself. Redone with tag_hash: 7 750 / 7 750 IDXD entries parsed (matches the corpus census), 190 782 records, 3 496 distinct keys, controls BASE_INFO/SYSTEM/MISSIONS/FONTS found, and STAGE01…33_UNIT_MAX → 0 records. With the literal search (0 in 26 443 entries) and config.ini printed in full, the section exists nowhere on the disc. The GP_HANGAR_ARSENAL word-hit that worried me is settled — not a record key. ⇒ PLANE = 200 / VESSEL = 20 for every stage, now on a controlled test. 📎 data/config-keys.txt lists the whole vocabulary — a lower bound (literal addis/addi keys only), but it is the engine's own configuration surface and directly useful to the port. 🟡 Still open: who CONSUMES [phase+356]/[phase+360]; whether Stage30Stage33 exist; and the fallbacks behind the other 64 sections.

  • 🟡 (2026-08-27) Stage30Stage33 DO NOT EXIST — the 33-slot table is headroom; and the [phase+356]/[phase+360] consumer is NOT findable by offset. structures/isl-condition-builtins. Probing pak entry names (name_hash — the right hash for entry names) across all 41 paks: UnitGroup_SNN.tbl exists for exactly S01S16 and S18S29 = 28 stages; S17 and S30S33 absent. The control is built in — 28 hits from 33 probes proves the loop resolves entry names. So the 33-entry STAGENN_UNIT_MAX table is oversized headroom over a 28-stage game, and the gate's != 16 skip of STAGE17 lines up with a stage that genuinely has no data of any kind. 🔑 An independent corroboration fell out of it. AIParams_SNN.tbl exists for S01S16 and S24S29 (22) — missing for exactly S18S23, precisely the six stages the corpus identified as the TUTORIALS by a completely different route (their only dominating condition is request_next() != 1). Tutorials ship no AI parameters. Two unrelated methods, same six stages. 🟡 Stage_SNN.tbl probes find 0 — expected, not a failure: stagetbl.py documents that the per-stage record is not name-addressed and must be found by content. 🔴 Dropping the [phase+356]/[phase+360] consumer. Image-wide there are 243 non-stack lwz reads from +356/+360, across UI, sound, render and script code — the offsets are far too common to attribute. Inside the ISL region the only hits are the two writers plus two vtable calls (0x82273400 slot 90, 0x82290ED8). This needs type information the offset index does not carry; it is not a cheap static question and should not be reopened as one.

  • (2026-08-27) THE CONFIG SECTIONS ARE REAL DISC DATA — 64/65, and the two reader functions separate perfectly. structures/isl-condition-builtins, artefact data/config-keys.txt. Testing every literal key from the artefact against the 3 496 distinct IDXD record keys with tag_hash: section names (arg to sub_82448AA0) → 64 / 65 present; field keys (arg to sub_824482D0) → 0 / 54 present. The two classes are each other's control and the dissociation is total, which confirms the model exactly: sub_82448AA0(node, NAME) finds a record keyed by tag_hash(NAME); sub_824482D0(node, KEY) reads a field, whose name is a literal string in the record's pool — so none of them can be a record key. 🔑 The single absent section is LANGUAGE — precisely the one real section in the disc-root config.ini. The same API serves both stores and the data shows the seam. This deflates my own earlier framing. I had listed "64 sections whose hardcoded fallbacks are worth pinning for the port". They are not fallbacks: all but one are real data on the disc. STAGENN_UNIT_MAX is the genuine exception, and only because its name is built at runtime from the stage index rather than being one of these literals. 📎 The artefact now names the pak each record lives in: tables.pak for the global tables (MISSIONS, STAGES, SOUNDS, SUB_OBJECTIVE, SQUADRON_ORDER_OBJECT, EPILOGUE_MOVIES, ACHIEVEMENTS_REQUIREMENTS, FONTS, BASE_INFO, …), GP_HANGAR_ARSENAL.pak for WEAPONS, UNITS, Camera, ControlTweak, Rendering, AUTO_SETTINGS, IGNORE — so the arsenal pak holds the canonical weapon/unit definitions — and the per-language GP_MAIN_GAME_* paks for the enum tables. 🟡 Not settled: the CONTENTS of those records. Knowing WEAPONS lives in GP_HANGAR_ARSENAL.pak is a pointer, not a decode; the field-level read is a separate job per table.

  • 🔴 (2026-08-27) THE CONFIG RECORDS ARE DECODED — real render/input constants for the port, and my "canonical weapon/unit definitions" guess was half wrong. Artefact data/config-records.txt, structures/isl-condition-builtins. The find — directly portable constants: Rendering = Brightness 1.4, Contrast 1.0, Saturation 1.00, Hue 0.00, ExposureKeyValue 0.18, BrightPassThreshold 0.6, BrightPassOffset 1.5, LuminanceMin 0.15, LuminanceMax 3.6, BloomScale 0.5, AfterimageScale 0.05, StarScale 1.20, GlareType 8, ColorLayer R/G/B/A. ControlTweak = CameraSpeed 0.50, TargetSpeed 0.50, TargetMovingRange 10.0…10.0, mov_trigger_play 20, eye_stick_play 6000, mov_stick_play 6000. Camera = NoseCameraFOV 0.92, ChangeTime 0.20. These are the game's own tone-mapping, bloom and input-deadzone numbers. 🔴 Correction to my own last entry. I said GP_HANGAR_ARSENAL.pak holds "the canonical weapon/unit definitions". WEAPONS is a 59-entry NAME ROSTER (No_Equipment, Machiene_Cannon_MG1, Broad_Sword_SG1, Frail_GP37, Stiletto_BG1, Pilum_BP, …) — an enumeration of ids, not a stat sheet. And UNITS is not units at all: seven craft-slot↔pilot pairs — Bird1-Sandra, Bird2-Billy, Bird3-Antonius, Bird4-Carl, Rhino1-Raymond, Rhino2-Katana, Rhino3-Ellenthe wingman roster. IGNORE is a 13-name blocklist over that enumeration (Long_Spear_HBP, Twin_Saber_LG2H, Eagle_120AM, Maelstrom_Bomb, Cluster_Mine_B10, plus placeholders Wep_82, Wep_85) — weapons that exist but are withheld. 🔑 AUTO_SETTINGS lists exactly 28 files (stage01_settings.tblstage29_settings.tbl) — a third independent confirmation of the 28-stage count, after the .ssb census and the UnitGroup_SNN.tbl probe. 🟡 The "objective" records are UI, not gameplay. SUB_OBJECTIVE is a HUD layout (MAX_OBJECTIVE_OFFSET 353,242, TEXT_OFFSET 85,271, NUM_QUALIFY_OFFSET 309,242, DOT_OFFSET 0,20) with a second variant in one pak (275,222 / 85,251 / 231,222 / 0,0), so two layouts ship. SQUADRON_ORDER_OBJECT is 29 .prt sprite names. MISSIONS holds TimeAttack, ScoreAttack, Extra01Extra04 — the challenge categories, not the campaign. STAGES has zero fields. 🟡 Not settled: where weapon/unit STATS live — the roster names an enumeration, and the corpus's existing weapon datasheet came from a runtime capture, so the static table behind it is still unlocated.

  • (2026-08-27) THE STATIC WEAPON DATASHEET IS FOUND — Weapon (131), Shell (131), AssortMissileParam (9). structures/weapon-datasheet-static, artefact data/weapon-shell-static.txt. The corpus's weapon numbers came from a RUNTIME capture; the static tables behind them are now read with no emulator. 🔑 The move: IDXD field names are literal strings in the pool, so the stat-shaped keys already harvested from the disassembly (MegaTons, GuidanceType, SpiralType, Straight1Type, …) can be searched directly. 8 648 records carry at least one, clustering on three record names in every per-language GP_MAIN_GAME_*.pak. Weapon (21 fields): ID/Name, TargetType Vessel,Craft,Structure, ShotType, HitRatio, Interval, TriggerShotCount/TriggerShotInterval, LoadingCount, Heating/Cooling, SprayAngle, Mass, ReticleType, plus SE and muzzle-flash FX names. Shell (37 fields): Power, Velocity, Acceleration, AngularVelocity, MinimumRange/MaximumRange, LifeTime, CollisionType Capsule, Radius/Length/Volume, ShellMass, HP, DamageType, MovementType, IsGravitated/IsDestructible/IsLockable/IsFullCollision, Color_R/G/B, ShellModel, hit/jet/contrail FX and SE. With world unit = 1 metre, the sample rocket is 6 km range at 3 km/s. AssortMissileParam (20 fields): the guidance model — GuidanceType, SpiralType, Straight1Type/2Type and min/max ranges for three named motion modes (sp_sp_* spiral, sp_qu_* quick-turn, sp_zg_* zig-zag) plus straight aperture/pitch. Nine variants ship. ⚠️ 131, not 59. The WEAPONS roster read last iteration lists 59 — that is the arsenal menu's list, not the full weapon set. 🔴 Controlled negative in the same pass: stageNN_settings.tbl does not exist. AUTO_SETTINGS names 28 of them; probing pak entry names with four path prefixes finds 0 / 116, while the two controls in the same loop (stage\UnitGroup_S02.tbl, stage\AIParams_S02.tbl) are both found. Same shape as STAGENN_UNIT_MAX — named but not shipped. 📎 Also decoded on the way: EnumUnit = 54 real unit ids (UN_e107_ADAN_AAFrigate, UN_f102_TCAF_LightCarrier_Inv, …), tying the eNNN/fNNN mesh prefixes to readable faction/class names; ArmsItemFile = 59 weapon-id → HUD icon .t32 mappings (icons, not stats); SETTINGS in tables.pak = the sound config (PATH game:\dat\sound.pak+, PARAM Pj_Silph.xgs, member BANK_SE); Parameters = HUD marker config. 🟡 Not settled: reconciling the 131 records against the runtime capture field by field; the stat keys in records not opened here (MegaTons, BulletLimit, CrewCount, HP_CLASS); why the roster lists 59 of 131.

  • (2026-08-27) THE OBJECTIVE/TUTORIAL MARKER RECORDS — and a CORRECTION: S24-S29 are the CHALLENGE missions, not story. structures/hud-config, structures/stage-numbering-and-player-craft, artefact data/hud-config.txt (+13 lines, 0 deletions — purely additive). Correction first. stage-numbering-and-player-craft.md called S24-S29 "story" and reported "22 story stages". challenge-mission-gate.md OWNS that split and already had it: S01-S16 story, S18-S23 tutorials, S24-S29 challenge. A third confirmation fell out of this iteration's reading: AUTO_SETTINGS names stage01..16_settings.tbl, tutorial01..06_settings.tbl, challenge01..06_settings.tbl — and its field tags are 1-16, 18-23, 24-29, the shipped stage numbers exactly. The campaign is 16 story missions, not 22. (Fourth time the grep-the-owning-doc rule has caught me; AUTO_SETTINGS and IGNORE are likewise already owned by isl-condition-builtins.md, so nothing there was new either.) What IS new: the 11 marker records hud-config.md listed as unread. 12 named fields each, and portable HUD data. Colour is ARGB, three per marker (ColorNormal, ColorEmphasis1, ColorEmphasis2): objectives red 0xFFFF0000, guard/waypoint green 0xFF20FF20, Acropolis/tanker cyan 0xFF00FFFF, sub-objective yellow 0xFFFFFF00, and every TutorialMarker_* amber 0xFFFFA010. Each carries a Normal sprite and an Emphasis part with optional …Sub companions; HPGauge 0/1/2; RadarCursorType Circle (Acropolis, tanker) / Rectangle (guard) / blank; BlinkCycle 0x800 on all eleven. TutorialTarget is Yes on exactly the five TutorialMarker_* and No on the six ObjectiveMarker_*. Parameters binds the roles: TargetMarker -> ObjectiveMarker_Target, HelpMarker -> ObjectiveMarker_SubObjective, TutoTargetMarker -> TutorialMarker_Target, plus ReloadDispTime 0.1. Enumerate_ObjectiveMarkers is the 11-name roster of the set — the same shape as Enumerate_Squadrons in unit-group-table.md. ⚠️ pgmark_em_ob_s.prt appears here and is one of the 28 names archive-naming.md shows resolving nowhere — consistent with the namespace finding, not a missing asset.

  • (2026-08-27) CoverArea IS A 6-BIT MOUNT MASK — plus the three other turret leftovers, all closed. structures/unit-substructure-records, artefact data/turret-coverarea.txt, regenerator tools/re-capture/turret_coverarea.py. Six bits, never more. Over all 835 turrets: 41 distinct values, max 0x3f, bits 6/7 set on none; per-bit 709/117/274/274/260/237. 0x00 (34) = the empty Weapon_NULL hardpoints, YawLimit 0 on all 34; 0x01 alone (439) = craft hardpoints, YawLimit 2.5/1.0/0; 2-6 bits (362) = warship mounts with YawLimit 45-180. 🔑 It tracks the MOUNT, not the weapon. UN_e107_ADAN_AAFrigate has eight identical AAFrigate_AAGun turrets: GN_GunXS_01..04 are 0x1d and 05..08 are 0x2d — same gun, same YawLimit 120, different mask. UN_f104_TCAF_Battleship spreads five masks over GN_TGunL_01..05 / GN_TGunM_02..03, all firing the same CAF_Ship_ASGun. 🟡 Which sector each bit denotes is NOT determined — six bits and the name CoverArea invite +-X/+-Y/+-Z or six hull faces, and the 01-04 / 05-08 split looks like a side, but nothing static fixes the convention. Not adopted. Refuted on the way: bits 2 and 3 are NOT a mutually-exclusive pair (188 turrets set both). The 26 weapons no turret mounts are a coherent set, not strays: 11 _P player variants, the nose/twin mounts (_NoseGun, _NoseGun_P, _None, _Ttrl, _TwinGun, _TwinGun_P), two _Child sub-munitions (wep_13, wep_29), the three S16Boss_*, three Weapon_Test_*, and ADAN_Attacker_S_GunTurret. Versatile_NNN ships NOWHERE — 0 populated records across all 41 archives, only the ??? template row. A sixth part kind with zero instances. The two units one turret short: both extras are MISSILE mounts with NO Frame. Elan_EX4 (TurretCount 1, 2 records) = Turret_NoseGun + Turret_Missile, both mask 0x01, both frameless. AAFrigate_EX4 (TurretCount 8, 9 records) = eight GN_GunXS_0N_ContH guns plus one Ship_AAMissile at mask 0x0c with no Frame. So TurretCount counts frame-mounted guns and a frameless launcher rides in the same record family uncounted — n=2, stated as the observed pattern, not a rule.

  • 🔴 (2026-08-27) BLOCKED — the 2D / GP_READY_ROOM TOC names: three routes, all closed, each with a control. structures/archive-naming, artefact data/archive-naming.txt (+22 lines, 0 deletions — purely additive). 1. A different hash family. The corpus knows three (idxd-tag-hash.md): name_hash, tag_hash, ixud_hash. Scoring all 5 977 harvested names x 6 prefixes: GP_TITLE 8/16 and GP_PAUSE_MENU 6/11 under name_hash (the positive controls), and tag_hash and ixud_hash explain NOTHING anywhere — including the paks name_hash does explain. So they are not the TOC function and the unnameable pair is not keyed by a different one. GP_MAIN_GAME_E2D stays at 0/711 under all three. 2. The executable. sylpheed.db's strings holds 7 140 rows, of which exactly TWO look like asset paths — Data\gmicon002_2.t32 and Data\gmicon006_2.t32, in a Data\ directory nothing else on the disc uses — and neither resolves in any archive. The binary is not the name source; it holds two strays and no table. 3. Name transformations — 13 of them on the 419 config paths, all 0. 🔴 The container runs out here. Those TOC keys hash names that exist on neither the disc nor the executable in readable form. The only lever left is a dictionary attack using name_hash's shape (top byte = character-sum checksum, so candidates are cheap to reject), and that needs a plausible name corpus this disc does not contain. Noted as blocked rather than improvised around. ⚠️ The port does not need these names: sprites and bundles are readable by CONTENT (T8aD, RATC), and the config records already say which asset each HUD element uses. Only the archive-key ↔ name mapping is missing.

  • (2026-08-27) WHICH ARCHIVES THE DISC CAN NAME — 100 % for eleven menu paks, 0.0 % for all six 2D paks AND GP_READY_ROOM. structures/archive-naming, artefact data/archive-naming.txt, regenerator tools/re-capture/archive_naming.py. Chasing more prefixes for the 419 HUD paths would have been the same mistake twice, so this censuses the whole disc: harvest every plausible asset-name string from every archive (6 027), hash each under the 16 known prefixes, and ask per archive what fraction of its TOC that explains. idxd-container.md/idxd-tag-hash.md own the hash; neither says which archives are REACHABLE by it. 🔑 The result is bimodal. GP_TITLE 16/16, GP_PAUSE_MENU 11/11, GP_STAGE_CLEAR 44/44, GP_CHALLENGE 151/151, GP_MOVIE_THEATER 56/56, MiscBin 40/40, GP_GAMEOVER, GP_BUNK, GP_SYSTEM, GP_TUTORIAL, fonts100 %; tables.pak 78/79, GP_DIALOG 139/140, the six language paks 115/117, GP_SAVE_LOAD/GP_LEADERBOARD/GP_OPTIONS/GP_MISSION_LOG 91-94 %; GP_MAIN_GAME_* 751/1119 = 67 %. Then the cliff: the six GP_MAIN_GAME_*2D.pak at 0 of 711 EACH, and GP_READY_ROOM at 6 of 1 106 = 0.5 % — the largest UI pak on the disc, and not previously noted anywhere in docs/re/. 🧪 Eleven paks at 100 % in the same run is the control that makes 0.0 % a finding rather than a failed guess: the method works; these archives are outside it. 🔑 So the 419 HUD paths are not "missing assets" — nothing in the 2D paks is reachable by name from the disc's own strings at all. Those TOC keys hash names that are not written anywhere readable: composed by the tool chain or by the executable, or listed outside these archives. Also refuted first: the 419 config values under 13 name transformations (as-is, forward slashes, basename, basename-no-ext, no-ext, UPPER, .rat substituted, 2d\/eng\/hud\/GP_MAIN_GAME_2D\ prefixed, first-directory stripped) — every one scored 0, against the E2D 711 and against all 16 630 entries on the disc. 🟡 Not settled: what those names are. The one lever left is the hash's shape — the top byte is the character-sum checksum, so candidates are cheap to reject — but that needs a name corpus the disc does not contain. Also unexplained: DefTables 8.9 % and GP_HANGAR_ARSENAL 22.6 % sitting in the middle.

  • (2026-08-27) THE WHOLE IN-GAME HUD CONFIG IS OPEN — 16 records, 419 asset paths, a per-stage ResourceTable — and NONE of it is name-hash addressed. structures/hud-config, artefact data/hud-config.txt (697 lines), regenerator tools/re-capture/hud_config.py. Checked first: no docs/re/ file mentions ArmsStatus, RangeFinder, Radar, Sight, Wing, NamePlate or ResourceTable. Only HudResource had been opened; the other fifteen records had not. The six IDXD entries of GP_MAIN_GAME_E2D.pak: two carry the 16-record HUD config (ArmsStatus 16, Map 3, ArmsItem 1, RangeFinder 10, Marker 27, Manuva 15, Number 10, HudResource 24, NamePlate 60, ActiveArm 13, Information 15, Wing 23, ArmsItemFile 59, Radar 29, Speed 9, Sight 35), two the 13-record ObjectiveMarker_*/TutorialMarker_* set, one Face (52 portrait sprites), one ResourceTable. 🔑 New subdirectory prefixes throughout: ArmsSt\, ActvArm\, RangeF\, Marker\, Manuva\, Map\, Speed\, Radar\, Sight\, Wing\, Hitmark\, Lockon\, Info\. Number is %d-templated (pghud_speed_num%d.t32, pgtimer_num%d.t32, pglockon_%d.t32). 🧪 THE DECISIVE CONTROL — the config's own exact path strings, no guessing left: 419 distinct .prt/.t32/.tbl values, ZERO resolve as a pak entry under 10 prefixes; the four config FILENAMES resolve to nothing either. The .t32 sprites certainly exist (574 T8aD in that pak). So the 2D pak is not addressed by name_hash of the name its config uses. ⚠️ This SUPERSEDES the earlier framing: the 28 "dangling" .prt names were never a missing-asset story — they are 28 of a set where none of the 419 resolves. The right statement is "the in-game asset namespace is not the archive namespace". ResourceTable = 58 positional fields = 29 PAIRS, alternating HudResource.tbl/HudMarkerResource.tbl, identical in all six language paks, with exactly one override at pair index 25: HudResource_S26.tbl / HudMarkerResource_S26.tbl. 🟡 The reading — indexed by stage number 1, so 29 entries cover S1..S29 and index 25 is S26, the one stage with its own HUD config. The arithmetic is exact and the _S26 suffix names the same stage, but nothing proves the indexing. Not adopted. 🟡 Not settled: how the 574 T8aD / 130 RATC entries ARE addressed — that is now the question; what HudMarkerResource.tbl pairs to (by content, not hash); Parameters and Enumerate_ObjectiveMarkers are in the artefact but unread.

  • (2026-08-27) THE "VARIANT INSIDE A PARENT BUNDLE" READING IS REFUTED — and the in-game screen config is HudResource, the counterpart of tables.pak. 28 .prt names exist NOWHERE on the disc. structures/mission-script-manifest, artefact data/prt-parts.txt (+45 lines, 0 deletions — purely additive). Censused the 32 bundle-less in-game names with the 68 as control: 4 of 32 appear as an ELEMENT of some 2D bundle, against 58 of 68. Being an element is normal for a part that exists; these are not elements either. The reading I flagged as "not adopted" last iteration is now refuted. 28 names are neither a bundle nor an element. 🔑 They are not unreferenced: a HudResource record inside the 2D paks' OWN IDXD entries names them — the in-game screen config, exactly parallel to tables.pak for menus. GP_MAIN_GAME_E2D.pak has 6 IDXD entries; two carry HudResource (24 named fields: PGTIMER, PGREMAINING, PGHUD_SUBTARGET, PGHUD_LOCATOR_EASY, the pitch-ladder/yaw/cockpit .t32 sprites, HUD_RES_FONT = DFSOGE5.TTC) beside ArmsStatus, Map, ArmsItem, RangeFinder, Marker, Manuva, Number; two more carry ObjectiveMarker_* / TutorialMarker_*. ⚠️ Its values carry subdirectory prefixesHitmark\, Lockon\ — which my hand-written prefix list never had. 🧪 So I rebuilt the negative the right way round: harvested every IDXD field value containing a backslash, kept the 60 commonest directories (Sight\, Radar\, Marker\, Wing\, ArmsSt\, Manuva\, Hitmark\, Lockon\, the <lang>\Voice\ and <lang>\etc\ trees) and re-swept. 241/376 with the hand list; 241 with hand + harvested — zero new resolutions. After the language\ miss this is the closure that counts: the prefix list came from the disc, not from me. 🟡 Still open: whether the 28 are cut features or assembled at runtime from sprites — nothing static separates those two. The family: pghud_range_*, pghud_wing_*, pghud_arms_active*, pgtarget_*, pgmanuva_eff2/3, pggauge_*_eff1, pgtimer, pgremaining, pgmsg_update.

  • (2026-08-27) AN EMBEDDED PART IS A TOP-LEVEL RATC BUNDLE, ADDRESSED BY ITS ELEMENT PREFIX — 68 of 100, against 0 of 241 for the shipped parts. structures/mission-script-manifest, artefact data/prt-parts.txt (+38 lines, 0 deletions — purely additive). Read first: ui-rat-layout.md OWNS the RATC stack and already documents the 60-byte element declaration table at 0x20 and "one bundle = one (context x language) build of that screen". What it does not say is how a part is addressed when it is NOT a pak entry. 🔑 The elements of one bundle share a common name prefix, and that prefix is the part name. GP_MAIN_GAME_E2D.pak = 130 bundles, 114 distinct element prefixespgmenu_btn00, pghud_wing, pgface, pghud_range, pgmanuva_eff0, i.e. exactly the in-game part names. 🧪 The 100 in-game-only .prt names match a 2D bundle prefix 68 times; the CONTROL — the 241 shipped parts — matches ZERO. The two families are disjoint on the test. (13 shipped parts match a bundle prefix in their OWN screen pak, which is the expected shape.) Four of the five mission banners land here: pgmsg_start.prt is E2D bundle 0x89fac252, a ONE-element bundle declaring pgmsg_start_sub.rat; likewise _end_ 0x92239624, _failed_ 0xc01017fc, _restart_ 0x606fef65. pgmsg_update has no bundle at all, consistent with having no _sub.rat. 🟡 32 in-game names still have no 2D bundle. They cluster into families whose BASE name is a bundle — pghud_wing_act_* beside pghud_wing, pghud_range_* beside pghud_range, pgmanuva_eff1..3 beside pgmanuva_eff0, pgtarget_* — reading as "a variant declared inside a parent bundle". A reading, not adopted; nothing here shows the variant mechanism. 🟡 Still open: those 32, and pgmsg_update, absent from every route tried.

  • (2026-08-27) WHO REFERENCES A .prt DECIDES WHETHER IT SHIPS — menu parts are named and shipped, in-game parts are embedded. 0 counterexamples in 376. structures/mission-script-manifest, artefact data/prt-parts.txt (+44 lines, 0 deletions — purely additive). Censused the whole 135 unresolved set instead of chasing the five. Cross-tab over all 376 .prt names: referenced by tables.pak AND resolves 241; neither 100; referenced but absent 35; resolves WITHOUT being referenced by tables.pak — ZERO. 🔑 Resolving implies a tables.pak reference, 0 counterexamples of 376. tables.pak holds the menu screen configs and every part it names ships as <lang3>\<name>.prt. The 100 names that appear only inside the GP_MAIN_GAME_*2D.pak bundles never ship as entries — the in-flight HUD is authored into the bundles, not loaded by name. So the five pgmsg_*.prt were never a special case; the real split is menu = named + shipped, in-game = embedded. It is NOT a prefix rule — 6 of the 135 pg* names DO resolve (pgloading, pgloading2, pgmsg_scr, pgpause, pgpause_ttrl, pgpbase), precisely because tables.pak names them. 🟡 The 35-name residual is one coherent family: tables.pak-referenced yet absent — pgmenu_btn*/pgmenu_item*/pgmenu_pad, pgfacewin*, pgtextwin, phinfo1-3, 02d, 02d_scr, psview_release: the in-game pause menu and squadron-order overlay, named by a menu config but drawn from the in-game bundles. Consistent with the split, not separately proved. 🟡 Still open: where an embedded part sits INSIDE a bundle.

  • 🟡 (2026-08-27) WHERE .prt SCREEN PARTS LIVE — and the five mission banners are a real exception. structures/mission-script-manifest, artefact data/prt-parts.txt, regenerator tools/re-capture/prt_parts.py. Read first, as the rule now requires: ui-prm-primitives.md, ui-rat-layout.md, ui-screen-runtime.md — they own the .prt/RATC/T8aD stack but describe element names INSIDE bundles, not where parts live. A .prt IS a pak entry, under a LANGUAGE directory. Of 376 distinct .prt names referenced on the disc, 241 resolve as archive entries — 220 under eng\ and jpn\, 38 under each of the other four. prbase.prt resolves under all six; palogo1.prt under two. All five pgmsg_*.prt resolve under NOTHING, across 15 prefixes that include every language dir — so this is not the missing-prefix mistake again. 🟡 A lead, refuted by its own control. Four of the five have a same-stem _sub.rat sub-bundle inside every per-language 2D pak (pgmsg_{start,end,failed,restart}_sub.rat, 6 archives each); pgmsg_update has none. Tempting as "the parts live as RATC sub-bundles" — but only 4 of 376 .prt stems have a _sub.rat (the other 7 belong to GP_BUNK, GP_SYSTEM, GP_MISSION_LOG), and prbase.prt, which does ship as an entry, has none. Not the container convention. Not adopted. 🟡 Still open: where the five actually are. What is settled is that they are not pak entries while 241 siblings are, and that pgmsg_update is the odd one out even among the five. ⚠️ Method note: an intermediate pass of this iteration nearly reported "the five appear only in the manifest" — wrong, because the token census was truncated by a [:20] slice and the RATC hits were pgmsg_start_sub.rat, a LONGER token. A truncated listing is not a distribution, and a prefix match is not an exact match.

  • (2026-08-27) CORRECTION — the mission dialogue table was ALREADY IN THE CORPUS, and my headline negative was a FALSE NEGATIVE. structures/mission-script-manifest, artefact data/script-manifest.txt (8 insertions, 3 deletions — every deleted line replaced by its corrected form). The entry below announced message\MissionDialogMessage.tbl as a find. structures/ixud-localised-text.md OWNS it and already says S02_P1_OBJECTIVE and friends "are record names in an IDXD map, message\MissionDialogMessage.tbl, whose positional fields list the lowercase per-line IXUD names", and that "*_GRAPH is the odd one: a single named field holding a texture, pgmsg_stg02_1.t32". I grepped the manifest doc and not the text doc. Third overclaim in four days — the rule is now: grep the doc that owns the DATA, not only the doc that owns the FILE. MissionDialog_local_string.tbl DOES resolve — as language\MissionDialog_local_string.tbl, IXUD, in all six GP_MAIN_GAME_* paks. My 33 prefixes omitted language\, which is precisely the convention ixud-localised-text.md records for language paks. A prefix sweep is only as good as its prefix list, and the list should come from the corpus. "LOSE has exactly one entry" was wrong too. Per kind: HINT_PAUSE 4, HINT 3, LOSE 4, OBJECTIVE 4 POSITIONAL fields, and GRAPH 1 NAMED field holding the .t32 (S10_P1_GRAPH -> pgmsg_stg10_1.t32). The single-field bucket was GRAPH, not LOSE. What survives as new: the 11 non-MISSION field VALUES read as a block; the GP_TEST (PATH = dat\GP_TEST\, a debug archive not on the disc) and TEXTS sibling records; the per-stage phase census (40 stage-phases over the 22 story stages, 1-3 phases each, the six with none being 18-23); and the five pgmsg_*.prt resolving nowhere under 34 prefixes x 41 archives, now with TWO working controls in the same sweep.

  • (2026-08-27) Stage\script.tbl's 11 NON-MISSION FIELDS, READ AT LAST — and one of them lands on the mission DIALOGUE table. structures/mission-script-manifest, artefact data/script-manifest.txt, regenerator tools/re-capture/script_manifest.py. structures/mission-script-ssb.md owns this manifest and names its 40 fields but never read the 11 that are not MISSION<n> = StageNN.ssb. They are DIALOG_MESSAGE = MissionDialogMessage.tbl, DIALOG_LOCAL_STRING = MissionDialog_local_string.tbl, FONT = dat\fonts.pak+HGRGE00.TTF, FONT_SIZE 15, TEXT_POS 128,128,16, TEXT_LINES 30, and the five pgmsg_{start,end,update,failed,restart}.prt. Two sibling records were also unread: GP_TEST (PATH = dat\GP_TEST\, a debug archive NOT on the disc) and TEXTS (a second text style, 33/3/296,565,39). 🔑 Probed 7 values x 33 prefixes x 41 archives. One resolves: message\MissionDialogMessage.tbl, in all six GP_MAIN_GAME_* paks — 200 records, 25 280 bytes, every name S<NN>_P<n>_<KIND> with five kinds 40 each (HINT_PAUSE, HINT, OBJECTIVE, GRAPH, LOSE), fields positional, tagged 0..3, each value a message key (S10_P1_HINT_PAUSE -> S10_P1_Hint_Pause_00.._03). An index from (stage, phase, kind) to the localised strings, on the same S<NN>_P<n> keying the ISL corpus already uses. 🧪 Control: the 40 stage-phases span stages 1-16 and 24-29 — a subset of the 28 shipped, and the six with NO hints are exactly 18-23, the tutorials. A fifth independent route to the story/tutorial split, and it hands over the phase count per stage (1 to 3; S02/S03/S06/S09 have 3, S10/S13 and all of S24-S29 have 1). 🟡 The other six do not resolve, with the control in the same sweep: MissionDialog_local_string.tbl and all five pgmsg_*.prt are not a pak entry under any of the 33 prefixes, while message\MissionDialogMessage.tbl and Stage\script.tbl both resolve in 6 archives. Same wall mission-phase-advance.md hit for the manifest's KEYS; this closes the VALUES. ⚠️ name_hash is CASE-INSENSITIVE (message\ == Message\); tag_hash is not. Already noted in sound-pak-contents.md; repeated because it doubles the apparent hit count of a prefix sweep.

  • (2026-08-27) THE STAGE NUMBERING IS CONSISTENT EVERYWHERE — and the manifest still declares the cut S17. Also a correction to my own claim in the entry below. structures/stage-numbering-and-player-craft, artefact data/stage-numbering.txt (+14 lines, 0 deletions — purely additive). Correction first: the entry below says the corpus "did not know the stage NUMBERS". It did — structures/mission-script-ssb.md has recorded S01-S16, S18-S29, S17 absent by a three-way convergence (hash lookup, the table sweep, the loader's != 16 guard) since before that pass. What was actually new there is the player craft per stage and ResourceID's ordinal tags. The numbering is a fourth independent route, not a discovery. The question this item asked — do the ISL StageNN.ssb files and the UnitGroup_SNN.tbl tables use the same numbering, or is something silently mis-labelled? They are identical: Stage\StageNN.ssb, stage\UnitGroup_SNN.tbl and stage\Route_SNN.tbl all resolve for the same 28 N, symmetric difference EMPTY; AIParams is the same minus 18-23. Nothing in the corpus is mis-keyed. (Formation_SNN/Message_SNN resolve 0/40 — field names, not files; the four families above are the controls that make that negative real.) 🔑 New: Stage\script.tbl's SCRIPTS record lists 29 MISSION*.ssb fields — MISSION1..MISSION29, including MISSION17 = Stage17.ssb — and only 28 of 29 name a file that ships. Control: MISSIONn -> Stage<n>.ssb with the same n, 0/29 mismatched. So S17 is a cut mission the shipping manifest still names, which is why the loader carries an explicit != 16 guard rather than just running 0..27.

  • (2026-08-27) THE DISC'S STAGE NUMBERS ARE NOT 1..28 — S01-S16, S17 ABSENT, S18-S23 the tutorials, S24-S29 — and the player's craft per stage falls out with it. structures/stage-numbering-and-player-craft, artefact data/stage-numbering.txt, regenerator tools/re-capture/stage_numbering.py. The corpus knew "28 stages" and "six tutorials with no AIParams"; it did not know the NUMBERS. Hashing stage\UnitGroup_S%02d.tbl for N=0..39: 1-16, 18-29 ship, 17 does not — 28 files, and the six with no AIParams_SNN.tbl are exactly 18-23. So 22 story stages + 6 tutorials. 🔑 eng\GP_HANGAR_ARSENAL_3D.tbl's ResourceID record keys a player-craft mesh by stage: Unit_St1_6 -> rou_f001 (DeltaSaber T), Unit_St7_16 -> rou_f002 (W), and six further fields tagged with the raw numbers 24,25,26,27,28,29 -> rou_f002 x4, rou_f004 (DeltaSaber A) at 28, rou_f002. The bare tags ARE the last six story-stage numbers. 🧪 Control in the same record: tag_hash(name) == tag for 11/11 named fields, so those six genuinely carry no name. 🧪 Cross-check from a different file — grouping the Arsenal pak's 168 stage-scoped entries by which _Player craft their loadout mounts gives f001 = 6, f002 = 15, f004 = 1, tutorials 5+1 = 6, and every number closes against ResourceID: 6 = Unit_St1_6; 15 = Unit_St7_16 (10) + tags 24,25,26,27,29 (5); 1 = tag 28; 6+15+1+6 = 28. 🔑 The player flies the DeltaSaber A in exactly one mission, S28, and the DeltaSaber T only for the first six. 🟡 Not settled: the Arsenal entry FILENAMES, so which of the 168 is S24 vs S25 is constrained but not pinned (name_hash probing over 8 templates x 40 indices x 6 languages resolved 0; controls <lang>\weapon.tbl, <lang>\strings.tbl, <lang>\GP_HANGAR_ARSENAL_3D.tbl all resolve).

  • (2026-08-27) WHO SELECTS A LOADOUT: THE PAK ENTRY DOES — the Arsenal pak is STAGE-SCOPED, 168 entries = 28 stages x 6 languages. Also a correction to the entry below. structures/hangar-loadout-system, artefact data/hangar-loadouts.txt (+40 lines, 1 changed heading). 🧪 Settled by elimination with two controls. The 15 loadout names appear as a field VALUE nowhere on the disc — 0 occurrences across every pak — while the control Arbalest_155KG, which is referenced, appears 150 times as a value in the same pak. They occur only as the field names of the UNITS record. They are not in the executable either; control: WEAPONS (a section key) is, Arbalest_155KG is not. So nothing references a loadout by name — the selection is which pak entry the Hangar loads. GP_HANGAR_ARSENAL.pak has 180 IDXD entries in 10 shapes; 168 carry a UNITS roster = 28 x 6, and the other 12 = 2 x 6 (the item table and a sibling). Each entry is one stage's whole Hangar config. 🔑 The UNITS roster is the flight for that stage, and it changes across the campaign as the story does — 5 distinct rosters over 15 / 5 / 5 / 2 / 1 stages: Bird 1-4 + Rhino1-Katana/Rhino2-Ellen/Rhino3-Gene/Rhino4-Yoji; a Raymond/Gene/Katana/Ellen variant; Rhino2-Katana ALONE (5 stages); a 7-pilot variant; and one with Rhino4-Brandon. Every count is a multiple of 6 and they sum to 28 — the disc's stage count by a fourth independent route. ⚠️ CORRECTION to the entry below: it read the pak with setdefault, so "15 loadouts / 24 allow-lists / STANDARD_ARM1 has 11 entries" is the union of first-seen records, not one table. The contents genuinely vary per entry — PlayerSET_ARM1 has 5 distinct contents, STANDARD_ARM1 3, ExSET_NOSE 4; the SNNSET_*/NULL_* sets have exactly 1 each. The chain and both controls (60/60, 70/88) are per-record and unaffected; only the per-list sizes were over-generalised. 🟡 Not settled: the entry FILENAMES. name_hash probing over 8 templates x 40 indices x 6 languages resolved 0, while the same probe's controls <lang>\weapon.tbl and <lang>\strings.tbl resolved 12/12 — the method works, the convention is not one of the eight guessed. So which entry is which stage is still unknown.

  • (2026-08-27) THE HANGAR LOADOUT SYSTEM — the layer above the item chain. structures/hangar-loadout-system, artefact data/hangar-loadouts.txt, regenerator tools/re-capture/hangar_loadouts.py. 15 loadout records, one per flight position x pilot (Bird1-SandraRhino4-Yoji), each with Arm1/Arm2/Arm3/Nose + UnitID. 🔑 The same trap as PlayerWeapon, one level up: Arm1/Arm2/Arm3/ Nose do NOT name items — they name a per-slot ALLOW-LIST record, one of 24 whose only named field is Type (the slot kind). The candidate items are the allow-list's positional, unnamed fields, in order. Four hops: Rhino4-Yoji.Arm1STANDARD_ARM1[Falcon_9AM, Condor_105AM, …]item.PlayerWeapon = Turret_NNNslot.WeaponIDWeapon.ID. 🧪 Controls: Arm1/2/3/Nose → allow-list record 60/60; allow-list positional entries → arsenal item 70/88, and every one of the 18 misses is the single sentinel No_Equipment — one of the four WEAPONS-roster values with no item record, i.e. the empty-slot marker, not a decode failure. Six sets: STANDARD_* (11/9/9/8), ExSET_* (10/8/4/7), S01SET_* (2/1/1/1), S02SET_* (3/2/2/1), PlayerSET_* (2/1/1/1), NULL_* (1 each) — so the SNNSET_ sets are stage-scoped narrowings of STANDARD_. 🔑 UnitID is TWO ID spaces at once: 5 rows name a unit Generic.ID (the three -Katana rows are the PLAYER — they point at a _Player craft and carry an extra, empty PlayerUnit field), 8 name a character, resolving as Character + the value into the 64-record character table. 🟡 Two values resolve to nothing, both single rows against 13 that do: Rhino2-Ellen.UnitID = UN_f001_TCAF_DeltaSaber_Wno such unit anywhere (checked as a Generic.ID across every pak AND as a record name, 0 hits) while UN_f002_TCAF_DeltaSaber_W exists → consistent with a shipped f001/f002 typo, reported, not diagnosed; and Rhino4-Brandon.UnitID = BRANDON — no CharacterBRANDON among the 64. 🟡 Not settled: who SELECTS a loadout record (the names encode flight position and pilot, and stage-definition-table.md has AI_TCAF_RhinoFlight / AI_TCAF_BirdFlight, but the join is not shown); ExSET_*/S02SET_*/NULL_* are referenced by no loadout in this pak; PlayerUnit is empty on all three rows that have it; IGNORE (13 item names) is a blocklist for something unread.

  • (2026-08-27) THE 59-OF-131 ARSENAL QUESTION IS CLOSED — an Arsenal item names a HARDPOINT SLOT, not a weapon. structures/arsenal-item-weapon-chain, artefact data/arsenal-chain.txt, regenerator tools/re-capture/arsenal_chain.py. Open since the weapon datasheet was decoded, and one wrong answer was refuted yesterday. The link is three hops: Arbalest_155KG.PlayerWeaponTurret_050 (a slot on UN_f001_TCAF_DeltaSaber_T_Player) → .WeaponIDWeapon_DSaber_P_wep_50_Cannon. 🧪 The controls, both run in the same loop: 0 / 59 distinct PlayerWeapon values are a Weapon.ID; 59 / 59 are a Turret_NNN slot id; the full chain lands on a Weapon.ID 59 / 59. WingmanWeapon resolves identically. The WEAPONS roster's 59 = 55 item names + 4 empty-slot sentinels (No_Equipment, NullWeapon_Arm1/2/3). 🔑 Wingmen fly a cheaper gun. Following the same 59 slots across craft variants: the _Player tables give each item 59 distinct weapon records (Weapon_DSaber_P_wep_NN_*); the AI tables (…DeltaSaber_T, _W, _EX5) collapse all 59 onto 10 generic classes (Weapon_TCAF_DeltaSaber_Beam, …_Gun, …). That is most of the 131: 59 player + 10 AI-Saber + ship/ADAN turret guns. Upgrades yesterday's 🟡 — the "hardpoint catalogue" reading of the player craft's 63 Turret_NNN slots is now ADOPTED, proved from an independent file. ⚠️ And it corrects a number in that entry: the "10 distinct WeaponIDs" was measured on the AI variant; _Player has 59. An item record also carries Model/Package/SilhouetteModel (the meshes the corpus decodes), Points + Stage (the develop economy arsenal-develop-economy.md recovered from the screen side), the Hangar stat bars Power/Range/Size/Speed/Weight, and Type = a slot class. 🟡 Not settled: Type and the per-slot allow-lists (STANDARD_ARM1/2/3, PlayerSET_*, ExSET_*, SNNSET_* — records whose only field is Type); the wingman loadout records (Bird1-Sandra, Rhino1-Katana, … with Arm1/Arm2/Arm3/Nose/UnitID) — named here, not opened; empty Dependency/MissionObjective; which weapons the 26 never-on-a-turret ones are.

  • (2026-08-27) THE DESTRUCTIBLE-SUBSYSTEM MODEL — a unit's sub-records, opened at last. 835 turrets, and every turret's weapon resolves. structures/unit-substructure-records, artefact data/unit-substructures.txt, regenerator tools/re-capture/unit_substructures.py. The corpus has named these since unit-struct-runtime.md but never opened them. Per unit table: Turret_NNN 835 records (max 63 on one unit), ShieldGenerator_NNN 46, Thruster_NNN 38, Hatch_NNN 26, Bridge_NNN 25, plus one each of Shield/Mass/SE/Explosion/StructureCount and NS_Body on 68 of 114. 🔑 Turret/ShieldGenerator/Thruster/Hatch/Bridge are ONE record shape — a shared 19-field destructible-part base (ID, Name, ParentStructureID, Frame = a mesh NODE name, NomalModel, CollisionModel, Radius, HP, IsDestructible, IsShielded, IsInvolved, IsRadarVisible, SpreadDamage, damaged/destroy motion + time, Effect_Explosion/_Flare/_Paralyze) with per-kind extras: turrets add WeaponID, AngularVelocity, YawLimit, PitchLimit_Elevation/_Depression, CoverArea, IsAuto, HasBarrel and up to 80 CannonModel_NNN/CannonFrame_NNN; shield generators, thrusters and bridges add PowerRatio (what the parent loses when the part dies); hatches add SquadronID, LoadedCount, MaxAvailableCount, TakeoffIntervala carrier's launch bay. 🧪 Control 1StructureCount.<Kind>Count == #<Kind>_NNN records: 612 equal / 55 "0 declared, one blank placeholder" / 11 differ / 6 kind absent, over 684 comparisons. The 55 are a real sub-rule (a zero count still emits exactly one record, blank in Name and NomalModel and Frame, 55/55). All 11 exceptions are Turret and all are declared < records; nine are the player DeltaSaber family (4 declared, 63 slots — 🟡 reads as a hardpoint catalogue, not adopted). 🧪 Control 2835 / 835 Turret_NNN.WeaponID values resolve to an ID in the 131-record Weapon datasheet, zero unresolved; 105 distinct used, 26 weapons never mounted on a turret. A hard cross-table link. Refuted in the same pass: "the DeltaSaber's 59 non-NULL hardpoints are the 59-name WEAPONS arsenal roster". The counts match exactly and the sets overlap in 0 values — the roster is display names (Machiene_Cannon_MG1), the hardpoints reference Weapon.ID (Weapon_TCAF_DeltaSaber_Gun), and the 59 slots carry only 10 distinct weapon IDs. So the 59-of-131 question stays open, minus one wrong answer. 🟡 Not settled: CoverArea is a hex bit mask with unknown bits; Versatile_NNN has only its ??? template row in this pak; the two +1 turret cases (Elan_EX4, AAFrigate_EX4).

  • (2026-08-27) CORRECTION — Generic is a per-FILE HEADER record, not a table. Only 114 of the 394 are unit datasheets. structures/unit-datasheet-static, artefact data/generic-record-partition.txt, tool tools/re-capture/generic_partition.py. Answers "178 distinct Generic IDs vs 394 records per pak — what are the extra records?" from the entry below. Every IDXD file carries exactly one Generic record, and its schema is set by what kind of file it is. Partitioned by field set, identically in all six GP_MAIN_GAME_*.pak: 114 have HP = unit datasheets; 204 are {Count} only = dialogue files; 64 are {ID, Name, SideID, Unique} = characters (36 TCAF + 28 ADAN, each with a Faces sibling); 10 are {EnumAsteroidGroup}; 2 are degenerate (one empty, one naming eff_n0071). 204+114+64+10+2 = 394, and 178 distinct Generic.ID = 114 unit + 64 character — the only two shapes that carry an ID. 🧪 Positive control in the same loop: for the 204 dialogue headers, Count equals the number of Message_NNN siblings 204 / 204, zero mismatches — so "header record describing its own file" is not a story fitted to the counts. 🔑 Cross-check from the other side: Maneuver = 114, Effect = 114, the carrying-entry sets are identical, every unit Generic has a Maneuver sibling, and Generic.Type splits 43 Craft + 71 Vessel — the same 43/71/114 unit-struct-runtime.md reached by reading live guest memory. 🟡 Still open from the entry below: no field-by-field reconciliation of the static sheet against the runtime capture (much of it is already done in unit-struct-runtime.md); why the arsenal roster lists 59 of 131 weapons; what Maneuver's AA_/AV_ prefixes select.

  • (2026-08-27) THE STATIC UNIT DATASHEET AND THE AI FLIGHT MODEL — Generic (394), Maneuver (114), Effect (114). structures/unit-datasheet-static, artefact data/unit-datasheet-static.txt. Same technique as the weapon tables: field names are literal, so a field-shape search over 190 782 records finds the carriers. Generic is the unit datasheet — 110 field names, 178 distinct IDs: HP 6400.0, ShieldRatio, DefencePoint, AttackCraftPoint/AttackVesselPoint, ResistanceToShell/ ToExplosion/ToOptics/ToPlayer/Paralyze, Size_X/Y/Z+Size_Radius, RadarRange 10000.0, FCSRange 8500.0, MountedFCS, MountedShieldGenerator, ScorePoint/DamageScore/MassScore, HQRatio, ThrusterRatio, NozzleCount with per-nozzle jet/afterburner/reverse/contrail FX, and Model rou_e006 — which ties the datasheet straight to the mesh names the corpus already decodes. In the corpus's 1 metre world unit that is a 10 km radar and a 12 × 5 × 30 m craft. 🔑 Maneuver is the AI FLIGHT MODEL — the richest find for the port: MaximumVelocity 1200, MinimumVelocity 100, CruisingVelocity 700, Acceleration 600, Deceleration 400, SideThrustAcceleration 1000, Turn_AngularVelocity 180, MaximumBank_Normal 60, yaw/roll/pitch DragFactor 3.0 + DragFactorThreshold 0.5, ArterBurner_Vc 2.5/_Acc 2.0 (the game's own spelling), ReverseThrust_Vc 0.5, UsingChaffRatio 0.5, HomingResistAdjustment 0.8 — plus nine named manoeuvres each with a Yes/No switch and its own timing/ratio/length bounds (TurnAttack, TurnAway, BarrelRoll, SideRoll, Slalom, Through, HoldPosition, SolidCutoff, BoostAway) and AA_*/AV_* pitch/yaw/roll min-max pairs. This is the static source for what flight-speed-law.md and flight-controls-runtime.md measured at runtime. 🟡 AA_/AV_ pairing with AttackCraftPoint/AttackVesselPoint suggests anti-air vs anti-vessel rate limits — a reading, not adopted. 🔴 HP_CLASS/HP_ID are NOT ship stats. They sit on records named for wingmen and ship classes (CARL, ELLEN, SANDRA, ACROPOLIS, Carrier_TCAF1, NP_Facility, …), 120144 instances each, alongside HPGauge/HPGaugeSub/RadarCursorType on the ObjectiveMarker_* and TutorialMarker_* records — a HUD gauge binding table. 🟡 Not settled: no field-by-field reconciliation against the runtime captures; and 178 distinct Generic IDs against 394 records per pak is unexplained.

SOLVED — the mission freeze was a modal sign-in dialog (2026-08-26)

XamShowSigninUI opens a modal dialog and xeXamDispatchDialog blocks the calling guest thread on fence.Wait() until it is dismissed — which nothing in a scripted run ever does. Fix: pass --logged_profile_slot_0_xuid=…. Same route, one variable: screen id goes from stuck at 4 forever to 4 → 5 → 6 → 8 → 9 → 10, allocation failures 1 → 0, guest throws 1 → 0, guest churn 0.000 % → 1.006 %. It hid for so long because --log_mask=13 (used by every script here) disables kernel logging, the one category that names the dialog. See mission-freeze-signin-dialog.md.

🔴 BLOCKER — the mission freeze is a software-rendering hang WITHDRAWN (2026-08-26)

Withdrawn the same day. Driving the null backend blind to the same screen shows it freezes identically — 0.000 % guest churn, Main XThread futex-blocked at 0 ms CPU, same 128 MB refusal and throw. The earlier control compared a null-GPU run still in the menus against a lavapipe run at the freeze, i.e. two different game states, and reported the difference as a backend effect. The hardware-Vulkan blocker below does not apply — rendering is not what is blocking this. The text is kept for the reasoning.

🔴 BLOCKER — the mission freeze is a software-rendering hang (2026-08-26)

The freeze that blocks every dynamic measurement is in the host rendering path, proven by control: with --gpu=null the guest runs at ~1 core through the same content load that hangs every rendered run, with zero allocation failures, while under lavapipe Main XThread sits at 0 ms CPU in futex_do_wait and nine llvmpipe threads saturate for six minutes without producing a frame.

Deciding whether lavapipe is deadlocked or merely pathologically slow needs hardware Vulkan, which this container does not have. That is a stated container limit, so it is recorded here rather than worked around.

🟡 Workaround with a gap: --gpu=null gives a live guest, and everything the backlog needs is read from guest memory, not pixels. What is missing is navigation — menu-walking is screenshot-driven today, and blind Ⓐ presses reached no mission (DEF_VTABLE scan 0/0). First step: drive navigation from guest memory instead of the screen. See mission-freeze-heap-exhaustion.md.

🧰 The boot path — three failures that look like the emulator and are not (2026-08-26)

  • --audio prevents boot outright. run-canary's own header says the flag is not a cvar in this tree, and that an unknown argument makes xenia call ShowSimpleMessageBox from ParseLaunchArguments before logging starts, blocking forever. Measured anyway, because this corpus also holds runs that passed it and booted fine: 3 trials each, in both orders

    log written
    run-canary --apu=sdl --log_mask=13 67 565 bytes
    run-canary --audio --apu=sdl --log_mask=13 209 bytes

    209 bytes is run-canary's own banner and not one line from xenia. The order was reversed deliberately — this corpus already has a lesson that an A/B from run order is noise. 🔴 Eight scripts on branch auto/idxd-unnamed-keys still pass it: launch_mission.sh, boot_menu.sh, fly_stage.sh, challenge_probe.sh, grab_tutorial.sh, find_partslot.sh, tutorial_capture.sh, nav_probe.sh. main and this branch are clean, which reconciles August's successes with today's failures. Left unfixed here: they are another branch's files.

  • 🔴 launch_mission.sh's skip_intro deadlocks on the attract loop. It classifies the screen as movie and declines to tap ("tapping breaks the title"), and waited out 600 s of unbroken movie verdicts before timing out. nav_to_flight.sh, against the same running emulator, reached the main menu in 12 s and flight in 2 min 20 s by tapping A at the title. The "wait it out" premise is wrong — the attract loop does not end on its own. Use nav_to_flight.sh on an already-running canary.

  • ⚠️ EMULATOR GONE at ~40 s is this project's own Stop hook, which kill -9s every xenia_canary when a Claude turn ends. Already recorded further down this file — and rediscovered the hard way over three boots because I did not search for it first. The rule is the one fly_session.sh's header states: an emulator session must be one task; nothing may depend on surviving between tool calls. Sequential tool calls within one turn are fine; it is ending the turn that kills it.

The world-unit measurement is DONE — one world unit is one metre. Two independent routes: the mapmesh_box_500km collision hull is a cube of exactly 500 000.0 units per axis (and the other box mesh exactly 100 000.0), and flight-speed-law.md gives 352 units per game-second against a HUD reading of 350. The CollisionSet objection that forced the 🟡 is dismantled in structures/mcol-collision.md: it read the four-member rob_ family (which contains a 50 km "player fighter") as if it were hull sizes, and 133 m is the 7th smallest of 78 rou_ meshes.

The world-unit measurement is still not made — but the blocker under it is now GONE. The entity scan works: see entities-live-roster.md, 13 unit definitions and 42 named live instances with 52 moving triples, on 2026-08-26.

The old note here blamed entities2.py's committed VA window. That was the wrong suspect: the real cause was the navigation route. Runs that reach a mission through MISSION SELECT scan 0/0; the route that populates the world is launch_mission.sh's title → LOAD GAME → slot 01 → YES → READY ROOM → TAKE OFF, which is what structures/unit-struct-runtime.md used when the constants were first derived.

The HUD is now reached too, and the control is paired — see world-unit-attempt-2026-08-26.md. Getting there needs two steps no script had: START skips the post-take-off cutscene, and a modal "tell you your objective?" dialog dims the whole frame (so the classifier reads other and liveness looks like a stall) until Ⓑ/NO answers it.

Two tool defects were measured on the way, and both must be fixed before the measurement will work:

  • entities2.py's ENT_VA_LO/HI = 0xBD0000000xBE000000 contains no live instance (they sit at 0xBC384CE00xBC9BAC20); it covers the definitions instead. Rescoped to the instance region, find_delta's ±0x400 radius yields zero votes.
  • gworld.py's WINDOW = 0x600 is too small — no position-like triple moves in the first 0x600 bytes of any of the 42 instances. 0x4000 finds one at +0x29d0 (🟡 one offset, one instance, one sample).

What is still owed is the unit itself: lock a target so the HUD prints a numeric range, then read that range and both position triples in the same second. Do not use speed as the corroboration — 116.6 units per 0.6 s wall-clock against the HUD's 350 looks like a free answer, but the emulator is not real-time under lavapipe, so the denominator is unknown.


WITHDRAWN (2026-08-26, same day) — the Japanese voice banks are NOT a separate format

The sound.pak census (see structures/sound-pak-contents.md) shows the archive is 5 100 jpn\ banks + 4 382 eng\ banks + 35 music banks, and that the leading-region rule holds for 0 of the 5 100 Japanese banks — 4 301 have a first RIFF at an offset that is not 1392 + n·2048, and 799 have no RIFF at all. The rule was derived on English banks and reproduces those exactly, so this is a limit of the finding, not a defect in the reader.

Since FILES names the Japanese set and the game substitutes the language directory, the undecoded half is the one the table actually points at.

Resolved by that first step. Scanning instead of assuming showed the offset is simply first_riff % 2048, which takes four values disc-wide (1392, 1468, 1600, 1728) by language and subdirectory. The Japanese banks decode fine at their own offset — median 70× more audio on a 140-bank FFmpeg-verified sample — and the same bug was silently affecting 1 873 eng\Voice banks. I had mistaken a constant derived from eng\etc\ for a property of the format. See structures/slb-data-offset.md.

Closed 2026-08-26: the 2 unaccounted TOC entries are static.slb and Pj_Silph.xgs, named by the same table's BANK_SE and SETTINGS records — sound.pak is now 9 519 of 9 519 accounted for.

Closed: the 55 "early RIFF" English banks are not an anomaly — all 55 sit at exactly 1392 behind a zero-filled header, i.e. a zero-length leading region, which both the old code and the new derivation already handle.

Closed 2026-08-26: the 28 offset-scan ties. A bank's seek chunk sits on a packet boundary, so seek_pos % 2048 is a second and structural derivation of the offset (99.97 % on the labelled set). It resolves 26 of the 28 correctly and none wrongly; the combined rule is 99.95 %.

Both remaining questions closed the same day, and both by correcting an error of mine rather than by finding something new:

  • "69.8 % of banks declare more data than they store" — the declared sizes are honest. Every RIFF-bearing entry on the disc carries seek magic at exactly data_at + declared_size with packet_count × 2048 == declared, 7 620 / 7 620. The TOC window is simply not the wave boundary. My supporting case, VOICE_TCAF_608, was not truncated either: it is stereo (170 of 8 021 banks are) and I decoded it as mono, which yields one frame. Read correctly it is 33.96 s, agreeing with both length signals inside the bank.
  • "the offset takes four values by directory" — the cause was wrong. X = (cumulative start of the .pNN segment) mod 2048; the per-directory table was a correlation, because directories cluster into segments. There is no header: those bytes are the previous bank's audio.

Both are written up in structures/slb-data-offset.md, including the seek layout (little-endian, packet count at +12) and the boundary recipe.


SOLVED (2026-08-26) — the mission scripts are readable as dialogue

Built-in 64 (request_script_message) stages a symbol-table-1 reference in slot 0; the symbol is a message id (MSG_VOICE_D_257), and the caption table holds its text under <id>_000_00. 2683 of 2683 call sites across all 28 stage scripts resolve — every one stages slot 0, every operand is type 6, every name has text. 1338 distinct names in six families.

This was only reachable after build_caption_text was switched to the IXUD field table on 2026-08-25 (537 → 8800 lines); before that most of these names had nothing to resolve to, which is why the item sat open.

Write-up + tool: structures/isl-message-dialogue-link.md, tools/re-capture/isl_dialogue.py.

Answered the same day: what drives MSG_DEMO_* (its own IDXD tables in the language packs — see structures/cutscene-message-table.md), and which bank voices a line (tables.pak's cue index — see structures/sound-cue-table.md; 1 326 of 1 338 script message ids resolve to a .slb).

Also closed (2026-08-26): the 17 cue-less cutscene pages and the 5 duplicated MSG_DEMO records are one and the same thing — the resupply lines, whose audio binds through the movie path rather than a DEMO_nnn cue. Their repeat counts match the movie-slot counts recorded for VOICE_D_450454, 5 of 5, from a separately decoded container.

Still open from this: nothing about the wrong-recording case — that was already settled in voice-bank-leading-region.md (generic line, correct binding) and my first write-up wrongly reopened it; the other five languages; and play order, since the tool prints script order without resolving phases or branches. Multi-page captions were settled the same day — and in doing so refuted the first version of the tool, which truncated 356 of the 1338 names to their opening utterance.


SOLVED (2026-08-19) — the paint order is a runtime child list, not a table in the file

The screen object the game builds at load time holds two lists of its elements: the declaration-ordered array at +0x08, and a reordered child array at +0x30 — and the second is the paint order. Read live off the title screen and checked against the draw capture: the seven nameable elements sit at child slots 0, 6, 7, 13, 16, 17, 22, strictly ascending, in exactly the captured order. See structures/ui-screen-runtime.md.

Landed rather than left open (2026-08-19): the compositor now paints in the measured order for the two builds that have been read off the running game (the title build and the GAME ARTS/SETA/anima splash) and falls back to declaration order elsewhere. Rendering that exposed a second defect the same capture settles — kind = 0x4 elements are motion-trail ghosts, absent at rest — and the title now composites correctly (captures/title-composited-measured-order.png). Disc-gated test, checked both ways. The Bevy viewer's UI Screens browser calls the same ui_layout::compose, so the fix reaches what a person actually looks at rather than only the CLI's screen render — checked in iso_loader.rs (compose_screen), which also keeps its element table in declaration order, so the per-element visibility toggles still line up.

Derivation found (2026-08-19): the order sorts by the word at +0x08 of the T8aD sprite header — non-decreasing in paint order on both measured screens, with no inversion, and on the splash it explains the whole permutation. See structures/ui-paint-order-key.md.

Wired into the compositor (2026-08-19) and regression-checked. compose sorts by the key for every build except the two whose measured order is hard coded. It reorders 341 of 965 builds, and a disc-gated test asserts every composite's draw list is strictly increasing in (key, declaration index). Against the two screens the corpus had already verified against the running game — the tutorial PAUSE menu and the title main menu — the new order changes 3.8 % and 1.1 % of pixels, max delta 45/255, with no layout change: only blends where translucent sprites overlap.

Still open:

  • 🟡 Which order is more faithful on those two verified screens. The difference is too small to decide against the committed side-by-side oracle and no fresh framebuffer capture of either exists. First thing to check if one is taken.
  • The tie-break. Two groups share a key and the game paints them in an order that is not declaration order; the compositor keeps declaration order there.
  • What the field's bits mean — 0x8000/0x80a0/0xa110 look like flag words with a layer in some bits, not a plain depth. Sorting the whole word works on both measured screens; which bits carry the layer is unknown. Censused 2026-08-26 over all 21 184 disc sprites (tools/re-capture/paint_key_census.py): the field is a u16 at +0x0A (upper half zero 21 184/21 184) and an enumeration — 216 values. 🔴 "the keys are pak-local" is REFUTED68/216 (31 %) cross a pak family and the per-pak ranges overlap heavily, so it looks like a shared vocabulary, not a per-screen depth. Which bits carry the layer is still unknown. ⚠️ A first version of this census was retracted. It filtered pak entries on a T8aD magic, but sprites are usually RATC children — it saw 4 525 of 21 184 sprites, 45 of 216 keys, and not GP_TITLE.pak at all, the pak both measured screens come from. It reported "45 values" and "pak-local", both wrong. A separate earlier slip on the same page counted the six language copies of GP_MAIN_GAME_2D as six paks. Same shape each time: a statistic computed over an unverified sampling frame.
  • A third measured permutation, to promote "holds on two" to a rule. Answered inside this same entry — a third, fourth and fifth screen were measured, the last from GP_SAVE_LOAD, i.e. outside GP_TITLE.pak. The cheapest is a screen whose object is resident at the same time as the title's.
  • 341 builds now composite in an order no capture has checked.

SOLVED (2026-08-19) — _eff glows were being dropped as focused states

compose skips focused-state records, and the flag matched a trailing f in the name. _eff — this UI's word for a glow layer — ends in one. 2 458 elements matched; 54 have the base element they would be the focused version of, and the other 2 404 across 864 bundles are glows. Requiring the pair recovers 587 of them in composable builds; GP_OPTIONS went from two floating brackets to an actual window. The opt link was tried as a replacement and refuted — 221 targets, 2 suffix-matches, and the targets include pjnet_bg.rat. See structures/ui-focus-and-effect-elements.md.

SOLVED (2026-08-19) — the developer-logo splash can be rendered

is_build required a .rat layout child; the splash has none (its elements name their sprites directly). New is_composable + opt-in --all on the screen commands. The splash draws 6/7 elements, glows first, in the order measured off the running game — so the second of the two measured paint orders is now checkable instead of merely recorded. See structures/ui-composable-bundles.md.

Opened by those two:

  • .prm primitives are decoded (2026-08-19). Untextured full-screen colour quads: 0 of 369 has a payload child, kind & 0x10.prm with zero exceptions in either direction, 361/369 are exactly 1280×720 at 100 % in the corner, and the fill colour is the keyframe's fade ARGB — mostly black at some alpha, i.e. the fade-to-black / dim / flash layers. See structures/ui-prm-primitives.md. Still not composited, for the reason below.

  • Element::rest() fixed (2026-08-19): the resting pose is the hold — the longest run of consecutive keyframes with an identical pose — not the longest gap. A keyframe is the start of a ramp toward the next one, so a long gap means the screen spends it arriving at the far end. Verified against the title framebuffer capture by edge correlation: plateau 0.4597 at shift (0,0), old rule 0.1511 and only after a (+3,+8) shift. Fixes six title elements that rested invisible and the fade quad that rested opaque black. See structures/ui-resting-pose.md and tools/re-capture/align_to_capture.py.

  • The keyframe fade alpha is applied (2026-08-19). ARGB, multiplied on top of tint. Title composite vs the running-game capture: 0.4597 → 0.9538 edge correlation at zero shift. No-op on 4 060 of 5 200 sprite elements, hides 687 transient HUD indicators, blanks zero builds. It also exposed a defect in the resting rule — a keyframe group carries the screen's exit animation too, and the tie-break was grabbing it, which erased the word PAUSE; a run ending on the last keyframe is now excluded. See structures/ui-resting-pose.md.

  • 🟡 The .prm quads are drawn, opt-in (2026-08-19). ComposeOptions::include_primitives / screen render --primitives. On the title — the one screen with ground truth — it takes mean luminance from +18 % to 1.3 % of the capture (76.30 → 63.72 vs 64.58) and mean abs diff 16.07 → 13.08. Off by default because of the item below.

  • 🟡 Where a primitive paints — not in the file; measured and tabled (2026-08-19). 🔴 Refuted twice over: the declaration entry's four unread words are constant (+28=0, +36=0xffffffff, +56=0, +44 a button ordinal), and the bundle carries no data at all for a primitive — the menu build declares three and has zero RATC children for any of them. The layer comes from the game's code. But it is consistent: pteff02.prm implies a key in (0x8010, 0x8040) on both screens it appears on, pteff00.prm past the maximum on both, palogo_eff0.prm below the minimum. implied_layer_key records those, and derived_paint_order now reproduces the layer-key sequence of all three measured orders, primitives included (element-for- element on 4 of 5 bundle instances; the title differs only inside tied groups). Still open: primitives whose position has never been measured — pzeff00.prm and pceff00.prm are what wipe the 36 builds, which is why include_primitives stays off by default. A capture of any screen carrying one would close it. See structures/ui-prm-primitives.md.

  • 🟡 The tie-break — refuted six ways, and its cost measured (2026-08-19). Elements sharing a layer key: on the menu and splash every tied group comes out in declaration order, which the stable sort already gives. The title is the only screen that discriminates and nothing predicts it (0x8083 ×5 paints eff1, eff2, eff5, eff3, eff4). Refuted: declaration order, RATC child order, first keyframe time, resting time, resting X/Y, and T8aD header words +00 +04 +0c +10. RATC child order is a strict improvement (7 misplaced positions instead of 9, and it recovers the logo grouping) and is exact on the other two screens — not adopted, because on the one screen that can tell them apart it is still wrong. What it costs, exactly: of 3 disagreeing pairs of drawn elements across all three screens, 2 share opaque pixels — ptlogo_back2eff5 vs eff3 (22 568 px) and vs eff4 (32 395 px). The residual is one element's blend on one screen, and it is pinned by a test. The third pair (ptlogo2 vs ptlogo_tm) overlaps by bounding box but shares no opaque pixel; a box test called it a defect and the alpha says otherwise. Fourth and fifth screens measured (2026-08-19), from GP_SAVE_LOAD, reachable now that the Canary threading fix makes the menu dependable. The 9-element slot-list header is EXACT under the derived rule — two tied groups both in declaration order, unkeyed .prm last — and it is the first screen outside GP_TITLE.pak, so it confirms the rule rather than being fitted to it. The 13-element save/load frame differs in exactly the two known ways: unkeyed pfbase.tbm backgrounds paint first (now covered by implied_layer_key), and the 0xb100 group of four paints 10,11,8,12. 🔴 Refuted: the tie-break is not kind. "Descending kind" reproduces 10,11,8,12 exactly but fails both title groups. Seven candidates refuted now. 🔴 Attempted 2026-08-19 and blocked: advancing past the title is intermittent — 1 success in 3 attempts, same binary, same profile, same procedure. And now diagnosed one layer deeper: the title does act on Ⓐ — the press spawns a slot-(1F) loader thread (exactly once per run, at the keydown, never in a run that got no press). In the successful boot that thread immediately reads six paths from the on-disc cache and the menu appears; in a failed boot it starts and issues no file I/O ever. So "the title ignores Ⓐ" is withdrawn — the loader stalls. 🔴 Refuted as the cause: the cache-flush crash. All four of today's runs have zero GUEST-THROW, CRASH DUMP and Access Violation; the guest stays alive and polling. And now measured to the bottom: with kernel logging finally on (LOG_MASK=12 LOG_LEVEL=3 — the scripts' log_mask=13 had Kernel disabled, which is why no boot log ever held a kernel call), a captured failure shows the handler doing everything right — XamUserGetXUID, NtCreateEvent, ExCreateThread(entry=0x821748F0, CREATE_SUSPENDED), NtResumeThread — and the thread then never executing: zero kernel calls of its own, and 00:00:00 host CPU time while the process runs at 546 %. A spinning thread burns CPU; this one never ran. A lost resume is a race, which is the first explanation that fits the ~1-in-3 success rate. LOCATED AND FIXED (canary a60fe7d11): threading_posix.cc publishes a suspended thread's state_ and its suspend_count_ in two separate lock scopes, and Resume() waits only for state_ before testing if (suspend_count_ == 0) return false. A resumer in that gap drops the resume; the thread then waits on the count forever. The Linux XThread::Resume discards the false, so the guest saw success. Fixed by publishing both under one lock and waiting without releasing it. On the first clean boot after, the loader thread is the caller on 20 kernel-call lines with 4 ResolvePath reads — every failure before had zero of both. 🟡 Still to show: that boots now reach the menu reliably. The post-fix boot is confounded — skip_intro.sh's title test has been wrong twice (an absolute pixel against the wrong surface size, then screen_id.py matching the SQUARE ENIX logo). Now tools/re-capture/is_title.py counts the green Ⓐ glyph: 0 px on the logo, 1520 on a real title. A before/after reliability count over several boots is the remaining work. See canary-scripted-input-traps.md.

  • Blend mode. Everything is straight alpha-over. The near-white flash quads (0xf0ffffff) and coloured ones (0x60ff0000) may be additive. The title capture cannot separate the two — its resting elements are all 0xffffff. A screen with a coloured primitive, captured, would.

  • 🔴 What marks a focused state in the file — NOT the declaration entry (2026-08-24). Swept disc-wide and asserted: 54 name-paired focused/base pairs, all 54 with identical kind (all 0x0), no bit ever set on the focused entry and clear on its base, and the only words of the 60-byte entry that ever differ are +48/+52, the pivot. The naming pairing is not standing in for a field — there is no field. Still open: the .rat record, the RATC child stream, or the game's code. See structures/ui-rat-layout.md.

  • 🔴 What makes a bundle a screen rather than a fragment — not the header (2026-08-24). Swept over all 2 859 composable bundles: no bit of the flags word at +0x10 labels a screen (best is bit 13 at 44 % full-screen against a 12.8 % base; the commonest bit is set on 91 % of everything). The population really is mostly fragments — element counts min 1, median 2, p95 23, max 56, and only 365 carry a full-screen element — so the separation is shape, or which bundle references which, and the PAK cannot answer the latter directly because its entries are name-hashed. By-product: the header is not dead space. +0x18/+0x1c are the design resolution (1280/720 on 98.7 %, asserted), and +0x08 is the animation length — checked against the keyframe times, which it bounds in 2 313 of 2 313 bundles and is attained by 444, with the ratio peaking at 1.0 rather than near zero (that histogram is what rules out a vacuous bound). 🟡 +0x04 (0x3C0000/0x1E0000 = 60.0/30.0 in 16.16) stays amber: the only supporting evidence is that the twelve 30.0 bundles cap at +0x08=30 while the 2 843 60.0 ones reach 1 440. +0x0c is two u16s forming an ordered intervalhigh < low in 2 985/2 985, both bounded by the animation length; it is authored, not the keyframe min/max — that reading holds in 6 of 2 985 (0.2 %), and the apparent 34 % match on high is a coincidence of zeros. The window is narrow, a median 2 % of the keyframe span. 🟡 what the window means stays open. +0x10 is a zero u16 plus a 16-bit flag word at +0x12 (high half zero in 2 985/2 985); the bit meanings, with four measurable predicates now excluded. See structures/ui-rat-layout.md.

  • 🟡 What opt links — a record→record reference (2026-08-24, measured disc-wide and asserted). All 1 467 links reachable from a declaration table resolve to a RATC child of their own bundle, all are .rat → .rat, none dangle, none self-link. 1 076 (73 %) are the <stem>f focus pattern; the rest are chains between effect records (px_bunk_eff01 → pjex_eff → pjex_eff07), which is also why only 227 targets are declared elements — the middle of a chain is, the end is not. So focus is the commonest use, not the meaning. ⚠️ Coverage: 18 718 raw opt tags exist against 1 467 classified — opt_link reads the first tag of a declared element's record, so ~92 % of occurrences sit deeper in the chains and are untested.

The investigation that got here follows, kept in full because most of it is refutations that were worth the cost.

Where the wave / spawn scheduler lives — the counter is not a roster

Raised by the user 2026-08-24, and it reframes the whole REMAINING OB line of work. A mission does not have a fixed enemy count: the number rises as waves arrive and falls as they are destroyed. So the thing to find is not a list of objectives but an algorithm with parameters — what spawns, where, when, and on what trigger. That also explains every negative so far: a per-entity flag search cannot find a schedule.

Places searched so far:

  • REGN is EXCLUDED as the wave scheduler (2026-08-26) — it is a tetrahedral navigation mesh (vertices, faces with planes and adjacency, tetrahedra with portal costs, a grid indexing which tets fall in each cell). Every section is accounted for; there is no time field, no unit reference and no trigger anywhere in it. It was chased here because "a scheduler would be indexed by a grid" — a guess from shape, and the shape was pathfinding.

  • hidden/MiscBin.pak — new to the corpus. 11 REGN + 11 MCOL objects, none name-resolved. REGN's header is now decoded: a per-map uniform spatial grid (bbox, cell size, 5³ or 10³ cells), self-checked 11/11. Its four data sections are unread — the 49 KB object is the cheapest way in. Section 3 decoded: one 8-byte (count, offset) record per cell followed by 32-byte payload records, payload at align16(index end)11 of 11, one record per occupied cell. And the container is a serialised object graph: every object carries a POF0 pointer-fixup table at exactly header[0x04] + 16 (11 of 11), which is why the internal offsets are absolute file offsets. 🔴 Two payload readings refuted by generalising from the one object they were fitted to (a "bounding-sphere radius" float, and "leaf arrays of count × 4 bytes"). The payload is float-dominated and otherwise . 2026-08-26: the other three sections have strides — 12 / 96 / 48 bytes, with counts[0..2] as their record counts (section 1's remainder is exactly 0 and section 2's exactly 96, in 11/11). Section 0 is a point list (13 467/13 467 inside the bbox) and section 2 is a plane list — unit normal, signed distance, and a point, with n·p + d = 0 to float round-off in 133 573/133 573. Section 1 (96 B) still in meaning, though its slot regions are censused. My static coupling search found nothing above chance — every index-shaped field followed into every section, against controls — and I concluded it needed the PE code rather than more correlation. SOLVED on branch auto/regn-reader (2026-08-26). REGN is a tetrahedral navigation mesh. The route in was the POF0 fixup table: it is the loader's own list of which words are pointers, so nothing had to be guessed. There are six sections, and the chain is position → cell → 32-byte item → tet refs → tetrahedron; section 2 is a face carrying a plane plus its 3 vertices and the two tetrahedra either side. Checked with controls: each face passes through exactly 3 of its tet's 4 vertices, 253 722/253 722, against a 0.072.2 % random-face control; portal cost equals the distance between face centroids, 380 460/380 460. ⚠️ The base is chunk + 0x10, and my offsets on this page were 16 bytes early — the plane fields happened to land on the same bytes, so that arithmetic survives, but the record boundary moves. My "13 467/13 467 points inside the bbox" was not evidence: a shift inside a homogeneous f32 array yields other floats from the same array, so the test passes either way. See structures/regn-map-grid.md. Still open there: the runtime consumer (not reached; a float4-aligned header read with VMX loads leaves no displacement signature to search for). MCOL is the collision sibling, and its broad phase is decoded (2026-08-26). Same container, same 11 maps, same cell size. Chain: position → cell → A record → B record {count, u16[n]} → 16-byte bounding sphere. The 0x5C block is stride 16 {centre f32[3], radius f32}, not the 12-byte points I had assumed — len(0x5C) is not even a multiple of 12 in 5 of 11 objects, and max u16 == len/16 1 in 11/11. Powered check: the referenced sphere reaches the cell that reached it, 18 559/18 577 = 99.90 % against a 12.02 % random-sphere control, and ablating either the centre or the radius costs most of the signal. Reproduce with tools/re-capture/mcol_probe.py verify. ⚠️ The stale ≈0.75× ratio I had recorded as "too consistent to be coincidence" was 12/16 — my own wrong stride, not a fact about the data. MCOL SOLVED the same day — it is a closed triangle collision mesh. The 0x50 word is two u16 counts (vertices, triangles), which gives the last two blocks their stride: len(0x54) == align16(12·nv) and len(0x58) == align16(6·nt) in 11/11, and nt == the sphere count in 11/11. Sphere i is the tight bounding sphere of triangle i4 768/4 768, max‖vc‖/r median 0.99990 (a fixed 1.0001 epsilon), against a 1.32 % random-triangle control — and the mesh is watertight: every edge shared by exactly two triangles, 7 152/7 152, zero degenerates, zero orphan vertices. The two smallest objects are 8 vertices / 12 triangles = the map's bounding cube. The cell lists are a correct broad phase: only 3 overlapping triangles in 18 577 entries are missing, and the 730 conservative extras place the builder's test between exact-SAT and AABB — which also explains the 18 "sphere misses" above as that same margin. tools/re-capture/mcol_probe.py verify reproduces it (data/mcol-verify.txt); mcol_probe.py obj exports any object as a Wavefront OBJ. And all 40 MiscBin entries are now name-resolved (2026-08-26). The names are the MapPath / MapMesh / CollisionMeshes values of the per-stage StageResource object (IDXD schema 3c9ae32e, in every GP_MAIN_GAME_<lang>.pak), each hashing with the ordinary pak name_hash straight to a TOC entry — 40/40, no collisions: 11 <stem>.rgn, 11 <stem>.col, and the 18 remaining blobs as CollisionSet_S01…S16 / _Tutorial / _test.bin. The .pe string table at 651 540 was the way in. This upgrades the pairing from "matching bbox/cell-size distributions" to an object-to-object link: a phase names one .rgn and one .col, and 11/11 share a stem and agree exactly on bbox and cell size. It also checks the format work from outside: mapmesh_box_500km.col is the object decoded as 8 vertices / 12 triangles spanning exactly ±250 000, and its name says 500 km — so one world unit is one metre. 70 of the 87 phases use that bare box; _AsteroidVolume_ names the rest. tools/re-capture/miscbin_names.py <root> [--pairs] (data/miscbin-names.txt). CollisionSet_*.bin decoded (2026-08-26) — the per-object library. All 18 are byte-identical, so the per-stage naming is nominal: one shared 1 675 148-byte library stored eighteen times. Record = {u32 size, u32 name_len, char name[], u32 nv, u32 nt, f32[3]×nv, u32[3]×nt} (note u32 indices, where MCOL uses u16), next at off + 8 + size. The walk consumes the file exactly over 158 variable-length records with the size word predicted from the counts 158/158, all indices in range 158/158, and 98.24 % of edges shared by two triangles (147/158 fully manifold). 158 meshes / 90 836 triangles: rou_/mob_/rob_ per-part ship proxies (_bdy/_brg/_eng/_wep/_sld) plus 46 stage asteroids. ⚠️ This downgraded the "1 unit = 1 m" claim to 🟡 — it implies a craft the tables call small is 133 m and the largest object 447 km (89 % of the arena). The _cmesh↔model link is now CONFIRMED (upgraded from 🟡 the same day): the right corpus was the GameResourceID field, not the .xbg manifests — 108/112 ship/mob stems are prefixed by one of the 480 resource ids, against a 0/112 shuffled-character control, and correctly 0/46 on asteroids. A CollisionSet entry is <GameResourceID>[_<part>]_cmesh. 🟡 The world unit needs a run — it is NOT blocked (label corrected 2026-08-26). Sweeping every pak for a km-bearing name returns mapmesh_box_500km and nothing else (162 refs, all that one pair), so no static test can settle it; that is not the same as blocked, and 🔴 is for what the container cannot do. The experiment: findplayer.py gives the player and target position triples in world units, the HUD prints the distance between them in the game's own units, and the ratio is the conversion. All of run-canary / pad.py / screenshot / findplayer.py already exist. tools/re-capture/collisionset.py verify|list|obj (data/collisionset.txt). Still open: no material/surface-type field exists in the record, and the library is not indexed — name lookup from the unit tables is untraced. The runtime consumer now has a name — CMapColliderBridge in the .pe RTTI at 9 044 264 — but has not been followed into the code. See structures/mcol-collision.md.

  • 🔴 hidden/DefTables.pak is NOT it (checked 2026-08-24). The three unnamed schemas are more model/render tables in the same vocabulary as the named ones — 7e66225f (283 objects) carries RenderFrameModel, FrameAnimLength1st, FrameAnimLoopLength, IsPlayerSE; 634a80ae (183) adds DissolveDistanceMin/Max, RootSEAttach, EnumNodes; a6d68fa1 (138) has no explicit fields at all (defaults only).

  • 🔴 The 40 XML entries are XPR2 build manifests with the developers' own source tree — machines\rou_e104\objects\*.xbg, …\images\*.bmp, D3DFMT_DXT1_SRGB. Tempting as a name source for the unresolved hashes, and it does not work: of 82 names × 9 patterns, the only hits are 16 that were already resolved under the known LOD_Frame_*/LOD_Parts_* convention, and the raw source paths hash to nothing in any of the 16 514 TOC entries. The on-disc keys are not the source paths.

  • 🔴 STAGES = Static.slb is a false lead. It is a field of the sound table; .slb is an XACT sound bank (structures/sound-slb.md).

  • FOUND — it is the GP_MAIN_GAME_<lang>.pak unnamed objects. Sweeping the 811 unnamed entries by schema turned up schema 3c9ae32e, the per-stage definition record: one per stage (Stage_S01.xprStage_S29.xpr), naming that stage's background, resource package, collision set, message set, nameplates, MapMesh/MapPath, and — the point — its EnumerateSquadron = UnitGroup_S<NN>.tbl. stage\UnitGroup_S02.tbl (0x019fd129) is the Stage 02 roster: 112 records, 112 squadron IDs (TCN001, ADN101, ADT102, …), and a field vocabulary of FormationID / AIID / SideID / Count / DisableInterval plus the unit model (UN_e010_ADAN_Attacker_S, matching our XBG7 names), the MessageSet_*, and the pilot character. DisableInterval is the first direct evidence of the timing knob. Same pass also settled MapPath = test.rgn → the REGN objects. See structures/stage-definition-table.md. 🔴 Refuted along the way: the 16-byte record key is not the squadron ID's name hash (0 of 112).

  • UnitGroup is fully decoded (2026-08-24). Container and field semantics in structures/unit-group-table.md, tool tools/re-capture/unitgroup.py, Stage 02 dump committed at data/unitgroup-s02.txt. A squadron record is Count member tuples — (unit model, message set, n, identity/nameplate) — followed by five named fields Count / SideID / AIID / FormationID / DisableInterval. Validated corpus-wide by two independent self-checks, each 1160/1160 across all 28 stage tables: the Count*4+5 length identity, and agreement with the file's own Enumerate_Squadrons roster. 🔴 Two refutations recorded: the record key is not the squadron-ID name hash, and an earlier "109/111" reading of the squadron-id string base was an artefact of the uniform 7-byte id stride (it shifted every name by three).

  • The rest of the mission-parameter layer is decoded (2026-08-24) — structures/stage-mission-tables.md, tool tools/re-capture/stagetbl.py --follow S02. The real stage record (the one dumped earlier was the _Test template) splits a stage into Phase_1..3 blocks and names Route_S<NN>.tbl, SUBObjectiveSettings_S<NN>.tbl, AIParams_S<NN>.tbl, FormationSet_S<NN>.tbl, nameplate_S<NN>.tbl and more. Route_S<NN>.tbl is the arrival schedule: records named Route_<squadron>_p<phase><kind> holding time-stamped keyframes (time, quat x4, pos x3), tying a UnitGroup squadron to a phase and a path — self-checked FrameCount*8+1 on 1449/1449 route records across 28 stages. AIParams carries directly portable combat tuning (firing/guard/muster ranges and 14 manoeuvre weights per profile). 🔴 Refuted: the 8-value frame is not universal — Formation_Fleet_01/02 use 136 and 4 values per frame.

  • 🔴 The arrival timetable is found; the entity-count proxy is refuted (2026-08-24) — mission-wave-arrivals.md. Route records' first-keyframe time is the arrival schedule, measured from the start of that route's phase (phase 2 has all 37 routes at t=0). Stage 02 phase 1: 25 at t=0, then 3/3/3/2/1 at t=90/120/170/210/240. 🔴 A live count of 0x820af030 entity records is FLAT at 116 for 210 s — no arrivals visible. 2026-08-26: the flat count is explained, not merely refuted. Built-in 12 activate_unit returns immediately when the live object is NULL — it registers an existing object and cannot spawn one, so arrivals are route positions, not new records. Stage 02's roster totals 116, and 116 is the maximum across all 28 stages and unique to Stage 02, so the match is not a common-number coincidence. 🟡 still n=1 for the per-member identity. This cannot settle timetable-vs-event — a position probe is needed, not a count. Settled 2026-08-26: t is SECONDS. Consecutive route keyframes give an implied speed; checked against the live-measured player ceiling (~1530 world units/s), 0 of 1 104 pairs exceed it under seconds while 8994 % do under frames at 30/60 fps. See mission-wave-arrivals.md.

  • 🟡 Motion-independent liveness probe works; n is probably craft-per-member (2026-08-24) — mission-liveness-probe.md. Enumerating by definition pointer instead of by motion removes the ±10 noise (monotone 298→280). The hunting pilot does kill (hull crossing at t=57s). 🔴 "No births in 164 s" does NOT separate the wave models — everything is pre-allocated, so an arrival must be a STATE CHANGE, not an allocation. 🔴 n = craft-per-member is WITHDRAWN (re-tested 2026-08-24): sites really are entities 1:1 (all same-unit gaps ≥0x1000, hull plausible 298/298), so the confound was not the explanation — and with it gone, DeltaSaber_T, Player and Acropolis all come out at exactly 2× sum(n). An undershoot is explainable by phases; an overshoot is not. n back to . Formation slot count also rejected (630 turret slots vs 214 live). Side result: FormationSet FrameCount = slot count. 🔴 A direct pointer link between them is REFUTED (2026-08-24, roster-to-craft-link.md): 0/116 roster records point at a craft base and 0/300 craft point at a roster base. They sit in distinct regions (0xbc372c000xbc9bc720 vs 0xbdb2fd800xbdcd1d80). Craft count varies run to run (296/298/300), so it must only be compared WITHIN a run. Still open: the expansion rule. Four candidates now dead — Count, n, formation slots, head pointers. SOLVED (2026-08-24): the link is a pointer at roster_base + 0x08. The delta histogram spiked at +0x08 with exactly 300 hits for 300 craft. Verified on something a coincidence cannot survive — each side's unit type resolved by a different chain (craft via def pointer +0x130, record via +0x04 name chain) — agreeing 300/300, 0 disagreements, fan-out [(1,300)]. Fan-in: only 41 of 116 records have craft (2×24, 4×1, 8×4, 18×12 = 300). ⇒ an arrival is craft appearing for a record that had none; a kill is that count decreasing — both attributable to a named squadron, hence to a route. Blocker downgraded (2026-08-24): the baseline DOES reproduce — two more runs give 116 roster records flat from t=0 (not a load race) with craft declining 300→288 and 296→280, so losses are observable. Raw hits == distinct VAs (116 == 116), so the VA-aliasing explanation is 🔴 refuted too. 🟡 The single 42/170 run stays unexplained; rule adopted: discard a run that disagrees with 116 rather than interpreting it, and reproduce any finding in ≥2 runs.

  • 🔴 Six runs, no arrival — plus an accidental control (mission-arrival-watch.md, 2026-08-24). Deployment reproduces byte-for-byte (116 records, 300 craft, 41 deployed, strengths 2×24/4×1/8×4/18×12). Losses require the player: an unpiloted run held at exactly 300 craft for 240 s / 22 samples, vs 1620 losses in each piloted run — so NPC crossfire destroys nothing on its own. 🔴 Zero 0→n arrivals in either condition, ~15 min cumulative. 🔴 Prime suspect REFUTED (2026-08-24): the clock is runningmission-clock-advances.md. With no pilot, 286 heap words advance linearly, a large cluster in lockstep at 16.5/s, which is the emulator's known ~1419 fps. 🟡 New leading explanation: the runs were far too short in GAME time — at ~55% of wall-clock, the longest 240 s run reached only t≈132, past the t=90/120 route entries but nowhere near t=170/210/240. 🔴 Long run done (2026-08-24): per-record tracking works — watched ONE turret squadron fall 18→14→12→10→8→6→4→2 over seven loss events while deployed held at 41. 🔴 Still no arrival at 234 s wall (≈129 game-s), past both t=90 and t=120, so the "too short" explanation no longer covers those (it still covers t=170/210/240; the run was cut at 240 s by the turn timeout, not the planned 330 s). 🟡 Sharper hypothesis: the squadron ended at 2, never 0 — no squadron has ever been eliminated in any run, so the trigger may be elimination, not damage. Elimination test unrun (2026-08-24)superseded below by the SYLPH_KEEPOUT=1400 run that refuted it; and 🔴 was the wrong marker, an unfinished run is , not blocked: the hunting pilot died at t=83 s with the squadron at 14. But the player's own record hit 2→0 — first n→0 ever seen, so the signal does register elimination (no arrival followed; weak, it was the player not an enemy squadron). KEY: after player death the mission is frozen — 288 craft, zero losses, zero arrivals for 220 s / 18 samples. The usable window is player survival, not probe duration. 🔴 Harness bug found+fixed: a sed had stripped the probe args, so every derived probe ran on defaults — the previous "cut by the turn timeout" claim was wrong (it hit its own 240 s default). 🔴 DONE + REFUTED (2026-08-24): SYLPH_KEEPOUT=1400 gives a pilot that kills and survives (hull 1500, asset 100 %, 8 losses). An enemy squadron was eliminatede007 Turret 2→0 at t=163 s, deployed 41→40, first ever — and no arrival followed in ~90 s of valid observation (two further losses in that window prove the mission was live). ⇒ "a wave is released when a squadron is wiped out" is refuted; event-gating generally is not (thresholds, objectives, specific squadrons untested). 🔴 Guest stalled at ~t=255 s — last 400 pilot lines have 1 distinct speed vs 236 in the first 400 — so trailing flat samples in ANY earlier run may be a frozen guest, not a quiet mission. Probe now carries a frame-rate tick witness printing *** GUEST STALLED ***.

  • 🔴 The stall witness WORKS and is now mandatory (guest-stalls.md, 2026-08-24). Validated against the pilot's independent telemetry (35 distinct speeds all run, 1 in the last 400 lines; witness flagged t=27 s). It immediately discarded a run that would otherwise have read as "no arrivals across 313 s" — worthless, the game was frozen for 90 % of it. Rule: a stalled run is discarded, and every write-up must state the witness result. 🔴 Stalls are frequent and early — last three long runs stalled at ~255 s, 83 s and ~27 s. 🟡 Leading suspect: the probe itself — it reads the whole 32 MB heap plus ~300 preads every 12 s while lavapipe renders, and AGENT.md warns a full scan competes for every core. CONFIRMED (2026-08-24): the probe was causing the stalls. The no-probe control ran 300 s clean — 211/257/250/241/200 distinct speeds per 60 s window, no decline — against probed runs stalling at 27/83/255 s and ending on 1 distinct speed. The instrument was degrading what it measured, exactly as AGENT.md warned. Control is n=1: acted on, not proven. ⚠️ Consequence: every "no arrival" result was collected under a struggling or frozen guest — none is as strong as written, and the arrival question needs re-running with cheap sampling before silence means anything. Fix VERIFIED (2026-08-24): wave7_probe.py (enumerate once, then poll only the hull word per known craft, ~1.2 KB/sample, rescan every 90 s) ran 0 stalled samples and produced 19 losses vs 8 — starving the emulator had been suppressing the activity the probe existed to watch. 🔴 Its one apparent arrival (0→2 at t=259 s) is FLICKER, not a wave — it reverted to 0 fifteen seconds later, and the same log shows a record reading 13 then 14 with no event printed because only decreases were surfaced. Count stands at 0 confirmed arrivals in 11 runs. Probe now prints every increase and requires an arrival to persist across 2 samples.

  • 🔴 Run 12 (2026-08-24): persistence rule works; stall witness does not. 16 losses / 290 s with a bound pilot, 0 confirmed arrivals (twelve runs now). One increase surfaced (13→15) and correctly not counted — exactly the flicker that nearly became "first arrival". 🔴 The single-word tick witness gives FALSE POSITIVES: 13 samples flagged GUEST STALLED while recording losses in the same samples, which a frozen guest cannot do. Cause: it took the first word in a 4 MB window with a plausible rate; intermittent counters pass that test. timer_probe had already solved this (286 candidates, modal rate ~17/s) and the lesson was not carried over. Fixed to a majority vote over the modal-rate cluster, plus explicit RUN UNVALIDATED when no witness exists.

  • 🔴 Run 13 (2026-08-24): the vote is better, the threshold was wrong, and freezes persist. Graded output (11→9→7→4→1→0 of 31) is coherent, and the drop to 0/31 at t=183 s coincides exactly with the last loss — a real freeze, 106 s of nothing after. 🔴 But "<half = stalled" marked the WHOLE run stalled including samples where craft died: 11/31 advancing is healthy. Cause: the modal cluster was 93/s, not the ~16.5/s frame rate — bursty subsystem counters. Fixed: prefer the 840/s frame-rate band, stall only when ZERO advance.

  • 🔴 Witness attempt 3 still unreliable (2026-08-24): only 5 candidates in band, and 0/5 moved in samples where craft died — a real 24/s counter cannot miss 15 s. Cause was mine: the candidate search had been narrowed to 4 MB when the witness was bolted in, while timer_probe searched the full 32 MB and found 286 with a clean ~17/s cluster. Fixed: full-region search once at startup + warn when <8 witnesses.

  • WITNESS VALIDATED (2026-08-24, 4th attempt): full-region search gives 6500 candidates → 32 witnesses at 21/s, and the flags have zero contradictions with the loss data (the one loss is unflagged; every flagged sample has no losses). That is the check the three previous versions failed.

  • ROOT-CAUSED 2026-08-26 — see mission-freeze-heap-exhaustion.md. The freeze is a guest 128 MB physical allocation being refused (MmAllocatePhysicalMemoryEx, parent free 28969/131072 pages ≈ 113 MB) after which the guest throws a C++ exception and stops; the emulator keeps spinning at 399 % CPU, which is why it reads as a hang. Independent of the cleared-stage mask poke and of the stage — a SYLPH_NO_POKE=1 control on Stage 01 froze with the identical free-page count as the poked Stage 02 run. This also refutes challenge-mission-gate.md §5.6's "poking only real story ids does not blow the heap".

  • 🔴 The freeze is now THE blocker. Onset across runs: 27, 45, 83, 183, 255 s (median ~83 s) — usable window is 14 min and unpredictable. This run froze at 45 s with 1 loss, useless for arrivals despite a working instrument. ⚠️ Being honest: the "cheap" probe still reads 32 MB at startup + 32 MB every 90 s, and the new calibration added two more 32 MB reads — the 45 s freeze came right after it. 🔴 The trim BROKE the witness (17 candidates, 0/17 on every sample of a run with 13 losses) — reverted; two 32 MB reads once at startup is the price of a working witness. 🟡 Instead, disabling the periodic rescan LARGELY removes the freeze — corrected from "removes" after more runs: clean at 210/240/300 s but frozen at 60 s on a fourth. Tally — heavy probe froze at 27/45/83/183/255 s; cheap+rescan at 183 s; cheap, no rescan: 3 of 4 clean past 200 s. A large probability improvement, not a fix. Practical rule: run, check the witness, discard frozen runs (3 clean of 5 now: clean 210/240/300, frozen 60/90).

  • BLOCKER REMOVED (2026-08-24): the emulator survives BETWEEN Bash calls in a turn (checked: 611 s elapsed, still running after the launching call returned), so observation is not capped by one call. tools/re-capture/attach_session.sh attaches pilot+probe to a live mission and attaches CHAIN. Cumulative 435 s verified-live on one mission (300 s clean + 135 s of an attach before it froze), craft 300→258 (42 destroyed), 0 confirmed arrivals — covering route entries t=90/120/170/210; t=240 missed by one second. 🟡 Coverage assumes the 55 % wall→game factor; the witness has measured 824/s across runs, and at the low end the same window covers only ~117 game-s (t=90 only). Pinning the tick rate would make this sharp.

  • 🟡 (2026-08-24) The 42-anomaly is STAGE 01, and it hands us two results. Rescans stuck at 42 (not a load race); live RAM holds UN_S01_Asteroid_cmesh_* and UnitGroup_S01.tbl with S02 absent — the launch sometimes loads Stage 01 (the discard rule caught every such run). That supplies the refutation test recorded as blocked for lack of a second stage: S01 static 42 members / 13 units vs live 42 records / 13 definitions, composition 20/20, 6/6, 5/5, 2/2 — one record per member now holds on two independent stages. 🟡 Route times are almost certainly FRAMES: S01 phase 2 has t=1500/1800/2100, which as seconds is 2535 min into one phase (implausible) but at 30 Hz is 50/60/70 s. ⇒ S02's t=90…240 would be 38 seconds, all before the probe's first sample (~25 s in) — explaining every null result, and consistent with deployed=41 already at t=0. Test: sample at flight+2 s vs flight+30 s — a reordering of the probe, not new decoding.

  • 🔴 (2026-08-24) Early sampling done; still flat. early_probe.py cuts setup from ~25 s to 0.50.8 s (no calibration, no labelling, bytes.find scan). At flight+0.8 s deployed=41 already, flat for 252 s (only 41→40, one squadron wiped). 🔴 The --wait variant caught the READY ROOM, not flight — the roster is built before take-off, so waiting for it is not catching mission start; that window (deployed=39, craft=276, flat 200 s) tests nothing. 🟡 But ready room 39/276 vs flight 41/300 suggests deployment is a single step at TAKE-OFF, not a schedule unfolding in-mission (different runs, so suggestive only). Next: one ~400 s run spanning ready room → take-off → flight to catch the 39→41 step in one continuous series.

  • 🔴 (2026-08-24) DEPLOYMENT IS RESOLVED AT MISSION LOAD. One continuous series, 64 samples over ~380 s from roster-appearance through the ready room, take-off (IN FLIGHT at +47 s) and flight: deployed=41, craft=292, zero changes throughout. 🔴 My own "deployment happens at take-off" is withdrawn — the 39/276 vs 41/300 gap was cross-run variance; within one run there is no step. ⇒ Stage 02 phase 1 has NO observable in-mission arrival: 41 of 116 records are deployed before the ready room ends, and nothing changes in the ready room, at take-off, or in up to 435 s of verified-live flight with 42 kills. Route times most likely encode fly-in animation timing applied at load, not release times (frames reading: S01's t=2100 → 70 s at 30 Hz). Phases 2 and 3 are entirely untested — every run has stayed in phase 1, and phase advance was never located; a phase transition is now the most likely place an arrival could exist.

  • 🟡 (2026-08-24) ANSWER: enemies come into play PER PHASE, deployed at phase startmission-phase-deployment.md. Static: phase 1 = 37 squadrons / 42 members, phase 2 = 36, phase 3 = 49. Dynamic: deployed = 41 from the first instant, never changing. 41 vs 42 — the deployed set IS the phase-1 roster. That makes everything coherent: all 116 records exist at load but only the current phase holds craft; no arrival was ever seen because phase 1 never completed; route times are within-phase fly-in timings, not release times. ⚠️ The off-by-one is unexplained and recorded, not rounded away. Single remaining question: what advances a phase. It is directly observable (deployed would jump), did not happen in 435 s with 42 kills, and SUBObjectiveSettings names 9 objectives but carries no trigger. Unsearched: StageMessageSet_S02.tbl (never resolved) and the PE in guest RAM.

  • 🔴 (2026-08-24) Message tables searched; trigger NOT there. StageMessageSet_S02.tbl resolved — prefix is message\, not stage\ (closes a long-standing ). message\UnitMessageSet_S02.tbl has CrewCount + PresetMessage_Phase1/2/3 — a THIRD independent table family organised around phases, after the stage record and route names. 🔴 Refuted: ScriptMessage_S02_msg.tbl's promising third field is a radio delivery category (None 105, Emergency 33, Killed 5, Noise 5; arg is 1 in every record), not mission control flow. No table anywhere carries a phase-advance condition — everything found is a consequence of the phase, never its cause. ⇒ the logic is in code, and default.xex is encrypted on disc, so the decrypted image exists only in guest RAM. That is the honest end of the static search.

  • 🟡 (2026-08-24) The mission script is READABLE — and phase 1 never advanced because the pilot killed the wrong things. mission-objectives-text.md. language\*_local_string.tbl is IXUD, UTF-16-BE (prefix language\, a third convention) — that encoding is why earlier dumps looked like garbage. Stage 02's nine sub-objectives in words ("You destroyed all enemy fighters!", "You sunk all enemy warships!", "You destroyed all enemy cruise missiles!"). Phases confirmed twice over: the guide script is three acts — marked attackers → warship engine/weapons/shield → missiles — matching the per-phase rosters, with phase 3's UN_e201_ADAN_ISCMissile ×9 exactly matching SUBOBJ_013. 🟡 Phase-1 objective is stated outright: destroy the MARKED ATTACKERS (UN_e010_ADAN_Attacker_S, 4 squadrons) — but every loss line in every run reads UN_e007_ADAN_Turret, because SYLPH_HUNT/KEEPOUT were built for turrets. 42 kills were the wrong 42. Test: a pilot that prioritises e010 over e007, watching deployed jump from 41.

  • 🚧 (2026-08-24) SYLPH_PREFER works; the blocker is now COMBAT, not RE. Preference knob added (matching units ×0.05, others ×4.0). One clean 320 s run (0 stalls): 8 turret kills vs 2 e010 — real effect (was ~1 across ALL prior runs) but weak; commitment + proximity keep pulling back to turrets, which outnumber attackers 108:16 in craft. deployed stayed 41, no advance. 🚧 Quantified blocker: phase 1 fields 16 Attacker_S craft; at 2 per 320 s clearing them needs ~2560 s ≈ 43 min of verified-live flight across many chained attaches, against a ~2-in-5 freeze rate. Everything needed to OBSERVE the advance is built and validated — roster link, liveness, stall witness, chained attaches, discard rule. What is missing is a pilot good enough to finish the objective. User's choice: (1) invest in the pilot (game-playing, uncertain); (2) accept the static answer (structure, rosters, routes, objective texts all decoded and cross-confirmed; only the trigger is inferred); (3) one 40+ min chained run betting against freezes.

  • (2026-08-24) THE OBJECTIVES, FROM THE GAME'S OWN TEXT — pinned by tests/phase_objectives_disc.rs; TextIndex::objectives(stage, phase) already existed. S02 phase 1: "Shoot down all invading enemy fighters while watching out for attacks on the ACROPOLIS" (lose: ACROPOLIS sunk). Phase 2: "Protect the CALIBAN until it has entered the safe zone"positional, not a kill count, so phase triggers are NOT all the same kind. Phase 3: "Destroy the interstellar cruise missiles headed for Schlos Base" (matches the 9 ISCMissile + SUBOBJ_013). [OB] = objective marker — hints say "Red mission markers [OB] indicate your targets", so REMAINING OB at 0xbdb59668 is phase-1 progress and is the right signal to watch, not deployed. Closes a loop to the first session. 🔴 Method lesson: the crate already knew this — several iterations reconstructed it the hard way; grep -rl TextIndex crates/ would have saved the detour. Minor, resolved 2026-08-26: strings are UTF-16BE (idxd-container, 1 104/1 104 objects), so ixud.rs is right and localization.rs is the wrong one — its header says UTF-16LE and it decodes with u16::from_le_bytes, so it is a code bug, not a stale comment.

  • 🔴 (2026-08-24) REMAINING OB hunt: method works, run unfinished (remaining-ob-hunt.md). Correlate heap words against named kill events instead of scanning for a value: one e010 event cut ~8 M words to 1056. 🔴 But the probe saved candidates only at the END and the turn timeout discarded them — the exact mistake already recorded in guest-stalls.md four iterations earlier. 🔴 And the follow-up attach logged 535 s of zero losses with no stall witness, so it cannot say whether the guest was quiet or frozen. Both fixed (save per event + SYLPH_OB_RESUME=1 resume; witness carried over). Pattern worth acting on: each new probe starts from scratch and re-earns the same lessons — a shared probe harness would stick where written-down lessons have not. Needs 23 e010 events, i.e. the same combat limit. 🔴 SYLPH_HZ=3 refuted as a lever: it gave the LOWEST frame rate (8/s) with the most kills, so pilot polling is not the throttle.

  • 🔴 (2026-08-24) OB hunt, second attempt. Incremental saving verified — one e010 event at t=241 s wrote 1187 candidates to disk before the turn timeout fired; session now clears the file at launch since offsets are only valid within one emulator instance. 🔴 The correlation had no value filter: survivors were float bit patterns (1044450858 ≈ 0.1f) whose integer forms differed by the loss count. Fixed — candidates must be small non-negative integers (0 ≤ v < 1000) in both samples. 🔴 The attach was FROZEN, not merely unproductive: 25 of 26 samples flagged GUEST STALLED. The witness worked; the summary just quoted "0 events" before checking it. Rule: read the witness FIRST, before interpreting what a run showed. Still no address; needs 23 e010 events in non-stalled samples.

  • 🔴 (2026-08-24) OB hunt: method PROVEN, turret tracking REFUTED. With the value filter, an attach watching frequent turret losses narrowed 374 → 2 → 0 across three events — exactly how a correlation search should behave, ending in a refutation rather than fizzling: no plain u32 in 0xBD0000000xBE000000 decrements with turret kills. Witness checked first: 12/25 samples stalled but all four events fall in the early non-stalled stretch. 🟡 The negative fits the objective text — phase 1 asks for fighters, so a marked-target counter should ignore turrets; it also rules out REMAINING OB being a general kill tally (it ignored ten turret deaths). Still needs 23 e010 events; one clean 220 s run produced zero.

  • 🔴 (2026-08-24) Kill-free HUD route works; BE-u32 assumption REFUTED. ob_by_hud.py reads the counter off screen (ob_read.py) and intersects heap words equal to it — no kills needed. Four readings at HUD=4 narrowed 6156→4312, then HUD read 11 and the intersection collapsed to 0. ⇒ within 0xBD0000000xBE000000 as big-endian u32 the counter does not exist; it may be u16/u8/LE or outside the region. Both previous hunts assumed BE-u32, so this eliminates the assumption rather than just failing. 🟡 The value went UP, 4→11, which a pure countdown should not do — candidates: wrong HUD cell, misread digits (template strip covers only 0 1 2 4 8; most samples read 00?/???), or a counter that can rise. Next: widen the scan to u16/u8 and LE, and beyond the heap — one function, no combat cost. Also extend ob_digits.png.

  • 🔴 (2026-08-24) Widened to 7 encodings; run inconclusive. ob_by_hud.py now keeps a candidate set per encoding (u32be/le, u16be/le at both alignments, u8) as byte offsets. u32le is tightest at 154 vs u32be's 4452 — a hint, not a result. 🔴 HUD read 4 at every sample, so nothing collapsed; and counts were byte-identical across five samples in all seven encodings from t=136 s, which is what a freeze looks like. The probe had no witness — now added. ⚠️ Fourth probe written without one, third flat run that cannot be told from a freeze; the recurring fix is the shared harness noted earlier. Needs two HUD readings at different values in non-stalled samples.

  • (2026-08-25) SHARED PROBE HARNESS built and verified (probe-harness.md, tools/re-capture/probeharness.py). Makes structural the four lessons that were re-learned in four separate probes: built-in stall witness (says UNVALIDATED when absent rather than reporting zero stalls), emit() flushes every line so a timeout cannot destroy results, baseline discard with rescan (Probe(baseline=116) refuses to start on a different stage), and summary() putting the witness first. Also provides craft()/strengths()/alive()/heap() so a new probe writes only its own logic. Verified: deploy_probe.py reimplements the deployment watch in ~40 lines vs 150, first live run clean — 116 roster, 32 witnesses at 10/s, 0 stalled, 7 losses, TSV written incrementally. ⚠️ Existing probes deliberately not ported — they work and other docs cite their results.

  • (2026-08-25) REMAINING OB FOUND AND VERIFIED: big-endian u32 at 0xbdb59668. On the new harness, the HUD changed 4→8 and the intersection collapsed in one step to a single u32be survivor (with u16be/u8 hits being the low half/byte of the same word). Verified live: HUD 012 vs mem@0xbdb59668 = 12. Independently rediscovers the address the earliest sessions found by digit-transition hunting. 🔴 Corrects the earlier "u32be refuted" — that came from intersecting on a HUD reading of "11" which was almost certainly a misread, since ob_digits.png only has templates for 0 1 2 4 8 and misreads rather than rejects other digits. A single bad input permanently poisons an intersection; the reader's confidence scores were printed but never gated on. 🟡 The counter INCREASES: 4 → 8 → 12 in ~5 min, measured in memory. Marked objective targets are being added during the mission — the arrival question again, now with a one-word signal instead of a 32 MB scan. Next: watch this address across a whole mission.

  • 🔴 (2026-08-25) OB address is RUN-DEPENDENT; watcher now self-sufficient. Two fresh launches: mem@0xbdb59668 = 3165285888 (MISMATCH) then 4 (MATCH) against HUD=4. The confirmation gate refused to report from the bad one — the old "recurs in ~5 of 7 runs" note is right. ob_watch.py now hunts the address on the current run when confirmation fails. HUD reader is now confidence-gated (every digit ≥0.80, margin ≥0.05 — the rule ob_read's own docstring states), closing the hole that produced the wrong u32be refutation. 🔴 OB flat at 4 for 250 s (witness clean, 0/50 stalled) while the pilot fired on 1635 of 1964 ticks at marked attackers — constant fire, zero decrements, so it destroyed none. Fire rate itself rose from 4.6% to 83% with no more kills. The earlier 4→8→12 rise is NOT reproduced — both readings were HUD-confirmed, so the rise is not a stable property of the first five minutes; recorded as unreproduced rather than explained away.

  • 🔴 (2026-08-25) THE MISSION BLACK-SCREENS AT ~4.5 MIN — that is what ends every run. Two-segment OB watch: segment 1 clean (0/50 stalled, OB steady at 4 for 250 s); segment 2 on the same live emulator got NOT IN A MISSION. Player never died (hull 1500 throughout), pilot log stops at t=267 s with byte-identical lines, screen now entirely black (mean=(0,0,0)). This is the known pre-existing mission-end freeze, caught in the act. 🔴 Corrects "chained attaches extend the window" — the emulator surviving is not the mission surviving; ~4.5 min is a hard per-mission bound and chaining does not cross it. ⇒ Anything needing >4.5 min of one mission is not doable on this build, including clearing 16 marked fighters at ~2 kills/5 min. The freeze is now the highest-value target: fixing it unblocks arrivals, phase advance and the OB series at once. The 4→8→12 OB rise is now unreproduced in 3 of 4 runs.

  • 🔴 (2026-08-25) Freeze: one new data point, and the INDEX was the real bug. New evidence closing the resume-spin lead from the other side: the 2026-08-25 freeze log has 0 resume was refused (vs 2447 in an older healthy-ish log) — a mission can freeze with none at all, so the warning is not even necessary, let alone sufficient. 🔴 Also refuted today: the BaseHeap::Release failed burst is not a freeze signature (routine; spans lines 10445210 in a log that runs 2 700 lines further). 🔴 Both were already settled in mission-freeze-resume-spin.md, as was 0xbdb59668 — that is twice in one session that existing work was redone. Cause is mechanical: INDEX.md listed 20 of 43 notes and none of the recent ones, so searching it returned nothing. Fixed: INDEX.md now has a generated table of all 59 notes with title + status and says to search it before starting. Regenerate when notes are added.

  • 🔴 (2026-08-25) Freeze frontier: wait confirmed, but info args won't work. Verified the frozen frame's export by ordinal: the shim's (unsigned short)176 = 0xB0 = KeWaitForSingleObject (xboxkrnl_table.inc:190) — previously read off frame names, now pinned. 🔴 Corrected: the doc's "the handle is one info args away" is wrong for this binary — readelf shows 0 debug sections, symtab only (27 807 entries). Not stripped ≠ has debug info; gdb can name frames and nothing inside them. Two routes, costs recorded: (1) prologue-guided stack read of XObject::Wait's spilled this — no rebuild, keeps the oracle identical, but per-frame archaeology; (2) a RelWithDebInfo build via build-canary — makes this and all future freeze questions easy, at a full compile and a binary differing from the one every other measurement used.

  • (2026-08-25) Route 1 is viable and cheap — this is in %rbx. Static analysis, no run spent: XObject::Wait's prologue does mov %rdi,%rbx at 8fbc9c, so this sits in a callee-saved register rather than a stack slot. And the binary carries .eh_frame with 127 231 FDEs (Release builds keep it for C++ exceptions), including one covering 8fbc90..8fbde2 that tracks rbx explicitly. ⇒ from a frozen thread, frame 3 + info registers rbx gives the XObject* being waited on, and x/gx $rbx → vtable symbol (in symtab) gives its concrete type — no DWARF and no rebuild needed. Revises the previous entry, which called route 1 "per-frame archaeology" and route 2 (RelWithDebInfo rebuild) the way to make it easy. Next: execute on a frozen run — two gdb commands per thread.

  • 🟡 (2026-08-25) Wait-object read WORKS: the object is an xe::kernel::XEvent. Executed under gdb: frame 3rbxx/1gx $rbx = 0x5555562db8f0 = PIE base + 0xd878f0 = vtable for xe::kernel::XEvent (+16 for offset-to-top/RTTI) — exact. Same vtable on every sampled waiting thread, different this each, so many threads waiting on different XEvent instances. No DWARF, no rebuild, as predicted. 🟡 Two caveats, both recorded not glossed: (1) this is a healthy-play snapshot (screen_id = flight), not the freeze — boot under gdb costs ~300 s and the whole thing must fit one call (a timeout kills the process group and took the emulator with it once), leaving too little to reach the ~267 s freeze; (2) the this addresses (0x7ffd…, 0x7ffc…) look like host stack, not heap, so either xenia places them unusually or rbx isn't Wait's this after unwind. Check: dump a few words at $rbx — XEvent-like (vtable, KernelState, handle) vs saved registers.*

  • 🟡 (2026-08-25) Refined: TWO wait types, and the read self-checks. Re-extracting per thread (not by grep): of 18 XObject::Wait frames, 8 have [rbx] = vtable for xe::kernel::XEvent+16, 2 = vtable for xe::kernel::XSemaphore+16, and 8 hold a non-vtable mmap pointer. ⇒ waits are on XEvent and XSemaphore; "it's an XEvent" was the majority, not the whole picture. The 8 misses are the method validating itself — a polymorphic object's first word is always a vtable, so a value only counts if [rbx] resolves to a vtable for … symbol; rbx simply wasn't restorable for those frames. That also dissolves the "looks like stack" worry: 0x7ffc… is the shared mmap region (stacks and big allocations), and the vtable check, not the address range, is the discriminator. 🔴 The follow-up run adding /proc/maps classification + x/8gx never booted (EMULATOR GONE at 0s, stale emulator/lock from the prior gdb session), so that check and the frozen capture are still unrun.

  • (2026-08-25) WITHDRAWN: the 8 "unrestorable rbx" reads were WaitMultiple. rbx was restored fine on all 18. The backtrace grep matched XObject::Wait as a substring, so WaitMultiple frames were pooled in and read with the wrong rule — Wait keeps this in rbx (8fbc90: mov %rdi,%rbx) but WaitMultiple keeps the XObject** array (8fbfc0: mov %rsi,%rbx), so [rbx] there is objects[0], an object pointer needing a second deref. Confirmed live: re-reading with the matching rule per frame resolves 30/30 objects across 23 wait frames, nothing unresolvedXEvent 20 / XSemaphore 9 / XTimer 1 (data/waitobj-s02.txt). XTimer was invisible before because its only thread is a WaitMultiple one. Every WaitMultiple thread waits on a pair; T78/79/80 and T64/65 are worker groups sharing a handle. 🟡 %ebp is not a usable count — WaitMultiple reuses it at 8fc158 — so the array is bounded by reading until an entry stops resolving. What survives: the object types and the self-validating read.

  • (2026-08-25) The frozen capture was unrun — the freeze did not happen that run. Superseded: it was taken (see FROZEN CAPTURE TAKEN below). 🔴 was wrong twice — the capture was not blocked, and "it did not happen this run" is a scheduling outcome, not a refutation. Two captures in one run (200 s and 367 s of mission), and screen_id reads flight at both, plus at ~470 s with a drifting frame mean. So the labelled healthy -> frozen diff is two healthy captures, and its 20 -> 18 is three threads exiting, not a freeze. 🟡 This also questions the "~270 s black-screen" bound these runs are planned around — confound not ruled out: gdb slows the emulator (boot to title took 207 s), so 470 s wall-clock may be far short of 270 s guest time. Next: reach the freeze by its actual trigger rather than a clock, or measure guest time under gdb so the wait is set in the right units. Chaining Bash calls within one turn works and is no longer the limit — the emulator survives between calls; this run spent ~900 s across three.

  • (2026-08-25) FROZEN CAPTURE TAKEN — and the per-thread diff is the result. freeze_waitobj.sh split into boot/watch; watch waits for the event (frozen.py + in_flight) instead of sleeping a guessed interval, and caught it first try. Hard stop, not a hitch: a frame minutes later is max_pixel_delta=0 against the capture. Same run, same mission (data/waitobj-s02.txt): 20 → 24 wait frames, XEvent 19 → 23, XSemaphore 8 → 7, XTimer 1 → 1. 🔑 17 of 24 threads sit on the EXACT object they were already on — so this is not a whole-emulator stall. What moves: T105/T67/T68 park on Wait(XEvent) and T69 on Wait(XSemaphore) having been running; T74 and T75 move off a semaphore onto an event (the only threads that change what kind of object they wait for → chase these first); T50 drops WaitMultiple(XEvent,XEvent)Wait(XEvent).

  • 🔴 (2026-08-25) WITHDRAWN: the T74/T75 signature. It does not reproduce — in run 2 they are on Wait(XEvent) while healthy and stay there. My error: the healthy state is not fixed either, so a one-sample-per-state diff cannot separate a freeze transition from ordinary variation between two instants. What survives two independent captures: T68 and T69 go from not-waiting to waiting in both runs (T69 on a semaphore, T68 on an event, both times), and the bulk of threads are untouched (17/24 run 1, 21/24 run 2) ⇒ not a whole-emulator stall now has two captures behind it. Next: repeat the capture several times inside ONE healthy run to learn which thread states are stable before reading any frozen diff as meaningful.

  • (2026-08-25) CORRECTION: screen_id == flight is NOT a freeze test. The previous entry used it to rule out a freeze. frozen.py exists precisely because a frozen mission still classifies as flight (its docstring: 724 s of identical state, 212 % CPU, classifier happy). Re-testing the saved frames says that run was genuinely healthy — right, but by luck. And the "~270 s black-screen" bound those runs were planned around is not a thing: the freeze does not black the screen and keeps no clock (onsets 27/45/83/183/255 s).

  • 🟡 (2026-08-25) One data point that OUR INSTRUMENT provokes the freeze. Run flew ~670 s clean with only the pilot; a heavy-CPU inducer (heavy_read.py cpu) started at 08:59:54 and it froze at 09:00:48 — 54 s later, inside the 27255 s band. Consistent with the existing tally but n=1, not causal (confounder: elapsed mission time). ⬆️ UPGRADED to n=2 (2026-08-25): run 2 put the inducer on from flight start and froze ~96 s in (10:07:20 → 10:08:56). Both freezes sit inside the 27255 s band; the only long clean stretch is the only window with no inducer. Contrast is sharp, confounder still untouched. Refuted en route: the I/O was never the cost — a full uncapped walk of every allocated extent is 371 MB in 0.1 s, all page cache; the expense is Python-level CPU, ~4.2 s a pass. Settle it with: alternating inducer-on/off windows within a run, several runs, compared per unit of mission time — cheap now watch is event-driven.

  • 🚧 BLOCKER: t=210/240 unreachable in one turnsuperseded, see above; it rested on an untested assumption that a turn is one shell call. 595 s shell cap ~220 s boot (a ~190 s title movie that cannot be tapped through) ~25 s startup = ~350 s observation ≈ 193 game-seconds. t=90 needs 164 s wall , t=120 → 218 s , t=170 → 309 s (only on a non-frozen run), t=210 → 382 s , t=240 → 436 s . No number of runs fixes this. Unblocking needs a decision I should not make alone: (1) a safe way to skip the title movie — it is >half the budget, and would roughly double the window to ~297 game-s, covering everything; or (2) a longer shell timeout. The rescan existed only to catch newly-allocated craft, which the roster work showed never happens.

  • Run 16: the first TRUSTWORTHY negative. Validated witness, no stall on any sample, guest demonstrably live (8 losses) ⇒ 0 confirmed arrivals over 210 s of verified-live flight ≈115 game-seconds. Establishes that nothing arrives in the first ~115 game-s of Stage 02 phase 1 while the player kills 8 craft. Does NOT settle the question — t=170/210/240 route entries are still out of reach.

  • Run 17 reproduces it (n=2): no stalls through t=240 s (~132 game-s), 9 losses, 0 confirmed arrivals — so nothing arrives past the t=90 and t=120 route entries either. 4 flicker ups correctly rejected (~1/min, which is why the persistence rule matters). 🔴 Startup eats ~100 s of a ~350 s budget: enumerate_craft iterated every 4-byte word of 32 MB in Python (8M steps) to find 14 fixed needles. Replaced with bytes.find() per definition VA — not yet run; if it recovers that time the window reaches ~340 s ≈ 187 game-s, finally covering the t=170 entry. Also: the 42-record anomaly recurred and the discard rule correctly refused the run; a rescan-until-baseline retry is now in place to test whether it climbs. All three witness failures share one pattern: a shortcut in selecting the witness, each caught only by the flagged samples contradicting losses in the same output.

  • Bind failure fixed + abort verified: entities2 self finds the player by MOTION, so a stationary craft at mission start is invisible; the session now holds throttle 3 s before each attempt. One run correctly aborted after 3 failures rather than flying unattended; the next bound immediately (11 losses, 0 confirmed arrivals — 14 runs). 🔴 The cheap probe froze too (t≈183 s), so the earlier "0 stalled samples" validation is superseded; with n=1 per arm (control clean 300 s, heavy 27255 s, cheap 183 s) it is unresolved whether cheap sampling helps or the freeze is stochastic. ⚠️ Usable window ≈3 min per run regardless — design experiments to fit or to survive a freeze. ⚠️ The "0 stalled samples" that validated the cheap probe came from the unreliable witness and should be re-confirmed. Also fixed: bind now retries 3× and aborts rather than flying an unattended craft (one run was wasted that way). Multi-squadron threshold test still not run (zero losses that run). ⚠️ The ~210 s title movie at boot is the binding constraint on observable game time per turn. Earlier framing: mission-per-record-strength.md — one run gives 116 records/300 craft, the next 42/170, same disc, save and script. Save drift is REFUTED (savedata untouched since 2026-08-23) and the guest was not frozen (pilot telemetry shows live engagement). Also: zero arrivals and zero losses in 190 s of hunting — weak evidence against clock arrivals at t=90/120/170 s, and no test of event-gating at all because nothing was killed (fc=0, hull never moved). Next: (1) sample the roster-record count repeatedly WITHIN one run to tell a load race from a run-specific constant; (2) get a confirmed kill — the pilot reaches 259 m and still misses, so the gap is accuracy, not engagement.

  • 🟡 COMPETING MODEL (user, 2026-08-24): waves are event-gated, not clock-driven — released by kills/objectives rather than at a fixed time, with the route's start time being a post-trigger delay. Fits the evidence better: both probe runs used the survival pilot (kills nothing) and saw no arrivals, which a clock model does not explain. DisableInterval also gains a referent. 🟡 Supporting: no DisableInterval=Yes squadron has a delayed route (55/55 at t=0, vs ~3.7 expected if independent, p≈0.02) — suggestive, NOT conclusive, deliberately not promoted. Test: same stage twice, kill nothing vs kill aggressively; cheapest precondition is whether REMAINING OB (0xbdb59668) moves in one and not the other.

  • 🔴 Diffing inside the 116 records did not find the arrival flag (2026-08-24, same doc). 10 of 116 records are dynamic, 106 never change a byte in 170 s — 🔴 the "10 of 116 dynamic" figure is WITHDRAWN — a hunting run measured 4156 records changing per tick; I had changed two variables at once (record bound and pilot) so the discrepancy is unattributed. The roster identity is unaffected: it now rests on the 10/10 unit-composition match. Earlier note, kept for history: the "turrets don't move" hedge was withdrawn after the user pointed out that early-mission "Turret" is a craft type, which the data confirms (UN_e007_ADAN_Turret flies under AI_ADAN_CraftSquadron_*, never AI_Structure). Lesson: check a unit's AIID, not its English name. No field transitions in groups of 3 at the predicted times. Blocked on two of my own defects: the record→unit-ID label resolved to ? for all 116 (reuse unit_discover.py, do not re-derive), and RECLEN=0x200 was assumed, not measured — the busiest fields sit at the very end of the window, which is what spilling into the next object looks like. Fix both before diffing these records again.

  • 🔴 Probed 2026-08-24 and refuted the cheap hypothesis — the phase state is NOT adjacent to the loaded table strings; see mission-phase-runtime.md. The run did confirm dynamically that every table the static decode predicts is resident in guest RAM and findable by name, which validates the whole static layer against a running mission. Next handles: watch Route_ADN101_p1F actually fire against entity positions, or work back from the SUBOBJ_*_Mes_L1 HUD strings; the phase state is more likely near the known mutable REMAINING OB counter at 0xbdb59668 than near the tables.

Superseded first step: find what advances a phase — the stage declares Phase_1..3 and routes are phase-tagged, but nothing static found so far says what ends a phase. This is the point where the oracle should be measured rather than reasoned about: fly Stage 02 and watch for the phase transition. 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.

Superseded first step: find where the arrival interval values live. DisableInterval is only a per-squadron flag (Yes for 31 of 1160); the durations, spawn triggers and arrival positions are not in UnitGroup. The stage record names two untouched candidates: Formation_*.tbl (formation geometry, possibly its timing) and EnumSquadron_Test.tbl. Also still open: the 4-byte record key. the member field nSETTLED 2026-08-25, see below. the missing S17S23 stage recordsSETTLED 2026-08-25, and half the premise was wrong.

  • n is the number of units the member tuple instantiates, filling slots of the squadron's formation. FormationSet_S<NN>.tbl records are slot lists1 + 8·FrameCount fields, exactly (4→33, 14→113, 30→241, 32→257). Resolving every squadron's FormationID and comparing: sum(n) ≤ FrameCount holds 1159/1160 across all 28 stages, 0 unresolved, 539 filling the formation exactly. The lone violation is a debug leftover (S20, AI_Test / MessageSet_test, Formation_1_only with n=2) and is recorded, not swept up. The old "n is not the _NN suffix of FormationID" 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.
  • FormationID resolves by an in-table roster, not by hashing — 0/16 resolve via name_hash; FormationSet_S02.tbl has 17 records for 16 formations and the extra one carries no FrameCount, its fields being (tag, name, "") with the tags equal to the record keys. Same convention as Enumerate_Squadrons. Second sighting — treat it as the rule for the next table.
  • (2026-08-25) 387-vs-~300 SOLVED — the probe double-counts. The squadron→phase map was on the disc all along, in the Route_S<NN>.tbl record names (Route_<squadron>_p<N><kind>, 120/120 for S02); 108/111 S02 squadrons map, the 3 misses are route-table typos and all phase 2. Phase 1 Σn = 151, and its member multiset {1×25, 2×1, 4×4, 9×12} matches the measured craft-per-record fan-in {2×24, 4×1, 8×4, 18×12} bucket for bucket at exactly 2×300 = 2 × (151 1), the 1 being the known 41-vs-42 off-by-one (probably the player). The 2× is the instrument, not the game: a 2× reading needs 16 Delta Sabers for 8 named pilots, and breaks Σn ≤ FrameCount in 20 of 37 phase-1 squadrons (16 on Formation_1_only, one slot) where 1× has 0. All three measured totals being even is a further tell. ⚠️ Static argument about a dynamic measurement — it says the number cannot mean what we thought, not what is being double-counted; that needs a run against the craft-scan counting rule. See mission-phase-membership.md.
  • 🔴 Refuted with it: UnitGroup has no phase/spawn/delay field (1019 = ΣCount·4 + 5·111, every slot accounted, positional tags are sequential indices); DisableInterval is No for all 111 S02 squadrons (its 31 corpus Yes records are only S04 and S14, all GNN***); stage\EnumSquadron_Test.tbl does not exist — that candidate is dead.
  • 🔴 (2026-08-25) NO freeze signature survives the control. Six wait-object captures across ONE healthy run: 12 thread states stable, 13 vary (data/waitobj-healthy-stability.txt). Every thread previously reported as a signature is in the VARIES set — including T68/T69, which I had kept as "what reproduces across both freezes": they park and unpark during ordinary play, landing on the same objects they hold when frozen. Both frozen diffs are consistent with healthy variation; one-sample-per-state could never separate them. Survives: the 12 stable threads held the same object in all six captures and none moved in either frozen capture, so "not a whole-emulator stall" stands — now resting on the stable set being undisturbed rather than a count. ⚠️ gdb thread numbers are not comparable across runs (this run had T132T142, earlier ones T104T106) — key on object address or guest tid. Next: compare DISTRIBUTIONS — N healthy vs N frozen captures, and call a thread a signature only if its frozen distribution leaves its healthy range.
  • 🐛 (2026-08-25) Boot-nav bug found and worked around, not yet fixed. Three consecutive BOOT FAILED (NO readyroom) were not flakes and not input loss — the d-pad and A both work (verified by moving the save cursor and opening the dialog by hand). wait_screen.sh readyroom 300 --tap A blind-taps A, which answers NO on the "Load game?" dialog, bounces back to the save list, then reopens it — a 300 s oscillation. Driving step upA by hand reached the ready room in 18 s. launch_mission.sh should not pass --tap A while a YES/NO dialog can be on screen.
  • (2026-08-25) WHAT ADVANCES A PHASE — SOLVED: a compiled script VM. See mission-phase-advance.md. [ScriptMission+40] is the 1/2/3 ordinal, init at 0x822606B0, and incremented at exactly one site 0x822609F8 0x82260A00 (checked: only one stw to 40(rN) in the whole state machine sub_82260710). Its guard is [ScriptPhase+196] != 0; that flag has only two writers — vtable slots 0/1 at 0x82264058/0x822640F8 — reached ONLY from built-ins 6 and 62 of the phase-script VM's 147-entry table (sub_82272220, jump table 0x8227226C). Built-in 39 sets [phase+300]=2 = "last phase", ending the mission instead of advancing. 🔴 All four candidate triggers refuted as direct causes — no kill counter, timer, trigger volume or message event is on the path; those conditions live inside the per-mission script, which is why the static sweep found nothing and why three phases of one stage can differ. sub_8230D1F8 reads "Phase_%1d" for map/background only — confirms the executable never consults Phase_N for a trigger, and nothing parses Route_*_p<N>* (the 3 such literals at 0x820AEA38 are debug defaults) — the route-name phase map is our convention, not the game's.
  • 🎯 PROBE TARGET: CScriptInterpreter::ChangePhase (sub_822FF330, opcode 995) writes a runtime phase mirror at [*(0x828F35F8) + 236] — readable from /dev/shm with no gdb.
  • (2026-08-25) SCRIPT BYTECODE FOUND — Stage\StageNN.ssb in dat/GP_MAIN_GAME_<L>.pak. See structures/mission-script-ssb.md. The earlier grep failed only because every pak entry is Z1+zlib. The loader resolves table keys, not a filename: GamePart name → GP_SCRIPTscript.tbl (name_hash 0x75FE4656), whose SCRIPTS record is a 40-field manifest MISSION1..MISSION29 → StageNN.ssb. 28 scripts; S17 is the missing one — agreeing with the table sweep and with the loader guard if (n==16 || n>32) return (so mission numbers there are 0-based): three independent routes to the same conclusion. Header decoded from ScriptMission::Load itself. Stage02 = 226,596 B, md5 aff69b5a…, identical in all 6 language paks. Symtab1 326 syms (197 messages, 119 route names, 10 subobjectives), symtab2 122 (111 unit ids = exactly the 111 UnitGroup_S02 squadrons). 🔑 The route names are SCRIPT SYMBOLS — which is why nothing in the executable parses Route_*_p<N>*; the _pN convention is the script's, resolved at compile time.
  • 🔴 Refuted: the 7 .embsec_ sections are code (32,368 instructions, 108 functions with real prologues), not script; MiscBin.pak and DefTables.pak have zero hits.
  • NEXT, and it is the one that pays: decode the 25 ISL opcodes (dispatcher 0x822635D4, low byte of a BE u32, jump table 0x822635FC) and the 147 built-ins (0x8227226C) against Stage02.ssb, hunting calls to built-ins 6/62 — the two that set [ScriptPhase+196] and end a phase. That gives the actual per-phase clear condition for every stage. The mission-level stream at +0x24 is partly read (3 groups, one per phase, each ending in a pair of plausible ISL entry offsets) but 1883's operand is not uniformly a pointer — two land on IEEE floats. (2026-08-27) The decode obstacle is GONE — isl-stream-is-flat. The stream is FLAT: a plain linear decode from the first phase base reaches 25705/25705 call sites across all 28 stages, 28/28 clean to code_end. The recorded "needs the coroutine entry points" blocker is refuted (following them buys 2.8 points; ignoring control flow buys 100 %). The real bug was isl.dis stopping at op 20 (ret) — a coroutine yield, not an end of code — which reached only 4.7 %. Fixed via stop_at_ret; the committed data/isl-stage02.txt regenerates byte-identical. 🟡 But END_PHASE's call site is the WRONG place to read the condition: all 12 Stage-02 END_PHASE sites sit in one stereotyped outro (wait_cmds_drained → fade_sound(3) → builtin85(3) → wait_s(3) → END_PHASE → end_coroutine). ▶️ Next, and it is now the only thing in the way: read the five branch handlers op10 (0x82271598), op13 (0x82271830), op14, op21, op23. (2026-08-27) DONE for the branches — structures/isl-branches. op10/op11 are signed/float COMPARE, writing three condition bits (0=EQ, 1=GT, 2=LT) to a bitset at phase+24; op13op18 are the six relational branches beq/bne/blt/ble/bgt/bge on those bits, targeting [phase+232] + word@+4 exactly like op12. All six relations present, each once — the completeness is the check. Handler addresses come from the bl inside each dispatcher thunk; guessing them at a fixed stride lands mid-function. ▶️ Still open: op23 (0x82271C30, takes a built-in's result to phase+168) and op21 (0x82175C20) are characterised but NOT named, and naming the branch does not yet give each stage's clear condition — that needs the operand chain feeding each compare. (2026-08-27) THE OPERAND CHAIN IS CLOSED — structures/isl-builtin-dispatch. ⚠️ op21/op23 were already named in isl-bytecode.md (the owning file) as push.i/pop.i; my isl-branches.md was the stale one. Verified and reconciled. NEW: the 147-entry built-in table is a thin dispatch layer112 of 147 stubs tail-call a fixed slot of the ScriptPhase vtable, and every named predicate is in that group. The vtable is 0x820A84BC, derived from MARK_LAST_PHASE's known [phase+300]=2 stub and confirmed by an unused-in-the-derivation prediction (slot 176 = the =1 stub) plus the db's own vptr_writes. unit_state = slot 184 = 0x8226ADF0, which indexes [phase+324] by local[4] and writes its answer to [phase+164] = special[0]. So: result → special[0], comparand popped → special[1], then cmp.i + branch. ▶️ Still open: the other 111 vtable slots are a lookup but unread; which comparand each site pushes (needs push.i tracked through the decode); the 35 non-vtable built-ins; the vtable's length. (2026-08-27) COMPARANDS RESOLVED — structures/isl-conditions. The deques are an expression stack: push the left operand, evaluate the right (result → special[0]), pop → special[1], compare. Evidence: push/pop balance 1877/1877 across 28 stages with ZERO underflows, and 319/319 Stage-02 pop.i sites are immediately followed by cmp.i. isl.conditions() now recovers 7563 condition sites disc-wide with 0.0 % left unresolved (83.2 % have a built-in call as LHS, 99.7 % compare against a plain number). data/isl-stage02-conditions.txt finally has a generator (isl_report.py conditions); the other two artefacts regenerate identical. Top predicates: hp_pct_test 1955, unit_state 1257, unit_relation 796, dist_lt 450. 🟡 1.6 % are WRONG: 15 sites attribute the LHS to end_coroutine… Fix = only set it for built-ins that write [phase+164]. (2026-08-27) FIXED, and that proposed fix was REFUTEDend_coroutine's handler 0x82272624 does stw r11,164(r31), so the filter would have kept it. Real cause: end_coroutine returns 3 = destroy the thread, so the flat stream continues into a DIFFERENT routine and the tracked state is stale. A/B over 28 stages: exactly 34 of 7563 sites change, 34 → 0 with an end_coroutine LHS, and the two counts being equal proves the leak was confined to them — the other 7529 were never affected. They now print an explicit unknown. 🔴 (2026-08-27) THAT FIX WAS TOO NARROW — I fixed the instance, not the class. op12 jmp is unconditional, so the next instruction is never reached by fall-through either, and the tracker walked through it exactly as it had walked through end_coroutine. Exposed by another impossible output: a six-way switch on builtin80, which returns only 1 or 0. A/B over 28 stages: 889 of 7563 sites (11.75 %) change, and unresolved goes 34 (0.45 %) → 756 (10.00 %). The earlier "0.0 % unresolved" was a MISSING CHECK, not a strong result. 🟡 Recovering the 756 needs a real dataflow join over each block's actual predecessors (a CFG fixpoint) — the analysis is not written. (2026-08-27) WRITTEN — tools/re-capture/isl_cfg.py. Worklist fixpoint, join over actual predecessors. 85.0 % of instructions reached; unknown LHS 756 (10.00 %) → 402 (5.32 %), of which 389 are never reached and only 13 are genuine join-aways. It also found 161 more sites where the linear walk gave a confident WRONG answer. ⚠️ Two of my own zero-results on the way: the phase bases reach only 36 % (most routines are coroutines with no static predecessor, so every start_coroutine target must be seeded), and that seeding first found 0 entries in a file with 216 because the target is staged in TWO steps (special[0]=imm then local[0]=special[0]). 🟡 The 389 are a real limit: they are started from the trigger queue at phase+272 — by data, not code. ▶️ Still open: the 35 unnamed built-ins (builtin16 132 sites, builtin105 117, builtin103 115 — each now a vtable-slot lookup); (2026-08-27) partial — structures/isl-unit-args: reading the implementations shows 55 built-ins take a unit at local[4], not 31. All 31 of the statistical set are confirmed (zero false positives); it missed 24, incl. builtin80, group_ratio_pct, is_engaged, set_unit_flags, squadron_trace, wait_units_ready. ⚠️ My first control (operands resolving to a symtab-2 index) was worthless — the 92 built-ins in neither set also score 99.3 %. The control that works is the tag word: slot0 == 1 in 100.0 % / 100.0 % / 2.5 %. Artefacts: calls + phase-ends byte-identical, conditions changes on 28 sites (raw number → unit name). (2026-08-27) The three built-ins that appear INSIDE clear conditions are READ — structures/isl-condition-builtins — but none is NAMED. builtin104 (slot 288, 0x8226BFE0) is a three- instruction pure getter for [phase+10160], so all six tutorial stages end on one engine-written word; that word has exactly one writer in the image (sub_821AA1B0 @0x821AAD9C, gated on a kind field in (16, 32]) and r29 there is a call result, so its meaning is unestablished. builtin7 (slot 40) indexes the unit array by local[4], bails when rec+16 (the unit state) is 1/3/4, and resolves local[12] through [phase+244] = symbol table 1 — independently confirming isl.SYM1_SLOTS[12] ∋ 7, which was derived from operand ranges alone. builtin141 (slot 428) has the same unit-array entry and returns 0 when the unit is dead; the rest is unread. Its Stage-16 call pair differs in one argument (0 vs -4000) which LOOKS like a coordinate — exactly the evidence this corpus does not accept. Still unnamed: all 24. builtin103 is a predicate over [phase+10152] and [phase+10156] (no unit arg); builtin105 tests a unit record's +16 == 4 (isl-builtins.md documents rec+16 as the unit STATE, 2 = active). 🔴 (2026-08-27) builtin80 is a COMMAND, not a predicate (0x82268460): it allocates a 20-byte object, stamps vtable 0x820A8CB0 + magic 0xAB0311BA
    • the unit's live object, pushes it on a queue via the push.i helper, and returns 1 (or 0 if the unit is absent). Finding that exposed a much bigger bug in my own condition tracker — see the entry below. which condition guards each END_PHASE (needs the control flow between them); (2026-08-27) DONE — structures/isl-phase-guards (isl_report.py phase-guards, data/isl-stage02-phase-guards.txt). Uses DOMINANCE over the CFG, not reachability. 🔴 The obvious query — "one branch reaches END_PHASE, the other doesn't" — is wrong for this language and was tried first: poll loops have BOTH successors reaching the exit, so it found 1/62/1 guards in Stage 02's three phases, the 1s being a read_freg(0) < 1200 timeout, missing every objective test. Dominators converge in 3 passes over 15670/18739 instructions. Result: every exit in all 3 phases requires unit_hp_pct(TCN001, Character_Player_Test) != 0 (the player alive); phase 1's objective exit additionally requires hp_pct_test(ADT102/ADT107/ADT113, 0) != 1, phase 3's requires ADT301/ADT302, and read_freg(0) gates at 210/300/1200. 🟡 Dominance gives NECESSARY not sufficient conditions. (2026-08-27) THE SUFFICIENT SIDE IS DONE. isl_cfg.must_reach_exit() = a least fixpoint of nodes from which END_PHASE is unavoidable (n qualifies when it has successors and ALL qualify — conservative, so a loop never enters, which is correct because a poll loop's exit depends on a LIVENESS property). A dominating condition is a TRIGGER when its satisfying successor is in that set. 732 dominating conditions, 234 triggers (31.97 %), and the artefacts now tag every line precond / TRIGGER. Stage 02 phase 1's objective exit = 6 preconditions plus ONE trigger, hp_pct_test(ADN101, 0) != 1 — destroying ADN101 is what fires it. Per-exit: 89 of 172 have exactly one trigger, 42 have none (those are the timeout exits — time passing is not a graph property, so declining to call it a trigger is correct), 41 have several. 🟡 "the FIRST trigger is the point of no return" holds 33/41, not universally — the listing is ordered by file offset, which is not execution order — so it is recorded as a heuristic, not a rule. 🟡 2 of 15 exits are reachable from NO static entry — consistent with the trigger queue at phase+272. (2026-08-27) EXTENDED TO ALL 28 STAGESdata/isl-phase-guards-all.txt (isl_report.py <dir> phase-guards). 177 exits, only 5 (2.8 %) unreachable; CFG reach 69.5 % (S26) to 95.8 % (S25), ~4 conditions per exit. 🔑 Independent cross-check, 6/6: all six TUTORIAL stages (S18S23) have exactly one exit with exactly one dominating condition, builtin104() != 1 — and isl-builtins.md derived built-in 104 from usage alone as "S18S23 only, a textbook poll loop". Two unrelated methods agree. S16 (the compiled-C++-script outlier) resolves too: read_freg(0) < 600, player_gauge0/1_test, and two builtin141 calls differing only in one argument (0 vs -4000) — a position/zone shape, unread so unnamed. the vtable's length. The condition lives in the op10/op13 poll loop upstream of the outro — e.g. phase 3 polls unit_state(ADT308) and branches back to 0xFEB4 until it passes. Artefact: data/isl-stage02-phase-ends.txt.
  • 🐛 (2026-08-25) The nav fix is NOT fully reliable. dialog_up.py works on the saved frame (mean 34.3 vs threshold 45), and one boot went readyroom-in-9 s — but a later boot logged "load dialog not up yet, retrying" and still ended NO readyroom within 300s. So the retry loop can desynchronise (a shot taken before the dialog renders reads as absent, and the next A then answers NO). Needs a settle-and-recheck rather than one shot per attempt.
  • 🔴 Not settled: the script bytecode is not on the disc under any obvious name. No GP_SCRIPT.pak; grepping the extraction for MISSION_START_PRT returns nothing. Loader sub_8225EE20 matches section names MISSION1..MISSION33 + 5 *_PRT; sub_8225EC78 gates if (n==16 || n>32). Candidates: the 7 .embsec_ sections (VAs 0x84D00000x86AC000, ~129 KB total, executable) or a hashed record in hidden/MiscBin.pak. Finding it gives the actual per-phase clear condition for every stage.
  • (2026-08-26) RETRACTED — "the resupply banks are missing audio." The error was mine and it stood for three iterations across two write-ups that each called the result proven: I treated a subtitle cue as a timestamp that must fall inside the voice clip. A cue is when the line STARTS — the voice plays from the cue to at most the movie's end. Under that reading all five banks fit at plain 48 kHz (3.31 s in a 5.30 s window, 2.26 s in 4.60 s, …), and 23.5 s is the right length for the lines. Nothing is missing. The 17 09120 563 Hz window from the previous entry is void with it. Each shared bank is ONE generic line — the 35 movies sharing a bank have identical subtitle text, 5/5 banks (examples/shared_bank_takes.rs). 🎯 That also explains the historical in-game rejection of hokyu_DS_s13A → VOICE_D_452: the line is the generic "Resupply complete. You are cleared for take-off!", the same for s02A/s07A/s08A/s13A. Someone expecting a stage-13-specific line would call the generic one wrong while the binding is right. (same day) The two parts are SEQUENTIAL SEGMENTS, not duplicates — so the totals do not double-count and the 48 kHz fit stands. Measured by RMS: 450/451/452 have a silent or near-silent leading region (RMS 0301 against ~9 000 for speech) with the line in the RIFF; 453/454 have the line in the leading region with a short loud tail in the RIFF. 🎯 That closes the original mystery. The decoder skips everything before the first RIFF. For the first three that discards only silence, so they looked fine (2.8 / 1.6 / 2.2 s); for the last two it discards the line itself, leaving 0.14 s and 0.43 s. One rule, two outcomes, depending on which segment holds the speech. (same day) LANDED. to_xma_riffs emits the leading segment wrapped mono when it is a whole number of packets and carries a non-zero byte. Both reasons the first attempt was withdrawn are answered: it used the stereo format (mono yields up to 113× more), and while the byte-level reach is still 1524 entries the audible reach is not — across the 84 movie-bound banks it adds >1 s to exactly 7, the hokyu_*_H tankers on VOICE_D_453/454, and ≤0.25 s to 66 of the rest. ⚠️ The safety oracle is weak and says so: 8 of the 84 already exceed their movie duration before the change, by hundredths of a second, so it establishes scoping rather than correctness. Pinned by tests/slb_leading_segment_disc.rs, including that the all-zero VOICE_D_451 region stays skipped. Not verified by ear — that needs a human.
  • (2026-08-25) The .slb "multi-subwave" guess is REFUTED, and the voice decoder is discarding up to 87 % of a bank. The record table gives a direct binding hokyu_DS_s13A -> VOICE_D_452 where the corpus records the movie as unbound and a test asserts None, citing an in-game verdict that the same value was "the wrong recording". Measured: the RIFF-magic count equals the sub-wave count in all five hokyu banks, so nothing between or after sub-waves is missed — the recorded "likely multi-subwave / not cleanly sliced" is wrong. The audio is lost because a large region precedes the first RIFF and slb::to_xma_riffs finds audio by searching for that magic: 87 % of VOICE_D_453 and 85 % of VOICE_D_454 sit in front of it, 2127 % zero over 256 distinct byte values — content, not padding. VOICE_D_451 is the control, its leading region being 100 % zero / 1 distinct value. 🟡 So the in-game verdict tested a decode that had thrown away most of the bank and is not evidence against the binding — though it does not confirm it either. (same day) The region's SIZE is now exact: the first RIFF sits at 1392 + n*2048 in all five banks (n = 8, 1, 7, 22, 29) — 1392 being the crate's own HEADERLESS_DATA_OFFSET and 2048 the XMA1 packet size. No free parameter. But my fix for it is WITHDRAWN. Emitting that region as a sub-wave took VOICE_D_453 from 5.4 % to 89.9 % byte coverage — and the stream decodes to 1792 PCM bytes, silence, while the RIFF sub-waves decode to 150270 KB. Byte coverage was the wrong success metric. The rule also matches 1524 of 8021 RIFF-bearing sound.pak entries, including RT* banks that work today, so it risked a wide regression to not-fix five banks. (same day) Audio really IS missing — proven by the subtitle cue times, not by the "sounds too short" impression the docs recorded. A subtitle that appears at t seconds cannot sit inside a clip shorter than t, and three of five banks fail that: D_450 cue 4.00 s vs 1.41 s decoded, D_451 3.70 vs 1.81, D_453 4.70 vs 0.07. The other two have their only cue at 0.0 s and give no signal. Artifact examples/voice_len_vs_subs.rs, FFmpeg-measured. (same day) The leading region IS XMA1 — MONO, not stereo. At channels = 2 every bank decodes to exactly 1792 bytes regardless of size (one frame, then it stops); at channels = 1 the same data yields up to 113× moreVOICE_D_453 goes 1 792 → 203 648. The bank's own RIFF sub-wave is decoded through the same pipe as a control (13 568 bytes), so the harness is sound, and the all-zero VOICE_D_451 region is the control the other way. The earlier 0-byte probe was my own error: I read synth_xma1_fmt's second argument as a stream count when it is a channel mask. Solving for the sample rate from the subtitle cue does not converge — 21 665 Hz for D_453 (temptingly near 22 050, and I nearly wrote it down) but 5 844 Hz for D_450. The decodes are partial: samples per input byte ranges 2.104.96 where a clean decode would be near-constant. (same day) Why FFmpeg stops is captured — I had been discarding its stderr. It reports an unimplemented "Reserved bit" and a negative bit-skip, and the failing frame is always the last one (44 of 45.7, 28 of 29.4, 198 of 198.9, 287 of 287.5). That corrects my own previous claim: "the decodes are visibly partial" was wrong — the 2.104.96 samples-per-byte spread is ordinary XMA1 variable bitrate (4.219.92 frames per packet), and only the final frame is lost. The sample rate still does not converge, including after counting leading region + RIFF together: 39 742 / 20 563 / 23 108 Hz for the three banks with a usable cue. Two of them agreed at a tidy ~2.1× ratio and the third refuted it. (2026-08-26) The cue unit IS seconds — checked against movie duration as an independent oracle: 66 movies with subtitle tracks, 0 cues land after their movie ends (examples/cue_unit_check.rs). Centiseconds would have overflowed essentially all 66. And "the sample rate does not converge" was my own error. The implied rates are not competing point estimates — each is a one-sided bound (samples/cue is an UPPER bound, samples/movie a LOWER one). Intersected, they give a non-empty window of 17 091 20 563 Hz: a single rate is consistent with all three banks. But that window holds no standard XMA rate (22 050 / 24 000 / 32 000 / 44 100 / 48 000 all fall outside). The lower bound assumes the whole bank plays within one movie, and each bank is bound to 35 movie slots — so if a bank holds several takes the lower bound is void, leaving rate <= 20 563, which 22 050 nearly meets. ▶️ Next: establish whether a shared bank is one line or several takes. See voice-bank-leading-region.md.
  • (2026-08-25) My own boot-nav diagnosis, MEASURED AND WITHDRAWN. I said the run died because skip_intro.sh gates the title test at rmse <= 1500 and the run logged 1503/1549, just above the cut. Measured over a clean no-press boot (boot-nav-title-gate.md, captures/boot-signal-trace.tsv): the gate opened eight times in 29 samples, and at t=145 s the RMSE was 1205 — inside the threshold — with is_title.py answering 0 glyph pixels. The glyph count was 0 in all 29 samples over 484 s. A static frame is not the title; the intro movie has long quiet stretches, three of them reading RMSE exactly 0. So the gate is not what stopped it, and raising the constant would have admitted two more movie frames. 🟡 Narrowed: the interactive title never appeared, rather than appearing and being missed. But the run does not prove that — the tracer intended 1 s sampling and achieved 16.9 s (two screenshots + ImageMagick compare + a fresh Python per iteration), so a title window under ~17 s falls between samples. ▶️ First step: make the tracer sample at the rate it claims — one long-lived Python process computing both signals — then re-run. Second: check whether the fast 2026-08-25 run had a warm shader cache that this one, started after rm -f /dev/shm/xenia_*, did not.
  • 🚧 (2026-08-25) Still unrun — the live test of the reset_phase_threads rename. Built-in 100 clears the trigger container and frees every thread but the caller, so at a phase terminator both [phase+272+20] (triggers queued) and [phase+216+8] (coroutines alive) should collapse; if they climb straight through a phase boundary instead, the reading is wrong. tools/re-capture/phase_watch.py prints both — written, still never exercised against a live guest. Blocked behind the boot-nav item above.
  • 🟡 (2026-08-25) The legacy IDXD string-pool reader is wrong far more often than assumed — now measurable for the first time, since the record table gives a ground truth. Verified by hand: FCSRange (the module doc's own canonical "field with no value") is really 500000.0; ShieldRatio is 1.0 where a committed test asserted None and called it defaulted; and get_raw("Model") on the hangar table returns the first record's model for every record — silent corruption, not an absent value. Single-source disc-wide rates: get_raw 52 % wrong, typed getters 38 % miss — but 100 % correct on the 548 single-record objects, so all the damage is the flat API having no way to say which record it means. ▶️ Open work: re-read every per-record number in this corpus through IdxdObject::record; highest value first — hangar models, weapon Power/Acceleration/MinimumVelocity, turret and subsystem stats. See idxd-legacy-reader-audit.md.
  • (2026-08-25) The 504 unnamed IDXD field keys were NOT recovered. All sit in GP_READY_ROOM.pak's sound-bank table (6 identical objects × 2 records × 42 keys). A dictionary of 572 464 strings — every pool string disc-wide, PE ASCII and UTF-16 runs, every identifier in this repo — plus 73 191 case/affix variants gave 0/42. The key deltas across stage01…stage16 do prove the preimage ends with the two decimal digits, and a meet-in-the-middle found nothing word-like at ≤8 lowercase chars. 32 bits is not invertible without the right wordlist; parked.
  • (2026-08-25) The IDXD/IXUD container is fully decoded — the "binary node/index region" in front of the string pool is a uniform 16-byte record array {name_hash, name_off, field_begin, field_end} sorted by hash, then a field count, 12-byte fields {key, name_off, value_off} sorted by key, then a pool size and the pool. Verified over the whole disc with zero failures: IDXD 7 750/7 750 objects, 190 782/190 782 records, 1 271 462/1 271 462 named fields; IXUD 1 104/1 104 objects, 628 165/628 165 fields (offsets in chars). Field names are stored on disc, so no preimage search is needed — only 504 fields disc-wide are hash-keyed with no name. 🔴 Two corrections: the header word at 0x08 is not a schema hash, it is record 0's name_hash (7 750/7 750) — the format has no type field at all, so an object's kind is known only from its loader; and the field's middle word is not an aux flags word. See structures/idxd-container.md. ⚠️ My first disc sweep globbed dat/** and missed hidden/DefTables.pak (1 425 objects); the test now walks the whole disc root. ▶️ Follow-up now open: the legacy value-before-key string-pool reader is an approximation of the real table, and every number in this corpus that came out of get_f32/get_raw is re-checkable against ground truth but not yet re-checked. First step: diff the two readers across the disc and count disagreements. Also open: recover the 504 unnamed hash keys. (2026-08-27) DIFFED — see idxd-legacy-reader-diff. Over 7 750 objects and 738 922 numerically-valued named fields, legacy get_f32 is correct 39.42 %, safely None 43.04 %, and returns a wrong number 17.54 %. The error has an exact predicate: 0 of 29 822 fields wrong in single-record objects, 18.28 % wrong in multi-record ones — because get_raw finds the first occurrence of the key in a flat token list and has no notion of records, so every record after the first inherits record 0's value.
  • (2026-08-25) Both guest hash routines locatedsub_82447DF0 (IDXD) and sub_82447E70 (IXUD), transcribed instruction-for-instruction into Python and Rust; cargo test -p sylpheed-formats --lib hash 10/10. IXUD SOLVED: it chains two exact moduli (loop mod 2^32-153 in 64-bit, then fold mod 2^24-33), which is why no single-modulus search could ever find it — 86/86 keys and 108,261/108,261 tags verified. 🔴 Two of my claims corrected: tag_hash must sign-extend (extsb) — the unsigned version matched all 1.27M disc names because every one is ASCII, but differs on 18,096/20,000 random high-byte inputs; and name_hash's reduction is EXACT, not lossy (0 wrong at every quotient boundary over the full 32-bit domain).
  • (2026-08-25) BOOT-NAV BUG FIXED and verified by artifact. dialog_up.py detects the dim the game draws behind a modal (mean 34 vs 5962), and wait_screen.sh --tap-if-dialog only presses while one is up; launch_mission.sh now verifies the "Load game?" dialog is actually open before selecting YES. Next boot: readyroom at 9 s, IN FLIGHT at 37 s (against three prior 300 s failures).
  • Tooling: .pe is NOT stale — it is a flat VA image (offset = VA 0x82000000), verified 7/7 against the DB. And instructions.raw in sylpheed.db is an INTEGER, not a hex string — decoding it as hex silently compares nothing and nearly recorded this correction backwards.
  • What ADVANCES a phase is still unknown and is not in the data: swept the stage record and every table it names plus the message\ family for interval|time|phase|delay|wave|spawn|arrival|trigger|start|appear|event|condition — only FrameCount and PresetMessage_Phase1/2/3 hit. It is in the executable.
  • 🟡 Does NOT close the 387-vs-~300 gap in roster-to-craft-link.md. Σn over Stage 02 is 387 vs 296300 live craft, but that was measured mid-mission after kills and squadrons deploy across phases, so they are not comparable as they stand. The earlier rejection of n assumed everything deploys at once — still untested. The record-key derivation also stays (the tag is not name_hash; a second hash function is unidentified).
  • S18S23 stage records were never missing. stagetbl.py Stage_S18 returns a full six-record definition and always would have; it was never run. They omit the literal Stage_S<NN> (no per-stage .xpr; AIParams/weapons/strings/ subobjectives/nameplate/collision all come from a shared _Tutorial set), so an enumeration keyed on that literal skipped all six. Counting distinct *_S<NN> names across all 1119 decompressed entries: UnitGroup and Route cover 28 stages (S01S16, S18S23, S24S29); Stage literal and AIParams 22; SUBObjectiveSettings 16 (story only). Tutorial records carry the same Phase_1/2/3 shape, so the tutorial is not a special mission type at the data layer. Dump in data/stage-tutorial-records.txt.
  • S17 alone is genuinely absent — it appears in none of the five families. Not a stage that lost its data; it does not exist. Matches the family split in challenge-mission-gate.md (story 116, tutorial 1823, challenge 2429).
  • 🔴 Refuted: GP_TUTORIAL.pak does not hold the tutorial stage config — 2 entries, both RATC, zero IDXD, exactly like GP_CHALLENGE.pak.

The dynamic-RE state is not in git, and it was gone

Found 2026-08-23. Everything the oracle runs on — the baseline emulator binary, the Xbox profile, the Stage 02 save, the shader/code caches — lives outside both repos and had been wiped. sylph-doctor says "all good" without any of it; the first symptom is NO PROFILE on disc one second into a boot.

Rebuilt and verified by driving it (LOAD GAME lists the slot → READY ROOM → Stage 02 flight): dynamic-re-state-restore.md carries the recipe — incremental rebuild of auto/upstream-baseline in the shared checkout (202 files, no submodule churn), profile bootstrapped with the instrumented binary's --create_profile_if_none, and the committed savedata-stage02-5pct.bin installed without an Xbox content header, which ContentManager::ListContent does not need.

Open, and cheap:

  • launch_mission.sh finishes unattended again (2026-08-23, later): boot → title → LOAD GAME → slot 01 → READY ROOM → TAKE OFF → IN FLIGHT at 34s, pilot bound and engaging. Two defects, not one: the fixed sleep 28 for LOAD → READY ROOM (now wait_screen.sh readyroom), and the READY ROOM being drawn before it is usablePreparing to Sortie, TAKE OFF greyed, which whole-image statistics cannot see (1.7 units of blue) so take_off_armed.py tests the label. A third defect fell out of the same run: wait_flight.sh was testing pixel (450,640) "inside the SHIELD bar" of a 1280×720 window, while screenshot crops to the 1279×675 game surface — it lands between the SHIELD and ARMOR bars. That is the long-standing "reported NEVER REACHED FLIGHT while plainly in flight" note, now explained and fixed.
  • Nothing guarantees this state survives the next container. If it is meant to, the profile + save + bin/ copies want a home inside a repo or a named volume; that is a call for the user, not for an agent.

🔴 SOLVED (2026-08-23) — the method for finding the mission objective counter is automated; the address is run-dependent

(Retitled 2026-08-26: the old heading asserted 0xbdb59668 as the answer while its own first line refutes that address. The method is the result.)

🔴 0xbdb59668 is refuted as a durable address (2026-08-23): 0 in two independent Stage 02 runs while the HUD read 004/008/012, on an allocated (not sparse) page. The method stands; the number does not, and every session must re-scan. Two candidates from the re-scan were themselves refuted by the corpus's own "verify across a transition you did not select on" rule. Detail and the corrected method note (the scan takes 0.9 s — the trap is the counter climbing 004 → 012 in four minutes, not scan duration) in structures/mission-objective-counter.md.

Settled the same day, once the HUD stopped costing a human round trip. ob_read.py reads the three digits by normalised template correlation and ob_hunt.py runs the whole method unattended; run 4 then gave one survivor from 35 897, selected on 004 → 008 and verified on the unselected 008 → 012, plus three live paired RAM/HUD readings. The blocker was never the pilot's survival — the evidence lives in the first four minutes of the stage, and the earlier runs simply could not look often enough to catch the 008 step.

🔴 Yesterday's refutation stands, refined: the address is not universal (runs 2 and 3 read a hard 0 there while the HUD counted), but it is not meaningless either — it recurs exactly, and run 3's amber candidate sits one 64 KB page below it at the same page offset 0x9668. Rule: try 0xbdb59668, check it against the HUD, re-scan (~5 min, ob_session.sh) when it reads 0. The follow-on that the autopilot actually needs is unchanged and untouched: what the counter counts, and whether an OB-badged entity carries a flag in its entity object.

What REMAINING OB counts — and an in-mission freeze in the way

2026-08-23. The address is settled (above); what it counts is not, and it is what the autopilot needs in order to CHOOSE a target. One run in:

  • 🔴 Not a live class head-count. Counter 4 against 8 attackers / 7 friendly Delta Sabers / 7 turrets / 1 player — no class matches, no pair sums to it.
  • 🔴 The per-entity flag is REFUTED (2026-08-23, final): sample A at counter 12 over 120 entities gave 2 candidates; the counter went 12 → 11 and neither survived. Within ±0x400 of the position triple there is no 4-byte word whose shared-value population tracks the counter. Not ruled out: a single bit ORed into a word that also varies (the test needs an exact shared value), anything outside that window, and anything on entities entities2 cannot see — it types by position changing, so stationary objectives are invisible. 🔴 The bit-level differential is REFUTED too (2026-08-24): two independently selected transitions in one run — scan at 4 filtered on 4→8 (29 of 710 survive), scan at 8 filtered on 8→12 (2 of 197) — and the intersection is empty. No per-entity bit in either polarity tracks the counter; 16 of pass 1's survivors were the same word +0x250 with different bits, i.e. a shared value, not a flag. Earlier note: 🔎 Built (ob_bitflag.py, 2026-08-24) and run three times with no verification yet: one window was spent on a mission that had already ended in GAME OVER, one hit the same dead mission, and the third had the counter at a different address and then froze after a single filter. Sample A alone gives ~187 set-polarity + 33 clear-polarity candidates at counter 4, so the second transition is the whole test.
  • ⚠️ Attrition is now the dominant cost of every in-mission item. Roughly half the runs that reach flight end early — a freeze, or a GAME OVER when the ACROPOLIS or the craft is lost — and a scan needs the run to survive two counter transitions. frozen.in_flight() at least makes a dead run say so immediately instead of waiting out its window.
  • What the counter's neighbourhood IS — the HUD glyph quads (2026-08-24). The four pointers that move with it lead to objects whose vtable is 0x820B2A64: 32 slots, methods 0x823c43b0…0x823c45a0, three construction sites in sylpheed.db. Each instance is a textured quad — a pixel size (34×42 for a digit) and four vertices of (colour, u, v) — and the UV rectangle × 1280×768 reproduces that pixel size to a rounding step, so the font atlas size is measured, not guessed. See structures/hud-glyph-quad.md.
  • 🔴 The counter is not "hostiles left" either. It held at 012 for fifteen minutes of live flight while the ADAN population fell 132 → 93.
  • But it decrements when the player kills: 12 → 11 with 411 fire=1 samples and YOU KILLED WARPLANES 0003 on the HUD — the first decrement seen, and the first run where the player's guns were part of the experiment.
  • pilot.py never fires — ROOT CAUSE FIXED (2026-08-23): flight_probe.Pad was writing to the vgamepad FIFO, dead since the uinput pad was replaced by --hid=file, so every axis, trigger and button from every flight tool went into a file nothing reads while /tmp/xenia_pad.txt stayed empty. The craft was never being flown. Verified: full stick went from 0.00° of heading change to 12.72°, and the attitude matrix from d 0.0000 to d 0.4438 — which also refutes the "stale attitude matrix" suspicion. And the second half: the PITCH stick sign was inverted. Measured on a 45° error, both sides, two pulse widths, with the opposite sign as control: the pilot's sign grew the error every time, the opposite shrank it every time. Fixed, and the pilot firesfire=1 in 43 of 1 732 samples against 0 of 13 521, aim down to 2.3°, range median 43 km → 6.3 km, and the HUD's own ammunition counters moving. 🟡 Still open: YOU KILLED is 0000 after 250 s of firing and REMAINING OB is still 012 — whether it destroys anything is the next measurement, and the objective-counter item is waiting on it. ⚠️ findrot_global.py, findself.py, findspeed.py, selfstate.py still write to the dead FIFO and ctrl_probe.py/target_probe.py still use pad.f; all flagged in place, none repaired. See pilot-never-fires.md.
  • EMULATOR GONE is SOLVED — it was this project's own Stop hook (2026-08-24), which kill -9s xenia_canary at the end of every agent turn. Every "mysterious" death was a turn boundary. Operational rule: an emulator experiment must complete inside one turn — nothing can be left running for a later tick, and a watcher armed for 1 500 s only watches the rest of this turn. Memory pressure was raised and refuted along the way; that measurement stands, it just was not pointing at anything.
  • 🔴 An in-mission freeze — the item in front of everything else. Reproduced with the Kernel channel on. 🔴 The resume-spin lead is refuted by its own control: a still-flying run has more refused resumes (2 738) than a frozen one, because the game runs a self-suspending worker and the host refusal is one per cycle by design. What is established instead: the guest is spinning, not deadlocked — over 10 s while frozen the main thread is in state R gaining 409 ticks, guest threads ~680 in total, and not one kernel call is made. So it is guest code waiting on guest memory. Seen from inside (2026-08-24, gdb): all 79 threads are in a wait — guest threads in KeWaitForSingleObject/NtWaitForSingleObjectEx, the GPU processor idle, the main thread in poll() — while the process still burns 1 253 ticks / 10 s, 403 of them in the TimerQueue thread and ~280 each in two guest threads the backtrace shows blocked. So they are cycling through a timed wait, and the CPU burn is in the kernel layer's wait path, not in guest code. No Canary build was needed: XENIA_BIN pointing at a gdb wrapper keeps the lockfile and satisfies ptrace_scope=1. 🔴 Reading the wait target from the log is blocked by cost: KeWaitForSingleObject is kHighFrequency and silent without --log_high_frequency_kernel_calls=true, and with it the emulator is 17 minutes into a boot with a black screen and 175 MB of log. Built (canary auto/re-wait-timeout-probe 820696c11, --log_stuck_waits=true, binary at /sylph-home/re/bin/waitprobe/): counts consecutive timeouts on the same object per thread and logs at 100 then every 500. Healthy-run control measured — 27 lines over 25 minutes, all one thread polling one Event at guest VA BE56BB5C with a ~30 ms timeout, so the freeze signal is a new (thread, object) pair, not the presence of output. 🔴 A freeze WAS caught (2026-08-24) and the probe says nothing. It froze 9 s into the watcher's window, in flight, and reported the healthy baseline only — one pair, same object VA, no new (thread, object) pair — while the CPU signature was unchanged (1 255 ticks/10 s, 401 in the TimerQueue thread). So the freeze is not a thread looping on timeouts against one object. Two blind spots survive: waits cycling over different objects (the streak resets, so they are invisible), or waits that succeed rather than time out (nothing for a timeout counter to count — which fits the self-suspending worker seen cycling successfully in the kernel log). v2 built and its baseline is itself a result (canary 597740046): it counts every call per thread per second with the distinct-object count and the return value. On a healthy 22-minute run the main thread cleared 500 calls/s in 224 windows, peaking at 1 235/s over up to 13 distinct objects, and the result was X_STATUS_SUCCESS in all 314 windows — not one timeout. So the game's normal mode is hundreds of successful waits a second across many objects, which is exactly what v1 could not see. 🟡 Consequence: 500/s is not self-selecting, so the freeze signal must be a different shape — far above 1 235/s, a new thread, or a non-SUCCESS result. Next: a frozen sample to compare against; runs 5 and 6 did not freeze (GAME OVER at ~22 min, and still healthy at 10 min). Three runs in a row have now failed to freeze (the third ended in GAME OVER), and the probe's healthy control is measured three times — 27, 24 and 36 lines, always the same single pair. frozen.py detects the state in one call; ob_hunt.py / ob_flag.py abort on it. Roughly two runs in three. See mission-freeze-resume-spin.md.

2026-08-27 — the "first step, revised" below is DONE, and --log_mask=0 is a dead end

The counter moves. pilot.py gained SYLPH_WEAKEST=1 (score scaled by the target's remaining hull, so fire concentrates on one already-damaged craft instead of spreading over a squadron) and the very next run drove REMAINING OB 008 → 007, concurrent with the live e010 floor dropping 16 → 15. REMAINING OB is now fully solved — see ob-counts-marked-attackers.md. So the obstacle this entry names is cleared; what the freeze work still needs is only a frozen sample for the v2 probe to compare against.

Do not try --log_mask=0 for that sample. Measured on a full Stage 02 run: 199 MB of log, growing ~33 MB/min, and a 300 k-line tail is 254 127 A> (Apu/XMA), 42 444 d>, 2 897 G>, 532 w> — with zero k> and only 14 K> lines in a 58 k-line boot sample. XamShowSigninUI, KeWaitForSingleObject and NtWaitForSingleObject each appear exactly once in the whole 199 MB, i.e. in an export listing, never as call traces. That independently confirms the cost note above: kernel calls are kHighFrequency and stay silent without --log_high_frequency_kernel_calls=true, so log_mask=0 buys nothing but audio spam. The purpose-built --log_stuck_waits probe remains the right instrument.

⚠️ That run also did not freeze — healthy from TIME 00:24.28 to 03:33.28, frozen.py animating throughout — making it the fourth consecutive non-freezing run across this entry and the last session.

First step, revised: make pilot.py shoot, then re-run ob_flag.py. The freeze is no longer the blocker it looked like — a 25-minute run stayed animating — and the actual obstacle is that nothing the pilot does moves the counter, so there is never a second sample. If the counter still will not move when the player is killing things, the next question is what does move it, and the objective card's own wording ("shoot down all invading enemy fighters") is the place to start. Second step, if that comes back empty: entities2.typed only sees entities whose position changes, so a stationary objective is invisible to it, and the enumeration itself would need widening before a null result means anything.

See mission-freeze-and-ob-flag.md.

The declaration table is not a paint order on every screen

Found 2026-08-17, building the Explorer's UI Screens browser on ui_layout. Status: 🔎 open — the pause menu is right, the title screen is not.

ui-rat-layout.md says the bundle's element declaration table lists elements "in back-to-front order", verified 11/11 on the tutorial pause bundle. That holds — the tutorial and in-mission PAUSE builds both composite correctly, and pgpeff02a → parent 3 / pgp_ttrl_btn10 at (546,288) / the 70 px button pitch all reproduce exactly.

GP_TITLE.pak build 7 does not. Painting in declaration order puts ptbase2.t32 (the full-screen background art, element 13) on top of the ptlogo1/ptlogo2 wordmarks (elements 05), which the real title screen obviously does not do. The pause bundles never caught this because their elements barely overlap.

What has been ruled out: there is no depth/layer key in the 60-byte declaration entry. Dumping every word across the title build's 30 entries, the unknown fields are constant — +28 is 0 everywhere, +44 is 0xffffffff everywhere, +56 is 0 everywhere — and +36, which the doc lists as 0xffffffff, is not a depth either: it is 0/1 only on the kind = 0x4 repeated-instance entries (ptlogo1/ptlogo2 copies), i.e. an instance index. So the order is not recoverable by sorting the table on any field it carries.

What that leaves. The background group is contiguous — elements 12, 13, 14 are pteff00.prm, ptbase2.t32, pteff04.t32, and 12 carries kind = 0x10, a flag no pause element has (theirs are 0x0 / 0x1 / 0x3002). pteff02.prm at 17 has it too. So 0x10 marking a PRMD primitive, and primitives opening a layer that draws beneath what precedes them, is the cheapest hypothesis — but it is a hypothesis, and "draw the .prm group first" would fit this one screen without being evidence of anything.

First step: composite GP_MISSION_SELECT / GP_READY_ROOM / GP_OPTIONS, which have both a background and overlapping foreground elements, and see whether their background sits at a 0x10-adjacent index too. Two more screens agreeing turns the hypothesis into a rule; one disagreeing kills it. The Explorer's screen render/screen info commands make that a minute's work per screen, and the per-element visibility toggles isolate a suspect element.

Meanwhile the viewer paints in declaration order and does not pretend otherwise — a screen whose background lands on top is showing you this bug, not a decode failure.

2026-08-18 — measured against the running game; three orderings refuted, and half the symptom was a different bug

The premise is confirmed by the oracle, which this entry had not had: a framebuffer capture of Canary on the title screen (captures/title-screen-oracle.png) shows the PROJECT SYLPHEED wordmarks (elements 05) drawn over ptbase2.t32 (element 13), which is a full-screen background. Declaration order is therefore not the paint order on this screen, and no reading of the element table changes that.

But part of what the render showed was not the paint order at all. In the capture ptbase2 covers the whole screen; the compositor drew it as a 960×540-visible slab starting at (320,180), because a keyframe's scale was being grown from the keyframe's corner instead of about the declared pivot. Fixed, and pinned against the capture by cross-correlation (peak at (0,0)) — see structures/ui-rat-layout.md. That was a real defect worth separating out: it moves 865 of the disc's 5 130 resting placements, on every screen, independently of any ordering question.

Three candidate orderings are now dead, all cheaply:

  • The placement region is not a second ordering. Its keyframe groups carry an explicit element index, so they could be stored in a different order — they are not, on every build on the disc (placement_region_order_is_never_a_second_ordering,

    500 builds, identity every time).

  • The RATC child order is not it either. For the title build it is the declaration order with the .prm elements absent — strictly less information, and it has no place to put ptbase2 other than where the table already puts it.
  • Reverse declaration order is refuted by the same capture: it would draw ptbase2 (13) over ptcopyright (28), and the copyright line is visible.

The 0x10-adjacency first step was run, and it does not survive. The background is adjacent to a kind = 0x10 .prm element on both screens that have one — but on opposite sides. GP_TITLE build 7 is 12 pteff00.prm (0x10), 13 ptbase2.t32, 14 pteff04.t32; GP_MISSION_SELECT build 0 is 0 px_mission_base.tbm, 1 px_mission_eff00.prm (0x10). So "the .prm opens a layer that draws beneath what precedes it" cannot place both, and no rule keyed on the .prm's position orders the background. GP_READY_ROOM and GP_OPTIONS turned out not to be the third and fourth witnesses this entry hoped for: neither of their largest builds carries a .prm or a full-screen background at all, so they cannot discriminate.

What is still open, stated plainly: nothing in the bundle has been found that orders element 13 behind elements 05. Every ordering the file itself carries is now either identical to the declaration table or refuted by the capture. The next step is no longer static — it is either the guest code that walks this table, or a per-draw capture of the title screen showing the order the game submits.

Blocker, checked rather than assumed. The obvious move is to reuse Canary's existing RE instrumentation, which is already in the built binary on sylpheed-re. Neither hook can answer this:

  • --log_draws (command_processor.cc) de-dups by a vertex-declaration fingerprint — shader hash + primitive type + per-stream element formats/offsets + index-buffer guest base — and writes each distinct one once. A screen's sprites share a declaration, so they collapse; and the record carries no texture identity and no per-frame submission order, only first-seen order. It is a mesh-format log, not a draw-order log.
  • The F10 ship capture does preserve per-draw order within a frame and de-dups on (vertex base, WVP transform, index range), which would separate the elements — but it explicitly drops UI draws: if (pos_off_bytes < 0 …) return; // no float-position stream (UI/effects) — skip. It requires an f32x3 position attribute, which a 2D quad stream does not have.

So this needs a new hook in Canary — log each draw in submission order with its bound texture fetch (or its screen-space quad), gated behind a cvar the way the other two are — and therefore a build-canary run. That is the cost to state up front rather than discover halfway in; it is not a container limitation, just a long build plus a title-screen run.

2026-08-18 (later) — the hook was built and run; the order is now measured

log_ui_draws exists (Canary branch auto/re-ui-draw-order), and the title screen's paint order is ground truth rather than a candidate: ui-title-paint-order-capture.md.

Background first, then the back2 glow pair, then ptlogo1 + ptlogo_tm, then ptlogo2, then ptcopyright, then the PRESS Ⓐ BUTTON plate — i.e. declaration indices 13, 22|24, 23, 0, 11, 1, 28 and then two elements that are not in build 7 at all. Two more orderings die on that evidence (keyframe start time, resting-keyframe time), and one structural fact reframes the whole item: the screen composites two bundles (build 7 plus the one-element build 2 that is the button), so no single build's element table can be the paint order whatever its order.

Still open, and now sharper: the rule. The bundle's 60-byte declaration entry carries no depth field (dumped, above); the per-element .rat record has not been checked for one against this ground truth, and nothing yet explains how the two bundles are sequenced. Both are static questions again — the oracle side is answered.

2026-08-18 (third pass) — the bundle does not carry the order at all

Three more places checked, all empty, so the static avenue for this item is exhausted (detail and evidence in ui-title-paint-order-capture.md):

  • the geometry has no depth. A UI quad's attribute 0 is k_32_32_32_FLOAT, so it carries a Z — and every Z in the capture is 0.00000. Submission order is the entire ordering.
  • the declaration table has no key. Every word of every entry dumped for the build the game actually runs: +28 0, +32 0xffffffff, +36 0xffffffff (except an instance index on kind = 0x4), +44 0xffffffff, +56 0.
  • the placement region has none either, including its per-group lead word, which is 0 for all 24 groups; and the region is followed straight by the RATC child stream, so there is no table hiding behind it.

Also corrected: the running screen is build 4, not the largest build 7 that screen info defaults to — the two disagree on sprite sizes and the capture matches build 4. The conclusions are unchanged, the indices are not.

So the next step is the guest code, not the file: the splash draw path from the emulator-era work (sub_821CC7A0, item vtable 0x820b30b4) submits with exactly the PS hash E59B2B3D this capture sees, and xenia-rs/sylpheed.db is available in the container.

And a second screen is NO LONGER BLOCKED, but it is not routine either. The main menu has been reached (screenshot in canary-scripted-input-traps.md), so the "Ⓐ is dead" reading is withdrawn. Not routine after all — see the 2026-08-19 tables in canary-scripted-input-traps.md: 4 of 5 successes without --log_ui_draws, 0 of 7 with it. An interleaved series refuted the boot-time confound (the latest title of all, 268 s, accepted Ⓐ; a 232 s title refused), and no mechanism exists for the flag — it is read only when F10 arms a capture, and F10 was never pressed. The variable was removed rather than believed — F10 now arms the capture unconditionally — and with it gone a fresh run still failed, so the flag is not the cause either. Net: Ⓐ succeeds about half the time and nothing measurable predicts which; five explanations are eliminated. The input path is now mapped statically (entry_pointsub_8216EA68 main loop → sub_822F1AA8 per-frame input → sub_82457038 pad poll → XamInputGetKeystrokeEx), and the poll itself is not state-gated, so the gate is in a consumer further up. Until that is separated, capturing a screen and navigating to it in the same run is not dependable. The earlier claim, kept: the title that ends the boot sequence accepts a single Ⓐ (2 of 2 at the time); the title the attract loop returns to accepts nothing (Ⓐ, START, B, BACK, X, Y — dozens of delivered presses). The proposed tell was refuted on the way: the two states draw 13 identical quads, ptbtn00 included, so they differ only to the guest. Recipe: first title after boot, one tap, and never tap during the boot (88 presses over the intro ends on a permanent black screen).

The second screen is captured — the main menu, GP_TITLE build 5 — and it does not discriminate: its background sits at declaration indices 12, so "declaration order" and "background first" predict the same sequence. Same failure mode as GP_READY_ROOM/GP_OPTIONS. The next screen worth capturing is one whose background sits late in its table, as the title's does.

The earlier reading, kept because it is what the evidence looked like: the title's Ⓐ leads into a content/save path that crashes the guest with --mem_watch=true and stalls it with --mem_watch=false. Three separate traps had to be cleared to establish that much — see canary-scripted-input-traps.md, which also carries the reproduction and the fix for two of them.

2026-08-18 (fourth pass) — the crash is named, and the code avenue is scoped

The crash PC resolves to an MSVC std::map/set erase that throws std::out_of_range from the game's cache-manager flush, and the trigger is now controlled: an incomplete on-disc cache throws ~100 s into a boot, a complete one never does. The access violation people have been chasing is only that throw returning, because this build does not unwind guest EH. And the handoff's suspect #1 is eliminated — cold cache with --mem_watch=false throws just the same, which withdraws a claim made here yesterday. See title-crash-stl-tree.md. That is a by-product of this item and belongs to whoever picks up the crash bisection.

For the ordering itself, three more negatives, all recorded in ui-title-paint-order-capture.md: the two time-based orderings were re-checked against build 4 (the previous pass used build 7's numbers, and build 7 is not what runs) and both still fail on the same element; and a fresh candidate — painter's order by resting Y — reproduces the capture to within a single transposition but is refuted by ptlogo_tm and by the background, so it is not the rule either.

The code avenue is scoped rather than walked: the splash item vtable 0x820b30b4 is real (25 slots, three construction sites), RTTI carries no class names disc-wide, and the format tags are fourcc immediates behind a virtual call rather than strings — so this needs a deliberate read of the UI engine, not a keyword search.

Capital ships assemble wrong in the viewer

Reported: 2026-07-30, by the user. Status: format-side cause found and fixed 2026-08-12 — see below for the 2026-08-10 diagnosis this supersedes.

The remaining format-side defect this entry pointed at (a shared turret decoding ~100× too large in some containers) was real and is gone. e303_wep_01 decoded as a 1600×2100×4800 block in Stage_S02, swallowing the e106 hull; requiring an index buffer to cover its vertex pool exactly moved it to the block every other container agrees on, and it now decodes 49×23×42 everywhere and places at ±179 on the hull. The same fix repaired e106_bdy_03 (a 600×1600×998 slab) and moved 29 anchors disc-wide, 22 of which had been carrying another resource's geometry under their own name. See structures/xbg7-mesh.md.

Two things are worth carrying forward rather than closing:

  • the assembler was audited and exonerated — every composite node carries scale 1.0 and an orthonormal matrix, so nothing on that side inflates a part;
  • no metric caught this. Coverage, cross-container consistency, the capture oracle and the twin invariant were all green while a 1 600-unit slab sat through the ship. It was found by rendering the ship and looking at it, and the numeric screens written afterwards to automate that check both failed.

The 2026-08-10 diagnosis follows, and its viewer-side pointers still stand.

Status (2026-08-10): 🔎 the format layer is exonerated. Runtime captures of three classes (f105, e105, e106) at controlled range reproduce assemble_ship to ≤0.43 units in translation and to 0.000 in rotation for every part that does not move; see ship-placement-capture-generalisation.md §4. So look at the viewer: first that it passes include_external = true (iso_loader.rs:4012 — with false an e106 loses its bridge and both nacelles, 5 parts instead of 11), then its own transform stack.

One real format-side bug was found on the way and is fixed: index-less parts (e105_brg) never matched their GN_Bridge_01 hardpoint, so 34 (stage, ship) entries — e102, e104, e105 across Stages 0229 — assembled without a bridge. The other apparent exception (e105_eng_01 rotation) was an aggregation artefact and is 0.000.

The original report and its reasoning follow.

The reborn viewer builds capital ships from the split XBG7 parts via sylpheed-formats::ship::assemble_ship, and they come out wrong — parts in the wrong place / wrong orientation.

Why this is a real finding and not a known limitation: the RE write-up ship-placement-runtime-capture.md declares static assembly exact as of 2026-07-26 — 9-channel joint tables [TX TY TZ RY RX RZ SX SY SZ], Euler Ry·Rx·Rz, with ship::tests::static_assembly_matches_runtime_capture asserting static == runtime capture (T < 1.0, R < 0.02). So either the viewer is not using that path, or the claim generalises worse than the test suggests.

The likely gap: that test is one ship — the e106 destroyer, 8 parts plus two nacelles, two turrets and the hull mirror. Nothing pins the other classes. Rules that were derived from e106 and could easily be e106-specific:

  • the engine cluster rig mounted at GN_Engine_01 (two mirrored nacelles + centre);
  • "X-reflect the shared-geometry twin whose lateral offset opposes the geometry's dominant side" — a heuristic, not a decoded flag;
  • cross-id turret instancing (×2).

First step (the oracle already exists): re-run the runtime capture on a different capital ship and diff static vs captured, exactly as e106 was done — F10 in the capture-ship-placement build of xenia-canary-native dumps the ship shader's c0..c2 WorldViewProjection rows per part; WV_ref⁻¹ · WV_p is the ship-space rigid transform, which is ground truth. Pick a class whose rig differs from e106 (different engine count, a ship with no sld, a carrier). Then extend static_assembly_matches_runtime_capture into a per-ship table so a regression in one class cannot hide behind e106 passing.

Also worth ruling out first, cheaply: that the viewer's own transform stack (scale, handedness, node-instance recursion) is not re-breaking a correct assembly — compare the viewer's placement against assemble_ship's output directly before blaming the format layer.


Viewer: include_external is already on — that hypothesis is dead

Checked 2026-08-11. The item above names "first that it passes include_external = true (iso_loader.rs:4012)" as the cheap first step. It does: ShipBrowser::show_external defaults to true (iso_loader.rs:643), the checkbox reads it (ui.rs:1593) and it is threaded through RequestShipRenderbuild_ship_modelassemble_ship unchanged (ui.rs:1689, iso_loader.rs:4012). So a ship rendered by the viewer is the full external assembly, not the bare hull.

The viewer also does not have a transform stack of its own to blame: it bakes ScenePart::apply straight into the vertices and rotates normals by the same p.m (iso_loader.rs:4030-4062), so its placement is assemble_ship's output by construction. What remains unexcluded, in order of cheapness: the mirror handling (det < 0 reverses triangle winding only — a reflected part keeps its reflected geometry), Xbg7Model::models_named resolving the wrong sub-model when a resource name repeats, and the exhaust cones. Next step is a visual: the diagnosis has run out of things it can settle by reading, so the viewer needs to be run against a known-good class (e106) and its render compared with ship_render's.


Viewer: the duplicate-resource-name hypothesis is dead too

Checked 2026-08-11. The diagnosis above left three candidates for why capital ships assemble wrong in the viewer: mirror handling, Xbg7Model::models_named resolving the wrong sub-model when a resource name repeats, and the exhaust cones. The second is now refuted, and comprehensively.

build_ship_model resolves each placement with base.iter().find(|m| m.name == p.resource) (iso_loader.rs:4041) — first match wins — so a repeated resource name inside a container would silently draw the wrong geometry. It cannot happen: decoding every XBG7 resource in all 22 stage containers gives 4 603 resources and zero repeated names.

Stage_S01  62/62      Stage_S07 323/323     Stage_S13 290/290     Stage_S25 351/351
Stage_S02 304/304     Stage_S08 388/388     Stage_S14  22/22      Stage_S26 318/318
Stage_S03 214/214     Stage_S09 316/316     Stage_S15 386/386     Stage_S27 321/321
Stage_S04 179/179     Stage_S10   7/7       Stage_S16  65/65      Stage_S28 118/118
Stage_S05  92/92      Stage_S11 157/157     Stage_S24 162/162     Stage_S29 386/386
Stage_S06 266/266     Stage_S12 376/376

Per-ship it is tighter still: e106 wants 9 distinct names and decodes exactly 9 models for 11 placements; e105 9 for 9; f105 5 for 6. Every placement resolves to the one model it names.

So two of the three candidates are gone (this one and include_external), leaving mirror handling and the exhaust cones — and the still-untried visual comparison, which remains the right next step.


Viewer: mirror handling and the exhaust cones are cleared too — the static avenue is exhausted

Checked 2026-08-11. Both remaining candidates were tested across every ship on the disc, and neither shows the reported signature.

Mirror handling. The concern was that ScenePart::apply bakes R·(S·v)+T while the viewer takes its winding-flip decision from det(m) alone and rotates normals by m alone — both ignoring s. A mirror encoded as a negative scale would then reflect geometry without flipping winding, drawing the part inside-out. It never happens: across 1 485 assembled parts in all 22 containers there are 22 mirrored parts, every one with det(m) < 0, and zero parts with a negative scale or a non-uniform one. apply_twin_mirrors writes the reflection into m (negating its X column), so the viewer's flip always fires, and ignoring s for normals is harmless because s is always uniform.

Exhaust cones. These are the one piece of geometry the viewer invents — a cone at each GN_Jet/GN_SJet frame, because the real engine geometry is recessed and the game draws FX there instead. If they landed wrongly they would read exactly as "a part in the wrong place". Across 335 assembled ships, 192 of which have exhaust frames, not one cone sits outside its hull's bounding box (tolerance 10 % of the axis span).

Caveat, stated rather than glossed: "inside the hull box" does not prove a cone is right — orientation and size are untested, and a cone could be wrong while still inside. What it does rule out is the reported symptom for that part.

So every mechanism this diagnosis proposed is now eliminated: include_external, duplicate resource names, mirror handling, and cones-in-the-wrong-place. The format and assembly layers pass every static test available, and the visual comparison is no longer merely the next step — it is the only remaining one. Render e106 in the viewer beside ship_render's output of the same assemble_ship result; if they agree, the bug is in neither and the original report needs re-grounding against a specific ship and a specific expectation.


⚠️ DIAGNOSED 2026-08-12 — a mis-decode; the locality fix was written, then withdrawn

Resolution at the end of this entry. Kept in full because the two wrong turns along the way (a "stray volume", then "monotonic anchoring") are the useful part.

⚠️ The format layer is NOT exonerated — but the cause is a MIS-DECODE, not a stray volume

Found 2026-08-11 by finally doing the visual, which the notes above kept naming as the next step. It overturns their conclusion.

Render e106 from the static assembly and from the baked runtime capture and compare — ship_render does both:

placements parts
runtime capture (ground truth) 8 bdy_01…04, brg_01, eng_01, eng_02, wep_02_01
assemble_ship(--static) 11 the same 8, plus e303_wep_01 ×2 and a second e106_eng_01

The render makes it obvious: the destroyer sits inside a white slab that dwarfs it (capture). That slab is e303_wep_01, and its own geometry is:

e303_wep_01     172 verts, 110 tris   bounds X[-1000, 600] Y[-1050, 1050] Z[-2400, 2400]   1600 x 2100 x 4800
e106_wep_02_01 1002 verts, 772 tris   269 x 179 x 417      ← what a real e106 turret looks like
e106_brg_01     202 verts, 202 tris   105 x  76 x 305

110 triangles, perfectly round axis-aligned bounds, and bigger than the ship it is mounted on.

CORRECTION (same day, one iteration later): it is not a volume — it is a bad decode

The first reading of this was that e303_wep_01 is a collision/trigger volume the assembler wrongly draws. That is wrong, and the evidence that settles it is decoding the same resource from every container that holds it:

Stage_S01   172 verts 110 tris   X[-24.5, 24.5] Y[0.0, 23.4] Z[-20.8, 20.8]   ← 49 × 23 × 42, a turret
Stage_S02   172 verts 110 tris   X[-1000, 600] Y[±1050]      Z[±2400]         ← 1600 × 2100 × 4800
Stage_S03…  172 verts 110 tris   49 × 23 × 42  (correct)
Stage_S08   …                    1600 × 2100 × 4800
Stage_S26   …                    1600 × 2100 × 4800

Same resource, same vertex and triangle count, decoding correctly in eleven containers and wrongly in exactly three (Stage_S02, S08, S26). So:

  • the placement is legitimatee303_wep_01 is a small shared turret, cross-mounted on e101 and e106, and at its true size it is unremarkable;
  • the original author's explanation of the capture's silence (vbase dedup) stands, and my "dedup would show one, not zero" objection does not survive: with the correct decode the turret is small, ordinary geometry;
  • the defect is in the mesh decoder, which resolved this resource's vertex data differently in three containers.

The render and the symptom are real; the cause named in the first version of this entry was not.

The part that matters more than this one resource

The decoder can produce wrong geometry without declining. The XBG7 audit counted 814 resources it refuses — a visible, honest failure. This is the other kind: e303_wep_01 decodes "fine" in Stage_S02 and is silently 100× too large. Screening for the signature (bounds that are exact multiples of 50 with a span over 1000) flags 2232 models in each of S02, S03, S08, S26, S27but that screen also catches legitimate e_rou_* composite proxies, so it is a candidate list, not a count of bugs.

Next: diff the anchor scan's chosen vb0 for e303_wep_01 between Stage_S01 (correct) and Stage_S02 (wrong) — same resource, two outcomes, so the divergence is directly observable — then use whatever distinguishes them to add a post-decode sanity check, so a silent 100× mis-decode becomes a decline.

Why this was missed

assemble_ship treats every rou_* node in the composite as a drawable part, and the doc comment states the cross-id mount as intended behaviour — "INCLUDING repeated instances and cross-id turret mounts (rou_e303_wep_01_root ×2 on the e106 hull)" — with ship::tests::static_assembly_matches_runtime_capture asserting count("e303_wep_01") == 2. The absence from the capture was explained away as vbase dedup, but dedup would show one instance, not zero.

The test cannot catch it either: it walks the capture's parts and looks each up in the static output, so extra static placements are invisible to it. That is the same shape of gap as the earlier include_external hypothesis — a test that can only fail one way.

Scope, stated carefully

Sweeping all 335 assembled ships for the signature ship-scale span with under 400 triangles flags 20 ships and 58 placements over 28 distinct resources (e005_ant_*, f001_ant_*, f002_bdy_*, f301_barrel, f303_body, e303_wep_01, …). Only the e106/e303_wep_01 case is proven — by render, by capture absence, and by geometry. Some of the others may be legitimately large low-poly parts, and each needs the same three checks before being called a bug.

Still true, and independent of the correction above: static_assembly_matches_runtime_capture walks the capture's parts and looks each up in the static output, so extra static placements can never fail it. That is worth fixing regardless — it is the same one-way-test shape as the earlier include_external hypothesis.

Also unchanged: only two cross-id placements exist fleet-wide (e303_wep_01 on e101 ×24 and e106 ×36, across 335 assembled ships), so cross-id mounting is a narrow, real feature rather than a systemic guess.


Resolution (2026-08-12)

anchor_pool_mesh took the first candidate in file order from a container-global scan, so a resource could be handed another resource's block whenever both shared (stride, vertex count, index count). Fixed by anchoring each resource near its descriptor neighbours (two-pass: learn, then re-anchor).

  • it took inconsistency 125 → 51 with coverage unchanged, and made e106 render correctly (after)
  • but it flipped the e106 twin-mirror decision, which static_assembly_matches_runtime_capture (ISO-gated, so it skips in a plain cargo test) catches against the runtime capture — so it was reverted
  • the user-reported "capital ships assemble wrong" is therefore diagnosed, not yet fixed; see xbg7 for what the real fix needs

Still open from this entry: static_assembly_matches_runtime_capture walks only the capture's parts, so extra static placements still cannot fail it.

2026-08-18 — that last line was stale, and the residual gap is now closed too

The one-way-test complaint had already been fixed when this entry was written down: 64d372c (the revert commit itself) added an extras check, so "extra static placements cannot fail it" has not been true since. Checked rather than assumed — perturbing the expectation makes the test fail with the real disc behind it, so it runs and is live, not a SYLPHEED_ISO-less skip.

But it compared a set of resource names, which leaves one direction open: a resource placed twice when the capture lists it once changes no set. That is not hypothetical — a duplicated instance is exactly what a bad node walk emits, and the two legitimate duplicates here (e106_eng_01, e303_wep_01) are the reason the test had to special-case counts at all. Replaced with the full multiset, pinned to the e106 ground truth:

e106_bdy_01 1  e106_bdy_02 1  e106_bdy_03 1  e106_bdy_04 1  e106_brg_01 1
e106_eng_01 2  e106_eng_02 1  e106_wep_02_01 1  e303_wep_01 2

— 9 resources, 11 placements, against the capture's 8 dedup'd parts. That subsumes the two hand-written count assertions, and it now fails on an extra resource, a missing one, and a duplicated one. Refuted before believing: declaring e106_bdy_01 twice makes it fail, with the real multiset on the left.

Not closed by this, and worth keeping separate: the multiset is e106's alone. The generalisation this entry originally asked for — a per-ship table so a regression in one class cannot hide behind e106 passing — still needs a runtime capture of a second capital ship.

That entry's stated blocker is stale (checked 2026-08-19): the ship capture is in the current build — RequestShipCaptureFrame / CaptureShipDrawForRE are in command_processor.cc on auto/re-ui-draw-order, and F10 wrote a 2.9 MB xenia_ship_capture_01.log from this session's binary. No separate capture-ship-placement build is needed.

Update 2026-08-19: the mission is now REACHABLE. With the Canary threading fix, tutorial_launch.sh drives boot → title → menu → TUTORIAL and the mission loads and renders (flight HUD, "Go to the box on your screen"). It then freezes under 13 243 crash dumps, all at 0x82307128, preceded by exactly one guest C++ throw — identical frames 6 s apart, no new dumps, 400 % CPU. So the blocker moved from "cannot reach a mission" to "the mission freezes". 🔴 The cache is REFUTED as the cure (3 runs): the missing entry \aab216c3\6 was real and got written, and the run with a complete cache stormed anyway — 11 497 dumps, all 0x82307128. But a usable window exists: both post-cache runs ran the mission with exactly 2 crashes for 5680 s before the storm, where the first run was at 641 by t+24 s. The ship capture needs F10 armed inside that window. Done, and the mission ran with ZERO crashes — first clean mission run, fully rendered. 🔴 But the capture contains no ship geometry: 181 deduped draws, 180 sharing one vertex shader, all screen-space, none 3D — against a known-good 2.9 MB capture from an earlier session. The 8 000-draw budget was not the limit and the scene was rendering. ⚠️ The "cache refuted" claim above is overstated: this run used the same complete cache as tut4 and got 0 crashes vs 11 497, so variance dominates. 🔴 Corrected: nothing is broken. Ship geometry is stride=24 prim=4 with a large vcount (vcount=10891 for a real one); this capture has one prim=4 vcount=6 quad and the 2.9 MB "known-good" file has no prim=4 at all — it is a UI capture (1 303 of 1 582 draws are stride=24 prim=13, the UI sprite shader). The earlier "3D draws" test counted UI sprite coordinates as 3D. The capture recorded what was on screen, and the tutorial's opening has no capital ship. What remains is what the capture doc always said: play into a real mission and frame a ship side-on — gameplay driving, not a menu step, and the original was taken on HW Vulkan where this container has lavapipe. 🔴 The resume-refused lead (1 663 on one thread) is REFUTED: that thread did execute, and KeWaitForSingleObject / NtWaitForSingleObjectEx are kHighFrequency, which is unlogged unless --log_high_frequency_kernel_calls=true — so a parked thread is invisible and the refusals are just the guest kicking a worker blocked on an object. Method note: the title-loader finding rested on host CPU time, not log silence, which is why it stands and this did not.

What blocked it before was the cache-flush crash, not navigation — measured 2026-08-19. tools/re-capture/tutorial_launch.sh (which retries whole boots, because re-pressing the same title never works) gets all the way from the title through the main menu to DIFFICULTY and then SELECT DATA, and the guest dies there at 0x82307128 — the same std::map/set erase as the boot-time throw, 537 stacked dumps, with --mem_watch=false. See title-crash-stl-tree.md.

So a second capital-ship capture needs that crash dealt with first. Everything up to the save-slot screen is now scripted and works, and one run got past it — SELECT DATA reached with zero crashes, slot chosen, the game proceeding into a cinematic — before crashing at the same 0x82307128. The crash is intermittent in where it fires, not whether, so there is no menu route around it. See title-crash-stl-tree.md for the end-to-end measurement and for what has been ruled out (--mem_watch=false, twice).


2026-08-27 — ORDOR_SQUADRON_EXTENDED opened: the reactive-chatter rule table

The biggest unopened record name in the 3 496-name census (864 records) is a record in the chatter rule table: 144 objects per language pack × 6 packs. Write-up: structures/preset-message-rules.md; artefact data/preset-messages.txt; regenerator tools/re-capture/preset_messages.py.

Docs checked before claiming anythingsquadron-orders.md, sound-cue-table.md, cutscene-message-table.md, ixud-localised-text.md, isl-message-dialogue-link.md, movie-subtitles.md, mission-phase-deployment.md, mission-phase-membership.md. Two of them own parts of this:

  • ixud-localised-text.md owns "PresetMessage_* is reactive combat chatter keyed by speaker class", and mission-phase-deployment.md owns the route in (UnitMessageSet_S<NN>.tblPresetMessage_Phase1/2/3). What neither says is what is inside the table.
  • cutscene-message-table.md owns the Generic+Message_NNN layout for the 32 cutscene objects per pack. What it does not say is that the same layout carries 137 more objects per pack for in-mission chatter. Control: of 273 PresetMessage_* names that hit an IDXD entry, 136 are rule tables and 137 are message tables — the prefix names two different shapes.

One schema, no variants. All 9 216 event records (144 × 64) carry the same seven named fields — EffectiveTime, Interval, Priority, Probability, MessageCount, IntervalFluctuation, Pattern — then positional (message id, Yes/No) pairs. MessageCount · 2 == positional count in 9 216/9 216, zero mismatches, which is what pins the pairing.

Named twice, independently, with the same answer. Harvested PresetMessage_* strings hashed under message\ → 136/144; the PresetMessage_Phase1/2/3 declarations of the 22 UnitMessageSet_S<NN>.tbl tables → 136/144; the two routes name the same set (set identity, not a matching count).

Joins to the settled cue table: 2 388 of 2 405 distinct message ids resolve (99.3 %) through sound-cue-table.md's index.

⚠️ A trap that doc had already paid for, and I walked into it anyway. Rewriting MSG_VOICE_ unconditionally scores 2 353/2 405, and the 52 misses looked like a finding ("lines with no recording"). 44 of them were a naming artefact: sound-cue-table.md states that a name already carrying VOICE_ keeps the one it has. Applying the documented exception gives 2 388/2 405. Reading the owning doc caught this after the first write-up was drafted — the earlier grep found the ownership, but the rule inside it still had to be read.

Correction to squadron-orders.md — it rendered the 0x820AEEB0 enum as twelve ORDER_* names. The executable's own strings misspell all four SQUADRON entries as ORDOR_*, and the disc data carries the identical misspelling, which is how the two sides join. That doc's 🔴 "this is a UI/comms axis, not the order classes" now has its explanation: the twelve are bark events.

Not settled. What the seven numeric fields mean (schema only, not measured against the running game); the Yes/No element of each pair; which of the 49 Sperkers speakers is a wingman (the "13 tables voice every player-facing event" partition is a count, not a verified roster). 🟡 8 of the 144 rule tables are named by neither route — nothing on the disc declares them. UnitMessageSet_S<NN>.tbl exists for 22 of the 28 stages and the six missing are exactly S18S23, the tutorials (same set as the missing AIParams_SNN.tbl), so tutorial chatter is the plausible reading — but there is no tutorial UnitMessageSet to confirm it, so it stays a reading.


🔴 2026-08-27 — the 8 undeclared chatter rule tables: all named, and NOT tutorial

Continues the entry above. 144/144 rule tables are now named, and the reading I left in that entry is refuted.

Two more naming routes, both cheap:

  • Route 3 — strip _msg. A message table's name gives its rule table's name. 137/144, a strict superset of route 1, and it hits zero entries that are not rule tables. It adds PresetMessage_Katana_14_S16-2.tbl.
  • Route 5 — sweep the grammar. PresetMessage_<who>[_NN][_S<nn>-<p>].tbl over the 86 <who> tokens the other routes expose. Adds PresetMessage_Margras_04_S14-1.tbl. 1/144.

Route 4 — the roster predicts the name, 6 of 6. Among the already-named two-speaker tables the rule is transparent: <FACTION>_Fleet<X><Y> speaks (<FACTION>OP<Y>, <FACTION><X>), and TCAF_..Ship<X> speaks (ADANPL<X>, TCAF<X>). The six remaining unnamed tables are six gaps in two series. Predicted from their rosters, then hashed: TCAF_FleetBB, TCAF_17thFleetBB, TCAF_17thFleetCA, TCAF_17thFleetCB, TCAF_17thFleetShipA, TCAF_17thFleetShipB6/6. Control: four same-shaped names the series does not contain (TCAF_FleetCC, TCAF_17thFleetCC, TCAF_ShipA_01, TCAF_17thFleetShipC_01) score 0/4, so it is not the case that any plausible name lands on an unnamed slot.

🔴 Refuted: "the undeclared tables are tutorial chatter." It was the obvious reading — UnitMessageSet exists for 22 of 28 stages and the six missing are exactly S18S23. But the eight are two story-stage tables (Katana_14_S16-2, Margras_04_S14-1) and six TCAF fleet/ship tables carrying no stage tag at all. Not one is a tutorial. Recorded rather than deleted: the inference from "S18S23 have no UnitMessageSet" to "the undeclared tables are theirs" skipped the step of looking at what is in them — all six residuals speak MSG_TCAF_* with TCAF fleet rosters.

Also settled in the same sweep: every one of the 144 rule tables has its _msg companion present, in all six language packs (the earlier count of 137 message tables was a count of harvested names, not of entries — six of the eight have message tables whose names appear as strings nowhere).

What the tutorial evidence does support, narrowly: no rule table is tagged _S18_S23 and the tutorials ship no UnitMessageSet, so on this evidence the six tutorial missions have no reactive chatter of their own. That is a smaller claim than the one it replaces.

Still open from the entry above: what the seven numeric fields mean, the Yes/No pair element, and which speakers are wingmen.


2026-08-27 — the seven chatter fields, read off the engine's loader

Continues the two entries above. sub_82213980 is the only function in the executable that references any of the seven field-name strings — they sit contiguously at 0x820A57940x820A5800, and all seven xrefs land in it. It is the rule table's loader: it walks records with sub_82448AA0 /sub_824482D0 / sub_82448BC8 and builds a 40-byte object per event.

Docs checked: preset-message-rules.md (mine), movie-subtitles.md (its "Pattern" is a filename-pattern table heading — unrelated), mission-phase-deployment.md (owns the radio delivery category None/Emergency/Killed/Noise in ScriptMessage_S02_msg.tbl — a different table on a different axis; Killed sits at 0x820A5784, immediately before this string block, which is precisely the adjacency that invites conflating them).

The three time fields are SECONDS at 60 Hz. The loader emits mulli rN, rN, 60 for Interval, IntervalFluctuation and EffectiveTime, and for nothing else. Disc values are all small ints ({0,3,5,10}, {0,3,4,5,30,120}, {0,2}), so seconds is the only reading that survives — 120 s = two minutes.

Pattern is a delivery channel with two dead arms. strcmp against "Log" → 2, "Window" → 3, "Demo" → 4, default 1. The disc says only Sound (8 395, → default 1) and Window (821, → 3): Log and Demo exist in code and in no data.

The Yes/No element is a 32-bit mask — and that explains the clamp. Two passes over the positional payload: sub_82448BC8(rec, 2·i) takes the even slots (message ids), sub_82448BC8(rec, 2·i+1) the odd, strcmps each against "Yes" (0x820A57A4) and sets 1 << i in a u32 at [obj+32]. The loader clamps MessageCount to 32 — the same constant as the mask width. The data agrees: max MessageCount on disc is 26, nothing exceeds the clamp, and no record's Yes count exceeds its own MessageCount. This re-derives the pair layout from the executable side, independently of the MessageCount · 2 == positional count identity it was first inferred from.

Probability is a gate, not just a weight. Read as a sign-extended byte; zero skips the record before the object is allocated. Values are percentages (1,2,3,5,10,20,25,30,40,50,60,100).

Object layout: +16 Probability u8, +17 Priority u8, +18 Pattern u8, +20 Interval·60, +24 IntervalFluctuation·60, +28 EffectiveTime·60, +32 the Yes mask, +36 zero.

Residual, characterised. Cross-tab of MessageCount == 0 against Probability == 0 over all 9 216 records: 6 786 zero on both, 2 417 set on both, 11 with a Probability but no lines, 2 with lines and Probability 0 — those two ship dialogue the loader can never reach. Thirteen dead records.

Not settled: the policy. The loader gives units and storage, not behaviour. Priority's comparison rule, whether Interval is a cooldown per event or per speaker, and what IntervalFluctuation randomises against all live in the consumer of the 40-byte object, which this iteration did not read. Pattern's Window is named, not measured. Also still open from the earlier entries: which of the 49 speakers are wingmen.


🔴 2026-08-27 — how the three phase tables merge, and one refuted handle

Continues the entry above. Docs checked: preset-message-rules.md (mine), mission-phase-deployment.md and mission-phase-membership.md (they own UnitMessageSet_S<NN>.tbl and its CrewCount + PresetMessage_Phase1/2/3 — what they do not say is what happens when the three tables are loaded), idxd-tag-hash.md (owns the hash family the map key comes from).

The three phase tables become ONE map. sub_82215A58 references exactly CrewCount and PresetMessage_Phase1/2/3 and reaches the loader from a single call site (0x82215D98). The map is keyed by a hash of the event record's name (sub_82455C78). All 64 event names recur in every phase table, so every merge collides.

sub_82213840 is the reconciliation comparator — called from nowhere but the loader. It compares the two message-id vectors elementwise plus +16 Probability, +17 Priority, +18 Pattern, +20 Interval, +24 IntervalFluctuation, +28 EffectiveTime. It does not compare +32 (the Yes mask) or +36. On a collision the loader frees the newcomer and keeps the incumbent either way; a mismatch also clears the byte it returns, which the caller propagates.

Measured on disc (regenerator extended, numbers in data/preset-messages.txt): 298 UnitMessageSet records name ≥2 phase tables; 26 432 duplicate-key insertions; 26 208 (99.15 %) identical; 224 different, of which 189 differ only in the message list. ⇒ The phase tables are not additive — phase 1 wins and 224 authored variants never play. A port that merges additively, or lets a later phase override, will not match the game.

A negative worth keeping: zero pairs agree on the six compared fields while disagreeing on the Yes mask, so the comparator's exclusion of +32 changes nothing on the shipped disc. It looks like a latent bug until counted.

🔴 Refuted handle for the firing policy. I tried to find the consumer by intersecting functions that touch +16/+17/+18/+20/+28/+32/+36. It returns dozens of unrelated functions across the binary — offset shape is not an identifier, which the corpus already warns about and I re-learned by spending a query on it. The remaining handle is the map's consumer reached via sub_82215A58sub_82214050.

Still open: the firing policy proper (who rolls Probability, compares Priority, ticks Interval, what IntervalFluctuation randomises); which of the 49 speakers are wingmen.


2026-08-27 — who fires a bark, and the first half of the firing policy

Continues the chatter thread; reached through the call graph, after the offset-intersection handle was refuted last iteration.

Docs checked: preset-message-rules.md (mine), squadron-orders.md — it owns sub_82320B48 as the selector for the twelve-string 0x820AEEB0 enum and warns it is a comms axis. What it does not say is that the function is one of eight bark firing sites sharing a single entry point, and that it also raises COUNTER, COUNTER_TO_PLAYER and FOUND_PLAYER.

36 of the 64 event names are executable strings (0x820AEEC80x820B2160), so the string-xref join names the firing sites directly: sub_823800A8 (17 events — damage/destruction), sub_82320B48 (15 — squadron orders + 3), sub_8237B020 (9 — player state), sub_823B1438 (ammo), sub_82381B10 (evasion), and three singles.

sub_8220FA98 is the shared entry — the only callee common to all eight. Lock around this+4; enable byte [this+8843]; speaker lookup in the map at [this+8820]; index [this+8816] 1 into a vector inside that entry (phase is a reading, not measured); event-name lookup → the rule object; then the roll.

Probability is a per-occurrence percentage — now measured, not inferred. f0 = rand01 · 100.0 and f0 > [rule+16] returns without firing. The constant at 0x820856F8 is exactly 100.0, read from the image. sub_8220D970 folds two rand() calls into a 16-bit uniform.

sub_82210670 picks the line. (1) Walks the pending list at [this+8736] for a node matching this (speaker, rule) pair and bails if the same bark is already queued. (2) Recomputes MessageCount from the vector and takes a message as eligible only if its bit is clear in [rule+36] — the word the loader zeroes — so +36 is a runtime exclusion mask ("already spoken" is the obvious reading; the write site was not found). (3) Uniform pick: eligible · rand01, subtract 1.0 (0x8208583C) per eligible bit until the counter empties or the accumulator reaches 0.0 (0x8209FD28). (4) Reads [rule+18] Pattern, [rule+17] Priority, [rule+28] EffectiveTime and hands them with the chosen message to sub_822109B0.

Measured negative: the pick path does not read +20 Interval, +24 IntervalFluctuation or +32 (the disc Yes mask). They are consumed after the hand-off. Within the chatter CU (0x8220C0000x82216000), +17 and +18 are read by exactly two functions — the comparator and this one — which is what makes the attribution safe where the binary-wide search was not. The bounded offset search is sound once the call graph says which functions matter; the unbounded one is what was refuted.

Still open: everything after sub_822109B0 — how Priority orders the queue, whether Interval is a per-event or per-speaker cooldown, what IntervalFluctuation randomises, where +36's bits get set, and what reads +32. Also still open: which of the 49 speakers are wingmen.


2026-08-27 — the bark queue, Priority, and Yes/No settled

Continues the chatter thread, bounded to the CU as the previous entry proposed. Docs checked: preset-message-rules.md (mine). Nothing else in the corpus mentions sub_822109B0, sub_8220FC50 or sub_82210AF0.

The pending queue. sub_822109B0 allocates 68-byte nodes from a pool at this+32; [this+8740] is the count and the enqueue returns 0 once it reaches 128 — barks past that are dropped. A slot is free iff [node+23] == 0, and +23 holds Pattern, which is never 0 for a loaded rule: the presentation field doubles as the occupancy flag. Node layout recorded in the doc.

Priority orders the list, and its fast path is dead data-wise. Insertion tests Priority against 10: ≥ 10 pushes straight to the front with no ordering; < 10 splices ahead of the first node with a strictly smaller Priority, leaving the list descending by Priority. The disc's range is 1…9, so the front-push arm never runs on retail data — every shipped bark takes the ordered path. sub_82210AF0 returns the largest [node+20] among pending nodes of higher priority whose [node+24] != 0x10: a preemption/ducking query.

Yes/No is a ONE-SHOT flag, and the +32/+36 pair closes. In the per-frame tick sub_8220FC50 (callers sub_821AB650, sub_821B5FB8), at 0x82210160:

rule[+36] = ((1 << node[+25]) & rule[+32]) | rule[+36]

The line just spoken enters the used mask only if its Yes bit is set. sub_82210670 picks only from messages whose +36 bit is clear, so Yes = the line is heard once and never again, No = it stays in the pool. That is what the census counts: 8 940 No, 388 Yes ⇒ 388 one-shot lines on the disc. It also answers the previous entry's open question "what reads +32" — nothing, until a line finishes, and then only to gate this AND.

The tick branches on Pattern < 3 (taken side uses this+8776), so Window (3) is a different presentation path from Sound (1) — the two values the disc actually ships.

Still open, and now it is only two fields: whether Interval (+20) is a cooldown per event, per rule or per speaker, and what IntervalFluctuation (+24) randomises against. Neither is read by the entry, pick, enqueue or tick paths read so far — sub_82210320 was checked as a candidate and is a different structure (a deque at this+8744, with a divide-by-60 frames→seconds), not the rule object. Also still open: which of the 49 speakers are wingmen.


2026-08-27 — Interval + IntervalFluctuation: the chatter system closes

The last two open fields. Docs checked: preset-message-rules.md (mine); nothing else in the corpus mentions sub_82210C38.

sub_82210C38 is the retire-a-line routine (called from the tick sub_8220FC50 and from sub_8220FB90) and is the only place in the chatter code that reads the rule's +20/+24 as words — which is what separates them from the byte fields at the same offsets in the queue node. On retirement:

node[+16] = (int)( (float)rule[+24] * rand01() + (float)rule[+20] )
node[+24] = 0x20

Interval is a floor, IntervalFluctuation is uniform additive jitter — the cooldown is uniform over [Interval, Interval + IntervalFluctuation) frames. Disc: Interval ∈ {0,3,4,5,30,120} s, jitter 0 or up to +2 s (813 records).

The cooldown is per (speaker, event), and the queue node IS the timer. node[+16] — which held EffectiveTime while the line played — is reused. In state 0x20 the tick decrements it per frame and only at zero unlinks the node, clears [node+23] to free the slot and decrements [this+8740]. This closes the loop with the "already queued" bail in sub_82210670: they are the same mechanism. A retired line keeps its node alive for Interval + jitter frames, and for exactly that long the same speaker cannot repeat the same event. The rule object holds no timestamp; nothing per-rule or global is involved.

⚠️ Port note: a lingering node still occupies one of the 128 pool slots — a 120 s Interval parks a slot for two minutes.

Same routine: stops the presentation via sub_822168E8 on this+8776 (Pattern 1) or node+28 (Pattern ≥ 3); and when [node+20] == 1 sweeps the pending list calling sub_82217980(…, 0) on every node not in state 0x10. With sub_82210AF0 (max [node+20] over higher-priority pending nodes) that makes +20 a ducking level, raised while a higher-priority line plays and released on retirement.

The chatter system is now closed end to end — data, loader, phase merge, firing, probability, pick, queue, priority, one-shot flag and cooldown.

Not established (and none of it changes the data model a port needs): the numeric attenuation of the ducking level, the meaning of node states other than 0x10/0x20, and whether sub_822168E8/sub_82217980 do more than stop/resume. Still open elsewhere: which of the 49 speakers are wingmen.


2026-08-27 — the player-facing partition, and a correction I owed

Docs checked, and two of them already owned the question I set out to ask:

  • isl-condition-builtins.md owns the wingman rosterUNITS in GP_HANGAR_ARSENAL.pak is seven fields pairing a craft slot with a pilot (Bird1-Sandra, Bird2-Billy, Bird3-Antonius, Bird4-Carl, Rhino1-Raymond, Rhino2-Katana, Rhino3-Ellen) and names it as such. So "which of the 49 speakers are wingmen" did not need an experiment.
  • unit-group-table.md owns the player link — Stage 02's own flight is UN_f001_TCAF_DeltaSaber_T_Player with msg=MessageSet_Katana, id=Character_Player_Test, beside msg=MessageSet_Ellen.
  • hangar-loadout-system.md owns the PlayerSET_* rows.

Correction to preset-message-rules.md (mine). It said the 14 player-facing events are "voiced only by the wingman tables". Wrong twice: the 13 tables are all PresetMessage_Katana_*, i.e. the player's sets, and the wingman roster is a separate already-settled list in which Rhino2-Katana is the player's own slot. Corrected in place with a note.

What the experiment did establish — an exact partition. Of the 144 rule tables: 13 voice all 14 player-facing events, 131 voice none, and 0 voice some. Not a tendency — zero partials. And none of the 129 non-Katana tables voices any of the 14, so LOCKON_*, PLAYER_HP_LESS_*, PLAYER_AMMO_LESS_* and ORDER_WINGMAN_* are exclusively the player's own callouts. CharacterKATANA is the only one of the 15 speakers in that set exclusive to it. Regenerator extended; artefact carries the partition.

🟡 Residual: there are 15 Katana tables, and the two that voice none are Katana_09_S10-1 and Katana_14_S16-2 — all-or-nothing like the rest. Why those two stage-phases opt out is not established from the disc.

Method note: the grep that would have caught this ran after the experiment was designed, not before. The rule says grep the doc that owns the DATA first; here the data was "wingman", the owning doc was isl-condition-builtins.md, and reading it first would have reframed the iteration rather than corrected it afterwards.


🔴 2026-08-27 — AA_/AV_ are one interleaved block; the selection is still unfound

Docs greped BEFORE designing the experiment (the rule that cost me last iteration): unit-datasheet-static.md owns the field family and already says 🟡 "anti-air vs anti-vessel is the obvious reading — not adopted, nothing here shows the selection"; live-unit-definitions.md owns the runtime field map and records that AA_AxisMode_*/AV_AxisMode_* default to 0; unit-struct-runtime.md owns the Maneuver declaration-order result; flight-speed-law.md and isl-builtins.md mention the prefixes in passing. What none of them says is which code reads them.

Exactly one function references the names. sub_822F9498 — the unit-definition loader, called only from sub_821A6CF0 — reads 78 named float fields; nothing else in the image touches an AA_/AV_ string.

The layout is an exact interleave: AV_ at X, AA_ at X+8. PitchPlus +196/+200 vs +204/+208; PitchMinus +212/+216 vs +220/+224; Yaw +228/+232 vs +236/+240; Roll +244/+248 vs +252/+256; AxisMode +320/+324 vs +328/+332. The constant +8 is the finding — a selector is an offset of 0 or 8, not two lookups.

Data control: the ten suffixes match exactly between families (list equality, not a count); 690 records each for the eight rate fields, 36 each for AxisMode. AA_ is usually the larger value (AA_AxisMode_Min 100 vs 35; AA_Yaw_Max 50 vs 25) — but AA_PitchPlus_Min 100 vs AV_ 200 goes the other way, so that is a description, not a rule.

⚠️ The 20 strings exist twice (0x82085B98… and 0x8209F674…); the loader uses the second block and the first 20 are referenced by nothing.

🔴 The selection is NOT settled, and the offset route failed again. Functions loading two or more of +196/+204/+320/+328 number 39 across the image — those offsets are far too common to identify a consumer. Only two are call-graph-reachable from the loader's owner (sub_821A6CF0, sub_821AB650); that is the next handle. "Anti-air vs anti-vessel" stays a reading, exactly as unit-datasheet-static.md had it.

Method slip to record: I dumped ~1 100 disassembled instructions to find the name→offset mapping when a scripted addistfs pairing (which I then wrote anyway) gave the same 20 lines. Script the extraction first; never eyeball a 4.5 KB function.


🔴 2026-08-27 — the AA_/AV_ selection is BLOCKED for static RE

Continues the entry above. Two more routes tried, both refuted with their cause shown; per the standing rule, when every route has a control and they all read zero, the item is blocked — writing it down and moving on.

Route 3 — call-graph bound. The only two functions reachable from the loader's owner, sub_821A6CF0 and sub_821AB650, each read 196/200/204 in three consecutive instructions off three different base registers (r25/r24/r11 and r30/r29/r11). That is three unrelated objects contributing one float each — not the AA/AV block. Refuted.

Route 4 — data-flow bound. The definition object lives in a global: the loader is fed by lwz r3, 13708(r26) with r26 = 0x828F0000, i.e. [0x828F358C]. 18 functions touch that global and 9 of them also touch block offsets — a far better bound than the 39 from offset shape alone. The two best candidates are both refuted:

  • sub_8230D1F8 is a different loader. Resolving its field names the same addistfs way gives +256 CraftScore_Adjustment, +320 FFPenalty_Zessel_Maximum, +324 RankScore_S, +328 RankScore_A, +332 RankScore_B — it stores at exactly the offsets the unit definition uses for AA_Roll_Min and the four AxisMode fields.
  • sub_82398CC0 uses r19 as a float-constant pool (320(r19) next to 272(r19) and -656(r19)), not as a definition pointer.

🔑 This explains the whole pattern of failure. The offset region is shared by at least two unrelated objects and by a constant-pool base, so every offset-based discriminator is contaminated by construction — that is the third time the offset route has failed on this corpus, and now with a mechanism rather than just a count.

🔴 BLOCKED. Catching the selection needs a runtime watch on the definition object's +196+332 during flight — emulator work, NEEDS-HUMAN, not a disc or image read. The layout finding stands; "anti-air vs anti-vessel" stays a reading.

Side finding, unowned: sub_8230D1F8 is the rank/score table loaderRankScore_S/_A/_B, FFPenalty_Zessel_Maximum, CraftScore_Adjustment. Grep confirms nothing in docs/re/ mentions any of those names. A cheap item for a future iteration.

Method slip, second time in two iterations: I dumped ~400 instructions of sub_82398CC0 by eye before writing the six-line name-resolution script that settled both candidates. Write the script first — it is in the standing rules and I broke it again.


2026-08-27 — mission scoring and the S/A/B/C/D rank thresholds

The side finding from the blocked AA_/AV_ item, followed up. New doc structures/mission-scoring.md; regenerator tools/re-capture/mission_scoring.py; artefact data/mission-scoring.txt (TWELVE artefacts now).

Docs checked first: isl-condition-builtins.md owns AUTO_SETTINGS (the 28 stageNN_settings.tbl filenames); unit-datasheet-static.md and unit-struct-runtime.md own the per-unit ScorePoint/DamageScore/ MassScore. RankScore, TimeBonus, KillBonus, FFPenalty and Difficulty_* appear nowhere in docs/re/ — the scoring rules were unowned.

sub_8230D1F8 is the stage-settings loader, not just a rank table: its 122 name→offset pairs cover cameras, post-processing (bloom/DOF/fog/colour), supply squadrons and movies, phase names — and the scoring block. Reading its names rather than its offsets is what identified it; the offsets were the contaminant that blocked the AA_/AV_ item.

24 IDXD objects per language pack × 6 = 144, each holding exactly Score_Easy / Score_Normal / Score_Hard ⇒ 72 records per pack, one 22-field schema, no variants. Groups: five RankScore_* thresholds; three *Score_Adjustment multipliers; MainMissionCount/Bonus; three TimeBonus_*; KillBonus_Maximum, FriendlyVesselDamageBonus, GettingScoreBasis_*; five penalties. Two of the game's typos are join keys: TimeBonus_Maximun and FFPenalty_Zessel_Maximum.

Difficulty changes the earning rate, never the bar. Comparing the three records inside each object, 10 of 22 fields move and 12 never do — and the 12 include all five RankScore_*. Multipliers run ×0.5 / ×1.0 / ×2.0. So a rank bar is per stage and identical on every difficulty; difficulty scales how fast you earn against it and how hard penalties bite. Measured over all 24 objects, not inferred from the names.

23 of 24 objects differ from the commonest Score_Normal record, mostly in the thresholds and the objective count/bonus. Nine of the 72 records (three objects × three difficulties) zero the multipliers, penalties and time bonus — unscored missions.

🔴 Which object is which stage is NOT settled. None of AUTO_SETTINGS's 28 filenames resolves to any of the 24 objects under 19 prefixes. 24 objects vs 28 declared files is itself unexplained — and the standing rule applies: do not assume the missing four are the tutorials without opening them.

Method note, and it worked: the offsets that made sub_8230D1F8 a false positive last iteration are the same offsets that made it findable — but only after switching from offset to name. Read what a loader names, not where it stores.


2026-08-27 — the rest of the stage-settings object: cameras, player limits, difficulty

Continues the scoring entry. New doc structures/stage-settings-table.md; mission_scoring.py extended; artefact data/mission-scoring.txt grows by the whole-object census. Still twelve artefacts.

Docs checked first. isl-condition-builtins.md owns Camera, ControlTweak, Rendering as records in GP_HANGAR_ARSENAL.pak — the game-wide defaults. What it does not say is that Camera recurs per stage in the GP_MAIN_GAME_* packs. stage-definition-table.md / challenge-mission-gate.md own StageResource; stage-mission-tables.md owns LodResource/MotionResource. SplinterCell and Difficulty_* are unowned.

⚠️ Two families share the Phase_1/2/3 record names. 53 objects in GP_MAIN_GAME_E.pak contain a Phase_1: 29 are the resource manifest (StageResource/LodResource/MotionResource, Phase_N = 4 named fields) and 24 are the settings table (Camera/Player/SplinterCell/Difficulty_*/ Score_*, Phase_N = 3190 fields). Counting without partitioning would have merged them — a record name is a file-local role, again.

This answers the 24-vs-28 puzzle left open last iteration: the settings family is 24; the 29 — matching AUTO_SETTINGS's 1…29 tag range — is a different table.

Camera: three chase rigs, and 13 of 14 fields are identical in every stage. Nose (0, 4.5, 7.0), Near (0, 10, 40), Far (0, 15, 80), all FOV 0.92; CameraNear 1.0, CameraFar 3 000 000.0 with a single stage at 500 000.0. World unit is 1 m, so these are metres — directly portable.

Player: BulletLimit 512, HomingLimit 256, LaserLimit 32, Pitch/Roll/YawAdjustment 0.30, AirDragFactor 1.0 — all constant across 24. GravityFactor is non-zero in 4 of 24 stages (250/400/700), and IsBoss16Enable appears in exactly ONE object, which identifies it as stage 16 — the first per-stage handle for a family whose filenames do not resolve.

Difficulty_Easy/Normal/Hard is a SECOND difficulty record, 8 damage and guidance multipliers, separate from Score_*. So difficulty is expressed twice in one object: once for scoring, once for damage.

🟡 Not settled: the Phase_1/2/3 block of the settings family (3190 fields — post-processing, fog, DOF, unsharp mask, supply squadrons/movies, UnderCommandSquadron, MapPath) is the largest remaining piece and was not read. Which object is which stage, beyond IsBoss16Enable. SplinterCell Count 5 is a name and a value only.


🔴 2026-08-27 — the Phase_1/2/3 block of the settings family

The largest remaining piece of the stage-settings table, now read. stage-settings-table.md extended; mission_scoring.py extended; artefact data/mission-scoring.txt grows. Still twelve artefacts.

Docs checked first. stage-definition-table.md owns MapPath/MapMesh — in the Stage_SNN records, naming .rgn/.col MiscBin objects; regn-map-grid.md and stage-mission-tables.md also carry MapPath. UnderCommandSquadron, BGOperateFrameName, SupplyMovie*, FinalPassVinetting, Nebura_* and ScreenColor* are owned by nothing.

72 records, 94 distinct field names, 31 in every one — the post-processing core, five Fog*, three ScreenColor*, SpaceSize, five Supply*, UnderCommandSquadron, and the first BGOperate slot. Optional families sit in clean tiers: Nebura_* (12) + ColorLayer* + BGRotateScale in 69/72; DOF_* (7), UnsharpMask_* (7), ExposureKey_* (4) in 15/72; FinalPass* (11) in 3/72.

🔴 Refuted: BGOperateFrameCount is NOT the number of BGOperateFrameName_i slots — 36 of 72. I expected the Count·k pattern that pinned the chatter pairs and the cutscene pages, and it does not hold. What holds is Count ≤ slots in 72/72: the slot array is fixed (1, 4 or 5 present) and Count says how many are live — the same shape as MessageCount under the 32-slot clamp. Recorded as a refutation because the strong form is the one a port would naively implement.

68 of the fields common to all three phases NEVER differ, in any of the 24 objects. The whole Nebura_*, DOF_*, UnsharpMask_*, FinalPass* families, most fog/colour settings, SupplySquadron2 and SupplyRange are per stage, duplicated into all three phase records. What moves — never in more than 6 of 24 — is BGOperateFrameName_0 (6), SupplySquadron1 (5), then Brightness, BrightPass*, ColorLayerA, BGOperateFrameCount, UnderCommandSquadron, FirstAdaptedLuminance, BGOperateFrameName_2 (3 each). The phase block is a per-stage environment block copied three times, and what a phase change is actually for is the backdrop animation and the supply/command squadron.

⚠️ MapPath is not in this family. Its 87 records per pack are 29 × 3 — the resource manifest's Phase_N, not the settings'. Arithmetic, not assumption: the settings family is 24 objects, the resource family 29.

🟡 Still open: which settings object is which stage beyond IsBoss16Enable; SplinterCell Count 5; what SpaceSize, GlareType and Nebura_* mean numerically.


2026-08-27 — all 24 settings objects named: stage\StageParameter_S<NN>.tbl

The grep-first rule paid for the whole iteration. The queued item was the 29-object resource manifest — and reading the owning docs first showed it is already settled: challenge-mission-gate.md counts exactly 29 stage records (16 story + 6 tutorial + 6 challenge + Test, StageResource schema 0x3c9ae32e) and stage-mission-tables.md documents the six sub-records including the Phase_N block with MapPath/MapMesh/AsteroidDefinition/ BackgroundResourceID — the exact four fields I measured. Nothing to add. So the iteration moved to the open question those docs make answerable.

24/24 named. Hashing every string that appears in any IDXD object on the disc — rather than guessing filenames — resolves all of them: stage\StageParameter_S01…S16.tbl (story), S24…S29 (challenge), StageParameter_Tutorial.tbl shared by all six tutorials, and StageParameter_Test.tbl. 16 + 6 + 1 + 1 = 24, which is why the settings family is 24 against 29 stage records: the tutorials do not get one table each.

IsBoss16Enable confirmed independently. Predicted last iteration to mark stage 16; the object carrying it is 0xEECC84F2 = StageParameter_S16.tbl.

⚠️ The names are NOT AUTO_SETTINGS's. That record lists stage01_settings.tblchallenge06_settings.tbl, and none of those 28 hashes to any of these 24 objects (19 prefixes tried). Two different naming schemes for stage-scoped parameters; the pak holds only StageParameter_*.

With names attached, earlier findings acquire stages: GravityFactor is non-zero in S10 (700), S11 (400), S03 (250), S27 (250); the three objects that zero their score multipliers are S24, S27, S28; the object without a SplinterCell record is _Test.

🔴 Refuted on the way: TOC order is not stage order. The 24 objects sit in a separate TOC region (661, 905, 9681030) from the 29 resource objects (622771), and the IsBoss16Enable object is 17th in TOC order, not 16th — the TOC is hash-sorted, which is arbitrary with respect to stage. Also, neither the StageResource template (13 fields) nor a real record (19) names the settings table, so the link is not through the stage record. ⚠️ I first sampled res[0] and got the _Test templatenever conclude from one sample.

Method note: the win was harvesting all disc strings and hashing them, after guessing filenames had failed twice. The real name resembled nothing I would have guessed.


2026-08-27 — correction: the naming sweep already covered the 24; it just never said so

Docs checked first, and this time the grep contradicted me. archive-naming.md owns disc-wide pak naming, and archive_naming.py already harvests 6 027 candidate names under 16 prefixes. Testing its candidate set directly: it names all 24 StageParameter_S<NN>.tbl objects, 24/24 — they were covered from the start.

Correction to the previous entry. It presented "hashing every string on the disc resolves all 24" as a new capability. It is not new. What was genuinely new was the identification — which object is which stage, that the six tutorials share one table, that IsBoss16Enable is S16 — not the naming method. The standing note that "the sweep has never been run corpus-wide" was also wrong: it has, and its artefact is data/archive-naming.txt.

The real gap, now closed. The sweep reported only per-archive percentagesGP_MAIN_GAME_E … 751 named, 67.1 % — and never emitted which entry got which name. That is exactly why nobody could say the settings objects were StageParameter_*. archive_naming.py now also prints, per archive, the resolved name families (digits collapsed to #) with counts: 6 573 named entries, 1 631 families across the disc, e.g. GP_MAIN_GAME_E = 751 named in 212 families (EnumUnit_S#.tbl ×28, StageResource_S#.tbl ×28, StageParameter_S#.tbl ×22, …). A coverage statistic is not a name table.

⚠️ Determinism, caught again by the verify loop. The resolved map is built by iterating the candidate set, so collided hashes got a different winner each run and the artefact failed its own byte-identical check. Now iterated sorted(). Second time this class of bug has appeared in two iterations — any map built by iterating a set needs a sort.

🟡 Not settled: the low-coverage archives are unchanged — DefTables 8.9 %, GP_HANGAR_ARSENAL 22.6 %, GP_MISSION_SELECT 25.0 %, GP_DEBRIEFING_PILOTLOG 33.3 % — and the content index now makes it possible to say what kind of thing is missing from each, which is the obvious next step. 🔴 The 2D / GP_READY_ROOM archives stay blocked.


2026-08-27 — why each archive is low-coverage: two different reasons

Uses last iteration's content index rather than re-running naming. archive_naming.py extended to split every unnamed entry by magic and, for IDXD, by record-name shape.

Docs checked: archive-naming.md (mine, owns the percentages); stage-definition-table.md / stage-mission-tables.md / arsenal-item-weapon-chain.md own the names EnumLODSet_*.tbl / EnumGameModel_*.tbl; isl-condition-builtins.md owns the 40 <?xml entries as XPR2 resource manifests (and already corrected the "config reader is XML" claim — not new here). Level_0, EnumMotions and ReferenceFrames appear in no document.

The three "low-coverage" UI archives have ZERO unnamed IDXD. GP_HANGAR_ARSENAL: 180 IDXD, 180 named, 0 unnamed — its 1 191 unnamed entries are 1 149 T8aD/RATC + 42 LSTA, i.e. sprites. GP_MISSION_SELECT and GP_DEBRIEFING_PILOTLOG contain no IDXD objects at all; their unnamed remainder is 64+2 and 148+8 artwork entries. So "22.6 %" for GP_HANGAR_ARSENAL is misleading — every data table in it is named, and these three are the same phenomenon as the 🔴 blocked 2D / GP_READY_ROOM archives, not a data gap.

DefTables is the only genuine data gap on the disc — 1 425 IDXD, 130 named, 1 295 unnamed, in 17 record-name shapes, two families covering almost all: Generic + Level_0…Level_3 (807, LOD sets) and Default + EnumMotions + Generic + ReferenceFrames ± Motion_break/Motion_dead/ Motion_down (463, motion sets). One LOD table and one motion table per model, keyed by model names the disc does not spell out.

Control, and it separates cleanly: GP_TITLE and the other 100 % archives have no unnamed entry of any kind; GP_MAIN_GAME_E is 667 named / 337 unnamed IDXD with zero unnamed artwork. Two failure modes, not a gradient.

🟡 Not settled: the model names behind the 1 295 DefTables tables (the XPR2 manifests name meshes like machines\rou_e104\objects\…xbg — whether those model names hash to the LOD/motion table keys is untested). The 337 unnamed IDXD in each GP_MAIN_GAME_* are also uncharacterised — the same census would say what they are, and was not run this iteration.


2026-08-27 — the Enumerate declaration tables close DefTables (99.2 %)

Item taken: "do the XPR2 manifest model names hash to the DefTables LOD/motion tables?" — refuted, and the real naming source found next to it.

Docs checked first: archive-naming.md (mine); isl-condition-builtins.md owns the 40 XPR2 manifests; stage-definition-table.md / stage-mission-tables.md own the names EnumLODSet_test.tbl / EnumGameModel_test.tbl and record that they live in DefTables.pak — but neither says what is inside them.

🔴 REFUTED — the XPR2 manifests are not a naming source. Their 82 Name= values and 82 DataFile/Source paths, hashed bare and under .tbl/.xbg/.xpr/.prt/game:\, resolve 0 DefTables entries. They do share the model namespace: 40 of the 82 appear as the Model field VALUE inside the LOD/motion tables (1 272 distinct Model values). ⚠️ The first attempt regexed Name=" and found 0 values — the manifests write Name = "…" with spaces; a regex miss looks exactly like a null result.

An IDXD object whose single record is named Enumerate is a DECLARATION TABLE: its FIELD NAMES are the names of other objects, each resolving as name_hash("<field name>.tbl"). EnumLODSet_test.tbl declares 676 names, EnumGameModel_test.tbl 360; the disc holds 144 such objects (138 in DefTables, one in each GP_MAIN_GAME_*) declaring 1 298 distinct names.

DefTables named by the string harvest (route 1) 130
named by the declaration tables (route 2) +1 283
still unnamed 12
coverage 1 413 / 1 425 IDXD = 99.2 %

130 + 1283 + 12 = 1425, no overlap between routes. Zero partials and the suffix is exact: of 5 suffixes × 6 prefixes, ('', '.tbl') scored 1 036/1 036 on the _test declarations and every other combination scored 0.

Residual, in full: 8 declaration tables nothing declares (the other 130 are named by route 1, from the stage tables that reference them), 2 Generic+Level_0 LOD sets whose Model reads rou_e004/rou_e013, and 2 motion sets. 15 declared names have no pak entry — content that did not ship.

archive_naming.py extended with route 2; artefact +17/18, every line paired (the DefTables census row 130→1413 and its shape list collapsing 17→3). Byte-identical across two runs.

🟡 Not settled: why those 12 are unreachable — no attempt was made to name the 8 orphan declaration tables or the 4 stragglers. And the six GP_MAIN_GAME_* Enumerate objects named nothing there; what they declare and where it lives was not chased.

Handed forward: with DefTables closed, the largest unnamed block is the identical 337 IDXD in each GP_MAIN_GAME_*, 53 shapes led by Shell+Weapon (90), Faces+Generic (64), the unit shape Effect+Explosion+Generic+Maneuver+Mass+SE+Shield+StructureCount (44) and a chatter table (8) — weapon/unit/chatter families the corpus already documents under other names. Characterising them is the next item.


2026-08-27 — the GP_MAIN_GAME_* unnamed block: 333 of 337 already owned, 4 genuinely new

Item (a). New regenerator tools/re-capture/main_game_unnamed.pydocs/re/data/main-game-unnamed.txt (112 lines, byte-identical across two runs).

Control first: all six language copies carry the identical 337 unnamed IDXD hashes, so this is one set, not six.

By record-name shape (53 shapes), 333 map onto documented families: 131 weapon datasheets (weapon-datasheet-static.md), 114 unit datasheets, 64 unit Faces tables (unit-datasheet-static.md), 10 unit message sets + 8 chatter rule tables (preset-message-rules.md), 5 enumerations/formations/ placement (stage-mission-tables.md, weapon-struct-runtime.md).

🔑 114 is exactly the corpus's unit count (43 Craft + 71 Vessel) — these are the tables the corpus already works with, reached by shape because they have no names. Naming them would add nothing.

⚠️ The Enumerate object in each GP_MAIN_GAME_* is EMPTY — zero fields. That answers the open sub-question: it declares nothing, which is why route 2 named 1 283 entries in DefTables and 0 here.

The 4 unclassified objects are new → new doc docs/re/structures/player-tuning-tables.md:

  1. 955ca077 — the analog stick response curves. 8 AnalogRevice_* records (yaw/pitch/roll/throttle/adv_yaw/adv_roll/eye_yaw/eye_pitch), each 11 positional values + a named Count = 11⚠️ the Count-names-the-group guess was TESTED and holds 8/8. yaw, roll and throttle are the identity ramp; the shaping is all on pitch and the camera axes. 🟡 adv_yaw peaks at sample 6 and falls back to 0.208 — not a deflection curve; unexplained. Plus a Tweak record with deadzones (mov_stick_play 5000, eye_stick_play 6000).
  2. 890e1be4 — the player craft's flight envelope. Booster (50 fields) is the player side of the AA_/AV_ pair unit-datasheet-static.md documents for NPCs, with afterburner twins; MaximumVelocity 2100, CruisingVelocity 612. Plus TacticalManeuver (the Turn180/roll evades whose morph poses the mesh work already found), SpecialAttack + SpecialAttackGauge_1/2/3, SpecialWeapon, Misc.
  3. 0202269d — the Stage 16 boss, identified by its own shell ids (Shell_S16Boss_AAGun/_Laser/_HBeam); Guardian HP 65 000, Core 42 000. Cross-checks the IsBoss16Enable hook in stage-settings-table.md.
  4. 🟡 a6c6c79b — one Generic record, one field named eff_n0071, empty value. Not identified.

🟡 Not settled: the Tweak units are inferred from magnitude, not read from a consumer; adv_yaw's meaning; object 4. No loader was located for any of the three new objects — this is a data-side characterisation only, so which code reads the curves is open.


🔴 2026-08-27 — the player-tuning consumer: found for half of it, BLOCKED for the other half, and two of my own labels refuted

Item (a). Static only; no artefact changed (the census output is unaffected).

The consumer is sub_822F9498 — the unit-definition loader the corpus already documents (live-unit-definitions.md, ← sub_821A6CF0, global [0x828F358C]). The field-name block 0x8209F3000x8209F600 (BulletTimeVolumePC, FireBirdPower, ChargeMaximum, Turn180RequiredTime, ShieldDoubler_Time, HPRegenerator_RegenSpeed, ExtraCartridge_Quantity, …) is 36/36 referenced, every reference inside that one function.

🔴 REFUTED — "Booster is a new schema". Its 50 field names are a strict subset of the unit Maneuver record: 50/50 shared, 0 Booster-only. CruisingVelocity occurs in exactly 115 records disc-wide — 114 Maneuver + this one. ⚠️ My first check compared against Generic and scored 0/50, which read as "brand new": the wrong record of a multi-record object is not a control.

🔴 REFUTED — "Booster is the player craft's flight envelope". flight-speed-law.md measured ~125 / ~420 / ~1 530. Unit Maneuver 100/350/1 200 gives consistent ratios 1.25 / 1.20 / 1.28; Booster 100/612/2 100 gives 1.25 / 0.69 / 0.73. The flight tracks Maneuver.

What Booster actually is: a second, faster profile39 of 50 values identical to the player unit's Maneuver, and of the 11 that move the three velocities scale by exactly ×1.75 (350→612 = 612.5 rounded; 1 200→2 100; SideThrustVelocity_Max 500→875) and two accelerations by ×1.5 (600→900; 1 000→1 500). 🟡 What selects it is not settled.

🔴 BLOCKED — the AnalogRevice_*/Tweak reader. That schema is its own contiguous block at 0x820A119C0x820A1378, headed by ControlTweakName, then the 12 Tweak names, then the 8 curve names — 0 of 28 strings has a code xref, and no instruction anywhere names a 0x820A1xxx operand. Control: the adjacent block sub_822F9498 reads scores 36/36, so the absence is real. Cause unexplained; the static route is exhausted. A runtime watch on the curve values is the way in (NEEDS-HUMAN).

⚠️ The twin-string-block trap, caught: mov_stick_play/eye_stick_play exist twice; only the second copy (0x820AA630) is referenced, by sub_822AE628 — which also references ControlTweak, Camera, Rendering, BackGround and the DOF/bloom names. ControlTweak records live only in GP_HANGAR_ARSENAL.pak (×6); Tweak only in the six main-game paks (×1). sub_822AE628 reads the hangar's control table — a clean, wrong answer if taken for ours.

🟡 Not settled: what selects the Booster profile; what adv_yaw's non-monotone curve indexes (unchanged — it needs the reader); why the 0x820A1xxx block has no xref at all when its neighbour has 36.


🔴 2026-08-27 — the object is PlayerParams, and sub_822F9498 is ITS loader, not the unit loader

Item (a), "what selects the Booster profile". It answered a different, bigger question and corrected a corpus doc. Static only; no artefact changed.

Resolving every string sub_822F9498 references, in code order, gives 90 — and they are exactly the 890e1be4 object's schema in its record order: Misc (5 effect names), SpecialAttack (+17), TacticalManeuver (+7), SpecialWeapon (+8), Booster (+50). It never names Generic, Maneuver, Explosion, Shield or StructureCount; the string Maneuver exists once and has 0 xrefs. One call site, from sub_821A6CF0 — itself called once, and referencing the literal PlayerParams.

🔴 CORRECTION to unit-datasheet-static.md, which calls sub_822F9498 "the unit-definition loader". It loads one object: the player parameter table. Its AA_/AV_ interleave (AV_ at X, AA_ at X+8, five axes) still stands as a struct layout — but the struct is PlayerParams's Booster record, not each unit's Maneuver. Note added in place. ⇒ What loads the 114 unit Maneuver records is now an open question — it uses none of these name strings.

🟡 PlayerParams hashed under 7 prefixes × 6 spellings × both hash families (84 combinations) hits the key 0x890E1BE4 0 times: the string names the table to the code, not the pak entry.

⚠️ The loader names 8 SpecialWeapon fields; the disc values 7HPRegenerator_Quantity is named and never valued.

Ranking all 114 units by how many of Booster's 50 values they reproduce: the top five are exactly the five _Player units (f001_T, two f001_T_Tt, f002_W, f004_A), all at 39/50; the sixth drops to 13/50; none matches 50/50. And the five players agree with each other on all 50 — the three player ships share one flight model. Booster stands alone on 10 fields. ⚠️ Correcting myself: the eleventh, AA_Yaw_Max, is 65.0 vs 65 — formatting, not a value. Last iteration's "11 differ" over-counted.

🟡 NOT SETTLED, and it is now sharper: nothing selects Booster. PlayerParams is loaded once, unconditionally — one call site, one caller, no branch. Yet flight-speed-law.md clocked the player at ~125 / ~420 / ~1 530, which tracks the unit Maneuver 100/350/1 200 at a flat ~1.25 and misses Booster's 100/612/2 100 badly; the RT-held run reached ~1 530, nowhere near 2 100. Both tables cannot govern the same craft. Cheapest next test is a runtime one (NEEDS-HUMAN): watch which constant set reaches the live flight struct, and re-measure with the afterburner held.


⚠️ 2026-08-27 — the unit loader was already in the corpus (sub_82341A20), and 0-xref ≠ no reader

Item (a), "what loads the 114 unit Maneuver records". The corpus already answers it and I should have found that before framing it as new.

⚠️ My own error, corrected: I claimed live-unit-definitions.md did not exist. It does — at docs/re/live-unit-definitions.md, not under structures/. Looking in one directory is not looking.

sub_82341A20 is the unit-definition loader, documented in unit-struct-runtime.md ("Cross-validated against the loader itself, 2026-08-13") with a checked-in 177-line layout at crates/sylpheed-formats/data/unit_definition_layout.txt (159 fields, 25/25 agreement with the solver). Verified independently here: 3 969 instructions, and 227 of its 236 addi rX, r30, -N displacements resolve to strings — the unit schema in order, Generic, UncertainName, IsDestructible, NamePlate, Size_X/Y/Z, … Maneuver sits at exactly 0x82088F94 13404.

Record keys are tag_hash, confirmed on the data side: the Maneuver record's key is 0x43FAA517 = tag_hash("Maneuver"), not name_hash (0x63A248D7). ×114 records.

🔑 And that explains the 0-xref phenomenon — which softens my own verdict from last iteration. Maneuver's string has 0 xrefs and a known reader: the loader takes its address as a register-passed base plus a displacement, which no static xref can see. So a 0-xref block is not evidence of no reader.

🟡 Verdict on the AnalogRevice_*/Tweak block softened 🔴🟡. A base-plus-displacement sweep over the 2 283 functions that form the 0x820A high half reaches it 0 times — but run against the unit block as a control, the same sweep recovers only 5 references and misses sub_82341A20 itself. The measurement stands; "the reader is unreachable" does not. Recorded in player-tuning-tables.md; runtime watch still the cheap way in.

⚠️ Also fixed there: the doc credited live-unit-definitions.md with documenting sub_822F9498. It documents sub_82341A20 — a different function.

🟡 Not settled: which table governs the measured player speeds (unchanged); a base-tracking sweep strong enough to follow register-passed bases was not built.


2026-08-27 — the base-solver: 277 name-block loaders, and the analog block is SOLVED

Item (b). New regenerator tools/re-capture/name_block_bases.pydocs/re/data/name-block-bases.txt (2 880 lines, ~65 s, byte-identical across two runs). This is a reusable tool, not a one-off.

🔑 The method. A loader that reads a table by field name keeps one base pointer and emits addi rX, rBASE, -N per name, so no static xref sees the strings. Solve the base from the displacement set alone: every (string address, displacement) pair implies a candidate base, and the true base collects a vote from every name it explains, so it wins outright. ⚠️ My first cut took candidates from ONE displacement and scored the unit loader at 52/226 against the right answer's 217/226 — a plausible wrong base. Vote over the whole set, not a probe.

Control passes with no prior knowledge: the tool recovers sub_82341A20 → r30 = 0x82088F94, 217/226. It also independently recovers sub_8230D1F8 (129/132), sub_822F9498 (90/91) and sub_822AE628 (81/108), and reports 277 name-block-reading functions image-wide with the schema each names — an index the corpus did not have.

The analog block is SOLVED: sub_821A6CF0, r29 = 0x820A1630, 22/24. In code order it names ControlTweakName, YawMagForNormal, the 12 Tweak fields, the 8 AnalogRevice_* curves and GP_MAIN_GAME — the whole schema in the object's own order, plus its pak. r29 is built at 0x821A6D34 as addi r29, r11, 5680 = 0x820A0000 + 5680, matching the solved base exactly.

🔑 It is the same function that reads PlayerParams (it references that literal and calls sub_822F9498): one function loads the player parameter object and the control-tweak/analog table.

🔴 Two of my own verdicts withdrawn. "Referenced by nothing" (🔴) and the softened "not found by these routes" (🟡) were both wrong; the measurements behind them — 0/28 xrefs, 0 hits from naive base tracking — were right. The base was solvable from the data the whole time. ⚠️ r1 groups are the tool's noise floor (stack frames) and are excluded.

🟡 Not settled: how the 11 curve samples are applied (the loader names the records; the interpolation is downstream); the remaining 255 of the 277 solved functions are listed but unexamined; what selects Booster (unchanged).


2026-08-27 — mining the base-solver index: a false-positive mode named, and the AI-table reader found

Item (a). 277 rows over 190 distinct functions (a function can read several blocks). name_block_bases.py extended with a confidence split; artefact +11/0, byte-identical across two runs.

🔴 The tool's false-positive mode, now measured and named. 107 of 277 rows solve to a base on a 64K boundary — a bare addis rX, r0, 0xHHHH with no addi, so any scatter of displacements votes for it. 82 are 0x820B0000: ~60 near-identical functions in 0x8281xxxx0x8284xxxx all "naming" the same rou_e007 rou_e010 … list. The 170 rows with a non-zero low half are the trustworthy set. ⚠️ A round base is not automatically wrong — sub_822215D0 sits on 0x820A0000 and resolves 205/206 — so read the ratio, not the base.

Already owned, and the index re-derives them (that is the control): the unit loader (217), stage settings (129), PlayerParams (90), hangar (81), squadron orders, missile guidance, shell movement, substructures, six camera/fog readers.

🔑 The find: sub_8233C368 reads the AI behaviour tabler28, base 0x8208583C, 20 names: Enumerate_AIs, FiringLength, GuardLength, AutoGuardLength, CounterLength, MusterLength, … stage-mission-tables.md owns those field names on the data side; Enumerate_AIs appears in no document and no reader was known — the corpus carries the AI tail of Maneuver as 🟡 NEEDS-HUMAN/runtime. It is statically reachable after all. The same base also serves sub_82338EE0 (97 names, Weapon TargetType SpecialWeaponType ReticleType IsCharging …) — the weapon datasheet loader, also not previously named.

Five unowned blocks surfaced, none opened: sub_822215D0 (205/206, PGHUD_*/PGREMAIN_NUM%d HUD part names), sub_822814D8 + sub_8227A3A0 (STAGE_RESULT, stage_num_shoot_down_aircrafts, EX_OVERVIEW), sub_822E3EC8 (g_mWorldViewProjection, NormalMap, GlossinessMap — engine material slots), sub_823C0260 + sub_823AE908 (Boss16Collision*, cross-linking the S16 Guardian object), sub_822AFA50 (roh_n001_menu1_cam_pos — menu camera tags).

🟡 Not settled: the index says what each function names, not what each means — nothing above was opened. And the 107 low-confidence rows were flagged, not re-solved with a stricter method.


⚠️ 2026-08-27 — AIParams disc-wide, and a correction to my own last entry

Item (a), "open sub_8233C368, the AI table". New regenerator tools/re-capture/aiparams_census.pydocs/re/data/aiparams-census.txt (45 lines, byte-identical across two runs).

⚠️ CORRECTION FIRST. Last iteration I recorded sub_8233C368 as unblocking a NEEDS-HUMAN item — "the corpus carries the AI tail of Maneuver as NEEDS-HUMAN/runtime; it is statically reachable after all." That was wrong. stage-mission-tables.md already documents AIParams_S02.tbl as "exact original values obtained by static RE… usable in the port directly", listing all 20 field names and both shapes. I grepped FiringLength and saw the file, but did not read the section. Finding the owning doc is not reading it — the same lesson this corpus has paid for before. The only genuinely new part was the loader's name.

What is new: the census generalises Stage 02 to the disc. 23 AIParams objects, identical in all six GP_MAIN_GAME_* paks, sharing one declared-name set of 34 profiles; 782 profile records = 23 × 34; 0 declared names without a record in their own object. So "34 AI profiles" is not a Stage-02 fact — every stage carries the same 34 and only the values move.

🔑 The roster is declared by an Enumerate_AIs record whose field names are the profile names — the same declaration-table mechanism that closed DefTables.

⚠️ Type predicts the field count, with exactly two exceptions. Fleet → 6 fields is 253/253, zero partials; Squad → 20 fields is 483/529. The 46-record residual, in full: AI_Test and AI_CraftSquadron_Test, both Type = Squad with only the six base fields, in all 23 objects (2 × 23 = 46) — the family's _Test templates again. No profile's shape varies between objects (0).

The loader: sub_8233C368, r28, base 0x8208583C, 20 names — exactly the field set above and nothing else.

🟡 Not settled: which of the 23 objects belongs to which stage (only AIParams_S02.tbl is named by the corpus; the disc-wide harvest was not re-run against the other 22), and what consumes an AIID at runtime.


2026-08-27 — every AIParams object mapped to its stage, zero residual

Item (a). aiparams_census.py extended with the stage mapping; artefact +7/0, byte-identical across two runs.

StageResource.EnumerateAIParams names the table, and every name resolves.

StageResource records 29
carrying EnumerateAIParams 28 (the one without it is the _Test template)
distinct table names declared 23
names that hash to an AIParams object key 23 / 23, prefix stage\
objects left unnamed 0
declared names with no object 0

AIParams_S01…S16 (16) + AIParams_S24…S29 (6) + AIParams_Tutorial.tbl shared by six (UnitGroup_S18…S23, the tutorials) = 23 tables over 16 + 6 + 6 = 28 records. The arithmetic closes both ways and matches the 23-object count found last iteration.

🔑 Same sharing shape as the settings family (stage-settings-table.md: 24 objects, StageParameter_Tutorial shared by six tutorials). Two independent families agree on how the tutorials are handled — n=2, a pattern rather than a rule, but a consistent one.

🟡 Not settled: what consumes an AIID at runtime (unchanged — the link from UnitGroup.AIID to a profile record is documented, the code that acts on the 20 parameters is not).


2026-08-27 — the executable's own PG* HUD roster; the disc side was already owned

Item (a), the five unowned base-solver blocks. Took the biggest, sub_822215D0 (205/206). Static only; no artefact changed.

⚠️ Grep-and-READ first, and it paid. PGHUD* pointed at mission-script-manifest.md — which documents HudResource's 24 fields — but hud-config.md owns the whole family: all 16 records in the six GP_MAIN_GAME_*2D paks, 419 distinct asset paths. Had I stopped at the first grep hit I would have "discovered" a documented table for the second time in three iterations.

Control: counting PG* field names straight off the paks reproduces that document's own numbers exactly — Sight 35, Radar 29, Marker 27, Wing 23, Manuva 15; HudResource 23 PG* + HUD_RES_FONT = its stated 24.

The increment: the executable carries its own roster. sub_822215D0 reads a 205-name block at base 0x820A0000, 205/205 PG*-prefixed (zero partials)PGHUD 77, PGTARGET 31, PGGAUGE 25, PGMANUVA 16, PGMARK 12, PGWARNING 10, PGMARKTGT 9.

distinct PG* on the disc 228
the code block 205
in both 200
code-only 5
disc-only 28

200 + 5 = 205, 200 + 28 = 228 — closes both ways.

⚠️ Four of the five code-only names are printf families whose stem is a disc field (PGHUD_HIT_NUM_EFF%d, PGHUD_HIT_NUM_RED%d, PGREMAIN_NUM%d, PGTIMER_NUM%d), and all four stems sit in the disc-only 28. The code builds indexed instance names from a base name the table declares. After pairing, the residual is one code-only name (PGMANUVA_EFF0) and 24 disc-only ones, listed in the doc.

🟡 Not settled: what the block is — lookup table, registration order or paint list. The function body was not read. And four of the five surfaced blocks are still unopened (STAGE_RESULT/EX_OVERVIEW; g_mWorldViewProjection/ NormalMap; Boss16Collision*; roh_n001_menu1_cam_pos) — each grepped this iteration and each appearing in no document but my own.


2026-08-27 — the debriefing and pilot-record screens; the save-screen block was already owned

Item (a), the results/leaderboard blocks. New doc docs/re/structures/result-screens.md. Static only; no artefact changed.

⚠️ Read-before-claiming caught one of the three. savegame-format.md already documents the shape and this exact list: "the LOAD/SAVE screen's config key list is compiled into the executable, as a pointer array of key strings — the same shape as the Arsenal's … match the pak record exactly", run starting 0x820A0074. sub_82286BC8's 18 names are that list (17/18 are a tables.pak field or record). Nothing new there; residual PLAYER_AMMO_LESS_10, not a tables.pak name at all.

The other two are new screens of the same shape, and they close exactly. All names resolve into tables.pak: sub_822814D8 24/24, sub_8227A3A0 21/21.

  • sub_822814D8 = the debriefing screen. 24 = 2 screen ids (STAGE_RESULT, EX_STAGE_RESULT) + 21 stage_* fields + one sound cue (SE_BOSS_CORE_CHARGE); the tables.pak STAGE_RESULT record has exactly 21 fields (2 objects, both 21).
  • sub_8227A3A0 = the pilot record / career screen. 21 = 5 screen ids (LAST_RESULT, EX_BASE, EX_MAIN, EX_OVERVIEW, OVERVIEW) + 7 ex_overview_* + 9 overview_*; the records EX_OVERVIEW and OVERVIEW have exactly 7 and 9.

2+21+1 = 24 and 5+7+9 = 21, with 21/7/9 measured independently off the pak — the arithmetic closes both ways.

🔑 The debriefing readout is nine num/points pairs plus three points-only lines: kills by class, main/sub objectives, clear time, shoot-down ratio and both damage categories carry a raw count and its score contribution; friendly_fire, shoot_down_others and weight have no counter column. Same partition mission-scoring.md measures on the settings side — that document owns the rules, this is the readout.

🔑 OVERVIEW = the seven EX_OVERVIEW fields plus overview_rank and overview_medals, which is what makes EX_ the reduced variant of the same screen rather than a different one.

🟡 Not settled: what EX_ means — EX_BASE, EX_MAIN, EX_STAGE_RESULT, EX_FONT are keys with no record of that name, so it is a screen-id convention, not a table. PLAYER_AMMO_LESS_10 unexplained. And two of the four surfaced blocks remain unopened (g_mWorldViewProjection/NormalMap engine material slots; Boss16Collision* + roh_n001_menu1_cam_pos).


⚠️ 2026-08-27 — the last base-solver blocks: two findings, and a correction to how I read the index

Item (a). Static only; no artefact changed.

⚠️ CORRECTION FIRST — a row is a (function, REGISTER) pair, not a function. Every one-line label I carried for these blocks quoted one of two rows, and the other row of the same function is an unrelated schema:

function one row the other
sub_822E3EC8 r11 29 — shader constants + techniques r10 15 — the material map slots
sub_822AFA50 r11 16 — XDK shader-compiler tokens r10 13 — the menu camera tags
sub_823AE908 r11 43 — the S16 boss collision table r31 33 — shader tokens (false positive)

So "sub_822AFA50 = menu camera tags" and "sub_822E3EC8 = NormalMap…" were each half-right by accident. Quote the register.

The engine's material vocabulary (written into xbg7-mesh.md, which owns the _col/_spc/_gls/_lum suffixes). Nine base map slots — ColorMap, LuminosityMap, SpecularityMap, GlossinessMap, ReflectionMap, TransparencyMap, NormalMap, FresnelMap, EdgeTransparencyMap — and seven *MapArray twins; 9 7 = 2, and the two without a twin are FresnelMap and EdgeTransparencyMap. 🔑 Four of the nine are exactly the four documented suffixes; the other five have no suffix in the shipped meshes — the engine supports more channels than the assets use. Plus 17 g_* shader constants, three cube samplers, and DepthOnlyTechnique / MotionVectorTechnique / TransparentTechnique — the …Last matrices plus the motion-vector technique say the renderer keeps the previous frame's per-object transform.

The S16 boss's collision parts (written into collisionset.md). sub_823AE908 r11 is an interleaved (mesh, logical-part) table: rou_e901_body_02_c → Boss16CollisionBody02, the five wing pairs, both tails, rou_e901_barrier_cmesh → …Barrier, rou_e910_core_cmesh → …Core, and mob_n900{,_b01,_b02,_gear}_cmesh → …Room00/01/02 + Gear00/01/02. 23 logical parts + 20 meshes = 43, differing by exactly three: Body01 opens with no mesh, and the one gear mesh carries three gear names. 🔑 Cross-links the S16 Guardian/Core object from the PlayerParams work — Shell_S16Boss_* weapons there, Boss16CollisionCore here.

🟡 Not settled: whether the rou_e901_*_c meshes are entries of the CollisionSet library (its 18 blobs are byte-identical, so a name join was not attempted); why Body01 has no mesh; and none of the shader slots was traced to a shipped shader.


2026-08-27 — all 277 base-solver rows classified objectively; 53 are data-table schemas

Item (a). name_block_bases.py extended with a per-row data-table test; artefact +57/0, byte-identical across two runs (~2 min 12 s now — it adds a disc-wide pak scan).

🔑 The test is objective, not by eye: a row is a data-table schema if its names are IDXD record/field names on the disc (13 450 such names disc-wide). 53 of 277 rows are ≥50 % disc names with ≥8 names; the other 224 are engine / XDK vocabulary, compiled key lists, or noise.

⚠️ The two axes are independent. Against base confidence: solved bases split 34 table / 136 not, round bases 16 / 91. So "round base" and "not a table" are different questions — the earlier round-base warning was about where the base is, this is about what the names are.

The 53 contain every loader already known (the control): unit definitions, stage settings, PlayerParams, hangar, AIParams, the weapon datasheet, the PG* roster, the three screen key lists.

Five rows in the 53 are unowned — each noun grepped, appearing in no docs/re/ file:

  • sub_823BDAA8 r11 (33) — rou_e901 + GN_MainGun_01_MuzC, GN_MainGun_02_Muz01…: the S16 boss's muzzle/attach frames, sitting beside the collision table.
  • sub_823BDAA8 r10 (25) — Motion_stand, Motion_stand_b1, Motion_attackA_start…: motion names, the EnumMotions family that DefTables declares.
  • sub_82315AE8 r11 (20) — InitRotation, MaxRotationSpeed, RotationAccel, MaxVerticalSpeed: the Guardian record's own fields — the S16 boss loader.
  • sub_8219E560 r11 (18) — Detail_Rank, MISSIONS, Detail_Board_Permanent: the leaderboard screen keys.
  • sub_825F2CF0 + sub_825F2F88 r0 (30 each, same base) — FinalPassBG, FinalPassToneRatio, FogMin/MaxDistance: post-processing.

⚠️ Four rows that look new are not, and their disc-overlap says so: 5370 % rather than ~100 %, because they mix arsenal fields the corpus owns (ConditionToDevelop, WeaponDesc, SilhouetteModel) with literal screen coordinates as strings (757,228, 903,343, 1092,457).

🟡 Not settled: none of the five was opened — this iteration produced the shortlist, not the findings. The 224 non-table rows were not partitioned further (XDK vs game engine vs noise).


2026-08-27 — the Stage 16 boss closed end to end; and five disc fields the image never names

Item (a). New doc docs/re/structures/stage16-boss.md joining three iterations' pieces: the data object (player-tuning-tables.md), the collision table (collisionset.md) and now the loader. Static only; no artefact changed.

sub_82315AE8 r11 is the boss loader, and it says so itself. 558 instructions, one call site (sub_82308AB8); its 21-entry block is 19 field names plus the Japanese error 「ボスのレーザー弾IDが取れていない」 — "the boss's laser shell ID could not be obtained".

Zero partials: every one of the 19 is a Guardian or Core field — 16 of Guardian's 19, 5 of Core's 10, DamageLevel1/2 shared. The boss reads as a rotating, vertically-moving platform with a guard cycle, three shell ids (AAGun/Laser/HomingLaser — the Shell_S16Boss_* set) and a Core that periodically drops lock-on.

🔑 The residual is the finding: 8 disc fields the loader never names, and five of them are not strings in the executable at allInitHight (the game's own typo for Height) and the four DamageLevel{1,2}Bomb{,Rand}Time. Controls: ChargeTime (10 chars) and GuardTime (9) are the same shape and both ARE present, so it is not an extraction artefact; HP (2 chars) is below the extractor's floor (shortest row is 6); Radius exists but is generic (3 copies, used by many systems).

The disc carries fields no code names — the mirror image of the usual trap (a loader naming a field the disc never values). 🟡 Whether they are read positionally or are dead data is not settled.

🟡 Not settled: the other four shortlisted rows (sub_823BDAA8 r11/r10 muzzle frames + motion names, sub_8219E560 leaderboard keys, sub_825F2CF0/ sub_825F2F88 post-processing) are still unopened.


2026-08-27 — the S16 boss's frames and motions; attackC is a cut attack

Item (a): sub_823BDAA8, the next boss piece. Written into stage16-boss.md. Static only; no artefact changed.

The boss subsystem is a linear chain, one call site each: sub_82385D48sub_823AE908 (collision pairs) → sub_823BDAA8 (frames + motions) → sub_823C0260 (hull part list).

r11, 33 names = the attach-frame roster, partitioning exactly: 15 hull frames + 9 muzzles (GN_MainGun_01_MuzC and GN_MainGun_02_Muz01…08 — one gun with a centre muzzle, one with eight) + 1 shield + 3 gear + 5 model ids = 33. The 15 hull frames are the same 15 the collision table pairs, and rou_e901_body_01 is present here — the one whose _c mesh the collision list did not carry.

r10, 25 names = a SUBSET of the disc's motion set. The disc object is DefTables 2ef95cdd: 58 records = 3 structural + 55 motions; all 25 code names are in it (25/25); 30 disc motions are not in the code block.

⚠️ A reading I nearly published, refuted by checking the other population. In the code block Motion_guard_start stands alone while every other stem carries _b1/_b2, which looks like the exception. It is not — the disc has guard_start_b1/_b2, plus guard_keep and guard_end with their variants. The asymmetry is in the code's list, not the data.

🔑 attackC is a third boss attack the executable never names. Most of the 30 omitted motions do exist as strings elsewhere (Motion_guard_keep, Motion_dead, Motion_break, Motion_damage01 — one row each), but the whole Motion_attackC_{start,keep,end,charge} × {,_b1,_b2} family — 12 names — has 0 string rows, against two identically-shaped controls at 3/3 (attackA_charge, attackB_charge). The boss ships with an animated third attack pattern no code path can select by name — the same disc-carries-what-code-never-names shape as InitHight, but a whole behaviour rather than five parameters.

🟡 Not settled: whether attackC is reachable by index rather than by name; what eff_s900_e_Charge attaches to; and the other three shortlisted rows (sub_8219E560 leaderboard, sub_825F2CF0/sub_825F2F88 post-processing).


2026-08-27 — the leaderboard screen, and a Detail_* prefix trap

Item (a): sub_8219E560 r11, censused against tables.pak exactly like the result screens. New doc docs/re/structures/leaderboard-screen.md. Static only; no artefact changed.

The 18 names partition exactly: 13 tables.pak record names + 5 field-only names. The 13 are the 12 Detail_* panel elements plus MISSIONS — a 0-field record already owned by isl-condition-builtins.md and challenge-mission-gate.md, so not new. 444 instructions, one call site (sub_821A2A80), 100 % of the names are tables.pak names.

The five field-only names are the screen's parameters: DETAIL_TITLE, one field in each of six mode records (ScoreAttack, TimeAttack, Extra01…Extra04), and Detail_Board_{Permanent,Monthly,Friend,Self}, four fields of CHIPS — the board scopes.

All 12 Detail_* record names are in the code block — zero residual, the opposite of the boss motions (a 25-of-55 subset). The panel is Rank, GamerTag, Main_{Time,Points}_{Self,Live}, Warships, Warplanes, Weapon_{Nose,Main1,Main2,Main3} — your figure against the selected entry's, plus that pilot's kills and four weapon slots. _Live, GamerTag and the Friend/Monthly boards make it the Xbox Live detail view.

🔑 The trap worth keeping: tables.pak has 12 Detail* RECORD names and 12 Detail* FIELD names, and they are DISJOINT (0 overlap). The fields are a different axis — 4 board scopes + 6 modes + Detail_Window_{Known,Unknown}. A Detail_* name means nothing until you say whether it is a record or a field; counting "24 Detail_* things" would merge two unrelated tables.

🟡 Not settled: what Detail_Window_Known/_Unknown switch between; whether Extra01…04 map onto the four challenge missions the corpus knows. And the last shortlisted row (sub_825F2CF0/sub_825F2F88 post-processing, two functions on one base) is still unopened.


🔴 2026-08-28 — the last shortlisted row is an artefact: r0 is not a base register

Item (a), the final unopened row: sub_825F2CF0 + sub_825F2F88, r0, 30 names each on the same base 0x8209EB30, 97 % disc names. The framing was "a pair, or a clone?" — both wrong. Neither function reads a name block.

🔑 Diffing the two settles it in one look. 145 instructions each, 72 differ, and every difference is the same substitution: stvx/stvx128 on one side, lvx/lvx128 on the other, over v14…v127. They are the compiler's VMX register save / restore helper pair. A pair, then — but the pairing is spill/reload, not two readers of one table.

🔴 The root cause is a whole class of false positive. In PowerPC addi rD, rA, N, the rA slot reads as literal zero when it names r0 — the form is li rD, N. The 72 addi r11, r0, -N are vector spill offsets, plain immediates. The solver excluded r1 (stack pointer) and nothing else, so it took them as displacements, voted a base, and landed 0x8209EB30inside the stage-settings name block, where a scatter of 16-byte-spaced negative offsets is dense enough to "resolve" 30 real field names.

⚠️ A 97 %-disc-names row can still be an artefact. The disc-name test measures what the base region contains, not that the function reads it. That is a correction to the 53-of-277 classification's own premise.

Nothing is lost, and the real reader was already ours. All 30 withdrawn names are a strict subset of sub_8230D1F8 r29 (base 0x8209FD28, 129 names) — the stage-settings loader. The string-xref join agrees independently: FinalPassBG, FogMinDistance, ScreenColorR and ExposureKey_BeginValue each exist once in the image with exactly one xref, from sub_8230D1F8. NoseCameraFOV and GlareType exist twice, the second copy read by sub_822AE628 — the hangar's game-wide defaults, the double-string-block trap the corpus already records.

Fix: name_block_bases.py now skips reg in ('r0', 'r1'). Every number moves by exactly the 16 r0 rows: rows 277 → 261, distinct functions 190 → 176 (14, not 16 — two of those functions keep a row through another register), non-64K bases 170 → 154, 64K bases 107 unchanged, data-table schemas 53 → 50. Both ways: 154 + 107 = 261 and 277 16 = 261. The artefact diff removes 100 lines and adds 3; every removed row line carries r0, the three added lines are those counts, and the control is untouched (sub_82341A20 r30 = 0x82088F94, 217/226). The three withdrawn data-table rows are sub_825F2CF0, sub_825F2F88 and sub_82222E70 (15 names, 100 % disc, same region, same mechanism); the "six camera/fog readers" are really three (sub_8247DFC0, sub_823B2620, sub_822C7480, all r31, all based in 0x8209E6xx).

Written up in docs/re/structures/player-tuning-tables.md; INDEX.md updated.

🟡 Not settled: the 🟡 stalled SpaceSize/GlareType/Nebura_* numerics are no closer — their reader was already known to be sub_8230D1F8, and this row never was a second one. The shortlist is now empty. Still open: whether a stvx- style helper elsewhere fooled a non-r0 row (the 64K-boundary cluster is the remaining known false-positive mode, unaddressed); Detail_Window_Known/_Unknown; whether Extra01…04 map onto the challenge missions.


🔴 2026-08-28 — the base-solver's confidence axis was inverted

Item (a): the remaining named false-positive mode, "107 rows solve to a base on a 64K boundary — a bare addis rX, r0, 0xHHHH with no addi of its own, so any scatter of displacements votes for it". Refuted, and by its own measurement.

The test, now in the tool. Simulate lis/addis rD, r0, HI, addi rD, rA, N and or rD, rA, rA forward through each row's own function and ask whether the solved base ever lands in the solved register — positive confirmation from the instruction stream, independent of the displacement vote.

  • bases on a 64K boundary ("low confidence"): 107 / 107 confirmed
  • bases with a non-zero low half ("trustworthy"): 8 / 154 confirmed

A 64K-boundary base is the case where the compiler needed no second instruction, so addis r11, r0, 0x820B stands in the code in full. A non-zero low half usually means the base was built in the caller or loaded from memory, which the simulation cannot see — a miss there is silence, not a refutation (the control sub_82341A20 r30 is one such silence; it stands on 217/226 and on the disc).

🔑 The control that settles it: every row the corpus independently validated against the disc has a 64K-boundary base — debriefing sub_822814D8, career sub_8227A3A0, save sub_82286BC8, leaderboard sub_8219E560, the 205-name PG* HUD roster sub_822215D0 (r11 and r10), material slots + shader constants sub_822E3EC8 (r11 and r10), the S16 boss collision/frames/motions sub_823AE908 / sub_823BDAA8 (r11 and r10) / sub_823C0260, and the boss loader sub_82315AE8. Thirteen rows over ten functions. Meanwhile the dense-short-string false positives the corpus did name — the r31 = 0x8202xxxx rows — all sit in the class that was called trustworthy. The axis pointed the wrong way in both directions.

What the 0x820B0000 cluster actually is: duplication, not error. 82 rows carry that base and 60 are one function emitted 60 times — exactly 491 instructions each, and diffing any two, two instructions differ, both a global data pointer (addi r30, r11, 14272 vs 15552; addi r3, r11, -13024 vs -12984). All 60 reference an identical sequence of 41 string addresses, writing 40 resource names into a per-copy global through sub_8217FA08(dest, name, -1) at 24-byte strides. A static array duplicated once per translation unit. The resolution ratio cannot show that, and the confirmation test does not care.

The roster is 30 rou_*, 6 mob_*, 3 rob_*, 1 rot_*, and 38 of the 40 are GameResourceID values on the disc (480 distinct, each used once). collisionset owns the prefix vocabulary and the GameResourceID link; what it does not say is that the image compiles a fixed 40-name subset. 🟡 The two that are not disc GameResourceIDs: rot_n001 — and no disc GameResourceID uses the rot_ prefix at all (eff 197, rou 172, mob 71, wep 23, rob 4, stg* 9) — and rou_e202, whose siblings rou_e201 and rou_e901 are both present.

Artefact diff is 13 added / 4 removed, confined to the replaced section; the 261-row table and the 64K-base histogram are untouched, and the file is byte-identical on a second run. Written up in docs/re/structures/player-tuning-tables.md.

🟡 Not settled: what reads the 60 duplicated globals; what selects 40 of the disc's 172 rou_* ids; whether rot_n001 / rou_e202 appear anywhere else on the disc. Both of the solver's named false-positive modes are now closed — one real (r0), one refuted (the 64K flag). What remains unmeasured is the 146 rows the positive test is silent on.


🟡 2026-08-28 — the 60 duplicated functions are static-object constructors

Item (a), continued from the duplication finding. What they are is settled; what reads them is not.

Each of the 60 is a static-object constructor building a 40-element array of 24-byte string objects. addis r11, r0, 0x820B + addi r4, r11, -9584 fetches the literal, addi r30, r11, 12944 fixes the array (0x828E3290 for the first), bl sub_8217FA08(dest, literal, -1) assigns, stw zeroes +4…+20, and addi r3, r30, 24 steps to the next element.

Why they have no callers. Each clone appears exactly twice in the image: once in the .pdata unwind table — where every function appears as (address, prolog/length word); reading that as a registry is a trap I nearly fell into — and once as a slot of a null-terminated 182-entry function-pointer array at 0x828700180x828702EC, of whose targets 170 have zero direct xrefs. That is the static-initialiser list, dispatched indirectly and run before main. Zero callers here means "static ctor", not "dead code".

The teardown side confirms the shape. Between consecutive clones sit 3943 functions (median 40), and the dominant size in the whole 0x8281xxxx0x8284xxxx region is 11 instructions, 2 280 of them ≈ 60 × 40. Three consecutive ones call sub_823F3D68 on 0x828E3290, +24, +48 — one destructor thunk per element, same stride, same global, all zero-xref. So the unit is one ctor + 40 one-line dtor thunks, emitted 60 times.

rot_n001 and rou_e202 are on the disc after all. A raw byte sweep over every decompressed pak entry: rot_n001 26 occurrences in 7 archives (DefTables included), rou_e202 6 in 6 — exactly one per GP_MAIN_GAME_<lang> pack and none in DefTables. Controls in the same sweep: rou_e201 151/8, rou_e901 917/8, mob_n500 46/8. Neither is image-only; rou_e202 is just the roster's rarest member. Withdraws the "two names the image carries that the disc never uses" reading — they are not GameResourceID values, which is a narrower statement than I made.

🟡 Not settled: what reads the arrays. An exact-address search finds 2 347 functions outside the 60 that materialise one of the 60 global addresses — far too broad to be a consumer list. The globals sit in a dense data region, so that route has no power; the consumer needs a different one. Also unsettled: what selects 40 of the disc's 172 rou_* ids, and which disc field holds rot_n001/rou_e202.


🔴 2026-08-28 — the roster's two vocabularies, and a prefix match I mistook for a hit

Item (a): which disc field holds rot_n001 / rou_e202.

🔴 Correction to yesterday's own result. The raw byte sweep reported rot_n001 "26 times in 7 archives". That was a substring count. The exact field value occurs zero times, in every IDXD record and every non-IDXD payload. Expanding the surrounding characters gives what the disc really carries: rot_n001_break ×14, LOD_Frame_rot_n001_break ×7, GameModel_rot_n001_break ×5. rot_n001 exact is not on this disc; rot_n001_break is — and it is one of the Generic.Model values. The image names the stem, the disc the _break variant. A prefix match is not an exact match, and a raw byte sweep is exactly the tool that breaks that rule — it is a cheap existence test only when the match is anchored.

rou_e202 does exist, in the other vocabulary: a Model field of a Generic record, six times, one per GP_MAIN_GAME_<lang> pack, with no GameResourceID. That is why the GameResourceID census called it absent.

The roster is covered by two fields. Exact-value sets over every IDXD record: roster 40, Generic.Model 46, GameResourceID 480; roster ∩ Model 34, roster ∩ GID 38, roster ∩ both 33. 38 + 34 33 = 3939 of 40 are one or the other, the sole exception being rot_n001. The six that are GID but not Model explain themselves: mob_n040mob_n043 are the four AsteroidGroup_00N.AsteroidModelName values (18 each, one per pack — asteroid models, not units) and rou_e004 is a Level_0.Model.

🟡 What selects the 40 is still open — but two candidate answers are now dead. Not the Generic.Model set (34 of 46), not the GameResourceID set (38 of 480). The 12 Generic.Model values outside the roster are mob_n050, rob_e005, rot_n001_break, rou_e001, rou_e002, rou_e016, rou_e017, rou_e018, rou_f002, rou_f004, an empty string, and a Shift-JIS 文字列 placeholder (the _Test pattern). rou_f002 and rou_f004 are the player's own craft for S7S16 + S2427,29 and for S28, and the roster omits both while keeping rou_f001 — so "the playable or most-used models" is dead too.

unit-substructure-records mentions Generic.Model in passing as "the mesh the corpus already decodes"; what it does not say is that there are only 46 distinct values disc-wide. AsteroidGroup_00N / AsteroidModelName are new to the corpus. Written up in docs/re/structures/player-tuning-tables.md.


2026-08-28 — the asteroid-field tables (AsteroidGroup_00N) decoded

Item (a), the new nouns from the roster census. New doc docs/re/structures/asteroid-fields.md, indexed.

The chain was already half in the corpus: stage-definition-table / stage-mission-tables record the per-phase AsteroidDefinition naming a .tbl, and unit-datasheet-static partitions Generic and finds 10 per pack whose only field is {EnumAsteroidGroup} — "an asteroid-group file". What neither says is what is inside them.

The table. 10 objects per pack × 6 packs = 60, 384 AsteroidGroup_* records, 4 or 7 groups per object (12 objects with 4, 48 with 7). Every record carries exactly three fields, all 384, no partials: AsteroidModelName (28 distinct — mob_n050mob_n056 ×24 each, mob_n040mob_n043 ×18), AsteroidFrameName (64 distinct, Frame_S<NN>[_pN]_Asteroid_NN — the same names regn-map-grid reports the Route tables showing), and EnumAsteroid (37 distinct, 1…588, 6 633 per pack summed).

Enum<Thing> is a COUNT; Enumerate<Thing> names a TABLE. EnumAsteroidGroup equals its object's group count in 54 of 60, and EnumAsteroid is a plain integer — the opposite of the Enumerate* join rule. The -erate is the difference. ⚠️ And Count-style fields still are not safe: the 6 failures are one object repeated per language pack, declaring 4 while holding 7.

🔑 The tenth object is unreferenced, and it is the one that miscounts. Joining by frame prefix: nine objects match the nine AsteroidDefinition values one-to-one (S01 4 groups, S04/S05/S13/S14_p1/S14_p2/S08_p1/S08_p2 7 each, Test 4), and the tenth carries Frame_Alpha_S01_Asteroid, Frame_Alpha_S01_Haze, Frame_TAlpha_S01_Asteroid and is named by nothing — an Alpha Stage-01 variant with its own haze. An abandoned table is exactly what a wrong declared count looks like.

🔑 S28 has an asteroid volume but no asteroid definition. mcol-collision lists nine _AsteroidVolume_wp meshes — S01, S04, S05, S08_p1, S08_p2, S13, S14_p1, S14_p2, S28_p1. The nine AsteroidDefinition values are those eight plus test_s8p1. Eight shared, one different each way.

🟡 Not settled: what EnumAsteroid counts of (instances per frame fits the 1…588 range, untested); why S28_p1 has a volume and no definition. 🔴 The name→object join did not reproduce by hashAsteroidDefinition values do not map to their objects' pak entry keys under name_hash, nor under that key reduced mod 0x00FFF9D7 or masked to 24 bits. The join above is by frame prefix, which is direct evidence; either those keys are not name hashes or archive-naming's resolver differs from unitgroup.name_hash. Not chased.


2026-08-28 — EnumAsteroid counts the rocks, and the hash join was mine to fix

Item (a), both open questions from the asteroid write-up, settled.

EnumAsteroid is the frame record's FrameCount — 57 / 57, zero mismatches. Each AsteroidFrameName is itself an IDXD record name; those records carry one named field, FrameCount, plus a run of anonymous ones, and the anonymous fields number exactly 8 × FrameCount on all 384 occurrences (ratio histogram {8.0: 384}). The frame record is the placement table — 8 values per asteroid — and EnumAsteroid counts the rocks in it. The frame-name → EnumAsteroid map is single-valued for all 64 names.

mission-wave-arrivals already met these records — they were the outliers that broke its route-speed measurement, filtered out by name, with "the corpus already records that the 8-value frame is not universal". regn-map-grid already guessed the link and said so honestly: "stated as a reading, not a measurement; nothing here counts objects." What neither says is that the count is exact.

🔑 The abandoned table lost its placement data too. Exactly 7 of 64 frame names have no record, and they are exactly the seven groups of the unreferenced tenth object — Frame_Alpha_S01_Asteroid_{01,02,04}, Frame_Alpha_S01_Haze_{01,02,03}, Frame_TAlpha_S01_Asteroid_03. 57 + 7 = 64, zero partials either way.

Withdrawing yesterday's 🔴: the hash join works, my call was wrong. AsteroidDefinition values resolve to their objects' pak entry keys as name_hash("stage\" + name) — 9 of 9, each landing on the object the frame-prefix join independently predicted. I had hashed the bare file name; archive-naming's resolver hashes prefix + name over a list of path prefixes, and stage\ is the one that works here (Stage\ scores identically — the hash lower-cases). Two independent joins, 9/9 agreement, both leaving the same tenth object unreferenced. Lesson: before calling a corpus routine's result a discrepancy, read how the corpus routine calls it — the prefix list was in archive_naming.py the whole time.

🟡 Not settled: what the 8 values per asteroid are (position/rotation/scale is the obvious reading, untested — regn-map-grid has the neighbouring work); why S28_p1 has an asteroid volume and no definition.


2026-08-28 — the 8 values per asteroid: index, quaternion, position

Item (a). Measured over every Frame_*Asteroid* record on the disc — 384 records, 37 518 items (6 253 per language pack × 6; the remaining 380 per pack belong to the seven record-less Alpha frames, which is the same 57-vs-64 split as before).

slot test result
0 equals the running index 0…N1 37 518 / 37 518, max 587 — and the largest FrameCount is 588, so the last index is N1
14 a unit quaternion? 37 518 / 37 518 within 1e-4 of 1.0 (min 0.999999, max 1.000001)
4 sign of the real part min +0.001, never negative — canonical w-last, so (x, y, z, w)
57 position in metres 163 858…+79 572, 114 308…+76 422, 105 224…+110 263 — inside the ±250 km half-extent of mapmesh_box_500km

len(anonymous fields) == 8 × FrameCount on 384 of 384, zero mismatches.

The unit-norm test is the control: an arbitrary 4-tuple has no reason to sit on the unit sphere, and every one of the 37 518 does. Two further independent cross-checks fell out — the maximum index (587) is exactly max(FrameCount) 1, and every position lies inside the 500 km box the corpus already knows (regn-map-grid, world unit = 1 metre).

🔑 There is no scale slot. An asteroid's size comes from its group's AsteroidModelName — which is why a group is one model plus one placement list, and why the group table needed both fields.

🟡 Not settled: whether the quaternion is object→world or world→object, and its handedness — a norm test cannot separate those. And still: why S28_p1 has an asteroid volume with no definition.


🔴 2026-08-28 — the eight-value frame, censused disc-wide (and my novelty claim withdrawn)

Item (a): apply the placement shape elsewhere. Grepping FrameCount first would have saved the previous iteration an overclaim.

🔴 Withdrawn: the 8-value layout was not a discovery. stage-mission-tables already documents Route_* records as (time, quat.x, quat.y, quat.z, quat.w, pos.x, pos.y, pos.z) with len(fields) == 8·FrameCount + 1 holding 1449 of 1449, and unit-group-table already documents 1 + 8·FrameCount for formation slot lists. I re-derived the shape from the asteroid tables and called slot 0 an "index" — it is the corpus's time slot. Corrected in asteroid-fields.md. What the asteroid pass did add is the norm test.

The census the corpus was missing. Both prior statements were measured on one table family each. Sweeping every IDXD record on the disc that carries a FrameCount:

  • 10 986 records carry one; 10 974 have anon == 8 × FrameCount.
  • The entire residual is 12 records: Formation_Fleet_01 (ratio 136) and Formation_Fleet_02 (ratio 4), six each — one per language pack. The two exceptions stage-mission-tables already names are the only two on the disc. "The common case, not universal" is now 10 974 / 10 986.

The quaternion is measured, not asserted. Over 78 762 frames in five families, |q| is within 1e-4 of 1.0 on 78 762 / 78 762. An arbitrary 4-tuple has no reason to land on the unit sphere.

🔑 Slot 0 is time only where something moves. It equals the running ordinal 0…N1 in every formation (2 196/2 196), asteroid (384/384), placement (36/36) and test-route (24/24) record — and in only 6 228 of 8 334 real routes, which are exactly the family that stores true timestamps there (Route_ADN101_p1F: t = 0, 20, 30). Read the slot as time; expect static tables to degenerate it to an index.

Two record families new to the corpus: Placement_00…02 and RouteTest_00…01, sharing a six-record template object whose sixth record is named Enumeration, two such objects per language pack. Nothing yet ties them to a reader.

Written up in docs/re/structures/stage-mission-tables.md; asteroid-fields.md carries the correction.


2026-08-28 — Placement_*/RouteTest_* are Null_Test.tbl, and Enumeration is a self-index

Item (a).

The template object is stage\Null_Test.tbl. Its key hashes as name_hash("stage\" + "Null_Test.tbl"), the file stage-definition-table names as EnumerateNullFrame. Ten records: 4 Frame_Test_Asteroid_01…04 + 3 Placement_00…02 + 2 RouteTest_00…01 + Enumeration — and the nine carrying a FrameCount are exactly the "9/9 for Null_Test.tbl" stage-mission-tables already counted. The corpus knew the file and the count; it did not know the record names. A second object (the same five Placement/RouteTest records + Enumeration, without the asteroid frames) matched none of the ten names I hashed across sixteen prefixes — a trimmed sibling.

Enumeration is a record that indexes its own object. Disc-wide:

IDXD objects                                          7 750
objects carrying an `Enumeration` record                360
   its fields == the object's other record names       360 / 360
   a strict subset                                       0
   naming something not in the object                    0

Field/record counts run from 2 past 25 — (3,3)×36, (9,9)×24, (13,13)×24, (2,2)×24, (25,25)×18, (16,16)×12every pair on the diagonal. A merely overlapping list would not do that at 360/360 across that spread.

⚠️ Enumeration vs Enumerate — one letter, opposite scope. Enumerate is archive-naming's route-2 seed: a single-record object whose fields name other objects. Enumeration names the records of its own object. Written up in idxd-container, which had left "an object's kind is known only from the caller that loads it" open — for these 360 the object states its own contents. mission-wave-arrivals counted 28 Enumeration_* records while filtering non-routes out of a speed measurement; what it does not say is what they are.

🔴 A sharper version of yesterday's correction. I recorded the stage\ prefix as something archive_naming.py knew. In fact stage-mission-tables states it in prose — "The prefix is stage\name_hash is case-insensitive" — and its stagetbl.py examples pass 'stage\AIParams_S02.tbl'. The answer to my "🔴 does not reproduce by hash" was sitting in the doc that owns the stage tables. Noted in asteroid-fields.md.

🟡 Not settled: what Placement_* and RouteTest_* are for (they are test fixtures in a _Test table, so possibly nothing ships them); the six-record sibling's name; why S28_p1 has an asteroid volume with no definition.


2026-08-28 — the Enumeration self-index is not a naming route (measured), and the residual is two _Test variants

Item (a). The tempting move — 360 objects state their own contents, so use that to name them — does not work, and now that is measured rather than assumed.

objects carrying an `Enumeration` record                       360
   key already named by archive-naming routes 1 + 2            348
   still unnamed                                                12
   newly named by name_hash(prefix + own record name + ext)      0

Sixteen prefixes × seven extensions × every record name of every unnamed object: zero gained. A self-index names an object's records; a TOC key is name_hash(path + file name). The two vocabularies never meet.

The residual is exactly two objects × six packs, each byte-identical across the packs and each an unnamed variant of a named _Test table — not a duplicate of it:

  • 2390212806 (1 493 B) — Placement_00…02 + RouteTest_00…01: a trimmed Stage\Null_Test.tbl (3121167452, 27 545 B, the same five records plus four Frame_Test_Asteroid_*).
  • 2719765792 (14 124 B) — Formation_100, Formation_Fleet_01, Formation_Fleet_02: the same three record names as Formation_Test.tbl (3463590559, 14 025 B) but a different payload.

That answers last iteration's about the "six-record Placement/RouteTest sibling": it is Null_Test.tbl minus the asteroid frames, and it is still unnamed.

Independent confirmation for stage-mission-tables: name_hash(prefix + "Formation_Test.tbl") → key 3463590559, whose records are exactly the three above — so the two frame-ratio exceptions really do live in the file that document names.

🔴 Dangling link, not mine to fix. idxd-container.md references idxd-unnamed-keys.md twice; that file does not exist on auto/re-isl-stream — it belongs to the other agent's branch auto/idxd-unnamed-keys. Recorded here so a merge review catches it.

One control missed unexplained: EnumSquadron_Test.tbl, named by the _Test stage record, matches no pak key under any of the sixteen prefixes. n = 1, not chased.

🟡 Still open: what Placement_*/RouteTest_* are for; the two variants' file names; why S28_p1 has an asteroid volume with no definition.


2026-08-28 — every declared table name censused; NamePlate holds two vocabularies

Item (a), from last iteration's n=1 control (EnumSquadron_Test.tbl matched no pak key). Censused properly: every declared value disc-wide — the eleven Enumerate* fields plus MessageSet, NamePlate, CollisionMeshes, MotionEnumTable, LodEnumTable, AsteroidDefinition, MapMesh, MapPath — against the 16 630 pak keys under the sixteen path prefixes.

distinct declared values                        344
   resolve to a pak object                      313
   do not                                        31
      NamePlate values that are not file names   27
      _Test tables that were never shipped        4

313 + 27 + 4 = 344. Every family is complete (EnumerateUnit 29/29, EnumerateFormation 29/29, EnumerateNullFrame 29/29, EnumerateAIParams 23/23, CollisionMeshes 18/18, AsteroidDefinition 9/9, MapMesh 11/11, MapPath 11/11, LodEnumTable 7/7, MotionEnumTable 7/7, EnumerateSubobjective 17/17, EnumerateWeapon 3/3) except the four below.

🔑 NamePlate carries two disjoint vocabularies, split by owning record. StageResource.NamePlate (174 uses) holds file namesnameplate_S01.tbl …, 24 distinct, 24/24 resolve. Generic.NamePlate (522 uses) holds bare plate idsNP_Cyclops, NP_Gargantua, ACROPOLIS, OTHERENEMY, 27 distinct, 0/27 resolve, none with a dot. Zero crossover. The 27 "missing" are a category error, not missing data — the Detail* trap in a new field. unit-group-table and stage-definition-table already record the NP_* plate vocabulary; what neither says is that the field name is shared between the plate and its table.

🔑 The four genuinely dangling names are all the template's ownEnumCharacter_Test.tbl, EnumLocalString_Test.tbl, EnumSquadron_Test.tbl, StageMessageSet_test.tbl — and in each of those four families the count is n1 / n with the _Test entry the sole miss. The _Test stage record declares four tables that were never shipped. Every non-_Test declaration resolves.

A corpus closed on the way past. stage-mission-tables recorded StageMessageSet_S02.tbl as "not in GP_MAIN_GAME_E.pak under that name (, probably localised elsewhere)". It is there: name_hash("message\" + "StageMessageSet_S02.tbl") = key 705319170, in all six packs. The stage family uses stage\; the message tables use message\. Right instinct, wrong mechanism — same pak, different prefix.

🟡 Not settled: why S28_p1 has an asteroid volume with no definition; what Placement_*/RouteTest_* are for; the two unnamed _Test variant objects.


🔴 2026-08-28 — the 2D/GP_READY_ROOM blocker: retry already done, but two new structural facts

Item (a): retry the 🔴 blocker with the "wrong prefix" lesson from StageMessageSet_S02.tbl (which resolves under message\, not stage\).

🔴 The retry was already run, and the owning doc says so. archive-naming's "13 name transformations" list already includes 2d\, eng\, hud\ and GP_MAIN_GAME_2D\ prefixes — every one scored 0, against both the E2D pak and all 16 630 entries. Reading the doc end to end first would have shown that in one minute. Recording it so the idea is not proposed a third time.

What the re-open did add — two measurements the earlier passes did not make.

  1. The six 2D archives share ONE key set: 711 keys, identical in all six. Intersection = union = 711; 4 266 entries, 711 distinct keys. The entry names are language-independent — the language lives in the pak file name, not the entry path. That refutes any <lang>\ prefix hypothesis structurally, not merely by a zero score: if the names carried a language directory, the six key sets would differ.
  2. The 2D key space is disjoint from the rest of the disc0 of 711 appear in any non-2D archive, while disc-wide 2 010 of 16 630 keys are shared between archives. Sharing is common; this set simply never does it. No named neighbour to borrow a convention from.

⚠️ The six named GP_READY_ROOM entries are all fontsdeu\GOTHICB.TTF, eng\, esp\, fra\, ita\ likewise, and jpn\HGRGE00.TTF. The doc recorded the count "6 named" without saying what they are: they are localisation fonts named from elsewhere on the disc, not a sample of the archive's own artwork vocabulary, so they cannot seed the dictionary attack the doc names as the last lever. (Only 2 of 1 106 GP_READY_ROOM keys appear in another archive.)

🔴 Still blocked, and now for a sharper reason: the 2D names are language-independent, unshared, and absent from disc and executable alike. The dictionary-attack lever remains without a corpus.

🟡 Not settled: why S28_p1 has an asteroid volume with no definition; the two unnamed _Test variant objects; what Placement_*/RouteTest_* are for.


🔴 2026-08-28 — S28 does have an asteroid definition: it borrows Stage 14's

Item (a). Withdrawing my own repeated claim that "S28_p1 has an asteroid volume with no definition". It came from a set-difference over file names, which cannot see reuse. Joining the fields per phase settles it:

S28.Phase_1   MapMesh = S28_p1_AsteroidVolume_wp.col
              AsteroidDefinition = S14_p2_asteroid.tbl

S28 scatters Stage 14 phase 2's rock list inside its own volume. Nine definition tables ↔ nine _AsteroidVolume_wp meshes, with one table shared by two phasesS14_p2_asteroid.tblS14.Phase_2 and S28.Phase_1.

⚠️ The earlier census's own arithmetic already contained the answer. The four values counted ×12 (2 per language pack) were S01, S04, S05 — two phases of one stage — and S14_p2, which is two phases of two different stages. Reading ×12 as "two phases of the same stage" was the error; the count was right and my interpretation of it was not.

⚠️ test_s8p1_asteroid.tbl is not test-only either — it is what S02.Phase_1 ships with. A test_ name here is a leftover, not a dead table. (That also softens the _Test-dangling pattern: a test_-named table can be live even when _Test-named declarations dangle.)

The true residual is two phases, and it is the opposite pairing to what I recorded: S01.Phase_3 has the volume and no definition (S01 keeps its volume across all three phases but scatters rocks only in 12), and S02.Phase_1 has a definition and no volume (its MapMesh is the plain mapmesh_box_500km).

Corrected in asteroid-fields.md and INDEX.md.

🟡 Not settled: whether the quaternion is object→world or world→object; the two unnamed _Test variant objects; what Placement_*/RouteTest_* are for.


2026-08-28 — every challenge mission is flown on a story mission's map

Item (a): apply the per-stage join to the other stage-resource fields. Sharing is exactly what the file-name view hides, and here it is, complete.

Of 338 distinct (field, value) pairs across the 22 stage objects, 31 are used by more than one stage. The strongest signal is CollisionMeshes:

stage CollisionMeshes EnumerateSubobjective BackGroundID EnumerateWeapon
S24 CollisionSet_S11.bin SUBObjectiveSettings_S01 Anastasis EnumWeapon_Test
S25 CollisionSet_S07.bin SUBObjectiveSettings_S07 Hargenteen EnumWeapon_Test
S26 CollisionSet_S03.bin SUBObjectiveSettings_S06 Hargenteen EnumWeapon_Test
S27 CollisionSet_S03.bin SUBObjectiveSettings_S03 Planet_Lebendorf EnumWeapon_Test
S28 CollisionSet_S14.bin SUBObjectiveSettings_S01 Lebendorf EnumWeapon_EX5.tbl
S29 CollisionSet_S15.bin SUBObjectiveSettings_S15 Earth EnumWeapon_Test

6 of 6 challenge missions reuse a story stage's collision set. Control: S01S16 are perfectly diagonal, CollisionSet_SNNSUBObjectiveSettings_SNN, 16/16.

🔑 Map, objectives and sky are borrowed independently — S24 flies S11's map with S01's objectives; S26 flies S03's map with S06's objectives under a Hargenteen sky belonging to neither; S28 flies S14's map with S01's objectives under S01's sky. Three of six mix, so "S24 is S11" would be wrong.

🔑 S28 is the only stage with its own weapon table — 21 stages use EnumWeapon_Test.tbl (×132), the tutorials EnumWeapon_Tutorial.tbl (×36), and S28 alone EnumWeapon_EX5.tbl (×6). S28 was already the odd stage (the only rou_f004 flight); this is a second hook on it.

A datapoint for result-screens's open "what does EX_ mean": EX5 is also a variant suffix on real namesUN_f001_TCAF_DeltaSaber_T_EX5 and Weapon_TCAF_Ship_AAGun_EX5, 24 each. Not yet enough to fix the meaning.

⚠️ Method note worth keeping: an unanchored EX[0-9] search over binary payloads returns EX0EX9 at 100180 hits each — pure noise from matching inside compressed data. Only prefix-anchored names are real. (Sibling of "a prefix match is not an exact match".)

Written up in docs/re/structures/stage-numbering-and-player-craft.md.

🟡 Not settled: what EX stands for; the two unnamed _Test variant objects; what Placement_*/RouteTest_* are for.


🔴 2026-08-28 — EnumWeapon_EX5.tbl adds exactly one weapon, and a tutorial expectation is refuted

Item (a): diff S28's bespoke weapon table against the common one.

The disc has exactly three weapon tables, and each is a single EnumWeapon record whose field names are the weapons — the Enumerate-style declaration shape again:

EnumWeapon_Test.tbl       126 names
EnumWeapon_EX5.tbl        127 names  = the same 126, same order, PLUS one
EnumWeapon_Tutorial.tbl     9 names  = a strict subset of Test
union                     127

The whole of S28's bespoke table is one extra weapon: Weapon_TCAF_Ship_AAGun_EX5. Zero removals, identical order on the shared 126.

That explains one of weapon-struct-runtime's five "never instantiated" disc records — the AA-gun variant is declared by EnumWeapon_EX5.tbl only, and that table belongs to S28 alone, which the captured save was not playing.

🔴 And it refutes that document's expectation. It says "Running the tutorial should instantiate the _Ttrl pair". It will not: EnumWeapon_Tutorial.tbl's nine names are Weapon_NULL, Weapon_ADAN_Puppy_NoseGun, Weapon_DSaber_P_wep_{01_Beam,02_Missile,58_Laser} and Weapon_TCAF_DeltaSaber_{Beam,Cannon,Missile,ASMissile}neither Weapon_TCAF_DeltaSaber_NoseGun_Ttrl nor _Laser_Ttrl is among them. Four of the five uninstantiated records (_NoseGun_Ttrl, _Laser_Ttrl, _NoseGun_None, Weapon_ADAN_Attacker_S_GunTurret) are declared by no weapon table on the disc at all — a static answer to a question the runtime capture left open.

EX is still not identified, but it is narrowed hard: the entire content of S28's own weapon table is a single _EX5 AA-gun variant, and the only other _EX5 name on the disc is UN_f001_TCAF_DeltaSaber_T_EX5.

🟡 Not settled: what EX stands for; the two unnamed _Test variant objects; what Placement_*/RouteTest_* are for.


2026-08-28 — _EXn is the n-th challenge stage; the Extra0n join is refuted

Item (a) continued. Reading the docs first paid: unit-substructure-records already had UN_e001_ADAN_Elan_EX4 and arsenal-item-weapon-chain already had _T_EX5_el, so last pass's "the only two _EX5 names on the disc" was too narrow — withdrawn and replaced by a census.

Every name ending in _EX<digit> across all 41 archives, taken from parsed record names / field names / string values: 26 names, two digits only_EX4 (7 units, 6 ADAN + 1 TCAF) and _EX5 (4 TCAF units + 1 weapon), each with a UnitName_/WeaponCannonName_ twin.

The stage join is exact. _EX4 appears only in tables S27 declares (EnumUnit_S27.tbl, UnitGroup_S27.tbl); _EX5 only in S28's (EnumUnit_S28.tbl, UnitGroup_S28.tbl, EnumWeapon_EX5.tbl); the other 20 stages have neither. The challenge missions are S24S29 in order, so S27 is challenge #4 and S28 challenge #5: _EXn = the n-th challenge ("EXtra") mission's bespoke variants. S28 declares both schemes at once — EnumUnit_S28.tbl by stage number, EnumWeapon_EX5.tbl by challenge index.

Refuted: _EXn is not the Extra0n numbering. There are four Extra slots but _EX5 exists, and under that reading Extra04 = S29, which has no _EX assets. (The six leaderboard modes do map onto the six challenge stages; _EXn counts stages.)

🟡 Makes available, not adopted: result-screens' open "what does EX_ mean" — "the challenge-mission debriefing" now fits its own evidence that the EX_ twin drops overview_rank/overview_medals. Consistency, not proof; the selection has not been shown.

🟡 Not settled: the four weapons declared by no weapon table — three of them (_NoseGun_None, _NoseGun_Ttrl, Weapon_ADAN_Attacker_S_GunTurret) are already in unit-substructure-records' "26 weapons no turret mounts", so they are unmounted and undeclared; _Laser_Ttrl is not on that list and is the one still to place.


2026-08-28 — every weapon placed; and a fourth, undeclared weapon table

Item (a): place Weapon_TCAF_DeltaSaber_Laser_Ttrl.

The declared × mounted partition of all 131 Weapon.ID records has no unexplained cell: declared+mounted 105, declared+unmounted 22, undeclared+mounted 0, undeclared+unmounted 4. Both marginals re-derive unit-substructure-records' own numbers independently (mounted 105; 22+4 = its "26 weapons no turret mounts").

🔑 Nothing is mounted without being declared (0 of 131) — declaration is the superset. So the four orphans are reachable by neither data route; each appears only as a Weapon record's ID value, ×6 (one per pak copy), and nowhere else. Control Weapon_TCAF_DeltaSaber_Beam: ×18 as an EnumWeapon field, ×228 as a Turret_NNN.WeaponID. That places _Laser_Ttrl and unifies all four as orphan datasheet rows.

⚠️ A route with no power, recorded so it is not re-run: the image holds 0 exact strings for the four — and also 0 for the live Weapon_TCAF_DeltaSaber_Beam and 0 for Weapon_NULL. Only three Weapon_-prefixed strings exist at all, and 0 contain Ttrl. The executable names no weapon; the negative does not discriminate.

New: a fourth EnumWeapon record — pak key 305595319, resolved as EnumWeapon_TestS01.tbl, three fields (Weapon_TestS01_{Gun,Laser,Missile}). Sweeping records rather than declared tables gives 130 names, not 127; 130 3 = 127 restores the earlier union. It dangles at both ends: no stage declares it, and its three weapons are the only declared names with no Weapon.ID record.

🟡 Not settled: why those four were kept on the disc; whether the debriefing code selects the EX_ key list for challenge missions; which challenge stage is which leaderboard mode.


2026-08-28 — the weapon law does not generalise to units; the exception is all S14

Item (b): apply the declared × deployed 2×2 to units.

Three vocabularies had to be separated first: declared = field names of the 31 EnumUnit records (118); deployed = slot 0 of each UnitGroup member tuple (122); datasheet = Generic records carrying HP (114, which re-derives unit-datasheet-static's own number as a control). Declared and deployed share the UN_* vocabulary (109 overlap); the datasheet's IDs are a third naming, disjoint from both — the "two vocabularies" trap, now three.

deployed not deployed
declared 109 9
not declared 13 1

Refuted as a general law: the cell that is empty for weapons (0 mounted-but-undeclared of 131) holds 13 for units.

The 13 are one family — all UN_S14_p{1,2}_Asteroid_cmesh_*. The other population settles it: S01 8/8, S04 10/10, S05 5/5, S08 6/6, S13 4/4 declared — 33 of 33 — while S14 declares 0 of 13, and non-asteroid deployed-but-undeclared = 0. Narrower law: every deployed unit is declared, except S14's asteroid collision meshes.

🟡 Not settled: why S14 differs. It is the stage whose asteroid table S28 borrows, but nothing measured links the borrow to the missing declarations — a coincidence recorded as a coincidence.


2026-08-28 — the per-stage view: EnumUnit_S<NN> is the stage's LOAD MANIFEST

Item (a). Joining each stage's own EnumerateUnit table to its own EnumerateSquadron table is far sharper than the global partition: declared == deployed exactly in 20 of 22 stages; S16 declares 4 it never deploys; S14 deploys 13 it never declares. Every per-stage deployed-but-undeclared count is 0 except S14's 13 — which is the entire disc-wide total, so the exception is one stage, not a scattering.

🔑 S16 explains the table's role. It declares 6 and deploys 2; two of the six are UN_e901_ADAN_Boss and UN_e910_core_ADAN_GeneratorCore — exactly the units stage16-boss shows are loaded by their own chain (sub_82315AE8), not by the squadron roster. So EnumUnit_S<NN> is the stage's load manifest: a unit that code spawns is declared but never deployed.

All 9 declared-but-never-deployed units are now named and accounted for: UN_e901_ADAN_Boss, UN_e910_core_ADAN_GeneratorCore (S16's code-spawned boss pair), Test_ADAN_PrometheusDriver_InsideP2_01, and six Ship_Test* placeholders (Ship_Test, _Test2, _Test_2, _TestS01, _TestS01_2, _TestS01_3).

🟡 S14 is still unexplained, and it is not a missing table. EnumUnit_S14.tbl exists with six names, one of which is UN_mn040_Asteroid_Big — S14 declares an asteroid but not its 13 Asteroid_cmesh entities, which refutes "asteroids are exempt from the manifest". Whatever the cause is, it is specific to the collision-mesh entities.


2026-08-28 — S14's 13 are dangling deployments; the unit chain is absent for them

Item (a). Two measurements settle the last open cell of the unit partition.

The deployment side is identical. S14's asteroid-cmesh squadron records match a declaring stage's field for field — same squadron ids (GNN901…) and the same Count 1 / SideID Neutral / AIID AI_Structure / FormationID Formation_1_only / DisableInterval No. Nothing about how S14 deploys them differs.

The definition side differs completely. Per stage, deployed / declared / Generic-with-HP / any Generic: S01 8/8/8/8, S04 10/10/10/10, S05 5/5/5/5, S08 6/6/6/6, S13 4/4/4/4 — 33/33/33 for the five — and S14 p1 9/0/0/0, p2 4/0/0/0.

🔑 The 13 are not a manifest omission: they have no unit definition anywhere on the disc, not even an un-HP'd Generic row. UnitGroup_S14.tbl names entities that do not exist — 13 dangling references. That rescues the law (nothing is deployed-without-being-loaded in a working sense) and explains why the gap is exactly the cmesh class and nothing else in S14.

🟡 NEEDS-HUMAN to go further: either those collision meshes fail to appear in Stage 14 (a shipped bug) or another route supplies them. Flying S14 and checking whether the large asteroids collide distinguishes the two; no static route can.

⚠️ Recorded so the populations are not conflated: asteroid-fields' AsteroidGroup_00N tables scatter mob_n0NN models; these are UN_<stage>_Asteroid_cmesh_* squadron entities. S28.Phase_1 borrows S14_p2_asteroid.tbl but deploys no cmesh of its own, so the borrow does not carry the dangling references.


🟡 2026-08-28 — value classes resolve by KIND; the 344-name residual drops to 4

Item (b): generalise the "does the referent exist?" sweep.

Three kinds, not one. AIID 31/31 and FormationID 126/126 resolve as record names; CollisionMeshes 18/18 and EnumerateSubobjective 17/17 as pak files; NamePlate splits 27 records + 24 files + 1 empty = 52; BackGroundID 0 of 10 as either. (FormationID re-derives unit-group-table's own "every FormationID resolves" as the control.)

🔑 The 27 NamePlate "non-file plate ids" are record namesACROPOLIS ×114, NP_ArrowHead ×84, NP_ASFrigate ×78. So stage-definition-table's census improves: 313 resolve as files + 27 as records = 340 of 344, leaving the 4 never-shipped _Test tables as the entire residual. The old reading was a kind error, not a missing file.

🟡 BackGroundID is a genuinely unresolved third kind — the ten sky names, used 636 times each, are neither records nor pak keys under 10 prefixes × 9 suffix forms.

⚠️ Powerless route, recorded so it is not re-run: the image holds 0 exact strings for all ten backgrounds — and also 0 for Formation_4_Bird, AI_Structure, NP_ArrowHead, ACROPOLIS, CollisionSet_S03.bin, all of which do resolve in data. The executable names no data value, so the negative discriminates nothing.