Resolver table 0x82271D74 gives four kinds: 0 global[i], 1 immediate, 2 special[i] ([phase+164]/[phase+168]), 3 local[i] ([phase+20+i]). Byte[0] is the rvalue kind, byte[1] the lvalue kind, so the recurring instruction pair is argument staging -- values land in local[] at offsets 0,4,8,0xC and the next call consumes them. A built-in's arguments are not in its own instruction. Fixed a decode that would have been believed: immediates in set.f are DOUBLES carried as two words (op 1 stores with stfd). Reading the high word as a float gives 2.125 where the script means 3.0. isl.py now tracks staging and prints call arguments, so the run-up to the first END PHASE in Stage02 reads as builtin=64(0x42,2,1,9,1,-1) / 120 / 59(3) / 85(3) / 4(3) / 6. Three built-ins taking 3 just before the phase ends look like a wait-seconds family -- flagged as unconfirmed until the built-in table is read.
218 lines
8.8 KiB
Python
Executable File
218 lines
8.8 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'}
|
|
|
|
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, args=True):
|
|
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
|
|
if op == 19 and words:
|
|
extra = ' builtin=%d' % words[0]
|
|
if args and staged:
|
|
extra += '(' + ', '.join(
|
|
'%s' % (('0x%X' % v) if isinstance(v, int) else v)
|
|
for _, v in sorted(staged.items())) + ')'
|
|
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)))
|
|
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)))
|