Files
Sylpheed/docs/re/structures/isl-trigger-node.md
Sylpheed RE agent 950db19f1b re: the kind-0 trigger condition is a point-to-segment proximity test
sub_8226DAF8 past the route-name lookup: it resolves the name through sub_823012D8,
requires the unit record's rec+100 != 0, pulls TWO three-double vectors out of that
record -- rec+32/+40/+48 and rec+64/+72/+80 -- and then makes a virtual call:

    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 = node+16)

Slot 60 resolves to sub_82268068, which no built-in uses, so it is engine-internal.
Its arithmetic leaves no room for interpretation: per-axis fsub, fmul plus two fmadd,
fsqrt, giving |B - A|; reject if that is under 0.1 (the constant is literally 0.1, a
degenerate-segment guard); reject if |P - A| > f1 or |P - B| > f1; then compute
t = (P-A).(B-A) / |B-A|^2 and reject if t < 0.

That is a point-to-segment distance test, and it makes the node's double -- built-in
19's local[24] -- a RADIUS.  So a kind-0 trigger fires when a route point lies within
that radius of the segment between two vectors from the unit's record.

Recorded as unproven: WHAT A and B are.  A previous/current position pair would make
this the standard frame-rate-robust waypoint test, and it would be easy to write that
down -- which is exactly why it is not written down.  rec+64/72/80 and rec+32/40/48
are two positions and nothing here says which.

All artefacts regenerate byte-identical; documentation only.

Still unread: sub_8226DC80, the kind-1 condition, entirely; and slot 60 past the
t < 0 rejection, where a second constant at 0x820B0000+25192 is loaded.
2026-08-27 08:08:01 +00:00

272 lines
12 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.
## ✅ `+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.
## 🟡 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 above. **`sub_8226DC80` (kind 1) is
still unread entirely.**
* **Which unit-record fields A and B are** — `rec+64/72/80` vs `rec+32/40/48`.
* **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.