Files
Sylpheed/docs/re/structures/isl-builtins.md
Sylpheed RE agent 63d3ce089f re: trigger queue layout at phase+272, and a readable pending count
Chasing what makes the phase-1 condition re-evaluate, since the polls do not run
continuously.

Two method corrections: searching the VM range for '272(rN)' mostly returns
VTABLE slot offsets -- 0x82273174 lwz r11,272(r11) is followed by mtctr/bctrl,
a virtual call through slot 68, not an access to the phase field. And
[phase+272] is not a pointer to a queue but an EMBEDDED container: vt2
(sub_82265DD0) is 'addi r3,r3,272 ; b 0x8226E3B8', passing phase+272 as this.

Layout from the push/pop pair (sub_8226E3B8 from built-in 100, sub_8226E220
called every frame from sub_8226D740): +12 list head, +16 current node, +20
element count (zero = empty, tested first by the pop), +24 scratch. The pop
returns the record through out-parameters read from node+8: three u32s, a
double at +16, another u32 at +24 -- matching the six pointers sub_8226D740
passes in.

The actionable part is [phase+272+20], a live pending-trigger count readable
from /dev/shm. Watching it alongside [ScriptMission+40] should show when the
engine hands the script an event, which is when condition coroutines start --
the thing every phase experiment so far has been blind to.

Layout is from disassembly only; not yet verified live.
2026-08-25 17:54:34 +00:00

12 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.

Not yet verified live. The layout above is read off the disassembly only.