Files
Sylpheed/docs/re/structures/isl-builtins.md
Sylpheed RE agent e24b46fbe8 re: the trigger-queue pending count verified live -- 0 -> 1 -> 2
Read from a running Stage 02 mission: the container at ScriptPhase+272 has +20
moving 0 -> 1 -> 2 over the first two minutes while the phase ordinal stays 1.
So +20 is a real count of currently registered triggers -- the script arming
watches as it goes, and Stage 02 has 12 push_trigger sites -- and it is readable
from /dev/shm with no debugger. First direct view of what the script is waiting
for.

Corrects one field from the static reading: +12 is not (only) a list head. It
reads 0x000A0009, which is not a pointer, even though the push's
'addi r31, r30, 12' made list-head the obvious interpretation. Recorded as
unresolved rather than kept quietly.
2026-08-25 18:03:50 +00:00

13 KiB
Raw Blame History

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 (the instruction encoding) and mission-phase-advance (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 0x82454A40std::string::c_str() — so [phase+20] is a packed operand blob, which is what the local[] staging in isl-bytecode 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.

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.

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 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_drainedfade_sound(3)builtin85(3)wait_s(3)END_PHASEyield. 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): 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).

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 u32s, +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 — the script arming watches as it goes (Stage 02 has 12 push_trigger sites) — and it is readable live with no debugger. That is the first direct view of what the script is waiting for.

🟡 +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.