Resolving symbol-table-2 indices turns the bytecode into mission logic. At 0xF524 Stage02.ssb polls unit_state on ADN110, ADN111 and ADN112, updates each one's objective marker, then latches set_flag(8) -- exactly the trigger/predicate/set_flag/END_PHASE shape predicted from the disassembly, now observed in the mission's own code with names the roster tables already gave. The 12 END_PHASE sites are outro sequences (wait_cmds_drained / fade_sound(3) / builtin85(3) / wait_s(3) / END_PHASE / yield) -- the terminator, not the decision. Fixes a decode bug that hid every argument: the tracker only followed local[i] = special[0], but the common form is an immediate written straight into local[i] (k=01,03), so every unit predicate printed with NO arguments. The disassembly looked complete while being empty exactly where it mattered. Also records the live probe result: the phase mirror at [*(0x828F35F8)+236] stayed 0 for ~530s of actively-hunting flight, no advance observed -- which is what the static analysis predicts for phase 1, since ChangePhase only posts once the ordinal exceeds 1.
283 lines
12 KiB
Python
Executable File
283 lines
12 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 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)
|
|
|
|
**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
|
|
|
|
|
|
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)))
|