Files
Sylpheed/docs/re/structures/isl-trigger-node.md
Sylpheed RE agent c9d479f083 re: the kind-1 trigger condition is the same test with a pre-filter -- mechanism read end to end
sub_8226DC80 turns out to be sub_8226DAF8's twin.  Diffing them instruction by
instruction, 33 of the first 86 differ, and every difference before index 65 is a
register rename or a branch target.  Both:

  * resolve the route name (sub_8217FA08 -> sub_823012D8), leaving the route point in
    [r31+112/120/128];
  * index [phase+324] by payload+0 and require rec+100 != 0;
  * copy the two 3-double vectors out of the unit record.

They part at index 65.  Kind 0 calls vtable slot 60 immediately.  Kind 1 instead
computes an inline point-to-point distance first -- three fsub against the route
point, fmul plus two fmadd, fsqrt -- and rejects when it exceeds f31, the same radius:

    8226DDC4  fsqrt f0, f0
    8226DDC8  fcmpu cr6, f0, f31
    8226DDCC  bc    4, gt, return-0
    8226DDE8  lwz   r11, 60(r11)      ; then the SAME slot 60
    8226DDF0  bcctrl

So both trigger kinds run the identical point-to-segment test; kind 1 only adds a
cheap early-out against the same radius, and per the drain it does not spawn.

Diffing the two functions rather than reading the second one cost one query and made
the relationship obvious -- the same move that resolved the six ISL branch handlers.

All artefacts regenerate byte-identical; documentation only.

What remains on triggers is peripheral: which unit-record fields A and B are, slot 60
past the t < 0 rejection, payload+8, and the kind-1 path in the drain.
2026-08-27 08:14:24 +00:00

13 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.

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_8217FA08sub_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 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.