re: the 147 ISL built-ins characterised; Stage 02 gates on units, not counts

Table at 0x8227226C is 147 big-endian absolute VAs (verified structurally: it
ends exactly where the first handler begins, all targets inside sub_82272220).
Arguments are not in the instruction -- every handler does c_str() on
[phase+20], a packed blob, which is what the local[] staging fills. Return 2 =
yield; five built-ins block by skipping the pc advance.

Recovered the ScriptPhase state layout: 32-entry float and flag register files,
int/double result registers, the timer block, and the runtime unit array at
+324 indexed by symbol-table-2 index -- a direct hook from bytecode call sites
to the two .ssb symbol tables.

Spot-checked two claims against the disassembly rather than trusting them: id 4
loads a DOUBLE into the thread countdown and returns 2 (wait_s), and id 24 reads
current/initial squadron member counts (squad_survival_pct). Both exact.

Counting Stage02.ssb: unit_state 255, hp_pct_test 167, dist_lt 92, unit_alive
71, unit_relation 52 -- and squad_survival_pct, group_ratio_pct and the two
global counters are NOT called at all. So Stage 02's phases are gated on named
units (destroyed / HP / proximity), never on an aggregate count, even though the
kill-counter primitives exist in the VM. That answers the standing 'next wave
after N kills or after an event?' question for this stage: specific units, not a
number.

isl.py now names the built-ins, so the run-up to the first END_PHASE reads
wait_cmds_drained / fade_sound(3) / builtin85(3) / wait_s(3) / END_PHASE.

Not settled: 3 handlers unresolved (55, 75, 105); the 1024-slot interpreter
command table is only partly recovered.
This commit is contained in:
Sylpheed RE agent
2026-08-25 12:36:02 +00:00
parent d434aafc6b
commit 56507e8c2d
3 changed files with 219 additions and 40 deletions

View File

@@ -0,0 +1,120 @@
# 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, 0x410x44, 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]` |
| **132134** | player gauges | speed/boost ratios and a player byte |
| **73, 123127** | 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.
## 🟡 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.