# The 147 ISL built-ins Status: βœ… table encoding, calling convention and the `ScriptPhase` state layout; βœ… ~135 of 147 handlers characterised from the disassembly; 🟑 three resolved only partially; ❔ the interpreter-command table is only partly recovered. Companion to [isl-bytecode](isl-bytecode.md) (the instruction encoding) and [mission-phase-advance](../mission-phase-advance.md) (why phases hinge on these). ## βœ… Table and calling convention `0x8227226C … 0x822724B7` is **147 big-endian absolute VAs** β€” no base-relative offsets. Verified structurally: the table starts immediately after the `bctr` at `0x82272268`, `0x8227226C + 147Β·4 = 0x822724B8` is exactly where the first handler begins, and every target lies inside `sub_82272220`. Six ids are **unused defaults** (0, 0x41–0x44, 0x7A) and about ten more are deliberate stubs returning a constant. Arguments do **not** live in the instruction. Every handler starts `addi r3,r31,20 ; bl 0x82454A40` β€” `std::string::c_str()` β€” so `[phase+20]` is a **packed operand blob**, which is what the `local[]` staging in [isl-bytecode](isl-bytecode.md) fills. ### βœ… Return codes β€” read off `ScriptPhase::Update` (`0x82263830`) The dispatcher switches on `r3` exactly four ways: | r3 | address | effect | |---|---|---| | **0** | `0x82263850` | continue to the next thread in the same frame | | **1** | `0x82263878` | `[thread+4] = [thread+0]` β€” **restart this coroutine from its entry** | | **2** | `0x82263888` | `[thread+4] = saved pc` β€” resume next frame | | **3** | `0x82263894` | `sub_8226EA20` erases the thread from the active list `[+216]`, then `sub_8226EAB8` returns it to the free list `[+204]` β€” **terminate this coroutine** | Two tails do the pc bookkeeping: `0x822724F8` is `li r3,0` then advance; `0x822724FC` advances only, preserving `r3`. Advance is `pc += [insn+2]`, the length byte β€” the same field [isl-bytecode](isl-bytecode.md) decodes. So **every handler that ends `b 0x822724F0` returns 0**, and its only output is `[phase+164]`/`[phase+176]`. ❌ **CORRECTED β€” the blocking set was wrong in two places.** The blocking form is `bctrl ; cmpwi r3,2 ; bne 0x822724FC`, and it appears at **102, 120, 137, 142, 143**. This file previously listed **97**, which does not block: its handler `0x8227313C` ends `b 0x822724F8`, so it *always* returns 0. And **102** was missing. All six handlers re-read to confirm. ## βœ… `ScriptPhase` state layout | offset | meaning | |---|---| | `+88` | **32-entry float register file** | | `+120` | **32-entry flag register file** | | `+160` | frame-wait counter | | `+164` / `+176` | **int** / **double result register** | | `+196` | phase-finished flag | | `+232` / `+236` | code base / end-event offset | | `+244` | **symbol table 1** base (route + message names) | | `+272` | trigger queue | | `+300` | 1 = not last phase, 2 = last | | `+304…+320` | mission timer (elapsed, t0, limit, running, enabled) | | `+324` | **runtime unit array**, indexed by **symbol table 2** index | Per-unit record: `+4` live object (NULL = absent), `+16` state (2 = active; 1/3/4 = gone/dead/invalid), `+32/40/48` position, `+128/132` HP / max HP, `+140` flag bitmask. **That is the hook into the data**: blob fields indexing `[phase+244]` are symtab-1 indices and fields indexing `[phase+324]` are symtab-2 indices β€” the two tables already parsed in [mission-script-ssb](mission-script-ssb.md). ## βœ… The conditions a phase can test | id | name | what it tests | |---|---|---| | **6 / 62** | `END_PHASE` / `FORCE_END_PHASE` | sets `[+196]`, with / without the end event | | **39 / 40** | `MARK_LAST_PHASE` / `mark_not_last` | `[+300] = 2` / `1` | | **69 / 70** | `unit_state` / `unit_alive` | a **named unit's** lifecycle state; state == 2 | | **20 / 95** | `hp_pct_test` / `unit_hp_pct` | unit HP as a percentage of max | | **18** | `dist_lt` | 3-D distance between two named units below a threshold | | **24 / 72** | `squad_survival_pct` / `group_ratio_pct` | current Γ· initial squadron members Γ— 100 | | **56 / 94** | `unit_relation` / `is_engaged` | relation between units; is anything engaging this one | | **33 / 34** | `global_counter0/1` | two global counters read straight into `[+164]` | | **132–134** | player gauges | speed/boost ratios and a player byte | | **73, 123–127** | timer family | start / resume / stop / reset / read elapsed / read limit | | **8 / 9 / 93** | `set_flag` / `read_freg` / `clear_flag` | latch a result into the 32-entry files | | **100 / 115** | `reset_phase_threads` / `named_event` | ❌ 100 is **not** `push_trigger` β€” see below | **The state machine is therefore:** a trigger fires a coroutine β†’ the coroutine tests one of the predicates β†’ it latches the answer with `set_flag` β†’ some later thread reaches `END_PHASE`. Two spot-checks I ran against the disassembly rather than taking on trust: * **id 4 (`wait_s`)** β€” `c_str()`, `li r3,2` (yield), `lfd f0,0(r11)`, `stfd f0,8(r30)`: a **double** seconds value into the thread countdown. Exactly as described. * **id 24 (`squad_survival_pct`)** β€” indexes `[phase+324]` by `[arg+4]`, rejects a NULL object and state 1, then calls `823011B0` (initial, packed `hi<<16|lo`) and `82301118` (current). Exactly as described. ## ❌ Three built-in names WITHDRAWN Each re-read twice β€” the handler, and the thing it calls β€” because all three had been named from their shape rather than their effect. | id | was | **is** | evidence | |---|---|---|---| | **11** | `yield` | **`end_coroutine`** | `0x82272624` is `li r11,1 ; li r3,3 ; stw r11,164(r31)`. Return 3 **destroys the thread**. It is the single most-used built-in in the game β€” 2945 sites, 372 in Stage 02 alone β€” so this was the most load-bearing wrong name in the file. | | **5** | `await_label` | **`kill_coroutine(label)`** | `sub_82273B08` computes `target = [phase+232] + blob[0]`; if that equals the **caller's own** pc it returns 3 (kill self), otherwise it finds the thread parked at `target` in `[phase+220]` and moves it to the free list. It does not wait for anything. | | **100** | `push_trigger` | **`reset_phase_threads`** | the handler calls vtable slot 2 (clears the trigger container at `[phase+272]`) and then `sub_82273BE8`, which walks `[phase+220]` and frees **every thread whose pc differs from the caller's**. It drops queued triggers and terminates every *other* coroutine β€” the opposite of pushing one. | One name is newly **recovered**, from the game's own text: interpreter opcode 992 prints `β˜…RequestScriptMessage %s` (`0x820A5700`), so **id 64 is `request_script_message`** β€” 2683 sites, and the second most-used built-in. ## βœ… Which operands are unit indices β€” settled from the data `tools/re-capture/isl.py` resolved a symbol-table-2 name only for 11 built-ins, at slot 4. The real set is much larger, and it was established by **measurement over all 28 stages** rather than by reading 147 handlers: > a slot qualifies only if every observed value is a valid symtab-2 index, it > takes β‰₯15 distinct values, **and its maximum reaches most of the table**. That last clause is what makes the test work. Symbol table 2 tops out at 122 entries, so a slot carrying something else overruns it; plain range-checking cannot separate an index from a bool, because every small integer is in range. * **unit index at slot 4** β€” 2, 3, 7, 12, 15, 16, 18, 19, 20, 24, 25, 26, 28, 29, 30, 47, 48, 56, 57, 58, 63, 69, 70, 79, 91, 92, 95, 105, 108, 128, 143 * **a second at slot 12** β€” 2, 18, 47, 48, 56, 79, 95, 128 * **a third at slot 20** β€” 128 Every one of these is 100.0 % in range across its call sites (the largest, id 20, over 2930 of them). The test also **refuted** a tempting entry: `set_flag`'s slot 0 passes the range and spread checks but its maximum *exceeds* the table β€” flag indices run 0..31 against symbol tables as small as 40 β€” so it is excluded, and the disassembler now declines to resolve it rather than printing an invented name. ❔ **New, unexplained:** symbol table 2 holds **two types**, 2 (1160 entries disc-wide) and 8 (249), and they are not interchangeable. Built-ins **95** and **128** take a type-2 unit at slot 4 and, at slot 12, an operand that is type 8 in **100 %** of its 90 and 152 call sites. What separates the two classes is not established. ## βœ… What Stage 02 actually uses β€” and it settles a standing question Counting call sites in `Stage02.ssb` (`data/isl-stage02.txt`, regenerated by `tools/re-capture/isl_report.py calls`): ⚠️ The sibling artefact `data/isl-stage02-conditions.txt` **predates the name corrections above** β€” it still prints `yield`, `await_label` and `push_trigger`, and its operand rendering predates the staging fix. It has no committed generator; reproducing it needs the coroutine entry points, which `start_coroutine`'s operand carries and the tool does not yet follow. | built-in | sites | |---|---| | `unit_state` | **255** | | `hp_pct_test` | **167** | | `dist_lt` | **92** | | `unit_alive` | **71** | | `unit_relation` | **52** | | `set_flag` / `clear_flag` / `reset_phase_threads` | 12 each | | `END_PHASE` / `MARK_LAST_PHASE` / `FORCE_END_PHASE` | 12 / 8 / 3 | **Not used at all in Stage 02:** `squad_survival_pct`, `group_ratio_pct`, `global_counter0/1`, `is_engaged`, `player_gauge*`, `prompt_yes_no`, `deploy_and_wait`. πŸ”‘ **So Stage 02's phases are gated on named-unit tests β€” destroyed / HP / proximity β€” and not on any aggregate count.** The kill-counter primitives exist in the VM (33, 34) and this mission never calls them. That is a direct answer to the standing "does the next wave start after N kills or after an event?" question, at least for Stage 02: **specific units, not a number.** "Certain objectives shot down" is right; "a certain number shot down" is not. ⚠️ Scoped to Stage 02. Other stages may well use `squad_survival_pct` β€” the counting is per-file and cheap to repeat. ## βœ… A real Stage 02 condition, read end to end With the symbol tables resolved (unit arguments are symbol-table-2 indices), the bytecode reads as mission logic. From `Stage02.ssb` at `0xF524` (`data/isl-stage02-conditions.txt`): ``` unit_state(1, ADN110) objective_marker(1, 0x01, 0, 8, 0) unit_state(1, ADN111) objective_marker(1, 0x02, 0, 8, 0) unit_state(1, ADN112) objective_marker(1, 0x05, 0, 8, 0) objective_marker(1, 0x3A, 1, 8, 0) set_flag(8) ``` Three **named ADAN squadrons** are polled for lifecycle state, each with its objective marker updated, and then **flag 8** is latched. That is the shape [mission-phase-advance](../mission-phase-advance.md) predicted from the disassembly alone β€” trigger β†’ predicate β†’ `set_flag` β†’ (later) `END_PHASE` β€” now seen in the mission's own code with the squadron names the roster tables already gave us. The 12 `END_PHASE` sites are, by contrast, **outro sequences**: `wait_cmds_drained` β†’ `fade_sound(3)` β†’ `builtin85(3)` β†’ `wait_s(3)` β†’ `END_PHASE` β†’ `yield`. The *decision* is not there; the terminator is. ### ⚠️ A decode bug that hid every argument The first version of the argument tracker only followed `local[i] = special[0]`. But the common form is **`set.i k=01,03` β€” an immediate written straight into `local[i]`** β€” and missing it meant every unit predicate printed with **no arguments at all** (`unit_state` rather than `unit_state(1, ADN110)`). The disassembly looked complete and was silently empty where it mattered most. Both staging forms are now handled. ## βœ… Correction: the script reads its own flags β€” no engine reader needed Last iteration ended with "what reads the flag file is unknown", after an offset search failed and a promising hit in `sub_8226D740` turned out to be a trigger record. **The framing was wrong.** I was looking for an *engine-side* reader; the consumer is the **script itself**, through built-in **9** (`read_freg`), which loads `[phase+88][i]` into the double result register `[phase+176]`. `Stage02.ssb` calls it **12 times** β€” the same count as `set_flag` (12) and `clear_flag` (12). So the latch is symmetric and entirely inside the VM: ``` set_flag(i) -> [phase+88][i] = 1.0 , [phase+120][i] = 1 read_freg(i) -> [phase+176] = [phase+88][i] clear_flag(i) -> zero entry i, or all 32 when the argument is -1 ``` That closes the middle of the `set_flag β†’ … β†’ END_PHASE` chain: a condition coroutine latches a flag, and another coroutine reads it back with `read_freg` and branches on it. ## 🟑 `op10` + `op13` look like a switch Seen repeatedly, e.g. at `0x5774`: ``` op13 -> 0x5448 op10 imm 4 op13 -> 0x54F0 op10 imm 5 op13 -> 0x5598 ``` Consecutive small immediates each paired with their own code offset is the shape of a **case/branch dispatch**, and `op12` is already confirmed as the unconditional jump. **Not confirmed** β€” the handlers (`0x82271598` for op10, `0x82271830` for op13) have not been read, and I am not going to name them from a pattern alone. ## πŸ”΄ Correction: `unit_state` does NOT read `+16` β€” it reads `+4` and `+104` Disassembling built-in 69's handler (`0x8226ADF0`) rather than trusting the one-line summary: ``` lwz r10, 324(r30) ; the unit array lwz r11, 4(r31) ; arg blob +4 = the symbol-table-2 index lwz r10, 4(r10) ; records base lwzx r9, r11, r10 ; rec = base[idx] lwz r9, 4(r9) ; <-- rec+4 cmplwi r9, 0 beq 0x8226AF44 ; rec+4 == 0 -> early exit, "absent" lwz r4, 4(r11) ; rec+4 again bl 0x82301240 ; lifecycle lookup ON rec+4 ... lbz r11, 104(r11) ; rec+104, a BYTE, compared against 1 li r11, 2 ; -> result 2 ``` **`rec+16` is never touched on this path.** The predicate reads the **handle at `rec+4`** β€” which is why it holds small consecutive integers (26/27/28) rather than pointers; `sub_82301240` resolves it β€” plus the byte at `rec+104`. That is exactly why poking `+16` to 4 changed nothing ([script-runtime-probe](../script-runtime-probe.md)): the value was written into a field the condition does not consult. `+16` still tracks deployed/active/destroyed faithfully as an *observable* β€” the arrival and death transitions were real β€” but it is a **readout, not the input**. **The corrected way to simulate "this squadron is gone" is `rec+4 = 0`**, which takes the documented early exit. That is the next experiment. ⚠️ General lesson for this table: it was assembled by a subagent from handler behaviour, and this is the second field description that did not survive contact with the disassembly. Treat the per-offset meanings as leads to verify, not as facts β€” the *identifications* (which built-in does what) have held up well. ## 🟑 Not settled * **Three handlers resisted**: id 55 (`vt35`, 411 instructions, returns a float), id 75 (`vt52`, message/HUD-ish), id 105 (`vt73`, meaning of unit field `+600`). * The **1024-slot interpreter-command table** is only partly recovered β€” 57 slots, by simulating the constant/stack dataflow of `sub_822FE040`. * Names here are from handler behaviour, not from symbols; `isl.py` prints a bare `builtinN` for anything unread rather than guessing. ## βœ… The trigger queue at `phase+272` β€” layout, and a readable pending count Chasing what makes the phase-1 condition re-evaluate (the polls do **not** run continuously β€” see [script-runtime-probe](../script-runtime-probe.md)). **Two method corrections first**, because both nearly sent me the wrong way: * Searching the VM's address range for `272(rN)` returns mostly **vtable slot offsets**, not accesses to the phase field. `0x82273174 lwz r11,272(r11)` is followed by `mtctr; bctrl` β€” it is a virtual call through slot 68, nothing to do with `[phase+272]`. * `[phase+272]` is **not a pointer to a queue** β€” it is an **embedded container**. `vt2` (`sub_82265DD0`) is literally `addi r3,r3,272 ; b 0x8226E3B8`, i.e. it passes `phase+272` as `this`. ### ❌ `sub_8226E3B8` is a CLEAR, not a push It was labelled "push" here, which is what made built-in 100 look like `push_trigger`. Read directly, it is the opposite: ``` lwz r11,20(r30) ; the element count cntlzw / extrwi ; == 0 ? bne -> 0x8226E450 ; count == 0 -> nothing to do, return addi r31,r30,12 ; else walk the node list… stw r11,0(r10) ; stw r10,4(r11) ; …unlinking each node ``` A push allocates and links **one** node; this runs only when the container is **non-empty** and splices nodes **out** until it is empty. So it is `clear()`. That is a third independent line for the rename above β€” the handler, the usage (all 12 Stage 02 sites sit in the phase terminator), and now the callee. The **append** is `sub_8226E160`, reached from built-ins 19 and 25: it takes the record fields as arguments (including a `double` in `f1`, matching built-in 19's `+24 dbl`) and is guarded on `[container+8]`. ### Container layout, from the clear/pop pair `sub_8226E3B8` (clear) and `sub_8226E220` (pop, called every frame from `sub_8226D740`): | offset in the container | meaning | |---|---| | `+12` | list head/sentinel (`addi r31, r30, 12`) | | `+16` | current node pointer | | `+20` | **element count β€” zero means empty**; the pop tests it first and returns 0 | | `+24` | scratch: the popped node is stashed here | The pop hands the record out through **out-parameters**, reading from `node+8`: `+0`, `+4`, `+8` as `u32`s, `+16` as a `double`, `+24` as another `u32` β€” which matches `sub_8226D740` passing six pointers into local slots. ### 🎯 `[phase + 272 + 20]` is a live "pending triggers" counter That is the useful part: a single `u32` that says how many triggers are queued, readable from `/dev/shm` with no debugger. Watching it alongside `[ScriptMission+40]` should show **when** the engine hands the script an event β€” which is exactly the moment the condition coroutines get started, and the thing every phase experiment so far has been blind to. ### βœ… Verified live Read from a running Stage 02 mission (`ScriptPhase 0xBE14DD80`, container at `0xBE14DE90`): ``` +272+12 = 0x000A0009 +272+16 = 0xBC28E620 (a node pointer) +272+20 = 0 +272+24 = 0 [ 0.0s] pending=0 phase=1 finished=0 [ 68.0s] pending=1 phase=1 finished=0 [ 108.1s] pending=2 phase=1 finished=0 ``` **`+20` moves, 0 β†’ 1 β†’ 2**, while the phase ordinal stays 1. So it is a real counter of **currently registered triggers**, readable live with no debugger β€” the first direct view of *what the script is waiting for*. ⚠️ The *measurement* stands; its attribution did not. This paragraph used to add "the script arming watches as it goes (Stage 02 has 12 `push_trigger` sites)", pointing at built-in 100. Built-in 100 is `reset_phase_threads` β€” it **clears** the trigger container, it does not arm one. The 12 sites are real, but they are 12 places where Stage 02 *tears the trigger set down*, which is close to the opposite reading. ❔ What actually arms a trigger is now open again; built-ins 19 and 25 both queue into `[phase+272]` and are the first place to look. 🟑 **`+12` is not a list head after all**, or not only that: it reads `0x000A0009`, which is not a pointer. The `addi r31, r30, 12` in the push made "list head" the obvious reading and the value does not support it. Recorded as unresolved rather than quietly kept. ## 🟑 Walking the trigger queue live β€” structure confirmed, contents not Walked the container's linked list from `+16` for 200 s of a Stage 02 mission: ``` [ 0s] count=0 head=0xBC28E610 [ 80s] count=1 head=0xBC28E610 node 0 @0xBC28E610: f0=0xBC63.. f4=0xBC40.. f8=0xBC25.. dbl=-0.000 f24=0xBC25.. [120s] count=2 head=0xBC28E610 node 1 @0xBC28E630: f0=0xBC65.. f4=0 f8=0xBC25.. dbl=-0.000 f24=0xBC25.. ``` βœ… **The structure holds**: the count at `+20` tracks the number of nodes, the nodes chain through their first word, and new entries appear as the mission runs (0 β†’ 1 β†’ 2, stable thereafter). πŸ”΄ **The record layout does not.** I expected `node+8` to hold small **symbol indices** β€” the pop's out-parameters made that the natural reading. Every field is a **guest heap pointer** (`0xBC…`). So the trigger record references objects, not table indices, and what those objects are is unidentified. ### ⚠️ A false resolution I introduced myself The `[120s]` line first printed `f4=0(ADN101)` β€” because the raw value is **0** and my formatter mapped index 0 to symbol-table-2's first entry. `ADN101` is not in that record; it is my own pretty-printer inventing a name for a null. A resolver must refuse to resolve values that were never indices, and this one had no such guard. Recorded because it is exactly the sort of plausible label that would survive into a conclusion. ## πŸ”΄ Correction: `sub_8226E3B8` is a CLEAR, not a push The previous section called it the push, reached from built-in 100 via `vt2`. Its tail refutes that: it decrements a counter, calls an erase helper (`sub_8226EAB8`), and **loops while `[+20] != 0`** (`beq 0x8226E3E4`). That is a **drain-the-whole-queue** routine. So built-in 100 *clears* the trigger queue and then rebuilds the thread list via `sub_82273BE8` β€” consistent with the built-in table's own description ("push the argument record ... then drain/rebuild"), and the "push" label was mine, not the disassembly's. `xrefs` gives it two callers: `0x82265DD4` (vt2, the script side) and **`0x8226D420`, an engine site** β€” so the engine clears it too. What actually *appends* a node is still unidentified. ## βœ… FOUND: the appender is `sub_8226EAB8`, and the count lives at `inner+8` The watchpoint plus Canary's own source settles it. At the moment of the write the guest context (`%rsi`, per `x64_emitter.cc:881`) contains **`0x8226EAE0`**, which is inside `sub_8226EAB8` β€” so that is the guest code doing it. `sub_8226EAB8` is a **generic list-node insert**: ``` 8226eae8 lwz r11, 8(r30) ; current count 8226eaf0 cmplwi r10, 0x1 ; overflow guard against 0x3FFFFFFF 8226eb30 addi r11, r11, 1 8226eb34 stw r11, 8(r30) ; count += 1 8226eb38 stw r3, 4(r29) ; link the new node 8226eb40 stw r3, 0(r11) ``` **It increments a count at `+8` of the container it is handed** β€” and it has **16 callers**, so it is a shared container helper, not trigger-specific. ### βœ… Why the static search missed it, and what `+12` really is The trigger container at `phase+272` **embeds an inner list object at `+12`** (which is why the push does `addi r31, r30, 12`). That inner object keeps its own count at **its** `+8`: ``` phase + 272 + 12 + 8 = phase + 272 + 20 ``` β€” exactly the word the watchpoint was set on. So the write really is `stw r11, 8(r30)` with `r30 = phase+284`, and searching for `stw rN, 20(rM)` could never have found it. That also resolves the earlier 🟑: **`+12` is the embedded list object**, not a list head pointer, which is why it read `0x000A0009` rather than an address. **Method note worth keeping:** the static hunt failed because it assumed the field's offset in the *outer* object would appear in the writing instruction. A watchpoint does not care about the addressing form, which is exactly why it was the right tool once the offset search came up empty twice. ## ~~πŸ”΄ What appends a trigger node β€” NOT FOUND~~ (superseded above) Three approaches, none of which produced the appender: * **`sub_8226E160`**, flagged earlier as "enqueue a pending trigger", takes a `double` plus several pointers, rejects `arg == -1`, and has **exactly one caller** (`0x8226A044`). It is a specific operation, not the general append. * ~~**Writes to the count at `+20`** … are part of a block initialisation … Those are **constructors**~~ β€” **WRONG, withdrawn.** Verified at `0x8226E86C`–`0x8226E8E0`: those functions do `li r3,28 ; bl 0x8230C160` (allocate 28 bytes), then `lis r10,0xAB03 ; ori r7,r10,0xE4BA ; stw r7,4(r3)`. They are building an **interpreter command record** stamped `0xAB03E4BA` = opcode **996**, and pushing it into the interpreter queue β€” `sub_8226E7D8` = **AddSelector**, `sub_8226E930` = **RemoveSelector**, with a 32-entry cap (`cmpwi r8, 32`). The `stw … 20(r3)` I read as "the container's count" is the *command record's* `+20`, a different object entirely. The analysis was wrong twice over: not constructors, and not that container. * So the increment that takes the count 0 β†’ 1 β†’ 2 β€” which is **measured, live** β€” does not appear as a plain `stw rN, 20(rM)` anywhere in the container's own code. It is either inlined into a caller, uses a different addressing form (`stwx`), or the node count is maintained somewhere I have not looked. **Honest state:** the queue's structure, its live count and its node chaining are verified; what writes a node into it is not identified, and I do not have a candidate I believe. Guessing from the shape of nearby functions is what produced the "push" mislabel last iteration, so I am not repeating it. **The approach that would settle it** costs more but is unambiguous: a **gdb watchpoint on the count word** during a live mission. The address is known at runtime (`ScriptPhase + 272 + 20`), the count demonstrably changes within ~2 minutes of flight, and the watchpoint reports the writing instruction directly instead of inferring it from static shape. ## 🟑 The watchpoint fired β€” the writer is JIT-compiled GUEST code, not host code `tools/re-capture/trigger_watch.sh` + `host_addr.py` translate the guest VA into a host address and set a gdb watchpoint on it: ``` mission 0xBC7A2A20 phase 0xBE14DD80 va 0xBE14DEA4 off 0x11E14DEA4 -> host 0x1BE14DEA4 Hardware watchpoint 1: *(unsigned int*)0x1BE14DEA4 Thread 50 "Main XThread" hit it: Old value = 0 New value = 16777216 ``` βœ… **Two things confirmed.** `16777216` is `0x01000000` β€” big-endian `1` read little-endian, so this is exactly the count going **0 β†’ 1**, independently confirming that `[ScriptPhase+272+20]` is the field. And the write happens on the **guest's own Main XThread**, not on an emulator worker. πŸ”΄ **But the writer cannot be named from the host stack.** The faulting PC is `0xa0c65f23`, with no symbol, and the instruction is `mov 0x110(%rsi),%rbx` β€” this is **Xenia's JIT-compiled guest code**. The backtrace above it is garbage (`0x45e0000000`, `0x100000000`), because JIT frames are not host-unwindable. So the host watchpoint answers *when* and *which guest thread*, but **not which guest function** β€” the thing I actually wanted. The method has a ceiling here, and it is worth recording rather than re-attempting the same way. **What would get past it:** the JIT keeps the guest context in a register (`%rsi` here, given `mov 0x110(%rsi),%rbx`), so the **guest PC is recoverable from the context block** at the moment of the write. Reading the right offset out of `$rsi` would name the guest instruction. That needs Xenia's context layout β€” which is in the xenia-rs sources on this box β€” and is a separate, tractable piece of work rather than another blind run. ## 🟑 `sub_8226E458` is a splice β€” but I have not shown it touches the trigger queue Chasing which of `sub_8226EAB8`'s 16 callers grows the trigger count, the promising one is `sub_8226E458`: ``` 8226e504 lwz r11, 8(r30) ; source count 8226e508 subi r11, r11, 1 8226e50c stw r11, 8(r30) ; source -= 1 8226e51c bl 0x8226EAB8 ; ... then insert into the destination (+1) ``` Remove from one list, insert into another β€” a **splice**. And it has exactly one caller, `0x8226D780`, **inside `sub_8226D740`**, the per-frame engineβ†’script drain. That is a tidy story: the engine moves records into the phase's queue each frame, and the count I watched rises as it does. πŸ”΄ **The tidy story is not supported by the call site.** At `0x8226D780` the argument is `lwz r4, 324(r29)` β€” `[ScriptPhase+324]`, the **unit array**, not the trigger container. So whatever `sub_8226E458` splices between, I have **not** shown it is the trigger queue, and the "engine feeds triggers each frame" reading is mine rather than the disassembly's. Recording it unresolved. The same over-reach β€” taking a function's shape as its purpose β€” produced the "push" mislabel on `sub_8226E3B8` and the `ADN110`-for-null pretty-print, both of which cost an iteration to undo. **What is solid** and does not depend on this: `sub_8226EAB8` increments a count at `+8` of the container it is handed; the trigger container embeds its list at `+12`; and the watched word at `phase+272+20` is therefore that inner list's count. The guest was executing inside `sub_8226EAB8` at the moment of the write. **Next:** rather than guessing among 16 callers, set the watchpoint again and read the **guest LR** out of the context (`%rsi`) at the hit β€” the same technique that named `sub_8226EAB8` will name its caller. ## βœ… The interpreter command table, recovered in full `sub_822FE040` is a **fully unrolled** registration sequence β€” no loop, no `.rdata` copy. It fills **1023** slots (not 1024: `32 + 8Β·1023 = 8216`, and `this+8216` is a `std::map`) with a default, then writes **57** explicit slots. Of those, **48 are real handlers**; nine point at a shared `li r3,1 ; blr` accept-and-discard stub. * default `0x82674028` = `li r3,0 ; blr` β†’ the 966 unpopulated slots always return 0, so an unknown command is retried 10Γ— and dropped. * Return convention is **nonzero = consumed**, 0 = retry β€” which is how the interpreter *waits*: several handlers return 0 until a named unit exists. * ⚠️ The dispatcher masks the opcode to 10 bits, so **opcode 1023 would fetch the map's first word as a handler** β€” a latent OOB nothing constructs. πŸ”‘ **Opcode 995 is the only handler that touches the phase mirror `[*(0x828F35F8)+236]`** β€” the sole read *and* the sole write in the entire table. That independently confirms why polling that mirror saw nothing during phase 1. πŸ”‘ **No handler spawns or despawns a unit.** 518/519 destroy *order* objects; 1014 broadcasts to every mission unit. Opcode **256** is the strongest deploy candidate (two nameβ†’definition lookups, a 52-byte request, message `0xFE0018EF`) but is **unconfirmed** β€” the message ids are write-only in this image, so the consumer cannot be reached statically. 🟑 **`sub_8230C398` may be Stage 16's script, compiled in C++.** It posts 256/513/514/784/803/896/998/1011 with hard-coded literals (`Route_TCN001_p1F`, `TCN001`, `SUBOBJ_001`, …) and is **gated on `*(0x82899CE0) == 16`** at both call sites. `mission-phase-advance.md` calls those literals "debug defaults" β€” that does not survive an `== 16` gate, especially as the `.ssb` loader explicitly refuses mission 16 (`if (n == 16 || n > 32) return`) and S16 is already the corpus outlier with no unit predicates. **Strongly indicated, not proven**: no writer for `0x82899CE0` was found.