Both defects from the previous iteration are fixed by measuring instead of assuming, and the fix immediately promotes a 🟡 result to ✅. Labelling: the previous probe assumed object+0x04 -> name_record+0x10 -> char* and resolved 0 of 116. wave3_probe.py searches for the chain per record instead, the way unit_discover.py does, and resolves 116 of 116 -- every one through the pointer at +0x04 with the string at delta 0x00, not 0x10. The 0x10 belongs to the definition object (vtable 0x820af844); the spawned-entity record (0x820af030) uses 0x00. Carrying one over to the other cost the last run. Stride: measured, not assumed. Gaps between consecutive records are min 32, median 800, with common values 800, 640, 608, 576, 416 and 32. There is no fixed record size, so the old RECLEN=0x200 window truncated large records and overran small ones -- which is why its busiest fields were the last words of the window. Future diffs must bound each record by the next record's address. With labels available, the "116 records == 116 roster members" claim was tested properly and is promoted from 🟡 to ✅. The multiset of unit types matches the static roster exactly: Turret 21/21, e106 Destroyer 19/19, f106 Destroyer 14/14, f105 Cruiser 11/11, ASFrigate 9/9, ISCMissile 9/9, Attacker_S 9/9, e105 Cruiser 7/7, DeltaSaber_T 7/7, ArrowHead 6/6 -- 10 of 10 exact. A coincidental total is possible; a coincidental distribution over ten unit types is not. The game allocates one record per roster member at mission load. Not settled: REMAINING OB at 0xbdb59668 held 95748078 unchanged all run. That address is known to be run-dependent, and this was one of the misses, so the run cannot say whether the pilot killed anything. Re-hunting it is a precondition for the kill-versus-no-kill test, not an optional extra.
15 KiB
The arrival timetable, and what the entity table is really counting
Status: ✅ the static arrival timetable is in Route_S<NN>.tbl; 🟡 the runtime
entity table appears to be one record per roster member, pre-allocated (n = 1);
🔴 counting entity records does not show wave arrivals; ❔ the timetable's
time unit is still unmeasured.
✅ The arrival timetable is the routes' first keyframe time
Each record in Route_S<NN>.tbl starts with a time
(structures/stage-mission-tables.md), and
that time is not always zero. For Stage 02, grouping the 120 route records
by phase and by first-frame time gives a timetable:
| phase | t=0 | 50 | 60 | 90 | 120 | 150 | 170 | 210 | 240 |
|---|---|---|---|---|---|---|---|---|---|
| 1 | 25 | 3 | 3 | 3 | 2 | 1 | |||
| 2 | 37 | ||||||||
| 3 | 36 | 1 | 1 | 3 | 1 | 4 |
Phase 2 has every route at t = 0, which is the useful detail: it means t
is measured from the start of its phase, not from the start of the mission.
Entering a phase releases that phase's t = 0 group, and the rest arrive on the
offsets above.
That is the "algorithm with parameters" this line of work was after: the
schedule is data — squadron, phase, arrival time, and the path flown in — spread
across UnitGroup (who) and Route (when and from where). Stage 02 phase 1
spawn points span x ∈ [-47371, 20800], y ∈ [-18856, 27395], z ∈ [-25349, 25036].
❔ The time unit is not measured. At 30 Hz, t = 240 is 8 s; as seconds it is 4 min. Distinguishing them was the point of the run below, and the run did not manage it.
🔴 Counting entity records does not show arrivals
One Stage 02 flight, 210 s sampled every 15 s, counting aligned occurrences of
the spawned-entity vtable 0x820af030
(structures/unit-struct-runtime.md) in an
8.3 MB span of guest RAM:
t=0s 116 t=45s 116 t=90s 116 t=135s 116 t=180s 116 t=210s 116
Perfectly flat. No steps at 90, 120, 170, 210 or anywhere else. The hypothesis that a live entity count would step up at the timetable's offsets is refuted for this proxy — but the reason matters more than the refutation.
✅ 116 records, 116 roster members — confirmed by composition (2026-08-24)
Promoted from 🟡 to ✅. The section below argued from a single number, 116 = 116, and explicitly refused to promote it on that. It is now confirmed on a far stronger test: with the records labelled (see the labelling section at the end of this file), the multiset of unit types matches the static roster exactly, not just the total.
| unit | static roster | live records |
|---|---|---|
UN_e007_ADAN_Turret |
21 | 21 |
UN_e106_ADAN_Destroyer |
19 | 19 |
UN_f106_TCAF_Destroyer |
14 | 14 |
UN_f105_TCAF_Cruiser |
11 | 11 |
UN_e108_ADAN_ASFrigate |
9 | 9 |
UN_e201_ADAN_ISCMissile |
9 | 9 |
UN_e010_ADAN_Attacker_S |
9 | 9 |
UN_e105_ADAN_Cruiser |
7 | 7 |
UN_f001_TCAF_DeltaSaber_T |
7 | 7 |
UN_f003_TCAF_ArrowHead |
6 | 6 |
10 of 10 exact, across counts from 21 down to 6. A coincidental total can happen;
a coincidental distribution over ten unit types cannot. The game allocates one
entity record per UnitGroup roster member at mission load, and every one of
them exists from the first sample — long before the member could have "arrived".
🟡 116 records, 116 roster members (original argument, superseded above)
UnitGroup_S02.tbl has 111 squadrons whose Count fields sum to 116
members. The entity table holds 116 records, from the first sample onwards.
The natural reading is that the game allocates one entity record per roster
member when the mission loads, and that arriving on a route makes an existing
record active rather than creating a new one. That would explain the flat count
exactly, and it would mean 0x820af030 records are a roster, not a live
population — consistent with the existing note in unit-struct-runtime.md that
these records are "not live state".
This is one data point and must not be promoted on it. 116 = 116 is a striking match but n = 1, and the obvious refutation has not been run: load a different stage and check the record count against that stage's member sum (S01 = 42, S10 = 14, S16 = 2, S29 = 95). S16 with 2 members would be decisive and cheap if a save for it existed — the only save slot available is Stage 02, which is the blocker.
What this run did not settle
- The timetable's time unit (frames vs seconds).
- Whether arrivals are visible at all from memory, and where. The live/dead flag is presumably a field inside the 116 records; diffing those records over a mission is the obvious next probe, and it is well scoped now that the record set is bounded and located.
- Whether the 116↔116 match generalises.
Operational note
Cold, the boot spent 204 s in the title movie before the first Ⓐ — the
wait_screen oracle correctly refused to tap through it. A 300 s probe plus that
boot exceeded a 580 s turn budget and the first attempt was killed with its
output still in the pipe. Log runs to a file (> /tmp/x.log 2>&1) rather than
piping to tail, or a timeout loses the evidence as well as the run.
Follow-up: diffing inside the 116 records (2026-08-24)
Status: 🟡 supporting evidence for the pre-allocated-roster reading; 🔴 the arrival flag was not found; 🔴 two defects in my own probe recorded.
The count being flat meant an arrival must flip a field inside a record, so
this diffed all 116 records (0x200 bytes each) every 5 s for 170 s of Stage 02
flight, under the pilot. tools/re-capture/wave2_probe.py.
🟡 Only 10 of 116 records are dynamic
Across the whole run, 10 distinct records ever changed a byte. The other 106 never changed at all — not one word, over 170 s of live mission.
That is a second, independent line of support for the reading in the section above: if all 116 were live entities they would be moving, and position words would churn in most of them every tick. Instead the overwhelming majority are inert, which is what a pre-allocated roster of not-yet-activated members looks like.
Correction (2026-08-24, from the user). The first version of this paragraph
hedged the conclusion by saying Stage 02's roster is heavy with
UN_e007_ADAN_Turret and "a turret does not move even when it is very much
alive", so inert and not-yet-arrived could not be told apart. That hedge was
wrong, and it was wrong because I read the unit's English name instead of its
data. "Turret" in the early missions is a craft — a distinct enemy type — not
an anti-aircraft mount on a capital ship. The tables say so plainly:
UN_e007_ADAN_Turret |
AI_ADAN_CraftSquadron_Rookie ×70, AI_ADAN_CraftSquadron_Veteran ×19 |
UN_e007_ADAN_Turret_EX4 |
AI_ADAN_CraftSquadron_Ace ×4 |
AI_Structure is used by |
asteroids, UN_mn500_ADAN_FloatingMine, UN_bf001_TCAF_SchlosBase, UN_bf002_TCAF_TransitPlatform, UN_be005_ADAN_SpaceFortress |
AI_ADAN_CraftSquadron_* is Type = Squad in
AIParams, with the full manoeuvre-weight
block — these things fly. AI_Structure exists precisely for the units that do
not, and no *_Turret unit ever uses it. The type also matches "an enemy of the
first few missions": 93 turret members in total, in S01 (20), S02 (21), S03 (11),
S04 (6), S05 (12), S06 (19), and then nothing until S27 (4).
So the caveat is withdrawn, and removing it makes the evidence stronger, not weaker: the 106 inert records cannot be explained away as immobile scenery, because Stage 02's turrets are craft that should be manoeuvring. The reading that they are pre-allocated, not-yet-activated roster entries survives a test it would otherwise have dodged.
The general lesson, which cost a wrong hedge: do not infer a unit's behaviour
from its English name — check its AIID. The roster is self-describing and
the answer was already in the committed dump.
🔴 No transition matching the arrival timetable
The prediction under test was groups of 3, 3, 3, 2, 1 records changing state at t = 90, 120, 170, 210, 240 s. Nothing of the sort appeared. Field-change events are spread evenly across ticks (1–7 words per 5 s tick, 1052 in total), with no cluster at any predicted time and no field that transitions once for exactly three records.
So one of these is true, and this run cannot say which:
- the timetable's
tis not seconds (at 30 Hz the whole phase-1 schedule finishes inside the first 8 s, before the probe's first sample); - arrival is not marked in the
0x820af030record at all; - phase 1 was not where the mission actually was during the run.
🔴 Two defects in this probe
-
label()failed for all 116 records — every one resolved to?. Theobject+0x04 → name_record+0x10 → char*chain from structures/unit-struct-runtime.md did not resolve here. Without labels the records cannot be tied back to squadrons, which is exactly what would have made the result decisive: "these three records that changed at t=90 areADN110,ADN111,ADN112" is evidence, "records 18, 32, 99" is not.unit_discover.pyalready solves this association and should be reused rather than re-derived. -
RECLEN = 0x200is probably too wide. The busiest fields by far are+0x1e0 … +0x1fc— the last eight words of the window — which is what spilling into the next object looks like. The record's true size was assumed, not measured. -
A method error worth keeping: the first attempt deferred all analysis to the end of the run, and the turn's timeout killed it with 240 s of collected data still in memory and nothing written. The probe now streams every transition to
/tmp/wave2-trans.tsvand prints a partial ranking every 60 s. Combined with the 219 s cold-boot title movie, a run that only reports at the end has perhaps 300 s of usable budget and one bad estimate loses everything.
Next
Fix the record→squadron association first (reuse unit_discover.py), and
measure the record stride instead of assuming 0x200. Until a record can be
named, no diff of these records can confirm or refute an arrival.
Competing model: waves are event-gated, not clock-driven (2026-08-24)
Status: 🟡 plausible and partly supported; ❔ untested. Raised by the user, and it is a better fit to the evidence than the pure-clock reading I had been assuming.
The proposal
The next wave may not arrive at a fixed time at all. It may be released when some condition is met — a number of enemies destroyed, an objective completed, or another event — with the route's first-keyframe time being a delay after the trigger, or simply the start of the fly-in animation once released.
Why it fits better than the clock model
- My two runs showed no arrivals. Both were flown by the survival pilot, whose job is to stay alive, not to shoot. Under a clock model, arrivals at t = 90/120/170 should have happened regardless of what the player did, and nothing appeared. Under an event model, a player who kills nothing triggers nothing — which is exactly what was observed. This is weak evidence (the proxy may simply not show arrivals) but it points the right way.
DisableIntervalfinally has a plausible referent. The name implies a gating interval that squadrons can opt out of. A pure timetable in the route table would not need a per-squadron flag to disable it.
🟡 The flag and the delayed routes do not co-occur
Cross-tabulating every squadron's DisableInterval against the start time of
its routes, across all stages:
| route t=0 | route t>0 | |
|---|---|---|
DisableInterval = No |
876 | 68 |
DisableInterval = Yes |
55 | 0 |
No squadron that disables the interval has a delayed route. If the two were independent we would expect about 3.7 such routes; the probability of seeing zero by chance is roughly 0.02. That is suggestive, not conclusive, and it is recorded at 🟡 deliberately — a p ≈ 0.02 association on one derived statistic is exactly the kind of near-fit that has already misled this corpus once (the "109 of 111" squadron-name base in structures/unit-group-table.md).
Name matching for this table is sound: for Stage 02, 108 of 111 squadrons have routes and 113 route names resolve, so the cross-tab is not an artefact of failed joins.
The test that separates the two models
Two runs of the same stage, differing only in what the player does:
- Run A — kill nothing. Evade for ~250 s. Clock model predicts arrivals anyway; event model predicts none.
- Run B — kill aggressively. Event model predicts arrivals that track kills, not the clock.
If Run B produces activity that Run A does not, the schedule is event-gated and the route times are post-trigger delays. This needs the record→squadron labelling fixed first, otherwise neither run can say which squadron arrived — the blocker already recorded above.
A cheaper precondition worth checking first: whether REMAINING OB at
0xbdb59668 moves in Run B but not Run A. That is a known-good counter and
needs no new decoding.
Labelling the records, and the real stride (2026-08-24)
Status: ✅ both defects from the previous section are fixed and measured.
✅ The id chain: record+0x04 → pointer → +0x00
The previous probe assumed object+0x04 → name_record+0x10 → char* and resolved
0 of 116. tools/re-capture/wave3_probe.py searches for the chain instead of
assuming one — for each record it walks the first 24 words, treats any
guest-range word as a pointer, chases it, and accepts the result only if it
lands on a UN_/NP_-prefixed string, optionally through one more indirection.
Result: 116 of 116 resolved, every one by the same chain — pointer at
+0x04, string at delta 0x00, not 0x10. The 0x10 in
structures/unit-struct-runtime.md is the
delta for the definition object (vtable 0x820af844); the spawned-entity
record (0x820af030) uses 0x00. Assuming one from the other is what cost the
previous run.
✅ The stride is variable — 0x200 was wrong
Measured gaps between consecutive record addresses: min 32, median 800, with
common values 800, 640, 608, 576, 416 and 32. There is no fixed record size, so
the previous probe's RECLEN = 0x200 window both truncated large records and
ran past small ones into their neighbours — which is exactly why its busiest
"fields" were the last words of the window.
Any future diff must bound each record by the next record's address rather than by a constant.
❔ REMAINING OB at 0xbdb59668 did not read as a counter this run
It held 95748078 for the whole run, unchanging. That address is known to be
run-dependent (structures/mission-objective-counter.md
records it recurring in about 5 runs of 7), and this was one of the misses, so
the run cannot say whether the pilot killed anything. Re-hunting the counter
with ob_hunt.py is a precondition for the kill-versus-no-kill test, not an
optional extra.