# 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> ` / `set.i k=02,03 <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 special[A] = V (immediate -> special) set.i k=02,03 <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.