Commit Graph

7 Commits

Author SHA1 Message Date
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
Sylpheed RE agent
70df1c3a1b 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
Sylpheed RE agent
a3c8257c64 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
Sylpheed RE agent
d27bf54998 re: both trigger kinds watch a ROUTE -- the unit-reaches-route mechanism
The kind-0 condition tester sub_8226DAF8 resolves a symbol as well, but from
payload+4 rather than +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
    8226DB50  addi   r4, r11, 4          ; -> the name string

Both appenders write local[12] into whichever slot their own path reads -- built-in 19
into +4, built-in 25 into +4 and +28 -- so the same operand is the symbol either way,
and it resolves cleanly:

    built-in 19   local[12] -> symtab 1   79 / 79   all symbol type 1
    built-in 25   local[12] -> symtab 1   25 / 25   all symbol type 1

isl-builtins.md records symtab-1 type 1 as the Route_* names, and the samples agree:
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 the payoff: built-in 19 additionally
carries a handler routine (local[32] -> payload+28, kind 0 -> spawn) while built-in 25
carries none (kind 1 -> no spawn).  That is a third independent agreement with
isl.py's SYM1_SLOTS, which lists slot 12 for both and was derived from operand ranges
alone.

It also joins up with the closed REMAINING OB work, where the counter was measured to
rise at a squadron's route arrival time -- route triggers are the mechanism that would
do that.  Recorded as a connection, not a demonstration: nothing here traces a trigger
to that counter.

All artefacts regenerate byte-identical; documentation only.

Still unread: what the conditions actually COMPARE -- only sub_8226DAF8's first ~34
instructions are read, up to the name lookup, and sub_8226DC80 not at all -- and
payload+8, a computed value passed to both testers.  A waypoint index would fit it,
which is exactly why it is not being called one.
2026-08-27 08:00:22 +00:00
Sylpheed RE agent
af9e8006b6 re: +24 is the trigger kind tag -- built-in 19 registers a routine, built-in 25 a route
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, a NON-spawn path

So the field I recorded last iteration as "kind-tag shaped, but the drain was not
shown branching on it" does branch on it, three ways.

  +24 = 0, written by built-in 19: condition sub_8226DAF8, then bl 0x822737C8 --
      +28 is a code offset, matching the 79/79-vs-27.8%-control measurement.
  +24 = 1, written by built-in 25: condition sub_8226DC80, no spawn, and the path
      resolves +28 through [phase+244] -- symbol table 1:
          8226D834  lwz  r10, 112(r31)     ; payload+28
          8226D838  lwz  r11, 244(r29)     ; symtab 1
          8226D848  lwzx r10, r10, r11

And that resolution checks out: over all 25 call sites disc-wide, built-in 25's
local[12] resolves to a symbol-table-1 entry 25 times out of 25, and every name is a
route -- Route_ADS101_p1F, Route_ADT107_p1F, Route_TCN004_p1S, Route_ADT202A_p2S.
The 1-12 values that looked like a puzzle two iterations ago are simply small symbol
indices.  This independently confirms isl.py's SYM1_SLOTS, which lists slot 12 for
built-in 25 and was derived from operand ranges alone.

So there are two kinds of trigger: built-in 19 registers one whose payload is a
HANDLER ROUTINE, built-in 25 one whose payload is a ROUTE.

All artefacts regenerate byte-identical; documentation only.

Unread: both condition testers (sub_8226DAF8, sub_8226DC80), what the kind-1 path
does with the route past sub_8217FA08 / sub_8218CC88 / sub_8225FEA0, and the drain's
second spawn site at 0x8226DA10.
2026-08-27 07:53:26 +00:00
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
Sylpheed RE agent
bee8d96394 re: decode the trigger node -- and withdraw two of my own claims about it
The reader is sub_8226E220, called by the per-frame drain sub_8226D740 as
(phase+272, six out-params).  It pops one node and copies seven payload fields out.
Built-in 25, the appender, writes exactly those seven offsets:

    +0   local[4]  -- the unit          +24  the constant 1
    +4   local[12]                      +28  local[12] again
    +8   computed                       +32  --
    +16  a DOUBLE from local[16]

Neither side was derived from the other, so the agreement is the check.  Container:
+16 head, +20 pending count -- which matches the count isl-builtins.md watched live at
phase+272+20 from a completely different direction -- and +24 a cursor.

WITHDRAWN, from the previous commit: "the drain spawns from [node+112]".  r31 = r1 -
256, the stack frame, so +112 is an output slot and not a node field.

REFUTED, the follow-up hypothesis that the trigger carries the routine offset and so
names the code nothing else starts: payload+28 is local[12], and across all 25 call
sites disc-wide those values are the small integers 1 through 12 -- 1 of 25 (4.0%)
land on the instruction stream against a 16.0% control, and none are unreached
run-starts.  Below chance.  My first version of that test used n=2, Stage 02 only; it
happened to agree, but two samples could not have supported it either way.

One correction in the other direction, to the corpus: isl-builtins.md withdrew
sub_8226E458's link to the trigger queue on the grounds that "the argument is
lwz r4, 324(r29), the unit array, not the trigger container".  That is the SECOND
argument; the first is r26 = phase + 272, set twelve instructions earlier.  The drain
does operate on the container.  What sub_8226E458 does to it remains unread, so only
the argument is corrected, not the conclusion.

All artefacts regenerate byte-identical; documentation only.

Still open: what local[12] indexes; what the drain actually spawns, since [stack+112]
is filled from payload+28 yet the spawner wants a code offset, so some step in that
chain is not what I read; and what starts the ~15% of unreached code.
2026-08-27 07:30:41 +00:00