The move I flagged last iteration -- "0xAB03E5BA is built once, so
nothing consumes it" -- is refuted by its own control: 25 of the 40
distinct 0xAB03xxxx tags in the image are built exactly once (62.5 %),
and no 4-aligned word anywhere in the image begins 0xAB03, so tags are
never stored statically.
What does stand:
* sub_82175C20(bus+4, &msg) is a ring-buffer PUSH, not a dispatch.
* The message class has a one-method vtable {scalar deleting dtor}
with an MSVC RTTI locator at vtable[-1] -- no handler of its own.
* The bus at [0x828F35DC] has a map at +8216, looked up via
sub_82254A08 from sub_823001E8, whose only caller is the per-frame
pump sub_8230C398.
So the item is NOT settled: the insert path into bus+8216 is unread,
and whether running out of time ends the mission is still unknown.
Docs only; ISL artefacts regenerate byte-identical.
204 KiB
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.mdleft "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 passcontainer+12to the generic helpersub_8226EAB8— only 2 of its 16 callers do, and one of them setsaddi r29, r24, 272first. That issub_8226A0D8= ScriptPhase vtable slot 28 = BUILT-IN 25, with ZERO realbls 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 0–1.6 % vs 6.6–8.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: each0x1883record is0x1883, base, size, 0, entry_a, entry_bwith the entries phase-relative — 82 of 88 land on a valid instruction (93.2 %) vs a 38.6 % control — andentry_bis 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) becausedominating_conditions()builds its own entry set — fix-the-instance again. ✅ (2026-08-27)entry_aIDENTIFIED — 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, tag0x19= int, tag0x1A= IEEE float, and the dispatcher only has 25 opcodes (cmplwi 0x18). 44 of 44 phases: the first opcode > 0x18 is exactlyentry_a, zero exceptions; only tags 0x19 (1394) and 0x1A (675) occur. So the record is0x1883, 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_offsetswas 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 + 3Nmatches the record count in every phase. 675 entries disc-wide — exactly the independently counted number of0x1Afloat 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).kindis 0 (556) or 5 (119) — unidentified. 🟡 Runtime cross-check, consistent not conclusive: the closedREMAINING OBwork 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. Artefactsdata/isl-stage02-schedule.txtanddata/isl-schedule-all.txt; the four earlier artefacts regenerate byte-identical. ✅ (2026-08-27) THE CONSUMER IS FOUND —sub_822748D0, called fromScriptPhase::Update(sub_82263408). The phase initialiser stores[phase+240] = code_base + entry_a(add r10,r26,r21; stw r10,240(r30)), and+240has exactly two readers in the ISL region. The walker confirms EVERY field independently of my structural inference: count attable+4,kindatgroup+20, floattatgroup+12, offset atgroup+4, stride 24. 🔑kindis a CLOCK INDEX, not a flag —rlwinm kind,2,0,29+lfsxinto float arrays at[phase+104]and[phase+88]; an entry fires when itstlies 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_822737C8IS 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 insidesub_8226D740(the per-frame engine→script drain), and two unread (sub_82273910,sub_82264058). 🔴 CORRECTION: the0x1883record'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 prologuespecial[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 atphase+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 drainsub_8226D740as(phase+272, …6 out-params). Writer (built-in 25) and reader touch the same seven payload offsets — +0local[4](the unit), +4 and +28local[12], +8 computed, +16 a double fromlocal[16], +24 the constant 1, +32 — neither derived from the other. Container: +16 head, +20 pending count (matching the countisl-builtins.mdwatched live atphase+272+20), +24 cursor. 🔴 WITHDRAWN: "the drain spawns from[node+112]" —r31 = r1 − 256, the STACK FRAME;+112is 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.mdwithdrewsub_8226E458's link to the trigger queue by reading its SECOND argument ([phase+324]); the FIRST isr26 = phase + 272, set twelve instructions earlier. The drain does operate on the container — whatsub_8226E458does 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+28is a 1–12 index, yet the drain spawnsbase + payload+28) had a simple cause: built-in 25 is not the only appender. Searching for functions writing a double at+16AND a word at+28of the same register finds exactly two —sub_8226A0D8(built-in 25, slot 28) andsub_8226E160, whose sole callersub_82269ED0is vtable slot 68 = built-in 19. Built-in 19 passeslocal[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 164–111 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
retstranded afterend_coroutine) — but the gap is real: 24443 instructions in 106 runs, up to 1526 long. It is live logic: Stage 26's biggest run callsbuiltin57(ADN110,1)andset_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_coroutine1765, timeline 675, built-in 19 triggers 79,kill_coroutine150,0x188388), not ONE lands on an unreached run-start. ✅ Not a tool artefact: 1765/1765start_coroutineentries 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 onsub_822737C8's third argument would. -
✅ (2026-08-27)
+24IS 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, conditionsub_8226DAF8,+28= a code offset (79/79 vs 27.8 % control); built-in 25 (writes 1) → no spawn, conditionsub_8226DC80, and the path resolves+28through[phase+244]= symbol table 1 (lwz r10,112(r31); lwz r11,244(r29); lwzx). 🔑 All 25 of built-in 25'slocal[12]values resolve to symtab-1 entries, and every one is aRoute_*(Route_ADS101_p1F,Route_TCN004_p1S, …) — so the puzzling 1–12 values are small symbol indices, and this independently confirmsSYM1_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 site0x8226DA10. -
✅ (2026-08-27) BOTH trigger kinds watch a ROUTE. structures/isl-trigger-node. The kind-0 condition
sub_8226DAF8resolves a symbol frompayload+4through[phase+244]= symtab 1 (rlwinm r10,r5,2,0,29; lwz r11,244(r30); lwzx), and both appenders writelocal[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 withSYM1_SLOTS, which lists slot 12 for both. 🔑 Joins up with the closedREMAINING OBresult (the counter rises at a squadron's route arrival time) — 🟡 stated as a connection, not a demonstration. 🟡 Still unread: what the conditions COMPARE (onlysub_8226DAF8's first ~34 instructions are read, up to the name lookup;sub_8226DC80entirely), andpayload+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_8226DAF8resolves the route name (sub_823012D8), requires the unit record'srec+100 != 0, extracts two 3-double vectors —rec+32/+40/+48andrec+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-axisfsub,fmul+ twofmadd,fsqrt→|B−A|; reject if|B−A| < 0.1(degenerate); reject if|P−A| > f1or|P−B| > f1; thent = (P−A)·(B−A) / |B−A|², reject ift < 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 thet < 0rejection. -
✅ (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_8226DC80issub_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_8217FA08→sub_823012D8), index[phase+324]bypayload+0, requirerec+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 (threefsubagainst the route point,fmul+ twofmadd,fsqrt) and returns 0 if it exceedsf31, 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 thet < 0rejection,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/+48and 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…60→stfd 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_8226DAF8readslwz 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/+16and+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_fregREADS 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-checks0 <= local[0] < 32thenlwz r10, 88(r31)/rlwinm i,2,0,29/lfsx— a FLOAT from[phase+88][i], whichisl-builtins.mdalready recorded. 🔑 That is exactly the array the TIMELINE'skindindexes, sokindis not a selector over six private clocks but an index into the same 32-entry float register file the script reads withread_freg(i)—read_freg(0) < 1200in a clear condition and a timeline entry withkind = 0read 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_fregreturns 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_fregRETURNS SECONDS, and the three "flag" built-ins are a STOPWATCH BANK. structures/isl-timers. Last iteration found whatread_fregreads 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 fromScriptPhase::Updateat0x82263480with Update's own float passed straight through — doesprev[i] = cur[i]thenif running[i] == 1: cur[i] += dt. So[phase+104]is last frame's value and[phase+120]the running flag, and the walker'sprev <= t < curis "did timerkindcrosstthis frame". 🔑dtis SECONDS, by a round trip that is not circular: the frame loopsub_821AA1B0computesframes × (1/60)(0x8289A0F8= 0.016666668), turns it into integer ticks with× 10000.0(0x820A13B4), clamps at 3200, andsub_821A49A8scales back by1e-4(0x8289A0FC) into the timing singleton at[0x828F35B4]—+8for the script VM,+16for 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 timelinekindmust be an index its own phase starts — and every one is. That is also whykindis only ever 0 or 5: every phase opensset_flag(5); set_flag(0). Artefactdata/isl-timers.txt; all seven existing artefacts regenerate byte-identical. 🔴 Two corrections toisl-builtins.md.set_flag(i)writes0.0, not1.0— the constant at0x8209FD28is literally zero, the same one the initialiser clears with; andclear_flag(i)does not zero the value, it clears[phase+120][i]only (all 133 sites disc-wide pass −1 = all 32, in the teardownreset_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 allkind = 0, in a coherent block of stages). Cause:isl.disdefaults tostop_at_ret=True, so aset_flagat the start of a coroutine lost its operand staging to the precedingret. Checked before believing — the corpus's own trap, paid again. 🟡 Not settled: the names (set_flag/read_freg/clear_flagdescribe none of this, but 127/123/124 already holdtimer_set/timer_resume/timer_stopfor a different timer, so renaming is its own change); one exception,read_freg(15)in Stage 06 phase 1 has noset_flag(15)in that phase; the three scale fieldss24/s40/s44(all 1.0 at construction) have no writer searched for — a game-speed setter would be one; and 31read_fregsites 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.mdleft 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 bysub_8225FEF8, and driven bysub_822639B8, called fromScriptPhase::Updateat0x82263528withfmr f1, f30— the SAMEdtthe stopwatch bank gets, so it counts the same seconds. Its body:if running: [+304] += dt, andif armed:[+308] -= dtwhile[+308] > 0, ELSE[+312] -= dtclamped at 0. 🔑 A threshold would be compared;[+312]is never compared with[+308]anywhere — it is decremented, and thebc 12, gtat0x822639FCjumps away to the A arm, so B only runs once A is spent. Two sequential countdowns. Disc-wide, 29timer_setsites: 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 90–94 (0x8226C690): 127timer_set(a,b)arms and loads A/B (as doubles fromlocal[0]/local[1]), 123timer_resumestarts the elapsed counter, 124timer_stopstops it and disarms, 125timer_resetzeroes all three floats, 126timer_elapsedreturns[+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, andexpired = armed && A <= 0. 🔴 CORRECTION tomission-phase-timers.md: "timer 0 is started bytimer_set(1200,180)thentimer_resume, with a 1200-second limit" merges two clocks.timer_set/timer_resumearm and start THIS object;set_flag(0), one instruction later, starts stopwatch 0, which is what the timeline'skind = 0reads. 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: 123–127 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 to0xAB03E5BA— the literal appears at exactly one site, the producer, but0xAB03is 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 170timer_stopsites 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 "
0xAB03E5BAis built at exactly one site, so nothing consumes the timer message". The control kills it: 25 of the 40 distinct0xAB03xxxxtags 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 begins0xAB03— 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 viasub_8228E208) and never looks at the message. (2) The message class has a one-method vtable:0x820A8D18–0x820A8DA0is a run of 2-word records{ sub_82301C20, 0x8210Exxx }; the timer message's vptr0x820A8D68givesvtable[0] = sub_82301C20(a scalar deleting destructor — stores0x820A7014over the vptr, conditionally frees) andvtable[-1] = 0x8210E288, an MSVC RTTI locator (0,0,0, &typeDesc 0x8289D094, &classHierarchy 0x8210E29C,numBaseClasses = 3) — data, not code, and belowsylpheed.db's0x82150000floor. 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 throughsub_82254A08fromsub_823001E8, whose only caller issub_8230C398— the per-frame message pump, called from the frame loop (0x821A6544) with the framedt. So the question reduces to what inserts intobus+8216and with which key. That path is unread, so the item is NOT settled: whether running out of time ends the mission is still unknown.
✅✅ 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 XThreadfutex-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)
-
✅
--audioprevents boot outright.run-canary's own header says the flag is not a cvar in this tree, and that an unknown argument makes xenia callShowSimpleMessageBoxfromParseLaunchArgumentsbefore 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=1367 565 bytes run-canary --audio --apu=sdl --log_mask=13209 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 branchauto/idxd-unnamed-keysstill 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.mainand 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'sskip_introdeadlocks on the attract loop. It classifies the screen asmovieand declines to tap ("tapping breaks the title"), and waited out 600 s of unbrokenmovieverdicts 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. Usenav_to_flight.shon an already-running canary. -
⚠️
EMULATOR GONE at ~40 sis this project's own Stop hook, whichkill -9s everyxenia_canarywhen 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 onefly_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'sENT_VA_LO/HI=0xBD000000–0xBE000000contains no live instance (they sit at0xBC384CE0–0xBC9BAC20); it covers the definitions instead. Rescoped to the instance region,find_delta's±0x400radius yields zero votes.gworld.py'sWINDOW = 0x600is too small — no position-like triple moves in the first0x600bytes of any of the 42 instances.0x4000finds 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
datathan they store" — the declared sizes are honest. EveryRIFF-bearing entry on the disc carriesseekmagic at exactlydata_at + declared_sizewithpacket_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_450–454, 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/0xa110look 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 au16at+0x0A(upper half zero 21 184/21 184) and ✅ an enumeration — 216 values. 🔴 "the keys are pak-local" is REFUTED — 68/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 aT8aDmagic, but sprites are usuallyRATCchildren — it saw 4 525 of 21 184 sprites, 45 of 216 keys, and notGP_TITLE.pakat 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 ofGP_MAIN_GAME_2Das 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 fromGP_SAVE_LOAD, i.e. outsideGP_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:
-
✅
.prmprimitives are decoded (2026-08-19). Untextured full-screen colour quads: 0 of 369 has a payload child,kind & 0x10⟺.prmwith zero exceptions in either direction, 361/369 are exactly 1280×720 at 100 % in the corner, and the fill colour is the keyframe'sfadeARGB — mostly black at some alpha, i.e. the fade-to-black / dim / flash layers. Seestructures/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. Seestructures/ui-resting-pose.mdandtools/re-capture/align_to_capture.py. -
✅ The keyframe
fadealpha is applied (2026-08-19). ARGB, multiplied on top oftint. 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. Seestructures/ui-resting-pose.md. -
🟡 The
.prmquads 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,+44a 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.prmimplies a key in (0x8010, 0x8040) on both screens it appears on,pteff00.prmpast the maximum on both,palogo_eff0.prmbelow the minimum.implied_layer_keyrecords those, andderived_paint_ordernow 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.prmandpceff00.prmare what wipe the 36 builds, which is whyinclude_primitivesstays off by default. A capture of any screen carrying one would close it. Seestructures/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 paintseff1, eff2, eff5, eff3, eff4). Refuted: declaration order, RATC child order, first keyframe time, resting time, resting X/Y, andT8aDheader 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_back2eff5vseff3(22 568 px) and vseff4(32 395 px). The residual is one element's blend on one screen, and it is pinned by a test. The third pair (ptlogo2vsptlogo_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), fromGP_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.prmlast — and it is the first screen outsideGP_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: unkeyedpfbase.tbmbackgrounds paint first (now covered byimplied_layer_key), and the0xb100group of four paints10,11,8,12. 🔴 Refuted: the tie-break is notkind. "Descending kind" reproduces10,11,8,12exactly 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 zeroGUEST-THROW,CRASH DUMPandAccess 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=13had 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, and00:00:00host 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 (canarya60fe7d11):threading_posix.ccpublishes a suspended thread'sstate_and itssuspend_count_in two separate lock scopes, andResume()waits only forstate_before testingif (suspend_count_ == 0) return false. A resumer in that gap drops the resume; the thread then waits on the count forever. The LinuxXThread::Resumediscards thefalse, 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 4ResolvePathreads — 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, thenscreen_id.pymatching the SQUARE ENIX logo). Nowtools/re-capture/is_title.pycounts 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. Seecanary-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 all0xffffff. 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(all0x0), 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.ratrecord, the RATC child stream, or the game's code. Seestructures/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
+0x10labels 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/+0x1care the design resolution (1280/720 on 98.7 %, asserted), and ✅+0x08is 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. ✅+0x0cis two u16s forming an ordered interval —high < lowin 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 onhighis a coincidence of zeros. The window is narrow, a median 2 % of the keyframe span. 🟡 what the window means stays open.+0x10is ✅ a zerou16plus a 16-bit flag word at+0x12(high half zero in 2 985/2 985); ❔ the bit meanings, with four measurable predicates now excluded. Seestructures/ui-rat-layout.md. -
🟡 What
optlinks — 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>ffocus 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 rawopttags exist against 1 467 classified —opt_linkreads 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:
-
❌
REGNis 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. 11REGN+ 11MCOLobjects, 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 atalign16(index end)— 11 of 11, one record per occupied cell. ✅ And the container is a serialised object graph: every object carries aPOF0pointer-fixup table at exactlyheader[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 ofcount × 4bytes"). The payload is float-dominated and otherwise ❔. ✅ 2026-08-26: the other three sections have strides — 12 / 96 / 48 bytes, withcounts[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, withn·p + d = 0to 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 branchauto/regn-reader(2026-08-26).REGNis a tetrahedral navigation mesh. The route in was thePOF0fixup 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 isposition → 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.07–2.2 % random-face control; portal cost equals the distance between face centroids, 380 460/380 460. ⚠️ The base ischunk + 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 homogeneousf32array yields other floats from the same array, so the test passes either way. Seestructures/regn-map-grid.md. ❔ Still open there: the runtime consumer (not reached; afloat4-aligned header read with VMX loads leaves no displacement signature to search for). ✅MCOLis 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. The0x5Cblock 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, andmax u16 == len/16 − 1in 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 withtools/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. ✅✅MCOLSOLVED the same day — it is a closed triangle collision mesh. The0x50word is twou16counts (vertices, triangles), which gives the last two blocks their stride:len(0x54) == align16(12·nv)andlen(0x58) == align16(6·nt)in 11/11, andnt== the sphere count in 11/11. Sphere i is the tight bounding sphere of triangle i — 4 768/4 768,max‖v−c‖/rmedian 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 verifyreproduces it (data/mcol-verify.txt);mcol_probe.py objexports any object as a Wavefront OBJ. ✅✅ And all 40MiscBinentries are now name-resolved (2026-08-26). The names are theMapPath/MapMesh/CollisionMeshesvalues of the per-stageStageResourceobject (IDXD schema3c9ae32e, in everyGP_MAIN_GAME_<lang>.pak), each hashing with the ordinary pakname_hashstraight to a TOC entry — 40/40, no collisions: 11<stem>.rgn, 11<stem>.col, and the 18 remaining blobs asCollisionSet_S01…S16/_Tutorial/_test.bin. The.pestring 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.rgnand 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.colis 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_*.bindecoded (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}(noteu32indices, whereMCOLusesu16), next atoff + 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 theGameResourceIDfield, not the.xbgmanifests — 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. ACollisionSetentry 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 returnsmapmesh_box_500kmand 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.pygives 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 ofrun-canary/pad.py/screenshot/findplayer.pyalready 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 —CMapColliderBridgein the.peRTTI at 9 044 264 — but has not been followed into the code. Seestructures/mcol-collision.md. -
🔴
hidden/DefTables.pakis 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) carriesRenderFrameModel,FrameAnimLength1st,FrameAnimLoopLength,IsPlayerSE;634a80ae(183) addsDissolveDistanceMin/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 knownLOD_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.slbis a false lead. It is a field of the sound table;.slbis an XACT sound bank (structures/sound-slb.md). -
✅ FOUND — it is the
GP_MAIN_GAME_<lang>.pakunnamed objects. Sweeping the 811 unnamed entries by schema turned up schema3c9ae32e, the per-stage definition record: one per stage (Stage_S01.xpr…Stage_S29.xpr), naming that stage's background, resource package, collision set, message set, nameplates,MapMesh/MapPath, and — the point — itsEnumerateSquadron = 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 ofFormationID/AIID/SideID/Count/DisableIntervalplus the unit model (UN_e010_ADAN_Attacker_S, matching our XBG7 names), theMessageSet_*, and the pilot character.DisableIntervalis the first direct evidence of the timing knob. Same pass also settledMapPath = test.rgn→ theREGNobjects. Seestructures/stage-definition-table.md. 🔴 Refuted along the way: the 16-byte record key is not the squadron ID's name hash (0 of 112). -
✅
UnitGroupis fully decoded (2026-08-24). Container and field semantics instructures/unit-group-table.md, tooltools/re-capture/unitgroup.py, Stage 02 dump committed atdata/unitgroup-s02.txt. A squadron record isCountmember tuples — (unit model, message set,n, identity/nameplate) — followed by five named fieldsCount / SideID / AIID / FormationID / DisableInterval. Validated corpus-wide by two independent self-checks, each 1160/1160 across all 28 stage tables: theCount*4+5length identity, and agreement with the file's ownEnumerate_Squadronsroster. 🔴 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, tooltools/re-capture/stagetbl.py --follow S02. The real stage record (the one dumped earlier was the_Testtemplate) splits a stage intoPhase_1..3blocks and namesRoute_S<NN>.tbl,SUBObjectiveSettings_S<NN>.tbl,AIParams_S<NN>.tbl,FormationSet_S<NN>.tbl,nameplate_S<NN>.tbland more.Route_S<NN>.tblis the arrival schedule: records namedRoute_<squadron>_p<phase><kind>holding time-stamped keyframes(time, quat x4, pos x3), tying aUnitGroupsquadron to a phase and a path — self-checkedFrameCount*8+1on 1449/1449 route records across 28 stages.AIParamscarries 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/02use 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 of0x820af030entity records is FLAT at 116 for 210 s — no arrivals visible. ✅ 2026-08-26: the flat count is explained, not merely refuted. Built-in 12activate_unitreturns 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:tis 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 89–94 % do under frames at 30/60 fps. Seemission-wave-arrivals.md. -
✅🟡 Motion-independent liveness probe works;
nis 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,PlayerandAcropolisall come out at exactly 2×sum(n). An undershoot is explainable by phases; an overshoot is not.nback to ❔. Formation slot count also rejected (630 turret slots vs 214 live). ✅ Side result:FormationSetFrameCount= 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 (0xbc372c00–0xbc9bc720vs0xbdb2fd80–0xbdcd1d80). 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 atroster_base + 0x08. The delta histogram spiked at+0x08with 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+0x04name 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 16–20 losses in each piloted run — so NPC crossfire destroys nothing on its own. 🔴 Zero0→narrivals in either condition, ~15 min cumulative. 🔴 Prime suspect REFUTED (2026-08-24): the clock is running —mission-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 ~14–19 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 whiledeployedheld 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 theSYLPH_KEEPOUT=1400run 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 hit2→0— firstn→0ever 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: asedhad 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=1400gives a pilot that kills and survives (hull 1500, asset 100 %, 8 losses). An enemy squadron was eliminated —e007 Turret 2→0at t=163 s,deployed41→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 ~300preads 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→2at 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 flaggedGUEST STALLEDwhile 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_probehad 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 explicitRUN UNVALIDATEDwhen 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 8–40/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 movedin 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, whiletimer_probesearched 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 — aSYLPH_NO_POKE=1control on Stage 01 froze with the identical free-page count as the poked Stage 02 run. This also refuteschallenge-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 1–4 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/17on 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.shattaches 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 8–24/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_*andUnitGroup_S01.tblwith 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 25–35 min into one phase (implausible) but at 30 Hz is 50/60/70 s. ⇒ S02's t=90…240 would be 3–8 seconds, all before the probe's first sample (~25 s in) — explaining every null result, and consistent withdeployed=41already 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.pycuts setup from ~25 s to 0.5–0.8 s (no calibration, no labelling,bytes.findscan). At flight+0.8 s deployed=41 already, flat for 252 s (only 41→40, one squadron wiped). 🔴 The--waitvariant 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 FLIGHTat +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 start —
mission-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, andSUBObjectiveSettingsnames 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.tblresolved — prefix ismessage\, notstage\(closes a long-standing ❔). ✅message\UnitMessageSet_S02.tblhasCrewCount+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 (None105,Emergency33,Killed5,Noise5; arg is1in 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, anddefault.xexis 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.tblisIXUD, UTF-16-BE (prefixlanguage\, 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'sUN_e201_ADAN_ISCMissile×9 exactly matchingSUBOBJ_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 readsUN_e007_ADAN_Turret, becauseSYLPH_HUNT/KEEPOUTwere built for turrets. 42 kills were the wrong 42. Test: a pilot that prioritisese010overe007, watchingdeployedjump from 41. -
✅🚧 (2026-08-24)
SYLPH_PREFERworks; 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 2e010— real effect (was ~1 across ALL prior runs) but weak; commitment + proximity keep pulling back to turrets, which outnumber attackers 108:16 in craft.deployedstayed 41, no advance. 🚧 Quantified blocker: phase 1 fields 16Attacker_Scraft; 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 9ISCMissile+SUBOBJ_013). ✅[OB]= objective marker — hints say "Red mission markers [OB] indicate your targets", soREMAINING OBat0xbdb59668is phase-1 progress and is the right signal to watch, notdeployed. 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), soixud.rsis right andlocalization.rsis the wrong one — its header says UTF-16LE and it decodes withu16::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: onee010event 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 inguest-stalls.mdfour 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=1resume; 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 2–3e010events, i.e. the same combat limit. 🔴SYLPH_HZ=3refuted 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
e010event 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 flaggedGUEST 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 2–3e010events 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
u32in0xBD000000–0xBE000000decrements 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 outREMAINING OBbeing a general kill tally (it ignored ten turret deaths). ❔ Still needs 2–3e010events; one clean 220 s run produced zero. -
✅🔴 (2026-08-24) Kill-free HUD route works; BE-
u32assumption REFUTED.ob_by_hud.pyreads 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. ⇒ within0xBD000000–0xBE000000as 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 read00?/???), 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 extendob_digits.png. -
🔴 (2026-08-24) Widened to 7 encodings; run inconclusive.
ob_by_hud.pynow keeps a candidate set per encoding (u32be/le,u16be/leat both alignments,u8) as byte offsets.u32leis 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 (saysUNVALIDATEDwhen 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), andsummary()putting the witness first. Also providescraft()/strengths()/alive()/heap()so a new probe writes only its own logic. ✅ Verified:deploy_probe.pyreimplements 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 OBFOUND AND VERIFIED: big-endian u32 at0xbdb59668. On the new harness, the HUD changed 4→8 and the intersection collapsed in one step to a singleu32besurvivor (withu16be/u8hits being the low half/byte of the same word). Verified live: HUD012vsmem@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, sinceob_digits.pngonly 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.pynow hunts the address on the current run when confirmation fails. ✅ HUD reader is now confidence-gated (every digit ≥0.80, margin ≥0.05 — the ruleob_read's own docstring states), closing the hole that produced the wrongu32berefutation. 🔴 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: theBaseHeap::Release failedburst is not a freeze signature (routine; spans lines 1044–5210 in a log that runs 2 700 lines further). 🔴 Both were already settled inmission-freeze-resume-spin.md, as was0xbdb59668— that is twice in one session that existing work was redone. Cause is mechanical:INDEX.mdlisted 20 of 43 notes and none of the recent ones, so searching it returned nothing. ✅ Fixed:INDEX.mdnow 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 argswon'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 oneinfo argsaway" is wrong for this binary —readelfshows 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 ofXObject::Wait's spilledthis— no rebuild, keeps the oracle identical, but per-frame archaeology; (2) aRelWithDebInfobuild viabuild-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 —
thisis in%rbx. Static analysis, no run spent:XObject::Wait's prologue doesmov %rdi,%rbxat8fbc9c, sothissits in a callee-saved register rather than a stack slot. And the binary carries.eh_framewith 127 231 FDEs (Release builds keep it for C++ exceptions), including one covering8fbc90..8fbde2that tracksrbxexplicitly. ⇒ from a frozen thread,frame 3+info registers rbxgives theXObject*being waited on, andx/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 3→rbx→x/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, differentthiseach, 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 (atimeoutkills the process group and took the emulator with it once), leaving too little to reach the ~267 s freeze; (2) thethisaddresses (0x7ffd…,0x7ffc…) look like host stack, not heap, so either xenia places them unusually orrbxisn'tWait'sthisafter 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::Waitframes, 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 avtable for …symbol;rbxsimply 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/mapsclassification +x/8gxnever 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.rbxwas restored fine on all 18. The backtrace grep matchedXObject::Waitas a substring, soWaitMultipleframes were pooled in and read with the wrong rule —Waitkeepsthisin rbx (8fbc90: mov %rdi,%rbx) butWaitMultiplekeeps theXObject**array (8fbfc0: mov %rsi,%rbx), so[rbx]there isobjects[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 unresolved — XEvent 20 / XSemaphore 9 / XTimer 1 (data/waitobj-s02.txt).XTimerwas invisible before because its only thread is aWaitMultipleone. EveryWaitMultiplethread waits on a pair; T78/79/80 and T64/65 are worker groups sharing a handle. 🟡%ebpis not a usable count —WaitMultiplereuses it at8fc158— 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 TAKENbelow). 🔴 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), andscreen_idreadsflightat both, plus at ~470 s with a drifting frame mean. So the labelledhealthy -> frozendiff is two healthy captures, and its20 -> 18is 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.shsplit intoboot/watch;watchwaits 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 ismax_pixel_delta=0against 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 onWait(XEvent)and T69 onWait(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 dropsWaitMultiple(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 == flightis NOT a freeze test. The previous entry used it to rule out a freeze.frozen.pyexists precisely because a frozen mission still classifies asflight(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 27–255 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 27–255 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 nowwatchis event-driven. -
🚧 BLOCKER: t=210/240 unreachable in one turn— superseded, 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_craftiterated every 4-byte word of 32 MB in Python (8M steps) to find 14 fixed needles. Replaced withbytes.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 selffinds 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 27–255 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.
DisableIntervalalso gains a referent. 🟡 Supporting: noDisableInterval=Yessquadron 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 whetherREMAINING 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 41–56 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_Turretflies underAI_ADAN_CraftSquadron_*, neverAI_Structure). Lesson: check a unit'sAIID, 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 (reuseunit_discover.py, do not re-derive), andRECLEN=0x200was 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: watchRoute_ADN101_p1Factually fire against entity positions, or work back from theSUBOBJ_*_Mes_L1HUD strings; the phase state is more likely near the known mutableREMAINING OBcounter at0xbdb59668than 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 — SETTLED 2026-08-25, see below.
nthe missing S17–S23 stage records — SETTLED 2026-08-25, and half the
premise was wrong.
- ✅
nis the number of units the member tuple instantiates, filling slots of the squadron's formation.FormationSet_S<NN>.tblrecords are slot lists —1 + 8·FrameCountfields, exactly (4→33, 14→113, 30→241, 32→257). Resolving every squadron'sFormationIDand comparing:sum(n) ≤ FrameCountholds 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_onlywithn=2) and is recorded, not swept up. The old "nis not the_NNsuffix ofFormationID" was right but drew the wrong conclusion — the suffix ISFrameCount, son=9against_30just means 9 units in 9 of 30 slots. - ✅
FormationIDresolves by an in-table roster, not by hashing — 0/16 resolve vianame_hash;FormationSet_S02.tblhas 17 records for 16 formations and the extra one carries noFrameCount, its fields being(tag, name, "")with the tags equal to the record keys. Same convention asEnumerate_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>.tblrecord 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 ≤ FrameCountin 20 of 37 phase-1 squadrons (16 onFormation_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. Seemission-phase-membership.md. - 🔴 Refuted with it:
UnitGrouphas no phase/spawn/delay field (1019 = ΣCount·4 + 5·111, every slot accounted, positional tags are sequential indices);DisableIntervalisNofor all 111 S02 squadrons (its 31 corpusYesrecords are only S04 and S14, allGNN***);stage\EnumSquadron_Test.tbldoes 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 T132–T142, earlier ones T104–T106) — 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 Ablind-taps A, which answers NO on the "Load game?" dialog, bounces back to the save list, then reopens it — a 300 s oscillation. Drivingstep up→Aby hand reached the ready room in 18 s.launch_mission.shshould not pass--tap Awhile 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 at0x822606B0, and incremented at exactly one site0x822609F8–0x82260A00(checked: only onestwto40(rN)in the whole state machinesub_82260710). Its guard is[ScriptPhase+196] != 0; that flag has only two writers — vtable slots 0/1 at0x82264058/0x822640F8— reached ONLY from built-ins 6 and 62 of the phase-script VM's 147-entry table (sub_82272220, jump table0x8227226C). 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_8230D1F8reads"Phase_%1d"for map/background only — confirms the executable never consultsPhase_Nfor a trigger, and nothing parsesRoute_*_p<N>*(the 3 such literals at0x820AEA38are 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/shmwith no gdb. - ✅✅ (2026-08-25) SCRIPT BYTECODE FOUND —
Stage\StageNN.ssbindat/GP_MAIN_GAME_<L>.pak. Seestructures/mission-script-ssb.md. The earlier grep failed only because every pak entry isZ1+zlib. The loader resolves table keys, not a filename: GamePart name →GP_SCRIPT→script.tbl(name_hash 0x75FE4656), whoseSCRIPTSrecord is a 40-field manifestMISSION1..MISSION29 → StageNN.ssb. 28 scripts; S17 is the missing one — agreeing with the table sweep and with the loader guardif (n==16 || n>32) return(so mission numbers there are 0-based): three independent routes to the same conclusion. Header decoded fromScriptMission::Loaditself. Stage02 = 226,596 B, md5aff69b5a…, identical in all 6 language paks. Symtab1 326 syms (197 messages, 119 route names, 10 subobjectives), symtab2 122 (111 unit ids = exactly the 111UnitGroup_S02squadrons). 🔑 The route names are SCRIPT SYMBOLS — which is why nothing in the executable parsesRoute_*_p<N>*; the_pNconvention 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.pakandDefTables.pakhave zero hits. - ❔ NEXT, and it is the one that pays: decode the 25 ISL opcodes
(dispatcher
0x822635D4, low byte of a BE u32, jump table0x822635FC) and the 147 built-ins (0x8227226C) againstStage02.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+0x24is partly read (3 groups, one per phase, each ending in a pair of plausible ISL entry offsets) but1883'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 tocode_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 wasisl.disstopping at op 20 (ret) — a coroutine yield, not an end of code — which reached only 4.7 %. Fixed viastop_at_ret; the committeddata/isl-stage02.txtregenerates byte-identical. 🟡 ButEND_PHASE's call site is the WRONG place to read the condition: all 12 Stage-02END_PHASEsites 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✅ (2026-08-27) DONE for the branches — structures/isl-branches.op10(0x82271598),op13(0x82271830),op14,op21,op23.op10/op11are signed/float COMPARE, writing three condition bits (0=EQ, 1=GT, 2=LT) to a bitset atphase+24;op13–op18are the six relational branchesbeq/bne/blt/ble/bgt/bgeon those bits, targeting[phase+232] + word@+4exactly likeop12. All six relations present, each once — the completeness is the check. Handler addresses come from theblinside each dispatcher thunk; guessing them at a fixed stride lands mid-function.▶️ Still open:✅ (2026-08-27) THE OPERAND CHAIN IS CLOSED — structures/isl-builtin-dispatch. ⚠️ op21/op23 were already named inop23(0x82271C30, takes a built-in's result tophase+168) andop21(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.isl-bytecode.md(the owning file) aspush.i/pop.i; myisl-branches.mdwas the stale one. Verified and reconciled. NEW: the 147-entry built-in table is a thin dispatch layer — 112 of 147 stubs tail-call a fixed slot of theScriptPhasevtable, and every named predicate is in that group. The vtable is0x820A84BC, derived fromMARK_LAST_PHASE's known[phase+300]=2stub and confirmed by an unused-in-the-derivation prediction (slot 176 = the=1stub) plus the db's ownvptr_writes.unit_state= slot 184 =0x8226ADF0, which indexes[phase+324]bylocal[4]and writes its answer to[phase+164]=special[0]. So: result →special[0], comparand popped →special[1], thencmp.i+ branch.▶️ Still open: the other 111 vtable slots are a lookup but unread; which comparand each site pushes (needs✅ (2026-08-27) COMPARANDS RESOLVED — structures/isl-conditions. The deques are an expression stack: push the left operand, evaluate the right (result →push.itracked through the decode); the 35 non-vtable built-ins; the vtable's length.special[0]), pop →special[1], compare. Evidence: push/pop balance 1877/1877 across 28 stages with ZERO underflows, and 319/319 Stage-02pop.isites are immediately followed bycmp.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.txtfinally has a generator (isl_report.py conditions); the other two artefacts regenerate identical. Top predicates:hp_pct_test1955,unit_state1257,unit_relation796,dist_lt450.🟡 1.6 % are WRONG: 15 sites attribute the LHS to✅ (2026-08-27) FIXED, and that proposed fix was REFUTED —end_coroutine… Fix = only set it for built-ins that write[phase+164].end_coroutine's handler0x82272624doesstw r11,164(r31), so the filter would have kept it. Real cause:end_coroutinereturns 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 anend_coroutineLHS, 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 jmpis unconditional, so the next instruction is never reached by fall-through either, and the tracker walked through it exactly as it had walked throughend_coroutine. Exposed by another impossible output: a six-way switch onbuiltin80, 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 everystart_coroutinetarget 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]=immthenlocal[0]=special[0]). 🟡 The 389 are a real limit: they are started from the trigger queue atphase+272— by data, not code. ▶️ Still open: the 35 unnamed built-ins (builtin16132 sites,builtin105117,builtin103115 — each now a vtable-slot lookup); ✅ (2026-08-27) partial — structures/isl-unit-args: reading the implementations shows 55 built-ins take a unit atlocal[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 == 1in 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]) andr29there is a call result, so its meaning is unestablished.builtin7(slot 40) indexes the unit array bylocal[4], bails whenrec+16(the unit state) is 1/3/4, and resolveslocal[12]through[phase+244]= symbol table 1 — independently confirmingisl.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 (0vs-4000) which LOOKS like a coordinate — exactly the evidence this corpus does not accept. Still unnamed: all 24.builtin103is a predicate over[phase+10152]and[phase+10156](no unit arg);builtin105tests a unit record's+16== 4 (isl-builtins.mddocumentsrec+16as the unit STATE, 2 = active). 🔴 (2026-08-27)builtin80is a COMMAND, not a predicate (0x82268460): it allocates a 20-byte object, stamps vtable0x820A8CB0+ magic0xAB0311BA- the unit's live object, pushes it on a queue via the
push.ihelper, 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✅ (2026-08-27) DONE — structures/isl-phase-guards (END_PHASE(needs the control flow between them);isl_report.py phase-guards,data/isl-stage02-phase-guards.txt). Uses DOMINANCE over the CFG, not reachability. 🔴 The obvious query — "one branch reachesEND_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 aread_freg(0) < 1200timeout, missing every objective test. Dominators converge in 3 passes over 15670/18739 instructions. Result: every exit in all 3 phases requiresunit_hp_pct(TCN001, Character_Player_Test) != 0(the player alive); phase 1's objective exit additionally requireshp_pct_test(ADT102/ADT107/ADT113, 0) != 1, phase 3's requiresADT301/ADT302, andread_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 whichEND_PHASEis 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 lineprecond/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 atphase+272. ✅ (2026-08-27) EXTENDED TO ALL 28 STAGES —data/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 (S18–S23) have exactly one exit with exactly one dominating condition,builtin104() != 1— andisl-builtins.mdderived built-in 104 from usage alone as "S18–S23 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 twobuiltin141calls differing only in one argument (0vs-4000) — a position/zone shape, unread so unnamed. the vtable's length. The condition lives in theop10/op13poll loop upstream of the outro — e.g. phase 3 pollsunit_state(ADT308)and branches back to0xFEB4until it passes. Artefact:data/isl-stage02-phase-ends.txt.
- the unit's live object, pushes it on a queue via the
- 🐛 (2026-08-25) The nav fix is NOT fully reliable.
dialog_up.pyworks 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 endedNO 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.NoGP_SCRIPT.pak; grepping the extraction forMISSION_START_PRTreturns nothing. Loadersub_8225EE20matches section namesMISSION1..MISSION33+ 5*_PRT;sub_8225EC78gatesif (n==16 || n>32). Candidates: the 7.embsec_sections (VAs 0x84D0000–0x86AC000, ~129 KB total, executable) or a hashed record inhidden/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 2–3.5 s is the right length for the lines. Nothing is missing. The
17 091–20 563 Hz window from the previous entry is void with it.
✅ Each shared bank is ONE generic line — the 3–5 movies sharing a bank have
identical subtitle text, 5/5 banks (
examples/shared_bank_takes.rs). 🎯 That also explains the historical in-game rejection ofhokyu_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/452have a silent or near-silent leading region (RMS 0–301 against ~9 000 for speech) with the line in the RIFF;453/454have 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 firstRIFF. 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_riffsemits 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, thehokyu_*_Htankers onVOICE_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 bytests/slb_leading_segment_disc.rs, including that the all-zeroVOICE_D_451region 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 bindinghokyu_DS_s13A -> VOICE_D_452where the corpus records the movie as unbound and a test assertsNone, 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 andslb::to_xma_riffsfinds audio by searching for that magic: 87 % ofVOICE_D_453and 85 % ofVOICE_D_454sit in front of it, 21–27 % zero over 256 distinct byte values — content, not padding.VOICE_D_451is 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 firstRIFFsits at1392 + n*2048in all five banks (n = 8, 1, 7, 22, 29) — 1392 being the crate's ownHEADERLESS_DATA_OFFSETand 2048 the XMA1 packet size. No free parameter. ❌ But my fix for it is WITHDRAWN. Emitting that region as a sub-wave tookVOICE_D_453from 5.4 % to 89.9 % byte coverage — and the stream decodes to 1792 PCM bytes, silence, while the RIFF sub-waves decode to 150–270 KB. Byte coverage was the wrong success metric. The rule also matches 1524 of 8021 RIFF-bearingsound.pakentries, includingRT*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_450cue 4.00 s vs 1.41 s decoded,D_4513.70 vs 1.81,D_4534.70 vs 0.07. The other two have their only cue at 0.0 s and give no signal. Artifactexamples/voice_len_vs_subs.rs, FFmpeg-measured. ✅ (same day) The leading region IS XMA1 — MONO, not stereo. Atchannels = 2every bank decodes to exactly 1792 bytes regardless of size (one frame, then it stops); atchannels = 1the same data yields up to 113× more —VOICE_D_453goes 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-zeroVOICE_D_451region is the control the other way. The earlier 0-byte probe was my own error: I readsynth_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 forD_453(temptingly near 22 050, and I nearly wrote it down) but 5 844 Hz forD_450. The decodes are partial: samples per input byte ranges 2.10–4.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.10–4.96 samples-per-byte spread is ordinary XMA1 variable bitrate (4.21–9.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/cueis an UPPER bound,samples/moviea 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 3–5 movie slots — so if a bank holds several takes the lower bound is void, leavingrate <= 20 563, which 22 050 nearly meets. ▶️ Next: establish whether a shared bank is one line or several takes. Seevoice-bank-leading-region.md. - ❌ (2026-08-25) My own boot-nav diagnosis, MEASURED AND WITHDRAWN. I said
the run died because
skip_intro.shgates the title test atrmse <= 1500and 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 att=145 sthe RMSE was 1205 — inside the threshold — withis_title.pyanswering 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 + ImageMagickcompare+ 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 afterrm -f /dev/shm/xenia_*, did not. - 🚧 (2026-08-25) Still unrun — the live test of the
reset_phase_threadsrename. 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.pyprints 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 really500000.0;ShieldRatiois1.0where a committed test assertedNoneand called it defaulted; andget_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_raw52 % 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 throughIdxdObject::record; highest value first — hangar models, weaponPower/Acceleration/MinimumVelocity, turret and subsystem stats. Seeidxd-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 acrossstage01…stage16do 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 at0x08is not a schema hash, it is record 0'sname_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 anauxflags word. Seestructures/idxd-container.md. ⚠️ My first disc sweep globbeddat/**and missedhidden/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 ofget_f32/get_rawis 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, legacyget_f32is correct 39.42 %, safelyNone43.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 — becauseget_rawfinds 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 located —
sub_82447DF0(IDXD) andsub_82447E70(IXUD), transcribed instruction-for-instruction into Python and Rust;cargo test -p sylpheed-formats --lib hash10/10. IXUD SOLVED: it chains two exact moduli (loop mod2^32-153in 64-bit, then fold mod2^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_hashmust 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; andname_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.pydetects the dim the game draws behind a modal (mean 34 vs 59–62), andwait_screen.sh --tap-if-dialogonly presses while one is up;launch_mission.shnow 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:
.peis NOT stale — it is a flat VA image (offset = VA − 0x82000000), verified 7/7 against the DB. Andinstructions.rawinsylpheed.dbis 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 unknownand is not in the data: swept the stage record and every table it names plus themessage\family forinterval|time|phase|delay|wave|spawn|arrival|trigger|start|appear|event|condition— onlyFrameCountandPresetMessage_Phase1/2/3hit. It is in the executable.- 🟡
Does NOT close the 387-vs-~300 gapinroster-to-craft-link.md. Σnover Stage 02 is 387 vs 296–300 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 ofnassumed everything deploys at once — still untested. The record-key derivation also stays ❔ (the tag is notname_hash; a second hash function is unidentified). - ✅ S18–S23 stage records were never missing.
stagetbl.py Stage_S18returns a full six-record definition and always would have; it was never run. They omit the literalStage_S<NN>(no per-stage.xpr;AIParams/weapons/strings/ subobjectives/nameplate/collision all come from a shared_Tutorialset), so an enumeration keyed on that literal skipped all six. Counting distinct*_S<NN>names across all 1119 decompressed entries:UnitGroupandRoutecover 28 stages (S01–S16, S18–S23, S24–S29);Stageliteral andAIParams22;SUBObjectiveSettings16 (story only). Tutorial records carry the samePhase_1/2/3shape, so the tutorial is not a special mission type at the data layer. Dump indata/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 1–16, tutorial 18–23, challenge 24–29). - 🔴 Refuted:
GP_TUTORIAL.pakdoes not hold the tutorial stage config — 2 entries, both RATC, zero IDXD, exactly likeGP_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.shfinishes 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 fixedsleep 28for LOAD → READY ROOM (nowwait_screen.sh readyroom), and the READY ROOM being drawn before it is usable —Preparing to Sortie, TAKE OFF greyed, which whole-image statistics cannot see (1.7 units of blue) sotake_off_armed.pytests the label. A third defect fell out of the same run:wait_flight.shwas testing pixel (450,640) "inside the SHIELD bar" of a 1280×720 window, whilescreenshotcrops 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
entities2cannot 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+0x250with 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, methods0x823c43b0…0x823c45a0, three construction sites insylpheed.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. Seestructures/hud-glyph-quad.md. - 🔴 The counter is not "hostiles left" either. It held at
012for fifteen minutes of live flight while the ADAN population fell 132 → 93. - ✅ But it decrements when the player kills:
12 → 11with 411fire=1samples andYOU KILLED WARPLANES 0003on the HUD — the first decrement seen, and the first run where the player's guns were part of the experiment. - ✅
pilot.pynever fires — ROOT CAUSE FIXED (2026-08-23):flight_probe.Padwas 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.txtstayed empty. The craft was never being flown. Verified: full stick went from0.00°of heading change to12.72°, and the attitude matrix fromd 0.0000tod 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 fires —fire=1in 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 KILLEDis0000after 250 s of firing andREMAINING OBis still012— 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.pystill write to the dead FIFO andctrl_probe.py/target_probe.pystill usepad.f; all flagged in place, none repaired. Seepilot-never-fires.md. - ✅
EMULATOR GONEis SOLVED — it was this project's ownStophook (2026-08-24), whichkill -9sxenia_canaryat 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
Rgaining 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 inKeWaitForSingleObject/NtWaitForSingleObjectEx, the GPU processor idle, the main thread inpoll()— 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_BINpointing at a gdb wrapper keeps the lockfile and satisfiesptrace_scope=1. 🔴 Reading the wait target from the log is blocked by cost:KeWaitForSingleObjectiskHighFrequencyand 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 (canaryauto/re-wait-timeout-probe820696c11,--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 VABE56BB5Cwith 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 (canary597740046): 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 wasX_STATUS_SUCCESSin 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.pydetects the state in one call;ob_hunt.py/ob_flag.pyabort on it. Roughly two runs in three. Seemission-freeze-resume-spin.md.
✅ 2026-08-27 — the "first step, revised" below is DONE, and
--log_mask=0is a dead endThe counter moves.
pilot.pygainedSYLPH_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 droveREMAINING OB008 → 007, concurrent with the livee010floor dropping 16 → 15.REMAINING OBis now fully solved — seeob-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=0for 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 127A>(Apu/XMA), 42 444d>, 2 897G>, 532w>— with zerok>and only 14K>lines in a 58 k-line boot sample.XamShowSigninUI,KeWaitForSingleObjectandNtWaitForSingleObjecteach 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 arekHighFrequencyand stay silent without--log_high_frequency_kernel_calls=true, solog_mask=0buys nothing but audio spam. The purpose-built--log_stuck_waitsprobe remains the right instrument.⚠️ That run also did not freeze — healthy from
TIME 00:24.28to03:33.28,frozen.pyanimating 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 0–5), 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 0–5) 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
.prmelements absent — strictly less information, and it has no place to putptbase2other than where the table already puts it. - Reverse declaration order is refuted by the same capture: it would draw
ptbase2(13) overptcopyright(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 0–5. 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 anf32x3position 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:
+280,+320xffffffff,+360xffffffff(except an instance index onkind = 0x4),+440xffffffff,+560. - 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_point →
sub_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 1–2, 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 02–29 — 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 RequestShipRender → build_ship_model → assemble_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 legitimate —
e303_wep_01is a small shared turret, cross-mounted one101ande106, 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 22–32 models in each
of S02, S03, S08, S26, S27 — but 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
e106render correctly (after) - but it flipped the
e106twin-mirror decision, whichstatic_assembly_matches_runtime_capture(ISO-gated, so it skips in a plaincargo 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
56–80 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).