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.
176 lines
7.8 KiB
Markdown
176 lines
7.8 KiB
Markdown
# 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` |
|
||
| **13–18** | `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 2–11 and 13–18 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 2–8, `0x8227152C` for 3–9).
|
||
* The mission-level stream at `+0x24` of a `.ssb` — as opposed to this ISL
|
||
stream — is still only partly read.
|