From 7b445916bcae7fc2246bee1a8977fe50bb23f679 Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Tue, 25 Aug 2026 12:21:44 +0000 Subject: [PATCH] re: ISL bytecode encoding decoded; phase-end call sites located in Stage02 Read the encoding off the interpreter rather than guessing: instruction is a big-endian u32 whose LOW byte is the opcode (25 of them, table 0x822635FC), byte[2] is the instruction length -- every handler advances the pc by it -- and bytes[0..1] are operand kinds. Op 12 is a jump whose operand is relative to the code base [phase+232], which settles that offsets are code-base-relative for this opcode. Op 19 is the built-in call: id in word@+4, and word@+8 is a monotonically increasing STATEMENT id (0x245, 0x248, 0x24A, ...). Confirmed by disassembling Stage02.ssb: the stream decodes cleanly from the code base and routines terminate on ret exactly where expected. Scanning the code region on the call encoding: 2846 call sites, 73 of the 147 built-ins used. The phase-control ones are located -- built-in 6 (end phase) at 12 sites, 62 at 3, 39 (mark last phase) at 8 -- so a phase has several exit paths, as a mission with win and lose branches should. New tool tools/re-capture/isl.py with --calls and --to (resync-into-target, needed because instructions are variable-length so you cannot walk backwards). Not settled: the 147 built-ins are uncharacterised, so this is structure without meaning -- we can see THAT a phase ends, not WHAT was tested. --- docs/re/data/isl-stage02.txt | 130 +++++++++++++++++++++++ docs/re/structures/isl-bytecode.md | 111 +++++++++++++++++++ tools/re-capture/isl.py | 164 +++++++++++++++++++++++++++++ 3 files changed, 405 insertions(+) create mode 100644 docs/re/data/isl-stage02.txt create mode 100644 docs/re/structures/isl-bytecode.md create mode 100755 tools/re-capture/isl.py diff --git a/docs/re/data/isl-stage02.txt b/docs/re/data/isl-stage02.txt new file mode 100644 index 00000000..5b000a90 --- /dev/null +++ b/docs/re/data/isl-stage02.txt @@ -0,0 +1,130 @@ +# Stage02.ssb — ISL built-in call sites + +2846 call sites, 73 distinct built-ins + builtin 11 372 site(s) + builtin 69 255 site(s) + builtin 1 216 site(s) + builtin 64 213 site(s) + builtin 30 179 site(s) + builtin 20 167 site(s) + builtin 47 137 site(s) + builtin 15 127 site(s) + builtin 108 117 site(s) + builtin 18 92 site(s) + builtin 12 86 site(s) + builtin 5 83 site(s) + builtin 70 71 site(s) + builtin 92 63 site(s) + builtin 56 52 site(s) + builtin 103 39 site(s) + builtin 105 38 site(s) + builtin 4 34 site(s) + builtin 26 33 site(s) + builtin 91 27 site(s) + builtin 80 25 site(s) + builtin 58 24 site(s) + builtin 79 23 site(s) + builtin 63 21 site(s) + builtin 3 20 site(s) + builtin 116 18 site(s) + builtin 19 18 site(s) + builtin 29 17 site(s) + builtin 118 15 site(s) + builtin 124 15 site(s) + builtin 120 14 site(s) + builtin 8 12 site(s) + builtin 101 12 site(s) + builtin 100 12 site(s) + builtin 93 12 site(s) + builtin 59 12 site(s) + builtin 85 12 site(s) + builtin 6 12 site(s) + builtin 9 12 site(s) + builtin 95 9 site(s) + builtin 115 9 site(s) + builtin 7 9 site(s) + builtin 57 9 site(s) + builtin 48 8 site(s) + builtin 39 8 site(s) + builtin 82 8 site(s) + builtin 90 8 site(s) + builtin 17 6 site(s) + builtin 35 6 site(s) + builtin 10 6 site(s) + builtin 123 5 site(s) + builtin 40 4 site(s) + builtin 137 3 site(s) + builtin 75 3 site(s) + builtin 52 3 site(s) + builtin 38 3 site(s) + builtin 106 3 site(s) + builtin 76 3 site(s) + builtin 14 3 site(s) + builtin 119 3 site(s) + builtin 62 3 site(s) + builtin 81 2 site(s) + builtin 25 2 site(s) + builtin 135 2 site(s) + builtin 78 2 site(s) + builtin 83 2 site(s) + builtin 77 1 site(s) + builtin 127 1 site(s) + builtin 130 1 site(s) + builtin 89 1 site(s) + builtin 117 1 site(s) + 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 + +## 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 +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 +00511C: 0000080D op13 len=8 k=00,00 00004D84 +005124: 01020C0A op10 len=12 k=01,02 00000000 00000001 +005130: 0000080D op13 len=8 k=00,00 00004E2C +005138: 01020C0A op10 len=12 k=01,02 00000000 00000002 +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 +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 diff --git a/docs/re/structures/isl-bytecode.md b/docs/re/structures/isl-bytecode.md new file mode 100644 index 00000000..c622e011 --- /dev/null +++ b/docs/re/structures/isl-bytecode.md @@ -0,0 +1,111 @@ +# The ISL script bytecode — instruction encoding decoded + +Status: ✅ the encoding, the 25-opcode table and the call form, read off the +interpreter and confirmed by disassembling `Stage02.ssb`; 🟡 most opcode +*semantics* are named only by their handler; ❔ the 147 built-ins are not yet +characterised. + +Follows [mission-script-ssb](mission-script-ssb.md) (where the scripts live) and +[mission-phase-advance](../mission-phase-advance.md) (why they matter). +Tool: `tools/re-capture/isl.py`. + +## ✅ Encoding + +`ScriptPhase::Update` (`sub_82263408`) fetches one **big-endian u32** per +instruction: + +``` +0x822635D4 lwz r11,0(r31) ; the instruction word +0x822635D8 clrlwi r4,r11,24 ; OPCODE = the LOW byte +0x822635DC cmplwi 0x18 ; 25 opcodes +0x822635FC jump table, 25 absolute VAs +``` + +Every handler advances the pc with `lbz r11,2(r31); add r31,r11,r31`, so: + +| byte | 0 | 1 | 2 | 3 | +|---|---|---|---|---| +| meaning | operand kind A | operand kind B | **instruction length in bytes** | **opcode** | + +Operand words follow. The common form is 12 bytes (opcode word + two operands). +Operand kinds go through resolvers with their own 4-entry table +(`sub_82271D40` for integers, `cmplwi 0x3`), so there are **4 operand kinds**. + +## ✅ The opcode table + +| op | handler | what the handler does | +|---|---|---| +| 0 | `82263660` | integer assign — resolve rvalue (`82271D40`, kind byte[0], word@+8), resolve lvalue (`82272030`, kind byte[1], word@+4), `stw` | +| 1 | `8226369C` | float assign — same shape with `82271F10`/`82272120` and `stfd` | +| 2,4,6,8 | `822636D0` | → `822713E8` (a compare/branch family; four opcodes share one handler) | +| 3,5,7,9 | `822636E4` | → `822714D0` (the sibling family) | +| 10 | `822636F8` | → `82271598` | +| 11 | `8226370C` | → `822716E0` | +| **12** | `82263720` | **JUMP** — `r31 = [phase+232] + word@+4` | +| 13–18 | `82263738`… | → `82271830`, `822718C8`, `82271960`, `822719F8`, `82271AC8`, `82271B60` | +| **19** | `822637B0` | **CALL BUILT-IN** → `sub_82272220` | +| 20 | `82263874` | `li r29,1` then the suspend path — **yield / return** | +| 21 | `822637C4` | `sub_82175C20(phase+44, phase+168)` | +| 22 | `822637E4` | `sub_82274BA0(phase+64, phase+184)` | +| 23,24 | `82263804`… | → `82271C30`, `82271CB8` | + +Handler return codes drive the outer loop at `0x82263828`: **0** continue, +**1** suspend, **2**/**3** other exits. + +### ✅ Jump operands are code-base-relative + +Op 12 adds its operand to `[phase+232]`, the code base — i.e. the `.ssb` +header's code offset (`0x24` in every file). That settles, for this opcode, the +question `mission-script-ssb.md` left open about whether offsets are file- or +code-base-relative. + +### ✅ The call form, and a statement counter + +`sub_82272220` reads the **built-in id from word@+4** (`cmplwi 0x92` → 147 +built-ins, table `0x8227226C`) and stores **word@+8** into `[phase+200]`. + +That second word turns out to be a **monotonically increasing statement id** — +`0x245, 0x248, 0x24A, 0x24B, 0x24C, 0x24D, 0x24E, 0x252…` along a routine. It is +a source-position counter, presumably for the script's own error traces. + +## ✅ It decodes — Stage 02 + +Disassembling from the code base runs cleanly, and routines terminate on `ret` +(op 20) exactly where expected. Data in `data/isl-stage02.txt`. + +Scanning the whole code region for the call encoding: + +``` +2846 call sites, 73 distinct built-ins used (of 147) +most used: 11 (×372), 69 (×255), 1 (×216), 64 (×213), 30 (×179), 20 (×167) +``` + +**The phase-control built-ins, located:** + +| built-in | meaning | sites in Stage02 | +|---|---|---| +| **6** | end phase (`[ScriptPhase+196] = 1`) | **12** | +| **62** | force-end, skipping the end event | **3** | +| **39** | mark last phase (`[phase+300] = 2`) | **8** | +| 40 | `[phase+300] = 1` | 4 | + +Twelve end-phase sites across three phases — so a phase has several exit paths, +which is what a mission with win *and* lose branches should look like. + +Argument passing is visible in the disassembly: pairs of +`set.i k=01,02 <0> ` / `set.i k=02,03 <0>` stage arguments into +slots, then `call`. Floats are staged the same way — e.g. `40080000` = 3.0 +immediately before several calls. + +## ❔ What this does not settle + +* **The 147 built-ins are uncharacterised.** Without them the disassembly is + structure without meaning: we can see *that* a phase ends here, not *what was + tested*. That is the remaining step to per-phase clear conditions. +* Opcodes 2–11 and 13–18 are named only by handler address. The four-way sharing + (2/4/6/8 and 3/5/7/9) suggests the handler re-reads the opcode to pick a + comparison or a type, but that is not yet read. +* Operand *kinds* (4 of them) are not decoded — the `k=01,02` / `k=02,03` pairs + are recorded literally. +* The mission-level stream at `+0x24` of a `.ssb` — as opposed to this ISL + stream — is still only partly read. diff --git a/tools/re-capture/isl.py b/tools/re-capture/isl.py new file mode 100755 index 00000000..02929da8 --- /dev/null +++ b/tools/re-capture/isl.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Disassemble the ISL script bytecode inside a `Stage\\StageNN.ssb`. + +The VM is `ScriptPhase::Update` (`sub_82263408`). Everything below is read off +the dispatcher and its 25 handlers, not guessed: + + 0x822635D4 lwz r11,0(r31) ; instruction = one big-endian u32 + 0x822635D8 clrlwi r4,r11,24 ; OPCODE = the LOW byte (= byte[3]) + 0x822635DC cmplwi 0x18 ; 25 opcodes + 0x822635FC jump table (25 absolute VAs) + +Each handler advances the pc by `lbz r11,2(r31); add r31,r11,r31`, so +**byte[2] is the instruction length in bytes**, and bytes [0]/[1] are operand +kind selectors passed to the operand resolvers as `r4`. + + op 0 `lbz 0` + word@+8 -> resolve ; `lbz 1` + word@+4 -> lvalue ; stw + (integer assignment; resolvers 0x82271D40 / 0x82272030) + op 1 same shape with fmr/stfd (float assignment; 0x82271F10/0x82272120) + op 12 JUMP: r31 = [phase+232] + word@+4 + -> jump operands are **relative to the code base**, which is the .ssb + header's code offset (0x24). That settles the "file- or + code-base-relative" question for this opcode at least. + op 19 CALL BUILT-IN: `sub_82272220` reads the id from **word@+4** + (`lwz r11,4(r28); cmplwi 0x92` -> 147 built-ins, table 0x8227226C) + and word@+8 into [phase+200]. + op 20 sets r29=1 and takes the suspend path -> yield/return. + +Handler return codes drive the outer loop: 0 = continue, 1 = suspend, +2/3 = other exits (`0x82263828`). + +Instruction layout, confirmed by the decode reading cleanly from the code base +and by every routine ending on a `ret`: + + byte[3] opcode | byte[2] length | byte[1],byte[0] operand kinds + following words: operands (12 bytes is the common `call` form) + +A `call` carries the built-in id in word@+4 and a monotonically increasing +STATEMENT ID in word@+8 (0x245, 0x248, 0x24A, ... across a routine) -- the value +`sub_82272220` stores to `[phase+200]`, i.e. a source-position counter. + +Usage: isl.py [count] offsets are FILE offsets + isl.py --entry follow from a code-base offset + isl.py --calls every built-in call site + histogram + isl.py --to [n] resync and disassemble INTO target +""" +import struct +import sys + +CODE_BASE_FIELD = 0x08 # .ssb header: code offset (0x24 in every file) + +# opcode -> (mnemonic, handler VA) from the jump table +OPS = { + 0: 'set.i', 1: 'set.f', + 2: 'cmp.a', 4: 'cmp.a', 6: 'cmp.a', 8: 'cmp.a', + 3: 'cmp.b', 5: 'cmp.b', 7: 'cmp.b', 9: 'cmp.b', + 10: 'op10', 11: 'op11', 12: 'jmp', 13: 'op13', 14: 'op14', 15: 'op15', + 16: 'op16', 17: 'op17', 18: 'op18', 19: 'call', 20: 'ret', + 21: 'op21', 22: 'op22', 23: 'op23', 24: 'op24', +} + + +def load(path): + return open(path, 'rb').read() + + +def dis(b, off, count=40, code_base=0x24): + out = [] + for _ in range(count): + if off + 4 > len(b): + break + w = struct.unpack_from('>I', b, off)[0] + op = w & 0xFF + ln = (w >> 8) & 0xFF + k1 = (w >> 24) & 0xFF + k0 = (w >> 16) & 0xFF + name = OPS.get(op, 'op%d?' % op) + words = [] + n = max(ln, 4) + for i in range(4, n, 4): + if off + i + 4 <= len(b): + words.append(struct.unpack_from('>I', b, off + i)[0]) + extra = '' + if op == 19 and words: + extra = ' builtin=%d' % words[0] + elif op == 12 and words: + extra = ' -> code+0x%X (file 0x%X)' % (words[0], code_base + words[0]) + out.append('%06X: %08X %-6s len=%-3d k=%02x,%02x %s%s' % ( + off, w, name, ln, k1, k0, + ' '.join('%08X' % x for x in words), extra)) + if ln == 0: + out.append(' (length 0 -- stopping)') + break + off += ln + if op == 20: + break + return out + + +def call_sites(b): + """Every `call` in the code region. Scans on the encoding, not by decoding, + so a bad length somewhere cannot hide the rest of the file.""" + code_end = struct.unpack_from('>I', b, 0x0C)[0] # symtab1 = end of code + out = [] + off = struct.unpack_from('>I', b, CODE_BASE_FIELD)[0] + while off + 12 <= code_end: + w = struct.unpack_from('>I', b, off)[0] + if (w & 0xFF) == 0x13 and ((w >> 8) & 0xFF) == 12 and (w >> 16) == 0: + bid = struct.unpack_from('>I', b, off + 4)[0] + if bid <= 0x92: + out.append((off, bid, struct.unpack_from('>I', b, off + 8)[0])) + off += 4 + return out + + +def resync(b, target, back=400): + """Find a start from which linear decode lands exactly on `target`. + + Instructions are variable-length, so you cannot simply walk backwards; but a + wrong start almost always desynchronises into an invalid length, so trying + every 4-byte start in a window and keeping the one that hits the target + exactly is reliable in practice. + """ + for start in range(max(0, target - back), target, 4): + off = start + for _ in range(300): + if off >= target or off + 4 > len(b): + break + ln = (struct.unpack_from('>I', b, off)[0] >> 8) & 0xFF + if ln == 0 or ln % 2: + off = -1 + break + off += ln + if off == target: + return start + return None + + +if __name__ == '__main__': + b = load(sys.argv[1]) + if sys.argv[2:3] == ['--calls']: + import collections + cs = call_sites(b) + h = collections.Counter(bid for _, bid, _ in cs) + print('%d call sites, %d distinct built-ins' % (len(cs), len(h))) + for bid, n in h.most_common(): + print(' builtin %-4d %5d site(s)' % (bid, n)) + sys.exit(0) + if sys.argv[2:3] == ['--to']: + t = int(sys.argv[3], 0) + st = resync(b, t) + if st is None: + print('could not resync into 0x%X' % t); sys.exit(1) + print('resync from 0x%X' % st) + print('\n'.join(dis(b, st, int(sys.argv[4], 0) if len(sys.argv) > 4 else 40))) + sys.exit(0) + code_base = struct.unpack_from('>I', b, CODE_BASE_FIELD)[0] + a = sys.argv[2] + if a == '--entry': + off = code_base + int(sys.argv[3], 0) + else: + off = int(a, 0) + cnt = int(sys.argv[4], 0) if len(sys.argv) > 4 else 40 + print('code base 0x%X, disassembling from 0x%X' % (code_base, off)) + print('\n'.join(dis(b, off, cnt, code_base)))