isl-builtins.md carried the trigger queue at phase+272 a long way -- layout, live
count at +272+20, the generic appender sub_8226EAB8 -- and stopped at "what actually
appends a node is still unidentified", proposing a gdb watchpoint on a running
emulator. It is a static question after all.
sub_8226EAB8 is a shared helper with 16 callers, so it cannot be identified by name.
But the corpus records that the trigger container EMBEDS its inner list at +12, so the
appender must hand it container+12. Only two of the sixteen callers do, and one sets
its base 34 instructions earlier:
8226A22C addi r29, r24, 272 ; r24 = the ScriptPhase
8226A2B4 addi r3, r29, 12 ; -> phase + 272 + 12
bl 0x8226EAB8
phase+272+12 is exactly the inner list whose count the corpus watched at +272+20.
sub_8226A0D8 is ScriptPhase vtable SLOT 28, which is what BUILT-IN 25's stub
tail-calls, and there are ZERO real `bl 0x8226A0D8` instructions in the image -- it is
reached only through the vtable. So the queue is populated by the SCRIPT, retiring
the residual "the engine moves records into the phase's queue each frame" reading that
isl-builtins.md had already flagged as unsupported by its own call site. Built-in 25's
arguments agree with two independently measured tables: UNIT_ARG (a unit at local[4])
and SYM1_SLOTS[12] (a symtab-1 name at local[12]).
REFUTED in the same pass, with a control: built-in 25 does NOT carry the coroutine
entry that a trigger fires, which would have seeded the 389 routines reachable from no
static entry. Taking each staged operand as a code offset from the phase base, 0 of 8
land on an instruction boundary, against 38.7% for an arbitrary 4-aligned offset --
below chance, not above it. And the scale is wrong regardless: built-in 25 has only
2 call sites in Stage 02.
So what starts the unreachable routines is still open, and it is not this. A trigger
node's own layout and its consumer are unread, and built-in 25 stays unnamed.
1082 lines
52 KiB
Markdown
1082 lines
52 KiB
Markdown
# 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.
|
||
|
||
### ✅ The interpreter's command table, recovered
|
||
|
||
`sub_822FE040` fills 1023 eight-byte slots at `table+32` with a default and then
|
||
overwrites individual ones; slot = `(N - 32) / 8` from each `std r9, N(r31)`.
|
||
Symbolically executing it yields **57 populated slots** — the full opcode →
|
||
handler map, committed as [`data/isl-command-table.txt`](../data/isl-command-table.txt)
|
||
and regenerable with `tools/re-capture/isl_cmdtab.py`.
|
||
|
||
Nine opcodes point at `0x82391BA8`, which is `li r3,1 ; blr` — accept-and-discard.
|
||
**768, 769, 774, 775, 776, 791, 792, 793 and 805 are dead in this build**, which
|
||
is why the built-ins that post them do nothing.
|
||
|
||
### ✅ Opcodes 800–802 send unit messages `0xED08 nn DE`
|
||
|
||
Their table entries are thunks 8 bytes apart into handlers `0x823008C8`,
|
||
`0x823009B8`, `0x82300AA8` — each 60 instructions, and **differing in exactly two
|
||
words**: a descriptor offset and the message id.
|
||
|
||
| opcode | built-in | unit message |
|
||
|---|---|---|
|
||
| 800 | 26 | **`0xED0802DE`** |
|
||
| 801 | **28** | **`0xED0803DE`** |
|
||
| 802 | **29**, 101 | **`0xED0804DE`** |
|
||
|
||
That fixes the id format as `0xED08 nn DE`, and the ids already known from other
|
||
work fit it: opcode 514 → `00DE`, 803 → `07DE`, 999 → `0FDE`.
|
||
|
||
🟡 The pump's arm for `0xED0802DE` does **not** apply an effect — it walks the
|
||
unit's child list at `[unit+320]`/`[unit+324]` and **rebroadcasts** to each child
|
||
as `0xED0902DE`. So `0xED08…` is the to-unit family and `0xED09…` the to-child
|
||
one, and the terminal effect is one link further on. ❌ Not followed; 26/28/29
|
||
remain unnamed.
|
||
|
||
### 🟡 Built-ins 26 / 28 / 29 are one family — and `damage_unit` looks mis-named
|
||
|
||
Method-diffing put the structure beyond doubt but did not reach the semantics.
|
||
|
||
| built-in | vtable slot | method | opcode | sites |
|
||
|---|---|---|---|---|
|
||
| 26 `damage_unit` | 76 | `sub_8226ACD0` (67) | **800** | 97 |
|
||
| **28** | 84 | `sub_82268F98` (69) | **801** | 410 |
|
||
| **29** | 88 | `sub_822690B0` (69) | **802** | 164 |
|
||
| 101 | 276 | `sub_822691C8` (78) | 802 (broadcast) | 133 |
|
||
|
||
**28 and 29 differ in two words only** — the opcode (`0x21BA` vs `0x22BA`) and a
|
||
descriptor pointer 8 bytes apart. Otherwise instruction-identical. All three take
|
||
`(unit, double)`.
|
||
|
||
26 differs from both by one guard: it rejects only state 3, while **28 and 29
|
||
reject states 1 and 3** (2 = active, 1/3/4 = gone/dead/invalid).
|
||
|
||
#### The operand distributions separate them
|
||
|
||
| built-in | n | distinct | range | most common |
|
||
|---|---|---|---|---|
|
||
| 26 | 97 | 7 | **[0, 100]** | **0 ×69**, 80 ×10, 100 ×6 |
|
||
| 28 | 410 | 13 | **[0, 2000]** | 200 ×116, 120 ×76, 300 ×74 |
|
||
| 29 | 164 | 6 | **[0, 100]** | **0 ×64**, 100 ×53, 50 ×38 |
|
||
|
||
26 and 29 are percentage-shaped; 28 is an absolute quantity an order of magnitude
|
||
larger.
|
||
|
||
#### 🟡 `damage_unit` (26) is doubtful
|
||
|
||
**69 of its 97 calls pass 0.** Dealing zero damage is a no-op, so 71 % of the
|
||
call sites would do nothing. *Setting* a percentage-valued property to 0 is a
|
||
perfectly natural thing to do 69 times, and 29 has the same shape (0 ×64 of 164).
|
||
The existing name predates this session and is not withdrawn, but it should not
|
||
be relied on.
|
||
|
||
#### ❌ Where this stopped
|
||
|
||
The three commands' descriptors sit at `0x820A8D10` / `+8` / `+16`. Following
|
||
them lands on data pointing into `0x8210E5xx`, which is **below the disassembly
|
||
DB's range** (it starts at `0x82150000`) and contains no code — so that route
|
||
does not reach an execute method. Reaching opcodes 800–802's semantics needs the
|
||
interpreter's command table, not the command objects.
|
||
|
||
### ✅ Built-in 108 is `deploy_squadron_ex` — `deploy_squadron` plus a `1 << n` selector
|
||
|
||
1146 sites in 22 stages, the second-largest unnamed built-in. Its method
|
||
(`sub_822646B8`, vtable slot 300) and built-in 2's (`sub_822642E0`, slot 12) are
|
||
190 and 199 instructions and **differ in one block**. Diffed instruction by
|
||
instruction, 108 adds:
|
||
|
||
```
|
||
lwz r11, 16(r29) ; the blob's slot-16 int
|
||
cmpwi cr6, r11, 0
|
||
blt cr6, … ; n < 0 -> default
|
||
cmpwi cr6, r11, 31
|
||
bgt cr6, … ; n > 31 -> default
|
||
li r25, 1
|
||
slw r21, r25, r11 ; r21 = 1 << n <- a 32-bit selector
|
||
b …
|
||
li r25, 1
|
||
mr r21, r25 ; default: 1
|
||
```
|
||
|
||
Everywhere built-in 2 passes its `r21`, 108 passes `r20` and reserves `r21` for
|
||
the mask — so the bit is an **extra argument to the same call**, not a
|
||
replacement. And both post the identical command word **`AB0100BA`, opcode 256**.
|
||
|
||
#### ✅ The operand is always a valid bit index
|
||
|
||
Over all 1146 sites the slot-16 value is in **[0, 31] — 1146 / 1146, none
|
||
outside**, so the out-of-range default never fires in shipped content. Fifteen
|
||
distinct values, clustered at 16 (531×), 31 (165×), 20 (161×) and 2 (90×);
|
||
21 of the 22 stages use more than one.
|
||
|
||
❔ **What the bit selects is not established.** There is a 32-bit space here —
|
||
built-in 92 reportedly allocates a *free* bit by OR-ing over live units, which
|
||
would make 108 the "place this squadron in a named slot" counterpart — but I have
|
||
not verified that, so the name says only what is proven: the same deploy as
|
||
built-in 2, with an extra selector.
|
||
|
||
### ✅ Built-in 12 is `activate_unit` — 1197 sites, all 28 stages
|
||
|
||
The highest-traffic unnamed built-in. `sub_822659F0`, read directly:
|
||
|
||
* indexes `[phase+324]`'s record array by the slot-4 symbol;
|
||
* **returns 0 immediately when the live object `[record+4]` is NULL** — so it
|
||
registers an object that already exists; it does **not** spawn one;
|
||
* sets **`[record+16] = 2`**, the documented *active* state that every unit
|
||
predicate tests;
|
||
* stores `sub_82301118`'s packed result into `[record+20]` (low 16 bits) and
|
||
`[record+24]` (high 16) — member counts;
|
||
* posts interpreter opcode **513** (`0xAB0201BA`) either way; the slot-8 mode
|
||
(1 in 999 sites, 0 in 198) only decides whether `cmd+20` is also set to 1.
|
||
|
||
#### ✅ The ordering test — 517 / 517
|
||
|
||
If this activates a unit for the script, no predicate should ever test a unit
|
||
before it. Over all 28 stages, for every (stage, unit) pair having both an
|
||
`activate_unit` call and a predicate (`unit_state`, `hp_pct_test`, `unit_alive`,
|
||
`unit_hp_pct`, `dist_lt`, `unit_relation`) on that same unit:
|
||
|
||
| | count |
|
||
|---|---|
|
||
| `activate_unit` comes first | **517** |
|
||
| a predicate comes first | **0** |
|
||
|
||
🟡 That is *file* order, not proven execution order — coroutines mean the two can
|
||
in principle interleave. But 517 with zero exceptions across 28 stages is not a
|
||
coincidence.
|
||
|
||
344 units are tested without ever being activated (they are live from mission
|
||
start) and 203 are activated without being tested.
|
||
|
||
#### ❌ It is NOT the survival-percentage baseline
|
||
|
||
The tempting closure: `squad_survival_pct` (id 24) reports current ÷ initial, and
|
||
`activate_unit` snapshots counts, so the snapshot must be the baseline. **It is
|
||
not.** Built-in 24 reads `[record+16]` for the state and then calls
|
||
`sub_823011B0` and `sub_82301118` on the **live object** — it never touches
|
||
`+20`/`+24`. ❔ What reads those two fields is unidentified.
|
||
|
||
### ✅ Built-in 15 is `set_group_speed` — the group's commanded speed
|
||
|
||
`15` is the biggest unnamed built-in: **1360 sites across 27 of 28 stages**. Its
|
||
operand shape is invariant — `(unit, double)` — and joining every call site's
|
||
unit to its craft type through `stage\UnitGroup_S<NN>.tbl` resolves **1360 of
|
||
1360, none unknown**. The values stratify hard by class:
|
||
|
||
| class | craft (sites) | values |
|
||
|---|---|---|
|
||
| capital hulls | `e106_Destroyer` (159), `e104_Carrier` (62), `e105_CruiserEX` (53), `f105_Cruiser` (40), `f101_Acropolis` (30), `e102_BattleshipEX` (27), `e108_ASFrigate` (33) | **0–100** |
|
||
| mobile craft | `e001_Elan` (73) 500, `f003_ArrowHead` (54) 400, `f001_DeltaSaber_T` (28) 400, `f002_DeltaSaber_W` (36) **600**, `e010_Attacker_S` (54) 250–400, `e009_Phantom` (16) 300 | **250–600** |
|
||
| asteroid | `mn040_Asteroid_Big` (74) | **0, and only 0** |
|
||
|
||
Capital ships crawl, fighters run, the player's own craft is fastest at 600, and
|
||
a rock never moves. That is a speed's signature and it is hard to explain any
|
||
other way.
|
||
|
||
#### ❌ Except that turrets break it
|
||
|
||
| craft | sites | values |
|
||
|---|---|---|
|
||
| `UN_e007_ADAN_Turret` | 112 | 400 ×89, 280 ×17, 380 ×4, 250 ×2 |
|
||
| `UN_e008_ADAN_TurretPlus` | 64 | 450 ×62, 500 ×2 |
|
||
|
||
**176 of the 1360 sites — 13 % — are turrets, and they carry fighter-class
|
||
values.** A turret does not move, so a literal hull speed cannot be what this
|
||
sets for them. Either the field means something else (a projectile speed, a
|
||
tracking rate, an FCS target-speed cap), or it means different things by class.
|
||
|
||
The asteroid is the control that makes this sharp: a genuinely immobile object
|
||
gets **0 every time**, so "immobile things get a meaningless value" does not
|
||
explain the turrets either.
|
||
|
||
#### ✅ What the members contribute — and why the turrets stop being a problem
|
||
|
||
`sub_82348830` is a `std::map::find`, and the object it returns is the per-member
|
||
**unit definition**. That identification is not a guess: the same spawn loop
|
||
builds *two* aggregates, and each lands on a semantically apt field with the apt
|
||
reducer —
|
||
|
||
| group field | reducer | member field (`unit_definition_layout.txt`) |
|
||
|---|---|---|
|
||
| `+192` | **min**, seeded `FLT_MAX` | `+164` = **`CruisingVelocity`** |
|
||
| `+472` | **sum** | `+84` = **`HP`** |
|
||
|
||
A wrong struct would have to make both offsets land on apt fields *and* pair each
|
||
with the apt reducer. Minimum of a speed, sum of hit points: that is a formation's
|
||
cruise limit and its total health.
|
||
|
||
#### ✅ The quantitative test, over all 1360 sites
|
||
|
||
If the script is commanding a speed, its value should respect the craft's own
|
||
limits. Joining every call site to its craft's definition:
|
||
|
||
| bound | holds | fails |
|
||
|---|---|---|
|
||
| value ≤ the craft's **`MaximumVelocity`** | **1355 / 1360 = 99.6 %** | 5 |
|
||
| value ≤ the craft's `CruisingVelocity` | 1042 / 1360 = 76.6 % | **318** |
|
||
|
||
The test discriminates: the cruise bound is broken 318 times, the hull maximum
|
||
just 5. So the script sets a **commanded speed**, free to exceed the cruise
|
||
default and bounded by what the hull can do.
|
||
|
||
🟡 The five exceptions are small overshoots on three craft —
|
||
`UN_e106_ADAN_Destroyer` 200 against a 150 maximum (×2) and
|
||
`UN_e011_ADAN_Attacker_B_HF`/`_Wayne` 500 against 450 (×3). Designer overrides,
|
||
or the engine clamps; not established.
|
||
|
||
#### ✅ The turret anomaly dissolves
|
||
|
||
`UN_e007_ADAN_Turret`'s definition carries `MaximumVelocity` **500** and
|
||
`CruisingVelocity` 280 — the data models turrets as if mobile. So a script value
|
||
of 400 is perfectly legal in the data model; it simply never manifests, because a
|
||
turret does not translate. The 13 % of sites that looked like a refutation were
|
||
an artefact of assuming turrets have no velocity fields.
|
||
|
||
**Named `set_group_speed`.** Default = the slowest member's `CruisingVelocity`;
|
||
mode 1 restores it, mode 3 sets it, mode 2 hands it a global constant.
|
||
|
||
#### ✅ The write side, read directly
|
||
|
||
`0x8232C7CC` in the unit message pump switches on `[msg+36]` three ways:
|
||
|
||
| mode | effect |
|
||
|---|---|
|
||
| 1 | `[unit+196] = [unit+192]` — restore the unit's stored default |
|
||
| 2 | `[unit+196] = [r27+13912]` — a global constant |
|
||
| 3 | `[unit+196] = [msg+40]` — the value the script passed |
|
||
|
||
So the field has a **per-unit default at `+192`** and a distinguished global
|
||
value, and built-in 15 either sets it, restores it, or hands it the constant.
|
||
The shipped scripts only ever use mode 3: slot 8 is the double tag in
|
||
**1961/1961** call sites, so the two defaulting modes are never exercised.
|
||
|
||
#### ✅ `+192` is a MIN over the group's members — seeded with `FLT_MAX`
|
||
|
||
The spawn routine `sub_8232B538` settles what the field *is*, without needing its
|
||
consumer:
|
||
|
||
```
|
||
8232B5B0 lfs f0, 25184(r11) ; r11 = 0x820B0000 -> 0x820B6260 = 3.4028235e38
|
||
8232B5B4 stfs f0, 192(r30) ; seed +192 with FLT_MAX
|
||
… per member …
|
||
8232B674 lfs f13, 164(r3) ; the member's own value
|
||
8232B67C lfs f0, 192(r30)
|
||
8232B680 fsubs f11, f13, f0
|
||
8232B688 fsel f0, f11, f0, f13 ; f0 = min(f0, f13)
|
||
8232B68C stfs f0, 192(r30)
|
||
… later …
|
||
8232BA10 lfs f0, 192(r30)
|
||
8232BA28 stfs f0, 196(r30) ; +196 starts equal to +192
|
||
```
|
||
|
||
The seed being **`FLT_MAX`** is what makes this unambiguous: `+192` is the
|
||
**minimum of `[member+164]` across the members**, and `+196` starts there.
|
||
|
||
🔑 **So built-in 15's target is a GROUP, not an individual unit** — this file and
|
||
my earlier notes both called it a unit. `sub_82348830` hands back a per-member
|
||
object and the reduction runs over all of them.
|
||
|
||
#### 🟡 That reading survives the turret anomaly
|
||
|
||
A minimum over members is exactly how a **formation limit** works: the group can
|
||
only go as fast as its slowest ship. On that reading `+192` is the natural cap and
|
||
`+196` the effective one, with built-in 15 either lowering it, restoring it
|
||
(mode 1), or handing it a global constant (mode 2).
|
||
|
||
And a *cap* explains the turrets, where a commanded speed could not. Capping a
|
||
static object at 400 is a **no-op** — nothing makes it move — so a designer can
|
||
set it uniformly from a unit template without consequence. The asteroid's
|
||
invariant `0` is consistent with the same reading.
|
||
|
||
❔ I have **not** shown mode 2's constant is `FLT_MAX`. It is
|
||
`[r27 + 13912]` where `r27` is loaded from a runtime pointer at `0x8232C718`, not
|
||
a static base, so it cannot be resolved from the image alone. If it is `FLT_MAX`
|
||
then mode 2 is literally "uncapped" and the three modes are
|
||
set / uncap / restore — tidy, and unproven.
|
||
|
||
#### ❌ The consumer is still NOT identified — three filters failed
|
||
|
||
Recorded so the next attempt does not repeat them:
|
||
|
||
* Searching the flight/AI range for `196(rN)` gives **170** hits, almost all on
|
||
unrelated structs — the same `N(rM)` trap this file already warns about.
|
||
* Narrowing to functions that touch **both** `+192` and `+196` still leaves
|
||
**50**. The pair is not distinctive either.
|
||
* `crates/sylpheed-formats/data/unit_definition_layout.txt` names offsets 192 and
|
||
196 as `AV_PitchMinus_Max` / `AV_PitchMinus_Min` — **but that is the unit
|
||
*definition* object** (vtable `0x820af844`), not the spawned entity built-in 15
|
||
writes to. It does not apply here, and it would be an easy wrong turn.
|
||
|
||
The object's identity is now partly pinned — it is a group with a member list,
|
||
not a single craft — but the struct is not bounded: the two constructors that
|
||
write vtable `0x820AF030` are 28 and 30 instructions and touch neither field, and
|
||
the image has **no RTTI at all** (0 of 1150 vtables), so class names are
|
||
synthetic. Bounding the group struct is the remaining prerequisite.
|
||
|
||
### ✅ Four built-ins are TUTORIAL-ONLY — 85 sites, every one in S18–S23
|
||
|
||
`96`, `97`, `98` and `104` were unnamed. Measured over all 28 stages, they form a
|
||
family that appears **nowhere outside the six tutorials**:
|
||
|
||
| id | sites | stages | shape |
|
||
|---|---|---|---|
|
||
| **96** | 8 | S18–S23 only | one call per tutorial *section* |
|
||
| **97** | 38 | S18–S23 only | followed by `start_coroutine` (27/38) |
|
||
| **98** | **0** | — | never called anywhere |
|
||
| **104** | 39 | S18–S23 only | **followed by `wait_s` 39/39**, preceded by `end_coroutine` 37/39 |
|
||
|
||
`104`'s adjacency is a textbook poll loop: a coroutine starts, tests the
|
||
predicate, waits, and goes round again — 39 sites, no exceptions.
|
||
|
||
`96`'s operand is the giveaway. Its eight payloads are, in stage order:
|
||
|
||
```
|
||
Stage18 → 101, 102, 103 Stage19 → 201 Stage20 → 301
|
||
Stage21 → 401 Stage22 → 501 Stage23 → 601
|
||
```
|
||
|
||
That is `(stage − 17) * 100 + section`: tutorial 1 has three sections, the other
|
||
five have one each. The tutorial index is encoded in the argument.
|
||
|
||
🟡 **Names are NOT applied.** `tutorial_begin` / `tutorial_end` /
|
||
`tutorial_message_pending` fit the shape, and `sub_82260710` reportedly suspends
|
||
while `[phase+340] == 2` with the payload latched at `[phase+344]` — but I have
|
||
not read that myself, and this file has already had to withdraw five names
|
||
guessed from shape. What is established here is the **distribution and the
|
||
argument encoding**, which is what a port actually needs; the labels can wait for
|
||
someone to read the handler.
|
||
|
||
### ✅ Five built-ins are the mission banners — named from usage, not from a guess
|
||
|
||
`77`, `78`, `81`, `82` and `135` were unnamed. The engine has five contiguous
|
||
strings — `MISSION_START_PRT` (`0x820A83F0`), `_END_`, `_UPDATE_`, `_FAILED_`,
|
||
`_RESTART_` — and five sequential `ScriptPhase` fields at `+388/+392/+396/+400/
|
||
+404`, stored in ascending order by one constructor region
|
||
(`0x82262D30 … 0x82263340`). Five names, five fields, five unnamed built-ins.
|
||
|
||
What decides *which is which* is the call-site structure, and it is exact:
|
||
|
||
| built-in | sites | stages | preceded by | followed by |
|
||
|---|---|---|---|---|
|
||
| **39** `MARK_LAST_PHASE` | 89 | 22 | 118 (89/89) | **82 (89/89)** |
|
||
| **82** → `banner_mission_failed` | 89 | 22 | **39 (89/89)** | `wait_s` (89/89) |
|
||
| **40** `mark_not_last` | 50 | 28 | 118 (43) | **78 (27) + 81 (17) + `END_PHASE` (6) = 50** |
|
||
| **78** → `banner_mission_complete` | 27 | 22 | **40 (27/27)** | `wait_s` (27/27) |
|
||
| **81** → `banner_objective_update` | 17 | 12 | **40 (17/17)** | `wait_s` (17/17) |
|
||
| **77** → `banner_mission_start` | 22 | **22 — one per stage** | `play_bgm` (15) | `end_coroutine` (21) |
|
||
| **135** → `banner_mission_restart` | 16 | 12 | `play_bgm` (16/16) | `end_coroutine` (16/16) |
|
||
|
||
`39 → 82` is a perfect 89/89 pairing, and `40`'s 50 sites partition *exactly*
|
||
into 78 / 81 / `END_PHASE`. One banner per stage after the music starts is a
|
||
mission-start banner; one after `mark_not_last` in a non-final phase is an
|
||
objective update.
|
||
|
||
🟡 The string↔field pairing itself is **inferred from ordering** — both sequences
|
||
ascend in the same order — not read directly; my operand tracker did not catch
|
||
the string loads in that constructor. The *roles* above do not depend on it.
|
||
|
||
❔ **76 is left unnamed**, deliberately. It has **38 sites = 22 + 16**, exactly
|
||
`77`'s count plus `135`'s, and it precedes them; its body sets `[phase+332] = 1`
|
||
and nothing in the image reads that field. The arithmetic is suggestive but a
|
||
name would be a guess.
|
||
|
||
🟡 **A consequence worth flagging:** `MARK_LAST_PHASE` is followed by the
|
||
**FAILED** banner in 89 of 89 sites, and `mark_not_last` by the END or UPDATE
|
||
banner. So `[phase+300] = 2` reads less like "this is the last phase" and more
|
||
like "end the mission now, unsuccessfully". The names in this file are the
|
||
original ones and may be mis-framing that pair.
|
||
|
||
### ✅ A symbol operand is a two-word pair: a **tag**, then the index
|
||
|
||
This is why the unit indices sit at slots 4/12/20 and never at 0/8/16 — the even
|
||
slot in front of each is a tag word holding the constant **1**, and it is not an
|
||
argument. Measured over all 28 stages:
|
||
|
||
* slot 0 is the integer **1 in 19 899 / 19 899** calls whose slot 4 is a unit;
|
||
* slot 8 is tag-shaped in **100 %** of calls for every built-in taking a second
|
||
unit, and slot 16 is the constant 1 in **152/152** for built-in 128, the only
|
||
one taking a third;
|
||
* **24 built-ins have a slot 0 that is not the constant** — and every one of them
|
||
takes no symbol there (`start_coroutine` a code offset, `wait_s` a double,
|
||
`set_flag` an index). The tag appears exactly where a symbol does.
|
||
|
||
⚠️ It does **not** generalise to "every even slot is a tag". Slot 8 is a *bare
|
||
double* for built-ins 4, 20, 24, 26, 28, 29, 90, 106 and 127, and built-in 75
|
||
carries five bare symbol indices at 0/4/8/12/16 with no tags at all. Each
|
||
built-in has a fixed signature and is **100 % consistent with itself** — not one
|
||
of the 34 built-ins with ≥20 sites mixes the two shapes. The disassembler now
|
||
drops the tag word, so `hp_pct_test(0x1, TCN004, 0)` reads `hp_pct_test(TCN004, 0)`.
|
||
|
||
### ✅ Symbol table 1 has three types, and its slots are type-pure
|
||
|
||
| type | entries (28 stages) | what |
|
||
|---|---|---|
|
||
| 1 | 1362 | `Route_*` names |
|
||
| 6 | 2247 | message / objective names |
|
||
| 7 | 81 | `eff_*` effect names |
|
||
|
||
Measured the same way as the unit slots — every observed value resolves, ≥5
|
||
distinct values, resolved type pure:
|
||
|
||
* **type 1** — 2@12, 3@12, 7@12, 16@12, 19@12, 25@12, 48@24, 90@20, 108@12,
|
||
128@28, 136@4, 143@12
|
||
* **type 6** — 64@0 (2683 sites), 75@0/4/8/12/16
|
||
* **type 7** — 115@0 (84/84)
|
||
|
||
Built-ins **24@4, 46@12 and 114@4** resolve 100 % but **mix type 6 and type 1**,
|
||
so the slot does not mean one thing; they are deliberately left unresolved.
|
||
|
||
Resolving these makes the listings say what the script means:
|
||
`request_script_message(MSG_VOICE_D_257, …)` — which is a fourth, independent
|
||
confirmation of that name, since its first operand is literally a `MSG_VOICE_*`.
|
||
|
||
🟡 **`115 named_event` is now suspect.** Its only symbol operand is an `eff_*`
|
||
**effect** name in 84/84 sites. The name is left alone pending a handler read,
|
||
but "named event" is probably not what it does.
|
||
|
||
### ❌ `camera_at` and `camera_at_route` WITHDRAWN
|
||
|
||
`isl.py` named built-ins 88 and 90. Both names are unsupported:
|
||
|
||
* **88 has zero call sites** in all 28 stages, so the name was never testable.
|
||
* **90 has exactly 8**, all in Stage 02 phase 3 — the nine-cruise-missile act —
|
||
and its first operand resolves to symbol-table-1 **type 7, `eff_n0071`, an
|
||
effect name**, in 8/8, with a per-missile `Route_ADT301..308_p3M` at slot 20.
|
||
|
||
Whatever 90 does, it is not aimed at a camera. Both are left **unnamed** rather
|
||
than renamed: replacing one guessed name with another is how the three names
|
||
above got wrong in the first place.
|
||
|
||
❔ **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.~~
|
||
🔴 **That reason is REFUTED** — see [isl-stream-is-flat](../isl-stream-is-flat.md).
|
||
The stream is flat; a plain linear decode reaches **25705/25705** call sites
|
||
across all 28 stages. What broke the naive decode was `isl.dis` stopping at
|
||
op 20 (`ret`), which in a coroutine VM is a *yield*, not an end of code.
|
||
|
||
| 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.~~
|
||
✅ **(2026-08-27) CONFIRMED from the handlers — see
|
||
[isl-branches](isl-branches.md).** `op10` is a signed compare writing three
|
||
condition bits (0=EQ, 1=GT, 2=LT) to a bitset at `phase+24`; `op11` is the float
|
||
twin via `fcmpu`; `op13`–`op18` are the six relational branches
|
||
(`beq bne blt ble bgt bge`) on those bits, targeting `[phase+232] + word@+4`
|
||
like `op12`. It is a case dispatch lowered to sequential compare-and-branch.
|
||
|
||
## 🔴 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.~~
|
||
✅ **(2026-08-27) IDENTIFIED — see [isl-trigger-queue](../isl-trigger-queue.md):
|
||
it is `sub_8226A0D8` = ScriptPhase vtable slot 28 = BUILT-IN 25.** The *script*
|
||
appends triggers; the engine does not.
|
||
|
||
|
||
## ✅ 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.
|