All 25 opcodes now have meanings. Ops 2/4/6/8 are integer compound assignment (+= -= *= /=) and 3/5/7/9 the float versions; 10 and 11 are integer and float compare writing three condition bits; 13-18 are je/jne/jl/jle/jg/jge; 21-24 are push.i/push.f/pop.i/pop.f over deques at phase+44 and phase+64. The shared-handler question is answered: the dispatcher leaves the opcode in r4 and the shared thunks never overwrite it, so those helpers take an extra opcode argument and index a secondary table (0x82271448, 0x8227152C). CORRECTION to my own tool and note: the branch/jump base is [phase+232], which the phase initialiser sets to 0x24 + the phase's entry from the mission-level stream -- 0xE4 / 0x14AA8 / 0x24B4C for Stage 02's three phases, not the file's 0x24. Measured on phase 1: base 0xE4 puts 525 of 525 branch targets on an instruction boundary; base 0x24 manages 188. isl.py had been using 0x24 for every phase, so its jump targets were wrong throughout. Fixed via isl.phase_bases(). That also settles two things mission-script-ssb.md left open: offsets ARE code-base-relative, and 0x1883's operand IS a code pointer -- the earlier worry that some 'land on IEEE floats' was an artefact of adding the wrong base.
299 lines
13 KiB
Python
Executable File
299 lines
13 KiB
Python
Executable File
#!/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 <A> <V> special[A] = V (immediate -> special)
|
|
set.i k=02,03 <B> <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 <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
|
|
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',
|
|
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]
|
|
|
|
|
|
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)))
|