Files
Sylpheed/docs/re/mission-wave-arrivals.md
Sylpheed RE agent c2e7f8f8e4 re: the flat entity count is explained -- arrivals are not spawns
Built-in 12 activate_unit returns 0 when the live object is NULL: it registers an
object that already exists and cannot create one. So a wave arrival is a craft
reaching a point on its route, not a new record, and a count of entity records
was never going to move.

Stage 02's UnitGroup totals Count = 116 across 111 squadrons, matching the live
probe's flat 116. The natural objection is that 116 might match by accident --
it does not: across all 28 stages the roster totals run 2..116, and 116 is the
MAXIMUM and unique to Stage 02, with nothing else within 5.

Three independent things agree: the handler cannot spawn, the roster totals 116,
and the live count sits at 116 while 22 of 113 routed squadrons have a route
starting after t=0.

Still n=1 for the per-member identity (only a Stage 02 save exists), and this
cannot settle timetable-vs-event -- that needs a position probe, not a count.
2026-08-26 04:59:32 +00:00

553 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# The arrival timetable, and what the entity table is really counting
Status: ✅ the static arrival timetable is in `Route_S<NN>.tbl`;
**arrivals ARE observable — and the time unit is SECONDS** (2026-08-25, see
below); 🔴 counting *entity records* does not show them, which is what the
original negative measured.
> ✅ **CORRECTION 2026-08-25.** "Counting entity records does not show wave
> arrivals" is still true, but the conclusion drawn from it — that arrivals
> could not be seen — was wrong. The **script VM's own unit table** shows them
> directly. See [script-runtime-probe](script-runtime-probe.md).
## ✅ 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](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.
## ✅ The timetable is confirmed live, and `t` is in SECONDS
The three squadrons the phase-1 clear condition polls — ADN110, ADN111, ADN112 —
all have **first-keyframe time `170`** on their `_p1F` routes:
```
Route_ADN110_p1F FrameCount=3 first field: 170
Route_ADN111_p1F FrameCount=3 first field: 170
Route_ADN112_p1F FrameCount=2 first field: 170
```
In a live run (`data/phase-watch-s02.txt`) all three flipped from
state 1 (not deployed) to state 2 (active) at **~143 s on the probe's clock**.
The probe starts after flight entry and after the pilot bind, so its zero sits
roughly 1020 s into the mission — putting the arrival at about **155165 s of
mission time against a predicted 170**.
**That pins the unit as seconds.** The alternatives are not close: 170 *frames*
at 30 fps is 5.7 s, so those squadrons would have been active at the first
sample, and they demonstrably were not. Three squadrons sharing one timetable
entry all changing state in the same 5 s sampling window is not coincidence.
⚠️ It is a **match, not an exact measurement** — the probe clock is not aligned
to phase start, so this bounds the unit rather than verifying the value to the
second. Logging mission start and phase start in the same stream would close
that.
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](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`](structures/stage-mission-tables.md), 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 (17 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 `t` is **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 `0x820af030` record 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 `?`. The
`object+0x04 → name_record+0x10 → char*` chain from
[structures/unit-struct-runtime.md](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 are `ADN110`, `ADN111`, `ADN112`" is evidence,
"records 18, 32, 99" is not. `unit_discover.py` already solves this
association and should be reused rather than re-derived.
* **`RECLEN = 0x200` is 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.tsv` and 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.
* **`DisableInterval` finally 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](structures/unit-group-table.md)).
### ❌ WITHDRAWN (2026-08-26) — the p ≈ 0.02 does not survive the clustering
The caution above was right and did not go far enough. Breaking the same
cross-tab down **by stage** destroys the result:
| stage | No, t=0 | No, t>0 | **Yes, t=0** | **Yes, t>0** |
|---|---|---|---|---|
| S04 | 42 | 5 | **10** | 0 |
| S14 | 22 | 0 | **45** | 0 |
| the other 9 stages with delayed routes | 836 | 63 | 0 | 0 |
All 55 `DisableInterval = Yes` routes live in **two** stages, and **45 of them
are in S14, which has no delayed route at all** — no squadron there is delayed
whether it sets the flag or not, so those 45 cannot be evidence of anything.
Only **one** stage, S04, contains both a `Yes` squadron and a delayed route.
The pooled p ≈ 0.02 treated 1 023 routes as independent draws when they are
really two clusters. The test that actually bears on the hypothesis is within
S04, and there it is:
P(none of S04's 5 delayed routes belongs to one of its 10 Yes squadrons)
= C(47,5) / C(57,5) = 0.37
Expected count under independence: **0.88**, observed **0**. That is entirely
unremarkable. **The association is not supported by the data**, and the
"`DisableInterval` finally has a plausible referent" reading above rests on the
name alone, not on this table.
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](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](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.
---
# Hunting run (2026-08-24) — and a withdrawal
Status: ✅ a hunting pilot exists and engages; 🔴 the "10 of 116 records are
dynamic" result is **withdrawn**; ❔ the wave question is still open, and the
entity scan is too noisy to settle it.
The user asked for an actively hunting pilot, with attention to the objectives,
because the two previous runs used the *survival* pilot and a player who kills
nothing cannot trigger an event-gated wave.
## ✅ `SYLPH_HUNT=1`
`pilot.py` gained a hunt mode. The change that matters is which contacts ENGAGE
is allowed to shoot: it previously skipped every "hard" target with the comment
*"turrets and hulls are not the objective"*, and kept 2500 units away from
turrets. That was written on the assumption that an `e007` "Turret" is an
anti-aircraft mount on a capital ship. It is not — it is a craft, one of the main
enemy types of the first six missions
(`AI_ADAN_CraftSquadron_*`, `Type = Squad`), and at 100 HP the cheapest kill on
the field. Under `SYLPH_HUNT=1` turrets are targets and the keep-out drops to
600.
The run confirms it flies and shoots: steady `ENGAGE`, `fire=1`, committed to
`e010_ADAN_Attacker_S` at ~2.2 km, hull 1500/1500 and escorted asset at 100 %
throughout 160 s.
## 🔴 Withdrawn: "only 10 of 116 records are dynamic"
The previous section reported that 10 of 116 records ever changed a byte in
170 s, and used it as supporting evidence for the pre-allocated roster. **This
run measured 4156 records changing in every single 10 s tick.** The earlier
figure does not reproduce and is withdrawn.
I cannot say why, because I changed two things at once — the record bound (fixed
`0x200` → bounded by the next record's address, capped at `0x400`) *and* the
pilot (survival → hunting). Either could explain it: the old window mis-framed
every record whose true size is not `0x200`, and a hunting pilot flies into
traffic that an evading one avoids. **That is a design error on my part**: a run
that changes two variables cannot attribute its own result, and the honest
outcome is that the old number is retracted without a replacement explanation.
The conclusion the retracted number was supporting is unaffected — the
roster identity now rests on the exact 10-of-10 unit-composition match, which is
far stronger evidence and was measured independently.
## ❔ The entity scan is too noisy to answer the wave question
`pilot.py`'s own scanner reports a live entity count each tick. Over the run:
```
t=0s 168 (147 ADAN) t=56s 147 (131) t=101s 145 (129)
t=20s 171 (147) t=76s 155 (131) t=137s 151 (129)
t=30s 148 (132) t=86s 147 (131) t=157s 166 (142)
```
ADAN drifts 147 → 129 and back to 142. The late rise is the shape an arrival
would have, but the sample-to-sample swing is ±10 or more, which is the same
size as the effect. This is exactly the trap `docker/agent/AGENT.md` warns about
— polling faster than the guest updates manufactures a curve out of noise — so
**no wave conclusion is drawn from it.**
Two things are needed before this run type can settle the question:
1. A *stable* liveness signal — a per-record field that means alive/dead, read
from the 116 labelled records, rather than a re-scan whose population changes
between samples.
2. A kill count. `fc=0` in the pilot's telemetry and the asset at 100 % suggest
the hunt did not actually destroy anything in 160 s, in which case this run
does not test the event-gated model either. `REMAINING OB` at `0xbdb59668`
still did not read as a counter, so that check remains unavailable.
## ✅ The timetable's `t` is SECONDS (2026-08-26)
This was recorded as "❔ still unmeasured: whether the timetable's `t` is frames
or seconds". It is seconds, and the evidence is a physical one rather than an
argument from plausibility.
**The method.** A route record is a list of `(time, quat×4, pos×3)` keyframes, so
each consecutive pair gives a distance and a time delta — an implied speed. The
flight-speed law was measured **live, against a real-time clock**
([flight-speed-law.md](flight-speed-law.md)): the player's craft cruises at ~420
world units/s and tops out at **~1530** under `RT`. That is an independent
yardstick in known units, so the two readings of `t` can simply be checked
against it.
**The result.** `Route_*` records across all 28 stage tables — 331 records with
two or more keyframes, **1 104** consecutive pairs:
| reading of `t` | median | p99 | max | pairs above 1 530 |
|---|---|---|---|---|
| **seconds** | 147 | 959 | **1 310** | **0 of 1 104 (0.0 %)** |
| frames @30 fps | 4 422 | 28 758 | 39 309 | 984 (89.1 %) |
| frames @60 fps | 8 844 | 57 516 | 78 617 | 1 037 (93.9 %) |
Under **seconds** not one keyframe pair in the game requires a craft to exceed
the measured player maximum, and the median 147 sits comfortably below cruise —
which is what waypoint-to-waypoint averages should look like once turns are
included. Under either **frames** reading, ~90 % of the game's routes would
demand impossible speeds. There is no overlap to argue about.
### ⚠️ Two corrections made while measuring this
Both are the same kind of mistake and worth recording:
1. **A first pass silently skipped non-numeric fields** while flattening a
record, which shifts every subsequent value and corrupts the frame split. It
produced a median implied speed of 65 534 — suspiciously exactly 2^16, which
is what gave it away. Fixed by rejecting a record outright rather than
dropping a field from it.
2. **The remaining outliers were not routes at all.** Seven stage tables gave
maxima in the tens of thousands; every offending record was named
`Frame_S<NN>_Asteroid`. The corpus already records that the 8-value frame is
not universal, and these are the cases. The final measurement therefore
filters on the record name (`Route_*`, 1 389 of the 1 477 records; the rest
are 60 `Frame_*` and 28 `Enumeration_*`) rather than excluding whole stages —
a filter by kind, not a filter that removes inconvenient numbers.
### What this does not settle
* **331 of the 1 389 `Route_*` records** carry two or more keyframes and pass the
`FrameCount*8` length identity; the rest are single-keyframe (arrival only) or
do not fit the 8-value frame. The conclusion rests on those 331.
* The **~1.25× world-unit factor** noted in `flight-speed-law.md` is not applied
here; it would move the ceiling, not the 30× and 60× gaps, so it cannot change
the answer.
* Whether `t` is measured from **phase start** (as the arrival schedule reads it)
or from mission start is unchanged by this — that was already settled
separately.
## ✅ The entity-count proxy was not just refuted — there was nothing to see
**2026-08-26.** A live count of `0x820af030` entity records stayed **flat at 116
for 210 s**, spanning Stage 02's scheduled arrivals at t = 90/120/170, and was
recorded above as refuting the proxy. It is worth saying *why* it was flat,
because the reason is now established and it is not "the proxy is broken".
**Arrivals are not spawns.** Built-in 12 `activate_unit`, read from the
disassembly (`sub_822659F0`, see
[structures/isl-builtins.md](structures/isl-builtins.md)), **returns 0
immediately when the live object `[record+4]` is NULL** — it registers an object
that already exists and sets its state to *active*. It cannot create one. So the
mission's craft are instantiated up front and a "wave arrival" is a craft
reaching a place along its route, not a new record appearing.
**The count matches the roster exactly, and the number is distinctive.**
Stage 02's `UnitGroup` table holds **111 squadron records totalling `Count` =
116 members** — the same 116 the live probe saw and never left. The obvious
objection is that 116 might be an unremarkable number that matches by accident.
It is not:
roster member totals, all 28 stages:
2 4 4 9 11 12 14 17 32 36 37 42 43 45 48 50 53 54 54 62 64 66 69 70 87 90 95 116
**116 is the maximum across the whole game and Stage 02 is the only stage with
it** — no other stage is within ±5. The save we have is a Stage 02 save.
So three independent things line up: the handler cannot spawn, the roster totals
116, and the live count sits at 116 and never moves while 22 of Stage 02's 113
routed squadrons have a route beginning after t = 0.
### What this does and does not settle
***Arrivals are not spawns**, and the flat count is the expected behaviour
rather than a failed measurement. Read from the code, not inferred.
* 🟡 **One pre-allocated record per roster member.** Strong — the mechanism
explains it and the matching number is unique to this stage — but still
**n = 1**: only a Stage 02 save exists, so the identity has been checked on one
mission. The previous entry's "n=1, NOT promoted" caution still applies; what
has changed is that there is now a mechanism behind the number.
***It does not settle timetable versus event.** Knowing that craft are
pre-allocated says nothing about *what* starts a route — a clock or a
trigger. That test still needs a live run, and the entity count can never
perform it, because there are no spawns for it to count. A different probe is
required: watch a delayed squadron's **position**, not the record count.