# 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: **0** continue, **2** yield (re-execute next frame), **3** coroutine control. Five built-ins skip the pc advance on 2 and so genuinely **block**: 97, 120, 137, 142, 143. ## βœ… `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** | `push_trigger` / `named_event` | the engineβ†’script edge | **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. ## βœ… What Stage 02 actually uses β€” and it settles a standing question Counting call sites in `Stage02.ssb` (`data/isl-stage02.txt`): | built-in | sites | |---|---| | `unit_state` | **255** | | `hp_pct_test` | **167** | | `dist_lt` | **92** | | `unit_alive` | **71** | | `unit_relation` | **52** | | `set_flag` / `clear_flag` / `push_trigger` | 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` into the push. ### Container layout, from the push/pop pair `sub_8226E3B8` (push, reached from built-in **100**) 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. **Not yet verified live.** The layout above is read off the disassembly only.