0x82448AA0 and 0x82448C50 are not strcmp. Both pass the name to 0x82447DF0 and use the result as a key: the first binary-searches a table of 16-byte records (x16 for the end, /16 for the count, >>1 for the midpoint), the second packs the hash into a three-word key and calls 0x8244E338. Decoded 0x82447DF0 from the disassembly as ((sum of extsb bytes) & 0xFF) << 24 | (rolling mod 0x00FFFFDF) -- i.e. tag_hash. tools/re-capture/unitgroup.py::tag_hash already documents itself as "a transcription of sub_82447DF0", so this was in the corpus; the useful part is that it identifies which hash the ARCHIVE uses. That exposed a defect in my previous sweep: it probed name_hash (0x00FFF9D7, lowercased) only, while the archive keys by tag_hash (0x00FFFFDF, case-sensitive). Re-ran with BOTH: 1380 names -> 2148 distinct hashes, 41 paks, 26443 records. STILL ZERO. The pak-record hypothesis is now refuted with the right hash rather than merely unsupported. Left open: the XEX's compressed/encrypted region (default.xex never decrypted here), plus two untried static threads -- the second pair of SCRIPTS/GP_SCRIPT references at 0x82262374 / 0x822622bc, in a different function, and tracing [r31+80] back to whoever opened the archive being name-tested.
12 KiB
What advances a mission phase: a compiled script VM, not data
Status: ✅ the mechanism and the phase counter, by direct disassembly; 🟡 class names inferred from destructor traces; 🔴 the script bytecode itself has not been located on disc.
This closes the ❔ left by mission-phase-membership. The exhaustive table sweep there found no trigger because there is none in the data: each phase ends when its own per-mission script says so.
Everything below is from /work/xenia-rs/sylpheed.db (1,865,751 decoded PPC
instructions). The retail default.xex is compressed/encrypted and was not
disassembled directly and not decrypted.
✅ The phase counter and its single writer
ScriptMission is the mission-level bytecode object. Layout: +4 ScriptPhase,
+8 ScriptDemo, +20 state, +24/+28 code base / pc, +40 the phase
ordinal.
| what | address | instruction |
|---|---|---|
| init to 1 | 0x822606B0 |
stw r11, 40(r30) (after li r11,1) |
| the only increment | 0x822609F8–0x82260A00 |
lwz r11,40(r30) / addi r11,r11,1 / stw r11,40(r30) |
Checked rather than assumed: across the whole state machine sub_82260710
(0x82260710–0x82260C18) there is exactly one stw to 40(rN). So
[ScriptMission+40] is the 1/2/3 field, and it has a single writer.
The guard on that increment, in state 1 ("phase running"):
r3 = [mission+4] ; the ScriptPhase
if ([phase+196] == 0) { phase->vtbl[111]() ; return } ; not finished -> keep running
if ([mission+52] != 0) return
if ([phase+300] == 2) post 994 ; state = 0 ; [phase+260] = 1 ; post 1014(0) ; MISSION OVER
else post 1014(1) ; state = 5 ; [mission+40] += 1 ; NEXT PHASE
So the immediate cause of a phase advance is [ScriptPhase+196] != 0.
✅ Who sets the "phase finished" flag — only the script can
[phase+196] has just two writers besides its initialisers: ScriptPhase
vtable slot 0 (0x82264058, "finish", fires the phase's end-event first if
[phase+236] != -1) and slot 1 (0x822640F8, immediate finish). The
ScriptPhase vtable is at 0x820A84BC, 113 slots.
Both are reached from exactly one place: the built-in command table of the
phase-script VM, sub_82272220, jump table at 0x8227226C, 147 entries.
Verified at the call sites:
| built-in | site | dispatch | effect |
|---|---|---|---|
| 6 | 0x82272540 |
lwz r11,0(r11) → vtbl slot 0 |
end this phase → +196 = 1 |
| 62 | 0x82272558 |
lwz r11,4(r11) → vtbl slot 1 |
force-end, skipping the end event |
| 39 | 0x82272ABC |
lwz r11,180(r11) → slot 45 |
[phase+300] = 2 — last phase, so the mission ends instead of advancing |
| 40 | 0x82272AD4 |
slot 44 | [phase+300] = 1 (semantics 🟡 unpinned) |
ScriptPhase::Update (sub_82263408, vtable slot 111) is a coroutine
scheduler: frame-wait counter at +160, thread list at +220, per-thread
double countdown and instruction pointer, 25 opcodes (jump table
0x822635FC). The engine→script edge is sub_8226D740, called every frame,
which pops trigger records from the queue at +272 and starts a script
coroutine at the code offset the trigger carries.
🔴 Refuted: all four candidate triggers
There is no kill counter, no timer, no trigger volume and no message event
compared against anything on the path to [mission+40]++. The single immediate
cause is [phase+196], which only built-ins 6/62 set, which only the phase's own
script invokes. Any kill-count or positional or timed condition is expressed
inside the script, via the trigger queue and the 32-entry float/flag register
files at [phase+88] and [phase+120].
That is why the static sweep found nothing — and why the three Stage 02 phases can each have a completely different clear condition.
✅ CScriptInterpreter::ChangePhase is the consumer, not the cause
sub_822FF330, the sole reference to ' CScriptInterpreter::ChangePhase( %d )'
(0x820AE623). It is opcode 995 in a 1024-slot command table built by
sub_822FE040; the dispatcher sub_822FE660 takes the opcode from bits 8..17
of [cmd+4] — which is why the posted word is 0xAB03E3BA (0x3E3 = 995).
r29 = *(0x828F35F8) ; mission-manager singleton
r31 = [cmd+16] ; the new phase number
if ([r29+236] != r31) { ... "--- CHANGE ASTEROIDS %d ---" ; "Clear all sounds" }
[r29+236] = r31 ; the runtime current-phase mirror
So [*(0x828F35F8) + 236] is a second 1/2/3 field — a mirror for the
renderer/audio, written after the fact. Useful as a probe target.
✅ Confirms the existing Phase_N finding
sub_8230D1F8 is the stage-config parser; its loop at 0x8230D4B8 sprintf's
"Phase_%1d" (0x8209F0F0) and looks the sub-record up — reading only the
map/background fields. The executable never consults Phase_N for a trigger.
Nothing anywhere parses Route_*_p<N>* names either; the only three such
literals in the image (0x820AEA38) are debug defaults, not a parser. So the
route-name phase map is a convention of the data, read by us, not by the game.
🔴 Not settled: where the script bytecode lives
The loader sub_8225EE20 matches section names MISSION1..MISSION33 and the
five MISSION_{START,END,UPDATE,FAILED,RESTART}_PRT, and sub_8225EC78 gates
on if (n == 16 || n > 32) return. But there is no GP_SCRIPT.pak on the
disc and grepping the whole extraction for MISSION_START_PRT returns
nothing — so the payload is hashed or compressed. Candidate homes, unchecked:
the seven executable .embsec_ sections in the XEX (~130 KB), or a hashed record
in hidden/MiscBin.pak.
Finding it would give the actual per-phase clear condition for every stage — which is the thing the port needs.
🔴 2026-08-27 — the .embsec_ candidate is REFUTED, and the pak route narrowed
Both named candidates were tested statically, from the flat-VA .pe and the
extracted paks. No emulator.
The seven .embsec_ sections hold PPC CODE, not bytecode. Parsed from the
section table (they begin at file offset 592, forty bytes apart):
| RVA | VirtSize |
|---|---|
0x84e800 |
39 340 |
0x858200 |
42 168 |
0x862800 |
7 436 |
0x864600 |
23 680 |
0x86a400 |
4 944 |
0x86b800 |
3 272 |
0x86c600 |
8 632 |
Total 129 472 bytes — matching this file's own "~130 KB" estimate, so these
are the right sections. Their contents are unambiguous machine code: six of the
seven begin 7d8802a6 (mflr r12) and every one contains the standard prologue
(9421ff?? stwu r1,-N(r1), fbc1fff0 std r30,-16(r1), 4b?????? bl).
Script bytecode for a 147-builtin VM does not look like this. ❌ Candidate
eliminated.
⚠️ A false lead recorded so it is not chased: strings reports .embsec_P,
which looks like a longer truncated name. It is not — the name field is exactly
.embsec_ and the P is byte 0x50 of the following VirtualSize (0x1350 =
4 944, the fifth section's size).
The MISSION_*_PRT names ARE in the executable. This file says grepping
"returns nothing", but that grep was over the disc extraction; all five
(START/END/UPDATE/FAILED/RESTART) are present in the image, in an
.rdata string table that reads:
"SCRIPTS" "GP_SCRIPT" "script load cancel\n" "MISSION1" … "MISSION33"
So the loader's vocabulary is intact and includes a GP_SCRIPT archive name.
But the scripts are not pak records under any obvious name. The paks are
name-hash addressed (unitgroup.name_hash), so candidate names can be probed
directly against every index rather than guessed at by eye: 616 distinct
hashes — MISSION1..33 and the five MISSION_*_PRT, each under the prefixes
SCRIPTS\, GP_SCRIPT\, scripts\, script\, SCRIPT\ and none, with
the suffixes .prt, .PRT, .scr, .bin and none — across all 35 paks in
dat/ and hidden/. Zero hits.
The pak route is now properly closed, and the loader's own code read
All 41 paks, not 35. The first sweep missed hidden/resource3d/ and
dat/movie/. Redone across every *.pak on the disc — 41 archives, 26 443
indexed records, 768 distinct name hashes — still zero hits. The scripts are
not a pak record under any of those names.
The loader compares names; it does not build a path. The three strings live at
known VAs (the .pe is a flat VA dump, so VA = 0x82000000 + offset):
| string | VA | referenced from |
|---|---|---|
SCRIPTS |
0x820a823c |
0x8225f1b8, 0x82262374 |
GP_SCRIPT |
0x820a8244 |
0x8225f168, 0x822622bc |
MISSION1 |
0x820a8264 |
0x8225eed8 |
All three references inside sub_8225EE20 disassemble to the same shape:
8225F160 addis r11, r0, 0x820B
8225F164 lwz r3, 80(r31) ; an object
8225F168 addi r4, r11, -32188 ; = 0x820A8244 "GP_SCRIPT"
8225F16C bl 0x82448AA0
...
8225F1B0 addis r11, r0, 0x820B
8225F1B4 lwz r3, 0(r25)
8225F1B8 addi r4, r11, -32196 ; = 0x820A823C "SCRIPTS"
8225F1BC bl 0x82448AA0
8225F1C0 cmpi cr6, 0, r3, 0 ; result tested as a comparison
Both call the same routine 0x82448AA0 with (object, string) and test the
result — so this is a name comparison against an already-open archive, not the
construction of dat\GP_SCRIPT.pak. That is consistent with GP_SCRIPT being
the name the engine expects an archive to report, which is why no file of that
name exists to be found.
The lookup is a HASHED directory search, and the pak route is now dead under both hashes
0x82448AA0 and 0x82448C50 are not strcmp. Both pass the name to
0x82447DF0 and use the result as a key:
0x82448AA0hashes, then binary-searches a table of 16-byte records —rlwinm r11,r11,4(×16) to find the end,srawi r11,r11,4(÷16) for the count,srawi r9,r11,1for the midpoint.0x82448C50hashes, packs the result into a three-word key atsp+80, and calls0x8244E338to find it.
0x82447DF0 is tag_hash — decoded from the disassembly as
((Σ extsb(byte)) & 0xFF) << 24 | (rolling mod 0x00FFFFDF), and
tools/re-capture/unitgroup.py::tag_hash already documents itself as "a
transcription of sub_82447DF0". So the archive keys records by the same
case-sensitive 0x00FFFFDF hash the IDXD tables use, not by name_hash
(0x00FFF9D7, lowercased).
❌ That was a real defect in the previous sweep — it probed name_hash only.
Re-run with both hashes: 1 380 candidate names → 2 148 distinct hashes,
against all 41 paks and 26 443 records. Still zero.
So the pak-record hypothesis is now refuted properly: right hash, right archive
set, right record count. The scripts are not in a pak under any of
MISSION1..33 / MISSION_*_PRT / SCRIPTS / GP_SCRIPT, with or without the
SCRIPTS\, GP_SCRIPT\, script\, SCRIPT\, scripts\ prefixes and the
.prt/.PRT/.scr/.bin/.dat suffixes.
❔ What is left. The XEX's compressed/encrypted region is now the leading
candidate — the retail default.xex has never been decrypted here. The untried
static thread is the second pair of references, 0x82262374 (SCRIPTS) and
0x822622bc (GP_SCRIPT), which live in a different function from
sub_8225EE20; and tracing [r31+80] back to whoever opened that archive would
name the container directly.
✅ Tooling correction: the .pe is NOT stale
A standing note said ".pe STALE vs sylpheed.db — DB only". Wrong, and the
reason is mundane: /work/Project Sylpheed … .pe is a flat VA image dump, so
file offset = VA − 0x82000000, not the PE section PointerToRawData.
Reading it through the section headers gives garbage for .text, which is what
"stale" was. Verified 7 functions byte-for-byte against the DB under the flat
rule: 7/7. It is usable for .rdata tables the DB does not decode.
⚠️ In sylpheed.db, instructions.raw is an integer, not a hex string —
decoding it as hex silently compares nothing and makes every check "fail". That
is how this correction nearly got recorded backwards.