#!/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)))