Files
Sylpheed/docs/re/structures/isl-trigger-node.md
Sylpheed RE agent 1c21a00f7c re: two trigger appenders -- built-in 19's node carries the handler routine
Resolves the puzzle the previous commit left open: payload+28 is a 1-12 index, yet
the drain hands it to a spawner that wants a code offset.  The cause is that built-in
25 is not the only thing that appends a trigger node.

Searching for functions that write a double at +16 AND a word at +28 of the same
register finds exactly two: sub_8226A0D8 (built-in 25, vtable slot 28) which fills the
payload inline, and sub_8226E160, a free-list recycling variant whose sole caller
sub_82269ED0 is vtable slot 68 = BUILT-IN 19.

Built-in 19 passes local[4] -> node+0 (the unit), local[12] -> +4, local[24] -> +16 as
a double, and local[32] -> +28.  That last one is the field the drain spawns from, and
it is a genuine code offset:

  over all 79 call sites disc-wide
    land on the instruction stream    79 / 79 = 100.0%
    control, random 4-aligned offsets           27.8%
    values 12164 .. 111080, 73 distinct

Large, distinct, and every one resolves against a control that resolves barely a
quarter of the time.  So built-in 19 registers a trigger whose handler is a routine,
and the drain's `base + payload+28` spawn is that handler.  The two builders simply
put different things in the same slot.

Observed alongside: built-in 25 writes +24 = 1 and built-in 19 writes +24 = 0, which
is kind-tag shaped -- but the drain was not shown branching on it, so that is recorded
as an observation and not a reading.

All artefacts regenerate byte-identical; documentation only.

Still open: whether +24 selects between the node kinds, and this STILL does not
explain the unreached code -- 0 of the 79 handler offsets are unreached run-starts.
2026-08-27 07:38:24 +00:00

144 lines
6.1 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.
# ✅ The trigger node's layout — from a matched writer/reader pair
[isl-trigger-queue](../isl-trigger-queue.md) located the *appender* (built-in 25).
The node's contents are now read, because the **reader** was found too and the two
agree field for field.
## The reader: `sub_8226E220`
`sub_8226D740` — the per-frame engine→script drain — calls it as
```
8226D758 addi r26, r29, 272 ; r29 = the phase, so r26 = the TRIGGER CONTAINER
8226D79C or r3, r26, r26
8226D7A0 bl 0x8226E220 ; (container, &out88, &out84, &out80,
; &out120, &out92, &out112)
```
and it pops one node:
```
8226E228 lwz r10, 20(r11) ; the PENDING COUNT -- return 0 if empty
8226E244 lwz r10, 16(r11) ; the list head
8226E250 stw r10, 24(r11) ; a cursor
8226E254 lwz r11, 8(r10) ; -> the node PAYLOAD
```
## ✅ The layout, confirmed from both ends
| payload | written by built-in 25 | read by `sub_8226E220` | source |
|---|---|---|---|
| **+0** | `stw r26, 0(r30)` | → out88 | `local[4]` — the unit |
| **+4** | `stw r28, 4(r30)` | → out84 | `local[12]` |
| **+8** | `stw r27, 8(r30)` | → out80 | computed |
| **+16** | `stfd f31, 16(r30)` | → out120 (a **double**) | `local[16]` |
| **+24** | `stw r25, 24(r30)` | → out92 | the constant 1 |
| **+28** | `stw r28, 28(r30)` | → out112 | `local[12]` again |
| **+32** | `stw r11, 32(r30)` | → out116 | — |
| +36 | `stw r23, 36(r30)` | *not read here* | — |
Container: **+16** head, **+20** pending count, **+24** cursor. The `+20` matches
the count `isl-builtins.md` watched live at `phase+272+20`, from a completely
different direction.
That the writer and the reader touch the same seven offsets, in the same order,
is the check — neither was derived from the other.
`payload+0` being `local[4]` also agrees with `UNIT_ARG`, which lists built-in 25
and was derived from operand ranges.
## 🔴 Two corrections, both mine
### `[node+112]` does not exist
[isl-coroutine-spawner](../isl-coroutine-spawner.md) recorded the drain's spawns
as taking their offset from `[node+112]`. **`r31 = r1 - 256`** — it is the
**stack frame**, so `+112` is an output slot, not a node field. Withdrawn.
### And the trigger does NOT carry a routine offset
The follow-up hypothesis — that `payload+28` reaches the spawner and so names the
routines nothing else starts — does not survive. `payload+28` is `local[12]`, and
across **all 25 call sites disc-wide** those values are the small integers
**1 … 12**:
| | |
|---|---|
| land on the instruction stream | **1 / 25 = 4.0 %** |
| control, random 4-aligned offsets | 16.0 % |
| are unreached run-starts | **0** |
Below chance. They are indices of some kind, not code offsets.
⚠️ My first version of this test used **n = 2** — Stage 02 only. It happened to
give the same answer, but two samples could not have supported it either way.
## ✅ One correction to the corpus, in the other direction
`isl-builtins.md` withdrew the idea that `sub_8226E458` touches the trigger
queue, reasoning: *"At `0x8226D780` the argument is `lwz r4, 324(r29)`
`[ScriptPhase+324]`, the unit array, not the trigger container."*
The call is `sub_8226E458(r3 = r26, r4 = [phase+324])`, and **`r26` was set to
`phase + 272` twelve instructions earlier**. The withdrawal read the *second*
argument. `sub_8226D740` does operate on the trigger container; what
`sub_8226E458` does *to* it is still unread, so only the argument is corrected
here, not the conclusion.
## ✅ RESOLVED: there are TWO appenders, and only one carries a routine
The puzzle above — `payload+28` holding a 112 index while the drain hands it to
a spawner that wants a code offset — had a simple cause: **built-in 25 is not the
only thing that appends a trigger node.**
Searching for functions that write a **double at +16** and a **word at +28** of
the same register finds exactly two:
| builder | how |
|---|---|
| `sub_8226A0D8`**built-in 25**, vtable slot 28 | fills the payload inline |
| `sub_8226E160` | a free-list recycling variant, called from `sub_82269ED0` |
`sub_82269ED0` is **vtable slot 68 = built-in 19**, and it calls the builder as
```
8226A034 addi r3, r28, 272 ; the trigger container
8226A040 lwz r4, 4(r30) ; local[4] -> node+0 (the unit)
8226A03C lwz r5, 12(r30) ; local[12] -> node+4
8226A038 lfd f1, 24(r30) ; local[24] -> node+16 (double)
8226A030 lwz r8, 32(r30) ; local[32] -> node+28
```
### ✅ Built-in 19's `local[32]` IS a code offset
| over all 79 call sites disc-wide | |
|---|---|
| land on the instruction stream | **79 / 79 = 100.0 %** |
| control, random 4-aligned offsets | 27.8 % |
| value range | 12 164 … 111 080, **73 distinct** |
Large, distinct, and every one resolves — against a control that resolves barely
a quarter of the time. So **built-in 19 registers a trigger whose handler is a
routine**, and the drain's `base + payload+28` spawn is exactly that handler.
Built-in 25's `payload+28` really is a small index; the two builders simply put
different things in the same slot. Observed alongside it: built-in 25 writes
**`+24 = 1`** and built-in 19 writes **`+24 = 0`**, which is the shape of a kind
tag — 🟡 but the drain was not shown branching on it, so that is an observation,
not a reading.
## 🟡 Not settled
* **What `local[12]` indexes.** Values 112, and `SYM1_SLOTS` lists slot 12 for
built-in 25 (symbol table 1 — routes, messages, objectives), which is
consistent with a small index but was not verified here.
* ~~What the drain actually spawns.~~ ✅ Resolved above: built-in 19's nodes carry
a real routine offset at `+28`.
* **Whether `+24` selects between the two node kinds** — 1 from built-in 25, 0
from built-in 19 — is unshown; the drain reads it into a stack slot that the
spawn path does not visibly use.
* **This still does not explain the unreached code.** All 79 of built-in 19's
handler offsets are already reachable — **0 are unreached run-starts**.
* **`sub_8226E458`'s effect** on the container.
* **What starts the ~15 % of unreached code** — still open, and this was not it.