#!/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 `[phase+232]`, which is **PER PHASE**, not the file's 0x24. The phase initialiser sub_82270DF8 writes it as 0x24 + the phase's entry from the mission-level stream, whose three `0x1883` records carry 0xC0 / 0x14A84 / 0x24B28 for Stage 02 -> bases 0xE4 / 0x14AA8 / 0x24B4C. MEASURED: with 0xE4, 525 of 525 phase-1 branch targets land on an instruction boundary; with 0x24, only 188. Using 0x24 for every phase -- which this tool did -- gives wrong targets in phases 2 and 3, and mostly-wrong ones in phase 1. 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) **Operand kinds** (resolver table `0x82271D74`, 4 entries): 0 global[i] lis 0x828E / bl 82454A40 / lwzx -- indexed global array 1 immediate mr r3,r31 -- the operand word itself 2 special[i] [phase+164] if i==0 else [phase+168] 3 local[i] addi r3,r3,20 / lwzx -- [phase+20 + i] so the recurring pair set.i k=01,02 special[A] = V (immediate -> special) set.i k=02,03 <0> local[B] = special[0] is **argument staging**: values land in `local[]` slots 0,4,8,0xC… and the next `call` consumes them. That is why a built-in's arguments are not in its own instruction. 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 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', 46: 'squadron_trace', 47: 'squadron_attack', 48: 'squadron_escort', 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', 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 symbols(b, which): """Parse a .ssb symbol table -> {index: (type, name)}. Built-in argument blobs carry INDICES into these: fields that index `[phase+244]` are symtab-1 (routes, messages, subobjectives) and fields that index `[phase+324]` are symtab-2 (the unit ids). Resolving them is what turns `unit_state(0x2b)` into `unit_state(ADN201)`. """ off = struct.unpack_from('>I', b, 0x0C if which == 1 else 0x10)[0] cnt = struct.unpack_from('>I', b, off)[0] base = off + 4 out = {} for i in range(cnt): o = struct.unpack_from('>I', b, base + 4 * i)[0] if o == 0: continue rp = base + o typ = struct.unpack_from('>I', b, rp)[0] e = b.index(b'\0', rp + 4) out[i] = (typ, b[rp + 4:e].decode('latin-1')) return out UNIT_ARG = {18, 20, 24, 26, 56, 69, 70, 94, 95, 105, 109} # unit idx at blob[4] # Built-ins 71 and 72 carry a SECOND unit index at blob[12] (handler 0x8226E568 # indexes [phase+324] with both), so their second operand printed as a bare # integer until this was noticed. 56 may be the same shape -- unverified. def dis(b, off, count=40, code_base=0x24, args=True, sym2=None): out = [] staged = {} # local[] slot -> last value staged into it pending = None # value most recently put in special[0] 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 in (0, 1) and len(words) >= 2: # op 0/1: lvalue = (kind byte[1], word@+4); rvalue = (kind byte[0], word@+8) rv = words[1] extra = ' %s[%d] = %s%s' % ( KIND.get(k0, '?%d' % k0), words[0], KIND.get(k1, '?%d' % k1), ('' if k1 == 1 else '[%s]' % rv) if True else '') if k1 == 1: if op == 1: lo = words[2] if len(words) > 2 else 0 extra += ' %.6g' % struct.unpack( '>d', struct.pack('>II', words[1], lo))[0] else: extra += ' 0x%X' % words[1] # track the staging pattern so a call can show its arguments if op in (0, 1) and len(words) >= 2: if k0 == 2 and k1 == 1: if op == 1: # op 1 stores with stfd, so an immediate float operand is a # DOUBLE carried as two words -- reading only the high word # as a float gives 2.125 where the script means 3.0. lo = words[2] if len(words) > 2 else 0 pending = '%.6g' % struct.unpack( '>d', struct.pack('>II', words[1], lo))[0] else: pending = words[1] elif k0 == 3 and k1 == 2 and pending is not None: staged[words[0]] = pending elif k0 == 3 and k1 == 1: # local[i] = immediate, DIRECTLY -- the common form. Missing this # made every unit predicate print with no arguments at all. if op == 1: lo = words[2] if len(words) > 2 else 0 staged[words[0]] = '%.6g' % struct.unpack( '>d', struct.pack('>II', words[1], lo))[0] else: staged[words[0]] = words[1] if op == 19 and words: extra = ' %s' % BUILTIN.get(words[0], 'builtin%d' % words[0]) if args and staged: parts = [] for slot, v in sorted(staged.items()): txt = ('0x%X' % v) if isinstance(v, int) else v if (sym2 and slot == 4 and words[0] in UNIT_ARG and isinstance(v, int) and v in sym2): txt = sym2[v][1] parts.append(txt) extra += '(' + ', '.join(parts) + ')' staged = {} 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 def phase_bases(b): """The per-phase code bases, from the mission-level stream's 0x1883 records.""" out = [] off = struct.unpack_from('>I', b, CODE_BASE_FIELD)[0] for o in range(0x24, 0x100, 4): if struct.unpack_from('>I', b, o)[0] == 0x1883: out.append(off + struct.unpack_from('>I', b, o + 4)[0]) return out 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, sym2=symbols(b, 2)))) 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)))