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.
This commit is contained in:
Sylpheed RE agent
2026-08-25 12:21:44 +00:00
parent aa5fe09d01
commit 7b445916bc
3 changed files with 405 additions and 0 deletions

164
tools/re-capture/isl.py Executable file
View File

@@ -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 <file.ssb> <offset> [count] offsets are FILE offsets
isl.py <file.ssb> --entry <off> follow from a code-base offset
isl.py <file.ssb> --calls every built-in call site + histogram
isl.py <file.ssb> --to <target> [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)))