Three iterations circled this. Following the value rather than the
filename settles it in two hops:
1. SilphScriptPhase's ctor sub_8225FEF8 never touches r7 (zero
mentions). The BASE ctor sub_822700C0 keeps it:
or r26, r7, r7 -> stw r26, 152(r30)
2. sub_82261F70 indexes a stack table of string pointers with that
field. The table is contiguous at 0x820A8880, 20 bytes/entry:
index 0 = STAGE01_UNIT_MAX ... index 32 = STAGE33_UNIT_MAX,
then PLANE at 33.
So X+12 = [phase+152] = a 0-based stage index, N -> STAGE(N+1), and the
gate is explained rather than described: <= 32 is the array bound (33
entries) and != 16 is STAGE17, the one stage number with no .ssb.
This retracts my own "not adopted": the stage-index reading was 1-of-1
but coincidence-shaped two iterations ago; 0-based is now PROVED by
index 5 -> STAGE06.
Caught a false positive: 0x82272D88 lwz r11, 152(r11) in the built-in
switch is a virtual call to slot 38 -- r11 is the vptr, not the phase.
Also names a third class: sub_822700C0 stamps 0x820A8E44 =
SilphScriptPhaseBase, so the hierarchy is Base <- ScriptPhase and
Base <- Demo.
Docs only; artefacts byte-identical.
11 KiB
✅ How a built-in's result reaches a condition — the operand chain
The previous iteration named the branches and stated plainly that this was still missing: "naming the branch does not by itself give the clear condition — that needs the operand chain feeding each compare." It is read now, and it closes.
⚠️ First: the corpus already had half of it, and I had written the stale file
isl-bytecode.md — which owns the opcode table — already
named ops 21–24 (push.i / push.f / pop.i / pop.f) and ops 13–18 as the
six branches. My own isl-branches.md, written one iteration
earlier, said op23 and op21 were unread. The stale file was mine.
Verified rather than assumed, from the thunks and handlers:
| op | moves | verified by |
|---|---|---|
21 push.i |
[phase+168] → deque at phase+44 |
thunk: addi r4,r30,168 / addi r3,r30,44 |
22 push.f |
[phase+184] → deque at phase+64 |
thunk: addi r4,r30,184 / addi r3,r30,64 |
23 pop.i |
→ [phase+168] |
handler 0x82271C30, only r3-offset touched is 168 |
24 pop.f |
→ [phase+184] |
handler 0x82271CB8, only r3-offset touched is 184 |
With isl-bytecode.md's operand-kind table (special[0] = [phase+164],
special[1] = [phase+168]), pop.i lands in special[1].
✅ The built-in table is a thin dispatch layer over a vtable
Each of the 147 entries is a stub, not an implementation. The stub resolves
the local[] argument base and tail-calls a fixed slot of the ScriptPhase
vtable at [phase+0]:
82272DFC addi r3, r31, 20 ; local[] base
82272E00 bl 0x82454A40 ; resolve
82272E04 lwz r11, 0(r31) ; the vptr
82272E10 lwz r11, 184(r11) ; <- fixed slot, one per built-in
82272E14 b 0x822724F0 ; mtspr CTR / bcctrl, then return 0
Measured over all 147:
| count | |
|---|---|
dispatched through a ScriptPhase vtable slot |
112 |
write [phase+164] (special[0]) inline in the stub |
17 |
write [phase+184] inline |
0 |
Every named predicate is in the vtable group — unit_state 184,
unit_alive 188, hp_pct_test 64, dist_lt 56, unit_hp_pct 256,
is_engaged 252, group_ratio_pct 196, timer_elapsed 372,
player_gauge0/1_test 396/400 — which is the control: the split is not
arbitrary, it separates engine queries from script-local bookkeeping.
✅ The vtable is at 0x820A84BC, derived self-checkingly
Not guessed from a stride — the trap this corpus already paid for. Derived from a known implementation:
isl-bytecode.mddocuments built-in 39MARK_LAST_PHASEas[phase+300] = 2.- The function
0x8226B498is exactlyaddi r11,r0,2 ; stw r11,300(r3) ; blr. - It appears as a data word at exactly one address:
0x820A8570. - Built-in 39's stub uses slot 180 → base =
0x820A8570 − 180=0x820A84BC.
The check, which was not used in the derivation: built-in 40
mark_not_last ([phase+300] = 1) uses slot 176, so the base predicts
0x820A84A8… and slot 176 holds 0x8226B4A8, which is
addi r11,r0,1 ; stw r11,300(r3) ; blr — the = 1 stub sitting immediately
after the = 2 one. Predicted and confirmed.
Third, independent: the database's own vptr_writes lists
0x820A84BC as a vtable, written at 0x82261B80.
✅ And the result lands in special[0]
unit_state is slot 184 → 0x8226ADF0. It indexes [phase+324] — the unit
array isl-builtins.md already documents — by local[4], reads the record, and
writes its answer to [phase+164] = special[0] at both its normal exit
(0x8226AEC4) and its early exit (0x8226AF48).
That completes the chain, and the phase-3 poll loop now reads end to end:
call unit_state(ADT308) ; result -> special[0]
pop.i ; special[1] <- the pushed comparand
cmp.i special[0], special[1]
beq -> 0xFEB4 ; loop back while they are equal
A built-in's return value is special[0]; the comparand is popped into
special[1]; the compare and branch do the rest. That is the shape of every
clear condition in the corpus.
🟡 Not settled
- The other 111 vtable slots are not read. The base is now known, so each is
a lookup rather than a search — but knowing where
hp_pct_testlives is not the same as having read it. - Which comparand each site pushes. The loop above compares against whatever
push.iput on the deque; recovering that per site needs the push tracked through the decode, whichisl.pydoes not do. - The 35 non-vtable built-ins, and
op21/op22's generic deque helpers (0x82175C20,0x82274BA0), were not read — only their arguments. The vtable's length.✅ SETTLED (2026-08-27): 113 slots (0…112).
✅ (2026-08-27) The class is silph::SilphScriptPhase, the vtable is 113 slots, and there is a SECOND one
The MSVC RTTI locator sits at vtable[-1]. At 0x820A84B8 it is 0x8210DDC0,
whose type descriptor 0x8289CD18 spells .?AVSilphScriptPhase@silph@@ — so
the class is named, not inferred.
The vtable ends at 0x820A867C, 113 entries. The terminator is not "a
non-code word" — a scan on that rule runs straight past the end, which is why an
earlier pass reported "≥200 slots, no non-code word in the first 200". The real
terminator is the next class's COL: 0x820A8680 holds 0x8210DD74, and a
second vtable begins at 0x820A8684.
Cross-check, independent of the RTTI: the 147 built-in stubs between them reference 109 distinct slots, minimum 0, maximum 110 — every one inside 0…112. A wrong length would have stubs pointing past the end.
The second class: silph::SilphScriptPhaseDemo
0x8210DD74 → type descriptor 0x8289CCC0 = .?AVSilphScriptPhaseDemo@silph@@,
vtable at 0x820A8684, also 113 slots. It overrides 109 of the 113, and
the overwhelming majority point at a single shared stub, 0x8226C160. So the Demo
phase implements almost none of the script surface — it keeps a handful of real
methods (slot 15 → sub_82391BA8, slot 111 Update → sub_82275800,
slot 112 → sub_8237EF08) and stubs the rest.
🔑 That explains a loose end from isl-timers: the stopwatch
advance sub_822710D0 has two callers, sub_82263408 and sub_82275800.
They are the two classes' Update methods — same slot 111, base and derived.
And both "unread spawner callers" are placed
isl-coroutine-spawner.md lists sub_82273910 and sub_82264058 as unread
callers of the spawner. They are a matched pair:
| slot | class | |
|---|---|---|
sub_82264058 |
0 | SilphScriptPhase |
sub_82273910 |
0 | SilphScriptPhaseDemo |
Slot 0 in this ABI is the scalar deleting destructor, so these are the two classes' teardown paths — not two separate mysteries. 🟡 Why a destructor reaches the spawner is not read here.
✅ (2026-08-27) A script load builds TWO phase objects, not one
Each vtable constant is materialised at exactly two sites — its constructor
and its destructor — and vptr_writes agrees:
| class | ctor | dtor |
|---|---|---|
SilphScriptPhase |
sub_8225FEF8 (0x8225FF28) |
sub_82261B60 |
SilphScriptPhaseDemo |
sub_82260568 (0x82260660) |
sub_82260FF8 |
⚠️ A wrong turn worth recording. sub_8225FEF8's only caller is
sub_82260568, which looks exactly like "base ctor called from derived ctor" —
i.e. every phase is a Demo. That would make the built-in dispatch inert,
since the Demo vtable no-ops almost everything. Reading the constructor kills it:
sub_82260568 allocates and builds two separate objects.
822605C8 bl 0x82150EF8 ; allocate 10216 bytes <- object A
822605EC bl 0x8225FEF8 ; SilphScriptPhase ctor on A
…
82260658 bl 0x822700C0 ; the sibling initialiser on B
82260660 addi r11, r11, -31100 ; 0x820A8684
82260664 stw r11, 0(r29) ; Demo vptr onto B
82260690 stw r29, 8(r30)
Both get the same four arguments. They land at Y+4 (the main phase) and
Y+8 (the demo phase) — which is why the load path read earlier resolves the
ScriptPhase through Y+4. A constructor calling another class's constructor is
not proof of inheritance; check which object each vptr lands on.
📏 The allocation is 10216 bytes, consistent with the highest field this
corpus has read, [phase+10160].
✅ There are THREE classes, not two — the base is SilphScriptPhaseBase
sub_8225FEF8 opens by calling sub_822700C0, which stamps its own vptr
0x820A8E44; sub_8225FEF8 then overwrites it with 0x820A84BC. That is the
derived-constructor pattern, on one object. 0x820A8E44's COL gives
.?AVSilphScriptPhaseBase@silph@@, and it is 113 slots like the other two.
silph::SilphScriptPhaseBase 0x820A8E44 ctor sub_822700C0
├── silph::SilphScriptPhase 0x820A84BC ctor sub_8225FEF8
└── silph::SilphScriptPhaseDemo 0x820A8684 ctor inlined in sub_82260568
The Demo object is built by calling the base ctor and stamping the Demo vptr,
which is why sub_822700C0 appears on both paths — and why the two objects are
separate allocations rather than one.
🔑 The base ctor is also what stores the load's kind argument
(or r26, r7, r7 → stw r26, 152(r30)). That field is now identified as a
0-based stage index — see isl-condition-builtins.
✅ What the Demo vtable's overrides actually are: no-ops
The 109 overridden slots point at three shared stubs, and all three do nothing:
8226C160 stw r11(=0), 164(r3) ; special[0] = 0 ; return
822748B8 stw r10(=0), 164(r11) ; r3 = 0 ; same, and return code 0
822748A8 lfd f0, 25600(0x820B) ; stfd f0, 176 ; [phase+176] = 0.0
It keeps only four real methods: slot 15 → sub_82391BA8, slot 111 Update →
sub_82275800, slot 112 → sub_8237EF08, and its destructor.
🟡 What a "Demo" phase is — a reading, from the corpus's own vocabulary.
DEMO is one of the eight cutscene text families already decoded here
(MSG_DEMO_<id>_<page>_<line> — see
isl-message-dialogue-link and the movie↔subtitle
work), so "demo" means cutscene in this codebase. A second phase object,
built from the same script load, with every gameplay built-in stubbed to a no-op
and only Update alive, reads as the cutscene script runner. Stated as a
reading: nothing here shows it executing cutscene bytecode.
🔑 A new handle on the stalled X+12 question. The Demo class's single
construction site is inside sub_8225EC78 (the script load):
sub_82260568(Y, r26, [X+12]) at 0x8225ED5C — the kind is passed straight
into the constructor as its third argument, alongside [r30+12] again at
0x822605E8 and 0x82260654.