Files
Sylpheed/docs/re/structures/isl-bytecode.md
Sylpheed RE agent d434aafc6b re: ISL operand kinds decoded; arguments are staged through local[]
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.
2026-08-25 12:28:04 +00:00

156 lines
6.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
## ✅ The four operand kinds, and how arguments are passed
Resolver table `0x82271D74`, four entries:
| kind | code | meaning |
|---|---|---|
| 0 | `lis 0x828E` / `bl 82454A40` / `lwzx` | **global[i]** — indexed global array |
| 1 | `mr r3,r31` | **immediate** — the operand word itself |
| 2 | `[phase+164]` if `i==0` else `[phase+168]` | **special[i]** — two scratch registers |
| 3 | `addi r3,r3,20` / `lwzx` | **local[i]**`[phase+20 + i]` |
Byte[0] is the rvalue's kind (operand word@+8) and byte[1] the lvalue's
(word@+4). That turns the recurring pair into something readable:
```
set.i k=01,02 <A> <V> special[A] = V (immediate -> special)
set.i k=02,03 <B> <0> local[B] = special[0]
```
— i.e. **argument staging**. Values land in `local[]` at byte offsets
0, 4, 8, 0xC…, and the following `call` consumes them; a built-in's arguments
are not in its own instruction. `isl.py` now tracks the staging and prints them.
⚠️ **Immediates in `set.f` are DOUBLES**, carried as two words — op 1 stores with
`stfd`. Reading only the high word as a *float* gives `2.125` where the script
means **3.0**, which is exactly the sort of plausible-but-wrong number that would
have been believed. The 16-byte `set.f` form is `high, low`.
With that, the run-up to the first `END PHASE` in Stage 02 reads:
```
0050F4 builtin=64(0x42, 0x2, 0x1, 0x9, 0x1, -1)
005160 builtin=120
005188 builtin=59(3)
0051B0 builtin=85(3)
0051D8 builtin=4(3)
0051E4 builtin=6 <-- end phase
0051F0 builtin=11
```
Three separate built-ins taking `3` immediately before the phase ends — a
plausible "wait 3 seconds" family, **unconfirmed** until the built-in table is
read.
## ❔ 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.
* The four-way opcode sharing (2/4/6/8 and 3/5/7/9) suggests the handler
re-reads the opcode to pick a comparison or a type; not yet read.
* The mission-level stream at `+0x24` of a `.ssb` — as opposed to this ISL
stream — is still only partly read.