This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Syplheed-Reborn/docs/re/BACKLOG.md
Sylpheed RE agent 33671c5086 re: wep_85 is the ONLY declared-but-unshipped asset; the package gaps are unused numbers
The obvious follow-up to last commit's wep_85 finding: the weapon packages number
00 to 84 with 26 holes, so are those holes cut weapons too? Partitioning every
number 00-85 by package-present x named-anywhere-in-the-parsed-pak-data:

                     named in data   not named
   package present        59             0
   package absent          1            26

59 + 0 + 1 + 26 = 86, the numbers 00 through 85.

The 26 gaps are simply unused numbers, not unshipped weapons - nothing in the
data names them at all. The one occupied cell is the one already known: wep_85,
named 16 times, with no package. It is an isolated cut, not a pattern, which
refutes the natural expectation that finding one cut weapon means finding a
family of them.

The other zero is worth as much: 0 shipped packages are never named. Every
rou_f001_wep_NN.xpr on the disc is referenced by the data, so the package set and
the weapon vocabulary agree in both directions but for that single entry.

Running the identical test on the craft family gives a clean negative. Every
rou_f### number appearing inside an .xpr against every number named in the parsed
data: 16 in both, 0 named-but-unshipped, 0 shipped-but-unnamed. The 16 are
f001-f004, f101, f102, f104-f106, f201, f202, f301-f303, f401, f402 - the
hundreds digit groups them into five families with holes inside each (no f103, no
f203). No craft was cut.

So across both families the disc ships exactly what it declares, with the single
exception of that one weapon.

All seventeen artefacts byte-identical.
2026-08-28 10:33:41 +00:00

6883 lines
429 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-trigger-queue.md).**
`isl-builtins.md` left "what actually appends a node is still unidentified" and
proposed a gdb watchpoint; it is static after all. The container embeds its inner
list at `+12`, so the appender must pass `container+12` to the generic helper
`sub_8226EAB8` — only 2 of its 16 callers do, and one of them sets
`addi r29, r24, 272` first. That is **`sub_8226A0D8` = ScriptPhase vtable slot 28
= BUILT-IN 25**, with **ZERO real `bl`s** 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](isl-stream-entry-points.md).**
`data/isl-phase-guards-all.txt`: **5 of 177 unreachable exits → 0.** The cheap
first step FAILED usefully: an unreached routine's entry offset does **not**
appear as a word in the file in any encoding (phase-relative 6.6 % vs an 11.5 %
control; absolute 1.6 % vs 3.3 %; /4 forms 01.6 % vs 6.68.2 %) — all at or
below chance, ruling out "some operand points at them". Nor is it dead code: the
3069 unreached instructions in Stage 02 hold **485 calls** (`start_coroutine` ×75,
`squadron_attack` ×59, `objective_marker` ×13). ✅ The answer is the mission-level
stream: each `0x1883` record is `0x1883, base, size, 0, entry_a, entry_b` with the
entries **phase-relative** — **82 of 88 land on a valid instruction (93.2 %) vs a
38.6 % control** — and `entry_b` is the phase's FORCE_END_PHASE handler (Stage 02:
`0x1482C`, `0x249F0`, `0x34A10`). Those exits now show **0 necessary conditions**,
which is what an engine-entered abort handler should look like.
⚠️ First seeding attempt moved NOTHING (85.0 %→85.0 %, 5→5) because
`dominating_conditions()` builds its own entry set — fix-the-instance again.
**(2026-08-27) `entry_a` IDENTIFIED — and it is NOT an entry point.** It is the
offset where the phase's CODE ends and a trailing **DATA table** begins: 8-byte
typed records, tag `0x19` = int, tag `0x1A` = IEEE float, and the dispatcher only
has 25 opcodes (`cmplwi 0x18`). **44 of 44 phases: the first opcode > 0x18 is
exactly `entry_a`, zero exceptions**; only tags 0x19 (1394) and 0x1A (675) occur.
So the record is `0x1883, base, size, 0, code_end, force_end_handler`. ⚠️ This
also retires the earlier "93.2 % land on a valid instruction" test as TOO WEAK — a
data record has length 8 and passes "nonzero, even". 🔧 `isl.linear_offsets` was
decoding all **2069** data records as instructions (1.23 %); now stops per phase
at the boundary: **168251 → 166182**, op>0x18 **2069 → 0**, call-site coverage
held at **25705/25705**, exits still 0 unreachable, conditions unchanged at 400.
⚠️ The first attempt at that fix cost **36 %** of the stream (168251→107596,
exits 0→74) because the walk is global and stopped at phase 1's table — it must
SKIP and resume per phase. 🟡 Still open: the table's contents are undecoded (its
int values hit the stream 46/51 vs 29.5 % chance but **0 are unreached
run-starts**), and what starts the other ~15 % of code is still unknown.
***(2026-08-27) THE TRAILING DATA TABLE IS A TIMELINE — [structures/isl-schedule](structures/isl-schedule.md).**
Layout `int N; N x [int offset; float t; int kind]`; `1 + 3N` matches the record
count in every phase. **675 entries disc-wide — exactly the independently counted
number of `0x1A` float records — and 675/675 offsets land on the instruction
stream vs a 33.3 % control.** The floats are SECONDS (0, 0.5, 1, 4, 30, 60, 90,
120, 150, 180, 210, 240, 300, 420, 1020 …) and the targets are small one-shot
coroutines (`set args; call builtinN; end_coroutine`). `kind` is 0 (556) or 5
(119) — unidentified. 🟡 **Runtime cross-check, consistent not conclusive:** the
closed `REMAINING OB` work measured Stage 02 arrivals at **t = 0, 120, 210 s**
(n=5 emulator runs); all three are in phase 1's static schedule and **120 and 210
each appear TWICE** — but the times are round numbers, so presence alone is weak;
the doubling is the sharper detail. Artefacts `data/isl-stage02-schedule.txt` and
`data/isl-schedule-all.txt`; the four earlier artefacts regenerate byte-identical.
**(2026-08-27) THE CONSUMER IS FOUND — `sub_822748D0`, called from
`ScriptPhase::Update` (`sub_82263408`).** The phase initialiser stores
`[phase+240] = code_base + entry_a` (`add r10,r26,r21; stw r10,240(r30)`), and
`+240` has exactly two readers in the ISL region. The walker confirms EVERY field
independently of my structural inference: count at `table+4`, `kind` at
`group+20`, float `t` at `group+12`, offset at `group+4`, **stride 24**.
🔑 **`kind` is a CLOCK INDEX, not a flag** — `rlwinm kind,2,0,29` + `lfsx` into
float arrays at **`[phase+104]`** and **`[phase+88]`**; an entry fires when its
`t` lies between the two readings (the "which events did this frame cross" test).
✅ Both arrays are phase fields, so **the clock is PER PHASE** — previously an
inference from layout, now a read. 🟡 Still open: what the six clocks are (only
indices 0 and 5 are used); `sub_822737C8`, which actually starts the routine, is
unread. 🟡 **Still NOT what starts the unreachable code** — 0 of the 675 targets
are unreached run-starts, so the ~15 % gap stands.
***(2026-08-27) `sub_822737C8` IS THE COROUTINE SPAWNER — [isl-coroutine-spawner](isl-coroutine-spawner.md).**
`spawn(phase, base, offset)`; **7 call sites** that between them name the whole
start-up path: the phase initialiser, `start_coroutine`'s stub, the timeline
walker, TWICE inside **`sub_8226D740`** (the per-frame engine→script drain), and
two unread (`sub_82273910`, `sub_82264058`).
🔴 **CORRECTION: the `0x1883` record's third word is the phase's MAIN ENTRY, not
a "size"** — my label was wrong. The initialiser passes it straight to the
spawner (`or r5,r22,r22; or r4,r26,r26; bl 0x822737C8`), **44/44 land on the
instruction stream vs a 25.0 % control**, and all three Stage-02 targets share the
prologue `special[0]=0; local[0]=0; call builtin116(0)`. Record =
`0x1883, base, MAIN_ENTRY, 0, code_end, force_end_handler`. 🟡 Seeding it moves
no coverage — every main entry was already an entry point by another route.
🔑 **LEAD:** the drain's two spawns take their offset from **`[node+112]`** —
the first field of a drained node located, and the best remaining angle on the
~15 % of code nothing appears to start. 🟡 NOT shown that those nodes come from
the trigger queue at `phase+272`; that is the exact over-reach the corpus already
withdrew once, so it is not asserted.
* ✅🔴 **(2026-08-27) THE TRIGGER NODE IS DECODED — and two of my own claims are
withdrawn. [structures/isl-trigger-node](structures/isl-trigger-node.md).**
The reader is `sub_8226E220`, called by the drain `sub_8226D740` as
`(phase+272, …6 out-params)`. **Writer (built-in 25) and reader touch the same
seven payload offsets** — +0 `local[4]` (the unit), +4 and +28 `local[12]`, +8
computed, +16 a **double** from `local[16]`, +24 the constant 1, +32 — neither
derived from the other. Container: +16 head, **+20 pending count** (matching the
count `isl-builtins.md` watched live at `phase+272+20`), +24 cursor.
🔴 **WITHDRAWN: "the drain spawns from `[node+112]`"**`r31 = r1 256`, the
STACK FRAME; `+112` is an output slot.
🔴 **REFUTED: the trigger carries a routine offset.** `payload+28` = `local[12]`,
and across **all 25 sites disc-wide** those are the small integers **1…12**:
**1/25 = 4.0 % land on the instruction stream vs a 16.0 % control**, 0 are
unreached run-starts. ⚠️ My first version of that test was **n=2** (Stage 02
only) — same answer, but unsupportable either way.
**And one correction TO the corpus:** `isl-builtins.md` withdrew
`sub_8226E458`'s link to the trigger queue by reading its SECOND argument
(`[phase+324]`); the FIRST is `r26 = phase + 272`, set twelve instructions
earlier. The drain does operate on the container — what `sub_8226E458` does to
it stays unread. 🟡 What starts the ~15 % of unreached code: still open, not this.
***(2026-08-27) RESOLVED — TWO trigger appenders, and built-in 19's carries the
handler routine. [structures/isl-trigger-node](structures/isl-trigger-node.md).**
Last iteration's puzzle (`payload+28` is a 112 index, yet the drain spawns
`base + payload+28`) had a simple cause: **built-in 25 is not the only appender**.
Searching for functions writing a double at `+16` AND a word at `+28` of the same
register finds exactly two — `sub_8226A0D8` (built-in 25, slot 28) and
`sub_8226E160`, whose sole caller `sub_82269ED0` is **vtable slot 68 =
built-in 19**. Built-in 19 passes `local[4]`→+0, `local[12]`→+4, `local[24]`→+16
(double), **`local[32]`→+28**. 🔑 **Those ARE code offsets: 79/79 land on the
instruction stream vs a 27.8 % control**, values 12 164111 080, 73 distinct. So
**built-in 19 registers a trigger whose handler is a routine**, and the drain's
spawn is that handler. Observed: built-in 25 writes `+24 = 1`, built-in 19 writes
`+24 = 0` — kind-tag shaped, 🟡 but the drain was not shown branching on it.
🟡 **Still does not explain the unreached code** — 0 of the 79 handler offsets are
unreached run-starts.
* 🟡 **(2026-08-27) THE UNREACHED CODE — five mechanisms eliminated, nothing in the
file names it. [isl-unreached-code](isl-unreached-code.md).** Inverting the question
(read what an unreached routine DOES) sharpened it. **Metric partly inflated:** of
24901 unreached instructions in 564 runs, **458 are length-1** (111 a lone `ret`
stranded after `end_coroutine`) — but the gap is real: **24443 instructions in 106
runs**, up to **1526** long. It is live logic: Stage 26's biggest run calls
`builtin57(ADN110,1)` and `set_group_speed(ADN110,2,0)`. 🔑 **NEW: `kill_coroutine`
(built-in 5) confirmed as a code-offset carrier — 150/150 on-stream vs a 34.0 %
control** — yet **0 unreached run-starts**, the fifth such result. Across **2757
code references** from five mechanisms (`start_coroutine` 1765, timeline 675,
built-in 19 triggers 79, `kill_coroutine` 150, `0x1883` 88), **not ONE lands on an
unreached run-start.** ✅ Not a tool artefact: 1765/1765 `start_coroutine` entries
resolve, 0 outside a phase region, 0 dropped by the on-stream filter. 🟡 With the
earlier "the offset appears nowhere as a word in any encoding", these routines are
referenced by **nothing inside the `.ssb`** — either unused in this build, or
entered from outside it. ▶️ **No static test I have devised can choose**; a runtime
probe on `sub_822737C8`'s third argument would.
***(2026-08-27) `+24` IS the trigger kind tag, and built-in 25's payload is a
ROUTE. [structures/isl-trigger-node](structures/isl-trigger-node.md).** Read
straight off the drain after the pop: `lwz r11,92(r31)` (payload+24),
`cmpli 0x1`, `bc lt → 0x8226D89C` (**+24 == 0 → the SPAWN path**), `bc ne → exit`
(**+24 > 1 → bail**), else fall through (**+24 == 1 → a NON-spawn path**).
So **built-in 19 (writes 0) → spawn**, condition `sub_8226DAF8`, `+28` = a code
offset (79/79 vs 27.8 % control); **built-in 25 (writes 1) → no spawn**, condition
`sub_8226DC80`, and the path resolves `+28` through **`[phase+244]` = symbol
table 1** (`lwz r10,112(r31); lwz r11,244(r29); lwzx`). 🔑 **All 25 of built-in
25's `local[12]` values resolve to symtab-1 entries, and every one is a
`Route_*`** (`Route_ADS101_p1F`, `Route_TCN004_p1S`, …) — so the puzzling 112
values are small symbol indices, and this independently confirms `SYM1_SLOTS[12] ∋
25`, derived from operand ranges alone. **Two trigger kinds: built-in 19 registers
a HANDLER ROUTINE, built-in 25 registers a ROUTE.** 🟡 Unread: both condition
testers, what the kind-1 path does with the route, and the drain's second spawn
site `0x8226DA10`.
***(2026-08-27) BOTH trigger kinds watch a ROUTE.
[structures/isl-trigger-node](structures/isl-trigger-node.md).** The kind-0
condition `sub_8226DAF8` resolves a symbol from **`payload+4`** through
`[phase+244]` = symtab 1 (`rlwinm r10,r5,2,0,29; lwz r11,244(r30); lwzx`), and
both appenders write `local[12]` into the slot their own path reads. **`local[12]`
resolves 79/79 (built-in 19) and 25/25 (built-in 25), every one symbol TYPE 1 =
`Route_*`** (`Route_ADN106_p1F`, `Route_ADS101_p1F`, `Route_TCN004_p1S`). So both
register **a trigger on a UNIT reaching a ROUTE** (`payload+0` = the unit); they
differ only in the payoff — built-in 19 also carries a **handler routine**
(`local[32]``+28`, kind 0 → spawn), built-in 25 carries none (kind 1 → no spawn).
Third independent agreement with `SYM1_SLOTS`, which lists slot 12 for both.
🔑 Joins up with the closed `REMAINING OB` result (the counter rises **at a
squadron's route arrival time**) — 🟡 stated as a connection, not a demonstration.
🟡 Still unread: what the conditions COMPARE (only `sub_8226DAF8`'s first ~34
instructions are read, up to the name lookup; `sub_8226DC80` entirely), and
`payload+8` (a computed value passed to both testers — waypoint-index shaped,
which is why it is not being called one).
***(2026-08-27) THE KIND-0 TRIGGER CONDITION IS A POINT-TO-SEGMENT PROXIMITY
TEST. [structures/isl-trigger-node](structures/isl-trigger-node.md).**
`sub_8226DAF8` resolves the route name (`sub_823012D8`), requires the unit record's
`rec+100 != 0`, extracts **two 3-double vectors**`rec+32/+40/+48` and
`rec+64/+72/+80` — and makes a VIRTUAL call to **ScriptPhase vtable slot 60 =
`sub_82268068`** (used by no built-in, so engine-internal) with those two vectors,
the route point, and the node's double. Slot 60's arithmetic is unambiguous:
per-axis `fsub`, `fmul` + two `fmadd`, `fsqrt``|BA|`; **reject if `|BA| < 0.1`**
(degenerate); **reject if `|PA| > f1` or `|PB| > f1`**; then
**`t = (PA)·(BA) / |BA|²`, reject if `t < 0`**. So it is a point-to-segment
distance test and **the node's double (`local[24]`) is a RADIUS**. 🟡 **What A and
B are is NOT established** — a previous/current position pair would make this the
standard frame-rate-robust waypoint test, which is exactly the tidy reading this
corpus insists on proving. 🟡 Still unread: `sub_8226DC80` (kind 1) entirely, and
slot 60 past the `t < 0` rejection.
***(2026-08-27) THE TRIGGER MECHANISM IS READ END TO END — the kind-1 condition
is the SAME test with a pre-filter. [structures/isl-trigger-node](structures/isl-trigger-node.md).**
`sub_8226DC80` is `sub_8226DAF8`'s twin: diffing instruction by instruction, 33 of
the first 86 differ and **every difference before index 65 is a register rename or a
branch target**. Both resolve the route name (`sub_8217FA08``sub_823012D8`),
index `[phase+324]` by `payload+0`, require `rec+100 != 0`, and copy the two 3-double
vectors out of the unit record. Then kind 0 calls vtable slot 60 directly, while
**kind 1 first computes an INLINE point-to-point distance** (three `fsub` against the
route point, `fmul` + two `fmadd`, `fsqrt`) and **returns 0 if it exceeds `f31`, the
same radius** — before calling **the same slot 60**. So both kinds run the identical
point-to-segment test; kind 1 only adds a cheap early-out and does not spawn.
▶️ Trigger work remaining is peripheral: which unit-record fields A and B are, slot
60 past the `t < 0` rejection, `payload+8`, and the kind-1 path in the drain.
* 🔎🔴 **(2026-08-27) A/B in the trigger test: SEARCHED, NOT SETTLED — and the tidy
reading is EXCLUDED. [structures/isl-trigger-node](structures/isl-trigger-node.md).**
The "who writes this" move found **99 sites in 40 functions** writing `+32/+40/+48`
and 16 in 13 writing `+64/+72/+80`; six functions write both. The inviting one,
`sub_821AD218`, widens **exactly 16 floats to 16 doubles** (`lfs r4+0…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_8226DAF8` reads **`lwz r10, 100(r11)`, a WORD at
+100**, inside where a 16-double matrix's row 3 (+96…+120) would be. `sub_8226A348`
(same built-in shape) confirms a MIXED layout — words at `+0/+4/+8/+12/+16` and
**`+80`**, doubles only at `+32/+40/+48`. So these offsets recur across several
unrelated object layouts and nothing ties a writer to the trigger's object.
**A and B stay unnamed** — the second tidy reading rejected for them, after
previous/current-position.
* ✅🔴 **(2026-08-27) `read_freg` READS THE TIMELINE'S CLOCK ARRAY — and my "six
clocks" framing is corrected.** Built-in 9 is inline in its stub (no vtable slot):
it bounds-checks **`0 <= local[0] < 32`** then `lwz r10, 88(r31)` /
`rlwinm i,2,0,29` / **`lfsx`** — a FLOAT from **`[phase+88][i]`**, which
`isl-builtins.md` already recorded. 🔑 **That is exactly the array the TIMELINE's
`kind` indexes**, so `kind` is not a selector over six private clocks but an index
into **the same 32-entry float register file the script reads with `read_freg(i)`**
`read_freg(0) < 1200` in a clear condition and a timeline entry with `kind = 0`
read the same register. 🟡 **NUANCE, not asserted as a correction:** `clear_flag`
(93) clears a DIFFERENT 32-entry array — `[phase+120]`, looping to 128 in steps of
4 — not `[phase+88]`; `set_flag` (8) does bounds-check against `[phase+88]`, but
only its first 13 instructions were read. 🟡 **This does NOT settle the unit.**
`read_freg` returns a float from a register file; that seconds is the unit is still
INFERRED from the 210/300/600/1200 gate values, exactly as before. 🟡 Also open:
what writes `[phase+88]` — 73 writers in the ISL region alone.
* ✅🔴 **(2026-08-27) THE UNIT IS SETTLED — `read_freg` RETURNS SECONDS, and the
three "flag" built-ins are a STOPWATCH BANK. [structures/isl-timers](structures/isl-timers.md).**
Last iteration found what `read_freg` reads and said plainly it had *not*
answered the item, which was the unit. Following the writers does.
**`[phase+88]`, `[phase+104]`, `[phase+120]` are three parallel 32-entry
arrays**, cleared together by the phase initialiser in one unrolled loop
(bases 12/44/76/108, offsets 0…127). **`sub_822710D0(phase, dt)` — called from
`ScriptPhase::Update` at `0x82263480` with Update's own float passed straight
through — does `prev[i] = cur[i]` then `if running[i] == 1: cur[i] += dt`.**
So `[phase+104]` is last frame's value and `[phase+120]` the running flag, and
the walker's `prev <= t < cur` is "did timer `kind` cross `t` this frame".
🔑 **`dt` is SECONDS, by a round trip that is not circular**: the frame loop
`sub_821AA1B0` computes `frames × (1/60)` (`0x8289A0F8` = 0.016666668), turns
it into integer ticks with `× 10000.0` (`0x820A13B4`), **clamps at 3200**, and
`sub_821A49A8` scales back by `1e-4` (`0x8289A0FC`) into the timing singleton
at `[0x828F35B4]``+8` for the script VM, `+16` for the flight code. Produced
as seconds × 10⁴, consumed as × 10⁻⁴; the clamp is **0.32 s**, a frame ceiling.
**Refutation test, 675 / 675 vs an 11.2 % control**: if a timer only runs
once started, every timeline `kind` must be an index its own phase starts —
and every one is. That is also why `kind` is only ever 0 or 5: **every phase
opens `set_flag(5); set_flag(0)`.** Artefact `data/isl-timers.txt`; all seven
existing artefacts regenerate **byte-identical**.
🔴 **Two corrections to `isl-builtins.md`.** `set_flag(i)` writes **`0.0`**,
not `1.0` — the constant at `0x8209FD28` is literally zero, the same one the
initialiser clears with; and `clear_flag(i)` does **not** zero the value, it
clears `[phase+120][i]` only (all 133 sites disc-wide pass **1** = all 32, in
the teardown `reset_phase_threads ; timer_stop ; clear_flag(-1)`). So the
family is start / read / stop, and the earlier "latch" reading was a timeout
all along. ✅ The corpus's grouping of 8/9/93 is **confirmed**`[phase+120]`
is this family's *running* column, not a different array as last iteration's
note allowed.
⚠️ **The first run of the test scored 433/675 and looked like a real
refutation** (misses all `kind = 0`, in a coherent block of stages). Cause:
`isl.dis` defaults to `stop_at_ret=True`, so a `set_flag` at the *start* of a
coroutine lost its operand staging to the preceding `ret`. Checked before
believing — the corpus's own trap, paid again.
🟡 Not settled: the **names** (`set_flag`/`read_freg`/`clear_flag` describe
none of this, but 127/123/124 already hold `timer_set`/`timer_resume`/
`timer_stop` for a different timer, so renaming is its own change); one
exception, `read_freg(15)` in Stage 06 phase 1 has no `set_flag(15)` in that
phase; the three scale fields `s24`/`s40`/`s44` (all 1.0 at construction) have
no writer searched for — a game-speed setter would be one; and 31 `read_freg`
sites index by a computed value rather than an immediate.
* ✅🔴 **(2026-08-27) `timer_set`'s SECOND ARGUMENT IS A SECOND COUNTDOWN — the
"warning threshold" reading is REFUTED. [structures/isl-mission-timer](structures/isl-mission-timer.md).**
`mission-phase-timers.md` left it open as "a limit and a warning threshold is
the obvious reading, but it is **not** established". Reading the updater
settles it the other way. The **mission timer** is five scalars on the
ScriptPhase — `[+304]` elapsed (up), `[+308]` countdown A, `[+312]` countdown
B, `[+316]` running, `[+320]` armed — zeroed together by `sub_8225FEF8`, and
driven by **`sub_822639B8`, called from `ScriptPhase::Update` at `0x82263528`
with `fmr f1, f30` — the SAME `dt` the stopwatch bank gets**, so it counts the
same seconds. Its body: `if running: [+304] += dt`, and `if armed:` **`[+308]
-= dt` while `[+308] > 0`, ELSE `[+312] -= dt` clamped at 0.** 🔑 A threshold
would be *compared*; `[+312]` is never compared with `[+308]` anywhere — it is
decremented, and the `bc 12, gt` at `0x822639FC` jumps **away** to the A arm,
so B only runs once A is spent. **Two sequential countdowns.** Disc-wide, 29
`timer_set` sites: first argument 600 ×19 / 1200 ×8 / 900 / 1800, **second
argument 180 in all 29** — a per-mission limit then a fixed 3-minute stage.
(Stated as consistent-with, not as the proof; the disassembly is the proof.)
**The five built-ins are vtable slots 9094** (`0x8226C690`): 127
`timer_set(a,b)` arms and loads A/B (as **doubles** from `local[0]`/`local[1]`),
123 `timer_resume` starts the elapsed counter, 124 `timer_stop` stops it and
disarms, 125 `timer_reset` zeroes all three floats, 126 `timer_elapsed` returns
`[+304]`. 🔑 **125 and 126 have ZERO call sites in all 28 scripts** — the script
arms, starts and stops this clock but **never reads it**. Its only consumer read
here is a 32-byte message (`0xAB03E5BA` → the bus at `[0x828F35DC]`) carrying
elapsed, B, running, and `expired = armed && A <= 0`.
🔴 **CORRECTION to `mission-phase-timers.md`:** "timer 0 is started by
`timer_set(1200,180)` then `timer_resume`, with a 1200-second limit" **merges
two clocks**. `timer_set`/`timer_resume` arm and start THIS object;
`set_flag(0)`, one instruction later, starts **stopwatch 0**, which is what the
timeline's `kind = 0` reads. They start together and so read almost the same
value — which is why the merge was invisible. The 1200 s is the mission timer's,
not stopwatch 0's.
✅ This also unblocks the naming collision noted in
[isl-timers](structures/isl-timers.md): 123127 act on five *scalars*, 8/9/93
on a 32-entry *bank*. Not renamed here — that is its own change and its own
artefact diff; everything regenerates byte-identical as it stands.
🟡 Not settled: **who subscribes to `0xAB03E5BA`** — the literal appears at
exactly one site, the producer, but `0xAB03` is the high half of many message
tags here and dispatch is not by literal compare, so one occurrence is **not**
evidence of no consumer. So whether running out of time ends the mission is
unread, as is what B reaching zero does, and whether all 170 `timer_stop` sites
are phase teardown.
* 🔎🔴 **(2026-08-27) THE TIMER MESSAGE'S CONSUMER: LOCATED, NOT READ — and the
argument I was about to make is REFUTED BY ITS OWN CONTROL.
[structures/isl-mission-timer](structures/isl-mission-timer.md).**
The tempting move was "`0xAB03E5BA` is built at exactly one site, so nothing
consumes the timer message". **The control kills it: 25 of the 40 distinct
`0xAB03xxxx` tags in the image are built exactly once (62.5 %)** — single-site
construction is the *majority*, not an anomaly. And the tag appears **nowhere
in the image as a 4-byte word** — in fact **no 4-aligned word anywhere begins
`0xAB03`** — so there is no static table of subscribed tags to find either.
**Two structural reads that do stand.** (1) The post is a **queue push, not a
dispatch**: `sub_82175C20(bus+4, &msg)` is a ring-buffer append (capacity `+8`,
head `+12`, count `+16`, array `+4`, grown via `sub_8228E208`) and never looks
at the message. (2) The message class has a **one-method vtable**:
`0x820A8D18``0x820A8DA0` is a run of 2-word records
`{ sub_82301C20, 0x8210Exxx }`; the timer message's vptr `0x820A8D68` gives
`vtable[0] = sub_82301C20` (a scalar deleting destructor — stores `0x820A7014`
over the vptr, conditionally frees) and `vtable[-1] = 0x8210E288`, an **MSVC
RTTI locator** (`0,0,0, &typeDesc 0x8289D094, &classHierarchy 0x8210E29C`,
`numBaseClasses = 3`) — data, not code, and below `sylpheed.db`'s `0x82150000`
floor. **So the message carries no handler of its own**, and dispatch is neither
a literal compare nor virtual on the message.
🟡 **Located:** the bus at `[0x828F35DC]` carries a **map at `+8216`**, looked
up through `sub_82254A08` from **`sub_823001E8`**, whose only caller is
`sub_8230C398` — the per-frame message pump, called from the frame loop
(`0x821A6544`) with the frame `dt`. So the question reduces to **what inserts
into `bus+8216` and with which key**. That path is unread, so **the item is NOT
settled**: whether running out of time ends the mission is still unknown.
* ✅🔴 **(2026-08-27) THE STOPWATCH BUILT-INS ARE RENAMED — and my own "message
pump" label from the previous pass is WITHDRAWN.**
**Rename landed.** `isl.py` emits **`stopwatch_start` (8) /
`stopwatch_elapsed` (9) / `stopwatch_stop` (93)**; 123127 keep `timer_*` for
the mission timer, which is five scalars rather than a 32-entry bank. Artefact
check: **84 lines changed across 5 files, and all 84 pair exactly** with their
old-name partners once column padding is normalised — 0 removed lines without
an old name, 0 added lines without a new one. `data/isl-timers.txt` regenerates
to the **same** 675/675, 11.2 % control, 82/1 and identical argument
histograms, which is the check that the rename is cosmetic.
🔴 **WITHDRAWN: "`sub_8230C398` is the per-frame message pump."** It *is*
called every frame (`sub_821A6470` at `0x821A654C`, `sub_821AA1B0` at
`0x821AA7DC`, both with `dt`) but it **drains nothing** — its body is a state
machine on `[0x828E1F8C]`, and over 466 instructions it calls only an allocator
`sub_8230C160`, string building `sub_8217FA08`, the lookup `sub_823001E8` and a
*push* `sub_8225FEA0`. A boot/loading sequencer that POSTS messages.
🔴 **And `bus+8216` is weaker evidence than I wrote.** `sub_82254A08` is a
generic map find with **~120 call sites image-wide**, and `+8216` is a common
offset (50+ `addi …, 8216` sites); worse, **the key `sub_823001E8` looks up is
a POINTER** (`lwz r4, 13780(r11)`, stored and passed by address), **not a
message tag** — so that map may not be the subscriber registry at all.
▶️ **The item is still open and the handle is now different**: find what drains
the ring buffer at `bus+4` (capacity `+8`, head `+12`, count `+16`), not what
reads `bus+8216`. Whether running out of time ends the mission is unanswered.
***(2026-08-27) BUILT-IN 104 IS NAMED — `request_next`, from the game's own
log string. [structures/isl-condition-builtins](structures/isl-condition-builtins.md).**
Built-in 104 returns `[phase+10160]`, whose **only writer in the image** is one
site in the mission frame loop `sub_821AA1B0`. Following that writer instead of
the reader: `r29` is `obj->slot2()` on an object fetched from the registry at
`[r30+2424]` by id **`0x20FFFF02`**, published to the phase, and then the same
object is `slot1(0)`**read, publish, clear, every frame**.
🔑 **The id namespace has exactly three members** (`0x20FFFF00/01/02`), each
built at exactly 4 sites image-wide — a closed family, so this is not the
coincidence the last two iterations kept tripping on. Two of those sites are in
`sub_821D5178`, which gets **both** `0x20FFFF01` and `0x20FFFF02` and passes
their values to a log call whose format string (`0x820A4968`) is:
*"silph::GamePart_ReadyRoom::Impl::OnCommand - Wait() command is requested.
Check flow control valiables. **WAIT_MODE : %d, REQUEST_NEXT : %d**"*.
Argument order settles it — `r4` (first `%d`) is `0x20FFFF01` = **WAIT_MODE**,
`r5` is `0x20FFFF02` = **REQUEST_NEXT**. ⇒ **`[phase+10160]` is REQUEST_NEXT**
and built-in 104 is **`request_next`**. Corroborated by
`PrepareScript`, which sets `WAIT_MODE = 1` and `REQUEST_NEXT = 0` before an ISL
script runs. So the six tutorial stages' single dominating condition
`request_next() != 1` is the script waiting on the surrounding game part's
*proceed* flag. ⚠️ Three other hits on offset 10160 are `lfs` on unrelated
objects — the recurring-offset trap, excluded.
✅ Renamed in `isl.py`; **6 artefact lines changed and all 6 pair exactly.**
🟡 Not settled: what the `(16, 32]` gate on `[r30+104]`'s `+12` selects; the
third id `0x20FFFF00` (used by the `GRAPH_PATH`/`EX_FONT`/`SYSTEM` code in
`sub_821D6350`/`sub_821D6A40`); and `builtin103`'s `[phase+10152]` /
`[phase+10156]`, which are neighbours of REQUEST_NEXT but have 14 and 11
touching sites rather than one — a different, busier mechanism.
* ✅⚠️ **(2026-08-27) `[phase+10152]` / `[phase+10156]` ARE READ — an ANSWER and a
request STATE, and built-ins 102/103/130 are one trio.
[structures/isl-condition-builtins](structures/isl-condition-builtins.md).**
The backlog carried these as "9 and 7 writers, unread", and warned they are not
`request_next`'s siblings just because they are adjacent. Correct — they belong
to **four consecutive vtable stubs**: 102 → slot 70 `sub_8226BF48`, 103 → slot
71 `sub_8226BFA8`, 104 → slot 72 (`request_next`), **130 → slot 97**
`sub_8226BFD0`. Built-in 102's body:
`state==1 → return 2` (pending); `state==2 → special[0] = (answer==1); state=0;
return 0`; `else → state=1; answer=0; return 2`. **`2` and `0` are the
dispatcher's thread codes**, so 102 is a *blocking* built-in that yields until
an answer arrives; **103 is the non-blocking form** (`state != 0 && answer ==
1`) and **130 clears the answer**. ⇒ **`[phase+10156]` = request state (0 idle /
1 pending / 2 answered), `[phase+10152]` = the answer**, tested against 1.
✅ The answer is published by `sub_821A9DC8` (`[10152] = [r6+4]`, `[10156] = 2`)
**under the same `(16,32]` gate on `[r30+104]+12`** that `request_next`'s writer
uses — one engine→script publish path serves both.
⚠️ **An inherited name I could not verify: `prompt_yes_no` (102).** Grepping the
corpus, it appears only in a list of built-ins Stage 02 does not call, with **no
derivation recorded anywhere**. The mechanism is consistent with a modal yes/no
prompt but does not establish it, so **103 and 130 are left unnamed** rather
than named off an unverified premise.
🟡 Not settled: what question is asked. `sub_821A9DC8` has **no strings** and
exactly one reference in the image (a tail `b` from `0x821AC064`), so the
string recipe finds nothing there; and the `(16,32]` gate is still unread.
📎 Reference picked up on the way: the image carries the **full GamePart id
table** as `silph::GamePartTask::RegisterToFactory<N,class silph::GamePart_X>`
strings — Title 0, SaveLoad 3/4, Extras 5, MovieTheater 6, MissionSelect 7,
Options 8, Movie 9, Bunk 10, ReadyRoom 11, Hangar 12, Arsenal 13, PilotLog 14,
System 15, MainGame 17, PauseMenu 19, StageClear 20, MissionLog 21, GameOver 22,
Debriefing 23, Dialog 24, Tutorial 25, ChallengeMission 26, Leaderboard 27.
* ✅🔴 **(2026-08-27) THE `(16,32]` GATE IS READ — it is `!= 16 && <= 32`, a
VALIDITY CHECK on a load-time parameter, and my own GamePart-id lead is killed
by its control. [structures/isl-condition-builtins](structures/isl-condition-builtins.md).**
🔴 **The corpus's own notation was wrong.** `(16, 32]` implies a lower bound;
there is none — everything **below 16 passes**. The test is
`kind != 16 && kind <= 32`.
**`X+12` is a constructor argument, not engine state.** `sub_8225EC78(X,
kind, …)` — the function carrying `"script load cancel"` — does
`stw r4, 12(r30)` and then applies **those same two tests to `r4`**, bailing
out of the load. Every later site re-tests the field it just stored, so the
gate is the object's **invariant**, not a selector between behaviours. Control:
**42 sites image-wide** have this exact `cmpi 16``cmpi 32` shape, **34 in one
region** (`sub_821A9DC8`, `sub_821AA1B0`, `sub_821AB570`, `sub_821AB650`) plus 3
in `sub_8225EC78` — one object, guarded everywhere it is touched.
**The object chain is read.** `X` is installed at `0x821A78EC`
(`stw r25, 104(r30)`), the previous instance torn down first via
`sub_8225EB60` — so **`X = [GamePart+104]` is the game part's CURRENT SCRIPT
INSTANCE**. `X+4``Y`; `Y+4` → the **ScriptPhase**; `Y+72` = the answer, and
`[ScriptPhase+10152]` gets the same value. `X+12` arrives as `[r21+8]`, `r21`
being `sub_821A6CF0`'s second argument.
🔴 **REFUTED, my own lead, in the same pass.** GamePart ids run 0…27 and **16 is
not among them**, which made "`X+12` is a GamePart id, 16 being the unregistered
one" very inviting. Its control kills it: **1, 2 and 18 are also missing** from
the `RegisterToFactory` list, so 16 is one of *four* gaps, not a unique one.
Not adopted.
🟡 Not settled: what the kind **means**. The gate is read; the value's domain is
not, and chasing it needs another hop up (`sub_821A6CF0`'s caller).
* ✅🟡 **(2026-08-27) THE CODE REGION IS NAMED — `GamePart_MainGame` — and the
kind's domain has a better-controlled lead that I am NOT adopting.
[structures/isl-condition-builtins](structures/isl-condition-builtins.md).**
`sub_821A6CF0` has no `bl` callers, only a tail `b` from `0x821AC04C`, which
sits in a run of adjustor thunks fed by a **vtable at `0x820A319C`** (RTTI at
`0x820A3198`). **Slot 1 is `addi r3, r0, 17; blr`** — the factory id, and
`RegisterToFactory<17, class silph::GamePart_MainGame>` names it. Every thunk
does `lwz r3, 8(r3)` first, so slots 410 are **`GamePart_MainGame::Impl`
methods**: 4 → `sub_821A6CF0`, 5 → `sub_821A82A0`, 6 → `sub_821A8428`,
7 → `sub_821A9DC8`, 8 → `sub_821A9CF0`, **9 → `sub_821AA1B0` (the per-frame
Update)**, 10 → `sub_821AB570`. The region several iterations have been reading
is now *named* rather than inferred.
🟡 **The lead, with its control stated.** The disc carries **28 mission scripts
numbered `Stage01``Stage16` and `Stage18``Stage29` — exactly one gap,
Stage17** — and the gate excludes **exactly one value, 16**. Under 0-based
indexing `16 ↔ Stage17`, with `<= 32` bounding indices 0…28. Against the
refuted GamePart-id reading (**4** values missing from its domain, gate excludes
1 → 1-of-4, worthless) this is **1-of-1**. Much tighter — and still
coincidence-shaped, so **not adopted**. ▶️ What would settle it: the
`Stage%02d` construction site. The image has a generic `'%s%02d'` formatter at
`0x820A9C8C`; whether it is fed `index` or `index + 1` decides it outright. Not
found this pass, and `grep`ping the extracted disc does not help — the pak names
are hashed, so neither `Stage16` nor `Stage17` appears as a plain token.
* ✅✅🔴 **(2026-08-27) THE VTABLE IS 113 SLOTS, THE CLASS IS
`silph::SilphScriptPhase`, THERE IS A SECOND ONE — and the test I proposed last
iteration does not exist. [structures/isl-builtin-dispatch](structures/isl-builtin-dispatch.md).**
🔴 **Dropped first, honestly:** the plan was to settle the stage-index lead at
the `Stage%02d` construction site. **There isn't one.** The `'%s%02d'` at
`0x820A9C8C` has one xref into a generic string helper; listing *every* short
`%d` string in the image (43) finds no stage pattern; and the `name_hash` of
`Stage_S00…30` / `Stage00…30` / `UnitGroup_S00…30` appears **nowhere as a 4-byte
word**. The executable never builds a stage script name — the mapping is on the
data side. **Do not retry this route.**
**Vtable LENGTH settled: 113 slots (0…112).** The RTTI locator at
`vtable[-1]` = `0x820A84B8` → type descriptor `0x8289CD18` =
**`.?AVSilphScriptPhase@silph@@`**. ⚠️ The earlier "≥200 slots, no non-code word
in the first 200" was a **bad terminator** — the scan ran into the next vtable.
The real terminator is the next class's COL at `0x820A8680`. Independent
cross-check: the 147 built-in stubs use **109 distinct slots, min 0, max 110**
all inside 0…112.
**A SECOND class: `silph::SilphScriptPhaseDemo`** (`0x8289CCC0`), vtable
`0x820A8684`, also 113 slots, **overriding 109 of them** — nearly all pointing at
one shared stub `0x8226C160`. The Demo phase implements almost none of the script
surface, keeping slot 15 → `sub_82391BA8`, slot 111 `Update``sub_82275800`,
slot 112 → `sub_8237EF08`.
🔑 **That closes a loose end from the timers work**: `sub_822710D0` (the
stopwatch advance) has two callers, `sub_82263408` and `sub_82275800` — they are
the two classes' `Update`, same slot 111, base and derived.
**And both "unread spawner callers" are placed**`sub_82264058` is
`SilphScriptPhase` **slot 0** and `sub_82273910` is `SilphScriptPhaseDemo`
**slot 0**: the two scalar deleting destructors, a matched pair rather than two
mysteries. 🟡 Why a destructor reaches the spawner is not read.
🟡 Still open: `X+12`'s domain — the one remaining static handle is another hop,
to whatever the GamePartTask manager passes into `GamePart_MainGame` slot 4.
* ✅⚠️ **(2026-08-27) A SCRIPT LOAD BUILDS **TWO** PHASE OBJECTS — and the tidy
"every phase is a Demo" reading is killed by reading the constructor.
[structures/isl-builtin-dispatch](structures/isl-builtin-dispatch.md).**
Each vtable constant is materialised at exactly two sites (ctor + dtor), and
`vptr_writes` agrees: `SilphScriptPhase``sub_8225FEF8` / `sub_82261B60`;
`SilphScriptPhaseDemo``sub_82260568` / `sub_82260FF8`.
⚠️ **The wrong turn.** `sub_8225FEF8`'s *only* caller is `sub_82260568` — which
looks exactly like a derived ctor calling its base, i.e. **every phase is a
Demo**. That would make the whole built-in dispatch inert, since the Demo
vtable no-ops nearly everything. Reading the constructor refutes it:
`sub_82260568` **allocates and builds two SEPARATE objects**`sub_82150EF8`
for **10216 bytes** then `sub_8225FEF8` on object A, and `sub_822700C0` +
`stw 0x820A8684, 0(r29)` on object B — stored at **`Y+4` (main)** and
**`Y+8` (demo)**. That is exactly why the load path resolves the ScriptPhase
through `Y+4`. 📏 10216 bytes is consistent with the highest field read here,
`[phase+10160]`. 🔑 **Method note: a constructor calling another class's
constructor is not proof of inheritance — check which object each vptr lands
on.**
**The Demo overrides are no-ops.** All 109 point at three shared stubs, and
every one does nothing: `0x8226C160` (`special[0] = 0`), `0x822748B8` (same,
return 0), `0x822748A8` (`[phase+176] = 0.0`). Only four real methods survive:
slot 15 `sub_82391BA8`, **slot 111 `Update` `sub_82275800`**, slot 112
`sub_8237EF08`, and the destructor.
🟡 **What "Demo" means — a reading from the corpus's own vocabulary.** `DEMO` is
one of the eight **cutscene** text families already decoded here
(`MSG_DEMO_<id>_<page>_<line>`), so a second phase built from the same load with
every gameplay built-in stubbed and only `Update` alive reads as **the cutscene
script runner**. Nothing here shows it executing cutscene bytecode, so it stays
a reading.
🔑 **New handle on the stalled `X+12` domain**: the Demo class's single
construction site is in `sub_8225EC78``sub_82260568(Y, r26, [X+12])` at
`0x8225ED5C`. The kind goes **straight into the constructor as arg 3**.
* ✅✅ **(2026-08-27) SETTLED — `X+12` IS A 0-BASED STAGE INDEX. The gate is a
bounds check on a 33-entry table, and `!= 16` is Stage17.
[structures/isl-condition-builtins](structures/isl-condition-builtins.md).**
Three iterations circled this. Following the *value* rather than the filename
does it in two hops. **(1)** `SilphScriptPhase`'s ctor `sub_8225FEF8` **never
touches `r7`** — zero mentions; the *base* ctor `sub_822700C0` keeps it:
`or r26, r7, r7`**`stw r26, 152(r30)`**. **(2)** `sub_82261F70` builds a
string-pointer table on its stack and indexes it with exactly that field
(`lwz r11, 152(r21)`; `rlwinm ×4`; `lwzx r4, r11, r10` where `r10 = r31+144`).
The table is contiguous at **`0x820A8880`**, 20 bytes/entry:
**index 0 = `STAGE01_UNIT_MAX` … index 16 = `STAGE17_UNIT_MAX` … index 32 =
`STAGE33_UNIT_MAX`**, then `PLANE` at 33.
**`X+12` = `[phase+152]` = a 0-based stage index, `N → STAGE(N+1)`** — and the
gate is now explained rather than described: **`<= 32` is the array bound**
(33 entries, 0…32) and **`!= 16` is STAGE17**, the one stage number with no
`.ssb` on the disc.
**Retracts my own "not adopted".** Two iterations ago the stage-index reading
scored 1-of-1 but was coincidence-shaped and deliberately not believed. It is
now read off the game's own strings, and **0-based is PROVED by index 5 →
`STAGE06`**, not assumed.
⚠️ **False positive caught**: `0x82272D88 lwz r11, 152(r11)` in the built-in
switch is not this field — `r11` had just been loaded from `0(r31)`, so it is
the **vptr**, and the instruction is a virtual call to **slot 38**. Offset 152
recurs; the base register decides.
**And a THIRD class** — [structures/isl-builtin-dispatch](structures/isl-builtin-dispatch.md).
`sub_822700C0` stamps its own vptr `0x820A8E44` before `sub_8225FEF8` overwrites
it: **`.?AVSilphScriptPhaseBase@silph@@`**, also 113 slots. So the hierarchy is
`SilphScriptPhaseBase``SilphScriptPhase` and ← `SilphScriptPhaseDemo`, and
the Demo object is built by calling the **base** ctor then stamping the Demo
vptr — which is why `sub_822700C0` shows up on both construction paths.
🟡 Not settled: what `STAGENN_UNIT_MAX` resolves to (the lookup goes through
`sub_824480D0` / `sub_82448AA0`), and whether `Stage30``Stage33` exist on the
disc at all — the table has 33 slots but only 28 scripts were extracted.
***(2026-08-27) `STAGENN_UNIT_MAX` IS TWO PER-STAGE UNIT CAPS — `PLANE` and
`VESSEL`. [structures/isl-condition-builtins](structures/isl-condition-builtins.md).**
Reading `sub_82261F70` past the table lookup finishes the mechanism the stage
index feeds. The section name is built from the 0-based stage index, the section
is looked up, and two ints land in the phase:
**`[phase+356] = PLANE`** (default **200**) and **`[phase+360] = VESSEL`**
(default **20**), the defaults taken when a stage has no entry
(`stw r11(=20), 360(r21)` / `stw r10(=200), 356(r21)`). Key strings are
`0x820A8B14 = "PLANE"` and `0x820A8B1C = "VESSEL"`. All three names — the
section and both keys — are the game's own. So a stage declares how many
fighters and how many capital ships it may hold.
✅ Surrounding reads name the config: `GP_SCRIPT`, `SCRIPTS`, `Resource2D`,
`TABLE`, and the pak census counts **40 `<?xm` entries** — an XML config reader
(`sub_824480D0` open, `sub_82448AA0` find-section, `sub_824482D0` read-int).
⚠️ **Register discipline mattered twice here.** In `sub_82261F70` `r21` is the
phase and **`r31` is the stack frame** — `lwz r4, 356(r31)` at `0x822624DC` is a
stack read, not a phase field. And `0x82273400 lwz r11, 360(r11)` in the
built-in switch is a **vtable slot 90 call** (built-in 123), not a read of
`[phase+360]`.
🟡 Not settled: the **values**. A crude `Z1`+zlib scan of `dat/*.pak` found no
`UNIT_MAX`, but it did not use the corpus's own pak reader (`unitgroup.py`), so
that is **not** evidence of absence. 🟡 No consumer of `[phase+356]`/`[phase+360]`
identified — the offsets recur and the plausible hit was the vtable call above.
🟡 Whether `Stage30``Stage33` exist on disc (33 table slots, 28 scripts) is
still open.
* ✅🔴 **(2026-08-27) THE CONFIG IS AN INI, IT SHIPS NEARLY EMPTY, AND EVERY STAGE
TAKES `PLANE = 200` / `VESSEL = 20`.
[structures/isl-condition-builtins](structures/isl-condition-builtins.md).**
🔴 **Correction to my own label.** I called `sub_824480D0`/`sub_82448AA0`/
`sub_824482D0` an *XML* config reader on the strength of the pak census's 40
`<?xml` entries. Those 40 are **XPR2 resource manifests** (`<RDF Version="XPR2">`,
`<XBGMesh>`, `<Texture … D3DFMT_DXT1_SRGB>`) — mesh/texture declarations, not
config.
**The real config is a loose `config.ini` at the disc root**, header comment
「アプリケーション/ゲームパート初期設定テーブル」 — "application / game-part
initial-settings table", noting that `SYSTEM` holds what the game and every game
part share. That matches every key this code touches (`SYSTEM`, `GP_SCRIPT`,
`SCRIPTS`, `Resource2D`, `TABLE`, `GRAPH_PATH`, `EX_FONT`, `SCRIPT_ID`,
`SCRIPT_PATH`, `BASE_INFO`, `PLANE`, `VESSEL`, `STAGENN_UNIT_MAX`) — they are INI
section and key names.
🔑 **The shipped file is 400 bytes**: `[SYSTEM]` (empty) and `[LANGUAGE]`
(`= eng` default plus `#0x01…#0x06`). **No `[STAGENN_UNIT_MAX]` anywhere**, and
`UNIT_MAX`/`VESSEL` appear in **0** pak entries.
**The absence is CONTROLLED.** The same scan over all 41 paks decompressed
**26 443 entries** and found `MSG_DEMO` 192×, `mapmesh_box_500km` 162× and
`<?xml` exactly **40×** — the census number. ⚠️ Last iteration's null was
worthless: that hand-rolled scan used `s[8:]` instead of `s[10:]` for the `Z1`
header and searched the index file rather than the `.pNN` data. **Using the
corpus's own reader (`unitgroup.py`) was the whole difference.**
⇒ **The lookup always misses, so every stage runs on the defaults: `PLANE = 200`,
`VESSEL = 20`.** For the port these are hardcoded fallbacks, not per-stage data.
🟡 A hash search (in case the section were name-hashed like a pak entry) found
**0** of `name_hash("STAGE01…33_UNIT_MAX"/"PLANE"/"VESSEL")` as pak entry keys;
8 of 35 appeared as 4-byte words inside entries, seven in the 1.1 GB `sound.pak`
— the chance rate for a 32-bit needle at that size, not a finding.
🟡 Still open: who CONSUMES `[phase+356]`/`[phase+360]`, and whether
`Stage30``Stage33` exist on disc.
* ✅🔴 **(2026-08-27) THE SECTION READER IS NOT INI-ONLY — it is a generic
accessor over the IDXD tables — and `UNIT_MAX`'s absence now has a REAL control.
[structures/isl-condition-builtins](structures/isl-condition-builtins.md),
artefact `data/config-keys.txt`.**
🔴 **Correction to yesterday's label.** Resolving the `r4` string at *every*
call site of `sub_82448AA0` and `sub_824482D0` yields **65 section names and 54
int keys** — `MISSIONS`, `FONTS`, `StageResource`, `EPILOGUE_MOVIES`, `WEAPONS`,
`UNITS`, `SOUNDS`, `ACHIEVEMENTS_REQUIREMENTS`, `LINE_PITCH`, `MSG_FONT_SIZE`,
`SUBTITLE_Y`, … far more than the 400-byte `config.ini` holds. Decisive:
**`BASE_INFO`, `SYSTEM`, `MISSIONS`, `FONTS` are all present as IDXD record keys
on the disc.** So the pair is a **generic named-section accessor over the IDXD
`.tbl` containers**, with `config.ini` as one small extra input — not "the INI
reader".
⚠️ **My first re-test was INVALID and only its control caught it.** IDXD record
keys are **`tag_hash`**, not `name_hash` (case-sensitive, modulus `0x00FFFFDF`;
`unitgroup.py` documents the difference). Scanning 190 782 records with
`name_hash` returned 0 for `STAGENN_UNIT_MAX` **and 0 for every control name**
which is exactly how a bad test announces itself.
**Redone with `tag_hash`:** 7 750 / 7 750 IDXD entries parsed (matches the
corpus census), **190 782 records, 3 496 distinct keys**, controls
`BASE_INFO`/`SYSTEM`/`MISSIONS`/`FONTS` **found**, and
**`STAGE01…33_UNIT_MAX` → 0 records**. With the literal search (0 in 26 443
entries) and `config.ini` printed in full, the section exists **nowhere on the
disc**. The `GP_HANGAR_ARSENAL` word-hit that worried me is settled — not a
record key. ⇒ **`PLANE = 200` / `VESSEL = 20` for every stage**, now on a
controlled test.
📎 `data/config-keys.txt` lists the whole vocabulary — a lower bound (literal
`addis`/`addi` keys only), but it is the engine's own configuration surface and
directly useful to the port.
🟡 Still open: who CONSUMES `[phase+356]`/`[phase+360]`; whether `Stage30``Stage33`
exist; and the fallbacks behind the other 64 sections.
* ✅🟡 **(2026-08-27) `Stage30``Stage33` DO NOT EXIST — the 33-slot table is
headroom; and the `[phase+356]/[phase+360]` consumer is NOT findable by offset.
[structures/isl-condition-builtins](structures/isl-condition-builtins.md).**
✅ Probing pak entry names (`name_hash` — the right hash for *entry* names)
across all 41 paks: **`UnitGroup_SNN.tbl` exists for exactly S01S16 and
S18S29 = 28 stages**; S17 and S30S33 absent. The control is built in — 28
hits from 33 probes proves the loop resolves entry names. So the 33-entry
`STAGENN_UNIT_MAX` table is oversized headroom over a 28-stage game, and the
gate's `!= 16` skip of STAGE17 lines up with a stage that genuinely has no data
of any kind.
🔑 **An independent corroboration fell out of it.** `AIParams_SNN.tbl` exists
for S01S16 and S24S29 (22) — **missing for exactly S18S23**, precisely the
six stages the corpus identified as the TUTORIALS by a completely different
route (their only dominating condition is `request_next() != 1`). Tutorials
ship no AI parameters. Two unrelated methods, same six stages.
🟡 `Stage_SNN.tbl` probes find 0 — expected, not a failure: `stagetbl.py`
documents that the per-stage record is *not* name-addressed and must be found
by content.
🔴 **Dropping the `[phase+356]`/`[phase+360]` consumer.** Image-wide there are
**243 non-stack `lwz` reads from +356/+360**, across UI, sound, render and
script code — the offsets are far too common to attribute. Inside the ISL region
the only hits are the two writers plus two **vtable calls** (`0x82273400` slot
90, `0x82290ED8`). This needs type information the offset index does not carry;
it is not a cheap static question and should not be reopened as one.
***(2026-08-27) THE CONFIG SECTIONS ARE REAL DISC DATA — 64/65, and the two
reader functions separate perfectly.
[structures/isl-condition-builtins](structures/isl-condition-builtins.md),
artefact `data/config-keys.txt`.**
Testing every literal key from the artefact against the **3 496 distinct IDXD
record keys** with `tag_hash`:
**section names (arg to `sub_82448AA0`) → 64 / 65 present**;
**field keys (arg to `sub_824482D0`) → 0 / 54 present**.
The two classes are each other's control and the dissociation is total, which
confirms the model exactly: `sub_82448AA0(node, NAME)` finds a **record** keyed
by `tag_hash(NAME)`; `sub_824482D0(node, KEY)` reads a **field**, whose name is
a literal string in the record's pool — so none of them can be a record key.
🔑 **The single absent section is `LANGUAGE`** — precisely the one real section
in the disc-root `config.ini`. The same API serves both stores and the data
shows the seam.
**This deflates my own earlier framing.** I had listed "64 sections whose
hardcoded fallbacks are worth pinning for the port". They are not fallbacks:
**all but one are real data on the disc.** `STAGENN_UNIT_MAX` is the genuine
exception, and only because its name is *built at runtime* from the stage index
rather than being one of these literals.
📎 The artefact now names the pak each record lives in: `tables.pak` for the
global tables (`MISSIONS`, `STAGES`, `SOUNDS`, `SUB_OBJECTIVE`,
`SQUADRON_ORDER_OBJECT`, `EPILOGUE_MOVIES`, `ACHIEVEMENTS_REQUIREMENTS`,
`FONTS`, `BASE_INFO`, …), **`GP_HANGAR_ARSENAL.pak` for `WEAPONS`, `UNITS`,
`Camera`, `ControlTweak`, `Rendering`, `AUTO_SETTINGS`, `IGNORE`** — so the
arsenal pak holds the canonical weapon/unit definitions — and the per-language
`GP_MAIN_GAME_*` paks for the enum tables.
🟡 Not settled: the CONTENTS of those records. Knowing `WEAPONS` lives in
`GP_HANGAR_ARSENAL.pak` is a pointer, not a decode; the field-level read is a
separate job per table.
* ✅🔴 **(2026-08-27) THE CONFIG RECORDS ARE DECODED — real render/input constants
for the port, and my "canonical weapon/unit definitions" guess was half wrong.
Artefact `data/config-records.txt`,
[structures/isl-condition-builtins](structures/isl-condition-builtins.md).**
**The find — directly portable constants:**
`Rendering` = `Brightness 1.4`, `Contrast 1.0`, `Saturation 1.00`, `Hue 0.00`,
`ExposureKeyValue 0.18`, `BrightPassThreshold 0.6`, `BrightPassOffset 1.5`,
`LuminanceMin 0.15`, `LuminanceMax 3.6`, `BloomScale 0.5`,
`AfterimageScale 0.05`, `StarScale 1.20`, `GlareType 8`, `ColorLayer R/G/B/A`.
`ControlTweak` = `CameraSpeed 0.50`, `TargetSpeed 0.50`,
`TargetMovingRange 10.0…10.0`, `mov_trigger_play 20`, `eye_stick_play 6000`,
`mov_stick_play 6000`. `Camera` = `NoseCameraFOV 0.92`, `ChangeTime 0.20`.
These are the game's own tone-mapping, bloom and input-deadzone numbers.
🔴 **Correction to my own last entry.** I said `GP_HANGAR_ARSENAL.pak` holds
"the canonical weapon/unit definitions". **`WEAPONS` is a 59-entry NAME ROSTER**
(`No_Equipment`, `Machiene_Cannon_MG1`, `Broad_Sword_SG1`, `Frail_GP37`,
`Stiletto_BG1`, `Pilum_BP`, …) — an enumeration of ids, **not a stat sheet**.
And **`UNITS` is not units at all**: seven craft-slot↔pilot pairs —
`Bird1-Sandra`, `Bird2-Billy`, `Bird3-Antonius`, `Bird4-Carl`,
`Rhino1-Raymond`, `Rhino2-Katana`, `Rhino3-Ellen`**the wingman roster**.
**`IGNORE` is a 13-name blocklist** over that enumeration
(`Long_Spear_HBP`, `Twin_Saber_LG2H`, `Eagle_120AM`, `Maelstrom_Bomb`,
`Cluster_Mine_B10`, plus placeholders `Wep_82`, `Wep_85`) — weapons that exist
but are withheld.
🔑 **`AUTO_SETTINGS` lists exactly 28 files** (`stage01_settings.tbl`
`stage29_settings.tbl`) — a **third independent confirmation of the 28-stage
count**, after the `.ssb` census and the `UnitGroup_SNN.tbl` probe.
🟡 **The "objective" records are UI, not gameplay.** `SUB_OBJECTIVE` is a HUD
layout (`MAX_OBJECTIVE_OFFSET 353,242`, `TEXT_OFFSET 85,271`,
`NUM_QUALIFY_OFFSET 309,242`, `DOT_OFFSET 0,20`) with a **second variant** in
one pak (`275,222` / `85,251` / `231,222` / `0,0`), so two layouts ship.
`SQUADRON_ORDER_OBJECT` is 29 `.prt` sprite names. `MISSIONS` holds
`TimeAttack`, `ScoreAttack`, `Extra01``Extra04` — the **challenge**
categories, not the campaign. `STAGES` has **zero** fields.
🟡 Not settled: where weapon/unit STATS live — the roster names an enumeration,
and the corpus's existing weapon datasheet came from a runtime capture, so the
static table behind it is still unlocated.
* ✅✅ **(2026-08-27) THE STATIC WEAPON DATASHEET IS FOUND — `Weapon` (131),
`Shell` (131), `AssortMissileParam` (9).
[structures/weapon-datasheet-static](structures/weapon-datasheet-static.md),
artefact `data/weapon-shell-static.txt`.**
The corpus's weapon numbers came from a RUNTIME capture; the static tables
behind them are now read with no emulator. 🔑 **The move: IDXD field names are
literal strings in the pool**, so the stat-shaped keys already harvested from
the disassembly (`MegaTons`, `GuidanceType`, `SpiralType`, `Straight1Type`, …)
can be searched directly. **8 648 records carry at least one**, clustering on
three record names in every per-language `GP_MAIN_GAME_*.pak`.
**`Weapon`** (21 fields): `ID`/`Name`, `TargetType Vessel,Craft,Structure`,
`ShotType`, `HitRatio`, `Interval`, `TriggerShotCount`/`TriggerShotInterval`,
`LoadingCount`, `Heating`/`Cooling`, `SprayAngle`, `Mass`, `ReticleType`, plus
SE and muzzle-flash FX names.
**`Shell`** (37 fields): `Power`, `Velocity`, `Acceleration`, `AngularVelocity`,
`MinimumRange`/`MaximumRange`, `LifeTime`, `CollisionType Capsule`,
`Radius`/`Length`/`Volume`, `ShellMass`, `HP`, `DamageType`, `MovementType`,
`IsGravitated`/`IsDestructible`/`IsLockable`/`IsFullCollision`, `Color_R/G/B`,
`ShellModel`, hit/jet/contrail FX and SE. With **world unit = 1 metre**, the
sample rocket is 6 km range at 3 km/s.
**`AssortMissileParam`** (20 fields): the guidance model — `GuidanceType`,
`SpiralType`, `Straight1Type/2Type` and min/max ranges for three named motion
modes (`sp_sp_*` spiral, `sp_qu_*` quick-turn, `sp_zg_*` zig-zag) plus straight
aperture/pitch. Nine variants ship.
⚠️ **131, not 59.** The `WEAPONS` roster read last iteration lists 59 — that is
the *arsenal menu's* list, not the full weapon set.
🔴 **Controlled negative in the same pass: `stageNN_settings.tbl` does not
exist.** `AUTO_SETTINGS` names 28 of them; probing pak entry names with **four**
path prefixes finds **0 / 116**, while the two controls in the same loop
(`stage\UnitGroup_S02.tbl`, `stage\AIParams_S02.tbl`) are **both found**. Same
shape as `STAGENN_UNIT_MAX` — named but not shipped.
📎 Also decoded on the way: `EnumUnit` = 54 real unit ids
(`UN_e107_ADAN_AAFrigate`, `UN_f102_TCAF_LightCarrier_Inv`, …), tying the
`eNNN`/`fNNN` mesh prefixes to readable faction/class names; `ArmsItemFile` = 59
weapon-id → HUD icon `.t32` mappings (icons, not stats); `SETTINGS` in
`tables.pak` = the sound config (`PATH game:\dat\sound.pak+`,
`PARAM Pj_Silph.xgs`, member `BANK_SE`); `Parameters` = HUD marker config.
🟡 Not settled: reconciling the 131 records against the runtime capture field by
field; the stat keys in records not opened here (`MegaTons`, `BulletLimit`,
`CrewCount`, `HP_CLASS`); why the roster lists 59 of 131.
* ✅❌ **(2026-08-27) THE OBJECTIVE/TUTORIAL MARKER RECORDS — and a CORRECTION:
S24-S29 are the CHALLENGE missions, not story.**
[structures/hud-config](structures/hud-config.md),
[structures/stage-numbering-and-player-craft](structures/stage-numbering-and-player-craft.md),
artefact `data/hud-config.txt` (+13 lines, 0 deletions — purely additive).
**Correction first.** `stage-numbering-and-player-craft.md` called S24-S29
"story" and reported "22 story stages". **`challenge-mission-gate.md` OWNS that
split and already had it**: `S01-S16` story, `S18-S23` tutorials, **`S24-S29`
challenge**. A third confirmation fell out of this iteration's reading:
**`AUTO_SETTINGS` names `stage01..16_settings.tbl`,
`tutorial01..06_settings.tbl`, `challenge01..06_settings.tbl` — and its field
tags are 1-16, 18-23, 24-29**, the shipped stage numbers exactly. The campaign
is **16** story missions, not 22. (Fourth time the grep-the-owning-doc rule has
caught me; `AUTO_SETTINGS` and `IGNORE` are likewise already owned by
`isl-condition-builtins.md`, so nothing there was new either.)
**What IS new**: the 11 **marker records** `hud-config.md` listed as unread.
12 named fields each, and portable HUD data. **Colour is ARGB, three per
marker** (`ColorNormal`, `ColorEmphasis1`, `ColorEmphasis2`): objectives
**red** `0xFFFF0000`, guard/waypoint **green** `0xFF20FF20`, Acropolis/tanker
**cyan** `0xFF00FFFF`, sub-objective **yellow** `0xFFFFFF00`, and every
`TutorialMarker_*` **amber** `0xFFFFA010`. Each carries a `Normal` sprite and
an `Emphasis` part with optional `…Sub` companions; `HPGauge` 0/1/2;
`RadarCursorType` `Circle` (Acropolis, tanker) / `Rectangle` (guard) / blank;
`BlinkCycle` `0x800` on all eleven. `TutorialTarget` is `Yes` on exactly the
five `TutorialMarker_*` and `No` on the six `ObjectiveMarker_*`.
**`Parameters` binds the roles**: `TargetMarker` -> `ObjectiveMarker_Target`,
`HelpMarker` -> `ObjectiveMarker_SubObjective`, `TutoTargetMarker` ->
`TutorialMarker_Target`, plus `ReloadDispTime 0.1`.
**`Enumerate_ObjectiveMarkers`** is the 11-name roster of the set — the same
shape as `Enumerate_Squadrons` in `unit-group-table.md`.
⚠️ `pgmark_em_ob_s.prt` appears here and is one of the 28 names
`archive-naming.md` shows resolving nowhere — consistent with the namespace
finding, not a missing asset.
***(2026-08-27) `CoverArea` IS A 6-BIT MOUNT MASK — plus the three other
turret leftovers, all closed.**
[structures/unit-substructure-records](structures/unit-substructure-records.md),
artefact `data/turret-coverarea.txt`, regenerator
`tools/re-capture/turret_coverarea.py`.
**Six bits, never more.** Over all **835** turrets: 41 distinct values, max
`0x3f`, **bits 6/7 set on none**; per-bit 709/117/274/274/260/237. `0x00` (34)
= the empty `Weapon_NULL` hardpoints, `YawLimit 0` on all 34; `0x01` alone
(439) = craft hardpoints, `YawLimit` 2.5/1.0/0; 2-6 bits (362) = warship
mounts with `YawLimit` 45-180.
🔑 **It tracks the MOUNT, not the weapon.** `UN_e107_ADAN_AAFrigate` has eight
identical `AAFrigate_AAGun` turrets: `GN_GunXS_01..04` are **`0x1d`** and
`05..08` are **`0x2d`** — same gun, same `YawLimit 120`, different mask.
`UN_f104_TCAF_Battleship` spreads five masks over `GN_TGunL_01..05` /
`GN_TGunM_02..03`, all firing the same `CAF_Ship_ASGun`.
🟡 **Which sector each bit denotes is NOT determined** — six bits and the name
*CoverArea* invite +-X/+-Y/+-Z or six hull faces, and the 01-04 / 05-08 split
looks like a side, but nothing static fixes the convention. **Not adopted.**
❌ Refuted on the way: bits 2 and 3 are NOT a mutually-exclusive pair (188
turrets set both).
**The 26 weapons no turret mounts** are a coherent set, not strays: 11 `_P`
player variants, the nose/twin mounts (`_NoseGun`, `_NoseGun_P`, `_None`,
`_Ttrl`, `_TwinGun`, `_TwinGun_P`), two `_Child` sub-munitions (`wep_13`,
`wep_29`), the three `S16Boss_*`, three `Weapon_Test_*`, and
`ADAN_Attacker_S_GunTurret`.
**`Versatile_NNN` ships NOWHERE** — 0 populated records across all 41
archives, only the `???` template row. A sixth part kind with zero instances.
✅ **The two units one turret short: both extras are MISSILE mounts with NO
`Frame`.** `Elan_EX4` (`TurretCount 1`, 2 records) = `Turret_NoseGun` +
`Turret_Missile`, both mask `0x01`, both frameless. `AAFrigate_EX4`
(`TurretCount 8`, 9 records) = eight `GN_GunXS_0N_ContH` guns plus one
`Ship_AAMissile` at mask `0x0c` with no `Frame`. So `TurretCount` counts
frame-mounted guns and a frameless launcher rides in the same record family
uncounted — **n=2, stated as the observed pattern, not a rule**.
* 🔴 **(2026-08-27) BLOCKED — the 2D / `GP_READY_ROOM` TOC names: three routes,
all closed, each with a control.**
[structures/archive-naming](structures/archive-naming.md), artefact
`data/archive-naming.txt` (+22 lines, 0 deletions — purely additive).
**1. A different hash family.** The corpus knows three (`idxd-tag-hash.md`):
`name_hash`, `tag_hash`, `ixud_hash`. Scoring all **5 977** harvested names x 6
prefixes: `GP_TITLE` **8/16** and `GP_PAUSE_MENU` **6/11** under `name_hash`
(the positive controls), and **`tag_hash` and `ixud_hash` explain NOTHING
anywhere** — including the paks `name_hash` does explain. So they are not the
TOC function and the unnameable pair is not keyed by a different one.
`GP_MAIN_GAME_E2D` stays at **0/711** under all three.
**2. The executable.** `sylpheed.db`'s `strings` holds **7 140** rows, of which
exactly **TWO** look like asset paths — `Data\gmicon002_2.t32` and
`Data\gmicon006_2.t32`, in a `Data\` directory nothing else on the disc uses —
and **neither resolves in any archive**. The binary is not the name source; it
holds two strays and no table.
**3. Name transformations** — 13 of them on the 419 config paths, all 0.
🔴 **The container runs out here.** Those TOC keys hash names that exist on
neither the disc nor the executable in readable form. The only lever left is a
dictionary attack using `name_hash`'s shape (top byte = character-sum
checksum, so candidates are cheap to reject), and that needs a plausible name
corpus this disc does not contain. **Noted as blocked rather than improvised
around.**
⚠️ **The port does not need these names**: sprites and bundles are readable by
CONTENT (T8aD, RATC), and the config records already say which asset each HUD
element uses. Only the archive-key ↔ name mapping is missing.
***(2026-08-27) WHICH ARCHIVES THE DISC CAN NAME — 100 % for eleven menu
paks, **0.0 %** for all six 2D paks AND `GP_READY_ROOM`.**
[structures/archive-naming](structures/archive-naming.md), artefact
`data/archive-naming.txt`, regenerator `tools/re-capture/archive_naming.py`.
Chasing more prefixes for the 419 HUD paths would have been the same mistake
twice, so this censuses the whole disc: harvest **every** plausible asset-name
string from **every** archive (**6 027**), hash each under the 16 known
prefixes, and ask per archive what fraction of its TOC that explains.
`idxd-container.md`/`idxd-tag-hash.md` own the hash; neither says which
archives are REACHABLE by it.
🔑 **The result is bimodal.** `GP_TITLE` 16/16, `GP_PAUSE_MENU` 11/11,
`GP_STAGE_CLEAR` 44/44, `GP_CHALLENGE` 151/151, `GP_MOVIE_THEATER` 56/56,
`MiscBin` 40/40, `GP_GAMEOVER`, `GP_BUNK`, `GP_SYSTEM`, `GP_TUTORIAL`, `fonts`
**100 %**; `tables.pak` 78/79, `GP_DIALOG` 139/140, the six language paks
115/117, `GP_SAVE_LOAD`/`GP_LEADERBOARD`/`GP_OPTIONS`/`GP_MISSION_LOG` 91-94 %;
`GP_MAIN_GAME_*` 751/1119 = 67 %. Then the cliff: **the six
`GP_MAIN_GAME_*2D.pak` at 0 of 711 EACH**, and **`GP_READY_ROOM` at 6 of
1 106 = 0.5 %** — the largest UI pak on the disc, and not previously noted
anywhere in `docs/re/`.
🧪 Eleven paks at 100 % in the same run is the control that makes 0.0 % a
finding rather than a failed guess: the method works; these archives are
outside it.
🔑 **So the 419 HUD paths are not "missing assets"** — nothing in the 2D paks
is reachable by name from the disc's own strings at all. Those TOC keys hash
names that are **not written anywhere readable**: composed by the tool chain or
by the executable, or listed outside these archives.
❌ Also refuted first: the 419 config values under **13 name transformations**
(as-is, forward slashes, basename, basename-no-ext, no-ext, UPPER, `.rat`
substituted, `2d\`/`eng\`/`hud\`/`GP_MAIN_GAME_2D\` prefixed, first-directory
stripped) — **every one scored 0**, against the E2D 711 and against all 16 630
entries on the disc.
🟡 Not settled: what those names are. The one lever left is the hash's shape —
the top byte is the character-sum checksum, so candidates are cheap to reject —
but that needs a name corpus the disc does not contain. Also unexplained:
`DefTables` 8.9 % and `GP_HANGAR_ARSENAL` 22.6 % sitting in the middle.
***(2026-08-27) THE WHOLE IN-GAME HUD CONFIG IS OPEN — 16 records, 419 asset
paths, a per-stage `ResourceTable` — and NONE of it is name-hash addressed.**
[structures/hud-config](structures/hud-config.md), artefact
`data/hud-config.txt` (697 lines), regenerator `tools/re-capture/hud_config.py`.
Checked first: no `docs/re/` file mentions `ArmsStatus`, `RangeFinder`,
`Radar`, `Sight`, `Wing`, `NamePlate` or `ResourceTable`. Only `HudResource`
had been opened; the other **fifteen** records had not.
The six IDXD entries of `GP_MAIN_GAME_E2D.pak`: two carry the 16-record HUD
config (`ArmsStatus` 16, `Map` 3, `ArmsItem` 1, `RangeFinder` 10, `Marker` 27,
`Manuva` 15, `Number` 10, `HudResource` 24, `NamePlate` 60, `ActiveArm` 13,
`Information` 15, `Wing` 23, `ArmsItemFile` 59, `Radar` 29, `Speed` 9,
`Sight` 35), two the 13-record `ObjectiveMarker_*`/`TutorialMarker_*` set, one
`Face` (52 portrait sprites), one `ResourceTable`.
🔑 New subdirectory prefixes throughout: `ArmsSt\`, `ActvArm\`, `RangeF\`,
`Marker\`, `Manuva\`, `Map\`, `Speed\`, `Radar\`, `Sight\`, `Wing\`,
`Hitmark\`, `Lockon\`, `Info\`. `Number` is `%d`-templated
(`pghud_speed_num%d.t32`, `pgtimer_num%d.t32`, `pglockon_%d.t32`).
🧪 **THE DECISIVE CONTROL — the config's own exact path strings, no guessing
left: 419 distinct `.prt`/`.t32`/`.tbl` values, ZERO resolve as a pak entry**
under 10 prefixes; the four config FILENAMES resolve to nothing either. The
`.t32` sprites certainly exist (574 T8aD in that pak). **So the 2D pak is not
addressed by `name_hash` of the name its config uses.**
⚠️ This SUPERSEDES the earlier framing: the 28 "dangling" `.prt` names were
never a missing-asset story — they are 28 of a set where none of the 419
resolves. The right statement is "**the in-game asset namespace is not the
archive namespace**".
**`ResourceTable` = 58 positional fields = 29 PAIRS**, alternating
`HudResource.tbl`/`HudMarkerResource.tbl`, identical in all six language paks,
with **exactly one override at pair index 25: `HudResource_S26.tbl` /
`HudMarkerResource_S26.tbl`**.
🟡 The reading — indexed by stage number 1, so 29 entries cover S1..S29 and
index 25 is **S26, the one stage with its own HUD config**. The arithmetic is
exact and the `_S26` suffix names the same stage, but nothing proves the
indexing. **Not adopted.**
🟡 Not settled: **how the 574 T8aD / 130 RATC entries ARE addressed** — that is
now the question; what `HudMarkerResource.tbl` pairs to (by content, not hash);
`Parameters` and `Enumerate_ObjectiveMarkers` are in the artefact but unread.
* ❌✅ **(2026-08-27) THE "VARIANT INSIDE A PARENT BUNDLE" READING IS REFUTED —
and the in-game screen config is `HudResource`, the counterpart of
`tables.pak`. 28 `.prt` names exist NOWHERE on the disc.**
[structures/mission-script-manifest](structures/mission-script-manifest.md),
artefact `data/prt-parts.txt` (+45 lines, 0 deletions — purely additive).
❌ Censused the 32 bundle-less in-game names with the 68 as control: **4 of 32
appear as an ELEMENT of some 2D bundle, against 58 of 68**. Being an element is
normal for a part that exists; these are not elements either. The reading I
flagged as "not adopted" last iteration is now **refuted**. **28 names are
neither a bundle nor an element.**
🔑 They are not unreferenced: a **`HudResource`** record inside the 2D paks'
OWN IDXD entries names them — the in-game screen config, exactly parallel to
`tables.pak` for menus. `GP_MAIN_GAME_E2D.pak` has 6 IDXD entries; two carry
`HudResource` (24 named fields: `PGTIMER`, `PGREMAINING`, `PGHUD_SUBTARGET`,
`PGHUD_LOCATOR_EASY`, the pitch-ladder/yaw/cockpit `.t32` sprites, `HUD_RES_FONT
= DFSOGE5.TTC`) beside `ArmsStatus`, `Map`, `ArmsItem`, `RangeFinder`,
`Marker`, `Manuva`, `Number`; two more carry `ObjectiveMarker_*` /
`TutorialMarker_*`.
⚠️ Its values carry **subdirectory prefixes**`Hitmark\`, `Lockon\` — which
my hand-written prefix list never had.
🧪 **So I rebuilt the negative the right way round**: harvested every `IDXD`
field value containing a backslash, kept the 60 commonest directories
(`Sight\`, `Radar\`, `Marker\`, `Wing\`, `ArmsSt\`, `Manuva\`, `Hitmark\`,
`Lockon\`, the `<lang>\Voice\` and `<lang>\etc\` trees) and re-swept.
**241/376 with the hand list; 241 with hand + harvested — zero new
resolutions.** After the `language\` miss this is the closure that counts: the
prefix list came from the disc, not from me.
🟡 Still open: whether the 28 are cut features or assembled at runtime from
sprites — nothing static separates those two. The family:
`pghud_range_*`, `pghud_wing_*`, `pghud_arms_active*`, `pgtarget_*`,
`pgmanuva_eff2/3`, `pggauge_*_eff1`, `pgtimer`, `pgremaining`, `pgmsg_update`.
***(2026-08-27) AN EMBEDDED PART IS A TOP-LEVEL RATC BUNDLE, ADDRESSED BY ITS
ELEMENT PREFIX — 68 of 100, against 0 of 241 for the shipped parts.**
[structures/mission-script-manifest](structures/mission-script-manifest.md),
artefact `data/prt-parts.txt` (+38 lines, 0 deletions — purely additive).
Read first: `ui-rat-layout.md` OWNS the RATC stack and already documents the
60-byte element declaration table at `0x20` and "one bundle = one
(context x language) build of that screen". What it does not say is how a part
is addressed when it is NOT a pak entry.
🔑 **The elements of one bundle share a common name prefix, and that prefix is
the part name.** `GP_MAIN_GAME_E2D.pak` = **130 bundles, 114 distinct element
prefixes** — `pgmenu_btn00`, `pghud_wing`, `pgface`, `pghud_range`,
`pgmanuva_eff0`, i.e. exactly the in-game part names.
🧪 **The 100 in-game-only `.prt` names match a 2D bundle prefix 68 times; the
CONTROL — the 241 shipped parts — matches ZERO.** The two families are disjoint
on the test. (13 shipped parts match a bundle prefix in their OWN screen pak,
which is the expected shape.)
Four of the five mission banners land here: `pgmsg_start.prt` is E2D bundle
`0x89fac252`, a ONE-element bundle declaring `pgmsg_start_sub.rat`; likewise
`_end_` `0x92239624`, `_failed_` `0xc01017fc`, `_restart_` `0x606fef65`.
**`pgmsg_update` has no bundle at all**, consistent with having no `_sub.rat`.
🟡 **32 in-game names still have no 2D bundle.** They cluster into families
whose BASE name is a bundle — `pghud_wing_act_*` beside `pghud_wing`,
`pghud_range_*` beside `pghud_range`, `pgmanuva_eff1..3` beside
`pgmanuva_eff0`, `pgtarget_*` — reading as "a variant declared inside a parent
bundle". **A reading, not adopted**; nothing here shows the variant mechanism.
🟡 Still open: those 32, and `pgmsg_update`, absent from every route tried.
***(2026-08-27) WHO REFERENCES A `.prt` DECIDES WHETHER IT SHIPS — menu parts
are named and shipped, in-game parts are embedded. 0 counterexamples in 376.**
[structures/mission-script-manifest](structures/mission-script-manifest.md),
artefact `data/prt-parts.txt` (+44 lines, 0 deletions — purely additive).
Censused the whole 135 unresolved set instead of chasing the five. Cross-tab
over all 376 `.prt` names: **referenced by `tables.pak` AND resolves 241;
neither 100; referenced but absent 35; resolves WITHOUT being referenced by
`tables.pak` — ZERO.**
🔑 **Resolving implies a `tables.pak` reference, 0 counterexamples of 376.**
`tables.pak` holds the menu screen configs and every part it names ships as
`<lang3>\<name>.prt`. The **100** names that appear only inside the
`GP_MAIN_GAME_*2D.pak` bundles never ship as entries — the in-flight HUD is
authored into the bundles, not loaded by name. So the five `pgmsg_*.prt` were
never a special case; the real split is **menu = named + shipped, in-game =
embedded**.
**It is NOT a prefix rule** — 6 of the 135 `pg*` names DO resolve
(`pgloading`, `pgloading2`, `pgmsg_scr`, `pgpause`, `pgpause_ttrl`, `pgpbase`),
precisely because `tables.pak` names them.
🟡 The 35-name residual is one coherent family: `tables.pak`-referenced yet
absent — `pgmenu_btn*`/`pgmenu_item*`/`pgmenu_pad`, `pgfacewin*`, `pgtextwin`,
`phinfo1-3`, `02d`, `02d_scr`, `psview_release`: the **in-game pause menu and
squadron-order overlay**, named by a menu config but drawn from the in-game
bundles. Consistent with the split, not separately proved.
🟡 Still open: where an embedded part sits INSIDE a bundle.
* 🟡 **(2026-08-27) WHERE `.prt` SCREEN PARTS LIVE — and the five mission
banners are a real exception.**
[structures/mission-script-manifest](structures/mission-script-manifest.md),
artefact `data/prt-parts.txt`, regenerator `tools/re-capture/prt_parts.py`.
Read first, as the rule now requires: `ui-prm-primitives.md`,
`ui-rat-layout.md`, `ui-screen-runtime.md` — they own the `.prt`/RATC/T8aD
stack but describe element names INSIDE bundles, not where parts live.
**A `.prt` IS a pak entry, under a LANGUAGE directory.** Of **376** distinct
`.prt` names referenced on the disc, **241 resolve** as archive entries — 220
under `eng\` and `jpn\`, 38 under each of the other four. `prbase.prt`
resolves under all six; `palogo1.prt` under two.
**All five `pgmsg_*.prt` resolve under NOTHING**, across 15 prefixes that
include every language dir — so this is not the missing-prefix mistake again.
🟡 **A lead, refuted by its own control.** Four of the five have a same-stem
**`_sub.rat` sub-bundle** inside every per-language 2D pak
(`pgmsg_{start,end,failed,restart}_sub.rat`, 6 archives each); **`pgmsg_update`
has none.** Tempting as "the parts live as RATC sub-bundles" — but **only 4 of
376 `.prt` stems have a `_sub.rat`** (the other 7 belong to `GP_BUNK`,
`GP_SYSTEM`, `GP_MISSION_LOG`), and `prbase.prt`, which does ship as an entry,
has none. Not the container convention. **Not adopted.**
🟡 Still open: where the five actually are. What is settled is that they are
not pak entries while 241 siblings are, and that `pgmsg_update` is the odd one
out even among the five.
⚠️ Method note: an intermediate pass of this iteration nearly reported "the
five appear only in the manifest" — wrong, because the token census was
truncated by a `[:20]` slice and the RATC hits were `pgmsg_start_sub.rat`, a
LONGER token. **A truncated listing is not a distribution**, and a prefix match
is not an exact match.
***(2026-08-27) CORRECTION — the mission dialogue table was ALREADY IN THE
CORPUS, and my headline negative was a FALSE NEGATIVE.**
[structures/mission-script-manifest](structures/mission-script-manifest.md),
artefact `data/script-manifest.txt` (8 insertions, 3 deletions — every deleted
line replaced by its corrected form).
❌ The entry below announced `message\MissionDialogMessage.tbl` as a find.
**`structures/ixud-localised-text.md` OWNS it** and already says
`S02_P1_OBJECTIVE` and friends "are record names in an IDXD map,
`message\MissionDialogMessage.tbl`, whose positional fields list the lowercase
per-line IXUD names", and that "`*_GRAPH` is the odd one: a single named field
holding a texture, `pgmsg_stg02_1.t32`". I grepped the manifest doc and not the
text doc. **Third overclaim in four days** — the rule is now: grep the doc that
owns the DATA, not only the doc that owns the FILE.
**`MissionDialog_local_string.tbl` DOES resolve** — as
`language\MissionDialog_local_string.tbl`, IXUD, in all six `GP_MAIN_GAME_*`
paks. My 33 prefixes omitted `language\`, which is precisely the convention
`ixud-localised-text.md` records for language paks. **A prefix sweep is only as
good as its prefix list, and the list should come from the corpus.**
❌ "`LOSE` has exactly one entry" was wrong too. Per kind: `HINT_PAUSE` 4,
`HINT` 3, `LOSE` 4, `OBJECTIVE` 4 POSITIONAL fields, and **`GRAPH` 1 NAMED
field** holding the `.t32` (`S10_P1_GRAPH` -> `pgmsg_stg10_1.t32`). The
single-field bucket was `GRAPH`, not `LOSE`.
**What survives as new**: the 11 non-`MISSION` field VALUES read as a block;
the `GP_TEST` (`PATH = dat\GP_TEST\`, a debug archive not on the disc) and
`TEXTS` sibling records; the per-stage phase census (40 stage-phases over the
22 story stages, 1-3 phases each, the six with none being 18-23); and the five
`pgmsg_*.prt` resolving nowhere under 34 prefixes x 41 archives, now with TWO
working controls in the same sweep.
***(2026-08-27) `Stage\script.tbl`'s 11 NON-`MISSION` FIELDS, READ AT LAST —
and one of them lands on the mission DIALOGUE table.**
[structures/mission-script-manifest](structures/mission-script-manifest.md),
artefact `data/script-manifest.txt`, regenerator
`tools/re-capture/script_manifest.py`.
`structures/mission-script-ssb.md` owns this manifest and names its 40 fields
but never read the 11 that are not `MISSION<n> = StageNN.ssb`. They are
`DIALOG_MESSAGE = MissionDialogMessage.tbl`, `DIALOG_LOCAL_STRING =
MissionDialog_local_string.tbl`, `FONT = dat\fonts.pak+HGRGE00.TTF`,
`FONT_SIZE 15`, `TEXT_POS 128,128,16`, `TEXT_LINES 30`, and the five
`pgmsg_{start,end,update,failed,restart}.prt`. Two sibling records were also
unread: **`GP_TEST` (`PATH = dat\GP_TEST\`, a debug archive NOT on the disc)**
and `TEXTS` (a second text style, 33/3/`296,565,39`).
🔑 Probed 7 values x 33 prefixes x 41 archives. **One resolves:
`message\MissionDialogMessage.tbl`**, in all six `GP_MAIN_GAME_*` paks —
**200 records, 25 280 bytes**, every name `S<NN>_P<n>_<KIND>` with five kinds
40 each (`HINT_PAUSE`, `HINT`, `OBJECTIVE`, `GRAPH`, `LOSE`), fields
**positional, tagged 0..3**, each value a message key
(`S10_P1_HINT_PAUSE` -> `S10_P1_Hint_Pause_00.._03`). An index from
(stage, phase, kind) to the localised strings, on the same `S<NN>_P<n>` keying
the ISL corpus already uses.
🧪 **Control**: the 40 stage-phases span stages **1-16 and 24-29** — a subset
of the 28 shipped, and the six with NO hints are exactly **18-23, the
tutorials**. A fifth independent route to the story/tutorial split, and it
hands over the **phase count per stage** (1 to 3; S02/S03/S06/S09 have 3,
S10/S13 and all of S24-S29 have 1).
🟡 **The other six do not resolve**, with the control in the same sweep:
`MissionDialog_local_string.tbl` and all five `pgmsg_*.prt` are not a pak entry
under any of the 33 prefixes, while `message\MissionDialogMessage.tbl` and
`Stage\script.tbl` both resolve in 6 archives. Same wall
`mission-phase-advance.md` hit for the manifest's KEYS; this closes the VALUES.
⚠️ **`name_hash` is CASE-INSENSITIVE** (`message\` == `Message\`); `tag_hash`
is not. Already noted in `sound-pak-contents.md`; repeated because it doubles
the apparent hit count of a prefix sweep.
***(2026-08-27) THE STAGE NUMBERING IS CONSISTENT EVERYWHERE — and the
manifest still declares the cut S17.** Also a **correction to my own claim**
in the entry below.
[structures/stage-numbering-and-player-craft](structures/stage-numbering-and-player-craft.md),
artefact `data/stage-numbering.txt` (+14 lines, 0 deletions — purely additive).
**Correction first**: the entry below says the corpus "did not know the
stage NUMBERS". It did — `structures/mission-script-ssb.md` has recorded
**S01-S16, S18-S29, S17 absent** by a three-way convergence (hash lookup, the
table sweep, the loader's `!= 16` guard) since before that pass. What was
actually new there is the **player craft per stage** and `ResourceID`'s
ordinal tags. The numbering is a fourth independent route, not a discovery.
**The question this item asked** — do the ISL `StageNN.ssb` files and the
`UnitGroup_SNN.tbl` tables use the same numbering, or is something silently
mis-labelled? **They are identical**: `Stage\StageNN.ssb`,
`stage\UnitGroup_SNN.tbl` and `stage\Route_SNN.tbl` all resolve for the same
28 N, **symmetric difference EMPTY**; `AIParams` is the same minus 18-23.
Nothing in the corpus is mis-keyed. (`Formation_SNN`/`Message_SNN` resolve
0/40 — field names, not files; the four families above are the controls that
make that negative real.)
🔑 **New**: `Stage\script.tbl`'s `SCRIPTS` record lists **29** `MISSION*.ssb`
fields — `MISSION1`..`MISSION29`, **including `MISSION17 = Stage17.ssb`**
and only **28 of 29** name a file that ships. Control: `MISSIONn` ->
`Stage<n>.ssb` with the same `n`, **0/29 mismatched**. So S17 is a **cut
mission the shipping manifest still names**, which is why the loader carries
an explicit `!= 16` guard rather than just running 0..27.
* ✅✅ **(2026-08-27) THE DISC'S STAGE NUMBERS ARE NOT 1..28 — S01-S16, S17
ABSENT, S18-S23 the tutorials, S24-S29 — and the player's craft per stage
falls out with it.**
[structures/stage-numbering-and-player-craft](structures/stage-numbering-and-player-craft.md),
artefact `data/stage-numbering.txt`, regenerator
`tools/re-capture/stage_numbering.py`.
The corpus knew "28 stages" and "six tutorials with no `AIParams`"; it did not
know the NUMBERS. Hashing `stage\UnitGroup_S%02d.tbl` for N=0..39:
**1-16, 18-29 ship, 17 does not** — 28 files, and the six with no
`AIParams_SNN.tbl` are exactly **18-23**. So 22 story stages + 6 tutorials.
🔑 `eng\GP_HANGAR_ARSENAL_3D.tbl`'s **`ResourceID`** record keys a player-craft
mesh by stage: `Unit_St1_6` -> `rou_f001` (DeltaSaber **T**), `Unit_St7_16` ->
`rou_f002` (**W**), and **six further fields tagged with the raw numbers
24,25,26,27,28,29** -> `rou_f002` x4, **`rou_f004` (DeltaSaber A) at 28**,
`rou_f002`. The bare tags ARE the last six story-stage numbers.
🧪 Control in the same record: `tag_hash(name) == tag` for **11/11** named
fields, so those six genuinely carry no name.
🧪 **Cross-check from a different file** — grouping the Arsenal pak's 168
stage-scoped entries by which `_Player` craft their loadout mounts gives
**f001 = 6, f002 = 15, f004 = 1, tutorials 5+1 = 6**, and every number closes
against `ResourceID`: 6 = `Unit_St1_6`; 15 = `Unit_St7_16` (10) + tags
24,25,26,27,29 (5); 1 = tag 28; 6+15+1+6 = **28**.
🔑 **The player flies the DeltaSaber A in exactly one mission, S28**, and the
DeltaSaber T only for the first six.
🟡 Not settled: the Arsenal entry FILENAMES, so which of the 168 is S24 vs S25
is constrained but not pinned (`name_hash` probing over 8 templates x 40
indices x 6 languages resolved 0; controls `<lang>\weapon.tbl`,
`<lang>\strings.tbl`, `<lang>\GP_HANGAR_ARSENAL_3D.tbl` all resolve).
***(2026-08-27) WHO SELECTS A LOADOUT: THE PAK ENTRY DOES — the Arsenal pak
is STAGE-SCOPED, 168 entries = 28 stages x 6 languages.** Also a **correction
to the entry below**.
[structures/hangar-loadout-system](structures/hangar-loadout-system.md),
artefact `data/hangar-loadouts.txt` (+40 lines, 1 changed heading).
🧪 Settled by elimination with two controls. The 15 loadout names appear as a
field **VALUE nowhere on the disc — 0 occurrences across every pak** — while
the control `Arbalest_155KG`, which *is* referenced, appears **150** times as a
value in the same pak. They occur only as the **field names of the `UNITS`
record**. They are **not in the executable** either; control: `WEAPONS` (a
section key) is, `Arbalest_155KG` is not.
So nothing references a loadout by name — the selection is **which pak entry
the Hangar loads**. `GP_HANGAR_ARSENAL.pak` has 180 IDXD entries in 10 shapes;
**168 carry a `UNITS` roster = 28 x 6**, and the other 12 = 2 x 6 (the item
table and a sibling). Each entry is one stage's whole Hangar config.
🔑 The `UNITS` roster is **the flight for that stage**, and it changes across
the campaign as the story does — 5 distinct rosters over 15 / 5 / 5 / 2 / 1
stages: Bird 1-4 + `Rhino1-Katana`/`Rhino2-Ellen`/`Rhino3-Gene`/`Rhino4-Yoji`;
a Raymond/Gene/Katana/Ellen variant; **`Rhino2-Katana` ALONE** (5 stages);
a 7-pilot variant; and one with `Rhino4-Brandon`. **Every count is a multiple
of 6 and they sum to 28** — the disc's stage count by a fourth independent
route.
⚠️ **CORRECTION to the entry below**: it read the pak with `setdefault`, so
"15 loadouts / 24 allow-lists / `STANDARD_ARM1` has 11 entries" is the union of
**first-seen** records, not one table. The contents genuinely vary per entry —
`PlayerSET_ARM1` has **5** distinct contents, `STANDARD_ARM1` **3**,
`ExSET_NOSE` **4**; the `SNNSET_*`/`NULL_*` sets have exactly 1 each. The chain
and both controls (60/60, 70/88) are per-record and unaffected; only the
per-list sizes were over-generalised.
🟡 Not settled: the entry FILENAMES. `name_hash` probing over 8 templates x 40
indices x 6 languages resolved **0**, while the same probe's controls
`<lang>\weapon.tbl` and `<lang>\strings.tbl` resolved **12/12** — the method
works, the convention is not one of the eight guessed. So which entry is which
stage is still unknown.
***(2026-08-27) THE HANGAR LOADOUT SYSTEM — the layer above the item chain.**
[structures/hangar-loadout-system](structures/hangar-loadout-system.md),
artefact `data/hangar-loadouts.txt`, regenerator
`tools/re-capture/hangar_loadouts.py`.
**15 loadout records**, one per flight position x pilot (`Bird1-Sandra`
`Rhino4-Yoji`), each with `Arm1`/`Arm2`/`Arm3`/`Nose` + `UnitID`.
🔑 **The same trap as `PlayerWeapon`, one level up**: `Arm1`/`Arm2`/`Arm3`/
`Nose` do NOT name items — they name a **per-slot ALLOW-LIST record**, one of
24 whose only *named* field is `Type` (the slot kind). The candidate items are
the allow-list's **positional, unnamed fields, in order**. Four hops:
`Rhino4-Yoji.Arm1``STANDARD_ARM1``[Falcon_9AM, Condor_105AM, …]`
`item.PlayerWeapon = Turret_NNN``slot.WeaponID``Weapon.ID`.
🧪 Controls: `Arm1/2/3/Nose` → allow-list record **60/60**; allow-list
positional entries → arsenal item **70/88**, and **every one of the 18 misses
is the single sentinel `No_Equipment`** — one of the four `WEAPONS`-roster
values with no item record, i.e. the empty-slot marker, not a decode failure.
Six sets: `STANDARD_*` (11/9/9/8), `ExSET_*` (10/8/4/7), `S01SET_*` (2/1/1/1),
`S02SET_*` (3/2/2/1), `PlayerSET_*` (2/1/1/1), `NULL_*` (1 each) — so the
`SNNSET_` sets are stage-scoped narrowings of `STANDARD_`.
🔑 **`UnitID` is TWO ID spaces at once**: 5 rows name a unit `Generic.ID`
(the three `-Katana` rows are the PLAYER — they point at a `_Player` craft and
carry an extra, empty `PlayerUnit` field), 8 name a character, resolving as
**`Character` + the value** into the 64-record character table.
🟡 Two values resolve to nothing, both single rows against 13 that do:
`Rhino2-Ellen.UnitID = UN_f001_TCAF_DeltaSaber_W`**no such unit anywhere**
(checked as a `Generic.ID` across every pak AND as a record name, 0 hits)
while `UN_f002_TCAF_DeltaSaber_W` exists → consistent with a shipped
`f001`/`f002` typo, **reported, not diagnosed**; and `Rhino4-Brandon.UnitID =
BRANDON` — no `CharacterBRANDON` among the 64.
🟡 Not settled: who SELECTS a loadout record (the names encode flight position
and pilot, and `stage-definition-table.md` has `AI_TCAF_RhinoFlight` /
`AI_TCAF_BirdFlight`, but the join is not shown); `ExSET_*`/`S02SET_*`/`NULL_*`
are referenced by no loadout in this pak; `PlayerUnit` is empty on all three
rows that have it; `IGNORE` (13 item names) is a blocklist for something
unread.
* ✅✅ **(2026-08-27) THE 59-OF-131 ARSENAL QUESTION IS CLOSED — an Arsenal item
names a HARDPOINT SLOT, not a weapon.**
[structures/arsenal-item-weapon-chain](structures/arsenal-item-weapon-chain.md),
artefact `data/arsenal-chain.txt`, regenerator
`tools/re-capture/arsenal_chain.py`.
Open since the weapon datasheet was decoded, and one wrong answer was
refuted yesterday. The link is **three hops**:
`Arbalest_155KG.PlayerWeapon`**`Turret_050`** (a slot on
`UN_f001_TCAF_DeltaSaber_T_Player`) → `.WeaponID`
`Weapon_DSaber_P_wep_50_Cannon`.
🧪 The controls, both run in the same loop: **0 / 59** distinct `PlayerWeapon`
values are a `Weapon.ID`; **59 / 59** are a `Turret_NNN` slot id; the full
chain lands on a `Weapon.ID` **59 / 59**. `WingmanWeapon` resolves
identically. The `WEAPONS` roster's 59 = 55 item names + 4 empty-slot
sentinels (`No_Equipment`, `NullWeapon_Arm1/2/3`).
🔑 **Wingmen fly a cheaper gun.** Following the same 59 slots across craft
variants: the `_Player` tables give each item **59 distinct** weapon records
(`Weapon_DSaber_P_wep_NN_*`); the AI tables (`…DeltaSaber_T`, `_W`, `_EX5`)
collapse all 59 onto **10** generic classes (`Weapon_TCAF_DeltaSaber_Beam`,
`…_Gun`, …). That is most of the 131: 59 player + 10 AI-Saber + ship/ADAN
turret guns.
**Upgrades yesterday's 🟡** — the "hardpoint catalogue" reading of the
player craft's 63 `Turret_NNN` slots is now ADOPTED, proved from an
independent file. ⚠️ And it corrects a number in that entry: the "10 distinct
`WeaponID`s" was measured on the **AI** variant; `_Player` has 59.
An item record also carries `Model`/`Package`/`SilhouetteModel` (the meshes
the corpus decodes), `Points` + `Stage` (the develop economy
`arsenal-develop-economy.md` recovered from the screen side), the Hangar stat
bars `Power`/`Range`/`Size`/`Speed`/`Weight`, and `Type` = a slot class.
🟡 Not settled: `Type` and the per-slot allow-lists (`STANDARD_ARM1/2/3`,
`PlayerSET_*`, `ExSET_*`, `SNNSET_*` — records whose only field is `Type`);
the **wingman loadout** records (`Bird1-Sandra`, `Rhino1-Katana`, … with
`Arm1`/`Arm2`/`Arm3`/`Nose`/`UnitID`) — named here, not opened; empty
`Dependency`/`MissionObjective`; which weapons the 26 never-on-a-turret ones
are.
***(2026-08-27) THE DESTRUCTIBLE-SUBSYSTEM MODEL — a unit's sub-records,
opened at last. 835 turrets, and every turret's weapon resolves.**
[structures/unit-substructure-records](structures/unit-substructure-records.md),
artefact `data/unit-substructures.txt`, regenerator
`tools/re-capture/unit_substructures.py`.
The corpus has *named* these since `unit-struct-runtime.md` but never opened
them. Per unit table: `Turret_NNN` **835** records (max 63 on one unit),
`ShieldGenerator_NNN` 46, `Thruster_NNN` 38, `Hatch_NNN` 26, `Bridge_NNN` 25,
plus one each of `Shield`/`Mass`/`SE`/`Explosion`/`StructureCount` and
`NS_Body` on 68 of 114.
🔑 **`Turret`/`ShieldGenerator`/`Thruster`/`Hatch`/`Bridge` are ONE record
shape** — a shared 19-field destructible-part base (`ID`, `Name`,
`ParentStructureID`, **`Frame`** = a mesh NODE name, `NomalModel`,
`CollisionModel`, `Radius`, `HP`, `IsDestructible`, `IsShielded`,
`IsInvolved`, `IsRadarVisible`, `SpreadDamage`, damaged/destroy motion +
time, `Effect_Explosion`/`_Flare`/`_Paralyze`) with per-kind extras:
turrets add `WeaponID`, `AngularVelocity`, `YawLimit`,
`PitchLimit_Elevation`/`_Depression`, `CoverArea`, `IsAuto`, `HasBarrel` and
up to 80 `CannonModel_NNN`/`CannonFrame_NNN`; shield generators, thrusters
and bridges add **`PowerRatio`** (what the parent loses when the part dies);
hatches add `SquadronID`, `LoadedCount`, `MaxAvailableCount`,
`TakeoffInterval`**a carrier's launch bay**.
🧪 **Control 1**`StructureCount.<Kind>Count == #<Kind>_NNN records`:
**612 equal / 55 "0 declared, one blank placeholder" / 11 differ / 6 kind
absent**, over 684 comparisons. The 55 are a real sub-rule (a zero count
still emits exactly one record, blank in `Name` *and* `NomalModel` *and*
`Frame`, 55/55). All 11 exceptions are `Turret` and all are
`declared < records`; nine are the player DeltaSaber family (4 declared,
63 slots — 🟡 reads as a hardpoint catalogue, **not adopted**).
🧪 **Control 2** — **835 / 835 `Turret_NNN.WeaponID` values resolve to an
`ID` in the 131-record `Weapon` datasheet, zero unresolved**; 105 distinct
used, **26 weapons never mounted on a turret**. A hard cross-table link.
**Refuted in the same pass**: "the DeltaSaber's 59 non-NULL hardpoints are
the 59-name `WEAPONS` arsenal roster". The counts match exactly and the sets
**overlap in 0 values** — the roster is display names
(`Machiene_Cannon_MG1`), the hardpoints reference `Weapon.ID`
(`Weapon_TCAF_DeltaSaber_Gun`), and the 59 slots carry only **10 distinct**
weapon IDs. So the 59-of-131 question stays open, minus one wrong answer.
🟡 Not settled: `CoverArea` is a hex bit mask with unknown bits;
`Versatile_NNN` has only its `???` template row in this pak; the two `+1`
turret cases (`Elan_EX4`, `AAFrigate_EX4`).
***(2026-08-27) CORRECTION — `Generic` is a per-FILE HEADER record, not a
table. Only **114** of the 394 are unit datasheets.**
[structures/unit-datasheet-static](structures/unit-datasheet-static.md),
artefact `data/generic-record-partition.txt`, tool
`tools/re-capture/generic_partition.py`.
Answers "178 distinct `Generic` IDs vs 394 records per pak — what are the
extra records?" from the entry below. **Every IDXD file carries exactly one
`Generic` record**, and its schema is set by what kind of file it is.
Partitioned by field set, identically in all six `GP_MAIN_GAME_*.pak`:
**114** have `HP` = unit datasheets; **204** are `{Count}` only = dialogue
files; **64** are `{ID, Name, SideID, Unique}` = characters (36 `TCAF` +
28 `ADAN`, each with a `Faces` sibling); **10** are `{EnumAsteroidGroup}`;
2 are degenerate (one empty, one naming `eff_n0071`). 204+114+64+10+2 = 394,
and **178 distinct `Generic.ID` = 114 unit + 64 character** — the only two
shapes that carry an `ID`.
🧪 **Positive control in the same loop**: for the 204 dialogue headers,
`Count` equals the number of `Message_NNN` siblings **204 / 204**, zero
mismatches — so "header record describing its own file" is not a story fitted
to the counts.
🔑 Cross-check from the other side: `Maneuver` = 114, `Effect` = 114, the
carrying-entry sets are **identical**, every unit `Generic` has a `Maneuver`
sibling, and `Generic.Type` splits **43 `Craft` + 71 `Vessel`** — the same
43/71/114 `unit-struct-runtime.md` reached by reading live guest memory.
🟡 Still open from the entry below: no field-by-field reconciliation of the
static sheet against the runtime capture (much of it is already done in
`unit-struct-runtime.md`); why the arsenal roster lists 59 of 131 weapons;
what `Maneuver`'s `AA_`/`AV_` prefixes select.
* ✅✅ **(2026-08-27) THE STATIC UNIT DATASHEET **AND** THE AI FLIGHT MODEL —
`Generic` (394), `Maneuver` (114), `Effect` (114).
[structures/unit-datasheet-static](structures/unit-datasheet-static.md),
artefact `data/unit-datasheet-static.txt`.**
Same technique as the weapon tables: field names are literal, so a field-shape
search over 190 782 records finds the carriers. **`Generic` is the unit
datasheet** — 110 field names, 178 distinct IDs: `HP 6400.0`, `ShieldRatio`,
`DefencePoint`, `AttackCraftPoint`/`AttackVesselPoint`, `ResistanceToShell`/
`ToExplosion`/`ToOptics`/`ToPlayer`/`Paralyze`, `Size_X/Y/Z`+`Size_Radius`,
**`RadarRange 10000.0`**, **`FCSRange 8500.0`**, `MountedFCS`,
`MountedShieldGenerator`, `ScorePoint`/`DamageScore`/`MassScore`, `HQRatio`,
`ThrusterRatio`, `NozzleCount` with per-nozzle jet/afterburner/reverse/contrail
FX, and **`Model rou_e006`** — which ties the datasheet straight to the mesh
names the corpus already decodes. In the corpus's **1 metre** world unit that is
a 10 km radar and a 12 × 5 × 30 m craft.
🔑 **`Maneuver` is the AI FLIGHT MODEL** — the richest find for the port:
`MaximumVelocity 1200`, `MinimumVelocity 100`, `CruisingVelocity 700`,
`Acceleration 600`, `Deceleration 400`, `SideThrustAcceleration 1000`,
`Turn_AngularVelocity 180`, `MaximumBank_Normal 60`, yaw/roll/pitch
`DragFactor 3.0` + `DragFactorThreshold 0.5`, `ArterBurner_Vc 2.5`/`_Acc 2.0`
(the game's own spelling), `ReverseThrust_Vc 0.5`, `UsingChaffRatio 0.5`,
`HomingResistAdjustment 0.8` — plus **nine named manoeuvres** each with a Yes/No
switch and its own timing/ratio/length bounds (`TurnAttack`, `TurnAway`,
`BarrelRoll`, `SideRoll`, `Slalom`, `Through`, `HoldPosition`, `SolidCutoff`,
`BoostAway`) and `AA_*`/`AV_*` pitch/yaw/roll min-max pairs. This is the static
source for what `flight-speed-law.md` and `flight-controls-runtime.md` measured
at runtime.
🟡 `AA_`/`AV_` pairing with `AttackCraftPoint`/`AttackVesselPoint` suggests
anti-air vs anti-vessel rate limits — **a reading, not adopted**.
🔴 **`HP_CLASS`/`HP_ID` are NOT ship stats.** They sit on records named for
wingmen and ship classes (`CARL`, `ELLEN`, `SANDRA`, `ACROPOLIS`,
`Carrier_TCAF1`, `NP_Facility`, …), 120144 instances each, alongside
`HPGauge`/`HPGaugeSub`/`RadarCursorType` on the `ObjectiveMarker_*` and
`TutorialMarker_*` records — a **HUD gauge binding table**.
🟡 Not settled: no field-by-field reconciliation against the runtime captures;
and 178 distinct `Generic` IDs against 394 records per pak is unexplained.
## ✅✅ SOLVED — the mission freeze was a modal sign-in dialog (2026-08-26)
`XamShowSigninUI` opens a modal dialog and `xeXamDispatchDialog` blocks the
calling guest thread on `fence.Wait()` until it is dismissed — which nothing in a
scripted run ever does. **Fix: pass `--logged_profile_slot_0_xuid=…`.** Same
route, one variable: screen id goes from stuck at **4 forever** to
**4 → 5 → 6 → 8 → 9 → 10**, allocation failures 1 → **0**, guest throws 1 → **0**,
guest churn 0.000 % → **1.006 %**.
It hid for so long because `--log_mask=13` (used by every script here)
**disables kernel logging**, the one category that names the dialog.
See [`mission-freeze-signin-dialog.md`](mission-freeze-signin-dialog.md).
## 🔴 ~~BLOCKER — the mission freeze is a software-rendering hang~~ WITHDRAWN (2026-08-26)
> **Withdrawn the same day.** Driving the null backend blind to the *same* screen
> shows it freezes identically — 0.000 % guest churn, `Main XThread` futex-blocked
> at 0 ms CPU, same 128 MB refusal and throw. The earlier control compared a
> null-GPU run still in the menus against a lavapipe run at the freeze, i.e. two
> different game states, and reported the difference as a backend effect. **The
> hardware-Vulkan blocker below does not apply** — rendering is not what is
> blocking this. The text is kept for the reasoning.
## 🔴 BLOCKER — the mission freeze is a software-rendering hang (2026-08-26)
The freeze that blocks every dynamic measurement is in the **host rendering
path**, proven by control: with `--gpu=null` the guest runs at ~1 core through
the same content load that hangs every rendered run, with zero allocation
failures, while under `lavapipe` `Main XThread` sits at 0 ms CPU in
`futex_do_wait` and nine `llvmpipe` threads saturate for six minutes without
producing a frame.
**Deciding whether `lavapipe` is deadlocked or merely pathologically slow needs
hardware Vulkan, which this container does not have.** That is a stated container
limit, so it is recorded here rather than worked around.
🟡 **Workaround with a gap:** `--gpu=null` gives a live guest, and everything the
backlog needs is read from guest memory, not pixels. What is missing is
navigation — menu-walking is screenshot-driven today, and blind Ⓐ presses reached
no mission (`DEF_VTABLE` scan 0/0). **First step: drive navigation from guest
memory instead of the screen.**
See [`mission-freeze-heap-exhaustion.md`](mission-freeze-heap-exhaustion.md).
## 🧰 The boot path — three failures that look like the emulator and are not (2026-08-26)
***`--audio` prevents boot outright.** `run-canary`'s own header says the flag
is not a cvar in this tree, and that an unknown argument makes xenia call
`ShowSimpleMessageBox` from `ParseLaunchArguments` *before* logging starts,
blocking forever. Measured anyway, because this corpus also holds runs that
passed it and booted fine: **3 trials each, in both orders**
| | log written |
|---|---|
| `run-canary --apu=sdl --log_mask=13` | **67 565 bytes** |
| `run-canary --audio --apu=sdl --log_mask=13` | **209 bytes** |
209 bytes is `run-canary`'s own banner and not one line from xenia. The order
was reversed deliberately — this corpus already has a lesson that an A/B from
run order is noise.
🔴 **Eight scripts on branch `auto/idxd-unnamed-keys` still pass it**:
`launch_mission.sh`, `boot_menu.sh`, `fly_stage.sh`, `challenge_probe.sh`,
`grab_tutorial.sh`, `find_partslot.sh`, `tutorial_capture.sh`, `nav_probe.sh`.
`main` and this branch are clean, which reconciles August's successes with
today's failures. Left unfixed here: they are another branch's files.
* 🔴 **`launch_mission.sh`'s `skip_intro` deadlocks on the attract loop.** It
classifies the screen as `movie` and declines to tap ("tapping breaks the
title"), and waited out **600 s of unbroken `movie` verdicts** before timing
out. `nav_to_flight.sh`, against the *same running emulator*, reached the main
menu in **12 s** and flight in **2 min 20 s** by tapping A at the title. The
"wait it out" premise is wrong — the attract loop does not end on its own.
**Use `nav_to_flight.sh` on an already-running canary.**
* ⚠️ **`EMULATOR GONE at ~40 s` is this project's own Stop hook**, which
`kill -9`s every `xenia_canary` when a Claude turn ends. Already recorded
further down this file — and rediscovered the hard way over three boots because
I did not search for it first. The rule is the one `fly_session.sh`'s header
states: **an emulator session must be one task; nothing may depend on surviving
between tool calls.** Sequential tool calls *within* one turn are fine; it is
ending the turn that kills it.
**The world-unit measurement is DONE — one world unit is one metre.** Two
independent routes: the `mapmesh_box_500km` collision hull is a cube of exactly
500 000.0 units per axis (and the other box mesh exactly 100 000.0), and
[`flight-speed-law.md`](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`](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`](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`](world-unit-attempt-2026-08-26.md). Getting
there needs two steps no script had: `START` skips the post-take-off cutscene,
and a modal *"tell you your objective?"* dialog **dims the whole frame** (so the
classifier reads `other` and liveness looks like a stall) until Ⓑ/NO answers it.
Two tool defects were measured on the way, and both must be fixed before the
measurement will work:
* `entities2.py`'s `ENT_VA_LO/HI` = `0xBD000000``0xBE000000` contains **no live
instance** (they sit at `0xBC384CE0``0xBC9BAC20`); it covers the
**definitions** instead. Rescoped to the instance region, `find_delta`'s
`±0x400` radius yields **zero** votes.
* `gworld.py`'s `WINDOW = 0x600` is too small — no position-like triple moves in
the first `0x600` bytes of any of the 42 instances. `0x4000` finds one at
`+0x29d0` (🟡 one offset, one instance, one sample).
❔ What is still owed is the unit itself: lock a target so the HUD prints a
numeric range, then read that range and both position triples in the same second.
❌ Do not use speed as the corroboration — 116.6 units per 0.6 s wall-clock
against the HUD's `350` looks like a free answer, but the emulator is not
real-time under lavapipe, so the denominator is unknown.
---
## ❌ WITHDRAWN (2026-08-26, same day) — the Japanese voice banks are NOT a separate format
The `sound.pak` census (see
[`structures/sound-pak-contents.md`](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`](structures/slb-data-offset.md).
**Closed 2026-08-26**: the 2 unaccounted TOC entries are `static.slb` and
`Pj_Silph.xgs`, named by the same table's `BANK_SE` and `SETTINGS` records —
`sound.pak` is now 9 519 of 9 519 accounted for.
**Closed**: the 55 "early `RIFF`" English banks are not an anomaly — all 55 sit
at *exactly* 1392 behind a zero-filled header, i.e. a zero-length leading
region, which both the old code and the new derivation already handle.
**Closed 2026-08-26**: the 28 offset-scan ties. A bank's `seek` chunk sits on a
packet boundary, so `seek_pos % 2048` is a second and structural derivation of
the offset (99.97 % on the labelled set). It resolves 26 of the 28 correctly and
none wrongly; the combined rule is 99.95 %.
**Both remaining questions closed the same day**, and both by correcting an
error of mine rather than by finding something new:
***"69.8 % of banks declare more `data` than they store"** — the declared
sizes are **honest**. Every `RIFF`-bearing entry on the disc carries `seek`
magic at exactly `data_at + declared_size` with `packet_count × 2048 ==
declared`, **7 620 / 7 620**. The TOC window is simply not the wave boundary.
My supporting case, `VOICE_TCAF_608`, was not truncated either: it is **stereo**
(170 of 8 021 banks are) and I decoded it as mono, which yields one frame. Read
correctly it is 33.96 s, agreeing with both length signals inside the bank.
***"the offset takes four values by directory"** — the *cause* was wrong.
`X = (cumulative start of the .pNN segment) mod 2048`; the per-directory table
was a correlation, because directories cluster into segments. There is no
header: those bytes are the previous bank's audio.
Both are written up in
[`structures/slb-data-offset.md`](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`](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`](structures/cutscene-message-table.md)),
and which bank voices a line (`tables.pak`'s cue index — see
[`structures/sound-cue-table.md`](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`](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`](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`](structures/ui-paint-order-key.md).
**Wired into the compositor (2026-08-19)** and regression-checked. `compose`
sorts by the key for every build except the two whose measured order is hard
coded. It reorders **341 of 965 builds**, and a disc-gated test asserts every
composite's draw list is strictly increasing in `(key, declaration index)`.
Against the two screens the corpus had already verified against the running game
— the tutorial PAUSE menu and the title main menu — the new order changes 3.8 %
and 1.1 % of pixels, max delta 45/255, **with no layout change**: only blends
where translucent sprites overlap.
**Still open:**
* 🟡 Which order is more faithful on those two verified screens. The difference
is too small to decide against the committed side-by-side oracle and no fresh
framebuffer capture of either exists. First thing to check if one is taken.
* ❔ The tie-break. Two groups share a key and the game paints them in an order
that is not declaration order; the compositor keeps declaration order there.
* ❔ What the field's bits mean — `0x8000`/`0x80a0`/`0xa110` look like flag words
with a layer in some bits, not a plain depth. Sorting the whole word works on
both measured screens; which bits carry the layer is unknown.
**Censused 2026-08-26** over **all 21 184** disc sprites
(`tools/re-capture/paint_key_census.py`): ✅ the field is a **`u16` at `+0x0A`**
(upper half zero **21 184/21 184**) and ✅ an **enumeration — 216 values**.
🔴 **"the keys are pak-local" is 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
a `T8aD` magic, but sprites are usually **`RATC` children** — it saw 4 525 of
21 184 sprites, 45 of 216 keys, and **not `GP_TITLE.pak` at all**, the pak both
measured screens come from. It reported "45 values" and "pak-local", both wrong.
A separate earlier slip on the same page counted the six *language* copies of
`GP_MAIN_GAME_2D` as six paks. Same shape each time: a statistic computed over
an unverified sampling frame.
* ~~❔ A third measured permutation, to promote "holds on two" to a rule.~~ **Answered inside this same entry** — a third, fourth and fifth screen were measured, the last from `GP_SAVE_LOAD`, i.e. outside `GP_TITLE.pak`. The
cheapest is a screen whose object is resident at the same time as the title's.
* ❔ 341 builds now composite in an order no capture has checked.
## ✅ SOLVED (2026-08-19) — `_eff` glows were being dropped as focused states
`compose` skips focused-state records, and the flag matched a trailing `f` in
the name. `_eff` — this UI's word for a glow layer — ends in one. 2 458 elements
matched; **54** have the base element they would be the focused version of, and
the other 2 404 across 864 bundles are glows. Requiring the pair recovers 587 of
them in composable builds; `GP_OPTIONS` went from two floating brackets to an
actual window. The `opt ` link was tried as a replacement and **refuted** — 221
targets, 2 suffix-matches, and the targets include `pjnet_bg.rat`.
See [`structures/ui-focus-and-effect-elements.md`](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`](structures/ui-composable-bundles.md).
**Opened by those two:**
***`.prm` primitives are decoded** (2026-08-19). Untextured full-screen
colour quads: 0 of 369 has a payload child, `kind & 0x10``.prm` with zero
exceptions in either direction, 361/369 are exactly 1280×720 at 100 % in the
corner, and the fill colour is the keyframe's `fade` ARGB — mostly black at
some alpha, i.e. the fade-to-black / dim / flash layers.
See [`structures/ui-prm-primitives.md`](structures/ui-prm-primitives.md).
**Still not composited**, for the reason below.
***`Element::rest()` fixed** (2026-08-19): the resting pose is the **hold**
the longest run of consecutive keyframes with an identical pose — not the
longest gap. A keyframe is the start of a ramp toward the next one, so a long
gap means the screen spends it *arriving at* the far end. Verified against the
title framebuffer capture by edge correlation: plateau **0.4597 at shift
(0,0)**, old rule 0.1511 and only after a (+3,+8) shift. Fixes six title
elements that rested invisible and the fade quad that rested opaque black.
See [`structures/ui-resting-pose.md`](structures/ui-resting-pose.md) and
`tools/re-capture/align_to_capture.py`.
***The keyframe `fade` alpha is applied** (2026-08-19). ARGB, multiplied on
top of `tint`. Title composite vs the running-game capture: **0.4597 → 0.9538**
edge correlation at zero shift. No-op on 4 060 of 5 200 sprite elements, hides
687 transient HUD indicators, blanks **zero** builds. It also exposed a defect
in the resting rule — a keyframe group carries the screen's *exit* animation
too, and the tie-break was grabbing it, which erased the word PAUSE; a run
ending on the last keyframe is now excluded.
See [`structures/ui-resting-pose.md`](structures/ui-resting-pose.md).
* 🟡 **The `.prm` quads are drawn, opt-in** (2026-08-19).
`ComposeOptions::include_primitives` / `screen render --primitives`. On the
title — the one screen with ground truth — it takes mean luminance from **+18 %
to 1.3 %** of the capture (76.30 → 63.72 vs 64.58) and mean abs diff 16.07 →
13.08. Off by default because of the item below.
* 🟡 **Where a primitive paints — not in the file; measured and tabled**
(2026-08-19). 🔴 Refuted twice over: the declaration entry's four unread words
are **constant** (`+28`=0, `+36`=0xffffffff, `+56`=0, `+44` a button ordinal),
and the bundle carries **no data at all** for a primitive — the menu build
declares three and has zero RATC children for any of them. The layer comes
from the game's code. ✅ But it is consistent: `pteff02.prm` implies a key in
**(0x8010, 0x8040) on both** screens it appears on, `pteff00.prm` past the
maximum on both, `palogo_eff0.prm` below the minimum. `implied_layer_key`
records those, and `derived_paint_order` now reproduces the **layer-key
sequence of all three measured orders**, primitives included (element-for-
element on 4 of 5 bundle instances; the title differs only inside tied groups).
**Still open:** primitives whose position has never been measured —
`pzeff00.prm` and `pceff00.prm` are what wipe the 36 builds, which is why
`include_primitives` stays off by default. A capture of any screen carrying one
would close it.
See [`structures/ui-prm-primitives.md`](structures/ui-prm-primitives.md).
* 🟡 **The tie-break — refuted six ways, and its cost measured** (2026-08-19).
Elements sharing a layer key: on the **menu and splash** every tied group comes
out in declaration order, which the stable sort already gives. The **title** is
the only screen that discriminates and nothing predicts it (`0x8083` ×5 paints
`eff1, eff2, eff5, eff3, eff4`). Refuted: declaration order, RATC child order,
first keyframe time, resting time, resting X/Y, and `T8aD` header words `+00`
`+04` `+0c` `+10`. RATC child order is a *strict improvement* (7 misplaced
positions instead of 9, and it recovers the logo grouping) and is exact on the
other two screens — **not adopted**, because on the one screen that can tell
them apart it is still wrong.
**What it costs, exactly:** of 3 disagreeing pairs of drawn elements across all
three screens, 2 share opaque pixels — `ptlogo_back2eff5` vs `eff3` (22 568 px)
and vs `eff4` (32 395 px). The residual is one element's blend on one screen,
and it is pinned by a test. The third pair (`ptlogo2` vs `ptlogo_tm`) overlaps
by bounding box but shares no opaque pixel; a box test called it a defect and
the alpha says otherwise.
**Fourth and fifth screens measured (2026-08-19)**, from `GP_SAVE_LOAD`,
reachable now that the Canary threading fix makes the menu dependable. The
9-element slot-list header is **EXACT** under the derived rule — two tied
groups both in declaration order, unkeyed `.prm` last — and it is the first
screen outside `GP_TITLE.pak`, so it *confirms* the rule rather than being
fitted to it. The 13-element save/load frame differs in exactly the two known
ways: unkeyed `pfbase.tbm` backgrounds paint **first** (now covered by
`implied_layer_key`), and the `0xb100` group of four paints `10,11,8,12`.
🔴 **Refuted: the tie-break is not `kind`.** "Descending kind" reproduces
`10,11,8,12` exactly but fails both title groups. Seven candidates refuted now. 🔴 **Attempted 2026-08-19 and blocked:** advancing
past the title is intermittent — **1 success in 3 attempts**, same binary,
same profile, same procedure. ✅ **And now diagnosed one layer deeper:** the
title *does* act on Ⓐ — the press spawns a slot-`(1F)` loader thread (exactly
once per run, at the keydown, never in a run that got no press). In the
successful boot that thread immediately reads six paths from the on-disc cache
and the menu appears; in a failed boot it starts and issues **no file I/O
ever**. So "the title ignores Ⓐ" is **withdrawn** — the loader stalls.
🔴 Refuted as the cause: the cache-flush crash. All four of today's runs have
**zero** `GUEST-THROW`, `CRASH DUMP` and `Access Violation`; the guest stays
alive and polling.
**And now measured to the bottom:** with kernel logging finally on
(`LOG_MASK=12 LOG_LEVEL=3` — the scripts' `log_mask=13` had Kernel *disabled*,
which is why no boot log ever held a kernel call), a captured failure shows the
handler doing everything right — `XamUserGetXUID`, `NtCreateEvent`,
`ExCreateThread(entry=0x821748F0, CREATE_SUSPENDED)`, `NtResumeThread` — and
the thread then **never executing**: zero kernel calls of its own, and
**`00:00:00` host CPU time** while the process runs at 546 %. A spinning thread
burns CPU; this one never ran. A lost resume is a race, which is the first
explanation that fits the ~1-in-3 success rate.
**LOCATED AND FIXED** (canary `a60fe7d11`): `threading_posix.cc` publishes a
suspended thread's `state_` and its `suspend_count_` in **two separate lock
scopes**, and `Resume()` waits only for `state_` before testing
`if (suspend_count_ == 0) return false`. A resumer in that gap drops the
resume; the thread then waits on the count forever. The Linux `XThread::Resume`
discards the `false`, so the guest saw success. Fixed by publishing both under
one lock and waiting without releasing it. On the first clean boot after, the
loader thread is the **caller** on 20 kernel-call lines with 4 `ResolvePath`
reads — every failure before had **zero** of both.
🟡 **Still to show:** that boots now reach the menu *reliably*. The post-fix
boot is confounded — `skip_intro.sh`'s title test has been wrong twice (an
absolute pixel against the wrong surface size, then `screen_id.py` matching the
SQUARE ENIX logo). Now `tools/re-capture/is_title.py` counts the green Ⓐ glyph:
0 px on the logo, 1520 on a real title. A before/after reliability count over
several boots is the remaining work.
See [`canary-scripted-input-traps.md`](canary-scripted-input-traps.md).
***Blend mode.** Everything is straight alpha-over. The near-white flash
quads (`0xf0ffffff`) and coloured ones (`0x60ff0000`) may be additive. The
title capture cannot separate the two — its resting elements are all
`0xffffff`. A screen with a coloured primitive, captured, would.
* 🔴 **What marks a focused state in the file — NOT the declaration entry**
(2026-08-24). Swept disc-wide and asserted: **54** name-paired focused/base
pairs, **all 54** with identical `kind` (all `0x0`), **no** bit ever set on the
focused entry and clear on its base, and the only words of the 60-byte entry
that ever differ are **`+48`/`+52`, the pivot**. The naming pairing is not
standing in for a field — there is no field.
❔ Still open: the `.rat` record, the RATC child stream, or the game's code.
See [`structures/ui-rat-layout.md`](structures/ui-rat-layout.md).
* 🔴 **What makes a bundle a screen rather than a fragment — not the header**
(2026-08-24). Swept over all **2 859** composable bundles: **no bit** of the
flags word at `+0x10` labels a screen (best is bit 13 at **44 %** full-screen
against a **12.8 %** base; the commonest bit is set on **91 %** of everything).
The population really is mostly fragments — element counts min 1, **median 2**,
p95 23, max 56, and only **365** carry a full-screen element — so the
separation is *shape*, or which bundle references which, and the PAK cannot
answer the latter directly because its entries are name-hashed.
**By-product:** the header is not dead space. `+0x18`/`+0x1c` are the
**design resolution** (1280/720 on 98.7 %, asserted), and ✅ `+0x08` is the
**animation length** — checked against the keyframe times, which it bounds in
**2 313 of 2 313** bundles and is attained by **444**, with the ratio peaking
at 1.0 rather than near zero (that histogram is what rules out a vacuous
bound). 🟡 `+0x04` (`0x3C0000`/`0x1E0000` = 60.0/30.0 in 16.16) stays amber:
the only supporting evidence is that the twelve 30.0 bundles cap at `+0x08`=30
while the 2 843 60.0 ones reach 1 440. ✅ `+0x0c` is **two u16s forming an ordered
interval** — `high < low` in **2 985/2 985**, both bounded by the animation
length; ✅ it is **authored, not the keyframe min/max** — that
reading holds in 6 of 2 985 (0.2 %), and the apparent 34 % match on `high` is a
coincidence of zeros. The window is narrow, a median 2 % of the keyframe span.
🟡 what the window means stays open. `+0x10` is ✅ a zero `u16` plus a 16-bit flag word at
`+0x12` (high half zero in 2 985/2 985); ❔ the bit meanings, with four
measurable predicates now excluded.
See [`structures/ui-rat-layout.md`](structures/ui-rat-layout.md).
* 🟡 **What `opt ` links — a record→record reference** (2026-08-24, measured
disc-wide and asserted). All **1 467** links reachable from a declaration table
resolve to a **RATC child of their own bundle**, all are `.rat → .rat`, none
dangle, none self-link. **1 076 (73 %)** are the `<stem>f` focus pattern; the
rest are **chains** between effect records (`px_bunk_eff01 → pjex_eff →
pjex_eff07`), which is also why only 227 targets are declared elements — the
middle of a chain is, the end is not. So focus is the commonest *use*, not the
meaning. ⚠️ Coverage: 18 718 raw `opt ` tags exist against 1 467 classified —
`opt_link` reads the first tag of a declared element's record, so ~92 % of
occurrences sit deeper in the chains and are untested.
The investigation that got here follows, kept in full because most of it is
refutations that were worth the cost.
## Where the wave / spawn scheduler lives — the counter is not a roster
**Raised by the user 2026-08-24, and it reframes the whole `REMAINING OB` line of
work.** A mission does **not** have a fixed enemy count: the number rises as
waves arrive and falls as they are destroyed. So the thing to find is not a list
of objectives but an **algorithm with parameters** — what spawns, where, when,
and on what trigger. That also explains every negative so far: a per-entity flag
search cannot find a *schedule*.
**Places searched so far:**
***`REGN` is EXCLUDED as the wave scheduler (2026-08-26)** — it is a
tetrahedral navigation mesh (vertices, faces with planes and adjacency,
tetrahedra with portal costs, a grid indexing which tets fall in each cell).
Every section is accounted for; there is no time field, no unit reference and
no trigger anywhere in it. It was chased here because "a scheduler would be
indexed by a grid" — a guess from shape, and the shape was pathfinding.
***`hidden/MiscBin.pak` — new to the corpus.** 11 `REGN` + 11 `MCOL` objects,
none name-resolved. `REGN`'s header is now decoded: a per-map **uniform spatial
grid** (bbox, cell size, 5³ or 10³ cells), self-checked 11/11. Its four data
sections are unread — the 49 KB object is the cheapest way in.
**Section 3 decoded**: one 8-byte `(count, offset)` record per cell followed
by 32-byte payload records, payload at `align16(index end)`**11 of 11**, one
record per occupied cell. ✅ **And the container is a serialised object graph**:
every object carries a **`POF0` pointer-fixup table** at exactly
`header[0x04] + 16` (11 of 11), which is why the internal offsets are absolute
file offsets. 🔴 **Two payload readings refuted** by generalising from the one
object they were fitted to (a "bounding-sphere radius" float, and "leaf arrays
of `count × 4` bytes"). The payload is float-dominated and otherwise ❔.
**2026-08-26: the other three sections have strides** — 12 / 96 / 48 bytes,
with `counts[0..2]` as their record counts (section 1's remainder is exactly 0
and section 2's exactly 96, in 11/11). **Section 0 is a point list**
(13 467/13 467 inside the bbox) and **section 2 is a plane list** — unit normal,
signed distance, and a point, with `n·p + d = 0` to float round-off in
**133 573/133 573**. Section 1 (96 B) still ❔ in meaning, though its slot
regions are censused. My static coupling search found nothing above chance —
every index-shaped field followed into every section, against controls — and
I concluded it needed the PE code rather than more correlation.
✅✅ **SOLVED on branch `auto/regn-reader` (2026-08-26).** `REGN` is a
**tetrahedral navigation mesh**. The route in was the **`POF0` fixup table**:
it is the loader's own list of which words are pointers, so nothing had to be
guessed. There are **six** sections, and the chain is
`position → cell → 32-byte item → tet refs → tetrahedron`; section 2 is a
**face** carrying a plane plus its 3 vertices and the two tetrahedra either
side. Checked with controls: each face passes through exactly 3 of its tet's
4 vertices, **253 722/253 722**, against a 0.072.2 % random-face control;
portal cost equals the distance between face centroids, **380 460/380 460**.
⚠️ **The base is `chunk + 0x10`, and my offsets on this page were 16 bytes
early** — the plane fields happened to land on the same bytes, so that
arithmetic survives, but the record boundary moves. My "13 467/13 467 points
inside the bbox" was **not evidence**: a shift inside a homogeneous `f32`
array yields other floats from the same array, so the test passes either way.
See [`structures/regn-map-grid.md`](structures/regn-map-grid.md).
❔ Still open there: the **runtime consumer** (not reached; a `float4`-aligned
header read with VMX loads leaves no displacement signature to search for).
**`MCOL` is the collision sibling, and its broad phase is decoded
(2026-08-26).** Same container, same 11 maps, same cell size. Chain:
`position → cell → A record → B record {count, u16[n]} → 16-byte bounding
sphere`. The `0x5C` block is **stride 16 `{centre f32[3], radius f32}`**, not
the 12-byte points I had assumed — `len(0x5C)` is not even a multiple of 12 in
5 of 11 objects, and `max u16 == len/16 1` in **11/11**. Powered check: the
referenced sphere reaches the cell that reached it, **18 559/18 577 = 99.90 %**
against a 12.02 % random-sphere control, and ablating either the centre or the
radius costs most of the signal. Reproduce with
`tools/re-capture/mcol_probe.py verify`.
⚠️ The stale ≈**0.75×** ratio I had recorded as "too consistent to be
coincidence" was **12/16** — my own wrong stride, not a fact about the data.
✅✅ **`MCOL` SOLVED the same day — it is a closed triangle collision mesh.**
The `0x50` word is **two `u16` counts** (vertices, triangles), which gives the
last two blocks their stride: `len(0x54) == align16(12·nv)` and
`len(0x58) == align16(6·nt)` in **11/11**, and `nt` == the sphere count in
**11/11**. Sphere *i* is the *tight* bounding sphere of triangle *i*
**4 768/4 768**, `max‖vc‖/r` median 0.99990 (a fixed 1.0001 epsilon), against
a 1.32 % random-triangle control — and the mesh is **watertight**: every edge
shared by exactly two triangles, **7 152/7 152**, zero degenerates, zero
orphan vertices. The two smallest objects are 8 vertices / 12 triangles = the
map's bounding cube. The cell lists are a **correct broad phase**: only **3**
overlapping triangles in 18 577 entries are missing, and the 730 conservative
extras place the builder's test between exact-SAT and AABB — which also
explains the 18 "sphere misses" above as that same margin.
`tools/re-capture/mcol_probe.py verify` reproduces it
([`data/mcol-verify.txt`](data/mcol-verify.txt)); `mcol_probe.py obj` exports
any object as a Wavefront OBJ.
✅✅ **And all 40 `MiscBin` entries are now name-resolved (2026-08-26).** The
names are the `MapPath` / `MapMesh` / `CollisionMeshes` values of the per-stage
`StageResource` object (IDXD schema `3c9ae32e`, in every
`GP_MAIN_GAME_<lang>.pak`), each hashing with the ordinary pak `name_hash`
straight to a TOC entry — **40/40, no collisions**: 11 `<stem>.rgn`, 11
`<stem>.col`, and the 18 remaining blobs as `CollisionSet_S01…S16` /
`_Tutorial` / `_test.bin`. The `.pe` string table at 651 540 was the way in.
This **upgrades the pairing** from "matching bbox/cell-size distributions" to
an object-to-object link: a phase names one `.rgn` and one `.col`, and
**11/11** share a stem and agree exactly on bbox and cell size.
It also checks the format work from outside: `mapmesh_box_500km.col` is the
object decoded as 8 vertices / 12 triangles spanning exactly ±250 000, and its
name says 500 km — so **one world unit is one metre**. **70 of the 87 phases**
use that bare box; `_AsteroidVolume_` names the rest.
`tools/re-capture/miscbin_names.py <root> [--pairs]`
([`data/miscbin-names.txt`](data/miscbin-names.txt)).
**`CollisionSet_*.bin` decoded (2026-08-26) — the per-*object* library.**
All 18 are **byte-identical**, so the per-stage naming is nominal: one shared
1 675 148-byte library stored eighteen times. Record =
`{u32 size, u32 name_len, char name[], u32 nv, u32 nt, f32[3]×nv, u32[3]×nt}`
(note `u32` indices, where `MCOL` uses `u16`), next at `off + 8 + size`. The
walk **consumes the file exactly** over 158 variable-length records with the
size word predicted from the counts **158/158**, all indices in range
**158/158**, and 98.24 % of edges shared by two triangles (**147/158** fully
manifold). 158 meshes / 90 836 triangles: `rou_`/`mob_`/`rob_` per-part ship
proxies (`_bdy`/`_brg`/`_eng`/`_wep`/`_sld`) plus 46 stage asteroids.
⚠️ **This downgraded the "1 unit = 1 m" claim to 🟡** — it implies a craft the
tables call *small* is 133 m and the largest object 447 km (89 % of the arena).
**The `_cmesh`↔model link is now CONFIRMED** (upgraded from 🟡 the same day):
the right corpus was the **`GameResourceID`** field, not the `.xbg` manifests —
**108/112** ship/mob stems are prefixed by one of the 480 resource ids, against
a **0/112** shuffled-character control, and correctly **0/46** on asteroids. A
`CollisionSet` entry is `<GameResourceID>[_<part>]_cmesh`.
🟡 **The world unit needs a run — it is NOT blocked** (label corrected
2026-08-26). Sweeping every pak for a km-bearing name returns
`mapmesh_box_500km` and **nothing else** (162 refs, all that one pair), so no
*static* test can settle it; that is not the same as blocked, and 🔴 is for
what the container cannot do. The experiment: `findplayer.py` gives the player
and target position triples in world units, the HUD prints the distance between
them in the game's own units, and the ratio is the conversion. All of
`run-canary` / `pad.py` / `screenshot` / `findplayer.py` already exist.
`tools/re-capture/collisionset.py verify|list|obj`
([`data/collisionset.txt`](data/collisionset.txt)).
❔ Still open: no material/surface-type field exists in the record, and the
library is not indexed — name lookup from the unit tables is untraced.
The runtime consumer now has a
name — `CMapColliderBridge` in the `.pe` RTTI at 9 044 264 — but has not been
followed into the code.
See [`structures/mcol-collision.md`](structures/mcol-collision.md).
* 🔴 **`hidden/DefTables.pak` is NOT it** (checked 2026-08-24). The three
unnamed schemas are more **model/render** tables in the same vocabulary as the
named ones — `7e66225f` (283 objects) carries `RenderFrameModel`,
`FrameAnimLength1st`, `FrameAnimLoopLength`, `IsPlayerSE`; `634a80ae` (183)
adds `DissolveDistanceMin/Max`, `RootSEAttach`, `EnumNodes`; `a6d68fa1` (138)
has **no explicit fields at all** (defaults only).
* 🔴 **The 40 XML entries are XPR2 build manifests** with the developers' own
source tree — `machines\rou_e104\objects\*.xbg`, `…\images\*.bmp`,
`D3DFMT_DXT1_SRGB`. Tempting as a name source for the unresolved hashes, and it
**does not work**: of 82 names × 9 patterns, the only hits are 16 that were
already resolved under the known `LOD_Frame_*`/`LOD_Parts_*` convention, and
the raw source paths hash to **nothing** in any of the 16 514 TOC entries. The
on-disc keys are not the source paths.
* 🔴 **`STAGES = Static.slb` is a false lead.** It is a field of the *sound*
table; `.slb` is an XACT sound bank ([`structures/sound-slb.md`](structures/sound-slb.md)).
***FOUND — it is the `GP_MAIN_GAME_<lang>.pak` unnamed objects.**
Sweeping the 811 unnamed entries by schema turned up **schema `3c9ae32e`, the
per-stage definition record**: one per stage (`Stage_S01.xpr``Stage_S29.xpr`),
naming that stage's background, resource package, collision set, message set,
nameplates, `MapMesh`/`MapPath`, and — the point — its
**`EnumerateSquadron = UnitGroup_S<NN>.tbl`**.
`stage\UnitGroup_S02.tbl` (`0x019fd129`) is the Stage 02 roster: **112 records**,
112 squadron IDs (`TCN001`, `ADN101`, `ADT102`, …), and a field vocabulary of
`FormationID` / `AIID` / `SideID` / `Count` / **`DisableInterval`** plus the unit
model (`UN_e010_ADAN_Attacker_S`, matching our XBG7 names), the `MessageSet_*`,
and the pilot character. `DisableInterval` is the first direct evidence of the
timing knob. Same pass also settled `MapPath = test.rgn` → the `REGN` objects.
See [`structures/stage-definition-table.md`](structures/stage-definition-table.md).
🔴 Refuted along the way: the 16-byte record key is **not** the squadron
ID's name hash (0 of 112).
***`UnitGroup` is fully decoded** (2026-08-24). Container and field semantics
in [`structures/unit-group-table.md`](structures/unit-group-table.md), tool
`tools/re-capture/unitgroup.py`, Stage 02 dump committed at
[`data/unitgroup-s02.txt`](data/unitgroup-s02.txt). A squadron record is
`Count` member tuples — (unit model, message set, `n`, identity/nameplate) —
followed by five named fields `Count / SideID / AIID / FormationID /
DisableInterval`. Validated corpus-wide by two independent self-checks, each
**1160/1160** across all 28 stage tables: the `Count*4+5` length identity, and
agreement with the file's own `Enumerate_Squadrons` roster.
🔴 Two refutations recorded: the record key is **not** the squadron-ID name
hash, and an earlier "109/111" reading of the squadron-id string base was an
artefact of the uniform 7-byte id stride (it shifted every name by three).
***The rest of the mission-parameter layer is decoded** (2026-08-24) —
[`structures/stage-mission-tables.md`](structures/stage-mission-tables.md),
tool `tools/re-capture/stagetbl.py --follow S02`. The *real* stage record (the
one dumped earlier was the `_Test` template) splits a stage into **`Phase_1..3`**
blocks and names `Route_S<NN>.tbl`, `SUBObjectiveSettings_S<NN>.tbl`,
`AIParams_S<NN>.tbl`, `FormationSet_S<NN>.tbl`, `nameplate_S<NN>.tbl` and more.
**`Route_S<NN>.tbl` is the arrival schedule**: records named
`Route_<squadron>_p<phase><kind>` holding time-stamped keyframes
`(time, quat x4, pos x3)`, tying a `UnitGroup` squadron to a phase and a path —
self-checked `FrameCount*8+1` on **1449/1449** route records across 28 stages.
`AIParams` carries directly portable combat tuning (firing/guard/muster ranges
and 14 manoeuvre weights per profile). 🔴 Refuted: the 8-value frame is *not*
universal — `Formation_Fleet_01/02` use 136 and 4 values per frame.
* ✅🔴 **The arrival timetable is found; the entity-count proxy is refuted**
(2026-08-24) — [`mission-wave-arrivals.md`](mission-wave-arrivals.md). Route
records' first-keyframe time is the **arrival schedule**, measured from the
start of that route's *phase* (phase 2 has all 37 routes at t=0). Stage 02
phase 1: 25 at t=0, then 3/3/3/2/1 at t=90/120/170/210/240. 🔴 A live count of
`0x820af030` entity records is FLAT at 116 for 210 s — no arrivals visible.
**2026-08-26: the flat count is explained, not merely refuted.** Built-in 12
`activate_unit` returns immediately when the live object is NULL — it registers
an existing object and cannot spawn one, so arrivals are route positions, not
new records. Stage 02's roster totals 116, and **116 is the maximum across all
28 stages and unique to Stage 02**, so the match is not a common-number
coincidence. 🟡 still n=1 for the per-member identity. ❌ This cannot settle
timetable-vs-event — a position probe is needed, not a count.
**Settled 2026-08-26: `t` is SECONDS.** Consecutive route keyframes give an
implied speed; checked against the live-measured player ceiling (~1530 world
units/s), **0 of 1 104** pairs exceed it under seconds while 8994 % do under
frames at 30/60 fps. See [`mission-wave-arrivals.md`](mission-wave-arrivals.md).
* ✅🟡 **Motion-independent liveness probe works; `n` is probably craft-per-member**
(2026-08-24) — [`mission-liveness-probe.md`](mission-liveness-probe.md).
Enumerating by definition pointer instead of by motion removes the ±10 noise
(monotone 298→280). The hunting pilot **does** kill (hull crossing at t=57s).
🔴 "No births in 164 s" does NOT separate the wave models — everything is
pre-allocated, so an arrival must be a STATE CHANGE, not an allocation.
🔴 **`n` = craft-per-member is WITHDRAWN** (re-tested 2026-08-24): sites really
are entities 1:1 (all same-unit gaps ≥0x1000, hull plausible 298/298), so the
confound was not the explanation — and with it gone, `DeltaSaber_T`, `Player`
and `Acropolis` all come out at exactly **2×** `sum(n)`. An undershoot is
explainable by phases; an overshoot is not. `n` back to ❔. Formation slot count
also rejected (630 turret slots vs 214 live). ✅ Side result: `FormationSet`
`FrameCount` = slot count. 🔴 **A direct pointer link between them is REFUTED**
(2026-08-24, [`roster-to-craft-link.md`](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``0xbc9bc720` vs
`0xbdb2fd80``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 at `roster_base + 0x08`.**
The delta histogram spiked at `+0x08` with exactly 300 hits for 300 craft.
Verified on something a coincidence cannot survive — each side's unit type
resolved by a *different* chain (craft via def pointer `+0x130`, record via
`+0x04` name chain) — **agreeing 300/300, 0 disagreements**, fan-out `[(1,300)]`.
Fan-in: only **41 of 116** records have craft (2×24, 4×1, 8×4, 18×12 = 300).
⇒ **an arrival is craft appearing for a record that had none; a kill is that
count decreasing — both attributable to a named squadron**, hence to a route.
**Blocker downgraded (2026-08-24): the baseline DOES reproduce** — two more
runs give 116 roster records flat from t=0 (not a load race) with craft
declining 300→288 and 296→280, so **losses are observable**. Raw hits ==
distinct VAs (116 == 116), so the VA-aliasing explanation is 🔴 refuted too.
🟡 The single 42/170 run stays unexplained; rule adopted: **discard a run that
disagrees with 116 rather than interpreting it, and reproduce any finding in
≥2 runs.**
* ✅🔴 **Six runs, no arrival — plus an accidental control**
([`mission-arrival-watch.md`](mission-arrival-watch.md), 2026-08-24). Deployment
reproduces byte-for-byte (116 records, 300 craft, 41 deployed, strengths
2×24/4×1/8×4/18×12). ✅ **Losses require the player**: an unpiloted run held at
exactly 300 craft for 240 s / 22 samples, vs 1620 losses in each piloted run —
so NPC crossfire destroys nothing on its own. 🔴 **Zero `0→n` arrivals in either
condition**, ~15 min cumulative. 🔴 **Prime suspect REFUTED (2026-08-24):
the clock is running** — [`mission-clock-advances.md`](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 ~1419 fps. 🟡 New leading
explanation: **the runs were far too short in GAME time** — at ~55% of
wall-clock, the longest 240 s run reached only t≈132, past the t=90/120 route
entries but nowhere near t=170/210/240. ✅🔴 **Long run done (2026-08-24)**: per-record tracking
works — watched ONE turret squadron fall **18→14→12→10→8→6→4→2** over seven
loss events while `deployed` held at 41. 🔴 **Still no arrival at 234 s wall
(≈129 game-s), past both t=90 and t=120**, so the "too short" explanation no
longer covers those (it still covers t=170/210/240; the run was cut at 240 s by
the turn timeout, not the planned 330 s). 🟡 **Sharper hypothesis:** the
squadron ended at **2, never 0** — no squadron has ever been eliminated in any
run, so the trigger may be *elimination*, not damage. ❔ **Elimination test unrun (2026-08-24)***superseded below by the `SYLPH_KEEPOUT=1400` run that refuted it; and 🔴 was the wrong marker, an unfinished run is ❔, not blocked*: the hunting
pilot died at t=83 s with the squadron at 14. ✅ But the player's own record hit
`2→0` — first `n→0` ever seen, so the signal does register elimination (no
arrival followed; weak, it was the player not an enemy squadron). ✅ **KEY:
after player death the mission is frozen** — 288 craft, zero losses, zero
arrivals for 220 s / 18 samples. **The usable window is player survival, not
probe duration.** 🔴 Harness bug found+fixed: a `sed` had stripped the probe
args, so every derived probe ran on defaults — the previous "cut by the turn
timeout" claim was wrong (it hit its own 240 s default).
✅🔴 **DONE + REFUTED (2026-08-24)**: `SYLPH_KEEPOUT=1400` gives a pilot that
kills and survives (hull 1500, asset 100 %, 8 losses). An enemy squadron was
**eliminated**`e007 Turret 2→0` at t=163 s, `deployed` 41→40, first ever —
and **no arrival followed** in ~90 s of valid observation (two further losses in
that window prove the mission was live). ⇒ *"a wave is released when a squadron
is wiped out"* is **refuted**; event-gating generally is not (thresholds,
objectives, specific squadrons untested). 🔴 **Guest stalled at ~t=255 s**
last 400 pilot lines have 1 distinct speed vs 236 in the first 400 — so trailing
flat samples in ANY earlier run may be a frozen guest, not a quiet mission.
Probe now carries a frame-rate tick witness printing `*** GUEST STALLED ***`.
* ✅🔴 **The stall witness WORKS and is now mandatory**
([`guest-stalls.md`](guest-stalls.md), 2026-08-24). Validated against the
pilot's independent telemetry (35 distinct speeds all run, 1 in the last 400
lines; witness flagged t=27 s). It immediately **discarded** a run that would
otherwise have read as "no arrivals across 313 s" — worthless, the game was
frozen for 90 % of it. **Rule: a stalled run is discarded, and every write-up
must state the witness result.** 🔴 Stalls are frequent and early — last three
long runs stalled at ~255 s, 83 s and **~27 s**. 🟡 **Leading suspect: the probe
itself** — it reads the whole 32 MB heap plus ~300 `pread`s every 12 s while
lavapipe renders, and AGENT.md warns a full scan competes for every core.
**CONFIRMED (2026-08-24): the probe was causing the stalls.** The no-probe
control ran **300 s clean** — 211/257/250/241/200 distinct speeds per 60 s
window, no decline — against probed runs stalling at 27/83/255 s and ending on
**1** distinct speed. The instrument was degrading what it measured, exactly as
AGENT.md warned. Control is **n=1**: acted on, not proven.
⚠️ **Consequence: every "no arrival" result was collected under a struggling or
frozen guest** — none is as strong as written, and the arrival question needs
re-running with cheap sampling before silence means anything.
**Fix VERIFIED (2026-08-24)**: `wave7_probe.py` (enumerate once, then poll
only the hull word per known craft, ~1.2 KB/sample, rescan every 90 s) ran
**0 stalled samples** and produced **19 losses vs 8** — starving the emulator
had been suppressing the activity the probe existed to watch.
🔴 **Its one apparent arrival (`0→2` at t=259 s) is FLICKER, not a wave** — it
reverted to 0 fifteen seconds later, and the same log shows a record reading 13
then 14 with no event printed because only decreases were surfaced. Count
stands at **0 confirmed arrivals in 11 runs**. Probe now prints every increase
and requires an arrival to **persist across 2 samples**.
* ✅🔴 **Run 12 (2026-08-24): persistence rule works; stall witness does not.**
16 losses / 290 s with a bound pilot, **0 confirmed arrivals** (twelve runs
now). One increase surfaced (`13→15`) and correctly not counted — exactly the
flicker that nearly became "first arrival". 🔴 **The single-word tick witness
gives FALSE POSITIVES**: 13 samples flagged `GUEST STALLED` *while recording
losses in the same samples*, which a frozen guest cannot do. Cause: it took the
first word in a 4 MB window with a plausible rate; intermittent counters pass
that test. `timer_probe` had already solved this (286 candidates, modal rate
~17/s) and the lesson was not carried over. **Fixed to a majority vote over the
modal-rate cluster, plus explicit `RUN UNVALIDATED` when no witness exists.**
* ✅🔴 **Run 13 (2026-08-24): the vote is better, the threshold was wrong, and
freezes persist.** Graded output (11→9→7→4→1→**0** of 31) is coherent, and the
drop to 0/31 at t=183 s coincides exactly with the last loss — a real freeze,
106 s of nothing after. 🔴 But "<half = stalled" marked the WHOLE run stalled
including samples where craft died: **11/31 advancing is healthy**. Cause: the
modal cluster was **93/s**, not the ~16.5/s frame rate — bursty subsystem
counters. **Fixed: prefer the 840/s frame-rate band, stall only when ZERO
advance.**
* 🔴 **Witness attempt 3 still unreliable (2026-08-24)**: only **5** candidates
in band, and `0/5 moved` in samples where craft died — a real 24/s counter
cannot miss 15 s. Cause was mine: the candidate search had been narrowed to
**4 MB** when the witness was bolted in, while `timer_probe` searched the full
**32 MB** and found 286 with a clean ~17/s cluster. **Fixed: full-region search
once at startup + warn when <8 witnesses.**
***WITNESS VALIDATED (2026-08-24, 4th attempt)**: full-region search gives
6500 candidates → 32 witnesses at 21/s, and the flags have **zero
contradictions** with the loss data (the one loss is unflagged; every flagged
sample has no losses). That is the check the three previous versions failed.
***ROOT-CAUSED 2026-08-26 — see [`mission-freeze-heap-exhaustion.md`](mission-freeze-heap-exhaustion.md).** The freeze is a **guest 128 MB physical allocation being refused** (`MmAllocatePhysicalMemoryEx`, `parent free 28969/131072 pages` ≈ 113 MB) after which the guest throws a C++ exception and stops; the emulator keeps spinning at 399 % CPU, which is why it reads as a hang. **Independent of the cleared-stage mask poke and of the stage** — a `SYLPH_NO_POKE=1` control on Stage 01 froze with the *identical* free-page count as the poked Stage 02 run. This also refutes `challenge-mission-gate.md` §5.6's "poking only real story ids does not blow the heap".
* 🔴 **The freeze is now THE blocker.** Onset across runs: **27, 45, 83, 183,
255 s** (median ~83 s) — usable window is 14 min and unpredictable. This run
froze at 45 s with 1 loss, useless for arrivals despite a working instrument.
⚠️ Being honest: the "cheap" probe still reads 32 MB at startup + 32 MB every
90 s, and the new calibration added **two more 32 MB reads** — the 45 s freeze
came right after it. 🔴 **The trim BROKE the witness** (17 candidates, `0/17` on
every sample of a run with 13 losses) — reverted; two 32 MB reads once at
startup is the price of a working witness. ✅🟡 **Instead, disabling the periodic
rescan LARGELY removes the freeze** — corrected from "removes" after more runs:
clean at 210/240/300 s but **frozen at 60 s** on a fourth. Tally — heavy probe
froze at 27/45/83/183/255 s; cheap+rescan at 183 s; cheap, no rescan: 3 of 4
clean past 200 s. A large probability improvement, **not a fix**. Practical
rule: run, check the witness, discard frozen runs (**3 clean of 5** now: clean
210/240/300, frozen 60/90).
***BLOCKER REMOVED (2026-08-24): the emulator survives BETWEEN Bash calls in
a turn** (checked: 611 s elapsed, still running after the launching call
returned), so observation is not capped by one call.
`tools/re-capture/attach_session.sh` attaches pilot+probe to a live mission and
attaches CHAIN. Cumulative **435 s verified-live on one mission** (300 s clean +
135 s of an attach before it froze), craft **300→258** (42 destroyed),
**0 confirmed arrivals** — covering route entries t=90/120/170/**210**; t=240
missed by one second. 🟡 Coverage assumes the 55 % wall→game factor; the witness
has measured 824/s across runs, and at the low end the same window covers only
~117 game-s (t=90 only). **Pinning the tick rate would make this sharp.**
* ✅🟡 **(2026-08-24) The 42-anomaly is STAGE 01, and it hands us two results.**
Rescans stuck at 42 (not a load race); live RAM holds `UN_S01_Asteroid_cmesh_*`
and `UnitGroup_S01.tbl` with S02 absent — **the launch sometimes loads Stage 01**
(the discard rule caught every such run). ✅ That supplies the refutation test
recorded as *blocked* for lack of a second stage: S01 static **42 members / 13
units** vs live **42 records / 13 definitions**, composition 20/20, 6/6, 5/5,
2/2 — **one record per member now holds on two independent stages.**
🟡 **Route times are almost certainly FRAMES**: S01 phase 2 has t=1500/1800/2100,
which as seconds is 2535 min into one phase (implausible) but at 30 Hz is
50/60/70 s. ⇒ S02's t=90…240 would be **38 seconds**, all before the probe's
first sample (~25 s in) — explaining every null result, and consistent with
`deployed=41` already at t=0. **Test: sample at flight+2 s vs flight+30 s**
a reordering of the probe, not new decoding.
* ✅🔴 **(2026-08-24) Early sampling done; still flat.** `early_probe.py` cuts
setup from ~25 s to **0.50.8 s** (no calibration, no labelling, `bytes.find`
scan). At flight+0.8 s **deployed=41 already**, flat for 252 s (only 41→40, one
squadron wiped). 🔴 **The `--wait` variant caught the READY ROOM, not flight**
the roster is built before take-off, so waiting for it is not catching mission
start; that window (deployed=39, craft=276, flat 200 s) tests nothing.
🟡 **But ready room 39/276 vs flight 41/300 suggests deployment is a single step
at TAKE-OFF**, not a schedule unfolding in-mission (different runs, so
suggestive only). **Next: one ~400 s run spanning ready room → take-off →
flight** to catch the 39→41 step in one continuous series.
* ✅🔴 **(2026-08-24) DEPLOYMENT IS RESOLVED AT MISSION LOAD.** One continuous
series, 64 samples over ~380 s from roster-appearance through the ready room,
take-off (`IN FLIGHT` at +47 s) and flight: **`deployed=41, craft=292`, zero
changes throughout.** 🔴 **My own "deployment happens at take-off" is withdrawn**
— the 39/276 vs 41/300 gap was cross-run variance; within one run there is no
step. ⇒ **Stage 02 phase 1 has NO observable in-mission arrival**: 41 of 116
records are deployed before the ready room ends, and nothing changes in the
ready room, at take-off, or in up to 435 s of verified-live flight with 42
kills. Route times most likely encode **fly-in animation timing applied at
load**, not release times (frames reading: S01's t=2100 → 70 s at 30 Hz).
**Phases 2 and 3 are entirely untested** — every run has stayed in phase 1,
and phase advance was never located; a phase transition is now the most likely
place an arrival could exist.
* 🟡 **(2026-08-24) ANSWER: enemies come into play PER PHASE, deployed at phase
start** — [`mission-phase-deployment.md`](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, and `SUBObjectiveSettings` names 9 objectives but carries no trigger.
Unsearched: `StageMessageSet_S02.tbl` (never resolved) and the PE in guest RAM.
* ✅🔴 **(2026-08-24) Message tables searched; trigger NOT there.**
`StageMessageSet_S02.tbl` **resolved — prefix is `message\`**, not `stage\`
(closes a long-standing ❔). ✅ `message\UnitMessageSet_S02.tbl` has
`CrewCount` + **`PresetMessage_Phase1/2/3`** — a THIRD independent table family
organised around phases, after the stage record and route names.
🔴 **Refuted:** `ScriptMessage_S02_msg.tbl`'s promising third field is a radio
*delivery category* (`None` 105, `Emergency` 33, `Killed` 5, `Noise` 5; arg is
`1` in every record), not mission control flow.
**No table anywhere carries a phase-advance condition** — everything found is
a consequence of the phase, never its cause. ⇒ the logic is in **code**, and
`default.xex` is encrypted on disc, so the decrypted image exists only in guest
RAM. That is the honest end of the static search.
* ✅🟡 **(2026-08-24) The mission script is READABLE — and phase 1 never advanced
because the pilot killed the wrong things.**
[`mission-objectives-text.md`](mission-objectives-text.md).
`language\*_local_string.tbl` is **`IXUD`, UTF-16-BE** (prefix `language\`,
a third convention) — that encoding is why earlier dumps looked like garbage.
✅ Stage 02's nine sub-objectives in words (*"You destroyed all enemy
fighters!"*, *"You sunk all enemy warships!"*, *"You destroyed all enemy cruise
missiles!"*). ✅ **Phases confirmed twice over**: the guide script is three acts
— marked attackers → warship engine/weapons/shield → missiles — matching the
per-phase rosters, with phase 3's **`UN_e201_ADAN_ISCMissile` ×9** exactly
matching `SUBOBJ_013`. 🟡 **Phase-1 objective is stated outright: destroy the
MARKED ATTACKERS** (`UN_e010_ADAN_Attacker_S`, 4 squadrons) — but every loss
line in every run reads `UN_e007_ADAN_Turret`, because `SYLPH_HUNT`/`KEEPOUT`
were built for turrets. **42 kills were the wrong 42.** **Test: a pilot that
prioritises `e010` over `e007`**, watching `deployed` jump from 41.
* ✅🚧 **(2026-08-24) `SYLPH_PREFER` works; the blocker is now COMBAT, not RE.**
Preference knob added (matching units ×0.05, others ×4.0). One clean 320 s run
(**0 stalls**): 8 turret kills vs **2** `e010` — real effect (was ~1 across ALL
prior runs) but weak; commitment + proximity keep pulling back to turrets, which
outnumber attackers 108:16 in craft. `deployed` stayed **41**, no advance.
🚧 **Quantified blocker:** phase 1 fields **16** `Attacker_S` craft; at 2 per
320 s clearing them needs **~2560 s ≈ 43 min** of verified-live flight across
many chained attaches, against a ~2-in-5 freeze rate.
**Everything needed to OBSERVE the advance is built and validated** — roster
link, liveness, stall witness, chained attaches, discard rule. What is missing
is a pilot good enough to finish the objective. **User's choice:** (1) invest in
the pilot (game-playing, uncertain); (2) accept the static answer (structure,
rosters, routes, objective texts all decoded and cross-confirmed; only the
*trigger* is inferred); (3) one 40+ min chained run betting against freezes.
* ✅✅ **(2026-08-24) THE OBJECTIVES, FROM THE GAME'S OWN TEXT** — pinned by
`tests/phase_objectives_disc.rs`; `TextIndex::objectives(stage, phase)` already
existed. **S02 phase 1: "Shoot down all invading enemy fighters while watching
out for attacks on the ACROPOLIS"** (lose: ACROPOLIS sunk). **Phase 2: "Protect
the CALIBAN until it has entered the safe zone"** — *positional, not a kill
count*, so phase triggers are NOT all the same kind. **Phase 3: "Destroy the
interstellar cruise missiles headed for Schlos Base"** (matches the 9
`ISCMissile` + `SUBOBJ_013`). ✅ **`[OB]` = objective marker** — hints say *"Red
mission markers [OB] indicate your targets"*, so **`REMAINING OB` at
`0xbdb59668` is phase-1 progress** and is the right signal to watch, not
`deployed`. Closes a loop to the first session. 🔴 **Method lesson: the crate
already knew this** — several iterations reconstructed it the hard way;
`grep -rl TextIndex crates/` would have saved the detour. ✅ Minor, resolved 2026-08-26:
strings are **UTF-16BE** ([`idxd-container`](structures/idxd-container.md),
1 104/1 104 objects), so `ixud.rs` is right and **`localization.rs` is the wrong
one** — its header says UTF-16LE *and* it decodes with `u16::from_le_bytes`, so
it is a code bug, not a stale comment.
* ✅🔴 **(2026-08-24) REMAINING OB hunt: method works, run unfinished**
([`remaining-ob-hunt.md`](remaining-ob-hunt.md)). Correlate heap words against
*named* kill events instead of scanning for a value: **one `e010` event cut ~8 M
words to 1056**. 🔴 But the probe saved candidates only at the END and the turn
timeout discarded them — **the exact mistake already recorded in
`guest-stalls.md` four iterations earlier**. 🔴 And the follow-up attach logged
535 s of zero losses with **no stall witness**, so it cannot say whether the
guest was quiet or frozen. Both fixed (save per event + `SYLPH_OB_RESUME=1`
resume; witness carried over). **Pattern worth acting on: each new probe starts
from scratch and re-earns the same lessons — a shared probe harness would stick
where written-down lessons have not.** Needs 23 `e010` events, i.e. the same
combat limit.
🔴 `SYLPH_HZ=3` refuted as a lever: it gave the LOWEST frame rate (8/s) with the
most kills, so pilot polling is not the throttle.
* ✅🔴 **(2026-08-24) OB hunt, second attempt.** ✅ **Incremental saving verified**
— one `e010` event at t=241 s wrote **1187 candidates** to disk before the turn
timeout fired; session now clears the file at launch since offsets are only
valid within one emulator instance. 🔴 **The correlation had no value filter**:
survivors were float bit patterns (1044450858 ≈ 0.1f) whose integer forms
differed by the loss count. Fixed — candidates must be small non-negative
integers (`0 ≤ v < 1000`) in both samples. 🔴 **The attach was FROZEN**, not
merely unproductive: **25 of 26 samples flagged `GUEST STALLED`**. The witness
worked; the summary just quoted "0 events" before checking it. **Rule: read the
witness FIRST, before interpreting what a run showed.** ❔ Still no address;
needs 23 `e010` events in non-stalled samples.
* ✅🔴 **(2026-08-24) OB hunt: method PROVEN, turret tracking REFUTED.** With the
value filter, an attach watching frequent turret losses narrowed
**374 → 2 → 0** across three events — exactly how a correlation search should
behave, ending in a refutation rather than fizzling: **no plain `u32` in
`0xBD0000000xBE000000` decrements with turret kills.** Witness checked first:
12/25 samples stalled but **all four events fall in the early non-stalled
stretch**. 🟡 The negative **fits the objective text** — phase 1 asks for
*fighters*, so a marked-target counter should ignore turrets; it also rules out
`REMAINING OB` being a general kill tally (it ignored ten turret deaths).
❔ Still needs 23 `e010` events; one clean 220 s run produced **zero**.
* ✅🔴 **(2026-08-24) Kill-free HUD route works; BE-`u32` assumption REFUTED.**
`ob_by_hud.py` reads the counter off screen (`ob_read.py`) and intersects heap
words equal to it — **no kills needed**. Four readings at HUD=4 narrowed
6156→4312, then HUD read **11** and the intersection collapsed to **0**. ⇒ within
`0xBD0000000xBE000000` as **big-endian u32 the counter does not exist**; it may
be u16/u8/LE or outside the region. Both previous hunts assumed BE-u32, so this
eliminates the assumption rather than just failing. 🟡 **The value went UP, 4→11**,
which a pure countdown should not do — candidates: wrong HUD cell, misread digits
(template strip covers only **0 1 2 4 8**; most samples read `00?`/`???`), or a
counter that can rise. **Next: widen the scan to u16/u8 and LE, and beyond the
heap** — one function, no combat cost. Also extend `ob_digits.png`.
* 🔴 **(2026-08-24) Widened to 7 encodings; run inconclusive.** `ob_by_hud.py`
now keeps a candidate set per encoding (`u32be/le`, `u16be/le` at both
alignments, `u8`) as byte offsets. **`u32le` is tightest at 154** vs u32be's
4452 — a hint, not a result. 🔴 HUD read **4 at every sample**, so nothing
collapsed; and counts were **byte-identical across five samples in all seven
encodings** from t=136 s, which is what a freeze looks like. The probe had **no
witness** — now added. ⚠️ **Fourth probe written without one, third flat run
that cannot be told from a freeze**; the recurring fix is the shared harness
noted earlier. Needs two HUD readings at *different* values in non-stalled
samples.
***(2026-08-25) SHARED PROBE HARNESS built and verified**
([`probe-harness.md`](probe-harness.md), `tools/re-capture/probeharness.py`).
Makes structural the four lessons that were re-learned in four separate probes:
**built-in stall witness** (says `UNVALIDATED` when absent rather than reporting
zero stalls), **`emit()` flushes every line** so a timeout cannot destroy
results, **baseline discard with rescan** (`Probe(baseline=116)` refuses to
start on a different stage), and `summary()` putting the witness first.
Also provides `craft()`/`strengths()`/`alive()`/`heap()` so a new probe writes
only its own logic. ✅ Verified: `deploy_probe.py` reimplements the deployment
watch in ~40 lines vs 150, first live run clean — 116 roster, 32 witnesses at
10/s, **0 stalled**, 7 losses, TSV written incrementally. ⚠️ Existing probes
deliberately **not** ported — they work and other docs cite their results.
* ✅✅ **(2026-08-25) `REMAINING OB` FOUND AND VERIFIED: big-endian u32 at
`0xbdb59668`.** On the new harness, the HUD changed **4→8** and the intersection
collapsed in one step to a single `u32be` survivor (with `u16be`/`u8` hits being
the low half/byte of the same word). **Verified live**: HUD `012` vs
`mem@0xbdb59668 = 12`. Independently rediscovers the address the earliest
sessions found by digit-transition hunting.
🔴 **Corrects the earlier "u32be refuted"** — that came from intersecting on a
HUD reading of "11" which was almost certainly a misread, since `ob_digits.png`
only has templates for **0 1 2 4 8** and misreads rather than rejects other
digits. **A single bad input permanently poisons an intersection**; the reader's
confidence scores were printed but never gated on.
🟡 **The counter INCREASES: 4 → 8 → 12** in ~5 min, measured in memory. Marked
objective targets are being **added during the mission** — the arrival question
again, now with a one-word signal instead of a 32 MB scan. **Next: watch this
address across a whole mission.**
* ✅🔴 **(2026-08-25) OB address is RUN-DEPENDENT; watcher now self-sufficient.**
Two fresh launches: `mem@0xbdb59668` = **3165285888 (MISMATCH)** then **4
(MATCH)** against HUD=4. The confirmation gate refused to report from the bad
one — the old "recurs in ~5 of 7 runs" note is right. `ob_watch.py` now **hunts
the address on the current run** when confirmation fails. ✅ HUD reader is now
**confidence-gated** (every digit ≥0.80, margin ≥0.05 — the rule `ob_read`'s own
docstring states), closing the hole that produced the wrong `u32be` refutation.
🔴 **OB flat at 4 for 250 s** (witness clean, 0/50 stalled) while the pilot
fired on **1635 of 1964 ticks** at marked attackers — constant fire, zero
decrements, so it destroyed none. Fire rate itself rose from 4.6% to 83% with
no more kills. ❔ **The earlier 4→8→12 rise is NOT reproduced** — both readings
were HUD-confirmed, so the rise is not a stable property of the first five
minutes; recorded as unreproduced rather than explained away.
* ✅🔴 **(2026-08-25) THE MISSION BLACK-SCREENS AT ~4.5 MIN — that is what ends
every run.** Two-segment OB watch: segment 1 clean (**0/50 stalled**, OB steady
at 4 for 250 s); segment 2 on the same live emulator got **`NOT IN A MISSION`**.
Player never died (**hull 1500 throughout**), pilot log stops at **t=267 s** with
byte-identical lines, screen now **entirely black** (`mean=(0,0,0)`). This is the
known pre-existing mission-end freeze, caught in the act.
🔴 **Corrects "chained attaches extend the window"** — the emulator surviving is
not the *mission* surviving; ~4.5 min is a hard per-mission bound and chaining
does not cross it. ⇒ **Anything needing >4.5 min of one mission is not doable on
this build**, including clearing 16 marked fighters at ~2 kills/5 min. The
freeze is now the highest-value target: fixing it unblocks arrivals, phase
advance and the OB series at once. ❔ The 4→8→12 OB rise is now unreproduced in
**3 of 4** runs.
* ✅🔴 **(2026-08-25) Freeze: one new data point, and the INDEX was the real bug.**
✅ New evidence closing the resume-spin lead from the other side: the 2026-08-25
freeze log has **0** `resume was refused` (vs **2447** in an older healthy-ish
log) — **a mission can freeze with none at all**, so the warning is not even
necessary, let alone sufficient. 🔴 Also refuted today: the `BaseHeap::Release
failed` burst is **not** a freeze signature (routine; spans lines 10445210 in a
log that runs 2 700 lines further). 🔴 **Both were already settled in
`mission-freeze-resume-spin.md`, as was `0xbdb59668` — that is twice in one
session that existing work was redone.** Cause is mechanical: **`INDEX.md`
listed 20 of 43 notes** and none of the recent ones, so searching it returned
nothing. ✅ **Fixed: `INDEX.md` now has a generated table of all 59 notes** with
title + status and says to search it before starting. Regenerate when notes are
added.
* ✅🔴 **(2026-08-25) Freeze frontier: wait confirmed, but `info args` won't work.**
**Verified** the frozen frame's export by ordinal: the shim's
`(unsigned short)176` = `0xB0` = **`KeWaitForSingleObject`**
(`xboxkrnl_table.inc:190`) — previously read off frame names, now pinned.
🔴 **Corrected:** the doc's "the handle is one `info args` away" is wrong for
this binary — `readelf` shows **0 debug sections**, symtab only (27 807 entries).
Not stripped ≠ has debug info; gdb can name frames and nothing inside them.
**Two routes, costs recorded:** (1) prologue-guided stack read of `XObject::Wait`'s
spilled `this` — no rebuild, keeps the oracle identical, but per-frame
archaeology; (2) a `RelWithDebInfo` build via `build-canary` — makes this and all
future freeze questions easy, at a full compile and a binary differing from the
one every other measurement used.
***(2026-08-25) Route 1 is viable and cheap — `this` is in `%rbx`.**
Static analysis, no run spent: `XObject::Wait`'s prologue does `mov %rdi,%rbx`
at `8fbc9c`, so `this` sits in a **callee-saved** register rather than a stack
slot. And the binary carries **`.eh_frame` with 127 231 FDEs** (Release builds
keep it for C++ exceptions), including one covering `8fbc90..8fbde2` that
tracks `rbx` explicitly. ⇒ from a frozen thread, **`frame 3` + `info registers
rbx` gives the `XObject*` being waited on**, and `x/gx $rbx` → vtable symbol
(in symtab) gives its concrete type — **no DWARF and no rebuild needed**.
**Revises the previous entry**, which called route 1 "per-frame archaeology"
and route 2 (RelWithDebInfo rebuild) the way to make it easy. **Next: execute on
a frozen run** — two gdb commands per thread.
* ✅🟡 **(2026-08-25) Wait-object read WORKS: the object is an `xe::kernel::XEvent`.**
Executed under gdb: `frame 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,
different `this` each, so many threads waiting on *different XEvent instances*.
No DWARF, no rebuild, as predicted. 🟡 **Two caveats, both recorded not glossed:**
(1) this is a **healthy-play** snapshot (`screen_id` = `flight`), not the freeze
— boot under gdb costs ~300 s and the whole thing must fit one call (a `timeout`
kills the process group and took the emulator with it once), leaving too little
to reach the ~267 s freeze; (2) the `this` addresses (`0x7ffd…`, `0x7ffc…`) look
like **host stack**, not heap, so either xenia places them unusually or `rbx`
isn't `Wait`'s `this` after unwind. **Check: dump a few words at `$rbx`
XEvent-like (vtable, KernelState*, handle) vs saved registers.**
* 🟡 **(2026-08-25) Refined: TWO wait types, and the read self-checks.**
Re-extracting per thread (not by grep): of **18** `XObject::Wait` frames, **8**
have `[rbx]` = `vtable for xe::kernel::XEvent`+16, **2** = `vtable for
xe::kernel::XSemaphore`+16, and **8** hold a non-vtable mmap pointer. ⇒ waits
are on **XEvent and XSemaphore**; "it's an XEvent" was the majority, not the
whole picture. ✅ **The 8 misses are the method validating itself** — a
polymorphic object's first word is always a vtable, so a value only counts if
`[rbx]` resolves to a `vtable for …` symbol; `rbx` simply wasn't restorable for
those frames. That also dissolves the "looks like stack" worry: `0x7ffc…` is the
shared mmap region (stacks *and* big allocations), and the **vtable check**, not
the address range, is the discriminator. 🔴 The follow-up run adding
`/proc/maps` classification + `x/8gx` **never booted** (`EMULATOR GONE at 0s`,
stale emulator/lock from the prior gdb session), so that check and the **frozen**
capture are still unrun.
***(2026-08-25) WITHDRAWN: the 8 "unrestorable rbx" reads were `WaitMultiple`.**
`rbx` was restored fine on all 18. The backtrace grep matched `XObject::Wait`
as a **substring**, so `WaitMultiple` frames were pooled in and read with the
wrong rule — `Wait` keeps `this` in rbx (`8fbc90: mov %rdi,%rbx`) but
`WaitMultiple` keeps the **`XObject**` array** (`8fbfc0: mov %rsi,%rbx`), so
`[rbx]` there is `objects[0]`, an object pointer needing a **second** deref.
Confirmed live: re-reading with the matching rule per frame resolves
**30/30 objects across 23 wait frames, nothing unresolved**
**XEvent 20 / XSemaphore 9 / XTimer 1** (`data/waitobj-s02.txt`). `XTimer` was
invisible before because its only thread is a `WaitMultiple` one. Every
`WaitMultiple` thread waits on a **pair**; T78/79/80 and T64/65 are worker
groups sharing a handle. 🟡 `%ebp` is **not** a usable count — `WaitMultiple`
reuses it at `8fc158` — so the array is bounded by reading until an entry stops
resolving. What survives: the object types and the self-validating read.
***(2026-08-25) The frozen capture was unrun — the freeze did not happen that run.** *Superseded: it was taken (see `FROZEN CAPTURE TAKEN` below). 🔴 was wrong twice — the capture was not blocked, and "it did not happen this run" is a scheduling outcome, not a refutation.*
Two captures in one run (200 s and 367 s of mission), and `screen_id` reads
**`flight`** at both, plus at ~470 s with a drifting frame mean. So the labelled
`healthy -> frozen` diff is **two healthy captures**, and its `20 -> 18` is three
threads exiting, not a freeze. 🟡 **This also questions the "~270 s black-screen"
bound** these runs are planned around — confound not ruled out: gdb slows the
emulator (boot to title took 207 s), so 470 s wall-clock may be far short of
270 s *guest* time. **Next: reach the freeze by its actual trigger rather than a
clock, or measure guest time under gdb so the wait is set in the right units.**
Chaining Bash calls **within one turn** works and is no longer the limit — the
emulator survives between calls; this run spent ~900 s across three.
* ✅✅ **(2026-08-25) FROZEN CAPTURE TAKEN — and the per-thread diff is the result.**
`freeze_waitobj.sh` split into `boot`/`watch`; `watch` waits for the **event**
(`frozen.py` + `in_flight`) instead of sleeping a guessed interval, and caught
it first try. Hard stop, not a hitch: a frame minutes later is
`max_pixel_delta=0` against the capture. Same run, same mission
(`data/waitobj-s02.txt`): **20 → 24 wait frames**, XEvent **19 → 23**,
XSemaphore **8 → 7**, XTimer 1 → 1. 🔑 **17 of 24 threads sit on the EXACT
object they were already on** — so this is *not* a whole-emulator stall. What
moves: T105/T67/T68 park on `Wait(XEvent)` and T69 on `Wait(XSemaphore)` having
been running; **T74 and T75 move off a semaphore onto an event** (the only
threads that change what *kind* of object they wait for → chase these first);
T50 drops `WaitMultiple(XEvent,XEvent)``Wait(XEvent)`.
* 🔴 **(2026-08-25) WITHDRAWN: the T74/T75 signature.** It does **not** reproduce
— in run 2 they are on `Wait(XEvent)` while **healthy** and stay there. My
error: the healthy state is not fixed either, so a **one-sample-per-state**
diff cannot separate a freeze transition from ordinary variation between two
instants. ✅ **What survives two independent captures:** T68 and T69 go from
not-waiting to waiting in *both* runs (T69 on a semaphore, T68 on an event,
both times), and the bulk of threads are untouched (17/24 run 1, **21/24** run
2) ⇒ **not a whole-emulator stall** now has two captures behind it. **Next:
repeat the capture several times inside ONE healthy run** to learn which thread
states are stable before reading any frozen diff as meaningful.
***(2026-08-25) CORRECTION: `screen_id == flight` is NOT a freeze test.**
The previous entry used it to rule out a freeze. `frozen.py` exists precisely
because a frozen mission still classifies as `flight` (its docstring: 724 s of
identical state, 212 % CPU, classifier happy). Re-testing the saved frames says
that run was genuinely healthy — right, but by luck. **And the "~270 s
black-screen" bound those runs were planned around is not a thing**: the freeze
does not black the screen and keeps no clock (onsets 27/45/83/183/255 s).
* 🟡 **(2026-08-25) One data point that OUR INSTRUMENT provokes the freeze.**
Run flew **~670 s clean** with only the pilot; a heavy-CPU inducer
(`heavy_read.py cpu`) started at 08:59:54 and it froze at **09:00:48 — 54 s
later**, inside the 27255 s band. Consistent with the existing tally but
**n=1, not causal** (confounder: elapsed mission time). ⬆️ **UPGRADED to n=2
(2026-08-25)**: run 2 put the inducer **on from flight start** and froze
**~96 s** in (10:07:20 → 10:08:56). Both freezes sit inside the 27255 s band;
the only long clean stretch is the only window with no inducer. Contrast is
sharp, confounder still untouched. ✅ **Refuted en route:
the I/O was never the cost** — a full uncapped walk of every allocated extent is
**371 MB in 0.1 s**, all page cache; the expense is Python-level CPU, ~4.2 s a
pass. **Settle it with:** alternating inducer-on/off windows within a run,
several runs, compared per unit of *mission* time — cheap now `watch` is
event-driven.
* ~~🚧 BLOCKER: t=210/240 unreachable in one 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 `up`s correctly rejected (~1/min, which is why
the persistence rule matters). 🔴 **Startup eats ~100 s of a ~350 s budget**:
`enumerate_craft` iterated every 4-byte word of 32 MB in Python (8M steps) to
find 14 fixed needles. **Replaced with `bytes.find()` per definition VA — not
yet run**; if it recovers that time the window reaches ~340 s ≈ 187 game-s,
finally covering the **t=170** entry. Also: the 42-record anomaly recurred and the discard rule
correctly refused the run; a rescan-until-baseline retry is now in place to
test whether it climbs. All three witness
failures share one pattern: a shortcut in *selecting* the witness, each caught
only by the flagged samples contradicting losses in the same output.
***Bind failure fixed + abort verified**: `entities2 self` finds the player by
MOTION, so a stationary craft at mission start is invisible; the session now
holds throttle 3 s before each attempt. One run correctly **aborted** after 3
failures rather than flying unattended; the next bound immediately (11 losses,
0 confirmed arrivals — 14 runs). 🔴 **The cheap probe froze too (t≈183 s)**, so the
earlier "0 stalled samples" validation is superseded; with n=1 per arm (control
clean 300 s, heavy 27255 s, cheap 183 s) it is unresolved whether cheap
sampling helps or the freeze is stochastic. ⚠️ **Usable window ≈3 min per run
regardless** — design experiments to fit or to survive a freeze. ⚠️ The "0 stalled
samples" that validated the cheap probe came from the unreliable witness and
should be re-confirmed. Also fixed: bind now retries 3× and aborts rather than
flying an unattended craft (one run was wasted that way). ❔ Multi-squadron threshold test still not run (zero losses that run).
⚠️ The ~210 s title movie at boot is the binding constraint on observable game
time per turn.
Earlier framing:
[`mission-per-record-strength.md`](mission-per-record-strength.md) — one run
gives 116 records/300 craft, the next 42/170, same disc, save and script. Save
drift is REFUTED (savedata untouched since 2026-08-23) and the guest was not
frozen (pilot telemetry shows live engagement). Also: **zero arrivals and zero
losses in 190 s of hunting** — weak evidence against clock arrivals at
t=90/120/170 s, and no test of event-gating at all because nothing was killed
(`fc=0`, hull never moved). **Next: (1)** sample the roster-record count
repeatedly WITHIN one run to tell a load race from a run-specific constant;
**(2)** get a confirmed kill — the pilot reaches 259 m and still misses, so the
gap is accuracy, not engagement.
* 🟡 **COMPETING MODEL (user, 2026-08-24): waves are event-gated, not
clock-driven** — released by kills/objectives rather than at a fixed time, with
the route's start time being a post-trigger delay. Fits the evidence better:
both probe runs used the *survival* pilot (kills nothing) and saw no arrivals,
which a clock model does not explain. `DisableInterval` also gains a referent.
🟡 Supporting: no `DisableInterval=Yes` squadron has a delayed route (55/55 at
t=0, vs ~3.7 expected if independent, p≈0.02) — suggestive, NOT conclusive,
deliberately not promoted. **Test:** same stage twice, kill nothing vs kill
aggressively; cheapest precondition is whether `REMAINING OB` (`0xbdb59668`)
moves in one and not the other.
* 🔴 **Diffing inside the 116 records did not find the arrival flag**
(2026-08-24, same doc). 10 of 116 records are dynamic, 106 never change a byte
in 170 s — 🔴 **the "10 of 116 dynamic" figure is WITHDRAWN** — a hunting
run measured 4156 records changing per tick; I had changed two variables at
once (record bound and pilot) so the discrepancy is unattributed. The roster
identity is unaffected: it now rests on the 10/10 unit-composition match.
Earlier note, kept for history: the "turrets don't move" hedge was withdrawn after the user
pointed out that early-mission "Turret" is a craft type, which the data
confirms (`UN_e007_ADAN_Turret` flies under `AI_ADAN_CraftSquadron_*`, never
`AI_Structure`). Lesson: check a unit's `AIID`, not its English name. No field transitions in groups of 3 at the
predicted times. **Blocked on two of my own defects**: the record→unit-ID
label resolved to `?` for all 116 (reuse `unit_discover.py`, do not re-derive),
and `RECLEN=0x200` was assumed, not measured — the busiest fields sit at the
very end of the window, which is what spilling into the next object looks like.
Fix both before diffing these records again.
* 🔴 **Probed 2026-08-24 and refuted the cheap hypothesis** — the phase state is
NOT adjacent to the loaded table strings; see
[`mission-phase-runtime.md`](mission-phase-runtime.md). The run did confirm
dynamically that every table the static decode predicts is resident in guest
RAM and findable by name, which validates the whole static layer against a
running mission. Next handles: watch `Route_ADN101_p1F` actually fire against
entity positions, or work back from the `SUBOBJ_*_Mes_L1` HUD strings; the
phase state is more likely near the known mutable `REMAINING OB` counter at
`0xbdb59668` than near the tables.
**Superseded first step:** find what *advances a phase* — the stage declares
`Phase_1..3` and routes are phase-tagged, but nothing static found so far says
what ends a phase. This is the point where the oracle should be measured rather
than reasoned about: fly Stage 02 and watch for the phase transition. Also open:
the route-name kind letters `F/S/A/M/B`, what activates a sub-objective, and
`StageMessageSet_S<NN>.tbl` which does not resolve in `GP_MAIN_GAME_E.pak`.
**Superseded first step:** find where the arrival *interval values* live.
`DisableInterval` is only a per-squadron flag (`Yes` for 31 of 1160); the
durations, spawn triggers and arrival positions are not in `UnitGroup`. The
stage record names two untouched candidates: `Formation_*.tbl` (formation
geometry, possibly its timing) and `EnumSquadron_Test.tbl`. Also still open: the
4-byte record key. ~~the member field `n`~~**SETTLED 2026-08-25, see below.**
~~the missing S17S23 stage records~~ — **SETTLED 2026-08-25, and half the
premise was wrong.**
***`n` is the number of units the member tuple instantiates**, filling slots
of the squadron's formation. `FormationSet_S<NN>.tbl` records are **slot
lists** — `1 + 8·FrameCount` fields, exactly (4→33, 14→113, 30→241, 32→257).
Resolving every squadron's `FormationID` and comparing: **`sum(n) ≤ FrameCount`
holds 1159/1160 across all 28 stages, 0 unresolved, 539 filling the formation
exactly.** The lone violation is a debug leftover (S20, `AI_Test` /
`MessageSet_test`, `Formation_1_only` with `n=2`) and is recorded, not swept
up. The old "`n` is not the `_NN` suffix of `FormationID`" was right but drew
the wrong conclusion — **the suffix IS `FrameCount`**, so `n=9` against `_30`
just means 9 units in 9 of 30 slots.
***`FormationID` resolves by an in-table roster, not by hashing** — 0/16
resolve via `name_hash`; `FormationSet_S02.tbl` has 17 records for 16
formations and the extra one carries no `FrameCount`, its fields being
`(tag, name, "")` with the **tags equal to the record keys**. Same convention
as `Enumerate_Squadrons`. **Second sighting — treat it as the rule for the next
table.**
* ✅✅ **(2026-08-25) 387-vs-~300 SOLVED — the probe double-counts.** The
squadron→phase map was on the disc all along, **in the `Route_S<NN>.tbl` record
names** (`Route_<squadron>_p<N><kind>`, 120/120 for S02); 108/111 S02 squadrons
map, the 3 misses are route-table typos and all phase 2. Phase 1 Σ`n` = **151**,
and its member multiset `{1×25, 2×1, 4×4, 9×12}` matches the measured
craft-per-record fan-in `{2×24, 4×1, 8×4, 18×12}` **bucket for bucket at
exactly 2×** ⇒ `300 = 2 × (151 1)`, the 1 being the known 41-vs-42
off-by-one (probably the player). **The 2× is the instrument, not the game:** a
2× reading needs 16 Delta Sabers for 8 named pilots, and breaks
`Σn ≤ FrameCount` in **20 of 37** phase-1 squadrons (16 on `Formation_1_only`,
one slot) where 1× has **0**. All three measured totals being even is a further
tell. ⚠️ Static argument about a dynamic measurement — it says the number cannot
mean what we thought, **not** what is being double-counted; that needs a run
against the craft-scan counting rule. See `mission-phase-membership.md`.
* 🔴 **Refuted with it:** `UnitGroup` has **no** phase/spawn/delay field
(`1019 = ΣCount·4 + 5·111`, every slot accounted, positional tags are
sequential indices); `DisableInterval` is `No` for all 111 S02 squadrons (its
31 corpus `Yes` records are only S04 and S14, all `GNN***`);
**`stage\EnumSquadron_Test.tbl` does not exist** — that candidate is dead.
* 🔴 **(2026-08-25) NO freeze signature survives the control.** Six wait-object
captures across ONE healthy run: **12 thread states stable, 13 vary**
(`data/waitobj-healthy-stability.txt`). **Every** thread previously reported as
a signature is in the VARIES set — including **T68/T69**, which I had kept as
"what reproduces across both freezes": they park and unpark during ordinary
play, landing on the same objects they hold when frozen. Both frozen diffs are
consistent with healthy variation; one-sample-per-state could never separate
them. ✅ **Survives:** the 12 stable threads held the same object in all six
captures and none moved in either frozen capture, so "not a whole-emulator
stall" stands — now resting on the stable set being undisturbed rather than a
count. ⚠️ **gdb thread numbers are not comparable across runs** (this run had
T132T142, earlier ones T104T106) — key on object address or guest tid.
**Next: compare DISTRIBUTIONS** — N healthy vs N frozen captures, and call a
thread a signature only if its frozen distribution leaves its healthy range.
* 🐛 **(2026-08-25) Boot-nav bug found and worked around, not yet fixed.** Three
consecutive `BOOT FAILED (NO readyroom)` were **not** flakes and **not** input
loss — the d-pad and A both work (verified by moving the save cursor and
opening the dialog by hand). `wait_screen.sh readyroom 300 --tap A`
**blind-taps A**, which answers **NO** on the "Load game?" dialog, bounces back
to the save list, then reopens it — a 300 s oscillation. Driving `step up`
`A` by hand reached the ready room in **18 s**. `launch_mission.sh` should not
pass `--tap A` while a YES/NO dialog can be on screen.
* ✅✅ **(2026-08-25) WHAT ADVANCES A PHASE — SOLVED: a compiled script VM.**
See `mission-phase-advance.md`. `[ScriptMission+40]` is the 1/2/3 ordinal,
init at `0x822606B0`, and **incremented at exactly one site** `0x822609F8`
`0x82260A00` (checked: only one `stw` to `40(rN)` in the whole state machine
`sub_82260710`). Its guard is `[ScriptPhase+196] != 0`; that flag has only two
writers — vtable slots 0/1 at `0x82264058`/`0x822640F8` — reached ONLY from
built-ins **6** and **62** of the phase-script VM's 147-entry table
(`sub_82272220`, jump table `0x8227226C`). Built-in **39** sets
`[phase+300]=2` = "last phase", ending the mission instead of advancing.
🔴 **All four candidate triggers refuted as direct causes** — no kill counter,
timer, trigger volume or message event is on the path; those conditions live
*inside* the per-mission script, which is why the static sweep found nothing
and why three phases of one stage can differ. ✅ `sub_8230D1F8` reads
`"Phase_%1d"` for map/background only — confirms the executable never consults
`Phase_N` for a trigger, and **nothing parses `Route_*_p<N>*`** (the 3 such
literals at `0x820AEA38` are debug defaults) — the route-name phase map is our
convention, not the game's.
* 🎯 **PROBE TARGET:** `CScriptInterpreter::ChangePhase` (`sub_822FF330`, opcode
995) writes a runtime phase mirror at **`[*(0x828F35F8) + 236]`** — readable
from `/dev/shm` with **no gdb**.
* ✅✅ **(2026-08-25) SCRIPT BYTECODE FOUND — `Stage\StageNN.ssb`** in
`dat/GP_MAIN_GAME_<L>.pak`. See `structures/mission-script-ssb.md`. The earlier
grep failed only because every pak entry is `Z1`+zlib. The loader resolves
**table keys, not a filename**: GamePart name → `GP_SCRIPT``script.tbl`
(`name_hash 0x75FE4656`), whose `SCRIPTS` record is a 40-field manifest
`MISSION1..MISSION29 → StageNN.ssb`. **28 scripts; S17 is the missing one**
agreeing with the table sweep *and* with the loader guard
`if (n==16 || n>32) return` (so mission numbers there are **0-based**): three
independent routes to the same conclusion. Header decoded from
`ScriptMission::Load` itself. Stage02 = 226,596 B, md5 `aff69b5a…`, identical
in all 6 language paks. Symtab1 326 syms (197 messages, **119 route names**,
10 subobjectives), symtab2 122 (**111 unit ids** = exactly the 111
`UnitGroup_S02` squadrons). 🔑 **The route names are SCRIPT SYMBOLS** — which is
why nothing in the executable parses `Route_*_p<N>*`; the `_pN` convention is
the script's, resolved at compile time.
* 🔴 **Refuted:** the 7 `.embsec_` sections are **code** (32,368 instructions,
108 functions with real prologues), not script; `MiscBin.pak` and
`DefTables.pak` have **zero** hits.
***NEXT, and it is the one that pays:** decode the **25 ISL opcodes**
(dispatcher `0x822635D4`, low byte of a BE u32, jump table `0x822635FC`) and
the **147 built-ins** (`0x8227226C`) against `Stage02.ssb`, hunting calls to
built-ins **6**/**62** — the two that set `[ScriptPhase+196]` and end a phase.
That gives the **actual per-phase clear condition for every stage**. The
mission-level stream at `+0x24` is partly read (3 groups, one per phase, each
ending in a pair of plausible ISL entry offsets) but `1883`'s operand is not
uniformly a pointer — two land on IEEE floats.
**(2026-08-27) The decode obstacle is GONE — [isl-stream-is-flat](isl-stream-is-flat.md).**
The stream is FLAT: a plain linear decode from the first phase base reaches
**25705/25705 call sites across all 28 stages**, 28/28 clean to `code_end`.
The recorded "needs the coroutine entry points" blocker is refuted (following
them buys 2.8 points; ignoring control flow buys 100 %). The real bug was
`isl.dis` stopping at op 20 (`ret`) — a coroutine **yield**, not an end of
code — which reached only 4.7 %. Fixed via `stop_at_ret`; the committed
`data/isl-stage02.txt` regenerates byte-identical.
🟡 **But `END_PHASE`'s call site is the WRONG place to read the condition:**
all 12 Stage-02 `END_PHASE` sites sit in one stereotyped outro
(`wait_cmds_drained → fade_sound(3) → builtin85(3) → wait_s(3) → END_PHASE →
end_coroutine`). ~~▶️ **Next, and it is now the only thing in the way: read the
five branch handlers** `op10` (`0x82271598`), `op13` (`0x82271830`), `op14`,
`op21`, `op23`.~~**(2026-08-27) DONE for the branches —
[structures/isl-branches](structures/isl-branches.md).** `op10`/`op11` are
signed/float COMPARE, writing three condition bits (0=EQ, 1=GT, 2=LT) to a
bitset at `phase+24`; `op13``op18` are the six relational branches
`beq/bne/blt/ble/bgt/bge` on those bits, targeting `[phase+232] + word@+4`
exactly like `op12`. All six relations present, each once — the completeness is
the check. Handler addresses come from the `bl` inside each dispatcher thunk;
guessing them at a fixed stride lands mid-function. ~~▶️ **Still open:** `op23`
(`0x82271C30`, takes a built-in's result to `phase+168`) and `op21`
(`0x82175C20`) are characterised but NOT named, and naming the branch does not
yet give each stage's clear condition — that needs the operand chain feeding
each compare.~~**(2026-08-27) THE OPERAND CHAIN IS CLOSED —
[structures/isl-builtin-dispatch](structures/isl-builtin-dispatch.md).**
⚠️ op21/op23 were already named in `isl-bytecode.md` (the owning file) as
`push.i`/`pop.i`; my `isl-branches.md` was the stale one. Verified and
reconciled. NEW: the 147-entry built-in table is a **thin dispatch layer**
**112 of 147** stubs tail-call a fixed slot of the `ScriptPhase` vtable, and
every named predicate is in that group. The vtable is **`0x820A84BC`**,
derived from `MARK_LAST_PHASE`'s known `[phase+300]=2` stub and confirmed by
an unused-in-the-derivation prediction (slot 176 = the `=1` stub) plus the
db's own `vptr_writes`. `unit_state` = slot 184 = `0x8226ADF0`, which indexes
`[phase+324]` by `local[4]` and writes its answer to **`[phase+164]` =
`special[0]`**. So: **result → `special[0]`, comparand popped → `special[1]`,
then `cmp.i` + branch.** ~~▶️ **Still open:** the other 111 vtable slots are a
lookup but unread; which comparand each site pushes (needs `push.i` tracked
through the decode); the 35 non-vtable built-ins; the vtable's length.~~
**(2026-08-27) COMPARANDS RESOLVED — [structures/isl-conditions](structures/isl-conditions.md).**
The deques are an **expression stack**: push the left operand, evaluate the
right (result → `special[0]`), pop → `special[1]`, compare. Evidence: push/pop
balance **1877/1877 across 28 stages with ZERO underflows**, and **319/319**
Stage-02 `pop.i` sites are immediately followed by `cmp.i`. `isl.conditions()`
now recovers **7563 condition sites disc-wide with 0.0 % left unresolved**
(83.2 % have a built-in call as LHS, 99.7 % compare against a plain number).
`data/isl-stage02-conditions.txt` finally has a generator
(`isl_report.py conditions`); the other two artefacts regenerate identical.
Top predicates: `hp_pct_test` 1955, `unit_state` 1257, `unit_relation` 796,
`dist_lt` 450. ~~🟡 **1.6 % are WRONG:** 15 sites attribute the LHS to
`end_coroutine`… Fix = only set it for built-ins that write `[phase+164]`.~~
**(2026-08-27) FIXED, and that proposed fix was REFUTED** — `end_coroutine`'s
handler `0x82272624` *does* `stw r11,164(r31)`, so the filter would have kept
it. Real cause: `end_coroutine` returns **3 = destroy the thread**, so the flat
stream continues into a DIFFERENT routine and the tracked state is stale. A/B
over 28 stages: exactly **34 of 7563** sites change, **34 → 0** with an
`end_coroutine` LHS, and the two counts being equal proves the leak was
confined to them — the other 7529 were never affected. They now print an
explicit unknown.
🔴 **(2026-08-27) THAT FIX WAS TOO NARROW — I fixed the instance, not the class.**
`op12 jmp` is unconditional, so the next instruction is never reached by
fall-through either, and the tracker walked through it exactly as it had walked
through `end_coroutine`. Exposed by another impossible output: a six-way switch
on `builtin80`, which returns only 1 or 0. A/B over 28 stages: **889 of 7563
sites (11.75 %) change**, and unresolved goes **34 (0.45 %) → 756 (10.00 %)**.
The earlier "0.0 % unresolved" was a MISSING CHECK, not a strong result.
~~🟡 Recovering the 756 needs a real dataflow join over each block's actual
predecessors (a CFG fixpoint) — the analysis is not written.~~
**(2026-08-27) WRITTEN — `tools/re-capture/isl_cfg.py`.** Worklist fixpoint,
join over actual predecessors. **85.0 %** of instructions reached; unknown LHS
**756 (10.00 %) → 402 (5.32 %)**, of which **389 are never reached** and only
**13** are genuine join-aways. It also found **161 more sites where the linear
walk gave a confident WRONG answer**. ⚠️ Two of my own zero-results on the way:
the phase bases reach only 36 % (most routines are coroutines with no static
predecessor, so every `start_coroutine` target must be seeded), and that seeding
first found **0 entries in a file with 216** because the target is staged in TWO
steps (`special[0]=imm` then `local[0]=special[0]`). 🟡 The 389 are a real limit:
they are started from the **trigger queue at `phase+272`** — by data, not code. ▶️ **Still open:** the 35 unnamed built-ins (`builtin16` 132
sites, `builtin105` 117, `builtin103` 115 — each now a vtable-slot lookup);
**(2026-08-27) partial — [structures/isl-unit-args](structures/isl-unit-args.md):**
reading the implementations shows **55** built-ins take a unit at `local[4]`,
not 31. All 31 of the statistical set are confirmed (**zero false positives**);
it missed **24**, incl. `builtin80`, `group_ratio_pct`, `is_engaged`,
`set_unit_flags`, `squadron_trace`, `wait_units_ready`. ⚠️ My first control
(operands resolving to a symtab-2 index) was **worthless** — the 92 built-ins
in neither set also score 99.3 %. The control that works is the **tag word**:
`slot0 == 1` in 100.0 % / 100.0 % / **2.5 %**. Artefacts: calls + phase-ends
byte-identical, conditions changes on 28 sites (raw number → unit name).
**(2026-08-27) The three built-ins that appear INSIDE clear conditions are
READ — [structures/isl-condition-builtins](structures/isl-condition-builtins.md)
— but none is NAMED.** `builtin104` (slot 288, `0x8226BFE0`) is a **three-
instruction pure getter for `[phase+10160]`**, so all six tutorial stages end on
one engine-written word; that word has **exactly one writer** in the image
(`sub_821AA1B0` @`0x821AAD9C`, gated on a kind field in `(16, 32]`) and `r29`
there is a call result, so its meaning is unestablished. `builtin7` (slot 40)
indexes the unit array by `local[4]`, bails when `rec+16` (the unit state) is
1/3/4, and resolves **`local[12]` through `[phase+244]` = symbol table 1** —
independently confirming `isl.SYM1_SLOTS[12] ∋ 7`, which was derived from
operand ranges alone. `builtin141` (slot 428) has the same unit-array entry and
returns 0 when the unit is dead; the rest is unread. Its Stage-16 call pair
differs in one argument (`0` vs `-4000`) which LOOKS like a coordinate — exactly
the evidence this corpus does not accept.
Still unnamed: all 24. `builtin103` is a predicate over `[phase+10152]` and
`[phase+10156]` (no unit arg); `builtin105` tests a unit record's `+16` == 4
(`isl-builtins.md` documents `rec+16` as the unit STATE, 2 = active).
🔴 **(2026-08-27) `builtin80` is a COMMAND, not a predicate** (`0x82268460`):
it allocates a 20-byte object, stamps vtable `0x820A8CB0` + magic `0xAB0311BA`
+ the unit's live object, pushes it on a queue via the `push.i` helper, and
returns 1 (or 0 if the unit is absent). Finding that **exposed a much bigger
bug in my own condition tracker** — see the entry below.
~~which condition guards each `END_PHASE` (needs the control flow between them);~~
**(2026-08-27) DONE — [structures/isl-phase-guards](structures/isl-phase-guards.md)**
(`isl_report.py phase-guards`, `data/isl-stage02-phase-guards.txt`). Uses
DOMINANCE over the CFG, not reachability. 🔴 The obvious query — "one branch
reaches `END_PHASE`, the other doesn't" — is **wrong for this language** and was
tried first: poll loops have BOTH successors reaching the exit, so it found
1/62/1 guards in Stage 02's three phases, the 1s being a `read_freg(0) < 1200`
timeout, missing every objective test. Dominators converge in 3 passes over
15670/18739 instructions. Result: **every exit in all 3 phases requires
`unit_hp_pct(TCN001, Character_Player_Test) != 0`** (the player alive); phase 1's
objective exit additionally requires `hp_pct_test(ADT102/ADT107/ADT113, 0) != 1`,
phase 3's requires `ADT301`/`ADT302`, and `read_freg(0)` gates at 210/300/1200.
~~🟡 Dominance gives NECESSARY not sufficient conditions.~~**(2026-08-27)
THE SUFFICIENT SIDE IS DONE.** `isl_cfg.must_reach_exit()` = a least fixpoint of
nodes from which `END_PHASE` is unavoidable (n qualifies when it has successors
and ALL qualify — conservative, so a loop never enters, which is correct because
a poll loop's exit depends on a LIVENESS property). A dominating condition is a
**TRIGGER** when its satisfying successor is in that set. **732 dominating
conditions, 234 triggers (31.97 %)**, and the artefacts now tag every line
`precond` / `TRIGGER`. Stage 02 phase 1's objective exit = 6 preconditions plus
ONE trigger, `hp_pct_test(ADN101, 0) != 1` — destroying ADN101 is what fires it.
Per-exit: **89 of 172 have exactly one trigger**, 42 have none (those are the
timeout exits — time passing is not a graph property, so declining to call it a
trigger is correct), 41 have several. 🟡 "the FIRST trigger is the point of no
return" holds **33/41**, not universally — the listing is ordered by file offset,
which is not execution order — so it is recorded as a heuristic, not a rule. 🟡 2 of 15 exits are
reachable from NO static entry — consistent with the trigger queue at `phase+272`.
**(2026-08-27) EXTENDED TO ALL 28 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 (S18S23) have exactly one exit with
exactly one dominating condition, `builtin104() != 1` — and `isl-builtins.md`
derived built-in 104 from usage alone as "S18S23 only, a textbook poll loop".
Two unrelated methods agree. S16 (the compiled-C++-script outlier) resolves too:
`read_freg(0) < 600`, `player_gauge0/1_test`, and two `builtin141` calls differing
only in one argument (`0` vs `-4000`) — a position/zone shape, unread so unnamed.
the vtable's length. The condition lives in the `op10`/`op13` poll loop upstream of
the outro — e.g. phase 3 polls `unit_state(ADT308)` and branches back to
`0xFEB4` until it passes. Artefact: `data/isl-stage02-phase-ends.txt`.
* 🐛 **(2026-08-25) The nav fix is NOT fully reliable.** `dialog_up.py` works on
the saved frame (mean 34.3 vs threshold 45), and one boot went
readyroom-in-9 s — but a later boot logged "load dialog not up yet, retrying"
and still ended `NO readyroom within 300s`. So the retry loop can desynchronise
(a shot taken before the dialog renders reads as absent, and the *next* A then
answers NO). Needs a settle-and-recheck rather than one shot per attempt.
* ~~🔴 **Not settled: the script bytecode is not on the disc under any obvious
name.**~~ No `GP_SCRIPT.pak`; grepping the extraction for `MISSION_START_PRT`
returns nothing. Loader `sub_8225EE20` matches section names
`MISSION1..MISSION33` + 5 `*_PRT`; `sub_8225EC78` gates `if (n==16 || n>32)`.
Candidates: the **7 `.embsec_` sections** (VAs 0x84D00000x86AC000, ~129 KB
total, executable) or a hashed record in `hidden/MiscBin.pak`. **Finding it
gives the actual per-phase clear condition for every stage.**
***(2026-08-26) RETRACTED — "the resupply banks are missing audio."** The
error was mine and it stood for three iterations across two write-ups that each
called the result proven: I treated a subtitle cue as a timestamp that must
fall *inside* the voice clip. **A cue is when the line STARTS** — the voice
plays from the cue to at most the movie's end. Under that reading all five
banks fit at plain **48 kHz** (3.31 s in a 5.30 s window, 2.26 s in 4.60 s, …),
and 23.5 s is the right length for the lines. Nothing is missing. The
17 09120 563 Hz window from the previous entry is void with it.
**Each shared bank is ONE generic line** — the 35 movies sharing a bank have
**identical subtitle text**, 5/5 banks (`examples/shared_bank_takes.rs`).
🎯 **That also explains the historical in-game rejection** of
`hokyu_DS_s13A → VOICE_D_452`: the line is the generic "Resupply complete. You
are cleared for take-off!", the same for s02A/s07A/s08A/s13A. Someone expecting
a stage-13-specific line would call the generic one wrong while the binding is
right.
**(same day) The two parts are SEQUENTIAL SEGMENTS, not duplicates** — so the
totals do not double-count and the 48 kHz fit stands. Measured by RMS:
`450`/`451`/`452` have a **silent or near-silent** leading region (RMS 0301
against ~9 000 for speech) with the line in the RIFF; `453`/`454` have the line
in the **leading** region with a short loud tail in the RIFF.
🎯 **That closes the original mystery.** The decoder skips everything before the
first `RIFF`. For the first three that discards only silence, so they looked
fine (2.8 / 1.6 / 2.2 s); for the last two it discards **the line itself**,
leaving 0.14 s and 0.43 s. One rule, two outcomes, depending on which segment
holds the speech. ✅ **(same day) LANDED.** `to_xma_riffs` emits the leading segment
wrapped **mono** when it is a whole number of packets and carries a non-zero
byte. Both reasons the first attempt was withdrawn are answered: it used the
stereo format (mono yields up to 113× more), and while the byte-level reach is
still 1524 entries the **audible** reach is not — across the 84 movie-bound
banks it adds >1 s to exactly **7**, the `hokyu_*_H` tankers on
`VOICE_D_453`/`454`, and ≤0.25 s to 66 of the rest. ⚠️ The safety oracle is
weak and says so: **8 of the 84 already exceed their movie duration before the
change**, by hundredths of a second, so it establishes scoping rather than
correctness. Pinned by `tests/slb_leading_segment_disc.rs`, including that the
all-zero `VOICE_D_451` region stays skipped. ❔ Not verified by ear — that
needs a human.
***(2026-08-25) The `.slb` "multi-subwave" guess is REFUTED, and the voice
decoder is discarding up to 87 % of a bank.** The record table gives a
**direct** binding `hokyu_DS_s13A -> VOICE_D_452` where the corpus records the
movie as unbound and a test asserts `None`, citing an in-game verdict that the
same value was "the wrong recording". Measured: the RIFF-magic count equals the
sub-wave count in all five hokyu banks, so nothing between or after sub-waves
is missed — the recorded "likely multi-subwave / not cleanly sliced" is wrong.
The audio is lost because a **large region precedes the first RIFF** and
`slb::to_xma_riffs` finds audio by searching for that magic: **87 % of
`VOICE_D_453` and 85 % of `VOICE_D_454`** sit in front of it, 2127 % zero over
256 distinct byte values — content, not padding. `VOICE_D_451` is the control,
its leading region being 100 % zero / 1 distinct value. 🟡 So the in-game
verdict tested a decode that had thrown away most of the bank and is **not**
evidence against the binding — though it does not confirm it either.
**(same day) The region's SIZE is now exact**: the first `RIFF` sits at
`1392 + n*2048` in all five banks (n = 8, 1, 7, 22, 29) — 1392 being the
crate's own `HEADERLESS_DATA_OFFSET` and 2048 the XMA1 packet size. No free
parameter.
**But my fix for it is WITHDRAWN.** Emitting that region as a sub-wave took
`VOICE_D_453` from 5.4 % to 89.9 % byte coverage — and the stream decodes to
**1792 PCM bytes**, silence, while the RIFF sub-waves decode to 150270 KB.
Byte coverage was the wrong success metric. The rule also matches **1524 of
8021** RIFF-bearing `sound.pak` entries, including `RT*` banks that work today,
so it risked a wide regression to not-fix five banks.
**(same day) Audio really IS missing — proven by the subtitle cue times**,
not by the "sounds too short" impression the docs recorded. A subtitle that
appears at *t* seconds cannot sit inside a clip shorter than *t*, and three of
five banks fail that: `D_450` cue 4.00 s vs 1.41 s decoded, `D_451` 3.70 vs
1.81, `D_453` **4.70 vs 0.07**. The other two have their only cue at 0.0 s and
give no signal. Artifact `examples/voice_len_vs_subs.rs`, FFmpeg-measured.
**(same day) The leading region IS XMA1 — MONO, not stereo.** At
`channels = 2` every bank decodes to *exactly* 1792 bytes regardless of size
(one frame, then it stops); at `channels = 1` the same data yields up to
**113× more**`VOICE_D_453` goes 1 792 → **203 648**. The bank's own RIFF
sub-wave is decoded through the same pipe as a control (13 568 bytes), so the
harness is sound, and the all-zero `VOICE_D_451` region is the control the
other way. The earlier 0-byte probe was my own error: I read
`synth_xma1_fmt`'s second argument as a stream count when it is a **channel
mask**. ❌ Solving for the sample rate from the subtitle cue **does not
converge** — 21 665 Hz for `D_453` (temptingly near 22 050, and I nearly wrote
it down) but **5 844 Hz** for `D_450`. The decodes are partial: samples per
input byte ranges 2.104.96 where a clean decode would be near-constant.
**(same day) Why FFmpeg stops is captured** — I had been discarding its
stderr. It reports an unimplemented "Reserved bit" and a negative bit-skip, and
the failing frame is always the **last** one (44 of 45.7, 28 of 29.4, 198 of
198.9, 287 of 287.5). ❌ **That corrects my own previous claim**: "the decodes
are visibly partial" was wrong — the 2.104.96 samples-per-byte spread is
ordinary XMA1 variable bitrate (4.219.92 frames per packet), and only the
final frame is lost. ❌ The sample rate still does not converge, including
after counting leading region + RIFF together: 39 742 / 20 563 / 23 108 Hz for
the three banks with a usable cue. Two of them agreed at a tidy ~2.1× ratio and
the third refuted it.
**(2026-08-26) The cue unit IS seconds** — checked against movie duration as
an independent oracle: **66 movies with subtitle tracks, 0 cues land after
their movie ends** (`examples/cue_unit_check.rs`). Centiseconds would have
overflowed essentially all 66.
**And "the sample rate does not converge" was my own error.** The implied
rates are not competing point estimates — each is a **one-sided bound**
(`samples/cue` is an UPPER bound, `samples/movie` a LOWER one). Intersected,
they give a **non-empty window of 17 091 20 563 Hz**: a single rate *is*
consistent with all three banks.
❔ But that window holds **no standard XMA rate** (22 050 / 24 000 / 32 000 /
44 100 / 48 000 all fall outside). The lower bound assumes the whole bank plays
within one movie, and each bank is bound to **35 movie slots** — so if a bank
holds several takes the lower bound is void, leaving `rate <= 20 563`, which
22 050 nearly meets. ▶️ Next: establish whether a shared bank is one line or
several takes. See
[`voice-bank-leading-region.md`](voice-bank-leading-region.md).
***(2026-08-25) My own boot-nav diagnosis, MEASURED AND WITHDRAWN.** I said
the run died because `skip_intro.sh` gates the title test at `rmse <= 1500`
and the run logged 1503/1549, just above the cut. Measured over a clean
no-press boot ([`boot-nav-title-gate.md`](boot-nav-title-gate.md),
`captures/boot-signal-trace.tsv`): the gate **opened eight times** in 29
samples, and at `t=145 s` the RMSE was **1205** — inside the threshold — with
`is_title.py` answering **0 glyph pixels**. The glyph count was 0 in *all* 29
samples over 484 s. A static frame is not the title; the intro movie has long
quiet stretches, three of them reading RMSE exactly 0. So the gate is not what
stopped it, and raising the constant would have admitted two more movie frames.
🟡 Narrowed: **the interactive title never appeared**, rather than appearing and
being missed. ❔ **But the run does not prove that** — the tracer intended 1 s
sampling and achieved **16.9 s** (two screenshots + ImageMagick `compare` + a
fresh Python per iteration), so a title window under ~17 s falls between
samples. ▶️ First step: make the tracer sample at the rate it claims — one
long-lived Python process computing both signals — then re-run. Second: check
whether the fast 2026-08-25 run had a warm shader cache that this one, started
after `rm -f /dev/shm/xenia_*`, did not.
* 🚧 **(2026-08-25) Still unrun — the live test of the `reset_phase_threads`
rename.** Built-in 100 clears the trigger container and frees every thread but
the caller, so at a phase terminator **both** `[phase+272+20]` (triggers
queued) and `[phase+216+8]` (coroutines alive) should collapse; if they climb
straight through a phase boundary instead, the reading is wrong.
`tools/re-capture/phase_watch.py` prints both — **written, still never
exercised against a live guest.** Blocked behind the boot-nav item above.
* 🟡 **(2026-08-25) The legacy IDXD string-pool reader is wrong far more often
than assumed** — now measurable for the first time, since the record table gives
a ground truth. Verified by hand: `FCSRange` (the module doc's own canonical
"field with no value") is really `500000.0`; `ShieldRatio` is `1.0` where a
**committed test asserted `None` and called it defaulted**; and
`get_raw("Model")` on the hangar table returns the *first* record's model for
every record — silent corruption, not an absent value. Single-source disc-wide
rates: `get_raw` 52 % wrong, typed getters 38 % miss — but **100 % correct on the
548 single-record objects**, so all the damage is the flat API having no way to
say *which* record it means. ▶️ **Open work:** re-read every per-record number in
this corpus through `IdxdObject::record`; highest value first — hangar models,
weapon `Power`/`Acceleration`/`MinimumVelocity`, turret and subsystem stats.
See [`idxd-legacy-reader-audit.md`](idxd-legacy-reader-audit.md).
***(2026-08-25) The 504 unnamed IDXD field keys were NOT recovered.** All sit
in `GP_READY_ROOM.pak`'s sound-bank table (6 identical objects × 2 records × 42
keys). A dictionary of 572 464 strings — every pool string disc-wide, PE ASCII
and UTF-16 runs, every identifier in this repo — plus 73 191 case/affix variants
gave **0/42**. The key deltas across `stage01…stage16` do prove the preimage
**ends with the two decimal digits**, and a meet-in-the-middle found nothing
word-like at ≤8 lowercase chars. 32 bits is not invertible without the right
wordlist; parked.
***(2026-08-25) The IDXD/IXUD container is fully decoded** — the "binary
node/index region" in front of the string pool is a **uniform 16-byte record
array** `{name_hash, name_off, field_begin, field_end}` sorted by hash, then a
field count, **12-byte fields** `{key, name_off, value_off}` sorted by key, then
a pool size and the pool. Verified over the *whole* disc with zero failures:
IDXD **7 750/7 750** objects, **190 782/190 782** records, **1 271 462/1 271 462**
named fields; IXUD **1 104/1 104** objects, **628 165/628 165** fields (offsets in
chars). **Field names are stored on disc**, so no preimage search is needed —
only **504** fields disc-wide are hash-keyed with no name.
🔴 **Two corrections:** the header word at `0x08` is **not a schema hash**, it is
record 0's `name_hash` (7 750/7 750) — the format has no type field at all, so an
object's kind is known only from its loader; and the field's middle word is not
an `aux` flags word. See [`structures/idxd-container.md`](structures/idxd-container.md).
⚠️ My first disc sweep globbed `dat/**` and **missed `hidden/DefTables.pak`**
(1 425 objects); the test now walks the whole disc root.
▶️ **Follow-up now open:** the legacy value-before-key string-pool reader is an
*approximation* of the real table, and every number in this corpus that came out
of `get_f32`/`get_raw` is re-checkable against ground truth but **not yet
re-checked**. First step: diff the two readers across the disc and count
disagreements. Also open: recover the 504 unnamed hash keys.
**(2026-08-27) DIFFED — see [idxd-legacy-reader-diff](idxd-legacy-reader-diff.md).**
Over 7 750 objects and 738 922 numerically-valued named fields, legacy
`get_f32` is correct 39.42 %, safely `None` 43.04 %, and **returns a wrong
number 17.54 %**. The error has an exact predicate: **0 of 29 822** fields wrong
in single-record objects, 18.28 % wrong in multi-record ones — because
`get_raw` finds the *first* occurrence of the key in a flat token list and has
no notion of records, so every record after the first inherits record 0's value.
***(2026-08-25) Both guest hash routines located** — `sub_82447DF0` (IDXD)
and `sub_82447E70` (IXUD), transcribed instruction-for-instruction into Python
and Rust; `cargo test -p sylpheed-formats --lib hash` 10/10. **IXUD SOLVED:**
it chains **two** exact moduli (loop mod `2^32-153` in 64-bit, then fold mod
`2^24-33`), which is why no single-modulus search could ever find it —
86/86 keys and 108,261/108,261 tags verified. 🔴 **Two of my claims corrected:**
`tag_hash` must **sign-extend** (`extsb`) — the unsigned version matched all
1.27M disc names because every one is ASCII, but differs on 18,096/20,000
random high-byte inputs; and **`name_hash`'s reduction is EXACT, not lossy**
(0 wrong at every quotient boundary over the full 32-bit domain).
***(2026-08-25) BOOT-NAV BUG FIXED and verified by artifact.** `dialog_up.py`
detects the dim the game draws behind a modal (mean 34 vs 5962), and
`wait_screen.sh --tap-if-dialog` only presses while one is up;
`launch_mission.sh` now verifies the "Load game?" dialog is actually open
before selecting YES. Next boot: **readyroom at 9 s, IN FLIGHT at 37 s**
(against three prior 300 s failures).
***Tooling:** `.pe` is **NOT stale** — it is a flat VA image
(offset = VA 0x82000000), verified 7/7 against the DB. And
`instructions.raw` in `sylpheed.db` is an **INTEGER**, not a hex string —
decoding it as hex silently compares nothing and nearly recorded this
correction backwards.
* ~~❔ **What ADVANCES a phase is still unknown**~~ and is not in the data: swept the
stage record and every table it names plus the `message\` family for
`interval|time|phase|delay|wave|spawn|arrival|trigger|start|appear|event|condition`
— only `FrameCount` and `PresetMessage_Phase1/2/3` hit. It is in the executable.
* 🟡 ~~**Does NOT close the 387-vs-~300 gap**~~ in `roster-to-craft-link.md`. Σ`n`
over Stage 02 is 387 vs 296300 live craft, but that was measured mid-mission
after kills and squadrons deploy across phases, so they are not comparable as
they stand. The earlier rejection of `n` assumed everything deploys at once —
**still untested**. The record-key derivation also stays ❔ (the tag is not
`name_hash`; a second hash function is unidentified).
***S18S23 stage records were never missing.** `stagetbl.py Stage_S18` returns
a full six-record definition and always would have; it was never run. They omit
the literal `Stage_S<NN>` (no per-stage `.xpr`; `AIParams`/weapons/strings/
subobjectives/nameplate/collision all come from a shared **`_Tutorial`** set),
so an enumeration keyed on that literal skipped all six. Counting distinct
`*_S<NN>` names across all **1119** decompressed entries: `UnitGroup` and
`Route` cover **28** stages (S01S16, **S18S23**, S24S29); `Stage` literal and
`AIParams` **22**; `SUBObjectiveSettings` **16** (story only). Tutorial records
carry the same `Phase_1/2/3` shape, so the tutorial is not a special mission
type at the data layer. Dump in `data/stage-tutorial-records.txt`.
***S17 alone is genuinely absent** — it appears in *none* of the five families.
Not a stage that lost its data; it does not exist. Matches the family split in
`challenge-mission-gate.md` (story 116, tutorial 1823, challenge 2429).
* 🔴 **Refuted:** `GP_TUTORIAL.pak` does **not** hold the tutorial stage config —
2 entries, both RATC, zero IDXD, exactly like `GP_CHALLENGE.pak`.
## The dynamic-RE state is not in git, and it was gone
**Found 2026-08-23.** Everything the oracle runs on — the baseline emulator
binary, the Xbox profile, the Stage 02 save, the shader/code caches — lives
outside both repos and had been wiped. `sylph-doctor` says "all good" without
any of it; the first symptom is `NO PROFILE on disc` one second into a boot.
**Rebuilt and verified by driving it** (LOAD GAME lists the slot → READY ROOM →
Stage 02 flight): [`dynamic-re-state-restore.md`](dynamic-re-state-restore.md)
carries the recipe — incremental rebuild of `auto/upstream-baseline` in the
shared checkout (202 files, no submodule churn), profile bootstrapped with the
*instrumented* binary's `--create_profile_if_none`, and the committed
`savedata-stage02-5pct.bin` installed **without** an Xbox content header, which
`ContentManager::ListContent` does not need.
**Open, and cheap:**
***`launch_mission.sh` finishes unattended again** (2026-08-23, later):
boot → title → LOAD GAME → slot 01 → READY ROOM → TAKE OFF → `IN FLIGHT at
34s`, pilot bound and engaging. Two defects, not one: the fixed `sleep 28` for
LOAD → READY ROOM (now `wait_screen.sh readyroom`), and the READY ROOM being
**drawn before it is usable**`Preparing to Sortie`, TAKE OFF greyed, which
whole-image statistics cannot see (1.7 units of blue) so `take_off_armed.py`
tests the label. A third defect fell out of the same run: `wait_flight.sh` was
testing pixel (450,640) "inside the SHIELD bar" of a **1280×720** window, while
`screenshot` crops to the **1279×675** game surface — it lands between the
SHIELD and ARMOR bars. That is the long-standing "reported NEVER REACHED FLIGHT
while plainly in flight" note, now explained and fixed.
***Nothing guarantees this state survives the next container.** If it is
meant to, the profile + save + `bin/` copies want a home inside a repo or a
named volume; that is a call for the user, not for an agent.
## ✅🔴 SOLVED (2026-08-23) — the *method* for finding the mission objective counter is automated; the **address is run-dependent**
*(Retitled 2026-08-26: the old heading asserted `0xbdb59668` as the answer while its own first line refutes that address. The method is the result.)*
🔴 **`0xbdb59668` is refuted as a durable address** (2026-08-23): 0 in two
independent Stage 02 runs while the HUD read `004`/`008`/`012`, on an allocated
(not sparse) page. The **method** stands; the number does not, and every session
must re-scan. Two candidates from the re-scan were themselves refuted by the
corpus's own "verify across a transition you did not select on" rule. Detail and
the corrected method note (the scan takes **0.9 s** — the trap is the counter
climbing `004 → 012` in four minutes, not scan duration) in
[`structures/mission-objective-counter.md`](structures/mission-objective-counter.md).
**Settled the same day, once the HUD stopped costing a human round trip.**
`ob_read.py` reads the three digits by normalised template correlation and
`ob_hunt.py` runs the whole method unattended; run 4 then gave **one** survivor
from 35 897, selected on `004 → 008` and verified on the unselected `008 → 012`,
plus three live paired RAM/HUD readings. The blocker was never the pilot's
survival — the evidence lives in the first four minutes of the stage, and the
earlier runs simply could not look often enough to catch the `008` step.
🔴 Yesterday's refutation **stands, refined**: the address is not universal (runs
2 and 3 read a hard 0 there while the HUD counted), but it is not meaningless
either — it recurs exactly, and run 3's amber candidate sits one 64 KB page below
it at the same page offset `0x9668`. Rule: try `0xbdb59668`, check it against the
HUD, re-scan (~5 min, `ob_session.sh`) when it reads 0.
The follow-on that the autopilot actually needs is unchanged and untouched:
❔ what the counter counts, and whether an `OB`-badged entity carries a flag in
its entity object.
## What `REMAINING OB` counts — and an in-mission freeze in the way
**2026-08-23.** The address is settled (above); *what it counts* is not, and it
is what the autopilot needs in order to CHOOSE a target. One run in:
* 🔴 **Not a live class head-count.** Counter 4 against 8 attackers / 7 friendly
Delta Sabers / 7 turrets / 1 player — no class matches, no pair sums to it.
* 🔴 **The per-entity flag is REFUTED** (2026-08-23, final): sample A at counter
12 over 120 entities gave **2** candidates; the counter went **12 → 11** and
**neither survived**. Within ±0x400 of the position triple there is no 4-byte
word whose shared-value population tracks the counter. ❔ **Not** ruled out: a
single **bit** ORed into a word that also varies (the test needs an exact
shared value), anything outside that window, and anything on entities
`entities2` cannot see — it types by position *changing*, so stationary
objectives are invisible. 🔴 **The bit-level differential is REFUTED too**
(2026-08-24): two independently selected transitions in one run — scan at 4
filtered on 4→8 (29 of 710 survive), scan at 8 filtered on 8→12 (2 of 197) —
and the **intersection is empty**. No per-entity bit in either polarity tracks
the counter; 16 of pass 1's survivors were the same word `+0x250` with
different bits, i.e. a *shared value*, not a flag. Earlier note: 🔎 **Built**
(`ob_bitflag.py`, 2026-08-24) and run three times with **no verification
yet**: one window was spent on a mission that had already ended in GAME OVER,
one hit the same dead mission, and the third had the counter at a different
address and then froze after a single filter. Sample A alone gives ~187
set-polarity + 33 clear-polarity candidates at counter 4, so the second
transition is the whole test.
* ⚠️ **Attrition is now the dominant cost of every in-mission item.** Roughly
half the runs that reach flight end early — a freeze, or a GAME OVER when the
ACROPOLIS or the craft is lost — and a scan needs the run to survive **two**
counter transitions. `frozen.in_flight()` at least makes a dead run say so
immediately instead of waiting out its window.
***What the counter's neighbourhood IS — the HUD glyph quads**
(2026-08-24). The four pointers that move with it lead to objects whose vtable
is `0x820B2A64`: **32 slots**, methods `0x823c43b0…0x823c45a0`, **three**
construction sites in `sylpheed.db`. Each instance is a **textured quad** — a
pixel size (34×42 for a digit) and four vertices of `(colour, u, v)` — and the
UV rectangle × **1280×768** reproduces that pixel size to a rounding step, so
the font atlas size is measured, not guessed.
See [`structures/hud-glyph-quad.md`](structures/hud-glyph-quad.md).
* 🔴 **The counter is not "hostiles left" either.** It held at `012` for fifteen
minutes of live flight while the ADAN population fell 132 → 93.
***But it decrements when the player kills**: `12 → 11` with 411 `fire=1`
samples and `YOU KILLED WARPLANES 0003` on the HUD — the first decrement seen,
and the first run where the player's guns were part of the experiment.
***`pilot.py` never fires — ROOT CAUSE FIXED** (2026-08-23):
`flight_probe.Pad` was writing to the **vgamepad FIFO**, dead since the uinput
pad was replaced by `--hid=file`, so every axis, trigger and button from every
flight tool went into a file nothing reads while `/tmp/xenia_pad.txt` stayed
empty. The craft was never being flown. Verified: full stick went from `0.00°`
of heading change to `12.72°`, and the attitude matrix from `d 0.0000` to
`d 0.4438` — which also **refutes** the "stale attitude matrix" suspicion.
**And the second half: the PITCH stick sign was inverted.** Measured on a
45° error, both sides, two pulse widths, with the opposite sign as control:
the pilot's sign grew the error every time, the opposite shrank it every time.
Fixed, and **the pilot fires**`fire=1` in **43 of 1 732** samples against 0
of 13 521, aim down to 2.3°, range median 43 km → 6.3 km, and the HUD's own
ammunition counters moving. 🟡 Still open: `YOU KILLED` is `0000` after 250 s
of firing and `REMAINING OB` is still `012` — whether it *destroys* anything is
the next measurement, and the objective-counter item is waiting on it.
⚠️ `findrot_global.py`, `findself.py`, `findspeed.py`, `selfstate.py` still
write to the dead FIFO and `ctrl_probe.py`/`target_probe.py` still use
`pad.f`; all flagged in place, none repaired.
See [`pilot-never-fires.md`](pilot-never-fires.md).
***`EMULATOR GONE` is SOLVED — it was this project's own `Stop` hook**
(2026-08-24), which `kill -9`s `xenia_canary` at the end of every agent turn.
Every "mysterious" death was a turn boundary. **Operational rule:** an emulator
experiment must complete **inside one turn** — nothing can be left running for
a later tick, and a watcher armed for 1 500 s only watches the rest of *this*
turn. Memory pressure was raised and refuted along the way; that measurement
stands, it just was not pointing at anything.
* 🔴 **An in-mission freeze — the item in front of everything else.** Reproduced
with the Kernel channel on. 🔴 The resume-spin lead is **refuted by its own
control**: a still-flying run has *more* refused resumes (2 738) than a frozen
one, because the game runs a self-suspending worker and the host refusal is one
per cycle by design. ✅ What is established instead: the guest is **spinning,
not deadlocked** — over 10 s while frozen the main thread is in state `R`
gaining 409 ticks, guest threads ~680 in total, and **not one kernel call** is
made. So it is guest code waiting on guest memory. ✅ **Seen from inside** (2026-08-24, gdb): all **79** threads are in a
**wait** — guest threads in `KeWaitForSingleObject`/`NtWaitForSingleObjectEx`,
the GPU processor idle, the main thread in `poll()` — while the process still
burns **1 253 ticks / 10 s**, 403 of them in the **TimerQueue** thread and
~280 each in two guest threads the backtrace shows *blocked*. So they are
**cycling through a timed wait**, and the CPU burn is in the kernel layer's
wait path, not in guest code. No Canary build was needed: `XENIA_BIN` pointing
at a gdb wrapper keeps the lockfile and satisfies `ptrace_scope=1`.
🔴 **Reading the wait target from the log is blocked by cost**:
`KeWaitForSingleObject` is `kHighFrequency` and silent without
`--log_high_frequency_kernel_calls=true`, and *with* it the emulator is 17
minutes into a boot with a **black screen** and 175 MB of log. ✅ **Built** (canary `auto/re-wait-timeout-probe` `820696c11`,
`--log_stuck_waits=true`, binary at `/sylph-home/re/bin/waitprobe/`): counts
consecutive timeouts on the same object per thread and logs at 100 then every
500. **Healthy-run control measured** — 27 lines over 25 minutes, all one
thread polling one Event at guest VA `BE56BB5C` with a ~30 ms timeout, so the
freeze signal is a **new (thread, object) pair**, not the presence of output.
🔴 **A freeze WAS caught (2026-08-24) and the probe says nothing.** It froze
9 s into the watcher's window, in flight, and reported the healthy baseline
only — one pair, same object VA, no new (thread, object) pair — while the CPU
signature was unchanged (1 255 ticks/10 s, 401 in the TimerQueue thread). So
the freeze is **not** a thread looping on timeouts against one object.
**Two blind spots survive:** waits cycling over *different* objects (the streak
resets, so they are invisible), or waits that **succeed** rather than time out
(nothing for a timeout counter to count — which fits the self-suspending worker
seen cycling successfully in the kernel log).
**v2 built and its baseline is itself a result** (canary `597740046`): it
counts every call per thread per second with the distinct-object count and the
return value. On a healthy 22-minute run the **main thread cleared 500 calls/s
in 224 windows, peaking at 1 235/s over up to 13 distinct objects**, and the
result was `X_STATUS_SUCCESS` in **all 314** windows — not one timeout. So the
game's normal mode is hundreds of *successful* waits a second across many
objects, which is exactly what v1 could not see.
🟡 **Consequence:** 500/s is not self-selecting, so the freeze signal must be a
*different shape* — far above 1 235/s, a new thread, or a non-SUCCESS result.
**Next:** a frozen sample to compare against; runs 5 and 6 did not freeze
(GAME OVER at ~22 min, and still healthy at 10 min). **Three** runs in a
row have now failed to freeze (the third ended in GAME OVER), and the probe's
healthy control is measured three times — 27, 24 and 36 lines, always the same
single pair. `frozen.py` detects the state in one call; `ob_hunt.py` /
`ob_flag.py` abort on it. **Roughly two runs in three.**
See [`mission-freeze-resume-spin.md`](mission-freeze-resume-spin.md).
> ### ✅ 2026-08-27 — the "first step, revised" below is DONE, and `--log_mask=0` is a dead end
>
> **The counter moves.** `pilot.py` gained `SYLPH_WEAKEST=1` (score scaled by the
> target's remaining hull, so fire concentrates on one already-damaged craft
> instead of spreading over a squadron) and the very next run drove
> `REMAINING OB` **008 → 007**, concurrent with the live `e010` floor dropping
> 16 → 15. `REMAINING OB` is now fully solved — see
> [`ob-counts-marked-attackers.md`](ob-counts-marked-attackers.md). So the
> obstacle this entry names is cleared; what the freeze work still needs is only
> a **frozen sample** for the v2 probe to compare against.
>
> ❌ **Do not try `--log_mask=0` for that sample.** Measured on a full Stage 02
> run: **199 MB of log**, growing **~33 MB/min**, and a 300 k-line tail is
> **254 127 `A>` (Apu/XMA)**, 42 444 `d>`, 2 897 `G>`, 532 `w>` — with **zero**
> `k>` and only **14 `K>` lines in a 58 k-line boot sample**. `XamShowSigninUI`,
> `KeWaitForSingleObject` and `NtWaitForSingleObject` each appear exactly **once**
> in the whole 199 MB, i.e. in an export listing, never as call traces. That
> independently confirms the cost note above: kernel calls are `kHighFrequency`
> and stay silent without `--log_high_frequency_kernel_calls=true`, so `log_mask=0`
> buys nothing but audio spam. The purpose-built `--log_stuck_waits` probe remains
> the right instrument.
>
> ⚠️ That run also **did not freeze** — healthy from `TIME 00:24.28` to
> `03:33.28`, `frozen.py` animating throughout — making it the fourth
> consecutive non-freezing run across this entry and the last session.
**First step, revised:** make `pilot.py` shoot, then re-run `ob_flag.py`. The
freeze is no longer the blocker it looked like — a 25-minute run stayed
animating — and the actual obstacle is that nothing the pilot does moves the
counter, so there is never a second sample. If the counter still will not move
when the player is killing things, the next question is what *does* move it, and
the objective card's own wording ("shoot down all invading enemy fighters") is
the place to start. Second step, if that comes back empty: `entities2.typed` only
sees entities whose position *changes*, so a stationary objective is invisible to
it, and the enumeration itself would need widening before a null result means
anything.
See [`mission-freeze-and-ob-flag.md`](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`](structures/ui-rat-layout.md). **Status: 🔎 open — the pause menu is
right, the title screen is not.**
`ui-rat-layout.md` says the bundle's element declaration table lists elements
"in back-to-front order", verified 11/11 on the tutorial pause bundle. That
holds — the tutorial and in-mission PAUSE builds both composite correctly, and
`pgpeff02a` → parent 3 / `pgp_ttrl_btn10` at (546,288) / the 70 px button pitch
all reproduce exactly.
**`GP_TITLE.pak` build 7 does not.** Painting in declaration order puts
`ptbase2.t32` (the full-screen background art, element **13**) *on top of* the
`ptlogo1`/`ptlogo2` wordmarks (elements **05**), which the real title screen
obviously does not do. The pause bundles never caught this because their
elements barely overlap.
**What has been ruled out:** there is no depth/layer key in the 60-byte
declaration entry. Dumping every word across the title build's 30 entries, the
unknown fields are constant — `+28` is 0 everywhere, `+44` is `0xffffffff`
everywhere, `+56` is 0 everywhere — and `+36`, which the doc lists as
`0xffffffff`, is not a depth either: it is `0`/`1` **only** on the `kind = 0x4`
repeated-instance entries (`ptlogo1`/`ptlogo2` copies), i.e. an instance index.
So the order is not recoverable by sorting the table on any field it carries.
**What that leaves.** The background group is contiguous — elements 12, 13, 14
are `pteff00.prm`, `ptbase2.t32`, `pteff04.t32`, and 12 carries `kind = 0x10`,
a flag no pause element has (theirs are `0x0` / `0x1` / `0x3002`). `pteff02.prm`
at 17 has it too. So `0x10` marking a `PRMD` primitive, and primitives opening a
layer that draws beneath what precedes them, is the cheapest hypothesis — but it
is a **hypothesis**, and "draw the `.prm` group first" would fit this one screen
without being evidence of anything.
**First step:** composite `GP_MISSION_SELECT` / `GP_READY_ROOM` / `GP_OPTIONS`,
which have both a background and overlapping foreground elements, and see
whether their background sits at a `0x10`-adjacent index too. Two more screens
agreeing turns the hypothesis into a rule; one disagreeing kills it. The
Explorer's `screen render`/`screen info` commands make that a minute's work per
screen, and the per-element visibility toggles isolate a suspect element.
**Meanwhile** the viewer paints in declaration order and does not pretend
otherwise — a screen whose background lands on top is showing you this bug, not
a decode failure.
### 2026-08-18 — measured against the running game; three orderings refuted, and half the symptom was a different bug
**The premise is confirmed by the oracle**, which this entry had not had: a
framebuffer capture of Canary on the title screen
([`captures/title-screen-oracle.png`](captures/title-screen-oracle.png)) shows
the `PROJECT SYLPHEED` wordmarks (elements 05) drawn **over** `ptbase2.t32`
(element 13), which is a full-screen background. Declaration order is therefore
not the paint order on this screen, and no reading of the element table changes
that.
**But part of what the render showed was not the paint order at all.** In the
capture `ptbase2` covers the whole screen; the compositor drew it as a
960×540-visible slab starting at (320,180), because a keyframe's `scale` was
being grown from the keyframe's corner instead of about the declared **pivot**.
Fixed, and pinned against the capture by cross-correlation (peak at (0,0)) — see
[`structures/ui-rat-layout.md`](structures/ui-rat-layout.md). That was a real
defect worth separating out: it moves **865** of the disc's 5 130 resting
placements, on every screen, independently of any ordering question.
**Three candidate orderings are now dead**, all cheaply:
- **The placement region is not a second ordering.** Its keyframe groups carry an
explicit element index, so they *could* be stored in a different order — they
are not, on **every** build on the disc (`placement_region_order_is_never_a_second_ordering`,
>500 builds, identity every time).
- **The RATC child order is not it either.** For the title build it is the
declaration order with the `.prm` elements absent — strictly less information,
and it has no place to put `ptbase2` other than where the table already puts it.
- **Reverse declaration order is refuted by the same capture**: it would draw
`ptbase2` (13) over `ptcopyright` (28), and the copyright line is visible.
**The `0x10`-adjacency first step was run, and it does not survive.** The
background *is* adjacent to a `kind = 0x10` `.prm` element on both screens that
have one — but on **opposite sides**. `GP_TITLE` build 7 is
`12 pteff00.prm (0x10)`, `13 ptbase2.t32`, `14 pteff04.t32`;
`GP_MISSION_SELECT` build 0 is `0 px_mission_base.tbm`, `1 px_mission_eff00.prm
(0x10)`. So "the `.prm` opens a layer that draws beneath what precedes it" cannot
place both, and no rule keyed on the `.prm`'s position orders the background.
`GP_READY_ROOM` and `GP_OPTIONS` turned out not to be the third and fourth
witnesses this entry hoped for: neither of their largest builds carries a `.prm`
or a full-screen background at all, so they cannot discriminate.
**What is still open, stated plainly:** nothing in the bundle has been found that
orders element 13 behind elements 05. Every ordering the file itself carries is
now either identical to the declaration table or refuted by the capture. The next
step is no longer static — it is either the guest code that walks this table, or a
per-draw capture of the title screen showing the order the game submits.
**Blocker, checked rather than assumed.** The obvious move is to reuse Canary's
existing RE instrumentation, which is already in the built binary on
`sylpheed-re`. Neither hook can answer this:
- **`--log_draws`** (`command_processor.cc`) de-dups by a *vertex-declaration
fingerprint* — shader hash + primitive type + per-stream element
formats/offsets + index-buffer guest base — and writes each distinct one once.
A screen's sprites share a declaration, so they collapse; and the record
carries no texture identity and no per-frame submission order, only first-seen
order. It is a mesh-format log, not a draw-order log.
- **The F10 ship capture** does preserve per-draw order within a frame and
de-dups on `(vertex base, WVP transform, index range)`, which would separate
the elements — but it **explicitly drops UI draws**:
`if (pos_off_bytes < 0 …) return; // no float-position stream (UI/effects) —
skip`. It requires an `f32x3` position attribute, which a 2D quad stream does
not have.
So this needs a **new hook in Canary** — log each draw in submission order with
its bound texture fetch (or its screen-space quad), gated behind a cvar the way
the other two are — and therefore a `build-canary` run. That is the cost to
state up front rather than discover halfway in; it is not a container
limitation, just a long build plus a title-screen run.
### 2026-08-18 (later) — the hook was built and run; the order is now measured
`log_ui_draws` exists (Canary branch `auto/re-ui-draw-order`), and the title
screen's paint order is **ground truth** rather than a candidate:
[`ui-title-paint-order-capture.md`](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`](ui-title-paint-order-capture.md)):
- **the geometry has no depth.** A UI quad's attribute 0 is `k_32_32_32_FLOAT`,
so it carries a Z — and every Z in the capture is 0.00000. Submission order is
the entire ordering.
- **the declaration table has no key.** Every word of every entry dumped for the
build the game actually runs: `+28` 0, `+32` `0xffffffff`, `+36` `0xffffffff`
(except an instance index on `kind = 0x4`), `+44` `0xffffffff`, `+56` 0.
- **the placement region has none either**, including its per-group lead word,
which is 0 for all 24 groups; and the region is followed straight by the RATC
child stream, so there is no table hiding behind it.
Also corrected: the running screen is **build 4**, not the largest build 7 that
`screen info` defaults to — the two disagree on sprite sizes and the capture
matches build 4. The conclusions are unchanged, the indices are not.
**So the next step is the guest code**, not the file: the splash draw path from
the emulator-era work (`sub_821CC7A0`, item vtable `0x820b30b4`) submits with
exactly the PS hash `E59B2B3D` this capture sees, and `xenia-rs/sylpheed.db` is
available in the container.
**And a second screen is NO LONGER BLOCKED, but it is not routine either.** The
main menu has been reached (screenshot in
[`canary-scripted-input-traps.md`](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`](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 12, so
"declaration order" and "background first" predict the same sequence. Same
failure mode as `GP_READY_ROOM`/`GP_OPTIONS`. The next screen worth capturing is
one whose background sits **late** in its table, as the title's does.
The earlier reading, kept because it is what the evidence looked like: the
title's Ⓐ leads into a content/save path that crashes the guest with
`--mem_watch=true` and stalls it with `--mem_watch=false`. Three separate traps
had to be cleared to establish that much — see
[`canary-scripted-input-traps.md`](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`](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`](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`](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`](ship-placement-capture-generalisation.md)
§4. So look at **the viewer**: first that it passes `include_external = true`
(`iso_loader.rs:4012` — with `false` an e106 loses its bridge and both nacelles,
5 parts instead of 11), then its own transform stack.
One real format-side bug was found on the way and is **fixed**: index-less parts
(`e105_brg`) never matched their `GN_Bridge_01` hardpoint, so 34 (stage, ship) entries
`e102`, `e104`, `e105` across Stages 0229 — assembled without a bridge. The other
apparent exception (`e105_eng_01` rotation) was an aggregation artefact and is 0.000.
The original report and its reasoning follow.
The reborn viewer builds capital ships from the split XBG7 parts via
`sylpheed-formats::ship::assemble_ship`, and they come out **wrong** — parts in the
wrong place / wrong orientation.
**Why this is a real finding and not a known limitation:** the RE write-up
[`ship-placement-runtime-capture.md`](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](captures/e106-static-assembly-volume-bug.png)). 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_01` is a small shared turret,
cross-mounted on `e101` and `e106`, and at its true size it is unremarkable;
- the original author's explanation of the capture's silence (**vbase dedup**)
stands, and my "dedup would show one, not zero" objection does not survive:
with the correct decode the turret is small, ordinary geometry;
- **the defect is in the mesh decoder**, which resolved this resource's vertex
data differently in three containers.
The render and the symptom are real; the cause named in the first version of this
entry was not.
### The part that matters more than this one resource
**The decoder can produce wrong geometry without declining.** The
[XBG7 audit](structures/xbg7-mesh.md) counted 814 resources it *refuses* — a
visible, honest failure. This is the other kind: `e303_wep_01` decodes "fine" in
`Stage_S02` and is silently 100× too large. Screening for the signature (bounds
that are exact multiples of 50 with a span over 1000) flags 2232 models in each
of `S02`, `S03`, `S08`, `S26`, `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 `e106`
render correctly ([after](captures/e106-static-assembly-fixed.png))
- **but it flipped the `e106` twin-mirror decision**, which
`static_assembly_matches_runtime_capture` (ISO-gated, so it skips in a plain
`cargo test`) catches against the runtime capture — so it was **reverted**
- the user-reported "capital ships assemble wrong" is therefore **diagnosed, not
yet fixed**; see [xbg7](structures/xbg7-mesh.md) for what the real fix needs
Still open from this entry: `static_assembly_matches_runtime_capture` walks only
the capture's parts, so **extra** static placements still cannot fail it.
### 2026-08-18 — that last line was stale, and the residual gap is now closed too
**The one-way-test complaint had already been fixed** when this entry was
written down: `64d372c` (the revert commit itself) added an extras check, so
"extra static placements cannot fail it" has not been true since. Checked rather
than assumed — perturbing the expectation makes the test fail with the real disc
behind it, so it runs and is live, not a `SYLPHEED_ISO`-less skip.
**But it compared a set of resource *names*, which leaves one direction open**: a
resource placed *twice* when the capture lists it once changes no set. That is
not hypothetical — a duplicated instance is exactly what a bad node walk emits,
and the two legitimate duplicates here (`e106_eng_01`, `e303_wep_01`) are the
reason the test had to special-case counts at all. Replaced with the full
**multiset**, pinned to the e106 ground truth:
```
e106_bdy_01 1 e106_bdy_02 1 e106_bdy_03 1 e106_bdy_04 1 e106_brg_01 1
e106_eng_01 2 e106_eng_02 1 e106_wep_02_01 1 e303_wep_01 2
```
— 9 resources, 11 placements, against the capture's 8 dedup'd parts. That
subsumes the two hand-written count assertions, and it now fails on an extra
resource, a missing one, **and** a duplicated one. Refuted before believing:
declaring `e106_bdy_01` twice makes it fail, with the real multiset on the left.
**Not closed by this**, and worth keeping separate: the multiset is `e106`'s
alone. The generalisation this entry originally asked for — a per-ship table so
a regression in one class cannot hide behind `e106` passing — still needs a
runtime capture of a *second* capital ship.
**That entry's stated blocker is stale** (checked 2026-08-19): the ship capture
is in the current build — `RequestShipCaptureFrame` / `CaptureShipDrawForRE` are
in `command_processor.cc` on `auto/re-ui-draw-order`, and F10 wrote a 2.9 MB
`xenia_ship_capture_01.log` from this session's binary. No separate
`capture-ship-placement` build is needed.
**Update 2026-08-19: the mission is now REACHABLE.** With the Canary threading
fix, `tutorial_launch.sh` drives boot → title → menu → TUTORIAL and the mission
**loads and renders** (flight HUD, "Go to the box on your screen"). It then
freezes under 13 243 crash dumps, all at `0x82307128`, preceded by exactly one
guest C++ throw — identical frames 6 s apart, no new dumps, 400 % CPU. So the
blocker moved from "cannot reach a mission" to "the mission freezes". 🔴 **The cache is REFUTED as the cure** (3 runs): the
missing entry `\aab216c3\6` was real and got written, and the run with a complete
cache stormed anyway — 11 497 dumps, all `0x82307128`. ✅ **But a usable window
exists:** both post-cache runs ran the mission with exactly **2 crashes for
5680 s** before the storm, where the first run was at 641 by t+24 s. The ship
capture needs `F10` armed *inside* that window. ✅ **Done, and the mission ran
with ZERO crashes** — first clean mission run, fully rendered. 🔴 **But the
capture contains no ship geometry**: 181 deduped draws, 180 sharing one vertex
shader, all screen-space, none 3D — against a known-good 2.9 MB capture from an
earlier session. The 8 000-draw budget was not the limit and the scene *was*
rendering. ⚠️ The "cache refuted" claim above is **overstated**: this run used
the same complete cache as `tut4` and got 0 crashes vs 11 497, so variance
dominates. 🔴 **Corrected:** nothing is broken. Ship
geometry is `stride=24 prim=4` with a large vcount (`vcount=10891` for a real
one); this capture has one `prim=4 vcount=6` quad and the 2.9 MB "known-good"
file has **no `prim=4` at all** — it is a **UI** capture (1 303 of 1 582 draws
are `stride=24 prim=13`, the UI sprite shader). The earlier "3D draws" test
counted UI sprite coordinates as 3D. The capture recorded what was on screen, and
the tutorial's opening has **no capital ship**. **What remains** is what the
capture doc always said: play into a real mission and frame a ship side-on —
gameplay driving, not a menu step, and the original was taken on HW Vulkan where
this container has lavapipe. 🔴 The resume-refused lead (1 663 on one
thread) is **REFUTED**: that thread did execute, and `KeWaitForSingleObject` /
`NtWaitForSingleObjectEx` are `kHighFrequency`, which is unlogged unless
`--log_high_frequency_kernel_calls=true` — so a parked thread is invisible and
the refusals are just the guest kicking a worker blocked on an object. Method
note: the title-loader finding rested on **host CPU time**, not log silence,
which is why it stands and this did not.
What blocked it before was **the cache-flush crash**, not navigation — measured
2026-08-19. `tools/re-capture/tutorial_launch.sh` (which retries whole boots,
because re-pressing the same title never works) gets all the way from the title
through the main menu to **DIFFICULTY** and then **SELECT DATA**, and the guest
dies there at `0x82307128` — the same `std::map`/`set` erase as the boot-time
throw, 537 stacked dumps, with `--mem_watch=false`. See
[`title-crash-stl-tree.md`](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`](title-crash-stl-tree.md) for the end-to-end
measurement and for what has been ruled out (`--mem_watch=false`, twice).
---
## ✅ 2026-08-27 — `ORDOR_SQUADRON_EXTENDED` opened: the reactive-chatter rule table
The biggest unopened record name in the 3 496-name census (**864 records**) is a
record in the **chatter rule table**: 144 objects per language pack × 6 packs.
Write-up: [`structures/preset-message-rules.md`](structures/preset-message-rules.md);
artefact `data/preset-messages.txt`; regenerator
`tools/re-capture/preset_messages.py`.
**Docs checked before claiming anything**`squadron-orders.md`,
`sound-cue-table.md`, `cutscene-message-table.md`, `ixud-localised-text.md`,
`isl-message-dialogue-link.md`, `movie-subtitles.md`, `mission-phase-deployment.md`,
`mission-phase-membership.md`. Two of them own parts of this:
* `ixud-localised-text.md` owns "`PresetMessage_*` is reactive combat chatter
keyed by speaker class", and `mission-phase-deployment.md` owns the route in
(`UnitMessageSet_S<NN>.tbl``PresetMessage_Phase1/2/3`). **What neither says**
is what is inside the table.
* `cutscene-message-table.md` owns the `Generic`+`Message_NNN` layout for the 32
cutscene objects per pack. **What it does not say** is that the same layout
carries **137 more** objects per pack for in-mission chatter. Control: of 273
`PresetMessage_*` names that hit an IDXD entry, 136 are rule tables and 137 are
message tables — the prefix names two different shapes.
**One schema, no variants.** All 9 216 event records (144 × 64) carry the same
seven named fields — `EffectiveTime, Interval, Priority, Probability,
MessageCount, IntervalFluctuation, Pattern` — then positional `(message id,
Yes/No)` pairs. **`MessageCount · 2 == positional count` in 9 216/9 216, zero
mismatches**, which is what pins the pairing.
**Named twice, independently, with the same answer.** Harvested
`PresetMessage_*` strings hashed under `message\` → 136/144; the
`PresetMessage_Phase1/2/3` declarations of the 22 `UnitMessageSet_S<NN>.tbl`
tables → 136/144; **the two routes name the same set** (set identity, not a
matching count).
**Joins to the settled cue table**: 2 388 of 2 405 distinct message ids
resolve (99.3 %) through `sound-cue-table.md`'s index.
⚠️ **A trap that doc had already paid for, and I walked into it anyway.**
Rewriting `MSG_``VOICE_` unconditionally scores 2 353/2 405, and the 52 misses
looked like a finding ("lines with no recording"). 44 of them were a naming
artefact: `sound-cue-table.md` states that a name already carrying `VOICE_` keeps
the one it has. Applying the documented exception gives 2 388/2 405. Reading the
owning doc caught this *after* the first write-up was drafted — the earlier grep
found the ownership, but the rule inside it still had to be read.
**Correction to `squadron-orders.md`** — it rendered the `0x820AEEB0` enum as
twelve `ORDER_*` names. The executable's own strings misspell all four SQUADRON
entries as **`ORDOR_*`**, and the disc data carries the identical misspelling,
which is how the two sides join. That doc's 🔴 "this is a UI/comms axis, not the
order classes" now has its explanation: the twelve are bark *events*.
**Not settled.** What the seven numeric fields mean (schema only, not
measured against the running game); the `Yes`/`No` element of each pair; which of
the 49 `Sperkers` speakers is a wingman (the "13 tables voice every player-facing
event" partition is a count, not a verified roster). 🟡 **8 of the 144 rule
tables are named by neither route** — nothing on the disc declares them.
`UnitMessageSet_S<NN>.tbl` exists for 22 of the 28 stages and the six missing are
exactly **S18S23, the tutorials** (same set as the missing `AIParams_SNN.tbl`),
so tutorial chatter is the plausible reading — but there is no tutorial
`UnitMessageSet` to confirm it, so it stays a reading.
---
## ✅🔴 2026-08-27 — the 8 undeclared chatter rule tables: all named, and NOT tutorial
Continues the entry above. **144/144 rule tables are now named**, and the
reading I left in that entry is **refuted**.
**Two more naming routes, both cheap:**
* **Route 3 — strip `_msg`.** A message table's name gives its rule table's
name. **137/144**, a strict superset of route 1, and it hits **zero** entries
that are not rule tables. It adds `PresetMessage_Katana_14_S16-2.tbl`.
* **Route 5 — sweep the grammar.** `PresetMessage_<who>[_NN][_S<nn>-<p>].tbl`
over the 86 `<who>` tokens the other routes expose. Adds
`PresetMessage_Margras_04_S14-1.tbl`. **1/144.**
**Route 4 — the roster predicts the name, 6 of 6.** Among the already-named
two-speaker tables the rule is transparent: `<FACTION>_Fleet<X><Y>` speaks
(`<FACTION>OP<Y>`, `<FACTION><X>`), and `TCAF_..Ship<X>` speaks (`ADANPL<X>`,
`TCAF<X>`). The six remaining unnamed tables are six **gaps in two series**.
Predicted from their rosters, then hashed: `TCAF_FleetBB`, `TCAF_17thFleetBB`,
`TCAF_17thFleetCA`, `TCAF_17thFleetCB`, `TCAF_17thFleetShipA`,
`TCAF_17thFleetShipB`**6/6**. **Control:** four same-shaped names the series
does not contain (`TCAF_FleetCC`, `TCAF_17thFleetCC`, `TCAF_ShipA_01`,
`TCAF_17thFleetShipC_01`) score **0/4**, so it is not the case that any plausible
name lands on an unnamed slot.
🔴 **Refuted: "the undeclared tables are tutorial chatter."** It was the obvious
reading — `UnitMessageSet` exists for 22 of 28 stages and the six missing are
exactly S18S23. But the eight are two **story**-stage tables (`Katana_14_S16-2`,
`Margras_04_S14-1`) and six TCAF fleet/ship tables carrying **no stage tag at
all**. Not one is a tutorial. Recorded rather than deleted: the inference from
"S18S23 have no `UnitMessageSet`" to "the undeclared tables are theirs" skipped
the step of *looking at what is in them* — all six residuals speak `MSG_TCAF_*`
with TCAF fleet rosters.
✅ Also settled in the same sweep: **every one of the 144 rule tables has its
`_msg` companion present**, in all six language packs (the earlier count of 137
message tables was a count of *harvested names*, not of entries — six of the
eight have message tables whose names appear as strings nowhere).
What the tutorial evidence does support, narrowly: no rule table is tagged
`_S18``_S23` and the tutorials ship no `UnitMessageSet`, so on this evidence the
six tutorial missions have **no reactive chatter of their own**. That is a
smaller claim than the one it replaces.
❔ Still open from the entry above: what the seven numeric fields mean, the
`Yes`/`No` pair element, and which speakers are wingmen.
---
## ✅ 2026-08-27 — the seven chatter fields, read off the engine's loader
Continues the two entries above. `sub_82213980` is the **only** function in the
executable that references any of the seven field-name strings — they sit
contiguously at `0x820A5794``0x820A5800`, and all seven xrefs land in it. It is
the rule table's loader: it walks records with `sub_82448AA0` /`sub_824482D0` /
`sub_82448BC8` and builds a **40-byte** object per event.
**Docs checked:** `preset-message-rules.md` (mine), `movie-subtitles.md` (its
"Pattern" is a *filename*-pattern table heading — unrelated),
`mission-phase-deployment.md` (owns the radio **delivery category**
`None`/`Emergency`/`Killed`/`Noise` in `ScriptMessage_S02_msg.tbl` — a different
table on a different axis; `Killed` sits at `0x820A5784`, immediately before this
string block, which is precisely the adjacency that invites conflating them).
**The three time fields are SECONDS at 60 Hz.** The loader emits
`mulli rN, rN, 60` for `Interval`, `IntervalFluctuation` and `EffectiveTime`, and
for nothing else. Disc values are all small ints (`{0,3,5,10}`, `{0,3,4,5,30,120}`,
`{0,2}`), so seconds is the only reading that survives — `120` s = two minutes.
**`Pattern` is a delivery channel with two dead arms.** `strcmp` against
`"Log"` → 2, `"Window"` → 3, `"Demo"` → 4, default **1**. The disc says only
`Sound` (8 395, → default 1) and `Window` (821, → 3): **`Log` and `Demo` exist in
code and in no data**.
**The `Yes`/`No` element is a 32-bit mask — and that explains the clamp.** Two
passes over the positional payload: `sub_82448BC8(rec, 2·i)` takes the even slots
(message ids), `sub_82448BC8(rec, 2·i+1)` the odd, `strcmp`s each against `"Yes"`
(`0x820A57A4`) and sets `1 << i` in a `u32` at `[obj+32]`. The loader clamps
`MessageCount` to **32** — the same constant as the mask width. **The data
agrees:** max `MessageCount` on disc is **26**, nothing exceeds the clamp, and no
record's `Yes` count exceeds its own `MessageCount`. This re-derives the pair
layout from the *executable* side, independently of the
`MessageCount · 2 == positional count` identity it was first inferred from.
**`Probability` is a gate, not just a weight.** Read as a sign-extended byte;
**zero skips the record before the object is allocated**. Values are percentages
(`1,2,3,5,10,20,25,30,40,50,60,100`).
Object layout: `+16` Probability u8, `+17` Priority u8, `+18` Pattern u8,
`+20` Interval·60, `+24` IntervalFluctuation·60, `+28` EffectiveTime·60,
`+32` the Yes mask, `+36` zero.
**Residual, characterised.** Cross-tab of `MessageCount == 0` against
`Probability == 0` over all 9 216 records: 6 786 zero on both, 2 417 set on both,
**11** with a `Probability` but no lines, **2** with lines and `Probability 0`
those two ship dialogue the loader can never reach. Thirteen dead records.
**Not settled: the policy.** The loader gives units and storage, not
behaviour. `Priority`'s comparison rule, whether `Interval` is a cooldown per
event or per speaker, and what `IntervalFluctuation` randomises against all live
in the *consumer* of the 40-byte object, which this iteration did not read.
`Pattern`'s `Window` is named, not measured. Also still open from the earlier
entries: which of the 49 speakers are wingmen.
---
## ✅🔴 2026-08-27 — how the three phase tables merge, and one refuted handle
Continues the entry above. **Docs checked:** `preset-message-rules.md` (mine),
`mission-phase-deployment.md` and `mission-phase-membership.md` (they own
`UnitMessageSet_S<NN>.tbl` and its `CrewCount` + `PresetMessage_Phase1/2/3`
what they do **not** say is what happens when the three tables are loaded),
`idxd-tag-hash.md` (owns the hash family the map key comes from).
**The three phase tables become ONE map.** `sub_82215A58` references exactly
`CrewCount` and `PresetMessage_Phase1/2/3` and reaches the loader from a **single**
call site (`0x82215D98`). The map is keyed by a hash of the *event record's name*
(`sub_82455C78`). All 64 event names recur in every phase table, so **every merge
collides**.
**`sub_82213840` is the reconciliation comparator** — called from nowhere but
the loader. It compares the two message-id vectors elementwise plus `+16`
`Probability`, `+17` `Priority`, `+18` `Pattern`, `+20` `Interval`, `+24`
`IntervalFluctuation`, `+28` `EffectiveTime`. It does **not** compare `+32` (the
`Yes` mask) or `+36`. On a collision the loader frees the newcomer and keeps the
incumbent either way; a mismatch also clears the byte it returns, which the
caller propagates.
**Measured on disc** (regenerator extended, numbers in `data/preset-messages.txt`):
298 `UnitMessageSet` records name ≥2 phase tables; **26 432** duplicate-key
insertions; **26 208 (99.15 %)** identical; **224 different**, of which **189
differ only in the message list**. ⇒ **The phase tables are not additive**
phase 1 wins and **224 authored variants never play**. A port that merges
additively, or lets a later phase override, will not match the game.
**A negative worth keeping:** **zero** pairs agree on the six compared fields
while disagreeing on the `Yes` mask, so the comparator's exclusion of `+32`
changes nothing on the shipped disc. It looks like a latent bug until counted.
🔴 **Refuted handle for the firing policy.** I tried to find the consumer by
intersecting functions that touch `+16`/`+17`/`+18`/`+20`/`+28`/`+32`/`+36`.
It returns dozens of unrelated functions across the binary — **offset shape is
not an identifier**, which the corpus already warns about and I re-learned by
spending a query on it. The remaining handle is the map's consumer reached via
`sub_82215A58``sub_82214050`.
**Still open:** the firing policy proper (who rolls `Probability`, compares
`Priority`, ticks `Interval`, what `IntervalFluctuation` randomises); which of
the 49 speakers are wingmen.
---
## ✅ 2026-08-27 — who fires a bark, and the first half of the firing policy
Continues the chatter thread; reached through the **call graph**, after the
offset-intersection handle was refuted last iteration.
**Docs checked:** `preset-message-rules.md` (mine), `squadron-orders.md` — it
owns `sub_82320B48` as the selector for the twelve-string `0x820AEEB0` enum and
warns it is a comms axis. **What it does not say** is that the function is one of
**eight bark firing sites sharing a single entry point**, and that it also raises
`COUNTER`, `COUNTER_TO_PLAYER` and `FOUND_PLAYER`.
**36 of the 64 event names are executable strings** (`0x820AEEC8``0x820B2160`),
so the string-xref join names the firing sites directly: `sub_823800A8` (17
events — damage/destruction), `sub_82320B48` (15 — squadron orders + 3),
`sub_8237B020` (9 — player state), `sub_823B1438` (ammo), `sub_82381B10`
(evasion), and three singles.
**`sub_8220FA98` is the shared entry** — the only callee common to all eight.
Lock around `this+4`; enable byte `[this+8843]`; speaker lookup in the map at
`[this+8820]`; index `[this+8816] 1` into a vector inside that entry (phase is
a *reading*, not measured); event-name lookup → the rule object; then the roll.
**`Probability` is a per-occurrence percentage — now measured, not inferred.**
`f0 = rand01 · 100.0` and `f0 > [rule+16]` returns without firing. The constant
at `0x820856F8` is exactly **100.0**, read from the image. `sub_8220D970` folds
two `rand()` calls into a 16-bit uniform.
**`sub_82210670` picks the line.** (1) Walks the pending list at
`[this+8736]` for a node matching this (speaker, rule) pair and **bails if the
same bark is already queued**. (2) Recomputes `MessageCount` from the vector and
takes a message as eligible only if its bit is **clear** in `[rule+36]` — the
word the loader zeroes — so **`+36` is a runtime exclusion mask** ("already
spoken" is the obvious reading; the write site was not found). (3) Uniform pick:
`eligible · rand01`, subtract `1.0` (`0x8208583C`) per eligible bit until the
counter empties or the accumulator reaches `0.0` (`0x8209FD28`). (4) Reads
`[rule+18]` `Pattern`, `[rule+17]` `Priority`, `[rule+28]` `EffectiveTime` and
hands them with the chosen message to `sub_822109B0`.
**Measured negative:** the pick path does **not** read `+20` `Interval`,
`+24` `IntervalFluctuation` or `+32` (the disc `Yes` mask). They are consumed
after the hand-off. Within the chatter CU (`0x8220C000``0x82216000`), `+17` and
`+18` are read by exactly **two** functions — the comparator and this one — which
is what makes the attribution safe where the binary-wide search was not. **The
bounded offset search is sound once the call graph says which functions matter;
the unbounded one is what was refuted.**
**Still open:** everything after `sub_822109B0` — how `Priority` orders the
queue, whether `Interval` is a per-event or per-speaker cooldown, what
`IntervalFluctuation` randomises, where `+36`'s bits get set, and what reads
`+32`. Also still open: which of the 49 speakers are wingmen.
---
## ✅ 2026-08-27 — the bark queue, `Priority`, and `Yes`/`No` settled
Continues the chatter thread, bounded to the CU as the previous entry proposed.
**Docs checked:** `preset-message-rules.md` (mine). Nothing else in the corpus
mentions `sub_822109B0`, `sub_8220FC50` or `sub_82210AF0`.
**The pending queue.** `sub_822109B0` allocates 68-byte nodes from a pool at
`this+32`; `[this+8740]` is the count and **the enqueue returns 0 once it reaches
128** — barks past that are dropped. A slot is free iff `[node+23] == 0`, and
`+23` holds **`Pattern`**, which is never 0 for a loaded rule: the presentation
field doubles as the occupancy flag. Node layout recorded in the doc.
**`Priority` orders the list, and its fast path is dead data-wise.**
Insertion tests `Priority` against **10**: ≥ 10 pushes straight to the front with
no ordering; < 10 splices ahead of the first node with a strictly smaller
`Priority`, leaving the list **descending by `Priority`**. **The disc's range is
1…9**, so the front-push arm never runs on retail data — every shipped bark takes
the ordered path. `sub_82210AF0` returns the largest `[node+20]` among pending
nodes of *higher* priority whose `[node+24] != 0x10`: a preemption/ducking query.
**`Yes`/`No` is a ONE-SHOT flag, and the `+32`/`+36` pair closes.** In the
per-frame tick `sub_8220FC50` (callers `sub_821AB650`, `sub_821B5FB8`), at
`0x82210160`:
rule[+36] = ((1 << node[+25]) & rule[+32]) | rule[+36]
The line just spoken enters the **used** mask **only if its `Yes` bit is set**.
`sub_82210670` picks only from messages whose `+36` bit is clear, so `Yes` = the
line is heard once and never again, `No` = it stays in the pool. That is what the
census counts: **8 940 `No`, 388 `Yes`** ⇒ 388 one-shot lines on the disc. It also
answers the previous entry's open question "what reads `+32`" — nothing, until a
line finishes, and then only to gate this AND.
✅ The tick branches on `Pattern < 3` (taken side uses `this+8776`), so `Window`
(3) is a different presentation path from `Sound` (1) — the two values the disc
actually ships.
**Still open, and now it is only two fields:** whether `Interval` (`+20`) is a
cooldown per event, per rule or per speaker, and what `IntervalFluctuation`
(`+24`) randomises against. Neither is read by the entry, pick, enqueue or tick
paths read so far — `sub_82210320` was checked as a candidate and is a different
structure (a deque at `this+8744`, with a divide-by-60 frames→seconds), not the
rule object. Also still open: which of the 49 speakers are wingmen.
---
## ✅ 2026-08-27 — `Interval` + `IntervalFluctuation`: the chatter system closes
The last two open fields. **Docs checked:** `preset-message-rules.md` (mine);
nothing else in the corpus mentions `sub_82210C38`.
**`sub_82210C38` is the retire-a-line routine** (called from the tick
`sub_8220FC50` and from `sub_8220FB90`) and is the **only** place in the chatter
code that reads the rule's `+20`/`+24` as **words** — which is what separates
them from the byte fields at the same offsets in the queue node. On retirement:
node[+16] = (int)( (float)rule[+24] * rand01() + (float)rule[+20] )
node[+24] = 0x20
**`Interval` is a floor, `IntervalFluctuation` is uniform additive jitter** —
the cooldown is uniform over `[Interval, Interval + IntervalFluctuation)` frames.
Disc: `Interval ∈ {0,3,4,5,30,120}` s, jitter 0 or up to +2 s (813 records).
**The cooldown is per (speaker, event), and the queue node IS the timer.**
`node[+16]` — which held `EffectiveTime` while the line played — is reused. In
state `0x20` the tick decrements it per frame and only at zero unlinks the node,
clears `[node+23]` to free the slot and decrements `[this+8740]`. **This closes
the loop with the "already queued" bail in `sub_82210670`: they are the same
mechanism.** A retired line keeps its node alive for `Interval + jitter` frames,
and for exactly that long the same speaker cannot repeat the same event. The rule
object holds no timestamp; nothing per-rule or global is involved.
⚠️ **Port note:** a lingering node still occupies one of the **128** pool slots —
a 120 s `Interval` parks a slot for two minutes.
✅ Same routine: stops the presentation via `sub_822168E8` on `this+8776`
(`Pattern` 1) or `node+28` (`Pattern ≥ 3`); and when `[node+20] == 1` sweeps the
pending list calling `sub_82217980(…, 0)` on every node not in state `0x10`.
With `sub_82210AF0` (max `[node+20]` over *higher*-priority pending nodes) that
makes `+20` a **ducking level**, raised while a higher-priority line plays and
released on retirement.
**The chatter system is now closed end to end** — data, loader, phase merge,
firing, probability, pick, queue, priority, one-shot flag and cooldown.
**Not established** (and none of it changes the data model a port needs): the
numeric attenuation of the ducking level, the meaning of node states other than
`0x10`/`0x20`, and whether `sub_822168E8`/`sub_82217980` do more than
stop/resume. ❔ Still open elsewhere: which of the 49 speakers are wingmen.
---
## ✅❌ 2026-08-27 — the player-facing partition, and a correction I owed
**Docs checked, and two of them already owned the question I set out to ask:**
* [`isl-condition-builtins.md`](structures/isl-condition-builtins.md) **owns the
wingman roster** — `UNITS` in `GP_HANGAR_ARSENAL.pak` is seven fields pairing a
craft slot with a pilot (`Bird1-Sandra`, `Bird2-Billy`, `Bird3-Antonius`,
`Bird4-Carl`, `Rhino1-Raymond`, `Rhino2-Katana`, `Rhino3-Ellen`) and names it
as such. So "which of the 49 speakers are wingmen" **did not need an
experiment**.
* [`unit-group-table.md`](structures/unit-group-table.md) **owns the player
link** — Stage 02's own flight is `UN_f001_TCAF_DeltaSaber_T_Player` with
`msg=MessageSet_Katana`, `id=Character_Player_Test`, beside
`msg=MessageSet_Ellen`.
* `hangar-loadout-system.md` owns the `PlayerSET_*` rows.
❌ **Correction to `preset-message-rules.md` (mine).** It said the 14
player-facing events are "voiced only by the wingman tables". Wrong twice: the 13
tables are **all `PresetMessage_Katana_*`**, i.e. the **player's** sets, and the
wingman roster is a separate already-settled list in which `Rhino2-Katana` is the
player's own slot. Corrected in place with a ❌ note.
✅ **What the experiment did establish — an exact partition.** Of the 144 rule
tables: **13 voice all 14** player-facing events, **131 voice none**, and
**0 voice some**. Not a tendency — zero partials. And **none of the 129
non-Katana tables voices *any* of the 14**, so `LOCKON_*`, `PLAYER_HP_LESS_*`,
`PLAYER_AMMO_LESS_*` and `ORDER_WINGMAN_*` are exclusively the player's own
callouts. `CharacterKATANA` is the **only** one of the 15 speakers in that set
exclusive to it. Regenerator extended; artefact carries the partition.
🟡 **Residual:** there are **15** Katana tables, and the two that voice none are
`Katana_09_S10-1` and `Katana_14_S16-2` — all-or-nothing like the rest. Why those
two stage-phases opt out is not established from the disc.
**Method note:** the grep that would have caught this ran *after* the experiment
was designed, not before. The rule says grep the doc that owns the DATA first;
here the data was "wingman", the owning doc was `isl-condition-builtins.md`, and
reading it first would have reframed the iteration rather than corrected it
afterwards.
---
## ✅🔴 2026-08-27 — `AA_`/`AV_` are one interleaved block; the selection is still unfound
**Docs greped BEFORE designing the experiment** (the rule that cost me last
iteration): `unit-datasheet-static.md` **owns the field family** and already says
🟡 "anti-air vs anti-vessel is the obvious reading — **not adopted**, nothing here
shows the selection"; `live-unit-definitions.md` owns the runtime field map and
records that `AA_AxisMode_*`/`AV_AxisMode_*` default to 0;
`unit-struct-runtime.md` owns the `Maneuver` declaration-order result;
`flight-speed-law.md` and `isl-builtins.md` mention the prefixes in passing.
**What none of them says is which code reads them.**
✅ **Exactly one function references the names.** `sub_822F9498` — the
unit-definition loader, called only from `sub_821A6CF0` — reads 78 named float
fields; nothing else in the image touches an `AA_`/`AV_` string.
✅ **The layout is an exact interleave: `AV_` at `X`, `AA_` at `X+8`.**
PitchPlus `+196/+200` vs `+204/+208`; PitchMinus `+212/+216` vs `+220/+224`;
Yaw `+228/+232` vs `+236/+240`; Roll `+244/+248` vs `+252/+256`; AxisMode
`+320/+324` vs `+328/+332`. **The constant +8 is the finding** — a selector is an
offset of 0 or 8, not two lookups.
✅ **Data control:** the ten suffixes match **exactly** between families (list
equality, not a count); 690 records each for the eight rate fields, 36 each for
`AxisMode`. `AA_` is usually the larger value (`AA_AxisMode_Min` 100 vs 35;
`AA_Yaw_Max` 50 vs 25) — but `AA_PitchPlus_Min` 100 vs `AV_` 200 goes the other
way, so that is a description, not a rule.
⚠️ **The 20 strings exist twice** (`0x82085B98…` and `0x8209F674…`); the loader
uses the second block and **the first 20 are referenced by nothing**.
🔴 **The selection is NOT settled, and the offset route failed again.** Functions
loading two or more of `+196`/`+204`/`+320`/`+328` number **39** across the image
— those offsets are far too common to identify a consumer. Only two are
call-graph-reachable from the loader's owner (`sub_821A6CF0`, `sub_821AB650`);
that is the next handle. **"Anti-air vs anti-vessel" stays a reading**, exactly as
`unit-datasheet-static.md` had it.
**Method slip to record:** I dumped ~1 100 disassembled instructions to find the
name→offset mapping when a scripted `addi`→`stfs` pairing (which I then wrote
anyway) gave the same 20 lines. Script the extraction first; never eyeball a
4.5 KB function.
---
## 🔴 2026-08-27 — the `AA_`/`AV_` selection is BLOCKED for static RE
Continues the entry above. Two more routes tried, both refuted with their cause
shown; per the standing rule, when every route has a control and they all read
zero, the item is blocked — writing it down and moving on.
**Route 3 — call-graph bound.** The only two functions reachable from the
loader's owner, `sub_821A6CF0` and `sub_821AB650`, each read `196`/`200`/`204`
in three consecutive instructions off **three different base registers**
(`r25`/`r24`/`r11` and `r30`/`r29`/`r11`). That is three unrelated objects
contributing one float each — not the AA/AV block. Refuted.
**Route 4 — data-flow bound.** The definition object lives in a global: the
loader is fed by `lwz r3, 13708(r26)` with `r26 = 0x828F0000`, i.e.
`[0x828F358C]`. **18** functions touch that global and **9** of them also touch
block offsets — a far better bound than the 39 from offset shape alone. The two
best candidates are both refuted:
* `sub_8230D1F8` is a **different loader**. Resolving its field names the same
`addi`→`stfs` way gives `+256 CraftScore_Adjustment`,
`+320 FFPenalty_Zessel_Maximum`, `+324 RankScore_S`, `+328 RankScore_A`,
`+332 RankScore_B` — it *stores* at exactly the offsets the unit definition
uses for `AA_Roll_Min` and the four `AxisMode` fields.
* `sub_82398CC0` uses `r19` as a **float-constant pool** (`320(r19)` next to
`272(r19)` and `-656(r19)`), not as a definition pointer.
🔑 **This explains the whole pattern of failure.** The offset region is shared by
at least two unrelated objects *and* by a constant-pool base, so every
offset-based discriminator is contaminated **by construction** — that is the
third time the offset route has failed on this corpus, and now with a mechanism
rather than just a count.
🔴 **BLOCKED.** Catching the selection needs a runtime watch on the definition
object's `+196`…`+332` during flight — emulator work, NEEDS-HUMAN, not a disc or
image read. The layout finding stands; "anti-air vs anti-vessel" stays a reading.
❔ **Side finding, unowned:** `sub_8230D1F8` is the **rank/score table loader** —
`RankScore_S`/`_A`/`_B`, `FFPenalty_Zessel_Maximum`, `CraftScore_Adjustment`.
Grep confirms nothing in `docs/re/` mentions any of those names. A cheap item for
a future iteration.
**Method slip, second time in two iterations:** I dumped ~400 instructions of
`sub_82398CC0` by eye before writing the six-line name-resolution script that
settled both candidates. **Write the script first** — it is in the standing rules
and I broke it again.
---
## ✅ 2026-08-27 — mission scoring and the S/A/B/C/D rank thresholds
The side finding from the blocked `AA_`/`AV_` item, followed up. New doc
[`structures/mission-scoring.md`](structures/mission-scoring.md); regenerator
`tools/re-capture/mission_scoring.py`; artefact `data/mission-scoring.txt`
(**TWELVE artefacts now**).
**Docs checked first:** `isl-condition-builtins.md` owns `AUTO_SETTINGS` (the 28
`stageNN_settings.tbl` **filenames**); `unit-datasheet-static.md` and
`unit-struct-runtime.md` own the *per-unit* `ScorePoint`/`DamageScore`/
`MassScore`. **`RankScore`, `TimeBonus`, `KillBonus`, `FFPenalty` and
`Difficulty_*` appear nowhere in `docs/re/`** — the scoring rules were unowned.
✅ **`sub_8230D1F8` is the stage-settings loader**, not just a rank table: its
122 name→offset pairs cover cameras, post-processing (bloom/DOF/fog/colour),
supply squadrons and movies, phase names — and the scoring block. Reading its
**names** rather than its offsets is what identified it; the offsets were the
contaminant that blocked the `AA_`/`AV_` item.
✅ **24 IDXD objects per language pack × 6 = 144**, each holding exactly
`Score_Easy` / `Score_Normal` / `Score_Hard` ⇒ 72 records per pack, **one**
22-field schema, no variants. Groups: five `RankScore_*` thresholds; three
`*Score_Adjustment` multipliers; `MainMissionCount`/`Bonus`; three `TimeBonus_*`;
`KillBonus_Maximum`, `FriendlyVesselDamageBonus`, `GettingScoreBasis_*`; five
penalties. Two of the game's typos are join keys: **`TimeBonus_Maximun`** and
**`FFPenalty_Zessel_Maximum`**.
✅ **Difficulty changes the earning rate, never the bar.** Comparing the three
records inside each object, **10 of 22 fields move and 12 never do — and the 12
include all five `RankScore_*`**. Multipliers run `×0.5 / ×1.0 / ×2.0`. So a rank
bar is per stage and identical on every difficulty; difficulty scales how fast
you earn against it and how hard penalties bite. Measured over all 24 objects,
not inferred from the names.
✅ **23 of 24 objects differ** from the commonest `Score_Normal` record, mostly in
the thresholds and the objective count/bonus. **Nine of the 72 records** (three
objects × three difficulties) zero the multipliers, penalties and time bonus —
unscored missions.
🔴 **Which object is which stage is NOT settled.** None of `AUTO_SETTINGS`'s 28
filenames resolves to any of the 24 objects under **19** prefixes. **24 objects
vs 28 declared files is itself unexplained** — and the standing rule applies: do
not assume the missing four are the tutorials without opening them.
**Method note, and it worked:** the offsets that made `sub_8230D1F8` a false
positive last iteration are the same offsets that made it findable — but only
after switching from *offset* to *name*. Read what a loader names, not where it
stores.
---
## ✅ 2026-08-27 — the rest of the stage-settings object: cameras, player limits, difficulty
Continues the scoring entry. New doc
[`structures/stage-settings-table.md`](structures/stage-settings-table.md);
`mission_scoring.py` extended; artefact `data/mission-scoring.txt` grows by the
whole-object census. **Still twelve artefacts.**
**Docs checked first.** `isl-condition-builtins.md` owns `Camera`,
`ControlTweak`, `Rendering` **as records in `GP_HANGAR_ARSENAL.pak`** — the
game-wide defaults. **What it does not say** is that `Camera` recurs *per stage*
in the `GP_MAIN_GAME_*` packs. `stage-definition-table.md` /
`challenge-mission-gate.md` own `StageResource`; `stage-mission-tables.md` owns
`LodResource`/`MotionResource`. `SplinterCell` and `Difficulty_*` are unowned.
⚠️ **Two families share the `Phase_1/2/3` record names.** 53 objects in
`GP_MAIN_GAME_E.pak` contain a `Phase_1`: **29** are the resource manifest
(`StageResource`/`LodResource`/`MotionResource`, `Phase_N` = 4 named fields) and
**24** are the settings table (`Camera`/`Player`/`SplinterCell`/`Difficulty_*`/
`Score_*`, `Phase_N` = 3190 fields). Counting without partitioning would have
merged them — *a record name is a file-local role*, again.
✅ **This answers the 24-vs-28 puzzle** left open last iteration: the settings
family is 24; the **29** — matching `AUTO_SETTINGS`'s 1…29 tag range — is a
different table.
✅ **`Camera`: three chase rigs, and 13 of 14 fields are identical in every
stage.** Nose (0, 4.5, 7.0), Near (0, 10, 40), Far (0, 15, 80), all FOV 0.92;
`CameraNear` 1.0, `CameraFar` 3 000 000.0 with a single stage at 500 000.0.
World unit is 1 m, so these are metres — directly portable.
✅ **`Player`:** `BulletLimit` 512, `HomingLimit` 256, `LaserLimit` 32,
`Pitch/Roll/YawAdjustment` 0.30, `AirDragFactor` 1.0 — all constant across 24.
**`GravityFactor` is non-zero in 4 of 24 stages** (250/400/700), and
**`IsBoss16Enable` appears in exactly ONE object**, which identifies it as stage
16 — the first per-stage handle for a family whose filenames do not resolve.
✅ **`Difficulty_Easy/Normal/Hard` is a SECOND difficulty record**, 8 damage and
guidance multipliers, separate from `Score_*`. So difficulty is expressed twice
in one object: once for scoring, once for damage.
🟡 **Not settled:** the `Phase_1/2/3` block of the settings family (3190 fields
— post-processing, fog, DOF, unsharp mask, supply squadrons/movies,
`UnderCommandSquadron`, `MapPath`) is the largest remaining piece and was not
read. Which object is which stage, beyond `IsBoss16Enable`. `SplinterCell Count
5` is a name and a value only.
---
## ✅🔴 2026-08-27 — the `Phase_1/2/3` block of the settings family
The largest remaining piece of the stage-settings table, now read.
`stage-settings-table.md` extended; `mission_scoring.py` extended; artefact
`data/mission-scoring.txt` grows. Still twelve artefacts.
**Docs checked first.** `stage-definition-table.md` owns `MapPath`/`MapMesh` — in
the **`Stage_SNN`** records, naming `.rgn`/`.col` MiscBin objects; `regn-map-grid.md`
and `stage-mission-tables.md` also carry `MapPath`. `UnderCommandSquadron`,
`BGOperateFrameName`, `SupplyMovie*`, `FinalPassVinetting`, `Nebura_*` and
`ScreenColor*` are owned by **nothing**.
✅ **72 records, 94 distinct field names, 31 in every one** — the post-processing
core, five `Fog*`, three `ScreenColor*`, `SpaceSize`, five `Supply*`,
`UnderCommandSquadron`, and the first `BGOperate` slot. Optional families sit in
clean tiers: `Nebura_*` (12) + `ColorLayer*` + `BGRotateScale` in **69/72**;
`DOF_*` (7), `UnsharpMask_*` (7), `ExposureKey_*` (4) in **15/72**;
`FinalPass*` (11) in **3/72**.
🔴 **Refuted: `BGOperateFrameCount` is NOT the number of `BGOperateFrameName_i`
slots — 36 of 72.** I expected the `Count·k` pattern that pinned the chatter pairs
and the cutscene pages, and it does not hold. What holds is
**`Count ≤ slots` in 72/72**: the slot array is fixed (1, 4 or 5 present) and
`Count` says how many are live — the same shape as `MessageCount` under the
32-slot clamp. Recorded as a refutation because the strong form is the one a port
would naively implement.
✅ **68 of the fields common to all three phases NEVER differ, in any of the 24
objects.** The whole `Nebura_*`, `DOF_*`, `UnsharpMask_*`, `FinalPass*` families,
most fog/colour settings, `SupplySquadron2` and `SupplyRange` are **per stage,
duplicated into all three phase records**. What moves — never in more than 6 of 24
— is `BGOperateFrameName_0` (6), `SupplySquadron1` (5), then `Brightness`,
`BrightPass*`, `ColorLayerA`, `BGOperateFrameCount`, `UnderCommandSquadron`,
`FirstAdaptedLuminance`, `BGOperateFrameName_2` (3 each). **The phase block is a
per-stage environment block copied three times**, and what a phase change is
actually for is the backdrop animation and the supply/command squadron.
⚠️ **`MapPath` is not in this family.** Its 87 records per pack are 29 × 3 — the
**resource** manifest's `Phase_N`, not the settings'. Arithmetic, not assumption:
the settings family is 24 objects, the resource family 29.
🟡 Still open: which settings object is which stage beyond `IsBoss16Enable`;
`SplinterCell Count 5`; what `SpaceSize`, `GlareType` and `Nebura_*` mean
numerically.
---
## ✅✅ 2026-08-27 — all 24 settings objects named: `stage\StageParameter_S<NN>.tbl`
**The grep-first rule paid for the whole iteration.** The queued item was the
29-object resource manifest — and reading the owning docs first showed it is
**already settled**: `challenge-mission-gate.md` counts exactly 29 stage records
(16 story + 6 tutorial + 6 challenge + `Test`, `StageResource` schema
`0x3c9ae32e`) and `stage-mission-tables.md` documents the six sub-records
including the `Phase_N` block with `MapPath`/`MapMesh`/`AsteroidDefinition`/
`BackgroundResourceID` — the exact four fields I measured. Nothing to add. So the
iteration moved to the open question those docs make answerable.
✅ **24/24 named.** Hashing **every string that appears in any IDXD object on the
disc** — rather than guessing filenames — resolves all of them:
`stage\StageParameter_S01…S16.tbl` (story), `S24…S29` (challenge),
**`StageParameter_Tutorial.tbl` shared by all six tutorials**, and
`StageParameter_Test.tbl`. **16 + 6 + 1 + 1 = 24**, which is why the settings
family is 24 against 29 stage records: the tutorials do not get one table each.
✅ **`IsBoss16Enable` confirmed independently.** Predicted last iteration to mark
stage 16; the object carrying it is `0xEECC84F2` = `StageParameter_S16.tbl`.
⚠️ **The names are NOT `AUTO_SETTINGS`'s.** That record lists
`stage01_settings.tbl` … `challenge06_settings.tbl`, and **none of those 28
hashes to any of these 24 objects** (19 prefixes tried). Two different naming
schemes for stage-scoped parameters; the pak holds only `StageParameter_*`.
With names attached, earlier findings acquire stages: **`GravityFactor` is
non-zero in S10 (700), S11 (400), S03 (250), S27 (250)**; the three objects that
zero their score multipliers are **S24, S27, S28**; the object without a
`SplinterCell` record is **`_Test`**.
🔴 **Refuted on the way:** TOC order is not stage order. The 24 objects sit in a
separate TOC region (661, 905, 9681030) from the 29 resource objects (622771),
and the `IsBoss16Enable` object is 17th in TOC order, not 16th — the TOC is
hash-sorted, which is arbitrary with respect to stage. Also, neither the
`StageResource` template (13 fields) nor a real record (19) names the settings
table, so the link is not through the stage record. ⚠️ I first sampled
`res[0]` and got the `_Test` **template** — *never conclude from one sample*.
**Method note:** the win was harvesting *all* disc strings and hashing them,
after guessing filenames had failed twice. The real name resembled nothing I
would have guessed.
---
## ❌✅ 2026-08-27 — correction: the naming sweep already covered the 24; it just never said so
**Docs checked first, and this time the grep contradicted me.**
[`archive-naming.md`](structures/archive-naming.md) owns disc-wide pak naming,
and `archive_naming.py` already harvests **6 027** candidate names under **16**
prefixes. Testing its candidate set directly: **it names all 24
`StageParameter_S<NN>.tbl` objects, 24/24** — they were covered from the start.
❌ **Correction to the previous entry.** It presented "hashing every string on the
disc resolves all 24" as a new capability. It is not new. What was genuinely new
was the **identification** — which object is which stage, that the six tutorials
share one table, that `IsBoss16Enable` is S16 — not the naming method. The
standing note that "the sweep has never been run corpus-wide" was also wrong: it
has, and its artefact is `data/archive-naming.txt`.
✅ **The real gap, now closed.** The sweep reported only per-archive
*percentages* — `GP_MAIN_GAME_E … 751 named, 67.1 %` — and never emitted **which**
entry got which name. That is exactly why nobody could say the settings objects
were `StageParameter_*`. `archive_naming.py` now also prints, per archive, the
**resolved name families** (digits collapsed to `#`) with counts: **6 573 named
entries, 1 631 families** across the disc, e.g. `GP_MAIN_GAME_E` = 751 named in
212 families (`EnumUnit_S#.tbl` ×28, `StageResource_S#.tbl` ×28,
`StageParameter_S#.tbl` ×22, …). A coverage statistic is not a name table.
⚠️ **Determinism, caught again by the verify loop.** The resolved map is built by
iterating the candidate **set**, so collided hashes got a different winner each
run and the artefact failed its own byte-identical check. Now iterated `sorted()`.
Second time this class of bug has appeared in two iterations — **any map built by
iterating a set needs a sort**.
🟡 Not settled: the low-coverage archives are unchanged — `DefTables` 8.9 %,
`GP_HANGAR_ARSENAL` 22.6 %, `GP_MISSION_SELECT` 25.0 %,
`GP_DEBRIEFING_PILOTLOG` 33.3 % — and the content index now makes it possible to
say *what kind* of thing is missing from each, which is the obvious next step.
🔴 The 2D / `GP_READY_ROOM` archives stay blocked.
---
## ✅ 2026-08-27 — why each archive is low-coverage: two different reasons
Uses last iteration's content index rather than re-running naming. `archive_naming.py`
extended to split every **unnamed** entry by magic and, for IDXD, by record-name shape.
**Docs checked:** `archive-naming.md` (mine, owns the percentages);
`stage-definition-table.md` / `stage-mission-tables.md` / `arsenal-item-weapon-chain.md`
own the **names** `EnumLODSet_*.tbl` / `EnumGameModel_*.tbl`;
`isl-condition-builtins.md` owns the 40 `<?xml` entries as **XPR2 resource
manifests** (and already corrected the "config reader is XML" claim — not new
here). **`Level_0`, `EnumMotions` and `ReferenceFrames` appear in no document.**
✅ **The three "low-coverage" UI archives have ZERO unnamed IDXD.**
`GP_HANGAR_ARSENAL`: **180 IDXD, 180 named, 0 unnamed** — its 1 191 unnamed
entries are 1 149 T8aD/RATC + 42 LSTA, i.e. **sprites**. `GP_MISSION_SELECT` and
`GP_DEBRIEFING_PILOTLOG` contain **no IDXD objects at all**; their unnamed
remainder is 64+2 and 148+8 artwork entries. So "22.6 %" for
`GP_HANGAR_ARSENAL` is misleading — **every data table in it is named**, and these
three are the same phenomenon as the 🔴 blocked 2D / `GP_READY_ROOM` archives, not
a data gap.
✅ **`DefTables` is the only genuine data gap on the disc** — 1 425 IDXD, 130
named, **1 295 unnamed**, in **17** record-name shapes, two families covering
almost all: `Generic + Level_0…Level_3` (**807**, LOD sets) and
`Default + EnumMotions + Generic + ReferenceFrames` ± `Motion_break`/`Motion_dead`/
`Motion_down` (**463**, motion sets). One LOD table and one motion table per
model, keyed by model names the disc does not spell out.
✅ **Control, and it separates cleanly:** `GP_TITLE` and the other 100 % archives
have no unnamed entry of any kind; `GP_MAIN_GAME_E` is 667 named / 337 unnamed
IDXD with **zero** unnamed artwork. Two failure modes, not a gradient.
🟡 Not settled: the model names behind the 1 295 `DefTables` tables (the XPR2
manifests name meshes like `machines\rou_e104\objects\…xbg` — whether those model
names hash to the LOD/motion table keys is untested). The 337 unnamed IDXD in
each `GP_MAIN_GAME_*` are also uncharacterised — the same census would say what
they are, and was not run this iteration.
---
## ✅✅ 2026-08-27 — the `Enumerate` declaration tables close `DefTables` (99.2 %)
Item taken: "do the XPR2 manifest model names hash to the `DefTables` LOD/motion
tables?" — **refuted**, and the real naming source found next to it.
**Docs checked first:** `archive-naming.md` (mine); `isl-condition-builtins.md`
owns the 40 XPR2 manifests; `stage-definition-table.md` / `stage-mission-tables.md`
own the names `EnumLODSet_test.tbl` / `EnumGameModel_test.tbl` and record that
they live in `DefTables.pak` — but **neither says what is inside them**.
🔴 **REFUTED — the XPR2 manifests are not a naming source.** Their 82 `Name=`
values and 82 `DataFile`/`Source` paths, hashed bare and under
`.tbl`/`.xbg`/`.xpr`/`.prt`/`game:\`, resolve **0** `DefTables` entries. They do
share the *model* namespace: 40 of the 82 appear as the `Model` field VALUE
inside the LOD/motion tables (1 272 distinct `Model` values). ⚠️ The first
attempt regexed `Name="` and found 0 values — the manifests write `Name = "…"`
with spaces; a regex miss looks exactly like a null result.
✅✅ **An IDXD object whose single record is named `Enumerate` is a DECLARATION
TABLE: its FIELD NAMES are the names of other objects**, each resolving as
`name_hash("<field name>.tbl")`. `EnumLODSet_test.tbl` declares 676 names,
`EnumGameModel_test.tbl` 360; the disc holds **144** such objects (138 in
`DefTables`, one in each `GP_MAIN_GAME_*`) declaring **1 298** distinct names.
| | |
|---|---:|
| `DefTables` named by the string harvest (route 1) | 130 |
| **named by the declaration tables (route 2)** | **+1 283** |
| still unnamed | **12** |
| **coverage** | **1 413 / 1 425 IDXD = 99.2 %** |
`130 + 1283 + 12 = 1425`, no overlap between routes. ✅ **Zero partials and the
suffix is exact**: of 5 suffixes × 6 prefixes, `('', '.tbl')` scored 1 036/1 036
on the `_test` declarations and every other combination scored 0.
**Residual, in full:** 8 declaration tables nothing declares (the other 130 are
named by route 1, from the stage tables that reference them), 2
`Generic+Level_0` LOD sets whose `Model` reads `rou_e004`/`rou_e013`, and 2
motion sets. **15 declared names have no pak entry** — content that did not ship.
`archive_naming.py` extended with route 2; artefact **+17/18**, every line
paired (the DefTables census row 130→1413 and its shape list collapsing 17→3).
Byte-identical across two runs.
🟡 Not settled: **why those 12 are unreachable** — no attempt was made to name
the 8 orphan declaration tables or the 4 stragglers. And the six
`GP_MAIN_GAME_*` `Enumerate` objects named **nothing** there; what they declare
and where it lives was not chased.
❔ Handed forward: with `DefTables` closed, the largest unnamed block is the
identical **337 IDXD in each `GP_MAIN_GAME_*`**, 53 shapes led by `Shell+Weapon`
(90), `Faces+Generic` (64), the unit shape
`Effect+Explosion+Generic+Maneuver+Mass+SE+Shield+StructureCount` (44) and a
chatter table (8) — weapon/unit/chatter families the corpus already documents
under other names. Characterising them is the next item.
---
## ✅ 2026-08-27 — the `GP_MAIN_GAME_*` unnamed block: 333 of 337 already owned, 4 genuinely new
Item (a). New regenerator `tools/re-capture/main_game_unnamed.py`
`docs/re/data/main-game-unnamed.txt` (112 lines, byte-identical across two runs).
**Control first:** all six language copies carry the **identical 337 unnamed
IDXD hashes**, so this is one set, not six.
**By record-name shape (53 shapes), 333 map onto documented families:** 131
weapon datasheets (`weapon-datasheet-static.md`), **114 unit datasheets**, 64
unit `Faces` tables (`unit-datasheet-static.md`), 10 unit message sets + 8
chatter rule tables (`preset-message-rules.md`), 5 enumerations/formations/
placement (`stage-mission-tables.md`, `weapon-struct-runtime.md`).
🔑 **114 is exactly the corpus's unit count** (43 `Craft` + 71 `Vessel`) — these
are the tables the corpus already works with, reached **by shape because they
have no names**. Naming them would add nothing.
⚠️ **The `Enumerate` object in each `GP_MAIN_GAME_*` is EMPTY** — zero fields.
That answers the open sub-question: it declares nothing, which is why route 2
named 1 283 entries in `DefTables` and 0 here.
✅✅ **The 4 unclassified objects are new** → new doc
`docs/re/structures/player-tuning-tables.md`:
1. **`955ca077` — the analog stick response curves.** 8 `AnalogRevice_*` records
(yaw/pitch/roll/throttle/adv_yaw/adv_roll/eye_yaw/eye_pitch), each **11
positional values + a named `Count = 11`** — ⚠️ the `Count`-names-the-group
guess was TESTED and holds **8/8**. **yaw, roll and throttle are the identity
ramp**; the shaping is all on pitch and the camera axes. 🟡 `adv_yaw` peaks at
sample 6 and falls back to 0.208 — not a deflection curve; unexplained. Plus a
`Tweak` record with deadzones (`mov_stick_play 5000`, `eye_stick_play 6000`).
2. **`890e1be4` — the player craft's flight envelope.** `Booster` (50 fields) is
the *player* side of the `AA_`/`AV_` pair `unit-datasheet-static.md`
documents for NPCs, with afterburner twins; `MaximumVelocity 2100`,
`CruisingVelocity 612`. Plus `TacticalManeuver` (the `Turn180`/roll evades
whose morph poses the mesh work already found), `SpecialAttack` +
`SpecialAttackGauge_1/2/3`, `SpecialWeapon`, `Misc`.
3. **`0202269d` — the Stage 16 boss**, identified by its own shell ids
(`Shell_S16Boss_AAGun`/`_Laser`/`_HBeam`); `Guardian` HP 65 000, `Core` 42 000.
Cross-checks the `IsBoss16Enable` hook in `stage-settings-table.md`.
4. 🟡 **`a6c6c79b`** — one `Generic` record, one field named `eff_n0071`, empty
value. Not identified.
🟡 Not settled: the `Tweak` units are inferred from magnitude, not read from a
consumer; `adv_yaw`'s meaning; object 4. **No loader was located for any of the
three new objects** — this is a data-side characterisation only, so which code
reads the curves is open.
---
## ✅🔴 2026-08-27 — the player-tuning consumer: found for half of it, BLOCKED for the other half, and two of my own labels refuted
Item (a). Static only; no artefact changed (the census output is unaffected).
✅ **The consumer is `sub_822F9498` — the unit-definition loader the corpus
already documents** (`live-unit-definitions.md`, ← `sub_821A6CF0`, global
`[0x828F358C]`). The field-name block `0x8209F3000x8209F600`
(`BulletTimeVolumePC`, `FireBirdPower`, `ChargeMaximum`, `Turn180RequiredTime`,
`ShieldDoubler_Time`, `HPRegenerator_RegenSpeed`, `ExtraCartridge_Quantity`, …)
is **36/36 referenced**, every reference inside that one function.
🔴 **REFUTED — "`Booster` is a new schema".** Its 50 field names are a **strict
subset of the unit `Maneuver` record: 50/50 shared, 0 `Booster`-only**.
`CruisingVelocity` occurs in exactly 115 records disc-wide — 114 `Maneuver` + this
one. ⚠️ My first check compared against `Generic` and scored 0/50, which read as
"brand new": **the wrong record of a multi-record object is not a control.**
🔴 **REFUTED — "`Booster` is the player craft's flight envelope".**
`flight-speed-law.md` measured ~125 / ~420 / ~1 530. Unit `Maneuver`
100/350/1 200 gives consistent ratios **1.25 / 1.20 / 1.28**; `Booster`
100/612/2 100 gives **1.25 / 0.69 / 0.73**. The flight tracks `Maneuver`.
**What `Booster` actually is:** a **second, faster profile** — **39 of 50
values identical** to the player unit's `Maneuver`, and of the 11 that move the
three velocities scale by **exactly ×1.75** (350→612 = 612.5 rounded;
1 200→2 100; `SideThrustVelocity_Max` 500→875) and two accelerations by **×1.5**
(600→900; 1 000→1 500). 🟡 What selects it is not settled.
🔴 **BLOCKED — the `AnalogRevice_*`/`Tweak` reader.** That schema is its own
contiguous block at `0x820A119C0x820A1378`, headed by **`ControlTweakName`**,
then the 12 `Tweak` names, then the 8 curve names — **0 of 28 strings has a code
xref, and no instruction anywhere names a `0x820A1xxx` operand**. Control: the
adjacent block `sub_822F9498` reads scores 36/36, so the absence is real. Cause
unexplained; the static route is exhausted. **A runtime watch on the curve values
is the way in (NEEDS-HUMAN).**
⚠️ **The twin-string-block trap, caught:** `mov_stick_play`/`eye_stick_play`
exist **twice**; only the second copy (`0x820AA630`) is referenced, by
`sub_822AE628` — which also references `ControlTweak`, `Camera`, `Rendering`,
`BackGround` and the DOF/bloom names. **`ControlTweak` records live only in
`GP_HANGAR_ARSENAL.pak` (×6)**; `Tweak` only in the six main-game paks (×1).
`sub_822AE628` reads the **hangar's** control table — a clean, wrong answer if
taken for ours.
🟡 Not settled: what selects the `Booster` profile; what `adv_yaw`'s non-monotone
curve indexes (unchanged — it needs the reader); why the `0x820A1xxx` block has
no xref at all when its neighbour has 36.
---
## ✅✅🔴 2026-08-27 — the object is `PlayerParams`, and `sub_822F9498` is ITS loader, not the unit loader
Item (a), "what selects the `Booster` profile". It answered a different, bigger
question and **corrected a corpus doc**. Static only; no artefact changed.
✅✅ **Resolving every string `sub_822F9498` references, in code order, gives 90 —
and they are exactly the `890e1be4` object's schema in its record order**: `Misc`
(5 effect names), `SpecialAttack` (+17), `TacticalManeuver` (+7), `SpecialWeapon`
(+8), `Booster` (+50). It **never** names `Generic`, `Maneuver`, `Explosion`,
`Shield` or `StructureCount`; the string `Maneuver` exists once and has **0
xrefs**. One call site, from `sub_821A6CF0` — itself called once, and referencing
the literal **`PlayerParams`**.
🔴 **CORRECTION to `unit-datasheet-static.md`**, which calls `sub_822F9498` "the
unit-definition loader". It loads one object: the player parameter table. Its
`AA_`/`AV_` interleave (`AV_` at `X`, `AA_` at `X+8`, five axes) **still stands as
a struct layout — but the struct is `PlayerParams`'s `Booster` record**, not each
unit's `Maneuver`. Note added in place. ⇒ ❔ **What loads the 114 unit `Maneuver`
records is now an open question** — it uses none of these name strings.
🟡 `PlayerParams` hashed under 7 prefixes × 6 spellings × both hash families (84
combinations) hits the key `0x890E1BE4` **0 times**: the string names the table to
the code, not the pak entry.
⚠️ The loader names **8** `SpecialWeapon` fields; the disc values **7**
`HPRegenerator_Quantity` is named and never valued.
✅ **Ranking all 114 units by how many of `Booster`'s 50 values they reproduce:
the top five are exactly the five `_Player` units** (`f001_T`, two `f001_T_Tt`,
`f002_W`, `f004_A`), all at **39/50**; the sixth drops to **13/50**; **none
matches 50/50**. And the five players **agree with each other on all 50** — the
three player ships share one flight model. `Booster` stands alone on **10**
fields. ⚠️ Correcting myself: the eleventh, `AA_Yaw_Max`, is `65.0` vs `65`
formatting, not a value. Last iteration's "11 differ" over-counted.
🟡 **NOT SETTLED, and it is now sharper: nothing selects `Booster`.**
`PlayerParams` is loaded **once, unconditionally** — one call site, one caller, no
branch. Yet `flight-speed-law.md` clocked the player at ~125 / ~420 / ~1 530,
which tracks the unit `Maneuver` 100/350/1 200 at a flat ~1.25 and misses
`Booster`'s 100/612/2 100 badly; the `RT`-held run reached ~1 530, nowhere near
2 100. **Both tables cannot govern the same craft.** Cheapest next test is a
runtime one (NEEDS-HUMAN): watch which constant set reaches the live flight
struct, and re-measure with the afterburner held.
---
## ✅⚠️ 2026-08-27 — the unit loader was already in the corpus (`sub_82341A20`), and 0-xref ≠ no reader
Item (a), "what loads the 114 unit `Maneuver` records". **The corpus already
answers it and I should have found that before framing it as new.**
⚠️ **My own error, corrected:** I claimed `live-unit-definitions.md` did not
exist. It does — at `docs/re/live-unit-definitions.md`, not under `structures/`.
Looking in one directory is not looking.
**`sub_82341A20` is the unit-definition loader**, documented in
`unit-struct-runtime.md` ("Cross-validated against the loader itself,
2026-08-13") with a checked-in 177-line layout at
`crates/sylpheed-formats/data/unit_definition_layout.txt` (159 fields, 25/25
agreement with the solver). Verified independently here: 3 969 instructions, and
**227 of its 236 `addi rX, r30, -N` displacements resolve to strings** — the unit
schema in order, `Generic`, `UncertainName`, `IsDestructible`, `NamePlate`,
`Size_X/Y/Z`, … `Maneuver` sits at exactly `0x82088F94 13404`.
**Record keys are `tag_hash`, confirmed on the data side:** the `Maneuver`
record's key is `0x43FAA517` = `tag_hash("Maneuver")`, not `name_hash`
(`0x63A248D7`). ×114 records.
🔑 **And that explains the 0-xref phenomenon — which softens my own verdict from
last iteration.** `Maneuver`'s string has **0 xrefs** *and* a known reader: the
loader takes its address as a **register-passed base plus a displacement**, which
no static xref can see. So a 0-xref block is not evidence of no reader.
🟡 **Verdict on the `AnalogRevice_*`/`Tweak` block softened 🔴 → 🟡.** A
base-plus-displacement sweep over the 2 283 functions that form the `0x820A` high
half reaches it **0** times — but run against the unit block as a **control**,
the same sweep recovers only **5** references and **misses `sub_82341A20`
itself**. The measurement stands; "the reader is unreachable" does not. Recorded
in `player-tuning-tables.md`; runtime watch still the cheap way in.
⚠️ Also fixed there: the doc credited `live-unit-definitions.md` with documenting
`sub_822F9498`. It documents `sub_82341A20` — a different function.
🟡 Not settled: which table governs the measured player speeds (unchanged); a
base-tracking sweep strong enough to follow register-passed bases was not built.
---
## ✅✅ 2026-08-27 — the base-solver: 277 name-block loaders, and the analog block is SOLVED
Item (b). New regenerator `tools/re-capture/name_block_bases.py`
`docs/re/data/name-block-bases.txt` (2 880 lines, ~65 s, byte-identical across
two runs). **This is a reusable tool, not a one-off.**
🔑 **The method.** A loader that reads a table by field name keeps one base
pointer and emits `addi rX, rBASE, -N` per name, so no static xref sees the
strings. Solve the base from the **displacement set alone**: every (string
address, displacement) pair implies a candidate base, and the true base collects
a vote from **every name it explains**, so it wins outright.
⚠️ My first cut took candidates from ONE displacement and scored the unit loader
at **52/226** against the right answer's **217/226** — a plausible wrong base.
**Vote over the whole set, not a probe.**
**Control passes with no prior knowledge:** the tool recovers
`sub_82341A20 → r30 = 0x82088F94`, **217/226**. It also independently recovers
`sub_8230D1F8` (129/132), `sub_822F9498` (90/91) and `sub_822AE628` (81/108),
and reports **277** name-block-reading functions image-wide with the schema each
names — an index the corpus did not have.
✅✅ **The analog block is SOLVED: `sub_821A6CF0`, `r29 = 0x820A1630`, 22/24.**
In code order it names `ControlTweakName`, `YawMagForNormal`, the 12 `Tweak`
fields, the 8 `AnalogRevice_*` curves and `GP_MAIN_GAME` — the whole schema in
the object's own order, plus its pak. `r29` is built at `0x821A6D34` as
`addi r29, r11, 5680` = `0x820A0000 + 5680`, matching the solved base exactly.
🔑 **It is the same function that reads `PlayerParams`** (it references that
literal and calls `sub_822F9498`): one function loads the player parameter object
*and* the control-tweak/analog table.
🔴 **Two of my own verdicts withdrawn.** "Referenced by nothing" (🔴) and the
softened "not found by these routes" (🟡) were both wrong; the measurements
behind them — 0/28 xrefs, 0 hits from naive base tracking — were right. **The
base was solvable from the data the whole time.** ⚠️ `r1` groups are the tool's
noise floor (stack frames) and are excluded.
🟡 Not settled: how the 11 curve samples are *applied* (the loader names the
records; the interpolation is downstream); the remaining 255 of the 277 solved
functions are listed but unexamined; what selects `Booster` (unchanged).
---
## ✅ 2026-08-27 — mining the base-solver index: a false-positive mode named, and the AI-table reader found
Item (a). 277 rows over **190 distinct functions** (a function can read several
blocks). `name_block_bases.py` extended with a confidence split; artefact
**+11/0**, byte-identical across two runs.
🔴 **The tool's false-positive mode, now measured and named.** **107 of 277** rows
solve to a base on a **64K boundary** — a bare `addis rX, r0, 0xHHHH` with no
`addi`, so any scatter of displacements votes for it. **82 are `0x820B0000`**:
~60 near-identical functions in `0x8281xxxx0x8284xxxx` all "naming" the same
`rou_e007 rou_e010 …` list. **The 170 rows with a non-zero low half are the
trustworthy set.** ⚠️ A round base is not automatically wrong — `sub_822215D0`
sits on `0x820A0000` and resolves 205/206 — so read the ratio, not the base.
**Already owned, and the index re-derives them** (that is the control): the
unit loader (217), stage settings (129), `PlayerParams` (90), hangar (81),
squadron orders, missile guidance, shell movement, substructures, six camera/fog
readers.
🔑 **The find: `sub_8233C368` reads the AI behaviour table**`r28`, base
`0x8208583C`, 20 names: **`Enumerate_AIs`**, `FiringLength`, `GuardLength`,
`AutoGuardLength`, `CounterLength`, `MusterLength`, … `stage-mission-tables.md`
owns those *field* names on the data side; **`Enumerate_AIs` appears in no
document and no reader was known** — the corpus carries the AI tail of `Maneuver`
as 🟡 NEEDS-HUMAN/runtime. **It is statically reachable after all.** The same base
also serves `sub_82338EE0` (97 names, `Weapon TargetType SpecialWeaponType
ReticleType IsCharging …`) — the weapon datasheet loader, also not previously
named.
**Five unowned blocks surfaced, none opened:** `sub_822215D0` (205/206,
`PGHUD_*`/`PGREMAIN_NUM%d` HUD part names), `sub_822814D8` + `sub_8227A3A0`
(`STAGE_RESULT`, `stage_num_shoot_down_aircrafts`, `EX_OVERVIEW`),
`sub_822E3EC8` (`g_mWorldViewProjection`, `NormalMap`, `GlossinessMap` — engine
material slots), `sub_823C0260` + `sub_823AE908` (`Boss16Collision*`,
cross-linking the S16 `Guardian` object), `sub_822AFA50`
(`roh_n001_menu1_cam_pos` — menu camera tags).
🟡 Not settled: the index says what each function *names*, not what each *means*
— nothing above was opened. And the 107 low-confidence rows were flagged, not
re-solved with a stricter method.
---
## ✅⚠️ 2026-08-27 — `AIParams` disc-wide, and a correction to my own last entry
Item (a), "open `sub_8233C368`, the AI table". New regenerator
`tools/re-capture/aiparams_census.py``docs/re/data/aiparams-census.txt`
(45 lines, byte-identical across two runs).
⚠️ **CORRECTION FIRST.** Last iteration I recorded `sub_8233C368` as unblocking a
NEEDS-HUMAN item — *"the corpus carries the AI tail of `Maneuver` as
NEEDS-HUMAN/runtime; it is statically reachable after all."* **That was wrong.**
`stage-mission-tables.md` already documents `AIParams_S02.tbl` as ✅ *"exact
original values obtained by static RE… usable in the port directly"*, listing all
20 field names and both shapes. I grepped `FiringLength` and saw the file, but
did not read the section. **Finding the owning doc is not reading it** — the same
lesson this corpus has paid for before. The only genuinely new part was the
**loader's name**.
**What is new: the census generalises Stage 02 to the disc.** 23 `AIParams`
objects, identical in all six `GP_MAIN_GAME_*` paks, sharing **one** declared-name
set of **34** profiles; **782** profile records = 23 × 34; **0** declared names
without a record in their own object. So "34 AI profiles" is not a Stage-02 fact —
**every stage carries the same 34 and only the values move.**
🔑 The roster is declared by an **`Enumerate_AIs` record whose field names are the
profile names** — the same declaration-table mechanism that closed `DefTables`.
⚠️ **`Type` predicts the field count, with exactly two exceptions.**
`Fleet` → 6 fields is **253/253, zero partials**; `Squad` → 20 fields is
**483/529**. The 46-record residual, in full: **`AI_Test` and
`AI_CraftSquadron_Test`**, both `Type = Squad` with only the six base fields, in
all 23 objects (2 × 23 = 46) — the family's `_Test` templates again. **No
profile's shape varies between objects (0).**
**The loader**: `sub_8233C368`, `r28`, base `0x8208583C`, 20 names — exactly
the field set above and nothing else.
🟡 Not settled: which of the 23 objects belongs to which stage (only
`AIParams_S02.tbl` is named by the corpus; the disc-wide harvest was not re-run
against the other 22), and what consumes an `AIID` at runtime.
---
## ✅ 2026-08-27 — every `AIParams` object mapped to its stage, zero residual
Item (a). `aiparams_census.py` extended with the stage mapping; artefact
**+7/0**, byte-identical across two runs.
**`StageResource.EnumerateAIParams` names the table, and every name resolves.**
| | |
|---|---:|
| `StageResource` records | 29 |
| carrying `EnumerateAIParams` | **28** (the one without it is the `_Test` template) |
| distinct table names declared | **23** |
| names that hash to an `AIParams` object key | **23 / 23**, prefix `stage\` |
| objects left unnamed | **0** |
| declared names with no object | **0** |
`AIParams_S01…S16` (16) + `AIParams_S24…S29` (6) + **`AIParams_Tutorial.tbl`
shared by six** (`UnitGroup_S18…S23`, the tutorials) = 23 tables over
`16 + 6 + 6 = 28` records. **The arithmetic closes both ways** and matches the
23-object count found last iteration.
🔑 Same sharing shape as the settings family (`stage-settings-table.md`: 24
objects, `StageParameter_Tutorial` shared by six tutorials). Two independent
families agree on how the tutorials are handled — n=2, a pattern rather than a
rule, but a consistent one.
🟡 Not settled: what consumes an `AIID` at runtime (unchanged — the link from
`UnitGroup.AIID` to a profile record is documented, the code that acts on the
20 parameters is not).
---
## ✅ 2026-08-27 — the executable's own `PG*` HUD roster; the disc side was already owned
Item (a), the five unowned base-solver blocks. Took the biggest, `sub_822215D0`
(205/206). Static only; no artefact changed.
⚠️ **Grep-and-READ first, and it paid.** `PGHUD*` pointed at
`mission-script-manifest.md` — which documents `HudResource`'s 24 fields — but
**`hud-config.md` owns the whole family**: all 16 records in the six
`GP_MAIN_GAME_*2D` paks, 419 distinct asset paths. Had I stopped at the first
grep hit I would have "discovered" a documented table for the second time in
three iterations.
**Control:** counting `PG*` field names straight off the paks reproduces that
document's own numbers exactly — `Sight` 35, `Radar` 29, `Marker` 27, `Wing` 23,
`Manuva` 15; `HudResource` 23 `PG*` + `HUD_RES_FONT` = its stated 24.
**The increment: the executable carries its own roster.** `sub_822215D0` reads
a **205-name** block at base `0x820A0000`, **205/205 `PG*`-prefixed (zero
partials)** — `PGHUD` 77, `PGTARGET` 31, `PGGAUGE` 25, `PGMANUVA` 16, `PGMARK`
12, `PGWARNING` 10, `PGMARKTGT` 9.
| | |
|---|---:|
| distinct `PG*` on the disc | **228** |
| the code block | **205** |
| in both | **200** |
| code-only | **5** |
| disc-only | **28** |
`200 + 5 = 205`, `200 + 28 = 228` — closes both ways.
⚠️ **Four of the five code-only names are printf families whose stem is a disc
field** (`PGHUD_HIT_NUM_EFF%d`, `PGHUD_HIT_NUM_RED%d`, `PGREMAIN_NUM%d`,
`PGTIMER_NUM%d`), and all four stems sit in the disc-only 28. **The code builds
indexed instance names from a base name the table declares.** After pairing, the
residual is **one** code-only name (`PGMANUVA_EFF0`) and **24** disc-only ones,
listed in the doc.
🟡 Not settled: what the block *is* — lookup table, registration order or paint
list. The function body was not read. And four of the five surfaced blocks are
still unopened (`STAGE_RESULT`/`EX_OVERVIEW`; `g_mWorldViewProjection`/
`NormalMap`; `Boss16Collision*`; `roh_n001_menu1_cam_pos`) — each grepped this
iteration and each appearing in **no** document but my own.
---
## ✅ 2026-08-27 — the debriefing and pilot-record screens; the save-screen block was already owned
Item (a), the results/leaderboard blocks. New doc
`docs/re/structures/result-screens.md`. Static only; no artefact changed.
⚠️ **Read-before-claiming caught one of the three.** `savegame-format.md` already
documents the shape *and* this exact list: *"the LOAD/SAVE screen's config key
list is compiled into the executable, as a pointer array of key strings — the
same shape as the Arsenal's … match the pak record exactly"*, run starting
`0x820A0074`. **`sub_82286BC8`'s 18 names are that list** (17/18 are a
`tables.pak` field or record). Nothing new there; residual
**`PLAYER_AMMO_LESS_10`**, not a `tables.pak` name at all.
**The other two are new screens of the same shape, and they close exactly.**
All names resolve into `tables.pak`: `sub_822814D8` **24/24**, `sub_8227A3A0`
**21/21**.
* **`sub_822814D8` = the debriefing screen.** 24 = 2 screen ids
(`STAGE_RESULT`, `EX_STAGE_RESULT`) + **21 `stage_*` fields** + one sound cue
(`SE_BOSS_CORE_CHARGE`); the `tables.pak` `STAGE_RESULT` record has **exactly
21** fields (2 objects, both 21).
* **`sub_8227A3A0` = the pilot record / career screen.** 21 = 5 screen ids
(`LAST_RESULT`, `EX_BASE`, `EX_MAIN`, `EX_OVERVIEW`, `OVERVIEW`) + **7
`ex_overview_*`** + **9 `overview_*`**; the records `EX_OVERVIEW` and `OVERVIEW`
have **exactly 7 and 9**.
`2+21+1 = 24` and `5+7+9 = 21`, with 21/7/9 measured independently off the pak —
the arithmetic closes both ways.
🔑 **The debriefing readout is nine `num`/`points` pairs plus three points-only
lines**: kills by class, main/sub objectives, clear time, shoot-down ratio and
both damage categories carry a raw count *and* its score contribution;
`friendly_fire`, `shoot_down_others` and `weight` have no counter column. Same
partition `mission-scoring.md` measures on the settings side — that document owns
the *rules*, this is the *readout*.
🔑 `OVERVIEW` = the seven `EX_OVERVIEW` fields **plus `overview_rank` and
`overview_medals`**, which is what makes `EX_` the reduced variant of the same
screen rather than a different one.
🟡 Not settled: what `EX_` means — `EX_BASE`, `EX_MAIN`, `EX_STAGE_RESULT`,
`EX_FONT` are keys with **no record of that name**, so it is a screen-id
convention, not a table. `PLAYER_AMMO_LESS_10` unexplained. And **two of the four
surfaced blocks remain unopened** (`g_mWorldViewProjection`/`NormalMap` engine
material slots; `Boss16Collision*` + `roh_n001_menu1_cam_pos`).
---
## ✅⚠️ 2026-08-27 — the last base-solver blocks: two findings, and a correction to how I read the index
Item (a). Static only; no artefact changed.
⚠️ **CORRECTION FIRST — a row is a (function, REGISTER) pair, not a function.**
Every one-line label I carried for these blocks quoted **one of two** rows, and
the other row of the same function is an unrelated schema:
| function | one row | the other |
|---|---|---|
| `sub_822E3EC8` | `r11` 29 — shader constants + techniques | `r10` 15 — the material map slots |
| `sub_822AFA50` | `r11` 16 — XDK shader-compiler tokens | `r10` 13 — the menu camera tags |
| `sub_823AE908` | `r11` 43 — the S16 boss collision table | `r31` 33 — shader tokens (false positive) |
So "`sub_822AFA50` = menu camera tags" and "`sub_822E3EC8` = `NormalMap`…" were
each half-right by accident. **Quote the register.**
**The engine's material vocabulary** (written into `xbg7-mesh.md`, which owns
the `_col`/`_spc`/`_gls`/`_lum` suffixes). Nine base map slots — `ColorMap`,
`LuminosityMap`, `SpecularityMap`, `GlossinessMap`, `ReflectionMap`,
`TransparencyMap`, `NormalMap`, `FresnelMap`, `EdgeTransparencyMap` — and
**seven `*MapArray` twins**; `9 7 = 2`, and the two without a twin are
`FresnelMap` and `EdgeTransparencyMap`. 🔑 **Four of the nine are exactly the four
documented suffixes**; the other five have no suffix in the shipped meshes — the
engine supports more channels than the assets use. Plus 17 `g_*` shader
constants, three cube samplers, and `DepthOnlyTechnique` /
`MotionVectorTechnique` / `TransparentTechnique` — the `…Last` matrices plus the
motion-vector technique say the renderer keeps the previous frame's per-object
transform.
**The S16 boss's collision parts** (written into `collisionset.md`).
`sub_823AE908` `r11` is an **interleaved (mesh, logical-part) table**:
`rou_e901_body_02_c → Boss16CollisionBody02`, the five wing pairs, both tails,
`rou_e901_barrier_cmesh → …Barrier`, `rou_e910_core_cmesh → …Core`, and
`mob_n900{,_b01,_b02,_gear}_cmesh → …Room00/01/02 + Gear00/01/02`.
**23 logical parts + 20 meshes = 43**, differing by exactly three: `Body01` opens
with no mesh, and the one gear mesh carries three gear names.
🔑 Cross-links the S16 `Guardian`/`Core` object from the `PlayerParams` work —
`Shell_S16Boss_*` weapons there, `Boss16CollisionCore` here.
🟡 Not settled: whether the `rou_e901_*_c` meshes are entries of the
`CollisionSet` library (its 18 blobs are byte-identical, so a name join was not
attempted); why `Body01` has no mesh; and none of the shader slots was traced to
a shipped shader.
---
## ✅ 2026-08-27 — all 277 base-solver rows classified objectively; 53 are data-table schemas
Item (a). `name_block_bases.py` extended with a per-row data-table test; artefact
**+57/0**, byte-identical across two runs (~2 min 12 s now — it adds a disc-wide
pak scan).
🔑 **The test is objective, not by eye:** a row is a *data-table schema* if its
names are **IDXD record/field names on the disc** (13 450 such names disc-wide).
**53 of 277 rows** are ≥50 % disc names with ≥8 names; the other 224 are engine
/ XDK vocabulary, compiled key lists, or noise.
⚠️ **The two axes are independent.** Against base confidence: `solved` bases
split 34 table / 136 not, `round` bases 16 / 91. So "round base" and "not a
table" are different questions — the earlier round-base warning was about
*where* the base is, this is about *what the names are*.
✅ The 53 contain every loader already known (the control): unit definitions,
stage settings, `PlayerParams`, hangar, `AIParams`, the weapon datasheet, the
`PG*` roster, the three screen key lists.
❔ **Five rows in the 53 are unowned — each noun grepped, appearing in no
`docs/re/` file:**
* `sub_823BDAA8` r11 (33) — `rou_e901` + `GN_MainGun_01_MuzC`, `GN_MainGun_02_Muz01…`:
the **S16 boss's muzzle/attach frames**, sitting beside the collision table.
* `sub_823BDAA8` r10 (25) — `Motion_stand`, `Motion_stand_b1`, `Motion_attackA_start`…:
**motion names**, the `EnumMotions` family that `DefTables` declares.
* `sub_82315AE8` r11 (20) — `InitRotation`, `MaxRotationSpeed`, `RotationAccel`,
`MaxVerticalSpeed`: the **`Guardian` record's own fields** — the S16 boss loader.
* `sub_8219E560` r11 (18) — `Detail_Rank`, `MISSIONS`, `Detail_Board_Permanent`:
the **leaderboard screen** keys.
* `sub_825F2CF0` + `sub_825F2F88` r0 (30 each, same base) — `FinalPassBG`,
`FinalPassToneRatio`, `FogMin/MaxDistance`: **post-processing**.
⚠️ **Four rows that look new are not**, and their disc-overlap says so: 5370 %
rather than ~100 %, because they mix arsenal fields the corpus owns
(`ConditionToDevelop`, `WeaponDesc`, `SilhouetteModel`) with **literal screen
coordinates as strings** (`757,228`, `903,343`, `1092,457`).
🟡 Not settled: none of the five was opened — this iteration produced the
shortlist, not the findings. The 224 non-table rows were not partitioned further
(XDK vs game engine vs noise).
---
## ✅ 2026-08-27 — the Stage 16 boss closed end to end; and five disc fields the image never names
Item (a). New doc `docs/re/structures/stage16-boss.md` joining three iterations'
pieces: the data object (`player-tuning-tables.md`), the collision table
(`collisionset.md`) and now the loader. Static only; no artefact changed.
**`sub_82315AE8` `r11` is the boss loader, and it says so itself.** 558
instructions, **one** call site (`sub_82308AB8`); its 21-entry block is 19 field
names plus the Japanese error 「ボスのレーザー弾IDが取れていない」 — *"the boss's
laser shell ID could not be obtained"*.
**Zero partials: every one of the 19 is a `Guardian` or `Core` field** — 16 of
`Guardian`'s 19, 5 of `Core`'s 10, `DamageLevel1`/`2` shared. The boss reads as a
rotating, vertically-moving platform with a guard cycle, three shell ids
(`AAGun`/`Laser`/`HomingLaser` — the `Shell_S16Boss_*` set) and a `Core` that
periodically drops lock-on.
🔑 **The residual is the finding: 8 disc fields the loader never names, and five
of them are not strings in the executable at all** — `InitHight` (the game's own
typo for *Height*) and the four `DamageLevel{1,2}Bomb{,Rand}Time`. **Controls:**
`ChargeTime` (10 chars) and `GuardTime` (9) are the same shape and both ARE
present, so it is not an extraction artefact; `HP` (2 chars) is below the
extractor's floor (shortest row is 6); `Radius` exists but is generic (3 copies,
used by many systems).
**The disc carries fields no code names** — the mirror image of the usual trap
(a loader naming a field the disc never values). 🟡 Whether they are read
positionally or are dead data is not settled.
🟡 Not settled: the other four shortlisted rows (`sub_823BDAA8` r11/r10 muzzle
frames + motion names, `sub_8219E560` leaderboard keys, `sub_825F2CF0`/
`sub_825F2F88` post-processing) are still unopened.
---
## ✅ 2026-08-27 — the S16 boss's frames and motions; `attackC` is a cut attack
Item (a): `sub_823BDAA8`, the next boss piece. Written into `stage16-boss.md`.
Static only; no artefact changed.
**The boss subsystem is a linear chain**, one call site each:
`sub_82385D48``sub_823AE908` (collision pairs) → `sub_823BDAA8` (frames +
motions) → `sub_823C0260` (hull part list).
**`r11`, 33 names = the attach-frame roster**, partitioning exactly:
**15** hull frames + **9** muzzles (`GN_MainGun_01_MuzC` and
`GN_MainGun_02_Muz01…08` — one gun with a centre muzzle, one with eight) + 1
shield + 3 gear + 5 model ids = 33. The 15 hull frames are the same 15 the
collision table pairs, and **`rou_e901_body_01` is present here** — the one whose
`_c` mesh the collision list did not carry.
**`r10`, 25 names = a SUBSET of the disc's motion set.** The disc object is
`DefTables` `2ef95cdd`: **58 records = 3 structural + 55 motions**; all 25 code
names are in it (25/25); **30 disc motions are not in the code block.**
⚠️ **A reading I nearly published, refuted by checking the other population.** In
the code block `Motion_guard_start` stands alone while every other stem carries
`_b1`/`_b2`, which looks like *the* exception. **It is not** — the disc has
`guard_start_b1`/`_b2`, plus `guard_keep` and `guard_end` with their variants.
The asymmetry is in the code's list, not the data.
🔑 **`attackC` is a third boss attack the executable never names.** Most of the 30
omitted motions *do* exist as strings elsewhere (`Motion_guard_keep`,
`Motion_dead`, `Motion_break`, `Motion_damage01` — one row each), but the whole
**`Motion_attackC_{start,keep,end,charge}` × `{,_b1,_b2}` family — 12 names — has
0 string rows**, against **two identically-shaped controls at 3/3**
(`attackA_charge`, `attackB_charge`). The boss ships with an animated third
attack pattern no code path can select by name — the same
disc-carries-what-code-never-names shape as `InitHight`, but a whole behaviour
rather than five parameters.
🟡 Not settled: whether `attackC` is reachable by index rather than by name; what
`eff_s900_e_Charge` attaches to; and the other three shortlisted rows
(`sub_8219E560` leaderboard, `sub_825F2CF0`/`sub_825F2F88` post-processing).
---
## ✅ 2026-08-27 — the leaderboard screen, and a `Detail_*` prefix trap
Item (a): `sub_8219E560 r11`, censused against `tables.pak` exactly like the
result screens. New doc `docs/re/structures/leaderboard-screen.md`. Static only;
no artefact changed.
✅ **The 18 names partition exactly: 13 `tables.pak` record names + 5 field-only
names.** The 13 are the **12 `Detail_*` panel elements** plus **`MISSIONS`** — a
0-field record already owned by `isl-condition-builtins.md` and
`challenge-mission-gate.md`, so not new. 444 instructions, **one** call site
(`sub_821A2A80`), 100 % of the names are `tables.pak` names.
The five field-only names are the screen's parameters: **`DETAIL_TITLE`**, one
field in each of six **mode** records (`ScoreAttack`, `TimeAttack`,
`Extra01…Extra04`), and **`Detail_Board_{Permanent,Monthly,Friend,Self}`**, four
fields of `CHIPS` — the board **scopes**.
**All 12 `Detail_*` record names are in the code block — zero residual**, the
opposite of the boss motions (a 25-of-55 subset). The panel is
`Rank`, `GamerTag`, `Main_{Time,Points}_{Self,Live}`, `Warships`, `Warplanes`,
`Weapon_{Nose,Main1,Main2,Main3}` — your figure against the selected entry's,
plus that pilot's kills and four weapon slots. `_Live`, `GamerTag` and the
Friend/Monthly boards make it the **Xbox Live** detail view.
🔑 **The trap worth keeping: `tables.pak` has 12 `Detail*` RECORD names and 12
`Detail*` FIELD names, and they are DISJOINT (0 overlap).** The fields are a
different axis — 4 board scopes + 6 modes + `Detail_Window_{Known,Unknown}`.
**A `Detail_*` name means nothing until you say whether it is a record or a
field**; counting "24 `Detail_*` things" would merge two unrelated tables.
🟡 Not settled: what `Detail_Window_Known`/`_Unknown` switch between; whether
`Extra01…04` map onto the four challenge missions the corpus knows. And the last
shortlisted row (`sub_825F2CF0`/`sub_825F2F88` post-processing, two functions on
one base) is still unopened.
---
## 🔴✅ 2026-08-28 — the last shortlisted row is an artefact: `r0` is not a base register
Item (a), the final unopened row: `sub_825F2CF0` + `sub_825F2F88`, **`r0`**, 30
names each on the same base `0x8209EB30`, 97 % disc names. The framing was "a
pair, or a clone?" — **both wrong. Neither function reads a name block.**
🔑 **Diffing the two settles it in one look.** 145 instructions each, **72
differ, and every difference is the same substitution**: `stvx`/`stvx128` on one
side, `lvx`/`lvx128` on the other, over v14…v127. They are the compiler's **VMX
register save / restore helper pair**. A pair, then — but the pairing is
spill/reload, not two readers of one table.
🔴 **The root cause is a whole class of false positive.** In PowerPC
`addi rD, rA, N`, the `rA` slot **reads as literal zero when it names r0** — the
form is `li rD, N`. The 72 `addi r11, r0, -N` are vector spill offsets, plain
immediates. The solver excluded `r1` (stack pointer) and nothing else, so it took
them as displacements, voted a base, and landed `0x8209EB30` — *inside the
stage-settings name block*, where a scatter of 16-byte-spaced negative offsets is
dense enough to "resolve" 30 real field names.
⚠️ **A 97 %-disc-names row can still be an artefact.** The disc-name test measures
what the *base region* contains, not that the function reads it. That is a
correction to the 53-of-277 classification's own premise.
**Nothing is lost, and the real reader was already ours.** All 30 withdrawn
names are a **strict subset** of `sub_8230D1F8 r29` (base `0x8209FD28`, 129
names) — the stage-settings loader. The string-xref join agrees independently:
`FinalPassBG`, `FogMinDistance`, `ScreenColorR` and `ExposureKey_BeginValue` each
exist **once** in the image with **exactly one** xref, from `sub_8230D1F8`.
`NoseCameraFOV` and `GlareType` exist **twice**, the second copy read by
`sub_822AE628` — the hangar's game-wide defaults, the double-string-block trap
the corpus already records.
**Fix:** `name_block_bases.py` now skips `reg in ('r0', 'r1')`. Every number moves
by exactly the 16 `r0` rows: rows **277 → 261**, distinct functions **190 → 176**
(14, not 16 — two of those functions keep a row through another register),
non-64K bases **170 → 154**, 64K bases 107 unchanged, data-table schemas
**53 → 50**. Both ways: `154 + 107 = 261` and `277 16 = 261`. The artefact diff
removes 100 lines and adds 3; **every removed row line carries `r0`**, the three
added lines are those counts, and the control is untouched (`sub_82341A20 r30 =
0x82088F94`, 217/226). The three withdrawn data-table rows are `sub_825F2CF0`,
`sub_825F2F88` and `sub_82222E70` (15 names, 100 % disc, same region, same
mechanism); the "six camera/fog readers" are really **three** (`sub_8247DFC0`,
`sub_823B2620`, `sub_822C7480`, all `r31`, all based in `0x8209E6xx`).
Written up in `docs/re/structures/player-tuning-tables.md`; `INDEX.md` updated.
🟡 Not settled: the 🟡 stalled `SpaceSize`/`GlareType`/`Nebura_*` numerics are no
closer — their reader was already known to be `sub_8230D1F8`, and this row never
was a second one. **The shortlist is now empty.** Still open: whether a `stvx`-
style helper elsewhere fooled a *non*-`r0` row (the 64K-boundary cluster is the
remaining known false-positive mode, unaddressed); `Detail_Window_Known/_Unknown`;
whether `Extra01…04` map onto the challenge missions.
---
## 🔴✅ 2026-08-28 — the base-solver's confidence axis was inverted
Item (a): the remaining named false-positive mode, "**107 rows solve to a base on
a 64K boundary — a bare `addis rX, r0, 0xHHHH` with no `addi` of its own, so any
scatter of displacements votes for it**". **Refuted, and by its own measurement.**
**The test, now in the tool.** Simulate `lis`/`addis rD, r0, HI`,
`addi rD, rA, N` and `or rD, rA, rA` forward through each row's own function and
ask whether the solved base ever lands in the solved register — positive
confirmation from the instruction stream, independent of the displacement vote.
* bases on a **64K boundary** ("low confidence"): **107 / 107 confirmed**
* bases with a **non-zero low half** ("trustworthy"): **8 / 154 confirmed**
A 64K-boundary base is the case where the compiler needed **no second
instruction**, so `addis r11, r0, 0x820B` stands in the code in full. A non-zero
low half usually means the base was built in the caller or loaded from memory,
which the simulation cannot see — a miss there is silence, not a refutation
(the control `sub_82341A20 r30` is one such silence; it stands on 217/226 and on
the disc).
🔑 **The control that settles it: every row the corpus independently validated
against the disc has a 64K-boundary base** — debriefing `sub_822814D8`, career
`sub_8227A3A0`, save `sub_82286BC8`, leaderboard `sub_8219E560`, the 205-name
`PG*` HUD roster `sub_822215D0` (r11 and r10), material slots + shader constants
`sub_822E3EC8` (r11 and r10), the S16 boss collision/frames/motions
`sub_823AE908` / `sub_823BDAA8` (r11 and r10) / `sub_823C0260`, and the boss
loader `sub_82315AE8`. Thirteen rows over ten functions. Meanwhile the
dense-short-string false positives the corpus *did* name — the
`r31 = 0x8202xxxx` rows — all sit in the class that was called trustworthy.
**The axis pointed the wrong way in both directions.**
**What the `0x820B0000` cluster actually is: duplication, not error.** 82 rows
carry that base and **60 are one function emitted 60 times** — exactly **491
instructions** each, and diffing any two, **two instructions differ**, both a
global data pointer (`addi r30, r11, 14272` vs `15552`; `addi r3, r11, -13024` vs
`-12984`). All 60 reference an **identical sequence of 41 string addresses**,
writing 40 resource names into a per-copy global through
`sub_8217FA08(dest, name, -1)` at 24-byte strides. A static array duplicated once
per translation unit. The resolution ratio cannot show that, and the confirmation
test does not care.
The roster is 30 `rou_*`, 6 `mob_*`, 3 `rob_*`, 1 `rot_*`, and **38 of the 40 are
`GameResourceID` values on the disc** (480 distinct, each used once).
[[collisionset]] owns the prefix vocabulary and the `GameResourceID` link; what it
does not say is that the image compiles a fixed 40-name subset. 🟡 The two that
are not disc `GameResourceID`s: **`rot_n001`** — and no disc `GameResourceID` uses
the `rot_` prefix at all (`eff` 197, `rou` 172, `mob` 71, `wep` 23, `rob` 4,
`stg*` 9) — and **`rou_e202`**, whose siblings `rou_e201` and `rou_e901` are both
present.
Artefact diff is 13 added / 4 removed, confined to the replaced section; the
261-row table and the 64K-base histogram are untouched, and the file is
byte-identical on a second run. Written up in
`docs/re/structures/player-tuning-tables.md`.
🟡 Not settled: what reads the 60 duplicated globals; what selects 40 of the disc's
172 `rou_*` ids; whether `rot_n001` / `rou_e202` appear anywhere else on the disc.
**Both of the solver's named false-positive modes are now closed — one real
(`r0`), one refuted (the 64K flag).** What remains unmeasured is the 146 rows the
positive test is silent on.
---
## ✅🟡 2026-08-28 — the 60 duplicated functions are static-object constructors
Item (a), continued from the duplication finding. **What they are is settled;
what reads them is not.**
✅ **Each of the 60 is a static-object constructor building a 40-element array of
24-byte string objects.** `addis r11, r0, 0x820B` + `addi r4, r11, -9584` fetches
the literal, `addi r30, r11, 12944` fixes the array (`0x828E3290` for the first),
`bl sub_8217FA08(dest, literal, -1)` assigns, `stw` zeroes `+4…+20`, and
`addi r3, r30, 24` steps to the next element.
**Why they have no callers.** Each clone appears exactly **twice** in the image:
once in the **`.pdata` unwind table** — where every function appears as
`(address, prolog/length word)`; reading that as a registry is a trap I nearly
fell into — and once as a slot of a **null-terminated 182-entry function-pointer
array at `0x82870018``0x828702EC`**, of whose targets **170 have zero direct
xrefs**. That is the static-initialiser list, dispatched indirectly and run before
`main`. **Zero callers here means "static ctor", not "dead code".**
**The teardown side confirms the shape.** Between consecutive clones sit
**3943 functions (median 40)**, and the dominant size in the whole
`0x8281xxxx``0x8284xxxx` region is **11 instructions, 2 280 of them** ≈ 60 × 40.
Three consecutive ones call `sub_823F3D68` on `0x828E3290`, `+24`, `+48` — one
destructor thunk per element, same stride, same global, all zero-xref. So the unit
is **one ctor + 40 one-line dtor thunks, emitted 60 times**.
**`rot_n001` and `rou_e202` are on the disc after all.** A raw byte sweep over
every decompressed pak entry: **`rot_n001` 26 occurrences in 7 archives**
(`DefTables` included), **`rou_e202` 6 in 6** — exactly one per
`GP_MAIN_GAME_<lang>` pack and none in `DefTables`. Controls in the same sweep:
`rou_e201` 151/8, `rou_e901` 917/8, `mob_n500` 46/8. Neither is image-only;
`rou_e202` is just the roster's rarest member. Withdraws the "two names the image
carries that the disc never uses" reading — they are not `GameResourceID` values,
which is a narrower statement than I made.
🟡 **Not settled: what reads the arrays.** An exact-address search finds **2 347**
functions outside the 60 that materialise one of the 60 global addresses — far too
broad to be a consumer list. The globals sit in a dense data region, so that route
has no power; the consumer needs a different one. Also unsettled: what selects 40
of the disc's 172 `rou_*` ids, and which disc field holds `rot_n001`/`rou_e202`.
---
## 🔴✅ 2026-08-28 — the roster's two vocabularies, and a prefix match I mistook for a hit
Item (a): which disc field holds `rot_n001` / `rou_e202`.
🔴 **Correction to yesterday's own result.** The raw byte sweep reported
`rot_n001` "26 times in 7 archives". That was a **substring** count. The exact
field value occurs **zero** times, in every IDXD record and every non-IDXD
payload. Expanding the surrounding characters gives what the disc really carries:
`rot_n001_break` ×14, `LOD_Frame_rot_n001_break` ×7, `GameModel_rot_n001_break`
×5. **`rot_n001` exact is not on this disc; `rot_n001_break` is** — and it is one
of the `Generic.Model` values. The image names the stem, the disc the `_break`
variant. **A prefix match is not an exact match**, and a raw byte sweep is exactly
the tool that breaks that rule — it is a cheap *existence* test only when the
match is anchored.
**`rou_e202` does exist**, in the other vocabulary: a **`Model` field of a
`Generic` record**, six times, one per `GP_MAIN_GAME_<lang>` pack, with no
`GameResourceID`. That is why the `GameResourceID` census called it absent.
**The roster is covered by two fields.** Exact-value sets over every IDXD record:
roster 40, **`Generic.Model` 46**, `GameResourceID` 480; roster ∩ Model **34**,
roster ∩ GID **38**, roster ∩ both **33**. `38 + 34 33 = 39` — **39 of 40 are one
or the other**, the sole exception being `rot_n001`. The six that are GID but not
Model explain themselves: `mob_n040``mob_n043` are the four
**`AsteroidGroup_00N.AsteroidModelName`** values (18 each, one per pack — asteroid
models, not units) and `rou_e004` is a `Level_0.Model`.
🟡 **What selects the 40 is still open — but two candidate answers are now dead.**
Not the `Generic.Model` set (34 of 46), not the `GameResourceID` set (38 of 480).
The 12 `Generic.Model` values outside the roster are `mob_n050`, `rob_e005`,
`rot_n001_break`, `rou_e001`, `rou_e002`, `rou_e016`, `rou_e017`, `rou_e018`,
**`rou_f002`, `rou_f004`**, an empty string, and a Shift-JIS `文字列` placeholder
(the `_Test` pattern). **`rou_f002` and `rou_f004` are the player's own craft** for
S7S16 + S2427,29 and for S28, and the roster omits both while keeping
`rou_f001` — so "the playable or most-used models" is dead too.
[[unit-substructure-records]] mentions `Generic.Model` in passing as "the mesh the
corpus already decodes"; what it does not say is that there are only **46
distinct** values disc-wide. `AsteroidGroup_00N` / `AsteroidModelName` are new to
the corpus. Written up in `docs/re/structures/player-tuning-tables.md`.
---
## ✅ 2026-08-28 — the asteroid-field tables (`AsteroidGroup_00N`) decoded
Item (a), the new nouns from the roster census. New doc
`docs/re/structures/asteroid-fields.md`, indexed.
The chain was already half in the corpus: [[stage-definition-table]] /
[[stage-mission-tables]] record the per-phase **`AsteroidDefinition`** naming a
`.tbl`, and [[unit-datasheet-static]] partitions `Generic` and finds **10 per pack
whose only field is `{EnumAsteroidGroup}` — "an asteroid-group file"**. What
neither says is what is inside them.
**The table.** 10 objects per pack × 6 packs = 60, **384 `AsteroidGroup_*`
records**, 4 or 7 groups per object (12 objects with 4, 48 with 7). Every record
carries exactly **three** fields, all 384, no partials: `AsteroidModelName` (28
distinct — `mob_n050``mob_n056` ×24 each, `mob_n040``mob_n043` ×18),
`AsteroidFrameName` (64 distinct, `Frame_S<NN>[_pN]_Asteroid_NN` — the same names
[[regn-map-grid]] reports the `Route` tables showing), and `EnumAsteroid`
(37 distinct, **1…588**, 6 633 per pack summed).
**`Enum<Thing>` is a COUNT; `Enumerate<Thing>` names a TABLE.**
`EnumAsteroidGroup` equals its object's group count in **54 of 60**, and
`EnumAsteroid` is a plain integer — the opposite of the `Enumerate*` join rule.
**The `-erate` is the difference.** ⚠️ And `Count`-style fields still are not
safe: the 6 failures are **one object repeated per language pack**, declaring
`4` while holding `7`.
🔑 **The tenth object is unreferenced, and it is the one that miscounts.**
Joining by frame prefix: nine objects match the nine `AsteroidDefinition` values
one-to-one (S01 4 groups, S04/S05/S13/S14_p1/S14_p2/S08_p1/S08_p2 7 each, Test 4),
and the tenth carries **`Frame_Alpha_S01_Asteroid`, `Frame_Alpha_S01_Haze`,
`Frame_TAlpha_S01_Asteroid`** and is named by nothing — an `Alpha` Stage-01
variant with its own haze. An abandoned table is exactly what a wrong declared
count looks like.
🔑 **S28 has an asteroid *volume* but no asteroid *definition*.**
[[mcol-collision]] lists nine `_AsteroidVolume_wp` meshes — S01, S04, S05,
S08_p1, S08_p2, S13, S14_p1, S14_p2, **S28_p1**. The nine `AsteroidDefinition`
values are those eight plus **`test_s8p1`**. Eight shared, one different each way.
🟡 Not settled: what `EnumAsteroid` counts *of* (instances per frame fits the
1…588 range, untested); why S28_p1 has a volume and no definition. 🔴 **The
name→object join did not reproduce by hash** — `AsteroidDefinition` values do not
map to their objects' pak entry keys under `name_hash`, nor under that key
reduced `mod 0x00FFF9D7` or masked to 24 bits. The join above is by frame prefix,
which is direct evidence; either those keys are not name hashes or
[[archive-naming]]'s resolver differs from `unitgroup.name_hash`. Not chased.
---
## ✅✅ 2026-08-28 — `EnumAsteroid` counts the rocks, and the hash join was mine to fix
Item (a), both open questions from the asteroid write-up, settled.
**`EnumAsteroid` is the frame record's `FrameCount` — 57 / 57, zero
mismatches.** Each `AsteroidFrameName` is itself an IDXD **record name**; those
records carry one named field, `FrameCount`, plus a run of anonymous ones, and
the anonymous fields number **exactly 8 × `FrameCount` on all 384 occurrences**
(ratio histogram `{8.0: 384}`). The frame record is the **placement table — 8
values per asteroid** — and `EnumAsteroid` counts the rocks in it. The
frame-name → `EnumAsteroid` map is single-valued for all 64 names.
[[mission-wave-arrivals]] already met these records — they were the outliers that
broke its route-speed measurement, filtered out by name, with "the corpus already
records that the 8-value frame is not universal". [[regn-map-grid]] already
guessed the link and said so honestly: "stated as a reading, not a measurement;
nothing here counts objects." **What neither says is that the count is exact.**
🔑 **The abandoned table lost its placement data too.** Exactly **7 of 64** frame
names have no record, and they are exactly the seven groups of the unreferenced
tenth object — `Frame_Alpha_S01_Asteroid_{01,02,04}`,
`Frame_Alpha_S01_Haze_{01,02,03}`, `Frame_TAlpha_S01_Asteroid_03`. `57 + 7 = 64`,
zero partials either way.
**Withdrawing yesterday's 🔴: the hash join works, my call was wrong.**
`AsteroidDefinition` values resolve to their objects' pak entry keys as
**`name_hash("stage\" + name)` — 9 of 9**, each landing on the object the
frame-prefix join independently predicted. I had hashed the **bare file name**;
[[archive-naming]]'s resolver hashes `prefix + name` over a list of path
prefixes, and `stage\` is the one that works here (`Stage\` scores identically —
the hash lower-cases). **Two independent joins, 9/9 agreement**, both leaving the
same tenth object unreferenced. Lesson: before calling a corpus routine's result
a discrepancy, **read how the corpus routine calls it** — the prefix list was in
`archive_naming.py` the whole time.
🟡 Not settled: what the 8 values per asteroid are (position/rotation/scale is the
obvious reading, untested — [[regn-map-grid]] has the neighbouring work); why
`S28_p1` has an asteroid volume and no definition.
---
## ✅ 2026-08-28 — the 8 values per asteroid: index, quaternion, position
Item (a). Measured over every `Frame_*Asteroid*` record on the disc — **384
records, 37 518 items** (6 253 per language pack × 6; the remaining 380 per pack
belong to the seven record-less `Alpha` frames, which is the same 57-vs-64 split
as before).
| slot | test | result |
|---|---|---|
| 0 | equals the running index `0…N1` | **37 518 / 37 518**, max **587** — and the largest `FrameCount` is **588**, so the last index is `N1` |
| 14 | a **unit quaternion**? | **37 518 / 37 518 within 1e-4 of 1.0** (min 0.999999, max 1.000001) |
| 4 | sign of the real part | min **+0.001**, never negative — canonical w-last, so `(x, y, z, w)` |
| 57 | position in metres | 163 858…+79 572, 114 308…+76 422, 105 224…+110 263 — inside the ±250 km half-extent of `mapmesh_box_500km` |
`len(anonymous fields) == 8 × FrameCount` on **384 of 384**, zero mismatches.
**The unit-norm test is the control**: an arbitrary 4-tuple has no reason to sit
on the unit sphere, and every one of the 37 518 does. Two further independent
cross-checks fell out — the maximum index (587) is exactly `max(FrameCount) 1`,
and every position lies inside the 500 km box the corpus already knows
([[regn-map-grid]], world unit = 1 metre).
🔑 **There is no scale slot.** An asteroid's size comes from its group's
`AsteroidModelName` — which is why a group is *one model plus one placement
list*, and why the group table needed both fields.
🟡 Not settled: whether the quaternion is object→world or world→object, and its
handedness — a norm test cannot separate those. And still: why `S28_p1` has an
asteroid volume with no definition.
---
## 🔴✅ 2026-08-28 — the eight-value frame, censused disc-wide (and my novelty claim withdrawn)
Item (a): apply the placement shape elsewhere. **Grepping `FrameCount` first
would have saved the previous iteration an overclaim.**
🔴 **Withdrawn: the 8-value layout was not a discovery.**
[[stage-mission-tables]] already documents `Route_*` records as
`(time, quat.x, quat.y, quat.z, quat.w, pos.x, pos.y, pos.z)` with
`len(fields) == 8·FrameCount + 1` holding **1449 of 1449**, and
[[unit-group-table]] already documents `1 + 8·FrameCount` for formation slot
lists. I re-derived the shape from the asteroid tables and called slot 0 an
**"index"** — it is the corpus's **`time`** slot. Corrected in
`asteroid-fields.md`. What the asteroid pass did add is the **norm test**.
**The census the corpus was missing.** Both prior statements were measured on
one table family each. Sweeping every IDXD record on the disc that carries a
`FrameCount`:
* **10 986** records carry one; **10 974** have `anon == 8 × FrameCount`.
* **The entire residual is 12 records**: `Formation_Fleet_01` (ratio 136) and
`Formation_Fleet_02` (ratio 4), six each — one per language pack. **The two
exceptions [[stage-mission-tables]] already names are the only two on the
disc.** "The common case, not universal" is now `10 974 / 10 986`.
**The quaternion is measured, not asserted.** Over **78 762 frames** in five
families, `|q|` is within 1e-4 of 1.0 on **78 762 / 78 762**. An arbitrary
4-tuple has no reason to land on the unit sphere.
🔑 **Slot 0 is `time` only where something moves.** It equals the running ordinal
`0…N1` in **every** formation (2 196/2 196), asteroid (384/384), placement
(36/36) and test-route (24/24) record — and in only **6 228 of 8 334** real
routes, which are exactly the family that stores true timestamps there
(`Route_ADN101_p1F`: t = 0, 20, 30). Read the slot as *time*; expect static
tables to degenerate it to an index.
**Two record families new to the corpus**: `Placement_00…02` and
`RouteTest_00…01`, sharing a **six-record template object** whose sixth record is
named `Enumeration`, two such objects per language pack. Nothing yet ties them to
a reader.
Written up in `docs/re/structures/stage-mission-tables.md`; `asteroid-fields.md`
carries the correction.
---
## ✅ 2026-08-28 — `Placement_*`/`RouteTest_*` are `Null_Test.tbl`, and `Enumeration` is a self-index
Item (a).
**The template object is `stage\Null_Test.tbl`.** Its key hashes as
`name_hash("stage\" + "Null_Test.tbl")`, the file [[stage-definition-table]]
names as `EnumerateNullFrame`. Ten records: **4 `Frame_Test_Asteroid_01…04` +
3 `Placement_00…02` + 2 `RouteTest_00…01` + `Enumeration`** — and the nine
carrying a `FrameCount` are exactly the "**9/9 for `Null_Test.tbl`**"
[[stage-mission-tables]] already counted. **The corpus knew the file and the
count; it did not know the record names.** A second object (the same five
`Placement`/`RouteTest` records + `Enumeration`, without the asteroid frames)
matched none of the ten names I hashed across sixteen prefixes — ❔ a trimmed
sibling.
**`Enumeration` is a record that indexes its own object.** Disc-wide:
```
IDXD objects 7 750
objects carrying an `Enumeration` record 360
its fields == the object's other record names 360 / 360
a strict subset 0
naming something not in the object 0
```
Field/record counts run from 2 past 25 — `(3,3)×36`, `(9,9)×24`, `(13,13)×24`,
`(2,2)×24`, `(25,25)×18`, `(16,16)×12`**every pair on the diagonal**. A merely
overlapping list would not do that at 360/360 across that spread.
⚠️ **`Enumeration` vs `Enumerate` — one letter, opposite scope.** `Enumerate` is
[[archive-naming]]'s route-2 seed: a *single-record* object whose fields name
**other objects**. `Enumeration` names the records of **its own** object. Written
up in [[idxd-container]], which had left "an object's kind is known only from the
caller that loads it" open — for these 360 the object states its own contents.
[[mission-wave-arrivals]] counted 28 `Enumeration_*` records while filtering
non-routes out of a speed measurement; what it does not say is what they are.
🔴 **A sharper version of yesterday's correction.** I recorded the `stage\`
prefix as something `archive_naming.py` knew. In fact **[[stage-mission-tables]]
states it in prose** — "The prefix is `stage\``name_hash` is case-insensitive"
— and its `stagetbl.py` examples pass `'stage\AIParams_S02.tbl'`. The answer to
my "🔴 does not reproduce by hash" was sitting in the doc that owns the stage
tables. Noted in `asteroid-fields.md`.
🟡 Not settled: what `Placement_*` and `RouteTest_*` are *for* (they are test
fixtures in a `_Test` table, so possibly nothing ships them); the six-record
sibling's name; why `S28_p1` has an asteroid volume with no definition.
---
## ❌✅ 2026-08-28 — the `Enumeration` self-index is not a naming route (measured), and the residual is two `_Test` variants
Item (a). The tempting move — 360 objects state their own contents, so use that
to name them — **does not work, and now that is measured rather than assumed.**
```
objects carrying an `Enumeration` record 360
key already named by archive-naming routes 1 + 2 348
still unnamed 12
newly named by name_hash(prefix + own record name + ext) 0
```
Sixteen prefixes × seven extensions × every record name of every unnamed object:
**zero gained**. A self-index names an object's **records**; a TOC key is
`name_hash(path + file name)`. The two vocabularies never meet.
**The residual is exactly two objects × six packs**, each byte-identical across
the packs and each an **unnamed variant of a named `_Test` table** — not a
duplicate of it:
* `2390212806` (1 493 B) — `Placement_00…02` + `RouteTest_00…01`: a trimmed
`Stage\Null_Test.tbl` (`3121167452`, 27 545 B, the same five records **plus**
four `Frame_Test_Asteroid_*`).
* `2719765792` (14 124 B) — `Formation_100`, `Formation_Fleet_01`,
`Formation_Fleet_02`: **the same three record names as `Formation_Test.tbl`**
(`3463590559`, 14 025 B) but a **different payload**.
That answers last iteration's ❔ about the "six-record `Placement`/`RouteTest`
sibling": it is `Null_Test.tbl` minus the asteroid frames, and it is still
unnamed.
**Independent confirmation for [[stage-mission-tables]]**:
`name_hash(prefix + "Formation_Test.tbl")` → key `3463590559`, whose records are
exactly the three above — so the two frame-ratio exceptions really do live in the
file that document names.
🔴 **Dangling link, not mine to fix.** `idxd-container.md` references
`idxd-unnamed-keys.md` twice; **that file does not exist on `auto/re-isl-stream`**
— it belongs to the other agent's branch `auto/idxd-unnamed-keys`. Recorded here
so a merge review catches it.
❔ One control missed unexplained: **`EnumSquadron_Test.tbl`**, named by the
`_Test` stage record, matches **no pak key** under any of the sixteen prefixes.
n = 1, not chased.
🟡 Still open: what `Placement_*`/`RouteTest_*` are *for*; the two variants' file
names; why `S28_p1` has an asteroid volume with no definition.
---
## ✅✅ 2026-08-28 — every declared table name censused; `NamePlate` holds two vocabularies
Item (a), from last iteration's n=1 control (`EnumSquadron_Test.tbl` matched no
pak key). Censused properly: **every** declared value disc-wide — the eleven
`Enumerate*` fields plus `MessageSet`, `NamePlate`, `CollisionMeshes`,
`MotionEnumTable`, `LodEnumTable`, `AsteroidDefinition`, `MapMesh`, `MapPath`
against the 16 630 pak keys under the sixteen path prefixes.
```
distinct declared values 344
resolve to a pak object 313
do not 31
NamePlate values that are not file names 27
_Test tables that were never shipped 4
```
`313 + 27 + 4 = 344`. Every family is complete (`EnumerateUnit` 29/29,
`EnumerateFormation` 29/29, `EnumerateNullFrame` 29/29, `EnumerateAIParams`
23/23, `CollisionMeshes` 18/18, `AsteroidDefinition` 9/9, `MapMesh` 11/11,
`MapPath` 11/11, `LodEnumTable` 7/7, `MotionEnumTable` 7/7,
`EnumerateSubobjective` 17/17, `EnumerateWeapon` 3/3) except the four below.
🔑 **`NamePlate` carries two disjoint vocabularies, split by owning record.**
`StageResource.NamePlate` (174 uses) holds **file names**`nameplate_S01.tbl` …,
**24 distinct, 24/24 resolve**. `Generic.NamePlate` (522 uses) holds **bare plate
ids** — `NP_Cyclops`, `NP_Gargantua`, `ACROPOLIS`, `OTHERENEMY`, **27 distinct,
0/27 resolve**, none with a dot. Zero crossover. The 27 "missing" are a category
error, not missing data — the `Detail*` trap in a new field. [[unit-group-table]]
and [[stage-definition-table]] already record the `NP_*` plate vocabulary; what
neither says is that **the field name is shared between the plate and its table**.
🔑 **The four genuinely dangling names are all the template's own**
`EnumCharacter_Test.tbl`, `EnumLocalString_Test.tbl`, `EnumSquadron_Test.tbl`,
`StageMessageSet_test.tbl` — and in each of those four families the count is
`n1 / n` with the `_Test` entry the sole miss. The `_Test` stage record declares
four tables that were never shipped. **Every non-`_Test` declaration resolves.**
**A corpus ❔ closed on the way past.** [[stage-mission-tables]] recorded
`StageMessageSet_S02.tbl` as "not in `GP_MAIN_GAME_E.pak` under that name (❔,
probably localised elsewhere)". It **is** there:
`name_hash("message\" + "StageMessageSet_S02.tbl")` = key **705319170**, in all
six packs. The stage family uses `stage\`; the message tables use **`message\`**.
Right instinct, wrong mechanism — same pak, different prefix.
🟡 Not settled: why `S28_p1` has an asteroid volume with no definition; what
`Placement_*`/`RouteTest_*` are for; the two unnamed `_Test` variant objects.
---
## 🔴✅ 2026-08-28 — the 2D/`GP_READY_ROOM` blocker: retry already done, but two new structural facts
Item (a): retry the 🔴 blocker with the "wrong prefix" lesson from
`StageMessageSet_S02.tbl` (which resolves under `message\`, not `stage\`).
🔴 **The retry was already run, and the owning doc says so.**
[[archive-naming]]'s "13 name transformations" list already includes `2d\`,
`eng\`, `hud\` and `GP_MAIN_GAME_2D\` prefixes — **every one scored 0**, against
both the E2D pak and all 16 630 entries. Reading the doc end to end first would
have shown that in one minute. Recording it so the idea is not proposed a third
time.
**What the re-open did add — two measurements the earlier passes did not make.**
1. **The six 2D archives share ONE key set: 711 keys, identical in all six.**
Intersection = union = 711; 4 266 entries, 711 distinct keys. The entry
**names are language-independent** — the language lives in the pak *file*
name, not the entry path. That refutes any `<lang>\` prefix hypothesis
**structurally**, not merely by a zero score: if the names carried a language
directory, the six key sets would differ.
2. **The 2D key space is disjoint from the rest of the disc****0 of 711**
appear in any non-2D archive, while disc-wide **2 010 of 16 630** keys are
shared between archives. Sharing is common; this set simply never does it. No
named neighbour to borrow a convention from.
⚠️ **The six named `GP_READY_ROOM` entries are all fonts**`deu\GOTHICB.TTF`,
`eng\`, `esp\`, `fra\`, `ita\` likewise, and `jpn\HGRGE00.TTF`. The doc recorded
the count "6 named" without saying what they are: they are localisation fonts
named from elsewhere on the disc, **not a sample of the archive's own artwork
vocabulary**, so they cannot seed the dictionary attack the doc names as the last
lever. (Only **2 of 1 106** `GP_READY_ROOM` keys appear in another archive.)
🔴 **Still blocked, and now for a sharper reason**: the 2D names are
language-independent, unshared, and absent from disc and executable alike. The
dictionary-attack lever remains without a corpus.
🟡 Not settled: why `S28_p1` has an asteroid volume with no definition; the two
unnamed `_Test` variant objects; what `Placement_*`/`RouteTest_*` are for.
---
## 🔴✅ 2026-08-28 — S28 does have an asteroid definition: it borrows Stage 14's
Item (a). **Withdrawing my own repeated claim** that "`S28_p1` has an asteroid
volume with no definition". It came from a set-difference over **file names**,
which cannot see reuse. Joining the fields **per phase** settles it:
```
S28.Phase_1 MapMesh = S28_p1_AsteroidVolume_wp.col
AsteroidDefinition = S14_p2_asteroid.tbl
```
**S28 scatters Stage 14 phase 2's rock list inside its own volume.** Nine
definition tables ↔ nine `_AsteroidVolume_wp` meshes, with **one table shared by
two phases** — `S14_p2_asteroid.tbl``S14.Phase_2` *and* `S28.Phase_1`.
⚠️ **The earlier census's own arithmetic already contained the answer.** The four
values counted **×12** (2 per language pack) were `S01`, `S04`, `S05` — two phases
of *one* stage — and `S14_p2`, which is two phases of **two different stages**.
Reading ×12 as "two phases of the same stage" was the error; the count was right
and my interpretation of it was not.
⚠️ **`test_s8p1_asteroid.tbl` is not test-only either** — it is what
**`S02.Phase_1`** ships with. A `test_` name here is a leftover, not a dead table.
(That also softens the `_Test`-dangling pattern: a `test_`-named *table* can be
live even when `_Test`-named *declarations* dangle.)
✅ **The true residual is two phases, and it is the opposite pairing to what I
recorded**: `S01.Phase_3` has the volume and no definition (S01 keeps its volume
across all three phases but scatters rocks only in 12), and `S02.Phase_1` has a
definition and no volume (its `MapMesh` is the plain `mapmesh_box_500km`).
Corrected in `asteroid-fields.md` and `INDEX.md`.
🟡 Not settled: whether the quaternion is object→world or world→object; the two
unnamed `_Test` variant objects; what `Placement_*`/`RouteTest_*` are for.
---
## ✅✅ 2026-08-28 — every challenge mission is flown on a story mission's map
Item (a): apply the per-stage join to the other stage-resource fields. **Sharing
is exactly what the file-name view hides**, and here it is, complete.
Of 338 distinct `(field, value)` pairs across the 22 stage objects, **31 are used
by more than one stage.** The strongest signal is `CollisionMeshes`:
| stage | `CollisionMeshes` | `EnumerateSubobjective` | `BackGroundID` | `EnumerateWeapon` |
|---|---|---|---|---|
| S24 | `CollisionSet_S11.bin` | `SUBObjectiveSettings_S01` | `Anastasis` | `EnumWeapon_Test` |
| S25 | `CollisionSet_S07.bin` | `SUBObjectiveSettings_S07` | `Hargenteen` | `EnumWeapon_Test` |
| S26 | `CollisionSet_S03.bin` | `SUBObjectiveSettings_S06` | `Hargenteen` | `EnumWeapon_Test` |
| S27 | `CollisionSet_S03.bin` | `SUBObjectiveSettings_S03` | `Planet_Lebendorf` | `EnumWeapon_Test` |
| S28 | `CollisionSet_S14.bin` | `SUBObjectiveSettings_S01` | `Lebendorf` | **`EnumWeapon_EX5.tbl`** |
| S29 | `CollisionSet_S15.bin` | `SUBObjectiveSettings_S15` | `Earth` | `EnumWeapon_Test` |
**6 of 6 challenge missions reuse a story stage's collision set.** Control:
**S01S16 are perfectly diagonal**, `CollisionSet_SNN`
`SUBObjectiveSettings_SNN`, **16/16**.
🔑 **Map, objectives and sky are borrowed independently** — S24 flies S11's map
with S01's objectives; S26 flies S03's map with S06's objectives under a
`Hargenteen` sky belonging to neither; S28 flies S14's map with S01's objectives
under S01's sky. Three of six mix, so "S24 *is* S11" would be wrong.
🔑 **S28 is the only stage with its own weapon table** — 21 stages use
`EnumWeapon_Test.tbl` (×132), the tutorials `EnumWeapon_Tutorial.tbl` (×36), and
S28 alone `EnumWeapon_EX5.tbl` (×6). S28 was already the odd stage (the only
`rou_f004` flight); this is a second hook on it.
**A datapoint for [[result-screens]]'s open "what does `EX_` mean"**: `EX5` is
also a **variant suffix on real names**`UN_f001_TCAF_DeltaSaber_T_EX5` and
`Weapon_TCAF_Ship_AAGun_EX5`, 24 each. Not yet enough to fix the meaning.
⚠️ **Method note worth keeping**: an *unanchored* `EX[0-9]` search over binary
payloads returns `EX0``EX9` at 100180 hits each — pure noise from matching
inside compressed data. Only prefix-anchored names are real. (Sibling of "a
prefix match is not an exact match".)
Written up in `docs/re/structures/stage-numbering-and-player-craft.md`.
🟡 Not settled: what `EX` stands for; the two unnamed `_Test` variant objects;
what `Placement_*`/`RouteTest_*` are for.
---
## ✅🔴 2026-08-28 — `EnumWeapon_EX5.tbl` adds exactly one weapon, and a tutorial expectation is refuted
Item (a): diff S28's bespoke weapon table against the common one.
**The disc has exactly three weapon tables**, and each is a **single
`EnumWeapon` record whose *field names* are the weapons** — the `Enumerate`-style
declaration shape again:
```
EnumWeapon_Test.tbl 126 names
EnumWeapon_EX5.tbl 127 names = the same 126, same order, PLUS one
EnumWeapon_Tutorial.tbl 9 names = a strict subset of Test
union 127
```
**The whole of S28's bespoke table is one extra weapon:
`Weapon_TCAF_Ship_AAGun_EX5`.** Zero removals, identical order on the shared 126.
✅ **That explains one of [[weapon-struct-runtime]]'s five "never instantiated"
disc records** — the AA-gun variant is declared by `EnumWeapon_EX5.tbl` only, and
that table belongs to **S28 alone**, which the captured save was not playing.
🔴 **And it refutes that document's expectation.** It says "Running the tutorial
should instantiate the `_Ttrl` pair". It will not: `EnumWeapon_Tutorial.tbl`'s
nine names are `Weapon_NULL`, `Weapon_ADAN_Puppy_NoseGun`,
`Weapon_DSaber_P_wep_{01_Beam,02_Missile,58_Laser}` and
`Weapon_TCAF_DeltaSaber_{Beam,Cannon,Missile,ASMissile}` — **neither
`Weapon_TCAF_DeltaSaber_NoseGun_Ttrl` nor `_Laser_Ttrl` is among them.** Four of
the five uninstantiated records (`_NoseGun_Ttrl`, `_Laser_Ttrl`, `_NoseGun_None`,
`Weapon_ADAN_Attacker_S_GunTurret`) are **declared by no weapon table on the
disc at all** — a static answer to a question the runtime capture left open.
**`EX` is still not identified**, but it is narrowed hard: the entire content of
S28's own weapon table is a single `_EX5` AA-gun variant, and the only other
`_EX5` name on the disc is `UN_f001_TCAF_DeltaSaber_T_EX5`.
🟡 Not settled: what `EX` stands for; the two unnamed `_Test` variant objects;
what `Placement_*`/`RouteTest_*` are for.
---
## ✅❌ 2026-08-28 — `_EXn` is the n-th challenge stage; the `Extra0n` join is refuted
Item (a) continued. Reading the docs first paid: `unit-substructure-records`
already had `UN_e001_ADAN_Elan_EX4` and `arsenal-item-weapon-chain` already had
`_T_EX5_el`, so last pass's "the only two `_EX5` names on the disc" was too
narrow — withdrawn and replaced by a census.
✅ Every name **ending in** `_EX<digit>` across all 41 archives, taken from
parsed record names / field names / string values: **26 names, two digits only**
`_EX4` (7 units, 6 ADAN + 1 TCAF) and `_EX5` (4 TCAF units + 1 weapon), each
with a `UnitName_`/`WeaponCannonName_` twin.
**The stage join is exact.** `_EX4` appears only in tables **S27** declares
(`EnumUnit_S27.tbl`, `UnitGroup_S27.tbl`); `_EX5` only in **S28**'s
(`EnumUnit_S28.tbl`, `UnitGroup_S28.tbl`, `EnumWeapon_EX5.tbl`); **the other 20
stages have neither.** The challenge missions are S24S29 in order, so S27 is
challenge **#4** and S28 challenge **#5**: `_EXn` = the n-th challenge
("EXtra") mission's bespoke variants. S28 declares both schemes at once —
`EnumUnit_S28.tbl` by stage number, `EnumWeapon_EX5.tbl` by challenge index.
**Refuted: `_EXn` is not the `Extra0n` numbering.** There are four `Extra`
slots but `_EX5` exists, and under that reading `Extra04` = S29, which has no
`_EX` assets. (The six leaderboard modes do map onto the six challenge stages;
`_EXn` counts stages.)
🟡 Makes available, not adopted: `result-screens`' open "what does `EX_` mean" —
"the challenge-mission debriefing" now fits its own evidence that the `EX_` twin
drops `overview_rank`/`overview_medals`. Consistency, not proof; the selection
has not been shown.
🟡 Not settled: the four weapons declared by no weapon table — three of them
(`_NoseGun_None`, `_NoseGun_Ttrl`, `Weapon_ADAN_Attacker_S_GunTurret`) are
already in `unit-substructure-records`' "26 weapons no turret mounts", so they
are unmounted **and** undeclared; `_Laser_Ttrl` is not on that list and is the
one still to place.
---
## ✅ 2026-08-28 — every weapon placed; and a fourth, undeclared weapon table
Item (a): place `Weapon_TCAF_DeltaSaber_Laser_Ttrl`.
✅ **The declared × mounted partition of all 131 `Weapon.ID` records has no
unexplained cell**: declared+mounted **105**, declared+unmounted **22**,
undeclared+mounted **0**, undeclared+unmounted **4**. Both marginals re-derive
`unit-substructure-records`' own numbers independently (mounted 105; 22+4 = its
"26 weapons no turret mounts").
🔑 **Nothing is mounted without being declared (0 of 131)** — declaration is the
superset. So the four orphans are reachable by neither data route; each appears
only as a `Weapon` record's `ID` value, ×6 (one per pak copy), and nowhere else.
Control `Weapon_TCAF_DeltaSaber_Beam`: ×18 as an `EnumWeapon` field, ×228 as a
`Turret_NNN.WeaponID`. That places `_Laser_Ttrl` and unifies all four as orphan
datasheet rows.
⚠️ **A route with no power, recorded so it is not re-run:** the image holds 0
exact strings for the four — and also 0 for the live `Weapon_TCAF_DeltaSaber_Beam`
and 0 for `Weapon_NULL`. Only three `Weapon_`-prefixed strings exist at all, and
0 contain `Ttrl`. The executable names no weapon; the negative does not
discriminate.
**New: a fourth `EnumWeapon` record** — pak key **305595319**, resolved as
**`EnumWeapon_TestS01.tbl`**, three fields (`Weapon_TestS01_{Gun,Laser,Missile}`).
Sweeping records rather than declared tables gives **130** names, not 127;
`130 3 = 127` restores the earlier union. It dangles at both ends: **no stage
declares it**, and its three weapons are the only declared names with **no
`Weapon.ID` record**.
🟡 Not settled: why those four were kept on the disc; whether the debriefing code
selects the `EX_` key list for challenge missions; which challenge stage is which
leaderboard mode.
---
## ✅❌ 2026-08-28 — the weapon law does not generalise to units; the exception is all S14
Item (b): apply the declared × deployed 2×2 to units.
Three vocabularies had to be separated first: **declared** = field names of the
**31** `EnumUnit` records (118); **deployed** = slot 0 of each `UnitGroup` member
tuple (122); **datasheet** = `Generic` records carrying `HP` (**114**, which
re-derives `unit-datasheet-static`'s own number as a control). Declared and
deployed share the `UN_*` vocabulary (109 overlap); the datasheet's `ID`s are a
third naming, disjoint from both — the "two vocabularies" trap, now three.
| | deployed | not deployed |
|---|---:|---:|
| declared | 109 | 9 |
| **not declared** | **13** | 1 |
**Refuted as a general law:** the cell that is empty for weapons
(0 mounted-but-undeclared of 131) holds **13** for units.
**The 13 are one family** — all `UN_S14_p{1,2}_Asteroid_cmesh_*`. The other
population settles it: S01 8/8, S04 10/10, S05 5/5, S08 6/6, S13 4/4 declared —
**33 of 33 — while S14 declares 0 of 13**, and **non-asteroid
deployed-but-undeclared = 0**. Narrower law: every deployed unit is declared,
*except* S14's asteroid collision meshes.
🟡 Not settled: **why** S14 differs. It is the stage whose asteroid table S28
borrows, but nothing measured links the borrow to the missing declarations — a
coincidence recorded as a coincidence.
---
## ✅ 2026-08-28 — the per-stage view: `EnumUnit_S<NN>` is the stage's LOAD MANIFEST
Item (a). Joining each stage's own `EnumerateUnit` table to its own
`EnumerateSquadron` table is far sharper than the global partition:
**`declared == deployed` exactly in 20 of 22 stages**; S16 declares 4 it never
deploys; S14 deploys 13 it never declares. Every per-stage
deployed-but-undeclared count is 0 except S14's 13 — which is the entire
disc-wide total, so the exception is one stage, not a scattering.
🔑 **S16 explains the table's role.** It declares 6 and deploys 2; two of the six
are `UN_e901_ADAN_Boss` and `UN_e910_core_ADAN_GeneratorCore` — exactly the units
`stage16-boss` shows are loaded by their own chain (`sub_82315AE8`), not by the
squadron roster. So `EnumUnit_S<NN>` is the stage's **load manifest**: a unit
that code spawns is declared but never deployed.
**All 9 declared-but-never-deployed units are now named and accounted for**:
`UN_e901_ADAN_Boss`, `UN_e910_core_ADAN_GeneratorCore` (S16's code-spawned boss
pair), `Test_ADAN_PrometheusDriver_InsideP2_01`, and six `Ship_Test*`
placeholders (`Ship_Test`, `_Test2`, `_Test_2`, `_TestS01`, `_TestS01_2`,
`_TestS01_3`).
🟡 **S14 is still unexplained, and it is not a missing table.** `EnumUnit_S14.tbl`
exists with six names, one of which is `UN_mn040_Asteroid_Big` — S14 declares an
asteroid but not its 13 `Asteroid_cmesh` entities, which **refutes** "asteroids
are exempt from the manifest". Whatever the cause is, it is specific to the
collision-mesh entities.
---
## ✅ 2026-08-28 — S14's 13 are dangling deployments; the unit chain is absent for them
Item (a). Two measurements settle the last open cell of the unit partition.
**The deployment side is identical.** S14's asteroid-cmesh squadron records match
a declaring stage's field for field — same squadron ids (`GNN901`…) and the same
`Count 1 / SideID Neutral / AIID AI_Structure / FormationID Formation_1_only /
DisableInterval No`. Nothing about how S14 deploys them differs.
**The definition side differs completely.** Per stage, deployed / declared /
`Generic`-with-`HP` / any `Generic`: S01 8/8/8/8, S04 10/10/10/10, S05 5/5/5/5,
S08 6/6/6/6, S13 4/4/4/4 — **33/33/33 for the five** — and **S14 p1 9/0/0/0,
p2 4/0/0/0**.
🔑 The 13 are not a manifest omission: they have **no unit definition anywhere on
the disc**, not even an un-`HP`'d `Generic` row. `UnitGroup_S14.tbl` names
entities that do not exist — **13 dangling references**. That rescues the law
(nothing is deployed-without-being-loaded in a working sense) and explains why
the gap is exactly the cmesh class and nothing else in S14.
🟡 **NEEDS-HUMAN to go further:** either those collision meshes fail to appear in
Stage 14 (a shipped bug) or another route supplies them. Flying S14 and checking
whether the large asteroids collide distinguishes the two; no static route can.
⚠️ Recorded so the populations are not conflated: `asteroid-fields`'
`AsteroidGroup_00N` tables scatter `mob_n0NN` models; these are
`UN_<stage>_Asteroid_cmesh_*` squadron entities. S28.Phase_1 borrows
`S14_p2_asteroid.tbl` but deploys no cmesh of its own, so the borrow does not
carry the dangling references.
---
## ✅🟡 2026-08-28 — value classes resolve by KIND; the 344-name residual drops to 4
Item (b): generalise the "does the referent exist?" sweep.
**Three kinds, not one.** `AIID` 31/31 and `FormationID` 126/126 resolve as
**record names**; `CollisionMeshes` 18/18 and `EnumerateSubobjective` 17/17 as
**pak files**; `NamePlate` splits **27 records + 24 files + 1 empty = 52**;
`BackGroundID` **0 of 10** as either. (`FormationID` re-derives
`unit-group-table`'s own "every `FormationID` resolves" as the control.)
🔑 **The 27 `NamePlate` "non-file plate ids" are record names**`ACROPOLIS`
×114, `NP_ArrowHead` ×84, `NP_ASFrigate` ×78. So `stage-definition-table`'s
census improves: 313 resolve as files **+ 27 as records = 340 of 344**, leaving
the **4 never-shipped `_Test` tables** as the entire residual. The old reading was
a *kind* error, not a missing file.
🟡 **`BackGroundID` is a genuinely unresolved third kind** — the ten sky names,
used 636 times each, are neither records nor pak keys under 10 prefixes × 9
suffix forms.
⚠️ **Powerless route, recorded so it is not re-run:** the image holds 0 exact
strings for all ten backgrounds — and also 0 for `Formation_4_Bird`,
`AI_Structure`, `NP_ArrowHead`, `ACROPOLIS`, `CollisionSet_S03.bin`, all of which
*do* resolve in data. The executable names no data value, so the negative
discriminates nothing.
---
## ✅❌ 2026-08-28 — `BackGroundID` is an identity; the sibling field names the asset
Item (a). The one value class that resolved to neither record nor file resolves
to neither **because it is neither**. `BackGroundID` lives on `StageResource`
(174 records) directly beside **`BackGroundPackage`**, which holds the real asset
`game:\hidden\Resource3D\BG_<something>.xpr`.
⚠️ **My earlier suffix sweep had a bug**: it tried `BG_` and `.xpr` as
alternatives, never as a pair, so `BG_Acheron.xpr` was never in the search space.
**A one-transform-at-a-time sweep cannot find a two-transform name.**
**"the package is `BG_<id>.xpr`" is refuted** — true in only **12 of 24**
records. `Hargenteen` spans six packages (`_near`/`_far`/`_dead`, plus
`BG_Acheron.xpr` and `BG_Stg26.xpr`); `BG_Acheron.xpr` serves two different ids.
**Many-to-many both ways**, ~18 packages across 10 ids.
🔑 The id is a **logical place** (Acheron, Earth, Hargenteen, Lebendorf) and the
package is the mesh actually loaded, chosen per stage from that place's variants.
`PD` = the Prometheus Driver (`BG_P_Driver.xpr`, S16). The challenge stages that
re-dress a story sky carry bespoke `BG_Stg24/26/27.xpr` while keeping the story
id — the same borrow-and-re-dress pattern already seen for maps and objectives.
🟡 Not settled: `Original` carries **no** `BackGroundPackage` — whether that is
"no sky" or an engine default is unknown.
---
## ✅❌ 2026-08-28 — the rest of `StageResource`: two more value kinds, one convention refuted
Item (a).
**`<X>ID` + `<X>Package` is not a convention.** `StageResource` has exactly one
`*ID` field (`BackGroundID`) and one pairing stem; `StageResourcePackage` has no
`StageResourceID`, `WingmanIconID_0…3` have no package. **n = 1 is not a rule**
last pass's generalisation withdrawn.
**Fourth kind — the `game:\` external path.** `BackGroundPackage` (18) and
`StageResourcePackage` (23) resolve as neither record nor pak key because they
are ISO paths: mapping `game:\`→extract root and `\``/`, **41 of 41 exist on the
extracted disc**.
**Fifth form — `game:\hidden\<archive>.pak+<entry>`.** `LodEnumTable`,
`MotionEnumTable`, `TBL_3D` name an archive *and* an entry
(`…\DefTables.pak+EnumLODSet_S24.tbl`); hashing the right half against **that
archive's** keys resolves **20 of 21**, the failure being `sound.pak+` with an
empty entry name.
🔑 Some entry names carry an explicit `<lang>\` prefix (`deu\GP_HANGAR_ARSENAL_3D.tbl`),
each present in 0 other archives — the language-prefix form is real.
⚠️ It does **not** unblock 2D: `archive-naming` refuted `<lang>\` there
structurally (six identical 711-key TOCs). Both stand.
**The 4 never-shipped `_Test` tables are named**: `EnumCharacter_Test.tbl`,
`EnumLocalString_Test.tbl`, `EnumSquadron_Test.tbl`, `StageMessageSet_test.tbl`.
**`WingmanIconID_1…3` = `PGHUD_WING2/3/4`**, HUD names from the `PG*` roster;
`WingmanIconID_0` is never valued.
---
## ❌ 2026-08-28 — the `archive.pak+entry` dictionary is 20 keys and misses the blocker entirely
Item (a). A path naming its own archive is a free name→key fact, so I swept them
disc-wide and aimed the result at the 🔴 2D / `GP_READY_ROOM` blocker.
**7 fields** carry a `game:` value (`BackGroundPackage` 18, `StageResourcePackage`
23, `TBL_3D` 6, `LodEnumTable` 7, `MotionEnumTable` 7, one `PATH`, one anon) —
**63 distinct paths**: 42 file-form (**41 on the ISO**, the miss being the bare
directory `game:\hidden\Resource3D\`) and 21 archive+entry (**20 of 20** resolve;
the 21st is `sound.pak+`, empty entry).
**Blocker test: 0 of 1 817.** The six 2D paks plus `GP_READY_ROOM` hold 1 817
keys; none is named by a `+` path. Total dictionary reach: **20 of 16 630 keys
(0.12 %)**, all inside `DefTables` or `GP_HANGAR_ARSENAL`.
The route is **exhausted, not unlucky** — these paths live in seven fields, none
of which ever addresses a 2D or ready-room asset. The blocker keeps its earlier
structural reason.
Full dictionary: 6 × `<lang>\GP_HANGAR_ARSENAL_3D.tbl` in `GP_HANGAR_ARSENAL.pak`,
and `EnumGameModel_*` + `EnumLODSet_*` for `S24, S26, S27, S28, S29, Tutorial,
test` in `DefTables.pak`.
❔ Unexplained: both `DefTables` families are seven names with **S25 absent** and
no story stage at all.
---
## ✅❌ 2026-08-28 — `EnumLODSet_test.tbl` is the story campaign's live LOD table
Item (a): why is S25 absent from the `DefTables` `+`-path families?
**The premise is withdrawn — it was an artifact of listing distinct values.**
S25 is not missing anything: it shares `EnumLODSet_test.tbl` with the sixteen
story stages, and `test` *is* in the list. **Counting distinct values hides who
uses them — join per user.**
**The per-stage join:** `EnumLODSet_test.tbl` / `EnumGameModel_test.tbl` serve
**17 stages** — all of S01S16 plus S25; the five challenge stages S24, S26, S27,
S28, S29 have bespoke tables; `Hangar.xpr` has no pair. 17 + 5 + 1 = 23 objects
carrying a `StageResource`.
🔑 **The `test`-named table is the live LOD/model table for the whole story
campaign** — the strongest case yet of "a `test_`-named table can be live",
serving 17 of 22 missions.
🟡 **Eight shipped tables nothing references.** The family covers only 11 tags
(S01, S02, S16, S24, S26S29, Tutorial, test, Test) — S03S15, S17S23 and S25
have none at all. Seven tags are referenced; **S01, S02, S16 and `Test` are
present with zero references disc-wide = 8 orphan tables**. "Early per-stage LOD
sets, abandoned for the shared `test` table" is a reading, not a measurement.
⚠️ Scope: `EnumLODSet`/`EnumGameModel` is overwhelmingly an **equipment** family —
116 of 130 referenced values are bare `Equip_EnumGameModel_wep_NN.tbl` names from
`PlayerLOD`/`WingmanLOD`/`PlayerMotion`/`WingmanMotion` (58 distinct each); only
the 14 stage-tagged values use the `+` form.
---
## ✅❌ 2026-08-28 — challenge LOD tables are `_test` + set dressing; orphan count corrected to 6
Item (a).
**The five bespoke challenge tables are exact supersets of `_test`** — 10 of 10
(5 stages × 2 families) with **zero removals**; extras are +2 to +9 and name the
stage's own scenery (`stg24_01…04`, `stg26_01…03`, `stg27_01…02`, `stg29_01…02`)
plus `rot_n001_break`.
🔑 **S28's nine extras independently confirm the player-craft result**:
`LOD_Frame_rou_f004`, four `rou_f004_mnv*`/`turn180` poses and three `eff_j004_*`
effects — the LOD table for the one stage that flies `rou_f004`.
⚠️ This does **not** revive "`rot_n001` is on the disc": that refutation concerned
the **bare** name (still 0 exact); only the `_break` form occurs, as recorded.
**Self-correction — 6 orphan tables, not 8.** `name_hash` is case-**insensitive**,
so `_test` and `_Test` are the *same pak entry*
(`EnumLODSet` → 3485720498, `EnumGameModel` → 4020329537 for both spellings). The
family has **10 distinct keys**, and the unreferenced set is **S01, S02, S16 × 2
= 6**.
🟡 **The six are not stale copies.** They are far smaller (120/129/178 vs 676
`EnumLODSet` fields) yet each holds **45 entries `_test` lacks**
`LOD_Effect_eff_e0033/_e0058/_e0059/_e0060/_n0071`, and S16 adds five
`rou_e106_*` boss parts (cf. `stage16-boss`). The live table is **not** a
superset of the abandoned ones. `Tutorial` has the same shape: 66 fields, 3
unique.
---
## ✅🟡 2026-08-28 — the challenge LOD extras are meshes in that stage's own `.xpr`
Item (a).
**Perfect diagonal, 11 of 11.** Each `stg24_01…04`, `stg26_01…03`,
`stg27_01…02`, `stg29_01…02` appears in `Stage_S<NN>.xpr` and **no other** stage
package. A challenge stage's bespoke LOD/model table exists to declare the
scenery meshes packaged with that stage.
🔑 **`stg24_04` is a composite** — 22 occurrences resolve as **1 bare + 1 `_all` +
20 `_child`**; every other `stg*` name occurs exactly once.
🔑 **`rou_f004` is not in `Stage_S28.xpr` — it is in `DeltaSaber_A.xpr`.** S28's
table declares the craft but the mesh ships in the player-craft package,
confirming from the ISO side that `_A` is the `f004` variant. `rot_n001_break`
resolves in `Tutorial.xpr` and `Stage_S28.xpr` — exactly the tables listing it.
🟡 **The orphans' unique effects only partly resolve**: `eff_e0033` is in
`Base.xpr`, but **`eff_e0058` and `eff_n0071` are in no `.xpr` on the disc** — the
same "declared, never shipped" shape as S14's asteroid meshes, much smaller.
⚠️ Method caveats recorded: these are **byte searches over `.xpr`**, not parsed
resource enumerations — they show presence only, and are acceptable here because
the names are long, distinctive, and matched to the full trailing token. Also
**`grep -c` counts lines, not occurrences**; the counts come from
`grep -o | wc -l` after that was caught.
---
## ❌✅ 2026-08-28 — `_all`/`_child` is not a convention; `eff_n0071` is a live explosion
Item (a).
**Refuted, and it was my own suggestion.** Across all **166 `.xpr`** packages
the `_child` suffix occurs in **one file** (`Stage_S24.xpr`) with **one stem**
(`stg24_04`); across the **105 394 distinct parsed pak names**, `_all`/`_child`
occur **0 times**. The pair is mesh-internal to a single model and never reaches
the data tables. **n = 1 is not a convention** — and the composite mechanism the
corpus actually owns is `xbg7-mesh`'s grouped index/vertex pools.
**The orphan tables' five unique effects split three ways**: `eff_n0071` is
**live** — a `Generic.GameResourceID` in `DefTables.pak` and an `Explosion`
record's **`ExplosionFxModel`** in all six `GP_MAIN_GAME_*` archives (×6 = one
user). `eff_e0033` has a mesh in `Base.xpr` but no data reference. `_e0058`,
`_e0059`, `_e0060` have **neither**.
So one "abandoned" entry isn't abandoned: the orphan LOD tables list an effect
the shipped `_test` table omits while the game still binds it elsewhere.
⚠️ Control recorded: effect names bind through a family of fields —
`Effect_Paralyze` (2 874), `HitFxModel` (738), `ShellModel` (516),
`JetFxModel_00N`, `MuzzleFlashFxModel_Loop` — so a value resolving through none
of them is genuinely unreferenced, not missed by a narrow search.
---
## ✅ 2026-08-28 — every bound effect vs every declared effect; a Shift-JIS placeholder
Item (a). The `Explosion` field list was already in
`unit-substructure-records.md`; the untested part was whether the effect names
**resolve**. Declared × used over **34 binder fields**:
| | declared | not declared |
|---|---|---|
| **used** | **172** | **58** |
| **not used** | **318** | — |
🔑 **The 58-cell is almost one field**: 53 are bound by `SilhouetteModel` and
are all `rou_f###_wep*` arsenal silhouettes ([[arsenal-item-weapon-chain]]) —
undeclared because they are **the wrong KIND**, each resolving as its own
package (`rou_f001_wep_01.xpr`), never as a LOD entry. Four effects are
genuinely undeclared: `eff_e0044`, `eff_f0002`, `eff_f0002_barn`, `eff_h308`.
🔑 **`文字列` is a dev placeholder.** One "effect name" is Shift-JIS
`95 B6 8E 9A 97 F1` = *"character string"*, bound by `Effect_Explosion` /
`Effect_Flare` 72× = 12 users. ⚠️ **Reader defect**: `unitgroup.py` returns it
as Latin-1 mojibake — IDXD string fields can be Shift-JIS.
**`_hangar` IS a convention — 59 of 166 `.xpr`, 59/59 with a bare twin.**
The direct contrast to yesterday's `_all`/`_child` refutation (1 of 166). Suffix
conventions here are worth testing precisely because they are not all real.
⚠️ Controls reproduced: `eff_n0071` declared + used 6× (= one user);
`eff_e0033` declared + used 0×. The 318-cell is expected — the LOD family is
overwhelmingly *equipment*, which no unit datasheet binds.
---
## ✅ 2026-08-28 — the disc ships the unit datasheet's own SCHEMA
Item (a), and it **corrects the previous entry**: `文字列` is not a developer's
placeholder. It is one member of a six-word Shift-JIS *type* vocabulary, and the
records holding it are a machine-readable schema. Written up in
[unit-datasheet-schema](structures/unit-datasheet-schema.md).
The whole non-ASCII population is **6 distinct values of 99 328** — 0 of 3 496
record names and 0 of 12 173 field names — all Shift-JIS type words: `文字列`
(string), `NS_"文字列"`, `整数`/`整数値` (integer), `浮動小数値` (float),
`浮動小数値[0〜1]` (float in [0,1]). **990 type-valued fields.** The reader
defect is real but bounded to these six strings; `name_hash` re-encodes Latin-1
byte-for-byte, so hashing was never affected.
They sit in **15 records × 6 `GP_MAIN_GAME_*` paks = one user**, named for the
substructure family, six of them carrying a literal wildcard: `Turret_???`,
`Hatch_???`, `Bridge_???`, `Thruster_???`, `ShieldGenerator_???`,
`Versatile_???`, `NS_*`. 🔑 **`???` is the numeric-suffix wildcard** at record
*and* field level. Where a field's type is an enum the schema holds an **example
value** instead: `Yes` for the five booleans, `Vessel` for `Generic.Type`,
`Ship_` for the `ID` prefix. Every `ResistanceTo*` and `Color_*` is declared
`FLOAT[0..1]`.
⚠️ Control: the `_???` records exist ONLY as schema (6 of 6 typed); the eight
real names are typed in 6 instances and untyped in the rest (`Generic` 6 of
3 651, the others 6 of 684).
New artefact: `tools/re-capture/datasheet_schema.py``docs/re/data/datasheet-schema.txt`.
---
## ✅❌ 2026-08-28 — the unit family is the only schema; and an `.xpr` search can't prove absence
Item (a).
**Only one schema exists.** Two exhaustive probes agree: the six Shift-JIS
type words occur in the six `GP_MAIN_GAME_*` paks and nowhere else, and a
disc-wide sweep for wildcard-shaped record names (`???`, `*`, `###`, `NNN`,
`<…>`) returns **7 distinct names — exactly the seven already in the schema**,
each ×6. The weapon datasheet, arsenal item and `StageResource` ship **none**.
Wildcard *field* names are likewise confined to the schema records.
**No untyped gaps: 30 of 30 slots hold examples.** 28 `Yes` booleans plus
`Generic.ID = Ship_` and `Generic.Type = Vessel`. The six destructible part
types each carry the same four-boolean core (`IsDestructible`, `IsInvolved`,
`IsRadarVisible`, `IsShielded`); `Turret_???` adds `IsAuto`; `NS_*` has
`AttenuationAlpha`/`AttenuationVolume`. **7 records fully typed + 8 with
examples = 15.**
❌ **The `.xpr` byte search has no power for effects — the control refuted the
instrument.** `eff_f0002`/`eff_f0002_barn` do appear in `Base.xpr`, but
`eff_n0071`, which is *measured live*, **also returns nothing**. So no
conclusion follows for `eff_e0044`/`eff_h308`, and the earlier "no mesh" remarks
about `eff_e0058`/`_e0059`/`_e0060` are weaker than written. ❔ **Open: how an
effect mesh is actually reached**, since `eff_n0071` is not a plain name string
in any of the 166 packages.
---
## 🔑 2026-08-28 — effects split into two families by digit-width; `ptc_pack.xpr` holds one
Item (a). The corpus already held the pointer — [[xbg7-mesh]] names
**`ptc_pack.xpr`**, a 20 MB shared particle package listing **532 distinct
`eff_*` resources** (527 three-digit, 3 four-digit, 2 unnumbered).
Joined against the 137 effect names the datasheets bind:
| bound name | in `ptc_pack` | not |
|---|---|---|
| **3-digit** | **71** | 39 |
| **4-digit** | **0** | **27** |
🔑 **Zero partials on the four-digit cell**, and that series is a closed
three-letter set — `e` 10, `f` 12, `n` 5. Since `eff_e0033` was found in
`Base.xpr`, the reading is **two effect families**: a shared three-digit library
in `ptc_pack.xpr`, and a four-digit series living in per-model/base packages.
⚠️ Unlike last iteration's refuted search, **this instrument passes its own
control** — the same kind of byte search demonstrably reads 532 names out of
this file, so a zero *within it* carries weight. The 39 three-digit misses
cluster on `j` (22) and `t` (14); `ptc_pack` holds just one `j` name, so
`eff_j###` is a third grouping elsewhere.
🟡 **Not closed**: 66 of 137 bound effects still have no located home,
`eff_n0071` included. ❔ Next: the `eff_j###` family and the four-digit series —
not another disc-wide grep.
---
## ✅❌ 2026-08-28 — the effect→package map (103 of 137), and a substring correction
Item (a). Enumerating `eff_*` **per `.xpr`** across all 166 packages and matching
bound names **exactly** locates **103 of the 137**, up from 71. Only 36 packages
carry an effect name; two dominate: **`Base.xpr` 53** and **`ptc_pack.xpr` 46**
(then `Stage_S28.xpr` 2 and five `rou_f001_wep_NN.xpr` at 1 each).
🔑 **Two shared effect libraries, not one** — and `ptc_pack.xpr` is the only
`*_pack` bundle on the disc. By digit-width: **3-digit 80 of 110, 4-digit 23 of
27**, so the previous iteration's split survives and sharpens — the four-digit
series lives outside `ptc_pack`, in `Base.xpr`.
**Correction to the previous entry**: `eff_f0002` and `eff_f0002_barn` do
**not** ship. `Base.xpr` contains **`eff_f0002_barnhaze`**, and both substring
greps matched inside it. The corpus's paid-for prefix lesson arriving from the
other direction — this time the *bound* name was a prefix of the *stored* one.
🟡 **34 remain**, dominated by **`eff_l###` (17)**, then `h` 4, `s` 4, `j` 2,
`m` 2, `t` 1, and four four-digit names including `eff_n0071`. ⚠️ Scope note: the
`j` 22 / `t` 14 clustering reported last time was the residual against
`ptc_pack` alone; against all packages `l` is what is left. **Say which
population a residual is measured against.**
New artefact: `tools/re-capture/effect_homes.py``docs/re/data/effect-homes.txt`
(lists all 34 by name).
---
## ✅ 2026-08-28 — `_e`/`_f` on an effect name is the binding unit's FACTION
Item (a). Chasing the 17 unlocated `eff_l###` showed they come in **`_e`/`_f`
pairs**. Partitioning every `eff_<letter><digits>_<e|f>` binding by the ID letter
of the owning unit (one `GP_MAIN_GAME_*` pak = one user):
| | effect `_e` | effect `_f` |
|---|---|---|
| **`UN_e###`** | **33** | 0 |
| **`UN_f###`** | 0 | **61** |
**94 of 94, both off-diagonal cells empty.** The control reads the factions off
the IDs: `UN_e### → ADAN` (42 objects), `UN_f### → TCAF` (26), `UN_n### → TTRL`
(2, binding neither). 🔑 **The same effect is authored twice, once per faction**
which is exactly why `eff_l###` arrives in pairs.
🟡 **What the 34 are**: `Generic` binds 32 of them, `Explosion` 19, `Shell` 9,
`Level_0`/`Weapon` 2 each; binder fields rank **`LowerHPFxModel` 252**,
`HitFxModel` 144, then `JetFxModel_00N`/`AfterBurnerFxModel_00N`. So the residual
is largely **per-faction battle-damage and hit visuals**. None is a record name;
one is a field name.
⚠️ **Still unlocated as assets** — knowing the family and its naming law does not
say where the geometry lives. The `.xpr` route is exhausted and the parsed pak
payloads hold references, not meshes. ❔ Open: which package (if any) ships them.
---
## ✅ 2026-08-28 — the faction law generalises: 564 of 564, four fields, six paks
Item (a). The previous entry measured `_e`/`_f` on one pak through the effect
binders only. Widening to **every string field** of every unit object in **all
six** `GP_MAIN_GAME_*` paks:
| | value `_e` | value `_f` |
|---|---|---|
| **`UN_e###`** | **198** | 0 |
| **`UN_f###`** | 0 | **366** |
**564 of 564; the mismatch residual is empty.** It holds separately at 100% in
four fields — `LowerHPFxModel` 252, **`ShieldHitEffectName` 210**,
**`ShieldRecoverEffectName` 84**, `ExplosionFxModel` 18. The two shield fields
are new to the law, so it reaches past the `*FxModel` family that suggested it.
⚠️ **Scope stated exactly**: this is a law about *effects*, not assets generally.
Every `_e`/`_f`-suffixed value a unit binds lives in those four effect fields —
no model, motion or SE value carries the suffix, so the pairing is **not** shown
for those kinds; there was nothing to test.
**`UN_n###` (TTRL) binds no `_e`/`_f` in any of the six paks** — 12 objects
(= 2 users), confirming the single-pak observation over the whole population.
⚠️ **ID-grammar correction + reconciliation**: the earlier 42+26+2 = 70 used a
regex requiring `UN_<l>###_<FACTION>_`; the looser form finds **71**. The extra
is **`UN_e910_core_ADAN_GeneratorCore`**, which inserts a sub-kind before the
faction tag. Grammar is `UN_<l>###_[<subkind>_]<FACTION>_<name>`.
---
## ❌✅ 2026-08-28 — `EF_IDX_` prefix: `ptc_pack` has 727 names, and the map reaches 128 of 137
Item (a). Censusing `ptc_pack`'s naming vocabulary found a third prefix trap that
had been corrupting a carried number.
**`ptc_pack` holds 727 distinct names, not 532.** **268 do not start with
`eff_`** — they start with **`EF_IDX_`** (e.g. `EF_IDX_eff_d001_f`). A regex
anchored at `eff_` truncates that and merges distinct names. ⚠️ Third variant of
the trap: the stored name longer (`rot_n001_break`), the bound name a prefix
(`eff_f0002`), and now **a prefix the pattern cannot see because its anchor sits
mid-name**.
**Looking each bound name up bare AND under `EF_IDX_` resolves 25 of the 34.**
The map is now **128 of 137**, residual **9**: `eff_e0044`, `eff_f0002`,
`eff_f0002_barn`, `eff_h308`, `eff_j002_e01`, `eff_j002_e02`, `eff_m010_wep_85`,
`eff_m011_wep_85`, `eff_n0071`.
**Withdraws my own previous correction**: I had recorded `Base.xpr` (53) as
holding more bound effects than `ptc_pack` (46). With prefixed keys `ptc_pack`
holds **71** — it *is* the larger library; 46 was an undercount from the same
pattern.
**Suffix vocabulary** (106 tokens over 727 names): `IDX` 223, `_f` 137, `_e`
119, `_root` 87, `_col` 54, `_mdl` 45, `_break` 43, `_ring` 38, `_ALL` 17.
**`_root` is strictly terminal, 87 of 87, never mid-name**; compounds place it
outermost (`_e_root` 19, `_f_root` 18, `_break_root` 13, bare 30). Bare-parent
test: **`_ALL` 17/17**, `_root` 64/87, `_break` 15/30, and 🔑 **`_e`/`_f` 0 of
135** — asset-side confirmation that effects are authored per faction with no
faction-neutral original.
---
## ✅ 2026-08-28 — the last 9 effects are genuinely unshipped; `wep_85` explains two
Item (a). `EF_IDX_` proved prefixes exist, so the residual got the same treatment
across every carrier. Censusing prefixes over all 36 effect-carrying `.xpr` gives
**seven**: `EF_IDX_` 223 names, `mdl_` 45, `EF_IDX_mdl_` 45, `VolumeLine_` 10,
`GN_` 10, `GN__` 6, plus bare.
**Testing all nine against all seven: 0 of 9 resolve.** ⚠️ This zero has force
where the earlier disc-wide zero did not — the control shows each prefix
genuinely carries names the same search reads (223/45/45/10/10/6), so the
instrument works on the population being asked about. The nine are **bound by a
datasheet field and shipped in no package**.
🔑 **Two of them belong to `wep_85`, a declared-but-unshipped weapon.**
`Weapon_DSaber_P_wep_85_Beam` is real in the data (24 uses, with `Shell_`,
`WeaponCannonName_`, `WeaponShellName_` siblings, and `GameModel_eff_m010_wep_85`
/ `_m011_` declaring the effects) — but **the weapon packages stop at 84**: 59
`rou_f001_wep_NN.xpr` files, 0084 with gaps, no 85. One cut asset accounts for
two of the nine. ⚠️ No contradiction with "every weapon is placed — 131": that
partition is declared × *mounted*, not whether a package ships.
🟡 **Seven remain unaccounted**: `eff_e0044`, `eff_f0002`, `eff_f0002_barn`,
`eff_h308`, `eff_j002_e01`, `eff_j002_e02`, `eff_n0071`. ❔ The `.xpr` route is
exhausted under every prefix the disc uses; a different container or a runtime
generator is the remaining possibility.
---
## ✅ 2026-08-28 — `wep_85` is the ONLY declared-but-unshipped asset; the gaps are unused numbers
Item (a). The obvious follow-up to `wep_85`: the weapon packages number **0084
with 26 holes** — are those holes cut weapons too? Partitioning every number
0085 by *package present* × *named in the parsed pak data*:
| | named in data | not named |
|---|---|---|
| **package present** | **59** | **0** |
| **package absent** | **1** | 26 |
`59 + 0 + 1 + 26 = 86`. 🔑 **The 26 gaps are unused numbers — nothing names
them.** The single occupied cell is `wep_85` (named 16×, no package): **an
isolated cut, not a pattern.** ⚠️ The other zero matters too — **0 shipped
packages are never named**, so the two sets agree in both directions but one.
**Craft models: 16 of 16, both residuals empty.** Same test on `rou_f###`: 16
in an `.xpr` and named, **0** named-but-unshipped, **0** shipped-but-unnamed. The
16 are `f001f004`, `f101`/`f102`/`f104``f106`, `f201`/`f202`, `f301``f303`,
`f401`/`f402` — the hundreds digit groups them into five families with internal
holes (no `f103`, no `f203`). **No craft was cut.**
Written up in [arsenal-item-weapon-chain](structures/arsenal-item-weapon-chain.md).