Files
Sylpheed/docs/re/structures/isl-trigger-node.md
Sylpheed RE agent eda1f3e368 re: A/B in the trigger test -- searched, not settled, and the matrix reading excluded
Ran the "who writes this" move on the two 3-double vectors the trigger condition pulls
out of the unit record.  It did not settle them, and it is worth recording why.

99 sites across 40 functions write +32/+40/+48; 16 sites across 13 write +64/+72/+80;
six functions write both.  The most inviting is sub_821AD218, which is a float-to-double
widening copy of exactly 16 floats -- lfs from r4+0..60, stfd to r3+0..120.  Sixteen
floats is a 4x4 matrix, under which +32/+40/+48 is row 1 and +64/+72/+80 is row 2,
making A and B basis vectors rather than positions.  That is a tidy story and it is
wrong for this record.

The disproof was already in hand: sub_8226DAF8 reads `lwz r10, 100(r11)`, a WORD at
+100, which falls inside where a 16-double matrix's row 3 (+96..+120) would sit.  A
second writer, sub_8226A348 -- same built-in shape, [phase+324] indexed by local[4]
with the rec+4 liveness check -- confirms a mixed layout: words at +0/+4/+8/+12/+16 and
at +80, doubles only at +32/+40/+48.

So these offsets recur at the same positions in several unrelated object layouts, and
nothing found here ties a specific writer to the object the trigger actually reads.

A and B stay unnamed.  That is the second tidy reading rejected for them, after
previous/current-position.

All artefacts regenerate byte-identical; documentation only.
2026-08-27 08:21:58 +00:00

322 lines
14 KiB
Markdown
Raw Permalink 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.
## ✅ `+24` IS the kind tag — a three-way dispatch in the drain
Read straight off the drain, immediately after the pop:
```
8226D7AC lwz r11, 92(r31) ; payload+24
8226D7B8 cmpli cr6, 0, r11, 0x1
8226D7C4 bc 12, lt, 0x8226D89C ; +24 == 0 -> the SPAWN path
8226D7C8 bc 4, eq, 0x8226DA44 ; +24 > 1 -> bail
; +24 == 1 -> fall through, the OTHER path
```
| `+24` | written by | path | condition test | what `+28` is |
|---|---|---|---|---|
| **0** | **built-in 19** | `0x8226D89C``bl 0x822737C8` | `sub_8226DAF8` | a **code offset** (79/79 vs a 27.8 % control) |
| **1** | **built-in 25** | falls through at `0x8226D7CC`**no spawn** | `sub_8226DC80` | an index into **`[phase+244]`** |
The non-spawn path is explicit about it:
```
8226D834 lwz r10, 112(r31) ; payload+28
8226D838 lwz r11, 244(r29) ; [phase+244] = SYMBOL TABLE 1
8226D848 lwzx r10, r10, r11 ; resolve it as a symtab-1 index
```
### ✅ And built-in 25's `+28` resolves to a ROUTE
Over all 25 call sites disc-wide, `local[12]` read through symbol table 1:
**25 of 25 resolve**, and every name is a route —
`Route_ADS101_p1F`, `Route_ADT107_p1F`, `Route_TCN004_p1S`, `Route_ADT202A_p2S`, …
So the 112 values that looked like a puzzle are simply small symbol indices.
This also confirms `isl.py`'s `SYM1_SLOTS`, which lists slot 12 for built-in 25
and was derived from operand ranges alone — **two independent routes, same
conclusion**.
**The two built-ins register two kinds of trigger:** built-in 19 a trigger whose
payload is a *handler routine*, built-in 25 one whose payload is a *route*.
## ✅ BOTH trigger kinds watch a ROUTE
The kind-0 condition tester `sub_8226DAF8` resolves a symbol too — from
`payload+4`, not `+28`:
```
8226DB14 rlwinm r10, r5, 2, 0, 29 ; r5 = payload+4, x4
8226DB24 lwz r11, 244(r30) ; [phase+244] = SYMBOL TABLE 1
8226DB3C lwzx r10, r10, r11 ; resolve it
8226DB50 addi r4, r11, 4 ; -> the name string
8226DB54 bl 0x8217FA08
```
Both appenders write `local[12]` into the slot their path reads — built-in 19
into `+4`, built-in 25 into `+4` *and* `+28`. So the same operand is the symbol
in both cases, and it resolves:
| | `local[12]` through symbol table 1 | symbol type |
|---|---|---|
| built-in 19 | **79 / 79** | **all type 1** |
| built-in 25 | **25 / 25** | **all type 1** |
`isl-builtins.md` records symtab-1 **type 1 as the `Route_*` names** (1362
entries), and the samples bear it out: `Route_ADN106_p1F`, `Route_ADN1xxe1_p1F`,
`Route_ADS101_p1F`, `Route_TCN004_p1S`.
**So both built-ins register a trigger on a UNIT reaching a ROUTE**`payload+0`
is the unit, `local[12]` the route. They differ only in what happens when it
fires: built-in 19 additionally carries a **handler routine** (`local[32]`
`payload+28`, kind 0 → spawn); built-in 25 carries none (kind 1 → no spawn).
This also confirms `isl.py`'s `SYM1_SLOTS`, which lists slot 12 for **both** 19
and 25 and was derived from operand ranges alone — a third independent agreement
with that table.
🔑 It joins up with the closed `REMAINING OB` result, where the counter was
measured to rise **at a squadron's route arrival time**. Route triggers are the
mechanism that would do that. 🟡 Stated as a connection, not a demonstration —
nothing here traces a trigger to that counter.
## ✅ What the kind-0 condition TESTS — a point-to-segment proximity check
`sub_8226DAF8` past the route-name lookup:
```
8226DB84 bl 0x823012D8 ; look the route name up -> a route object; bail if absent
8226DBB8 lwz r11, 324(r30) ; the unit array
8226DBBC rlwinm r10, r26, 2,0,29; r26 = payload+0, the unit index
8226DBC8 lwz r10, 100(r11) ; require rec+100 != 0, else return 0
8226DBD4 lfd f0, 32(r11) … ; THREE doubles: rec+32, +40, +48 -> out vector B
8226DBF8 lfd f0, 64(r11) … ; THREE doubles: rec+64, +72, +80 -> out vector A
8226DBFC fmr f1, f31 ; the node's double (payload+16)
8226DC1C lwz r11, 0(r30) ; the ScriptPhase vptr
8226DC28 lwz r11, 60(r11) ; VTABLE SLOT 60
8226DC30 bcctrl ; test(phase, A, B, P=the route, f1)
```
**Slot 60 → `sub_82268068`**, which no built-in uses — it is engine-internal — and
its arithmetic is unambiguous:
```
f29 = |B - A| ; fsub per axis, fmul + 2x fmadd, fsqrt
if |B - A| < 0.1 -> 0 ; degenerate segment (the constant is 0.1)
f28 = |P - A| ; if > f1 -> 0
|P - B| ; if > f1 -> 0
t = (P-A)·(B-A) / |B-A|² ; the projection parameter
if t < 0.0 -> 0
```
Per-axis `fsub`, a sum of three squares, `fsqrt`, and a dot product divided by
the squared length: this is a **point-to-segment distance test**, and the node's
double is therefore a **RADIUS**.
So a kind-0 trigger fires when a route point lies within `local[24]` of the
segment between two vectors taken from the unit's record.
🟡 **What A and B are is not established.** `rec+64/72/80` and `rec+32/40/48` are
two positions; a previous/current pair would make this the standard
frame-rate-robust waypoint test, and that is exactly the kind of tidy reading
this corpus makes itself prove. Not proven here.
## ✅ The kind-1 condition is the SAME test with a cheap pre-filter
`sub_8226DC80` turns out to be `sub_8226DAF8`'s twin. Diffing them instruction
by instruction: of the first 86, 33 differ — and every difference before index 65
is a register rename or a branch target. Both do exactly the same thing up to
that point:
* resolve the route name (`sub_8217FA08``sub_823012D8`), leaving the route
point in `[r31+112/120/128]`;
* index `[phase+324]` by `payload+0`, require `rec+100 != 0`;
* copy the two 3-double vectors out of the unit record.
Then they part:
| | kind 0 — `sub_8226DAF8` | kind 1 — `sub_8226DC80` |
|---|---|---|
| next | calls vtable slot 60 straight away | **inline distance first** |
| | | `fsub` ×3 against the route point, `fmul` + 2 `fmadd`, `fsqrt` |
| | | `fcmpu` vs `f31`; **if > the radius, return 0** |
| then | — | **calls the same vtable slot 60** |
```
8226DDC4 fsqrt f0, f0
8226DDC8 fcmpu cr6, f0, f31 ; f31 = the node's double, the radius
8226DDCC bc 4, gt, return-0 ; too far -> reject without the segment test
8226DDD0 lwz r11, 0(r30)
8226DDE8 lwz r11, 60(r11) ; the SAME slot 60
8226DDF0 bcctrl
```
**So both trigger kinds run the identical point-to-segment test.** Kind 1 adds a
point-to-point early-out against the same radius and, per the drain, does not
spawn. The trigger mechanism is now read end to end.
## 🟡 Not settled
* ~~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~~ ✅ It does — see above.
* ~~What the kind-0 condition tests~~ ✅ Read. ~~`sub_8226DC80` (kind 1)~~ ✅ Read —
the same test with an inline distance pre-filter.
* **Which unit-record fields A and B are** — `rec+64/72/80` vs `rec+32/40/48`.
🔎 **Searched, not settled — and one tidy reading positively excluded.** The
"who writes this" move found **99 sites in 40 functions** writing `+32/+40/+48`
and 16 sites in 13 writing `+64/+72/+80`, with six functions writing both. The
most inviting of them, `sub_821AD218`, is a **float→double widening copy of
exactly 16 floats** (`lfs r4+0…60``stfd r3+0…120`) — a **4×4 matrix**, in
which `+32/+40/+48` would be row 1 and `+64/+72/+80` row 2, i.e. basis vectors
rather than positions.
🔴 **That reading is wrong for the trigger's record**, and the disproof was
already in hand: `sub_8226DAF8` reads **`lwz r10, 100(r11)` — a WORD at +100**,
which falls inside where a 16-double matrix's row 3 (+96…+120) would sit. A
second writer, `sub_8226A348` (same built-in shape: `[phase+324]` by `local[4]`,
`rec+4` liveness), confirms a mixed layout — words at `+0/+4/+8/+12/+16` and
**`+80`**, doubles only at `+32/+40/+48`.
So `+32/+40/+48` appears at the same offsets in **several unrelated object
layouts**, and nothing ties a specific writer to the object the trigger reads.
A and B stay unnamed.
* **The rest of slot 60** past the `t < 0` rejection (a second constant at
`0x820B0000+25192` is loaded next).
* **`payload+8`** is a computed value in both appenders (`r29-1` in built-in 19)
and is passed to both testers; a waypoint index would fit, which is exactly why
it is not being called one.
* **What the kind-1 path DOES with the route** past `sub_8217FA08` /
`sub_8218CC88` / `sub_8225FEA0` is unread.
* **The drain's second spawn site** (`0x8226DA10`) is in a later block and was
not traced.
* **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.