This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Syplheed-Reborn/docs/re/structures/isl-builtins.md
Sylpheed RE agent bad96eb54a re: read the ISL branch handlers -- it is a condition-code machine
Closes the backlog item that was the last thing between the flat decode and a
per-phase clear condition, and closes isl-builtins.md's standing "op10 + op13 look
like a switch -- NOT confirmed".

op10 resolves two operands, issues a SIGNED cmp, and writes three condition bits to
a bitset at phase+24: bit 0 = EQ, bit 1 = GT, bit 2 = LT.  op11 is the same machine
for floats via fcmpu.  op13-op18 branch on those bits to [phase+232] + word@+4 --
the same phase-relative target form as the unconditional op12:

  13 bit0 set    beq      16 bits 2 then 0   ble
  14 bit0 clear  bne      18 bits 1 then 0   bge
  15 bit2 set    blt      17 bit1 set        bgt

13/14/15/17 are byte-identical apart from the bit index and the polarity.  All six
relations are present and each appears exactly once; that completeness is the check
that the reading is right, rather than the usage pattern -- which the item
explicitly warned against.

Operand order recorded because it is easy to reverse: LHS = (kind byte[1], word@+4),
RHS = (kind byte[0], word@+8).

Method note in the doc: the jump table at 0x822635FC holds THUNKS, and the handler
is the bl target inside each.  My first pass guessed handler addresses at a fixed
stride, landed mid-function, and produced a 20-line "difference" that was pure
misalignment.

isl.py names the ops; data/isl-stage02.txt is regenerated and every diff line pairs
exactly, only the op-name column changing (op10->cmp.i x5, op13->beq x4,
op14->bne x1).  data/isl-stage02-phase-ends.txt now shows the phase-3 poll loop
reading as one: unit_state(ADT308) -> op23 -> cmp.i -> beq back to 0xFEB4.

Left unnamed on purpose: op23 (0x82271C30) and op21 (0x82175C20).
2026-08-27 05:10:50 +00:00

52 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 — 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 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.

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 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 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 800802 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 800802's semantics needs the interpreter's command table, not the command objects.

Built-in 108 is deploy_squadron_exdeploy_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) 0100
mobile craft e001_Elan (73) 500, f003_ArrowHead (54) 400, f001_DeltaSaber_T (28) 400, f002_DeltaSaber_W (36) 600, e010_Attacker_S (54) 250400, e009_Phantom (16) 300 250600
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_Minbut 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 S18S23

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 S18S23 only one call per tutorial section
97 38 S18S23 only followed by start_coroutine (27/38)
98 0 never called anywhere
104 39 S18S23 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)
82banner_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
78banner_mission_complete 27 22 40 (27/27) wait_s (27/27)
81banner_objective_update 17 12 40 (17/17) wait_s (17/17)
77banner_mission_start 22 22 — one per stage play_bgm (15) end_coroutine (21)
135banner_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. 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 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. (2026-08-27) CONFIRMED from the handlers — see isl-branches. 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; op13op18 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): 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.

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

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 constructorsWRONG, withdrawn. Verified at 0x8226E86C0x8226E8E0: 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.