Corrects this corpus's own notation: there is no lower bound, values
below 16 pass.
sub_8225EC78(X, kind, ...) -- the function carrying "script load
cancel" -- stores its second argument into X+12 and applies the same
two tests to it immediately, bailing out of the load. So the gate is
the object's invariant, re-checked at every later touch, not a
selector. 42 sites image-wide have the shape, 34 of them in one code
region plus 3 in sub_8225EC78.
Object chain: X = [GamePart+104] is the current script instance
(installed at 0x821A78EC, previous one torn down via sub_8225EB60);
X+4 -> Y; Y+4 -> the ScriptPhase; Y+72 and [ScriptPhase+10152] get
the answer.
Refutes my own lead in the same pass: "X+12 is a GamePart id and 16 is
the unregistered one" fails its control -- 1, 2 and 18 are also absent
from the RegisterToFactory list, so 16 is one of four gaps.
Docs only; artefacts byte-identical.
Built-ins 102/103/130 are one trio over two phase fields, read off
four consecutive vtable stubs (slots 70/71/72/97). 102's body:
state==1 -> return 2 (pending)
state==2 -> special[0] = (answer==1); state=0; return 0
else -> state=1; answer=0; return 2 (issue)
2 and 0 are the dispatcher's thread codes, so 102 blocks the coroutine
until an answer arrives; 103 is the non-blocking form and 130 clears
the answer. The answer is published by sub_821A9DC8 under the same
(16,32] gate that request_next's writer uses.
Flags an inherited name I could not verify: prompt_yes_no (102) has no
derivation recorded anywhere in the corpus, so 103 and 130 are left
unnamed rather than named off it.
Docs only; artefacts byte-identical.
Followed the writer, not the reader. [phase+10160]'s only writer in
the image is one site in the mission frame loop sub_821AA1B0: it does
obj->get() on an object fetched from a registry by id 0x20FFFF02,
stores it to the phase, then clears the object -- read, publish, clear,
every frame.
The id namespace has exactly three members (0x20FFFF00/01/02), each
built at exactly 4 sites, and two of those are in sub_821D5178, which
gets 0x20FFFF01 and 0x20FFFF02 and logs both:
GamePart_ReadyRoom::Impl::OnCommand - Wait() command is requested.
Check flow control valiables. WAIT_MODE : %d, REQUEST_NEXT : %d
Argument order gives 0x20FFFF01 = WAIT_MODE, 0x20FFFF02 = REQUEST_NEXT.
PrepareScript corroborates: it sets WAIT_MODE=1, REQUEST_NEXT=0 before
an ISL script runs. So the six tutorial stages' lone dominating
condition request_next() != 1 is the script waiting on the game part's
proceed flag.
6 artefact lines changed, all 6 pair exactly.
They are start / read / stop of one of 32 per-phase stopwatches, not
flag operations. 123-127 keep timer_* -- that is the mission timer,
five scalars at [phase+304..320], a different clock.
Artefact check: 84 lines changed across 5 files and all 84 pair
exactly with their old-name partners once column padding is
normalised (0 removed lines without an old name, 0 added lines
without a new one). data/isl-timers.txt reproduces the same
675/675, 11.2 % control, 82/1 and identical histograms, which is
what shows the rename is cosmetic.
Also withdraws a label from the previous commit: sub_8230C398 is NOT
the message pump. It runs every frame but drains nothing -- a state
machine on [0x828E1F8C] that only allocates, builds strings, looks up
and PUSHES. And bus+8216 is weak evidence: sub_82254A08 is a generic
map find with ~120 sites, and the key looked up is a pointer, not a
tag. The open handle is now the ring buffer at bus+4, not bus+8216.
The move I flagged last iteration -- "0xAB03E5BA is built once, so
nothing consumes it" -- is refuted by its own control: 25 of the 40
distinct 0xAB03xxxx tags in the image are built exactly once (62.5 %),
and no 4-aligned word anywhere in the image begins 0xAB03, so tags are
never stored statically.
What does stand:
* sub_82175C20(bus+4, &msg) is a ring-buffer PUSH, not a dispatch.
* The message class has a one-method vtable {scalar deleting dtor}
with an MSVC RTTI locator at vtable[-1] -- no handler of its own.
* The bus at [0x828F35DC] has a map at +8216, looked up via
sub_82254A08 from sub_823001E8, whose only caller is the per-frame
pump sub_8230C398.
So the item is NOT settled: the insert path into bus+8216 is unread,
and whether running out of time ends the mission is still unknown.
Docs only; ISL artefacts regenerate byte-identical.
mission-phase-timers.md left 180 open as "a limit and a warning
threshold is the obvious reading, but not established". Reading
sub_822639B8 -- ScriptPhase::Update 0x82263528, same dt as the
stopwatch bank -- settles it the other way:
if running: [+304] += dt
if armed: [+308] -= dt while [+308] > 0
else [+312] -= dt, clamped at 0
[+312] is never compared with [+308]; it is decremented, and only in
the A<=0 arm. Two sequential countdowns. Disc-wide the second
argument is 180 in all 29 timer_set sites while the first varies
(600 x19, 1200 x8, 900, 1800).
Built-ins 123-127 are vtable slots 90-94 on five scalars at
[phase+304..320]. 125 and 126 have ZERO call sites in all 28 scripts:
the script arms, starts and stops this clock but never reads it.
Corrects mission-phase-timers.md, which merged this clock with
stopwatch 0 -- timer_resume starts [+304], set_flag(0) one instruction
later starts the stopwatch the timeline's kind=0 reads.
Docs only; all seven ISL artefacts regenerate byte-identical.
The open question was the unit, not the array. Following the writers of
[phase+88] settles it:
* sub_822710D0(phase, dt), called from ScriptPhase::Update, does
prev[i] = cur[i] then, only while [phase+120][i] == 1, cur[i] += dt --
so +88/+104/+120 are current / previous / running, 32 entries each.
* dt is seconds by a non-circular round trip: frames * (1/60) * 10000
-> clamp 3200 -> * 1e-4, in the timing singleton at [0x828F35B4].
The clamp is 0.32 s, a frame ceiling.
* 675/675 timeline kinds are indices their own phase starts (control
11.2 %), which is why kind is only ever 0 or 5.
Corrects isl-builtins.md twice: set_flag writes 0.0 not 1.0, and
clear_flag clears the running flag rather than the value. Confirms its
grouping of 8/9/93 as one family. Docs only -- all seven ISL artefacts
regenerate byte-identical.
Built-in 9 is inline in its stub, with no vtable slot. It bounds-checks
0 <= local[0] < 32 and then:
822725E8 lwz r10, 88(r31) ; [phase+88]
822725EC rlwinm r11, r11, 2, 0, 29 ; i * 4
822725F0 lfsx f0, r11, r10 ; a FLOAT
isl-builtins.md already recorded read_freg(i) -> [phase+88][i]. What is new is that
this is exactly the array, and exactly the indexing, that the TIMELINE walker uses for
its `kind` field. So kind is not a selector over six private clocks -- it is an index
into the same 32-entry float register file the script reads with read_freg(i). A
clear condition like `read_freg(0) < 1200` and a timeline entry with kind = 0 read the
same register. My "six clocks" framing came from the data only ever using indices 0
and 5, and it is corrected in isl-schedule.md.
Recorded as a nuance rather than a correction, because it is half-read: clear_flag
(93) clears a DIFFERENT 32-entry array, [phase+120], looping to 128 in steps of 4 --
not [phase+88]. set_flag (8) does bounds-check against [phase+88], but only its first
13 instructions were read, so the corpus's grouping of 8/9/93 as one "32-entry file"
family is neither confirmed nor overturned here.
This does NOT settle what the item actually asked: read_freg returns a float out of a
register file, and that the unit is seconds remains INFERRED from the 210/300/600/1200
gate values, exactly as it was before. Also still open: what writes [phase+88], which
has 73 writers in the ISL region alone.
All artefacts regenerate byte-identical; documentation only.
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.
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.
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.
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.
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.
Inverting the question -- read what an unreached routine DOES, rather than testing
another candidate mechanism -- sharpened this a lot without solving it.
The metric was partly inflated. Of 24901 unreached instructions in 564 runs, 458 runs
are length 1, and 111 of those are a lone `ret` stranded after an end_coroutine, with
the next routine being a properly seeded entry. Stage 02's first two "unreached
routines" are exactly that. But those are only 458 instructions: the real gap is
24443 in 106 runs, the largest 1526 (Stage 26), 1496 and 1481 (Stage 29), 1069, 904.
And it is live mission logic. Stage 26's 1526-instruction run stages arguments and
calls builtin57(ADN110, 1) and set_group_speed(ADN110, 2, 0) -- named units, named
built-ins.
New this iteration: kill_coroutine (built-in 5) is confirmed as a code-offset carrier,
which is what the corpus said -- its local[0] resolves 150/150 onto the instruction
stream against a 34.0% control, values 13740..221048. And, like the others, it points
at zero unreached run-starts.
That is now five mechanisms. Across 2757 code references -- start_coroutine 1765, the
phase timeline 675, built-in 19's trigger handlers 79, kill_coroutine 150, and the
0x1883 records 88 -- not ONE lands on an unreached run-start. A categorical absence,
not a near miss.
Not a tool artefact: 1765 of 1765 start_coroutine sites resolve with 0 missed, 0
entries land outside their phase region, and 0 are discarded by the on-stream filter.
Combined with the earlier result that an unreached routine's offset appears nowhere in
the file as a word in any encoding, these routines are referenced by nothing inside the
.ssb. Either they are unused in this build, or they are entered from outside it. A
1526-instruction routine driving a named squadron sits badly with "unused", but that
discomfort is not evidence and the two readings are recorded without choosing.
No static test I have devised can decide it; a runtime probe logging sub_822737C8's
third argument through a mission would.
All artefacts regenerate byte-identical; documentation only.
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.
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.
Asking who calls the function the timeline calls enumerates every way an ISL routine
can begin. sub_822737C8(phase, base, offset) computes base + offset early on, and has
seven real call sites: the phase initialiser sub_82270DF8, the built-in stub region
(start_coroutine), the timeline walker sub_822748D0, TWICE inside sub_8226D740 -- the
per-frame engine->script drain -- and two unread, sub_82273910 and sub_82264058.
CORRECTION to my own write-up: the 0x1883 record's third word is the phase's MAIN
ENTRY, not a "size". The initialiser hands it straight to the spawner:
8227101C or r5, r22, r22 ; the record's third word
82271020 or r4, r26, r26 ; the code base
82271030 bl 0x822737C8
44 of 44 records land on the instruction stream (100%) against a 25.0% control, and
all three Stage-02 targets open with the identical prologue
`special[0]=0 ; local[0]=0 ; call builtin116(0)` -- a routine entry, not a length.
So the record is 0x1883, base, MAIN_ENTRY, 0, code_end, force_end_handler.
Seeding the main entries moves no coverage number: every one was already among the
CFG's entry points by another route. This corrects a field's meaning, not the graph.
Lead recorded rather than claimed: both of the drain's spawns take their offset from
[node+112], the first field of a drained node to be located, and the best remaining
angle on the ~15% of code nothing appears to start. It is NOT shown that those nodes
come from the trigger queue at phase+272 -- that is precisely the over-reach
isl-builtins.md already made and withdrew, so it is not asserted here.
All artefacts regenerate byte-identical; this is documentation only.
The schedule decode previously rested on structural checks alone. The engine's own
reader is now found, statically.
The phase initialiser sub_82270DF8 takes the 0x1883 record's six words as arguments
and stores the table pointer:
82270FDC add r10, r26, r21 ; code_base + entry_a
82270FEC stw r10, 240(r30) ; [phase+240]
[phase+240] has exactly two readers in the ISL region, and one -- sub_822748D0 -- is
called from sub_82263408, ScriptPhase::Update. It walks the table and confirms every
field independently of my inference: N at table+4, kind at group+20, float t at
group+12, offset at group+4, and a 24-byte stride (three 8-byte records).
kind is NOT a flag. `rlwinm r11, r11, 2, 0, 29` then `lfsx` -- it is multiplied by 4
and used to index float arrays at [phase+104] and [phase+88], so it selects WHICH
TIMER the entry is scheduled against; 0 and 5 are two of at least six. An entry fires
only when its t lies between the two readings, the classic "which events did this
frame cross" test. Which array is previous and which is current follows from the two
comparison directions and is stated as a reading of those branches, not measured.
Both arrays are fields of the phase object, so the clock is PER PHASE -- which is why
every phase's table restarts at t = 0. That was an inference from the layout in the
previous commit; it is now a read.
All five artefacts regenerate byte-identical: this changes documentation only.
Still open and said so: what the six clocks are (only indices 0 and 5 appear in the
data, and neither array was traced to a source); sub_822737C8, which actually starts
the routine, is unread; and this is still not what starts the unreachable code, since
0 of the 675 targets are unreached run-starts.
Decodes the table found at the end of every phase region. Layout:
int N
N x [ int offset ; float t ; int kind ] -- 8-byte typed records,
tag 0x19 int, 0x1A float
1 + 3N matches the record count in every phase measured (Stage 02: 76/40/55
records for N = 25/13/18).
Checks, all independent of each other:
schedule entries disc-wide 675
0x1A float records disc-wide 675 (counted by a different route)
offsets landing on the instruction stream 675/675 = 100.0%
control, random 4-aligned offsets 33.3%
The floats are seconds -- 0, 0.5, 1, 4, 5, 30, 50, 60, 90, 120, 150, 170, 180, 210,
240, 270, 300, 330, 360, 420, 570, 1020, 1080, 1140, 1170 -- and the targets are small
one-shot coroutines that set arguments, call one built-in and end_coroutine. kind is
0 (556) or 5 (119) and is not identified.
Runtime cross-check, recorded as consistency rather than confirmation: the closed
REMAINING OB work measured Stage 02's squadron arrivals at t = 0, 120 and 210 s over
n=5 emulator runs, and all three appear in phase 1's static schedule, with 120 and 210
each appearing TWICE. These are round numbers and phase 1 has ~22 distinct times over
0-1170, so presence alone is not unlikely; the doubling is the sharper detail and was
not predicted in advance.
New artefacts data/isl-stage02-schedule.txt and data/isl-schedule-all.txt with a
committed generator (isl_report.py schedule). calls, phase-ends, conditions and
phase-guards all regenerate byte-identical.
Not settled and said so: kind is unread; the consumer is unread, so the decode rests
on the structural checks above; whether the clock is per-phase or per-mission is an
inference from the layout; and this is NOT what starts the unreachable code -- 0 of
the 675 targets are unreached run-starts, so that ~15% gap stands.
Disassembling the three Stage-02 entry_a targets shows opcodes 0x19 and 0x1A, and the
ISL dispatcher's table has 25 entries (cmplwi 0x18). They are not instructions. Each
phase region ENDS with a trailing data table of 8-byte typed records -- tag 0x19 = int,
tag 0x1A = IEEE float (0.0, 0.5, 1.0, 4.0) -- and entry_a is where it starts.
Confirmed across the disc: in 44 of 44 phases the first offset whose opcode exceeds
0x18 is exactly that phase's entry_a, with zero exceptions, and only two tags ever
appear (1394 x 0x19, 675 x 0x1A). So the record is
0x1883, base, size, 0, code_end, force_end_handler
one boundary and one entry, not two entries as the previous commit said.
That also retires this thread's own "82 of 88 land on a valid instruction = 93.2% vs a
38.6% control" as TOO WEAK a test: a data record has length 8 and passes "nonzero,
even". The entry_b result stands on different evidence -- those targets were matched
against isl.call_sites(), an independent enumeration.
isl.linear_offsets was decoding all 2069 data records as instructions, 1.23% of the
stream. Now each phase's walk stops at its boundary:
decoded instructions 168251 -> 166182 (= 168251 - 2069, as predicted)
opcode > 0x18 2069 -> 0
call sites covered 25705/25705 -> 25705/25705
exits unreachable 0 -> 0
conditions unknown 400 -> 400
Recorded because the first attempt at the fix was worse than the bug: it destroyed 36%
of the stream (168251 -> 107596, exits 0 -> 74) because linear_offsets is ONE global
walk from the first phase base, so stopping at phase 1's table lost every later phase.
It has to skip the region and resume at the next base. A count moving hard in the
wrong direction is the same signal as one that will not move.
Still open: the table's contents are undecoded -- its int values land on the
instruction stream 46/51 against a 29.5% chance rate, but 0 of them are unreached
run-starts, so this is not what starts the unreachable code either.
data/isl-phase-guards-all.txt goes from 5 of 177 unreachable exits to 0.
The cheap first step failed, usefully. An unreached routine's entry offset does NOT
appear as a word anywhere in the file, in any encoding: phase-relative 6.6% against an
11.5% control on reached offsets, absolute 1.6% vs 3.3%, and the /4 forms 0-1.6% vs
6.6-8.2%. Every variant is at or below its control, which rules out the whole family
of "some instruction operand points at them". It also rules out dead code: Stage 02's
3069 unreached instructions contain 485 calls, including start_coroutine x75,
squadron_attack x59, set_group_speed x42 and objective_marker x13.
The answer is the mission-level stream that isl-bytecode.md already partly read. Each
0x1883 record is
0x1883, base_delta, size, 0, entry_a, entry_b ; entries PHASE-RELATIVE
Measured over all 28 stages, 82 of those 88 values land on a valid instruction --
93.2%, against a 38.6% chance rate for a random 4-aligned offset. In Stage 02
entry_b is the phase's force-end handler: 0x1482C, 0x249F0 and 0x34A10, two of which
were exactly the unreachable exits, and the third being already reachable is the
consistency check.
Seeding them: exits unreachable 5 -> 0. Those exits now report 0 necessary
conditions, which is what an engine-entered abort handler should look like.
Recorded because it is the same mistake twice: the first seeding attempt moved NOTHING
(reach 85.0% -> 85.0%, exits 5 -> 5). dominating_conditions() builds its own entry set
and did not use the one I had patched -- fix-the-instance-not-the-class again, caught
only because an unchanged count is by now a standing signal.
Not settled and stated: reach went only 85.0% -> 85.2%, so what starts the other ~15%
of code is still unknown, and the negative above says it is not an operand in the file;
entry_a is unidentified; 6 of the 88 values do not land on an instruction.
isl-builtins.md carried the trigger queue at phase+272 a long way -- layout, live
count at +272+20, the generic appender sub_8226EAB8 -- and stopped at "what actually
appends a node is still unidentified", proposing a gdb watchpoint on a running
emulator. It is a static question after all.
sub_8226EAB8 is a shared helper with 16 callers, so it cannot be identified by name.
But the corpus records that the trigger container EMBEDS its inner list at +12, so the
appender must hand it container+12. Only two of the sixteen callers do, and one sets
its base 34 instructions earlier:
8226A22C addi r29, r24, 272 ; r24 = the ScriptPhase
8226A2B4 addi r3, r29, 12 ; -> phase + 272 + 12
bl 0x8226EAB8
phase+272+12 is exactly the inner list whose count the corpus watched at +272+20.
sub_8226A0D8 is ScriptPhase vtable SLOT 28, which is what BUILT-IN 25's stub
tail-calls, and there are ZERO real `bl 0x8226A0D8` instructions in the image -- it is
reached only through the vtable. So the queue is populated by the SCRIPT, retiring
the residual "the engine moves records into the phase's queue each frame" reading that
isl-builtins.md had already flagged as unsupported by its own call site. Built-in 25's
arguments agree with two independently measured tables: UNIT_ARG (a unit at local[4])
and SYM1_SLOTS[12] (a symtab-1 name at local[12]).
REFUTED in the same pass, with a control: built-in 25 does NOT carry the coroutine
entry that a trigger fires, which would have seeded the 389 routines reachable from no
static entry. Taking each staged operand as a code offset from the phase base, 0 of 8
land on an instruction boundary, against 38.7% for an arbitrary 4-aligned offset --
below chance, not above it. And the scale is wrong regardless: built-in 25 has only
2 call sites in Stage 02.
So what starts the unreachable routines is still open, and it is not this. A trigger
node's own layout and its consumer are unread, and built-in 25 stays unnamed.
Dominance said a phase cannot end unless X. A port also needs "once X holds, it
must end", and that is a must-reach set: nodes from which END_PHASE is unavoidable,
as a least fixpoint where n qualifies when it has successors and ALL of them qualify.
The conservatism is deliberate and is the honest answer: a loop never enters the set,
because a poll loop reaches its exit only if the polled predicate eventually becomes
true, which is a liveness property rather than a graph one.
A dominating condition is a TRIGGER when the successor it takes on being satisfied
lies in that set. Over all 28 stages: 732 dominating conditions, 234 triggers
(31.97%). isl_report.py phase-guards now tags every line precond / TRIGGER.
The split lands where it should. Stage 02's phase-1 objective exit is six
preconditions -- player alive, TCN004 destroyed, t <= 210, ADT102/ADT107/ADT113
destroyed -- and exactly ONE trigger: hp_pct_test(ADN101, 0) != 1. Destroying ADN101
is what fires the phase. That is a sentence a port can implement.
Per-exit distribution over 172 reachable exits: 89 have exactly one trigger, 42 have
none, 41 have several. The 42 with none are not a failure -- they are the exits no
branch fires; Stage 02's 0x006260 ends on read_freg(0) < 1200, a timeout, and time
passing is not a property of the graph, so declining to call it a trigger is correct.
Recorded as a heuristic rather than a rule: "the first trigger is the point of no
return" holds for 33 of the 41 multi-trigger exits, with 8 counterexamples where a
precondition appears after a trigger. The likely cause is that the listing is
ordered by file offset, which is not execution order -- coroutines and jumps let a
lower offset run later. Not asserted.
calls, phase-ends and conditions all regenerate byte-identical; the two phase-guards
artefacts change only by gaining the tags.
isl-phase-guards produced the per-phase clear conditions, but three of the built-ins
in them were unread, leaving the conditions half-readable. All three are read now
via the vtable at 0x820A84BC (control: 69 unit_state -> slot 184 -> 0x8226ADF0).
NONE is named: the corpus has withdrawn two names taken from usage shape, and what I
read does not support naming these.
builtin104 (slot 288, 0x8226BFE0) is a THREE-INSTRUCTION PURE GETTER:
lwz r11, 10160(r3) ; stw r11, 164(r3) ; blr
So all six tutorial stages S18-S23 end on the value of a single engine-written word,
which is why their exits each have exactly one dominating condition and why
isl-builtins.md only ever saw 104 inside a poll loop. That word has EXACTLY ONE
writer in the image -- sub_821AA1B0 at 0x821AAD9C, gated on a kind field being in
(16, 32] -- and r29 there is the return of a preceding call, so the meaning of the
value and of the gate are both unestablished.
builtin7 (slot 40) indexes [phase+324] by local[4], bails when rec+16 (the documented
unit state) is 1, 3 or 4, and resolves local[12] through [phase+244] -- symbol table 1.
isl.py's SYM1_SLOTS already listed slot 12 for built-in 7, derived from operand ranges
alone; reading the implementation shows the mechanism behind that. Two independent
methods agreeing. What it asks ABOUT the route is still unread.
builtin141 (slot 428) has the same unit-array entry and returns 0 when the unit is
gone; everything past 0x8226CA0C is unread. Stage 16 calls it twice with arguments
differing in one position (0 vs -4000), which looks like a coordinate -- and looking
like one is exactly the evidence this corpus does not accept.
No artefact changes: the listings already printed builtin104/7/141 and still do.
All four Stage 02 artefacts regenerate byte-identical.
isl_report.py now accepts a directory, so the dominance analysis runs over the whole
disc: data/isl-phase-guards-all.txt, 177 phase exits, of which only 5 (2.8%) are
reachable from no static entry. CFG reach ranges 69.5% (S26) to 95.8% (S25), median
about 4 dominating conditions per exit.
The lopsided number in the per-stage table was the six TUTORIAL stages, S18-S23, each
with exactly ONE exit and exactly ONE dominating condition. That could have been a
degenerate result, so I looked: it is the same condition in all six,
END_PHASE <- builtin104() != 1
and isl-builtins.md reached built-in 104 from call-site USAGE alone -- "S18-S23 only,
followed by wait_s 39/39, preceded by end_coroutine 37/39, a textbook poll loop".
Usage said 104 is the tutorial's polled test; dominance says it is the tutorial's
clear condition. Two unrelated methods, six for six.
Stage 16 -- the corpus outlier whose script may be compiled C++ -- resolves as well:
read_freg(0) < 600, player_gauge0_test, player_gauge1_test, and two builtin141 calls
differing in a single argument (0 vs -4000), which is the shape of a position or zone
test. builtin141 is unread, so it is not named.
Stage 02's separate artefact regenerates byte-identical.
Also added: an RLIMIT_AS cap in isl_report's entry point. The dominator pass
OOM-killed a run earlier on this 15 GB box; a bad input should now fail the process
rather than the machine.
Still not settled and stated in the doc: dominance gives necessary, not sufficient,
conditions; the 5 unreachable exits need the trigger queue at phase+272; builtin104,
builtin141 and builtin7 all appear in clear conditions and are unread.
Closes the backlog's "which condition guards each END_PHASE". With the CFG from the
previous commit this is a graph query, not new machinery.
The obvious query is WRONG for this language, and I implemented it first: "one
successor reaches END_PHASE and the other does not" finds 1/62/1 guards across Stage
02's three phases, and the 1s are both the same read_freg(0) < 1200 timeout -- every
objective test missed. The cause is the dominant idiom: a POLL LOOP's loop-back
branch also reaches the exit, one iteration later, so neither successor discriminates.
The asymmetric 1/62/1 is what exposed it; a uniform number would have read as
plausible.
Dominance has no such blind spot: a condition dominates an exit when every path from
an entry passes through it, so it is NECESSARY for the phase to end that way, and a
poll loop's test dominates its own exit by construction. Iterative dominators
converge in 3 passes over 15670/18739 instructions (83.6%).
Result for Stage 02 -- every exit in all three phases is dominated by
unit_hp_pct(TCN001, Character_Player_Test) != 0, the player's ship being alive, which
falls out rather than being assumed. Beyond that, phase 1's objective exit requires
hp_pct_test on ADT102, ADT107 and ADT113; phase 3's requires ADT301 and ADT302;
read_freg(0) gates at 210 / 300 and times out at 1200; random(3) and random(5)
dominate only the exits that pick one of several closing lines.
Two of the 15 exits are reachable from NO static entry, both FORCE_END_PHASE. That
agrees with the independently measured 389 unreachable routines: they are started from
the trigger queue at phase+272, by data rather than code.
Practical note recorded: the first dominator run was OOM-killed -- 6743 nodes each
holding a Python set of up to 6743 elements. Integer bitmasks run in seconds.
Not settled, and said so: dominance gives necessary, not sufficient, conditions; only
Stage 02's artefact is committed; one listed condition is still an unresolved
<unknown>; read_freg's units are inferred from the gate values, not read.
calls, phase-ends and conditions all regenerate byte-identical.
The linear walk's 10% unknown was a floor imposed by the method: a block entered only
by a branch has a well-defined state, just not one a straight-line pass can see.
tools/re-capture/isl_cfg.py replaces it with a worklist fixpoint that joins each
block's state over its ACTUAL predecessors -- a value survives only if every
predecessor agrees.
Over all 28 stages:
instructions reached by the CFG 85.0%
condition sites, unknown LHS 756 (10.00%) -> 402 (5.32%)
of those, never reached at all 389
joined away (predecessors disagree) 13
both resolve but DISAGREE 161 <- linear walk was wrong here
Those 161 are on top of the 889 the previous jmp fix caught.
Two zero-results on the way, both my own bug, both caught because the number looked
wrong rather than because a test failed:
* The first CFG run reached only 36% of instructions and made things WORSE (35%
unknown). Cause: the phase bases reach almost nothing. Most routines are
COROUTINES the engine starts from its trigger queue, with no static predecessor,
so every start_coroutine target has to be seeded as an entry.
* That seeding then found ZERO entries in a file with 216 start_coroutine calls,
because the target is staged in TWO steps -- special[0] = imm, then
local[0] = special[0] -- and I matched only the direct-immediate form.
Reachability went 36% -> 64% -> 85% as each was fixed.
The 389 still unreached are an honest limit rather than a gap: nothing in the bytecode
starts them; they are entered from the trigger queue at phase+272, by data rather than
code, so no purely static analysis reaches them.
isl_report.py conditions now uses isl_cfg; calls and phase-ends regenerate
byte-identical. Stage 02 unknowns drop from 71 to 25.
Reading builtin80's body (0x82268460) to name it: it is NOT a predicate. It
allocates a 20-byte object, stamps vtable 0x820A8CB0, magic 0xAB0311BA and the
unit's live object into it, pushes it onto a queue via the same helper push.i uses,
and returns 1 -- or 0 when the unit is absent. A command.
That made the conditions listing impossible: it showed a six-way switch
`if builtin80(TCT206) == 0 … == 5` on a function returning 1 or 0. Disassembling the
site shows two unconditional `jmp`s between the call and the compare, so 0x1B6C0 is
reached ONLY by a branch and its special[0] has nothing to do with builtin80.
op12 is unconditional -- the next instruction is never reached by fall-through -- and
the tracker walked through it exactly as it had walked through end_coroutine. Last
iteration I fixed the instance and not the class, leaving 22x more bad sites in place
than the fix removed.
A/B over all 28 stages, 7563 sites, resetting at jmp as well:
sites whose operands change 889 (11.75%)
LHS unresolved, before -> after 34 (0.45%) -> 756 (10.00%)
So the previous commit's headline "0.0% unresolved" was a MISSING CHECK, not a strong
result: the linear walk always had some value to report, and reporting it was the bug.
10% is the honest figure and the other 90% is trustworthy for a reason.
Also corrected: isl-unit-args.md illustrated its diff with 0x1B6C0, which is one of
the bogus sites. The UNIT_ARG result itself stands -- it came from reading
implementations, not from this listing -- but the example was picked from bad output.
Not done, and said so: recovering the 756 needs a dataflow join over each block's
actual predecessors, a CFG fixpoint rather than a linear pass. The branch targets are
all known so the CFG is available; the analysis is not written.
calls and phase-ends regenerate byte-identical; conditions changes on 187 lines.
isl.py's UNIT_ARG decides whether a built-in's slot-4 operand prints as a unit name
or a raw number. It was inferred statistically from operand ranges and, by its own
comment, listed a slot "only when the ratio stayed below 1.0" -- conservative.
The vtable base makes it a lookup instead: every unit-taking built-in's implementation
opens with lwz 324(phase) / lwz 4(argbase) / rlwinm 2,0,29 / lwzx / lwz 4(rec). Read
directly for all 147:
implementation indexes [phase+324] by an argument 55
of the statistical set's 31, confirmed 31 (zero false positives)
UNIT_ARG claims a unit, implementation does not 0
implementation says unit, UNIT_ARG missed it 24
The 24 include builtin80, group_ratio_pct, is_engaged, set_unit_flags,
squadron_trace, wait_units_ready and deploy_and_wait. Hand-verified by reading
builtin7, 16, 80, 105, 117 and 136.
Recorded because it nearly passed: my FIRST control -- whether the additions' operands
resolve to a symbol-table-2 index -- is worthless. The additions score 100.0%, but so
do the 31 baseline (100.0%) AND the 92 built-ins in neither set (99.3%). Symtab 2 is
dense enough that almost any small integer lands in it. A control the negative class
also passes is not evidence.
The control that discriminates is the tag word: a symbol operand is a two-word pair
whose first word is the constant 1, so slot0 == 1 exactly when slot 4 is a unit --
100.0% (13677 calls) / 100.0% (140) / 2.5% (2903). A 40x separation.
Artefacts: isl-stage02.txt and -phase-ends.txt regenerate byte-identical; -conditions
changes on 28 sites, every diff line pairing, each a raw number becoming a unit name.
Left unnamed on purpose: all 24. builtin80 returns a small enum (tested 0..4 in a
switch) but its body past the liveness check is unread; builtin103 is a predicate over
[phase+10152]/[phase+10156]; builtin105 tests a unit record's +16 against 4.
The listing showed end_coroutine as the left-hand side of 34 comparisons disc-wide.
That is impossible -- it returns no value a script can test -- so it was the bug
reporting itself.
The recorded fix ("set special[0] only for built-ins that write [phase+164]") is
REFUTED. end_coroutine's handler 0x82272624 is `addi r11,r0,1 ; addi r3,r0,3 ;
stw r11,164(r31)` -- it DOES write [phase+164], so that filter would have kept it.
Reading the handler before writing the filter is what caught this.
The real cause: end_coroutine returns 3, which DESTROYS the thread. Execution does
not continue past it, so the instructions following it in the flat stream belong to
a different routine and every tracked value is stale. The linear walk that makes
the decode possible is exactly what walks across that boundary.
A/B over all 28 stages, 7563 sites, resetting the tracker at end_coroutine:
sites whose operands change 34 (0.45%)
LHS = end_coroutine, before -> after 34 -> 0
left as an explicit unknown 34 (0.45%)
The two counts being equal is the result: the leak was confined to exactly the sites
that displayed the impossible value, so the other 7529 conditions were never
affected. Those 34 now print "<unknown: reached after a coroutine boundary>".
Not done, and said so: their RHS is still exact and the LHS is recoverable by seeding
the tracker at coroutine entries, whose targets are staged slot 0 of start_coroutine.
data/isl-stage02-conditions.txt regenerated; calls and phase-ends both byte-identical.
The deque ops are an EXPRESSION STACK: push the left operand, evaluate the right
(a built-in call, whose result lands in special[0]), pop the comparand back into
special[1], compare. Tracking that through the linear decode is enough to recover
what each site tests.
Evidence the model is right, not just plausible:
push vs pop across all 28 stages 1877 vs 1877
files that underflow or end unbalanced 0 of 28
Stage 02 pop.i sites followed by cmp.i 319 / 319
ops immediately before a pop.i call x313, cmp.a x6
isl.conditions() recovers 7563 condition sites disc-wide with 0.0% left as an
unresolved special[N]; 83.2% have a built-in call as the LHS and 99.7% compare
against a plain number. Most-tested: hp_pct_test 1955, unit_state 1257,
unit_relation 796, dist_lt 450, unit_alive 413.
They read as conditions now:
if unit_alive(TCN105) != 1
if hp_pct_test(ADT308, 0) != 1
if dist_lt(ADT308, TCN000, 15000) != 1 (world unit = 1 m, so 15 km)
if unit_state(ADT308) == 1
data/isl-stage02-conditions.txt was a stale artefact with NO generator -- the thing
isl_report.py's docstring complained about. It has one now (isl_report.py
conditions). The calls and phase-ends artefacts both regenerate byte-identical, so
the change is additive.
Recorded rather than glossed: 15 of Stage 02's 965 sites (1.6%) attribute the LHS to
end_coroutine, which returns no value -- the tracker sets special[0] on EVERY call,
so those show a stale value and are wrong, not imprecise. The fix is to set it only
for built-ins that write [phase+164], which the vtable work makes checkable.
Answers what the previous commit left open: naming the branches did not give a
clear condition, because that needs the operand chain feeding each compare.
First, a correction to my own work. isl-bytecode.md -- which OWNS the opcode table
-- already named ops 21-24 push.i/push.f/pop.i/pop.f. isl-branches.md, which I
wrote last iteration, said op21 and op23 were unread. The stale file was mine.
Verified from the thunks rather than accepted: 21 pushes [phase+168] onto the deque
at phase+44, 22 pushes [phase+184] onto phase+64, and the 23/24 handlers touch only
r3+168 and r3+184. So pop.i lands in special[1].
New: the 147-entry built-in table is a thin DISPATCH LAYER, not implementations.
Each stub resolves the local[] argument base and tail-calls a fixed ScriptPhase
vtable slot. 112 of 147 dispatch that way; 17 write [phase+164] inline; 0 write
+184. Every named predicate is in the vtable group -- unit_state 184, unit_alive
188, hp_pct_test 64, dist_lt 56, is_engaged 252, timer_elapsed 372 -- which is the
control that the split separates engine queries from script bookkeeping.
The vtable is 0x820A84BC, derived from a known implementation rather than a stride:
MARK_LAST_PHASE is documented as [phase+300]=2; the function 0x8226B498 is exactly
that stub; it appears as a data word at exactly one address, 0x820A8570; built-in
39 uses slot 180. The check NOT used in the derivation: built-in 40 mark_not_last
uses slot 176, and slot 176 holds the [phase+300]=1 stub. Predicted and confirmed.
The db's own vptr_writes independently lists 0x820A84BC, written at 0x82261B80.
unit_state = slot 184 = 0x8226ADF0, which indexes [phase+324] by local[4] and writes
its answer to [phase+164] = special[0] at both exits. The phase-3 poll loop now
reads end to end: unit_state(ADT308) -> special[0]; pop.i -> special[1]; cmp.i; beq.
isl.py names ops 21-24; the calls artefact regenerates with NO diff.
Left open and said so: the other 111 vtable slots, which comparand each site pushes,
the 35 non-vtable built-ins, and the vtable's length.
Closes the backlog item that was the last thing between the flat decode and a
per-phase clear condition, and closes isl-builtins.md's standing "op10 + op13 look
like a switch -- NOT confirmed".
op10 resolves two operands, issues a SIGNED cmp, and writes three condition bits to
a bitset at phase+24: bit 0 = EQ, bit 1 = GT, bit 2 = LT. op11 is the same machine
for floats via fcmpu. op13-op18 branch on those bits to [phase+232] + word@+4 --
the same phase-relative target form as the unconditional op12:
13 bit0 set beq 16 bits 2 then 0 ble
14 bit0 clear bne 18 bits 1 then 0 bge
15 bit2 set blt 17 bit1 set bgt
13/14/15/17 are byte-identical apart from the bit index and the polarity. All six
relations are present and each appears exactly once; that completeness is the check
that the reading is right, rather than the usage pattern -- which the item
explicitly warned against.
Operand order recorded because it is easy to reverse: LHS = (kind byte[1], word@+4),
RHS = (kind byte[0], word@+8).
Method note in the doc: the jump table at 0x822635FC holds THUNKS, and the handler
is the bl target inside each. My first pass guessed handler addresses at a fixed
stride, landed mid-function, and produced a 20-line "difference" that was pure
misalignment.
isl.py names the ops; data/isl-stage02.txt is regenerated and every diff line pairs
exactly, only the op-name column changing (op10->cmp.i x5, op13->beq x4,
op14->bne x1). data/isl-stage02-phase-ends.txt now shows the phase-3 poll loop
reading as one: unit_state(ADT308) -> op23 -> cmp.i -> beq back to 0xFEB4.
Left unnamed on purpose: op23 (0x82271C30) and op21 (0x82175C20).
Two files (isl_report.py's docstring and structures/isl-builtins.md) recorded the
same blocker on a faithful per-phase condition listing: that it needs the coroutine
entry points from start_coroutine's operand. Measured against isl.call_sites(),
which enumerates by scanning the encoding rather than by decoding and so is an
independent denominator:
linear + jumps, stopping at ret (what the tool did) 133 / 2846 = 4.7%
linear + jumps, continuing past ret 2275 / 2846 = 79.9%
... + following start_coroutine (the recorded fix) 2355 / 2846 = 82.7%
plain linear decode, no control flow at all 2846 / 2846 = 100.0%
Following the coroutine entries buys 2.8 points. Disc-wide, a plain linear decode
from the first phase base reaches 25705/25705 call sites over all 28 stages, and
28/28 decode clean to code_end with no desync.
The real bug was isl.dis ending on `if op == 20: break`. Op 20 is `ret`, but this
is a coroutine VM -- the thread suspends and resumes at the FOLLOWING instruction,
so code continues past it. dis() now takes stop_at_ret (default True, preserving
the old output: data/isl-stage02.txt regenerates byte-identical) and
isl.linear_offsets() is the correct walk.
By-product, kept with its control: start_coroutine's target is staged slot 0 --
73/83 phase-1 sites land on a valid instruction, against a 38.7% chance rate for an
arbitrary 4-aligned offset.
New artefact data/isl-stage02-phase-ends.txt with a committed generator
(isl_report.py phase-ends). It shows END_PHASE's call site is the WRONG place to
read a clear condition: all 12 Stage-02 sites sit in one stereotyped outro. Not
settled, and stated as such: op10/op13/op14/op21/op23 are unread handlers, so the
condition in the poll loop upstream cannot be named yet.
Answers the backlog's open "first step: diff the two readers across the disc and
count disagreements", statically over every IDXD object.
Of 7750 objects and 738922 named fields whose true value is numeric, legacy
get_f32 is correct 39.42%, returns None (harmless) 43.04%, and returns a WRONG
NUMBER 17.54% (129612 fields).
The wrongness has an exact predicate: single-record objects 0 of 29822 wrong
(0.00%); multi-record objects 129612 of 709100 (18.28%). The mechanism is in
get_raw itself -- it flattens the pool to a token list, finds the FIRST occurrence
of the key, and returns the preceding token, with no notion of records. So every
record after the first inherits record 0's value: Weight truth=1.0 legacy=0.3,
Points truth=10000 legacy=4000.
Practical rule recorded: a get_f32 number from a single-record object is safe; from
a multi-record object only the first record is.
Withdrawn in the same document: my first sweep compared against "the string before
THIS field's own key" and reported 65.90% -- that is not what get_raw does, so the
figure is not the legacy reader's error rate.
Two updates to the mission-freeze entry, both measured.
DONE: the entry's "first step, revised" was "make pilot.py shoot, then re-run
ob_flag.py ... the actual obstacle is that nothing the pilot does moves the
counter". pilot.py now has SYLPH_WEAKEST=1 (target score scaled by remaining
hull) and the next run moved REMAINING OB 008 -> 007 concurrent with the live e010
floor dropping 16 -> 15. The counter is fully solved; the freeze work now needs
only a frozen sample for the v2 wait probe.
DEAD END, with numbers: --log_mask=0 does not surface kernel call traces. A full
Stage 02 run produced 199 MB at ~33 MB/min, and a 300k-line tail is 254127 A>
(Apu/XMA), 42444 d>, 2897 G>, 532 w>, with ZERO k> and only 14 K> lines per 58k of
boot. XamShowSigninUI / KeWaitForSingleObject / NtWaitForSingleObject each appear
exactly once in the whole log -- an export listing, not call traces. That
independently confirms the entry's own cost note: those calls are kHighFrequency
and silent without --log_high_frequency_kernel_calls=true.
The run also did not freeze (healthy TIME 00:24.28 -> 03:33.28), making it the
fourth consecutive non-freezing run.
mission-phase-advance.md still carried "🔴 Not settled: where the script bytecode
lives". It was settled, in a sibling file I had never opened:
structures/mission-script-ssb.md, which says in its own opening that this file
"recorded the bytecode as not on the disc under any obvious name. It is on the
disc."
Verified independently before correcting: name_hash resolves Stage\script.tbl (the
manifest, 838 B compressed) and 28 Stage\StageNN.ssb records -- S01-S16, S18-S29 --
all in dat/GP_MAIN_GAME_S.pak, with Stage\Stage17.ssb absent, matching the loader
guard sub_8225EC78 (n == 16 || n > 32).
Recorded WHY the last three iterations' searches could not have worked, since that
is the reusable part: MISSION1..33 and MISSION_*_PRT are manifest FIELD KEYS, not
record names. The records are named Stage\StageNN.ssb and the manifest maps
between them, so probing the record namespace with field-key names cannot hit --
2148 hashes over 41 paks and 26443 records returned zero for that reason alone.
Two side findings survive: the .embsec_ sections hold PPC code, not bytecode; and
the archive lookup keys by tag_hash (0x00FFFFDF) while the pak record index uses
name_hash -- not interchangeable.
0x82448AA0 and 0x82448C50 are not strcmp. Both pass the name to 0x82447DF0 and
use the result as a key: the first binary-searches a table of 16-byte records
(x16 for the end, /16 for the count, >>1 for the midpoint), the second packs the
hash into a three-word key and calls 0x8244E338.
Decoded 0x82447DF0 from the disassembly as ((sum of extsb bytes) & 0xFF) << 24 |
(rolling mod 0x00FFFFDF) -- i.e. tag_hash. tools/re-capture/unitgroup.py::tag_hash
already documents itself as "a transcription of sub_82447DF0", so this was in the
corpus; the useful part is that it identifies which hash the ARCHIVE uses.
That exposed a defect in my previous sweep: it probed name_hash (0x00FFF9D7,
lowercased) only, while the archive keys by tag_hash (0x00FFFFDF, case-sensitive).
Re-ran with BOTH: 1380 names -> 2148 distinct hashes, 41 paks, 26443 records.
STILL ZERO. The pak-record hypothesis is now refuted with the right hash rather
than merely unsupported.
Left open: the XEX's compressed/encrypted region (default.xex never decrypted
here), plus two untried static threads -- the second pair of SCRIPTS/GP_SCRIPT
references at 0x82262374 / 0x822622bc, in a different function, and tracing
[r31+80] back to whoever opened the archive being name-tested.
My earlier negative was under-scoped: it probed 35 paks, missing hidden/resource3d/
and dat/movie/. Redone across every *.pak on the disc -- 41 archives, 26443
indexed records, 768 distinct name hashes -- still ZERO hits. The scripts are not
a pak record under any of those names.
Located the three loader strings by VA (the .pe is a flat VA dump, VA = 0x82000000
+ offset) and pulled their xrefs from sylpheed.db:
SCRIPTS 0x820a823c <- 0x8225f1b8, 0x82262374
GP_SCRIPT 0x820a8244 <- 0x8225f168, 0x822622bc
MISSION1 0x820a8264 <- 0x8225eed8
Disassembling sub_8225EE20 shows both archive-name references call THE SAME
routine 0x82448AA0 with (object, string) and test the result with cmpi -- so the
loader COMPARES a name against an already-open archive rather than building a path
like dat\GP_SCRIPT.pak. That fits GP_SCRIPT being a name an archive reports,
which is why no such file exists to find.
Next concrete steps recorded: identify 0x82448AA0 (strcmp/strstr/hash-compare) and
its neighbours 0x82448C50 / 0x8216F218, and trace [r31+80] -- the object being
name-tested -- back to whoever opened it. That names the container.
Both candidates this file named for the missing phase-script bytecode were tested
statically, from the flat-VA .pe and the extracted paks.
REFUTED: the seven .embsec_ sections hold PPC CODE. Parsed from the section table
(offset 592, 40 bytes apart) they total 129472 bytes, matching this file's own
"~130 KB" estimate, so they are the right sections -- but six of seven begin
7d8802a6 (mflr r12) and all carry the standard prologue (stwu r1,-N(r1), std
r30,-16(r1), bl). That is not bytecode for a 147-builtin VM.
Also recorded: ".embsec_P" from `strings` is a false lead -- the name field is
exactly ".embsec_" and the P is byte 0x50 of the following VirtualSize (0x1350 =
4944, the fifth section's size).
CORRECTION: this file says grepping for MISSION_START_PRT "returns nothing". That
grep was over the DISC EXTRACTION; all five MISSION_*_PRT names are present in the
executable image, in an .rdata table reading "SCRIPTS" "GP_SCRIPT" "script load
cancel\n" "MISSION1".."MISSION33".
NARROWED: the paks are name-hash addressed, so names can be probed rather than
eyeballed. 616 distinct hashes -- MISSION1..33 and the five MISSION_*_PRT under
prefixes SCRIPTS\, GP_SCRIPT\, scripts\, script\, SCRIPT\ and none, with suffixes
.prt/.PRT/.scr/.bin and none -- across all 35 paks: ZERO hits.
Left open: the XEX's compressed/encrypted region, or a name outside those guesses.
GP_SCRIPT is the strongest remaining thread -- referenced by code, absent from disc.
SYLPH_WEAKEST=1 (a25ddab) worked on its first live run: concentrating fire on the
already-damaged attacker produced the kill that four previous runs could not.
All gates enforced first -- FLIGHT confirmed, stage asserted, mission clock shown
advancing -- then:
live e010 floor: 16 for samples 0-11, then 15 for samples 12-39 (one death)
counter: 004 -> 008 at 02:06.33 (t=120 arrival, +4)
008 -> 007 at 03:10.28 <-- DECREMENT, bracket (171.2s, 190.3s]
007 -> 011 at 03:48.40 (t=210 arrival, +4 FROM 7, not from 8)
011 held to 06:50.30 -- ceiling 11, where every prior run reached 12
Conclusive because: it fell by exactly 1 and not by 4, so the counter tracks CRAFT
not squadrons; exactly one attacker died and exactly one decrement occurred, in the
same window; and the CEILING moved with it -- a counter that merely read arrivals
would still have shown 012. Control held: turrets fell 109 -> 92, seventeen
deaths, none of which moved it.
Settled reading: REMAINING OB is the number of objective-marked craft still alive,
the marked craft being exactly the members of the phase's A-route squadrons. It
rises by a squadron's membership on that squadron's route arrival time and falls by
one per marked craft destroyed. Every number was predicted from Route_S02.tbl and
UnitGroup_S02.tbl before it was measured.
pilot.py gains SYLPH_WEAKEST=1, which scales a target's score by its remaining
hull (pos+0x154) so the pilot finishes what is already hurt instead of
re-engaging whatever is nearest. Motivated by 00dc016: over ~8 minutes the pilot
damaged 14 of 16 e010 attackers (hulls 360..500) and killed none, because 500 HP
spread across a squadron kills nobody.
STATUS: the flag is implemented and its targeting works -- 3105 of 3105 target
samples selected e010 -- but it is UNVERIFIED in combat, because the run it was
written for was lost.
That loss is the second half of this commit. The run printed "READY ROOM / >>>
HUD / Stage 02 OK" and I began the experiment; there was no FLIGHT: line, because
the flight check failed three times and fell through silently while the next line
read like success. The game was frozen on a near-black screen (screen_id `other`,
mean 10.8/2.8/2.1, frozen.py max_pixel_delta=0) and the pilot's every sample from
t=0.0 to t=406.1 is byte-identical with speed 0.
assert_stage.py could not have caught it: it reads the DEFINITION table, which is
populated when the STAGE loads, independently of whether the mission is running.
Recorded in nav-guards.md with the rule -- enforce the flight gate with a non-zero
exit, and run the three-crop TIME liveness check before any experiment.
Fourth Stage 02 run: 520s of pilot with SYLPH_PREFER=e010 (198 fire=1), per-class
counts every 10s, OB+TIME throughout.
ARRIVAL TIMING n=4: 008 first at 02:05.00 (step bracket contains 120), 012 first
at 03:45.47 (bracket contains 210), then 012 held 3.5 more minutes with no fall
and no 016.
THE BLOCKER IS MEASURED, and it is not aim. Reading hull at pos+0x154 for every
live e010 at the end: 14 of 16 are damaged, hulls 360..500, lowest 360/500 = 28%
gone. The shots land; they just do not finish. A kill needs roughly 3-4x longer
on one target than ~8 minutes of combat produced.
The contrast with the control shows the mechanism is hit points: e007 turrets
(HP 100) lost ~9 dead, 106 -> 97 live, while e010 attackers (HP 500) lost none and
the floor never moved off 16.
Decrement therefore still unproven after four runs, and "fly longer" is not a fix
since ~half of runs end early. Options recorded for a redesign: point the pilot
at the WEAKEST attacker (hull is readable per entity; one is at 360/500), use
missiles (never deliberately fired), or wait on a scripted kill.
Third Stage 02 run, pilot with SYLPH_PREFER=e010 (312 fire=1 samples), per-class
live counts logged every 11s beside the HUD (new tools/re-capture/class_count.py).
CONTROL CONFIRMED: the live turret population fell 108 -> 101 -- seven e007 deaths
-- and REMAINING OB never decremented, only rose. Previously this was inferred
from a run whose kill log happened to be turrets; it is now measured with the
classes counted directly.
ARRIVAL TIMING n=3: 004 -> 008 in (108.7s, 125.5s] and 008 -> 012 in (204.2s,
221.7s], both brackets containing the predicted 120 and 210.
The live e010 count sat at exactly 16 in 20 of 26 samples -- precisely phase 1's
e010 roster (ADT102/ADT107/ADT113/ADS151, each n=4) -- an independent runtime
corroboration of the static roster.
DECREMENT STILL UNPROVEN: the e010 floor never fell, so no marked attacker died
and the counter had no chance to move. Three runs have failed to kill one. The
blocker is combat effectiveness, not instrumentation.
Artifact recorded: six of 26 class samples read 17-28. Spikes are always upward
and transient -- the tool dedups on a position triple read just after the pattern
scan, so an entity written between the two reads is counted twice.
pilot.py with SYLPH_PREFER=e010, 300s, mission clock sampled throughout.
RISES REPRODUCED: 004 -> 008 in (114.8s, 134.7s] and 008 -> 012 in (196.4s,
213.3s], both brackets containing the predicted 120 and 210. Second independent
run, so the arrival half is now n=2.
DECREMENT INCONCLUSIVE. The pilot fought properly -- 424 fire=1 samples, 865 with
the target inside 1500 units, closest approach 79, target e010 throughout, hull
untouched -- and 13 ADAN died (129 -> 116). The counter held 012 for 98s. That is
NOT evidence against the decrement: the 13 dead were not identified by class, this
pilot's kills historically skew to turrets, and the live e010 count ROSE over the
run so attacker deaths cannot be inferred from it. Recording it as inconclusive
rather than as a negative, which is the error this corpus has already logged twice.
POSITIVE RESULT: at TIME 05:56.85, both arms sampled together, REMAINING OB = 012
while live UN_e010_ADAN_Attacker_S = 23. So the counter is NOT the live attacker
head-count; it tracks a subset, and 12 is exactly the three A-route squadrons'
membership (3 x n=4). Phase 1 fields only 16 e010, so 23 live means later-phase or
F-route squadrons joined without touching the counter.
Anomaly recorded, not explained: the enumeration reports 2 _Player entities at two
distinct positions.
Predicted from the disc alone, before the run: ADT102/ADT107/ADT113, each n=4,
arrive at t=0/120/210, so the counter reads 004, steps to 008 at t=120 and 012 at
t=210, and goes no higher in phase 1.
Measured with the mission clock sampled beside the counter throughout:
004 held over six samples to 01:45.44
008 first seen at 02:00.87 -> step bracketed in (105.4s, 120.9s]
008 held over five samples to 03:17.55
012 first seen at 03:30.66 -> step bracketed in (197.6s, 210.7s]
012 held four more samples to 04:36.95, no 016
Both steps land on the predicted second, and the ceiling holds. Three independent
features -- starting value, both step TIMES, and the ceiling -- came from
Route_S02.tbl and UnitGroup_S02.tbl with nothing fitted to runtime data. So the
counter's rises ARE the A-route attacker squadrons arriving.
Status upgraded to CONFIRMED for the rises. The DECREMENT half stays 🟡 and is
now the only open part: this run killed nothing, and the single observed 12 -> 11
remains one sample.
The OB kill test did not run -- the guest froze about a minute into the mission --
but sampling the HUD's own mission clock beside the counter caught it instantly.
Twelve samples over ~4 minutes of wall time: the first reads TIME 00:43.24, every
one after reads 01:02.23 unchanged, with REMAINING OB 004 throughout. frozen.py
agrees (max_pixel_delta=0), movers in the entity window are 0, and screen_id still
says `flight` with the process alive.
Worth a file because the mission clock defeats both traps this corpus has paid
for: it is the simulation's own counter, so neither a frozen world nor a finished
one advances it, where screen_id and pixel-churn are fooled by a GAME OVER screen
that animates. Rule recorded: "X never changed" is only evidence if TIME changed
across the same window.
It also closes an open ❔ from earlier this session -- entity-position-anchor-
refuted.md's "0 of 64 regions changed across 357 MB, cause unknown, not
reproduced". Same signature, now reproduced: it was this freeze.
Still unrun for the fourth iteration: the kill test itself. Not conceptually
blocked -- blocked by attrition, with this run dying at t~62s, before even the
t=120 arrival the trajectory prediction needs.
Swept every Route_*_p<n><kind> in every stage table, resolving each squadron to
its unit via that stage's own UnitGroup, with F/S/M routes as the control:
A/B routes : 23 of 67 resolved are attacker/bomber = 34.3%
F/S/M control: 27 of 928 = 2.9%
A ~12x enrichment, so the signal is real and not a one-stage accident. But "A
means the marked attackers" remains too strong: of the 67 resolved A/B routes, 35
carry UN_mn040_Asteroid_Big and 9 carry UN_n001_TTRL_Box (TTRL = tutorial). A
reading covering all four is "things the mission wants shot" -- attackers in a
combat stage, asteroids in an asteroid stage, boxes in a tutorial -- which keeps A
as an objective marker rather than a unit-class marker, but that is a reading of
four unit types, not a measurement.
Records a near-miss: a truncated listing showed the first ~26 rows, dominated by
early stages, in which every resolved row was an attacker. I nearly wrote "23/23,
unanimous". The full set is 23/67 -- the tail of a sorted listing is not the
distribution.
The Stage 02 derivation is unaffected: its three A routes are still exactly the
three n=4 attacker squadrons.
Pure static, from Route_S02.tbl. Only THREE routes in the entire stage are kind
'A', all in phase 1 -- ADT102, ADT107, ADT113 -- and every one is a
UN_e010_ADAN_Attacker_S squadron of n=4. The fourth phase-1 e010 squadron,
ADS151, is kind 'F'.
Their first-keyframe times are t=0, t=120 and t=210, so counting only the 'A'
squadrons predicts the counter exactly: 004 at entry, 008 at t=120, 012 at t=210,
and never above 012 in phase 1. That is what every run has shown -- starting
value, step size and ceiling all fall out of the disc with nothing fitted.
It also explains the two awkward observations: the fifteen-minute hold at 012 (all
'A' squadrons arrived, and those kills were turrets), and why 016 never appeared.
The earlier prediction of a 016 cap is WITHDRAWN: ADS151 is an F route and is not
counted, so the ceiling is 12.
Kept 🟡: "A = marked attack objective" is inference from three routes in one
stage, consistent with the mission dialogue and with A appearing only in early
phases, but the letter's meaning is not proven and the kill test is still unrun.
Replaces the 16/4 MEAN with a per-group measurement, closing the caveat that a
5/5/3/3 split would have made the counter's step-of-four a coincidence.
Route_S02.tbl names routes Route_<squadron>_p<phase><kind>, so the squadron->phase
link is readable off the disc. Stage 02 has 37 phase-1 squadrons; intersecting
with the e010 roster gives exactly FOUR -- ADS151, ADT102, ADT107, ADT113 -- and
every one has n=4, summing to 16 craft.
The five e010 groups NOT in phase 1 (ADN204, ADN206, ADN208, ADN209, ADS251)
include both n=6 groups and the n=9 group. That is why the stage-wide mean
misled: the odd-sized squadrons belong to later phases. Restricted to phase 1 the
size is uniform.
Pure static, no emulator. The hypothesis itself stays 🟡 -- the runtime
kill-one-e010 test is still the thing that would confirm or kill it.