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

12 KiB
Raw Blame History

The trigger node's layout — from a matched writer/reader pair

isl-trigger-queue 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 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_8226A0D8built-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 0x8226D89Cbl 0x822737C8 sub_8226DAF8 a code offset (79/79 vs a 27.8 % control)
1 built-in 25 falls through at 0x8226D7CCno 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 ROUTEpayload+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 arerec+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.