From d434aafc6bcf890406b94cb8bd0e49e92df7e4d1 Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Tue, 25 Aug 2026 12:28:04 +0000 Subject: [PATCH] re: ISL operand kinds decoded; arguments are staged through local[] 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. --- docs/re/structures/isl-bytecode.md | 48 ++++++++++++++++++++++++-- tools/re-capture/isl.py | 55 +++++++++++++++++++++++++++++- tools/re-capture/launch_mission.sh | 23 ++++++++++--- 3 files changed, 119 insertions(+), 7 deletions(-) diff --git a/docs/re/structures/isl-bytecode.md b/docs/re/structures/isl-bytecode.md index c622e011..2f8be46e 100644 --- a/docs/re/structures/isl-bytecode.md +++ b/docs/re/structures/isl-bytecode.md @@ -97,6 +97,50 @@ Argument passing is visible in the disassembly: pairs of 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 @@ -105,7 +149,7 @@ immediately before several calls. * 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. -* Operand *kinds* (4 of them) are not decoded — the `k=01,02` / `k=02,03` pairs - are recorded literally. +* 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 `+0x24` of a `.ssb` — as opposed to this ISL stream — is still only partly read. diff --git a/tools/re-capture/isl.py b/tools/re-capture/isl.py index 02929da8..ed188ee4 100755 --- a/tools/re-capture/isl.py +++ b/tools/re-capture/isl.py @@ -34,6 +34,22 @@ and by every routine ending on a `ret`: byte[3] opcode | byte[2] length | byte[1],byte[0] operand kinds following words: operands (12 bytes is the common `call` form) +**Operand kinds** (resolver table `0x82271D74`, 4 entries): + + 0 global[i] lis 0x828E / bl 82454A40 / lwzx -- indexed global array + 1 immediate mr r3,r31 -- the operand word itself + 2 special[i] [phase+164] if i==0 else [phase+168] + 3 local[i] addi r3,r3,20 / lwzx -- [phase+20 + i] + +so the recurring pair + + set.i k=01,02 special[A] = V (immediate -> special) + set.i k=02,03 <0> local[B] = special[0] + +is **argument staging**: values land in `local[]` slots 0,4,8,0xC… and the next +`call` consumes them. That is why a built-in's arguments are not in its own +instruction. + A `call` carries the built-in id in word@+4 and a monotonically increasing STATEMENT ID in word@+8 (0x245, 0x248, 0x24A, ... across a routine) -- the value `sub_82272220` stores to `[phase+200]`, i.e. a source-position counter. @@ -49,6 +65,8 @@ import sys CODE_BASE_FIELD = 0x08 # .ssb header: code offset (0x24 in every file) # opcode -> (mnemonic, handler VA) from the jump table +KIND = {0: 'global', 1: 'imm', 2: 'special', 3: 'local'} + OPS = { 0: 'set.i', 1: 'set.f', 2: 'cmp.a', 4: 'cmp.a', 6: 'cmp.a', 8: 'cmp.a', @@ -63,8 +81,10 @@ def load(path): return open(path, 'rb').read() -def dis(b, off, count=40, code_base=0x24): +def dis(b, off, count=40, code_base=0x24, args=True): out = [] + staged = {} # local[] slot -> last value staged into it + pending = None # value most recently put in special[0] for _ in range(count): if off + 4 > len(b): break @@ -80,8 +100,41 @@ def dis(b, off, count=40, code_base=0x24): if off + i + 4 <= len(b): words.append(struct.unpack_from('>I', b, off + i)[0]) extra = '' + if op in (0, 1) and len(words) >= 2: + # op 0/1: lvalue = (kind byte[1], word@+4); rvalue = (kind byte[0], word@+8) + rv = words[1] + extra = ' %s[%d] = %s%s' % ( + KIND.get(k0, '?%d' % k0), words[0], + KIND.get(k1, '?%d' % k1), + ('' if k1 == 1 else '[%s]' % rv) if True else '') + if k1 == 1: + if op == 1: + lo = words[2] if len(words) > 2 else 0 + extra += ' %.6g' % struct.unpack( + '>d', struct.pack('>II', words[1], lo))[0] + else: + extra += ' 0x%X' % words[1] + # track the staging pattern so a call can show its arguments + if op in (0, 1) and len(words) >= 2: + if k0 == 2 and k1 == 1: + if op == 1: + # op 1 stores with stfd, so an immediate float operand is a + # DOUBLE carried as two words -- reading only the high word + # as a float gives 2.125 where the script means 3.0. + lo = words[2] if len(words) > 2 else 0 + pending = '%.6g' % struct.unpack( + '>d', struct.pack('>II', words[1], lo))[0] + else: + pending = words[1] + elif k0 == 3 and k1 == 2 and pending is not None: + staged[words[0]] = pending if op == 19 and words: extra = ' builtin=%d' % words[0] + if args and staged: + extra += '(' + ', '.join( + '%s' % (('0x%X' % v) if isinstance(v, int) else v) + for _, v in sorted(staged.items())) + ')' + staged = {} elif op == 12 and words: extra = ' -> code+0x%X (file 0x%X)' % (words[0], code_base + words[0]) out.append('%06X: %08X %-6s len=%-3d k=%02x,%02x %s%s' % ( diff --git a/tools/re-capture/launch_mission.sh b/tools/re-capture/launch_mission.sh index 7a7b2626..e0eecb58 100755 --- a/tools/re-capture/launch_mission.sh +++ b/tools/re-capture/launch_mission.sh @@ -80,15 +80,30 @@ tap A; sleep 8 # save list, slot 01 preselected # A fixed sleep here desynchronised the whole route: if the dialog had not # opened yet, `step up` moved the SAVE CURSOR instead of selecting YES, and the # blind `--tap A` below then oscillated the dialog for 300 s. See dialog_up.py. +# POLL for the dialog instead of taking ONE shot after a fixed 4 s. That single +# shot was still a race: if the dialog had not rendered yet it read as absent, +# and the retry then tapped A *into an open dialog*, which answers NO -- so a +# slow frame turned into the same open/close oscillation the blind tap caused. +# Measured 2026-08-25: one boot reached the ready room in 9 s, a later one still +# died at 300 s. Wait for the dialog to SETTLE, and only re-tap if it is really +# not there. +wait_dialog(){ # wait_dialog + local deadline=$(( SECONDS + ${1:-10} )) + while [ $SECONDS -lt $deadline ]; do + shot "lm-loaddialog.png" + python3 "$SD/dialog_up.py" "$SHOTS/lm-loaddialog.png" >/dev/null 2>&1 && return 0 + sleep 1 + done + return 1 +} opened=0 for _ in 1 2 3; do - tap A; sleep 4 - shot "lm-loaddialog.png" - if python3 "$SD/dialog_up.py" "$SHOTS/lm-loaddialog.png" >/dev/null 2>&1; then + tap A + if wait_dialog 10; then step up # cursor starts on NO tap A; opened=1; break fi - echo "--- load dialog not up yet, retrying" + echo "--- load dialog still not up after 10s, re-tapping" done [ $opened -eq 1 ] || { echo "LOAD DIALOG NEVER OPENED"; exit 5; } # NOT a fixed sleep. LOAD -> READY ROOM took longer than 28 s in both runs on