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.
230 lines
11 KiB
Markdown
230 lines
11 KiB
Markdown
# ✅ 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`](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`](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**:
|
||
|
||
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 `Update` → `sub_82275800`,
|
||
slot 112 → `sub_8237EF08`) and stubs the rest.
|
||
|
||
🔑 That explains a loose end from [isl-timers](isl-timers.md): 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](isl-condition-builtins.md).
|
||
|
||
## ✅ 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](isl-message-dialogue-link.md) 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`.
|