From 56507e8c2d507a2a933a656a742a2ff3be3aa32b Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Tue, 25 Aug 2026 12:36:02 +0000 Subject: [PATCH] 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. --- docs/re/data/isl-stage02.txt | 113 +++++++++++++++++---------- docs/re/structures/isl-builtins.md | 120 +++++++++++++++++++++++++++++ tools/re-capture/isl.py | 26 ++++++- 3 files changed, 219 insertions(+), 40 deletions(-) create mode 100644 docs/re/structures/isl-builtins.md diff --git a/docs/re/data/isl-stage02.txt b/docs/re/data/isl-stage02.txt index 5b000a90..08e49dca 100644 --- a/docs/re/data/isl-stage02.txt +++ b/docs/re/data/isl-stage02.txt @@ -1,4 +1,4 @@ -# Stage02.ssb β€” ISL built-in call sites +# Stage02.ssb β€” ISL disassembly artefacts 2846 call sites, 73 distinct built-ins builtin 11 372 site(s) @@ -75,28 +75,63 @@ builtin 99 1 site(s) builtin 28 1 site(s) -## the phase-control built-ins, by site -builtin 6 END PHASE 12 site(s): 0x51e4 0x5828 0x6010 0x6260 0x19640 0x19934 0x1ac44 0x2b96c 0x2bfb0 0x2c1e0 0x2cf74 0x2d1dc -builtin 62 force-end 3 site(s): 0x1482c 0x249f0 0x34a10 -builtin 39 mark LAST phase 8 site(s): 0x4dfc 0x52c8 0x60fc 0x19258 0x19724 0x2b584 0x2ba50 0x2c054 -builtin 40 result=1 4 site(s): 0x5a10 0x1a270 0x2c578 0x2d06c +## phase-control sites +6 END_PHASE 12: 0x51e4 0x5828 0x6010 0x6260 0x19640 0x19934 0x1ac44 0x2b96c 0x2bfb0 0x2c1e0 0x2cf74 0x2d1dc +62 FORCE_END_PHASE 3: 0x1482c 0x249f0 0x34a10 +39 MARK_LAST_PHASE 8: 0x4dfc 0x52c8 0x60fc 0x19258 0x19724 0x2b584 0x2ba50 0x2c054 +40 mark_not_last 4: 0x5a10 0x1a270 0x2c578 0x2d06c -## disassembly into the first END PHASE +## condition vocabulary actually used by Stage 02 + 11 yield 372 + 69 unit_state 255 + 1 start_coroutine 216 + 30 objective_marker 179 + 20 hp_pct_test 167 + 18 dist_lt 92 + 5 await_label 83 + 70 unit_alive 71 + 56 unit_relation 52 + 4 wait_s 34 + 26 damage_unit 33 + 3 move_order 20 + 124 timer_stop 15 + 120 wait_cmds_drained 14 + 8 set_flag 12 + 100 push_trigger 12 + 93 clear_flag 12 + 59 fade_sound 12 + 6 END_PHASE 12 + 9 read_freg 12 + 95 unit_hp_pct 9 + 115 named_event 9 + 39 MARK_LAST_PHASE 8 + 90 camera_at_route 8 + 17 wait_frames 6 + 10 random 6 + 123 timer_resume 5 + 40 mark_not_last 4 + 137 wait_units_ready 3 + 52 play_stream 3 + 14 play_bgm 3 + 62 FORCE_END_PHASE 3 + 127 timer_set 1 + +## disassembly into the first END_PHASE resync from 0x5058 005058: 0000080C jmp len=8 k=00,00 0000507C -> code+0x507C (file 0x50A0) -005060: 01020C00 set.i len=12 k=01,02 00000000 00000042 -00506C: 02030C00 set.i len=12 k=02,03 00000000 00000000 -005078: 01020C00 set.i len=12 k=01,02 00000000 00000002 -005084: 02030C00 set.i len=12 k=02,03 00000004 00000000 -005090: 01020C00 set.i len=12 k=01,02 00000000 00000001 -00509C: 02030C00 set.i len=12 k=02,03 00000008 00000000 -0050A8: 01020C00 set.i len=12 k=01,02 00000000 00000009 -0050B4: 02030C00 set.i len=12 k=02,03 0000000C 00000000 -0050C0: 01020C00 set.i len=12 k=01,02 00000000 00000001 -0050CC: 02030C00 set.i len=12 k=02,03 00000010 00000000 -0050D8: 01021001 set.f len=16 k=01,02 00000000 BFF00000 00000000 -0050E8: 02030C01 set.f len=12 k=02,03 00000018 00000000 -0050F4: 00000C13 call len=12 k=00,00 00000040 00000245 builtin=64 +005060: 01020C00 set.i len=12 k=01,02 00000000 00000042 special[0] = imm 0x42 +00506C: 02030C00 set.i len=12 k=02,03 00000000 00000000 local[0] = special[0] +005078: 01020C00 set.i len=12 k=01,02 00000000 00000002 special[0] = imm 0x2 +005084: 02030C00 set.i len=12 k=02,03 00000004 00000000 local[4] = special[0] +005090: 01020C00 set.i len=12 k=01,02 00000000 00000001 special[0] = imm 0x1 +00509C: 02030C00 set.i len=12 k=02,03 00000008 00000000 local[8] = special[0] +0050A8: 01020C00 set.i len=12 k=01,02 00000000 00000009 special[0] = imm 0x9 +0050B4: 02030C00 set.i len=12 k=02,03 0000000C 00000000 local[12] = special[0] +0050C0: 01020C00 set.i len=12 k=01,02 00000000 00000001 special[0] = imm 0x1 +0050CC: 02030C00 set.i len=12 k=02,03 00000010 00000000 local[16] = special[0] +0050D8: 01021001 set.f len=16 k=01,02 00000000 BFF00000 00000000 special[0] = imm -1 +0050E8: 02030C01 set.f len=12 k=02,03 00000018 00000000 local[24] = special[0] +0050F4: 00000C13 call len=12 k=00,00 00000040 00000245 builtin64(0x42, 0x2, 0x1, 0x9, 0x1, -1) 005100: 0000080C jmp len=8 k=00,00 0000507C -> code+0x507C (file 0x50A0) 005108: 0000080C jmp len=8 k=00,00 0000507C -> code+0x507C (file 0x50A0) 005110: 01020C0A op10 len=12 k=01,02 00000000 00000000 @@ -107,24 +142,24 @@ resync from 0x5058 005144: 0000080D op13 len=8 k=00,00 00004ED4 00514C: 01020C0A op10 len=12 k=01,02 00000000 00000003 005158: 0000080D op13 len=8 k=00,00 00004F7C -005160: 00000C13 call len=12 k=00,00 00000078 00000248 builtin=120 -00516C: 01021001 set.f len=16 k=01,02 00000000 40080000 00000000 -00517C: 02030C01 set.f len=12 k=02,03 00000000 00000000 -005188: 00000C13 call len=12 k=00,00 0000003B 0000024A builtin=59 -005194: 01021001 set.f len=16 k=01,02 00000000 40080000 00000000 -0051A4: 02030C01 set.f len=12 k=02,03 00000000 00000000 -0051B0: 00000C13 call len=12 k=00,00 00000055 0000024B builtin=85 -0051BC: 01021001 set.f len=16 k=01,02 00000000 40080000 00000000 -0051CC: 02030C01 set.f len=12 k=02,03 00000000 00000000 -0051D8: 00000C13 call len=12 k=00,00 00000004 0000024C builtin=4 -0051E4: 00000C13 call len=12 k=00,00 00000006 0000024D builtin=6 -0051F0: 00000C13 call len=12 k=00,00 0000000B 0000024E builtin=11 -0051FC: 01030C00 set.i len=12 k=01,03 00000000 00000001 -005208: 01030C00 set.i len=12 k=01,03 00000004 00000056 -005214: 01021001 set.f len=16 k=01,02 00000000 00000000 00000000 -005224: 02030C01 set.f len=12 k=02,03 00000008 00000000 -005230: 00000C13 call len=12 k=00,00 00000014 00000252 builtin=20 +005160: 00000C13 call len=12 k=00,00 00000078 00000248 wait_cmds_drained +00516C: 01021001 set.f len=16 k=01,02 00000000 40080000 00000000 special[0] = imm 3 +00517C: 02030C01 set.f len=12 k=02,03 00000000 00000000 local[0] = special[0] +005188: 00000C13 call len=12 k=00,00 0000003B 0000024A fade_sound(3) +005194: 01021001 set.f len=16 k=01,02 00000000 40080000 00000000 special[0] = imm 3 +0051A4: 02030C01 set.f len=12 k=02,03 00000000 00000000 local[0] = special[0] +0051B0: 00000C13 call len=12 k=00,00 00000055 0000024B builtin85(3) +0051BC: 01021001 set.f len=16 k=01,02 00000000 40080000 00000000 special[0] = imm 3 +0051CC: 02030C01 set.f len=12 k=02,03 00000000 00000000 local[0] = special[0] +0051D8: 00000C13 call len=12 k=00,00 00000004 0000024C wait_s(3) +0051E4: 00000C13 call len=12 k=00,00 00000006 0000024D END_PHASE +0051F0: 00000C13 call len=12 k=00,00 0000000B 0000024E yield +0051FC: 01030C00 set.i len=12 k=01,03 00000000 00000001 local[0] = imm 0x1 +005208: 01030C00 set.i len=12 k=01,03 00000004 00000056 local[4] = imm 0x56 +005214: 01021001 set.f len=16 k=01,02 00000000 00000000 00000000 special[0] = imm 0 +005224: 02030C01 set.f len=12 k=02,03 00000008 00000000 local[8] = special[0] +005230: 00000C13 call len=12 k=00,00 00000014 00000252 hp_pct_test(0) 00523C: 01020C0A op10 len=12 k=01,02 00000000 00000001 005248: 0000080E op14 len=8 k=00,00 0000575C -005250: 01020C00 set.i len=12 k=01,02 00000000 00000000 -00525C: 02030C00 set.i len=12 k=02,03 00000000 00000000 +005250: 01020C00 set.i len=12 k=01,02 00000000 00000000 special[0] = imm 0x0 +00525C: 02030C00 set.i len=12 k=02,03 00000000 00000000 local[0] = special[0] diff --git a/docs/re/structures/isl-builtins.md b/docs/re/structures/isl-builtins.md new file mode 100644 index 00000000..0dbaec86 --- /dev/null +++ b/docs/re/structures/isl-builtins.md @@ -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, 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. + +## 🟑 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. diff --git a/tools/re-capture/isl.py b/tools/re-capture/isl.py index ed188ee4..c1140b78 100755 --- a/tools/re-capture/isl.py +++ b/tools/re-capture/isl.py @@ -67,6 +67,30 @@ CODE_BASE_FIELD = 0x08 # .ssb header: code offset (0x24 in every file) # opcode -> (mnemonic, handler VA) from the jump table KIND = {0: 'global', 1: 'imm', 2: 'special', 3: 'local'} +# Built-in names, from the 147-entry table at 0x8227226C. Only the ones whose +# handler was actually read are named; the rest print as a bare id rather than a +# guess. See docs/re/structures/isl-builtins.md. +BUILTIN = { + 1: 'start_coroutine', 2: 'deploy_squadron', 3: 'move_order', 4: 'wait_s', + 5: 'await_label', 6: 'END_PHASE', 8: 'set_flag', 9: 'read_freg', + 10: 'random', 11: 'yield', 13: 'play_se', 14: 'play_bgm', + 17: 'wait_frames', 18: 'dist_lt', 20: 'hp_pct_test', 24: 'squad_survival_pct', + 26: 'damage_unit', 30: 'objective_marker', 31: 'objective_marker_at_route', + 33: 'global_counter0', 34: 'global_counter1', 36: 'screen_fade', + 39: 'MARK_LAST_PHASE', 40: 'mark_not_last', 43: 'play_voice', + 45: 'play_voice_vol', 52: 'play_stream', 53: 'sound_busy', 54: 'stop_sound', + 56: 'unit_relation', 59: 'fade_sound', 62: 'FORCE_END_PHASE', + 69: 'unit_state', 70: 'unit_alive', 72: 'group_ratio_pct', 73: 'timer_start', + 74: 'timer_limit', 88: 'camera_at', 90: 'camera_at_route', + 93: 'clear_flag', 94: 'is_engaged', 95: 'unit_hp_pct', 100: 'push_trigger', + 102: 'prompt_yes_no', 109: 'set_unit_flags', 115: 'named_event', + 120: 'wait_cmds_drained', 123: 'timer_resume', 124: 'timer_stop', + 125: 'timer_reset', 126: 'timer_elapsed', 127: 'timer_set', + 132: 'player_gauge0_test', 133: 'player_gauge1_test', 134: 'player_byte', + 137: 'wait_units_ready', 139: 'fade_to_black_end', 142: 'deploy_and_wait', + 143: 'deploy_and_wait2', 145: 'random_rand', +} + OPS = { 0: 'set.i', 1: 'set.f', 2: 'cmp.a', 4: 'cmp.a', 6: 'cmp.a', 8: 'cmp.a', @@ -129,7 +153,7 @@ def dis(b, off, count=40, code_base=0x24, args=True): elif k0 == 3 and k1 == 2 and pending is not None: staged[words[0]] = pending if op == 19 and words: - extra = ' builtin=%d' % words[0] + extra = ' %s' % BUILTIN.get(words[0], 'builtin%d' % words[0]) if args and staged: extra += '(' + ', '.join( '%s' % (('0x%X' % v) if isinstance(v, int) else v)