This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Syplheed-Reborn/docs/re/structures/isl-builtin-dispatch.md
Sylpheed RE agent 4feab6c579 re: a script load builds TWO phase objects, main at Y+4 and demo at Y+8
sub_8225FEF8's only caller is sub_82260568, which looks like a derived
ctor calling its base -- i.e. every phase is a Demo, which would make
the built-in dispatch inert.  Reading the constructor refutes it: it
allocates 10216 bytes and runs SilphScriptPhase's ctor on object A,
then initialises a second object B and stamps the Demo vptr on it.
They land at Y+4 and Y+8, which is why the load path resolves the
ScriptPhase through Y+4.

The Demo class's 109 overrides all point at three no-op stubs; only
slot 15, slot 111 (Update = sub_82275800), slot 112 and the destructor
survive.  DEMO is the corpus's established cutscene text family, so the
cutscene-runner reading is recorded as a reading, not a result.

Also notes a new handle for the stalled X+12 question: the kind is
passed straight into sub_82260568 as its third argument.

Docs only; artefacts byte-identical.
2026-08-27 10:14:00 +00:00

9.7 KiB
Raw Blame History

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 2124 (push.i / push.f / pop.i / pop.f) and ops 1318 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 groupunit_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:

  1. isl-bytecode.md documents built-in 39 MARK_LAST_PHASE as [phase+300] = 2.
  2. The function 0x8226B498 is exactly addi r11,r0,2 ; stw r11,300(r3) ; blr.
  3. It appears as a data word at exactly one address: 0x820A8570.
  4. 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_test lives is not the same as having read it.
  • Which comparand each site pushes. The loop above compares against whatever push.i put on the deque; recovering that per site needs the push tracked through the decode, which isl.py does 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 Updatesub_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].

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 Updatesub_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.