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 17d1d2c999 re: X+12 is a 0-based STAGE INDEX -- proved by the game's own string table
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.
2026-08-27 11:05:50 +00:00

230 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ✅ 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 2124 (`push.i` / `push.f` / `pop.i` / `pop.f`) and ops 1318 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`.