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 aa5fe09d01
commit 7b445916bc
3 changed files with 405 additions and 0 deletions

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.