Files
Sylpheed/docs/re/structures/isl-bytecode.md
Sylpheed RE agent 28a4b1ead1 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

7.8 KiB
Raw Blame History

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 822713E8integer compound assign: += -= *= /=
3,5,7,9 822636E4 822714D0float compound assign: fadd fsub fmul fdiv
10 822636F8 82271598integer compare, sets 3 condition bits
11 8226370C 822716E0float compare (fcmpu; NaN clears all three)
12 82263720 JUMPr31 = [phase+232] + word@+4
1318 82263738 conditional branchesje, jne, jl, jle, jg, jge
19 822637B0 CALL BUILT-INsub_82272220
20 82263874 li r29,1 then the suspend path — yield / return
21 822637C4 push.iphase+44 deque ← [phase+168] (special int 1)
22 822637E4 push.fphase+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 id0x245, 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.