re: ISL bytecode encoding decoded; phase-end call sites located in Stage02

Read the encoding off the interpreter rather than guessing: instruction is a
big-endian u32 whose LOW byte is the opcode (25 of them, table 0x822635FC),
byte[2] is the instruction length -- every handler advances the pc by it -- and
bytes[0..1] are operand kinds. Op 12 is a jump whose operand is relative to the
code base [phase+232], which settles that offsets are code-base-relative for
this opcode. Op 19 is the built-in call: id in word@+4, and word@+8 is a
monotonically increasing STATEMENT id (0x245, 0x248, 0x24A, ...).

Confirmed by disassembling Stage02.ssb: the stream decodes cleanly from the code
base and routines terminate on ret exactly where expected.

Scanning the code region on the call encoding: 2846 call sites, 73 of the 147
built-ins used. The phase-control ones are located -- built-in 6 (end phase) at
12 sites, 62 at 3, 39 (mark last phase) at 8 -- so a phase has several exit
paths, as a mission with win and lose branches should.

New tool tools/re-capture/isl.py with --calls and --to (resync-into-target,
needed because instructions are variable-length so you cannot walk backwards).

Not settled: the 147 built-ins are uncharacterised, so this is structure without
meaning -- we can see THAT a phase ends, not WHAT was tested.
This commit is contained in:
Sylpheed RE agent
2026-08-25 12:21:44 +00:00
parent d2f3bcdbac
commit 0ba25a281f
3 changed files with 405 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
# Stage02.ssb — ISL built-in call sites
2846 call sites, 73 distinct built-ins
builtin 11 372 site(s)
builtin 69 255 site(s)
builtin 1 216 site(s)
builtin 64 213 site(s)
builtin 30 179 site(s)
builtin 20 167 site(s)
builtin 47 137 site(s)
builtin 15 127 site(s)
builtin 108 117 site(s)
builtin 18 92 site(s)
builtin 12 86 site(s)
builtin 5 83 site(s)
builtin 70 71 site(s)
builtin 92 63 site(s)
builtin 56 52 site(s)
builtin 103 39 site(s)
builtin 105 38 site(s)
builtin 4 34 site(s)
builtin 26 33 site(s)
builtin 91 27 site(s)
builtin 80 25 site(s)
builtin 58 24 site(s)
builtin 79 23 site(s)
builtin 63 21 site(s)
builtin 3 20 site(s)
builtin 116 18 site(s)
builtin 19 18 site(s)
builtin 29 17 site(s)
builtin 118 15 site(s)
builtin 124 15 site(s)
builtin 120 14 site(s)
builtin 8 12 site(s)
builtin 101 12 site(s)
builtin 100 12 site(s)
builtin 93 12 site(s)
builtin 59 12 site(s)
builtin 85 12 site(s)
builtin 6 12 site(s)
builtin 9 12 site(s)
builtin 95 9 site(s)
builtin 115 9 site(s)
builtin 7 9 site(s)
builtin 57 9 site(s)
builtin 48 8 site(s)
builtin 39 8 site(s)
builtin 82 8 site(s)
builtin 90 8 site(s)
builtin 17 6 site(s)
builtin 35 6 site(s)
builtin 10 6 site(s)
builtin 123 5 site(s)
builtin 40 4 site(s)
builtin 137 3 site(s)
builtin 75 3 site(s)
builtin 52 3 site(s)
builtin 38 3 site(s)
builtin 106 3 site(s)
builtin 76 3 site(s)
builtin 14 3 site(s)
builtin 119 3 site(s)
builtin 62 3 site(s)
builtin 81 2 site(s)
builtin 25 2 site(s)
builtin 135 2 site(s)
builtin 78 2 site(s)
builtin 83 2 site(s)
builtin 77 1 site(s)
builtin 127 1 site(s)
builtin 130 1 site(s)
builtin 89 1 site(s)
builtin 117 1 site(s)
builtin 99 1 site(s)
builtin 28 1 site(s)
## the phase-control built-ins, by site
builtin 6 END PHASE 12 site(s): 0x51e4 0x5828 0x6010 0x6260 0x19640 0x19934 0x1ac44 0x2b96c 0x2bfb0 0x2c1e0 0x2cf74 0x2d1dc
builtin 62 force-end 3 site(s): 0x1482c 0x249f0 0x34a10
builtin 39 mark LAST phase 8 site(s): 0x4dfc 0x52c8 0x60fc 0x19258 0x19724 0x2b584 0x2ba50 0x2c054
builtin 40 result=1 4 site(s): 0x5a10 0x1a270 0x2c578 0x2d06c
## disassembly into the first END PHASE
resync from 0x5058
005058: 0000080C jmp len=8 k=00,00 0000507C -> code+0x507C (file 0x50A0)
005060: 01020C00 set.i len=12 k=01,02 00000000 00000042
00506C: 02030C00 set.i len=12 k=02,03 00000000 00000000
005078: 01020C00 set.i len=12 k=01,02 00000000 00000002
005084: 02030C00 set.i len=12 k=02,03 00000004 00000000
005090: 01020C00 set.i len=12 k=01,02 00000000 00000001
00509C: 02030C00 set.i len=12 k=02,03 00000008 00000000
0050A8: 01020C00 set.i len=12 k=01,02 00000000 00000009
0050B4: 02030C00 set.i len=12 k=02,03 0000000C 00000000
0050C0: 01020C00 set.i len=12 k=01,02 00000000 00000001
0050CC: 02030C00 set.i len=12 k=02,03 00000010 00000000
0050D8: 01021001 set.f len=16 k=01,02 00000000 BFF00000 00000000
0050E8: 02030C01 set.f len=12 k=02,03 00000018 00000000
0050F4: 00000C13 call len=12 k=00,00 00000040 00000245 builtin=64
005100: 0000080C jmp len=8 k=00,00 0000507C -> code+0x507C (file 0x50A0)
005108: 0000080C jmp len=8 k=00,00 0000507C -> code+0x507C (file 0x50A0)
005110: 01020C0A op10 len=12 k=01,02 00000000 00000000
00511C: 0000080D op13 len=8 k=00,00 00004D84
005124: 01020C0A op10 len=12 k=01,02 00000000 00000001
005130: 0000080D op13 len=8 k=00,00 00004E2C
005138: 01020C0A op10 len=12 k=01,02 00000000 00000002
005144: 0000080D op13 len=8 k=00,00 00004ED4
00514C: 01020C0A op10 len=12 k=01,02 00000000 00000003
005158: 0000080D op13 len=8 k=00,00 00004F7C
005160: 00000C13 call len=12 k=00,00 00000078 00000248 builtin=120
00516C: 01021001 set.f len=16 k=01,02 00000000 40080000 00000000
00517C: 02030C01 set.f len=12 k=02,03 00000000 00000000
005188: 00000C13 call len=12 k=00,00 0000003B 0000024A builtin=59
005194: 01021001 set.f len=16 k=01,02 00000000 40080000 00000000
0051A4: 02030C01 set.f len=12 k=02,03 00000000 00000000
0051B0: 00000C13 call len=12 k=00,00 00000055 0000024B builtin=85
0051BC: 01021001 set.f len=16 k=01,02 00000000 40080000 00000000
0051CC: 02030C01 set.f len=12 k=02,03 00000000 00000000
0051D8: 00000C13 call len=12 k=00,00 00000004 0000024C builtin=4
0051E4: 00000C13 call len=12 k=00,00 00000006 0000024D builtin=6
0051F0: 00000C13 call len=12 k=00,00 0000000B 0000024E builtin=11
0051FC: 01030C00 set.i len=12 k=01,03 00000000 00000001
005208: 01030C00 set.i len=12 k=01,03 00000004 00000056
005214: 01021001 set.f len=16 k=01,02 00000000 00000000 00000000
005224: 02030C01 set.f len=12 k=02,03 00000008 00000000
005230: 00000C13 call len=12 k=00,00 00000014 00000252 builtin=20
00523C: 01020C0A op10 len=12 k=01,02 00000000 00000001
005248: 0000080E op14 len=8 k=00,00 0000575C
005250: 01020C00 set.i len=12 k=01,02 00000000 00000000
00525C: 02030C00 set.i len=12 k=02,03 00000000 00000000

View File

@@ -0,0 +1,111 @@
# The ISL script bytecode — instruction encoding decoded
Status: ✅ the encoding, the 25-opcode table and the call form, read off the
interpreter and confirmed by disassembling `Stage02.ssb`; 🟡 most opcode
*semantics* are named only by their handler; ❔ the 147 built-ins are not yet
characterised.
Follows [mission-script-ssb](mission-script-ssb.md) (where the scripts live) and
[mission-phase-advance](../mission-phase-advance.md) (why they matter).
Tool: `tools/re-capture/isl.py`.
## ✅ Encoding
`ScriptPhase::Update` (`sub_82263408`) fetches one **big-endian u32** per
instruction:
```
0x822635D4 lwz r11,0(r31) ; the instruction word
0x822635D8 clrlwi r4,r11,24 ; OPCODE = the LOW byte
0x822635DC cmplwi 0x18 ; 25 opcodes
0x822635FC jump table, 25 absolute VAs
```
Every handler advances the pc with `lbz r11,2(r31); add r31,r11,r31`, so:
| byte | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| meaning | operand kind A | operand kind B | **instruction length in bytes** | **opcode** |
Operand words follow. The common form is 12 bytes (opcode word + two operands).
Operand kinds go through resolvers with their own 4-entry table
(`sub_82271D40` for integers, `cmplwi 0x3`), so there are **4 operand kinds**.
## ✅ The opcode table
| op | handler | what the handler does |
|---|---|---|
| 0 | `82263660` | integer assign — resolve rvalue (`82271D40`, kind byte[0], word@+8), resolve lvalue (`82272030`, kind byte[1], word@+4), `stw` |
| 1 | `8226369C` | float assign — same shape with `82271F10`/`82272120` and `stfd` |
| 2,4,6,8 | `822636D0` | → `822713E8` (a compare/branch family; four opcodes share one handler) |
| 3,5,7,9 | `822636E4` | → `822714D0` (the sibling family) |
| 10 | `822636F8` | → `82271598` |
| 11 | `8226370C` | → `822716E0` |
| **12** | `82263720` | **JUMP**`r31 = [phase+232] + word@+4` |
| 1318 | `82263738`… | → `82271830`, `822718C8`, `82271960`, `822719F8`, `82271AC8`, `82271B60` |
| **19** | `822637B0` | **CALL BUILT-IN**`sub_82272220` |
| 20 | `82263874` | `li r29,1` then the suspend path — **yield / return** |
| 21 | `822637C4` | `sub_82175C20(phase+44, phase+168)` |
| 22 | `822637E4` | `sub_82274BA0(phase+64, phase+184)` |
| 23,24 | `82263804`… | → `82271C30`, `82271CB8` |
Handler return codes drive the outer loop at `0x82263828`: **0** continue,
**1** suspend, **2**/**3** other exits.
### ✅ Jump operands are code-base-relative
Op 12 adds its operand to `[phase+232]`, the code base — i.e. the `.ssb`
header's code offset (`0x24` in every file). That settles, for this opcode, the
question `mission-script-ssb.md` left open about whether offsets are file- or
code-base-relative.
### ✅ The call form, and a statement counter
`sub_82272220` reads the **built-in id from word@+4** (`cmplwi 0x92` → 147
built-ins, table `0x8227226C`) and stores **word@+8** into `[phase+200]`.
That second word turns out to be a **monotonically increasing statement id**
`0x245, 0x248, 0x24A, 0x24B, 0x24C, 0x24D, 0x24E, 0x252…` along a routine. It is
a source-position counter, presumably for the script's own error traces.
## ✅ It decodes — Stage 02
Disassembling from the code base runs cleanly, and routines terminate on `ret`
(op 20) exactly where expected. Data in `data/isl-stage02.txt`.
Scanning the whole code region for the call encoding:
```
2846 call sites, 73 distinct built-ins used (of 147)
most used: 11 (×372), 69 (×255), 1 (×216), 64 (×213), 30 (×179), 20 (×167)
```
**The phase-control built-ins, located:**
| built-in | meaning | sites in Stage02 |
|---|---|---|
| **6** | end phase (`[ScriptPhase+196] = 1`) | **12** |
| **62** | force-end, skipping the end event | **3** |
| **39** | mark last phase (`[phase+300] = 2`) | **8** |
| 40 | `[phase+300] = 1` | 4 |
Twelve end-phase sites across three phases — so a phase has several exit paths,
which is what a mission with win *and* lose branches should look like.
Argument passing is visible in the disassembly: pairs of
`set.i k=01,02 <0> <value>` / `set.i k=02,03 <slot> <0>` stage arguments into
slots, then `call`. Floats are staged the same way — e.g. `40080000` = 3.0
immediately before several calls.
## ❔ What this does not settle
* **The 147 built-ins are uncharacterised.** Without them the disassembly is
structure without meaning: we can see *that* a phase ends here, not *what was
tested*. That is the remaining step to per-phase clear conditions.
* Opcodes 211 and 1318 are named only by handler address. The four-way sharing
(2/4/6/8 and 3/5/7/9) suggests the handler re-reads the opcode to pick a
comparison or a type, but that is not yet read.
* Operand *kinds* (4 of them) are not decoded — the `k=01,02` / `k=02,03` pairs
are recorded literally.
* The mission-level stream at `+0x24` of a `.ssb` — as opposed to this ISL
stream — is still only partly read.

164
tools/re-capture/isl.py Executable file
View File

@@ -0,0 +1,164 @@
#!/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 <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
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)))