Files
Sylpheed/docs/re/structures/isl-bytecode.md
Sylpheed RE agent 7a0ee48c3f re: ISL opcodes decoded; the branch base is PER PHASE and isl.py was wrong
All 25 opcodes now have meanings. Ops 2/4/6/8 are integer compound assignment
(+= -= *= /=) and 3/5/7/9 the float versions; 10 and 11 are integer and float
compare writing three condition bits; 13-18 are je/jne/jl/jle/jg/jge; 21-24 are
push.i/push.f/pop.i/pop.f over deques at phase+44 and phase+64.

The shared-handler question is answered: the dispatcher leaves the opcode in r4
and the shared thunks never overwrite it, so those helpers take an extra opcode
argument and index a secondary table (0x82271448, 0x8227152C).

CORRECTION to my own tool and note: the branch/jump base is [phase+232], which
the phase initialiser sets to 0x24 + the phase's entry from the mission-level
stream -- 0xE4 / 0x14AA8 / 0x24B4C for Stage 02's three phases, not the file's
0x24. Measured on phase 1: base 0xE4 puts 525 of 525 branch targets on an
instruction boundary; base 0x24 manages 188. isl.py had been using 0x24 for
every phase, so its jump targets were wrong throughout. Fixed via
isl.phase_bases().

That also settles two things mission-script-ssb.md left open: offsets ARE
code-base-relative, and 0x1883's operand IS a code pointer -- the earlier worry
that some 'land on IEEE floats' was an artefact of adding the wrong base.
2026-08-25 18:52:09 +00:00

176 lines
7.8 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`**integer compound assign**: `+= -= *= /=` |
| **3,5,7,9** | `822636E4` | → `822714D0`**float compound assign**: `fadd fsub fmul fdiv` |
| **10** | `822636F8` | → `82271598`**integer compare**, sets 3 condition bits |
| **11** | `8226370C` | → `822716E0`**float compare** (`fcmpu`; NaN clears all three) |
| **12** | `82263720` | **JUMP**`r31 = [phase+232] + word@+4` |
| **1318** | `82263738`… | **conditional branches**`je`, `jne`, `jl`, `jle`, `jg`, `jge` |
| **19** | `822637B0` | **CALL BUILT-IN**`sub_82272220` |
| 20 | `82263874` | `li r29,1` then the suspend path — **yield / return** |
| **21** | `822637C4` | **`push.i`** — `phase+44` deque ← `[phase+168]` (special int 1) |
| **22** | `822637E4` | **`push.f`** — `phase+64` deque ← `[phase+184]` (special float 1) |
| **23,24** | `82263804`… | **`pop.i` / `pop.f`** — back into `[+168]` / `[+184]` |
Handler return codes drive the outer loop at `0x82263828`: **0** continue,
**1** suspend, **2**/**3** other exits.
### 🔴 CORRECTED: the branch base is PER PHASE, not the file's `0x24`
Op 12 adds its operand to `[phase+232]` — and **that is not `0x24`**. The phase
initialiser `sub_82270DF8` writes it as `0x24 + the phase's entry from the
mission-level stream`, whose three `0x1883` records carry `0xC0`, `0x14A84`,
`0x24B28` for Stage 02 → bases **`0xE4`, `0x14AA8`, `0x24B4C`**, one per phase.
Measured on Stage 02's phase-1 segment:
| base | branch targets landing on an instruction boundary |
|---|---|
| `0xE4` | **525 / 525** |
| `0x24` | 188 / 525 |
So the earlier "the code base is the header's `0x24`" was wrong, and
`tools/re-capture/isl.py` printed wrong jump targets for every phase — badly for
phases 2 and 3, and mostly wrong even in phase 1. Fixed: `isl.phase_bases()`
returns the three bases, and branch ops are annotated with the base in use.
This also settles two things `mission-script-ssb.md` left open: offsets **are**
code-base-relative, and `0x1883`'s operand **is** a code pointer (the earlier
worry that two of them "land on IEEE floats" was an artefact of adding the wrong
base).
### ✅ 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.
***How the shared handlers disambiguate — answered.** The dispatcher leaves
the opcode in `r4`, and the two shared thunks never overwrite it, so the
helpers are `f(phase, opcode, frame, &pc)` where every non-shared helper is
`f(phase, frame, &pc)`. Each helper then subtracts its base opcode and indexes
a **secondary** table (`0x82271448` for ops 28, `0x8227152C` for 39).
* The mission-level stream at `+0x24` of a `.ssb` — as opposed to this ISL
stream — is still only partly read.