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.
6.5 KiB
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 (where the scripts live) and
mission-phase-advance (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 |
| 13–18 | 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 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.
- 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
+0x24of a.ssb— as opposed to this ISL stream — is still only partly read.