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.
Decoded stage\UnitGroup_S02.tbl with the existing unitgroup.py (pure static, no
emulator; 111 squadrons, roster self-check 111/111).
The "four per squadron" premise was 16/4, a mean, and I flagged that 5/5/3/3 would
make the step-of-four a coincidence. Measured: Stage 02 has NINE e010_Attacker_S
squadrons totalling 45 members, sized 4,4,4,6,4,6,4,9,4 -- not uniform across the
stage, but SIX of the nine hold exactly 4, and phase 1's share is independently
recorded as 4 groups / 16 craft = 4 x 4. So the phase-1 squadrons are the n=4
ones and the step-of-four is a real size, not an averaging artifact.
But a premise of the note is refuted: ADN110/111/112, which I cited as the polled
squadrons arriving at t=170, are UN_e007_ADAN_Turret with n=9 -- TURRET squadrons,
not attackers. That arrival evidence is about turrets and is withdrawn from the
argument.
Records the tension it exposes: mission-wave-arrivals.md calls ADN110/111/112 the
squadrons the phase-1 clear condition polls, while mission-objectives-text.md has
the phase-1 objective as the marked attackers. Unresolved.
Assembled from existing captures, not newly measured, and marked 🟡 accordingly.
mission-objectives-text.md gives phase 1's roster as 4 e010_Attacker_S GROUPS
totalling 16 CRAFT -- four per squadron -- and the counter moves in steps of
exactly four (004 -> 008 -> 012). The phase-1 objective is stated in the mission
dialogue as "the attackers with the orange markers", i.e. the e010s, not the
turrets that outnumber them 7:1. mission-wave-arrivals.md pins the arrival of the
three polled squadrons at t=170 SECONDS, which is when the counter is seen to
climb.
That accounts for every number taken so far, including the two that looked
contradictory: the counter held at 012 while the hostile population fell by a
third because those kills were almost all turrets, and it decremented 12 -> 11 in
the one run whose pilot actually killed attackers.
Recorded with its own refutation tests (kill one e010 -> must fall by exactly 1;
kill a turret -> must not move; phase 1 should cap at 016) and with the weak point
stated: "four per squadron" is 16/4, a MEAN, and if the squadrons are 5/5/3/3 the
step-of-four pattern is coincidence.
Every earlier refutation in this file carried the caveat that entities2.typed
types entities by their position CHANGING, so a stationary objective is invisible
to it. This session's definition-pointer enumeration does not have that limit, so
the sweep was re-run against it (ob_flag_all.py, guarded route, stage asserted,
HUD cropped beside each sample):
A: HUD 004, 147 entities -> 152 candidates
B: HUD 012, 133 entities -> 15 candidates
intersection: 1
The lone survivor pos+0x0250 = 239d6732 is the same offset AND identical value
this file already characterised as a per-group word. Membership test: all 12
holders are UN_e010_ADAN_Attacker_S, 12 of 16 live attackers. It is a squad
parameter, and it survived only because that population equalled the counter at
both samples.
Also reconfirms "not a class head-count" on 147 entities including capital ships.
Trap recorded: the first sweep reported 298 entities and a class with head-count
exactly 4 -- a perfect-looking hit that was pure artifact. Deduping by ADDRESS
leaves the measured exact 2x duplication (pairs 0x1000 apart, byte-identical
positions) intact and doubles every population. Dedup on the position VALUE.
Ran this file's own transition filter on a guarded Stage 02 run (stage asserted),
reading the HUD from a crop taken at the same instant as each memory sample:
scan at HUD 004 -> 41537 candidates; filter at HUD 008 -> 8; verify across the
008 -> 012 transition, which was NOT selected on -> exactly ONE survivor.
That survivor, 0xbdb69668, tracked 4 -> 8 -> 12 against the HUD's 004 -> 008 ->
012. The other seven collapsed into noise at the first unselected transition,
which is precisely what that rule exists to catch.
The file's "try 0xbdb59668 first, re-scan when it reads 0" rule worked verbatim:
it read a hard 0 here, and the re-scan cost about the predicted five minutes.
The ...9668 page-offset pattern is REFINED, not reinstated: the three located
addresses (0xbdb49668, 0xbdb59668, 0xbdb69668) are three ADJACENT 64 KB pages at
one offset, and in this run exactly one of 8192 probed pages held 12 -- the
counter -- making it a one-step lookup. But the 2026-08-26 refutation stands as
measured (zero ...9668 VAs held the HUD value in that run), so this is a fast
heuristic to be HUD-checked, not a law.
The file proposed that the counter lives at a fixed offset inside an allocation
whose base moves by whole 64 KB pages, and stated the test itself: "a third scan
should again land on ...9668". Ran it on a fresh guarded Stage 02 run.
Probing all 8192 pages of the form 0x????9668 across 0xa0000000-0xbfffffff: with
the HUD at 004, exactly two VAs held 4 (0xbc3f9668, 0xbe3f9668); with the HUD at
012, ZERO held 12. Both candidates also failed the file's own transition rule --
over 252 s 0xbc3f9668 held a flat 4 and 0xbe3f9668 flickered 4/0 while the HUD
went 004 -> 012.
Both arms were sampled at the same instant (cropped HUD digits beside each memory
read), after a stale-screenshot comparison earlier in this session produced a
spurious 13-vs-004 mismatch.
Scope kept narrow: this refutes the page-offset prediction, not the confirmed
finding that 0xbdb59668 carries the counter in some runs. The counter's address
in THIS run remains unknown -- no transition filter was run.
The section I added last iteration claimed to be testing an untested 🟡 and
concluded "the address is run-dependent, always re-derive". Both halves were
wrong, and the evidence was already further down the SAME file:
- cross-run stability was not untested -- the body records 0xbdb59668 carrying
the counter in 3 of 5 measured runs and reading a hard 0 in the other two. The
stale claim was in the status line at the top, which I took at face value.
- "always re-derive" is worse advice than the rule the file already gives: try
0xbdb59668 first, check it against the HUD, re-scan only when it reads 0.
What my run genuinely adds is a SIXTH data point with a new failure mode: the
address read neither the counter nor 0 but 95748078, constant over four samples.
Status line rewritten to match the body (🟡 recurs but not universal), so the
document no longer contradicts itself.
The doc carried a 🟡 saying cross-run stability was untested. Tested now on a
fresh guarded Stage 02 run (stage asserted): the HUD reads "Remaining OB : 004"
while RAM at the documented 0xbdb59668 reads 95748078, constant over four samples
12s apart. Not 4, not near 4, not moving. So the address belongs to that run's
heap, as the corpus's own heap-reallocation warning predicts.
No constant-shift shortcut either: a BE u32 equal to 4 occurs 1654 times within
+-1 MB of the old address and 11202 times within +-16 MB, far too many to isolate
without the transition filter. The durable result is the METHOD (ob_scan.py:
scan at one value, filter against live memory at a DIFFERENT value), not the
number.
Also fixes a contradiction in INDEX.md, which said in one row that the address is
"still ❔" while another row linked the doc that had already CONFIRMED it.
My enumeration sourced entities from moving(), which requires a position to CHANGE
between two samples. A capital ship holding station changes by exactly zero, so it
can never appear -- which is also why lo=0 and the whole-map scan did not help, and
why parked ArrowHead wingmen were missing. The filter was the problem, not the
range, and not the game.
navigator.py already does it right and says so in its header: search for the
DEFINITION POINTER and take position = hit - 0x130, "which finds every entity
whether it is moving or not". The existing mission_state.py scan on Stage 02
returns 149 entities with hull(+0x154) == definition HP for 129 of them:
UN_f101_TCAF_Acropolis (+5000, +0, +0) hull=25000.0 HP=25000.0 frac=1.000
UN_e105_ADAN_Cruiser hull=30000.0 HP=30000.0
UN_e106_ADAN_Destroyer hull= 9150.0 HP=10000.0 (under fire)
So "an autopilot that must protect the Acropolis cannot locate it through the
+0x130 method at all" is withdrawn outright -- it can, and INDEX.md had already
recorded the Acropolis falling 25000 -> 23038 over 240s.
What survives: the two enumerations are still different structures in different
regions (0/116 vtable instances lie in the +0x130 region). What does not survive
is any claim that the +0x130 model covers only four types.
With the sliver stopped at both ends (290cbe3 producer, 30e53f5 consumer) a full
guarded run reached Stage 02 and asserted it: "guard: menu confirmed (1279x675)"
then "OK: UN_f101_TCAF_Acropolis is in the 14 definitions", exit 0.
Read the menu the guard confirmed rather than continuing to assume it:
NEW GAME / LOAD GAME / TUTORIAL / OPTIONS / EXTRAS with the cursor on NEW GAME.
So dpad-down x1 = LOAD GAME is correct and dpad-down x2 = TUTORIAL, one press
further -- exactly consistent with the observed mis-selection when the guard was
being fed a sliver. Capture committed as captures/main-menu-items.png.
Stated plainly: n=1 for "reproducible", and why the game window leaves the window
tree during a load was guarded against, not investigated.
assert_stage.py checks the DEFINITION table against an expected stage marker and
earned its keep immediately: its first live run reported MISMATCH -- the capture
had a live flight HUD and would have been filed as Stage 02, but was the S01
tutorial. That is exactly the failure that silently invalidated an earlier
cross-run comparison.
require_menu (launch_mission.sh) refuses to press until screen_id reads `menu`.
It is NOT sufficient, and this refutes my previous explanation: the run DID
confirm the menu and still loaded the tutorial. The real cause was that the
guard's own capture was a 10x710 sliver which classified as `menu` -- fixed
separately in 30e53f5.
Left open: whether the menu guard suffices now that slivers are rejected (not
re-run), and why the capture was a sliver at all when the other shots in the same
run were 1279x675.
Every statistic in screen_id is an AREA FRACTION, so a capture that is not a game
frame still produces clean numbers. Measured 2026-08-26: a guard shot came back
10x710 -- a sliver -- and classified as `menu` with green=0.0000, white=0.0157.
The guard passed, the fixed key sequence went out anyway, and the run loaded a
TUTORIAL instead of the save's Stage 02.
This is the second time this failure has been paid for. bin/screenshot's own
header records the first (2026-08-18): a second window of class "xenia_canary"
meant grabs came back as slivers and "a whole session's screen ids were noise".
That fix hardened the CAPTURE side only, so the same failure still reached the
oracles by any other path. Reject it at the point the answer is consumed too:
features() now returns None below 640x360 and classify() reports `none`.
Verified: the 10x710 sliver -> `none`; readyroom, flight and the briefing capture
all still classify as before.
Closes the caveat I flagged in c9dc8dd. savegame.rs documents the chunk stream
(read off the title's own serializer at 0x822C00E8) as GDAA / phase string /
'GHAD' + 122 bytes / SHAB table, and FieldSpec.offset is "offset within the
122-byte GHAD block" -- so the base is the byte after the tag, tag+4.
Reading there yields three fields, two corroborated independently elsewhere in the
corpus: Stage +52 = 2, Points +24 = 4101 (weapon-datasheet-runtime.md's "4101 P"),
FlightTime +4 = 324773 (the 05:24.77 Stage-01 best time). tag+8 reproduced only
the stage. The payload's phase string reads GP_BUNK.
Left open: the .header mirror does not reproduce -- savegame.rs lists Stage at
header offset 0x14 but a BE u32 there reads 2097200 (00 20 00 30), which looks
like UTF-16 text. Not chased; the payload reading does not depend on it.
Refutes my own claim from the previous commit that slot 01 is an auto-save whose
restored mission drifts. savedata has not been written since 2026-08-23; every run
today left it untouched (only the .gpd profile files moved), and there is exactly
one save, so a wandering save-list cursor cannot explain it either.
Decoded the save statically (GDHA + zlib at 0x92, 545 bytes): only SHAB record 0
carries clear data, records 1-15 are zeroed. Three independent readings agree it
is Stage 02 -- the 324773 ms clear time is the 05:24.77 that SESSION-2026-08-11
documents for Stage 01, the 4101 points match weapon-datasheet-runtime.md's
"Stage 02, At Standby, 5% clear, 4101 P", and the GHAD block at tag+8 gives
+52 = 2. Stage 02 is the Acropolis escort mission, which is exactly the roster the
last run produced.
So the odd run out was the S01 "Glasner Training Area" one -- a TUTORIAL, reached
because the nav issued dpad-down + A from a state never confirmed to be the main
menu. Fix is two guards: verify screen_id reads `menu` immediately before the
dpad, and assert Acropolis is in the definition table before comparing rosters.
Caveats kept: the GHAD field base was not isolated by the search (7 candidates, 3
giving stage 2 -- tag+8 chosen because it agrees with the other two readings), and
the tutorial mis-selection was not demonstrated by re-running the emulator.
Same moment, same stage: the INST_VTABLE scan sees 116 objects across 14 types;
the moving+0x130 method sees 30 across 4. Every capital ship, station, missile and
the objective-critical Acropolis reads ZERO in the +0x130 method.
The absence is structural, not a filter artifact -- both obvious explanations were
tested and failed. Dropping the speed floor to 0 raised the count 30 -> 59 and
recovered the _Player but still only 4 types; scanning the WHOLE map with no floor
gives 181824 movers and still 4 types. And 0/116 vtable instances lie inside the
window where the +0x130 blocks are found (instances 0xbc372cc0-0xbc9bc720, window
0xbd000000-0xbe000000), independently confirming these are separate allocations.
Consequence: an autopilot that must protect the Acropolis cannot find it via the
+0x130 method at all.
Also found: LOAD GAME -> slot 01 no longer restores the S01 training area but a
Stage-02-style escort mission (Acropolis, SchlosBase, cruisers, frigates). Slot 01
is the AUTO-SAVE, so the restored mission moves as the save is written -- which
invalidates the earlier "101 vs 42" comparison outright, since those came from
different stages.
Corrects the previous note: entities2 prints its count AFTER dedup, so the 101 was
already deduplicated; the gap was the stage change, not duplication.
Three checks kill "the fast turrets are shots":
- persistence: 42/42 survived 15s, including 22/22 of the FAST (>300/s) ones;
- hull at the documented pos+0x154 is a clean per-type constant -- 1000.0 for
DeltaSaber_T, 100.0 for e007_Turret, with one turret at 79.0 (damaged);
- the speed was never suspicious: isl-builtins.md already records e007's
MaximumVelocity 500 / CruisingVelocity 280 and notes the data models turrets as
mobile. My premise "a turret is a fixed emplacement" was an inference from the
NAME that the corpus had already contradicted from the DATA.
The inflated count is two measured effects, neither mis-typing: exact 2x
duplication (64 raw -> 32 deduped, every per-name count halving, commonest offset
gap 0x1000 = one page), and a growing population (42 -> 60 raw in 15s, matching
the documented wave arrivals). So "101 vs 42" compared different mission times
with at least one count un-deduplicated.
Left open: whether the earlier 101 was raw or deduped (it is odd, so not a pure 2x
artifact); whether the 0x1000 spacing is a real second copy or page aliasing; and
which of the two enumerations is the entity list, since they describe different
sets.
Fresh run, unmodified tools: entities2.py self 0x130 returns 74-101 typed live
entities with positions and speeds, a player entity, and the orientation matrix at
pos-0x70 with 16-byte stride -- exactly the documented layout. Artifact committed
as docs/data/live-entities-2026-08-26.txt.
Last iteration's "zero moving triples across 357 MB" does not reproduce: sampled
seven times 4s apart, the whole map gives 47k-123k movers and the committed
ENT_VA window 10k-20k. So the window is populated and the tooling is sound; what
differed between the sessions is NOT determined and is recorded as open, since the
earlier zero persisted for minutes across several commands. Practical rule added:
check movers are non-zero before concluding anything from a memory probe.
New open discrepancy: the vtable scan reports 42 instances (20 turrets) while the
+0x130 method reports 101 (82 turrets), most of the excess moving at ~375/s -- and
a turret is a fixed emplacement. Likely projectiles typed as their shooter, but
explicitly NOT asserted: no test here separates a projectile from a fast craft.
Three results, two of them against my own earlier claims:
- REFUTED: position = instance - 0x12c. The corpus anchors on position (def ptr
at +0x130, orientation at -0x70, hull at +0x154) and name_of reads the def ptr
at instance+4, which predicts -0x12c. Measured over all 42 named instances:
almost every read is (0,0,0), three are garbage, and 0/42 move. The vtable
object and the position block are different structures.
- WITHDRAWN: last iteration's claim that entities2.py's ENT_VA window is aimed at
the definitions rather than the instances. entities2 does not look for vtable
objects at all -- it hunts moving position triples and checks +0x130, the
documented anchor -- and it had worked minutes earlier (6914 movers, +0x130
voted 92x; then 6634/138x). I built the defect report on the single sample in
between that returned zero. The +0x29d0 position candidate falls with it.
- FOUND: sampling 64 spots across the 505 extents (357 MB) twice, 2.5s apart,
with the mission visibly running, only 5 change. The megabyte holding all 42
instances is byte-identical over seconds, as is its primary-VA counterpart.
The mapping IS live (5 regions prove it); what is undetermined is whether the
entity records are simply static or whether writes land in a different alias.
Either way: verify a region changes before measuring through it.
Last iteration I ruled the speed route out because the emulator is not real-time
so the wall-clock denominator is unknown. Wrong: the game prints its own clock,
and flight-speed-law.md had already used it -- mission TIME across a wall interval
gives 1.26x, turning 443.6 units/wall-second into 352 per game-second against a
HUD 350. Withdrawn in place, with the reasoning, rather than deleted.
Closes the backlog item: one world unit is one metre, by two independent routes.
The 2026-08-26 downgrade rested on two numbers, and grouping the CollisionSet
names by prefix dissolves both:
- The 447 km "largest object" is rob_f002_cmesh, one of only FOUR rob_ meshes
(44k-448k). That family is not hull size: rob_f001_bdy_cmesh measures 50179
and f001 is the player's own Delta Saber, whose disc record gives Size_Radius
10.0. A 50 km player fighter is impossible under every unit convention, so
rob_ spans something else entirely and is not evidence about the world unit.
- The "small" 133 m craft is small: rou_e010_cmesh = 133.2 is the 7th smallest
of 78 rou_ meshes (median 636.2, max 9534.9). Against its own family, the
game calling e010 small AGREES with the metre.
Measured this run: the 500 km box is a cube of exactly 500000.0 units on every
axis (+-250000), and the second box mesh is exactly 100000.0 -- the same ruler.
Independent dynamic confirmation already in the corpus (flight-speed-law.md):
443.6 world units per wall-second, mission clock 1.26x wall, so 352 units per
game-second against a HUD reading of 350.
The HUD is reachable and the control is paired; what blocks the measurement is
narrower and now named -- two tool defects (entities2.py's VA window covering the
definitions rather than the instances, gworld.py's 0x600 instance window) plus the
missing piece itself, a locked target whose HUD range and position can be read in
the same second. Also records the speed shortcut as refuted so it is not retried.
Discharges the control owed by entities-live-roster.md: same process, title screen
scans 0/0 against 13/42 in flight.
Reaching a live HUD needed two steps no script had: START skips the post-take-off
cutscene, and a modal "tell you your objective?" dialog DIMS the frame (so the
classifier reads `other` and liveness looks like a stall) until Ⓑ/NO answers it.
After that, green 0.0145 -- inside the documented 1.3-1.5% flight band.
Two tool defects measured, not inferred:
- entities2.py's ENT_VA_LO/HI (0xBD000000-0xBE000000) misses every live instance
(they sit at 0xBC384CE0-0xBC9BAC20) and instead covers the DEFINITIONS. Rescoped
to the instance region, find_delta's +-0x400 radius yields zero votes.
- gworld.py's WINDOW=0x600 is too small: no position-like triple moves inside the
first 0x600 bytes of any of the 42 instances; 0x4000 finds one at +0x29d0.
The unit itself is NOT measured. That needs a locked target so the HUD prints a
numeric range to an entity whose position can be read at the same moment; this run
never locked one. The tempting shortcut -- 116.6 units per 0.6s wall-clock against
the HUD's 350 -- is recorded as refuted, because the emulator is not real-time.
The entry blamed a stale committed VA window for "0 unit definitions". Measured
2026-08-26: the scan works and returns 13 definitions + 42 named live instances
once the run reaches the mission via LOAD GAME -> slot 01 rather than via MISSION
SELECT. What still blocks the measurement itself is narrower and now stated: the
flight HUD was not up (green 0.03% vs 1.3-1.5%), so there was no distance readout
to compare positions against.
The briefing map is cyan and satisfies every clause of the menu rule (b-r > 30,
r < 45, little white), with no earlier rule claiming it -- so it was labelled
`menu`. That made wait_screen.sh report NEVER REACHED READY ROOM on a run that
had successfully done LOAD GAME -> slot 01 -> YES and was three screens further
on: a working route scored as a failed one, pointing the next debugging step at
an input path that was fine.
Cyan has b and g nearly equal (b-g ~ 5) where the menu's blue leads its green
(b-g ~ 32), so `r < 20 and g > 30 and b - g < 20` separates them; the r floor
keeps the title screen out. The file's own docstring already carried the
briefing's mean as an aside -- it just never had a class.
Verified against all eight signatures the file documents (2 menu variants, title,
ready room, flight, 3 briefing measurements): no regressions, and the captured
briefing image now reads `briefing`.
gworld.py's DEF_VTABLE 0x820AF844 and INST_VTABLE 0x820AF030 resolve exactly as
written: 13 unit definitions and 42 live instances, every one name-resolved, with
52 moving triples. The roster is coherent and stage-specific -- UN_S01_Asteroid_*
in the S01 training area, 2 e106 destroyers against 20 e007 turrets, 6 DeltaSaber_T
and exactly one _Player -- which is far stronger evidence than a hit count.
The constants were never stale. structures/unit-struct-runtime.md records its own
provenance ("all six tutorials and Stage 02 loaded from save slot 01") and
launch_mission.sh already encoded that route: title -> LOAD GAME -> slot 01 -> YES
-> READY ROOM -> TAKE OFF. LOAD GAME is the menu's SECOND item; the previous
iteration pressed the first (NEW GAME) and read the resulting 0/0 as evidence
about the constants. One `step down` separated a dozen iterations from this.
Control still owed and stated in the doc: the title-screen arm was measured in
earlier processes, not this one.
Before deriving a new vtable I checked where gworld.py's constants came from.
They cite structures/unit-struct-runtime.md, which states its provenance:
"Captured 2026-07-29 ... all six tutorials and Stage 02 'Declaration of War'
loaded from save slot 01."
Loaded from a save slot -- not through MISSION SELECT, which is the route every
run this session has taken, and which needs the cleared-stage mask poke to offer
a stage at all. So the constants may not be stale; they may just need the state
that route produces. That is a cheaper question than writing a new vtable
finder, and it should be answered first.
First attempt inconclusive: driving the main menu's first item blind, five
presses deep, advanced the progress counter every time (3 -> 5 -> 6 -> 8 -> 10 ->
12, so the game responds) but left DEF_VTABLE / INST_VTABLE at 0/0 throughout.
Without a screen identity this is dead reckoning, and the first item may not be
the load-game entry -- newgame_path.sh documents it as NEW GAME with SELECT DATA
two screens further in.
Next: reach the save-slot screen deliberately rather than by counting presses,
and load slot 01.
Locating DeltaSaber at mission time works: 53 occurrences, all in the heap
(0xBC66..-0xBC6C..), none in the XEX image -- DeltaSaber_T.xpr, _Special,
_NoseGun, _Missile, _TwinGun.
But searching all of guest memory for a big-endian pointer to those addresses
returns ZERO references for every one tried. That is the format, not a search
bug: those strings live in the mission pak's IDXD string pool, and this corpus
decoded that container long ago -- records reference names by OFFSET into the
pool, never by absolute pointer. So there are no pointers to find, and "find the
name, follow what points at it" cannot work on pak data by construction.
This also narrows what the string counts proved. The title-vs-mission control
stands (DeltaSaber 0 -> 81), so mission-specific DATA is loaded, which is more
than "the tables load at boot". But these are asset-table strings, not live
entity objects, so they do not show entities have been spawned. Honest split:
the mission's pak data is loaded; whether entity objects exist is still
unmeasured, because both probes tried -- gworld's vtable constants and
name-pointer following -- are respectively stale and structurally inapplicable.
Remaining routes, untried: derive the entity vtable from CODE via sylpheed.db's
vptr_writes table, which exists for exactly this; or find the entity list from
the mission update function rather than from data.
Three iterations concluded "no units are loaded" from scan_vtable(DEF_VTABLE)
returning 0. Withdrawn: the scan was measuring the wrong thing.
Counting unit-name strings in guest memory with the TITLE as a control (same boot
recipe, no mission):
string title after take-off
DeltaSaber 0 81
rou_ 1,981 7,680
UN_ 19 167
e010 67 134
ADAN 1,450 1,921
DeltaSaber is the player's craft: absent at the title, present 81 times after
take-off. Every other count rises several-fold. The stage content is
unambiguously in memory.
The control is what makes this a result. Raw counts prove nothing on their own --
rou_ and ADAN are numerous at the title too, because the unit tables load at boot.
Only the title-vs-mission difference, and DeltaSaber's 0 -> 81 in particular,
separates "tables loaded" from "mission loaded". My first reading skipped the
control and over-claimed; the control was run before publishing.
Withdrawn as a consequence: "no units are loaded"; and the inference that the
take-off lands in a never-ending cutscene (the movie accesses are real, but the
conclusion rested on DEF_VTABLE=0). gworld.py's DEF_VTABLE 0x820AF844 and
INST_VTABLE 0x820AF030 do not locate entities in this build/state despite being
genuine vtables in sylpheed.db.
Next: derive the correct entity vtable. The UN_ strings reachable by search are
UN_NOSE/UN_MOUNT attachment names in the XEX image, not runtime records -- so use
a name that only exists at mission time. DeltaSaber is exactly that: find its
heap occurrences, find what points at them, read the referencing object's vtable.
wait_flight.sh carries the corpus's best discriminator, and it is a whole-image
statistic rather than a named pixel: the flight HUD paints green over the frame
at 1.3-1.5% green fraction, against <0.1% on every menu.
Measured on the post-take-off state: green=0.0022, mean (1.6, 45.6, 50.1). That
is 0.22% -- an order of magnitude above a menu, an order of magnitude below
flight, on a dark blue-green frame. screen_id.py files it under "menu" because
that is the closest bucket it has; the state is neither.
wait_flight.sh then tapped A for 280 seconds and never saw the HUD.
With the 12 \dat\movie accesses, the reading is consistent: this is the cutscene,
it does not end on A, and it does not reach the flight HUD within five minutes.
Open, cheapest first: how the movie path signals completion (the corpus already
decoded the movie handler for the intro video and found a software-decode
deadline); whether a mission can be started by a route that skips the launch
cinematic; or whether the cutscene simply needs far longer under lavapipe.
Oracle status overall: boot, menus and take-off are reliable and reproducible;
only the cutscene->mission step is unsolved -- a much smaller and better-defined
gap than the freeze it replaced.
Logging file and kernel activity through the whole navigation and counting names:
12 \dat\movie <- heavy, repeated movie access
3 \dat\sound
2 Stage_S01 <- the stage resource is referenced
1 XamShowDeviceSelectorUI
So after take-off the game plays the launch cutscene. Under software rendering a
720p decode is very slow -- this corpus already raised INSTRUCTIONS_PER_MS 100x
because the movie handler has a 2000 ms software-decode deadline -- which is why
the state persists for minutes.
It is unambiguously live, by the widest margin yet measured:
frozen menus this state
screen liveness 0.00% 0.22% 11.00%
guest churn 0.000% 0.190% 0.671%
and the progress counter advances on input (17 -> 101 across the skip attempts),
so the pad reaches the game.
But it does not skip: four rounds of START / A / B advanced the counter every
time and left DEF_VTABLE / INST_VTABLE at 0/0. The input registers, it just does
not end the sequence.
The remaining gap is now narrow and named: the take-off cutscene runs and is not
skippable by the obvious buttons. Either it must play out, or the right skip
input is unknown, or it waits on something the movie path never delivers.
With the emulator launched correctly and the boot verified by the progress
counter first (counter > 0 = title reached; a flaky boot never leaves 0),
nav_to_flight.sh ran the whole sequence -- main menu at 20 s, mask poke, stage
select, briefing skip, ready room, take-off -- where every previous attempt
stalled.
The game is alive at the end of it: screen liveness 2.13% against 0.00% when
frozen, guest churn 0.288-2.932% against 0.000%. An animating screen and a busy
guest at the point that used to be stone dead.
But no units. DEF_VTABLE (0x820AF844) has no hits on a direct byte search, with
a working control in the same run: searching for the poked cleared-stage mask
0x0001FFFE finds it at 0x828F40C0, exactly where nav_to_flight wrote it. So the
search is sound and the vtable is genuinely absent. Both vtable constants were
also checked against sylpheed.db's vtables table and are real vtables, so they
are not stale. Three minutes of waiting left DEF/INST at 0/0 with the counter
static at 17.
So the run reaches a live, animating state that is not a mission with spawned
units -- most likely the launch cinematic or a post-take-off screen. The freeze
is gone; what still separates this from unit spawn is the remaining work.
nav_to_flight.sh contains no run-canary invocation -- it drives an
already-running emulator -- so whether a profile is signed in depends entirely on
how the caller launched it. Every nav_to_flight run in this session was launched
with a bare run-canary, which is why they froze. Worth stating plainly because
the script prints "signing in profile B13EBABEBABEBABE", which comes from the
emulator's startup rather than from the script arranging it.
Refuted: "each screen loads its own GP_*.pak", a plausible route to a real screen
identity. With full file logging the only names that appear are at boot
(sound.p0x, tables.p00, hidden) and nothing new opens as the UI moves between
screens -- the paks are opened once and read by offset, so file I/O carries no
per-screen signal.
Also recorded: one launch this iteration never reached the title -- progress
counter stuck at 0, black screen, guest churn 0.001%. That is a DIFFERENT
failure from the sign-in freeze, which stalls after the main menu with the
counters already advanced, and it matches the boot flakiness
canary-scripted-input-traps.md already documents. The counters distinguish
them: sign-in freeze stalls past the title, a bad boot never leaves 0.
The names in menu-state-in-memory.md are wrong for at least two of the three
words, and the test that shows it is going backwards. Driving deep into the
menus, then three presses of B:
0x828F38AC "cursor" 36 -> 38 -> 40 -> 41
0x828F37B4 "misc" 971 -> 1067 -> 1068 -> 1068
0x828A690C "screen" 56 -> 56, 56, 56
A cursor returns when you go back. These only ever increase -- monotonic
counters, with 0x828F38AC advancing about 2 per input. Driving forward produced
1,3,4,5,6,8,10,12,25,29,32,33,50,53,56 for the "screen id", which is an identity
sequence only if the game has 56+ screens and never revisits one -- exactly what
a counter also looks like.
Withdrawn: 0x828A690C as a screen IDENTITY (1 title, 3 main menu, 4 extras). The
values are path-dependent; they matched across runs because the same key sequence
produces the same count, not because 3 means main menu.
Survives: all three advance if and only if the game responds, and are stable when
it does not. That is a real input-progress signal, reproducible across runs and
both GPU backends, and it is what made blind navigation work. Read it as "did
the game react?", never "which screen is this?".
It also retro-confirms the freeze diagnosis: with the sign-in fix the sequence
runs 3 -> 5, skipping 4 entirely, so "4 = extras" was never a screen -- it was the
count at which the game stopped responding. The counter reading explains both
observations.
Open: no mission reached. Counters at 56/41/1068, guest churn 0.190% (alive;
frozen is 0.000%), DEF_VTABLE and INST_VTABLE still 0. Without a real screen
identity, navigation is dead reckoning; finding a genuine state enum is next, and
the snapshot-and-diff method can be repeated with these counters excluded.
Every Xam UI dialog goes through xeXamDispatchDialog, and the calling guest
thread blocks on fence.Wait() until it is dismissed. With kernel logging on the
last call before the freeze is XamShowSigninUI(00000001, 00000001): the game
asks for a signed-in profile, Xenia opens a modal dialog, and nothing in a
scripted run ever dismisses an ImGui dialog.
That accounts for every symptom at once -- Main XThread futex-blocked at 0 ms CPU
rather than spinning, emulator alive, no guest progress, no faults, and
independence from both the GPU backend and the allocation outcome.
Fix: run-canary --logged_profile_slot_0_xuid=B13EBABEBABEBABE. Same route, one
variable changed:
no profile with profile
screen id 4, forever 4 -> 5 -> 6 -> 8 -> 9 -> 10
XamShowSigninUI called not called
alloc failures 1 0
guest throws 1 0
guest churn 0.000% 1.006%
XamShowDeviceSelectorUI is then requested but storage_selection_dialog defaults
to false, so it is not shown and does not block.
Why it took so long: --log_mask=13, used by every script in this corpus,
DISABLES kernel logging -- the one category that names the dialog. Without it
the freeze presents as a bare futex block, which is why it was attributed in
turn to a heap leak, rounding, MmQueryStatistics, a build regression, the route,
the savegame, shader compilation, an infinite guest loop and the software
rasterizer, each refuted in its own section.
Nearest miss worth recording: challenge-mission-gate.md reported a "Disc Read
Error" dialog on this failure. That is XamShowDirtyDiscErrorUI, which calls
exit(1) -- "This is death, and should never return" -- so it cannot be our
freeze, whose process stays alive. Checking that distinction is what led to the
dialog mechanism.
Open: no mission reached yet (DEF_VTABLE/INST_VTABLE still 0 at screen 10), so
the remaining screens are menus. Ordinary navigation, not a blocker.
Driving the null backend blind, using the memory screen-id as the guide, to the
SAME screen the rendered run froze on (screen 4):
guest churn 19 of 8,089,600 bytes in 4 s (0.000%)
Main XThread 0 ms CPU / 4 s, state S, wchan futex_do_wait
log AllocRange unable ... 134217728 bytes; guest throw
Frozen identically with no rasterizer at all.
The flaw is the comparison, not the data. The earlier "control" measured the
null-GPU run while it was still in the MENUS and the lavapipe run AT the freeze,
then reported the difference as a backend effect. The allocations I cited as
proof it had walked through the content load (114 -> 120) were menu-time
allocations. A control needs both arms verified to be in the same state -- and
the memory screen-id built this same session is exactly what makes that
checkable, which I failed to use on the arm that mattered.
Withdrawn: "the freeze requires the rasterizer", "the fault is in the host
rendering path", and the hardware-Vulkan blocker that followed from it. The
rendering question is no longer load-bearing.
Survives: the freeze happens on the first content load after the main menu, on
every route and BOTH GPU backends, with Main XThread futex-blocked at zero CPU
rather than spinning; and four runs froze with three different allocation
outcomes, so the 128 MB refusal is correlated but not necessary.
Also survives and is independently verified: the memory screen-id navigation,
now reproduced on a third run. It is what exposed this error.
Open again: what is Main XThread waiting on? Identify the futex and its holder,
carefully -- gdb perturbs this measurement.
Menu navigation here has always been screenshot-driven, which is unusable under
--gpu=null -- the only configuration where the game does not hit the
software-rasterizer freeze. Without a memory signal, the one backend that runs
is the one that cannot be steered.
Snapshotting 0x82800000+3 MB at each menu of a rendered run and keeping the
4-byte words that differ between screens and hold small integers leaves exactly
four of 786 432. One has the property that matters -- it changes on a screen
transition and holds steady when only the highlight moves:
0x828A690C screen id 1 title, 3 main menu, 4 extras
0x828F38AC menu cursor (second copy at 0x828F38BC)
0x828F37B4 per-menu value
Verified on a fresh --gpu=null run with no display at all, driving the same keys
blind: title 1/2/12, main menu 3/4/45, after 4x down 3/12/45, extras 4/14/49 --
4/4 exact against the rendered run, across two runs and two GPU backends. That
is the check that matters, since this corpus has already had to mark one runtime
address run-dependent.
tools/re-capture/menu_state.py reads them; `menu_state.py watch` prints on
change.
Open: the rest of the sequence into a mission. Blind driving reached extras
(screen 4) and a further A did not move it, so MISSION SELECT needs a cursor
move first. Screen ids beyond 4 are unmapped, and the rendered run freezes on
entering that screen -- so map ids up to the freeze, then step blind past it.
The cleanest control available, and it settles which side the bug is on. Xenia
has a null graphics backend. Same ISO, same route, same presses:
lavapipe --gpu=null
Main XThread CPU 0 ms / 3 s 3860 ms / 4 s
state / wchan S / futex_do_wait R / not blocked
guest memory churn ~0 (18 B of 8.2 MB) 22.6% of 6 MB in 4 s
allocations stall 114 -> 120, no failures
process CPU 265% (all llvmpipe) 330% (mostly guest)
With no rasterizer the guest does not freeze: it runs at close to a full core and
walks through the content load -- the exact point that hangs every rendered run.
So the fault is in the host rendering path, which explains why every game-side
hypothesis on that page was refuted in turn.
Blocked, and recorded as such rather than improvised around: deciding whether
lavapipe is deadlocked or merely taking hours over one pathological draw needs
hardware Vulkan, which this container does not have. Six minutes of nine
saturated llvmpipe threads with no frame is suggestive, not conclusive.
Amber workaround: --gpu=null gives a live guest, and everything the backlog needs
-- entity positions, flight model, world unit -- is read from guest memory, not
pixels. The gap is navigation: menu-walking is screenshot-driven, and blind A
presses advanced allocations 114 -> 120 but never reached a mission (DEF_VTABLE
and INST_VTABLE scans both 0). Next step is to drive navigation from guest
memory instead of the screen, which is engineering rather than mystery.
The previous section concluded the guest spins forever in sub_82457780.
Measured without gdb, that is false. Every observation behind it came from a
gdb-hosted run, and gdb intercepts every SIGSEGV -- which Xenia uses for guest
memory watches -- so it perturbs precisely what was being measured.
Same measurement, no debugger, per-thread /proc sampling parsed after the last
')':
Main XThread 0 ms CPU per 3 s, 0 faults, state S, futex_do_wait
GPU Commands 10 ms
WSI swapchain queue 130 ms
llvmpipe-0..9 ~1030 ms each per 6 s
Process at 265% of a core, essentially all in the software rasterizer. The
guest thread is BLOCKED on a futex using zero CPU -- not spinning, and not in
sub_82457780.
Under gdb that same thread appeared as the top CPU consumer (890 ms / 4 s) and
appeared to be in guest JIT code, then in xe::ExceptionHandlerCallback. That
was signal interception plus gdb re-stopping it between samples (state t,
wchan ptrace_stop).
Withdrawn: "an infinite copy loop in sub_82457780 is the freeze", and "Main
XThread is the top CPU consumer, so it is a spin not a block". The opposite
holds.
Survives: the emit_source_annotations technique is sound and did prove the PC
was 0x824578A0 while the guest ran under gdb, so the guest does pass through
that copy loop. The bne-exact-equality reading remains an accurate description
of the disassembly and a plausible hazard -- just not this freeze.
What the freeze actually is: guest blocked on a host futex at zero CPU, software
rasterizer saturated for six minutes without producing a frame, nothing
faulting, screen unchanged. That is the guest waiting on a GPU operation that
never completes, with lavapipe spinning -- a HOST RENDERING problem, which is
also why every game-side hypothesis this session was refuted in turn.
Method rule earned the hard way and recorded on the page: do not diagnose a
performance or liveness question under a debugger. Use it to read state at a
known stopping point, then re-measure timing and CPU detached.
The previous section withdrew this localisation for resting on a stale
PPCContext. It is now re-established by a sound method.
Xenia has a CPU cvar, emit_source_annotations, that makes MarkSourceOffset emit
"nop; nop; mov eax,<guest_address>; nop; nop" at EVERY guest instruction
boundary -- 90 90 B8 xx xx xx xx 90 90 in the code bytes. So the guest PC reads
straight out of memory around rip, with no context and no offset guessing.
Dumping backwards from rip gives a clean run of them -- 0x82457874, 78, 7C, 80,
84 -- and the annotation immediately before rip is 0x824578A0. That is
`sth r6, 0(r9)` inside sub_82457780: the exact instruction predicted from the
disassembly last time, now reached independently.
The loop advances r11 by 8 and terminates on `bne cr6` -- exact inequality. If
r31 - r7 is not a positive multiple of 8, the comparison never becomes equal and
the loop never exits.
And the thread is genuinely running: over 4 s, Main XThread consumed 890 ms of
CPU, the top of any thread, with the process at ~238%. A spin, not a block.
One bad metric recorded: I tried to show the loop marching through memory by
watching the last non-zero byte above 0x70200000. It never moved -- but that
statistic saturates in an already-written region, so it could not have moved.
Not evidence either way, though it briefly looked like a refutation.
Open and now narrow: what makes r31 - r7 non-congruent to 0 mod 8. r7 comes
from 0(r30), r31 is the container end, and r3 holds a fresh buffer from
0x824F7240 -- read those three from HOST registers at the freeze.
Went back to verify the instrumentation before building on it. Some does not
hold.
Verified: the trailing comments in ppc_context.h are not struct offsets (the
struct opens with eight cr unions and fpscr), but rsi+0x20 for r[32] is
nonetheless correct -- confirmed empirically because the f[32] doubles begin at
+0x140 and 32*8 back from there lands on +0x20.
Withdrawn: the context is STALE mid-function. The same dump shows r1 = 0, and a
guest stack pointer cannot be zero inside a function -- Xenia's JIT keeps live
guest registers in host registers and spills only at certain points. So
r13 = 0x82457864 does not show where the guest is executing, and identifying
sub_82457780 as the stuck function is withdrawn as unproven. The description of
what that code does (grow-and-copy, beq exit) is still accurate; it is just not
established that the guest is in it.
Three readings retracted for broken instrumentation:
* "rip identical across samples ⇒ no progress" -- gdb stops the process
between samples, and the JIT lays code out differently per run (a05be939 vs
a05bc839 for the same instruction).
* "zero minor faults on the stuck thread" and "utime=0" -- both parsed
/proc/<pid>/task/<tid>/stat with awk positional fields, but comm is
parenthesised AND contains spaces ("Main XThread (F"), so every index after
it was wrong. Printing state=XThread should have been the giveaway.
* the SIGSEGV fault-storm refutation rested on the same parse; downgraded to
unproven, though the process-wide ~300/s still argues against a tight loop.
Survives: the freeze reproduces without gdb; with the process stopped exactly
one thread is in userspace JIT code while a wchan census puts the rest in
futex_do_wait (55) or hrtimer_nanosleep (11); the instruction is a 16-bit store
through the membase to guest 0x701d0000, a stack-region address, first of a
four-store group; and that memory was unchanged over seconds of running time.
Next step is a host->guest code mapping from Xenia's own code cache rather than
inference from a stale context.
Ran the freeze under the corpus's gdb wrapper (ptrace_scope is 1, so a debugger
must launch rather than attach, and its handle lines are needed because Xenia
uses SIGSEGV for guest memory watches).
One thread of 80 is in guest code: thread 50, Main XThread, at rip a05be939 in
JIT output. All others are in a futex or clock_nanosleep.
Xenia's x64 backend keeps PPCContext in rsi, with r[32] at +0x20. The guest
GPRs there give r8 = a3ac0000 (the 64 MB buffer from the doubling sequence),
r12 = a3ac0a18 inside it, and r13 = 82457864 -- guest code, sub_82457780.
That address sits in a grow-and-copy: size = count*8 clamped to 0x1FFFFFFF, a
call to 0x824F7240, then a loop copying halfwords eight bytes at a time. The
host instruction it is stopped on is that copy's store:
mov %r12w,(%rdi,%rax,1) rdi = membase, rax = 0x701d0000
Sampled three times seconds apart with continue in between: rip, r13, r11 and
r31 identical every time. No progress.
The loop's exit test is beq -- equality, not >= -- so an inconsistent start/end
pair never terminates it. Recorded as amber: that is a reading of the
disassembly, not a demonstration.
Two of my own readings corrected:
* "the guest spins at ~400% CPU" was ps's CUMULATIVE AVERAGE since process
start, not an instantaneous rate. Per-thread sampling puts Main XThread
nowhere near the top.
* a SIGSEGV fault storm fitted the constant rip nicely and is refuted: 1500
minor faults in 5 s, zero major.
Caveat kept on the page: these are gdb-hosted observations and gdb intercepts
every SIGSEGV, so absolute timings are not the ungoverned ones. The freeze is
not a gdb artefact -- it reproduces in every non-gdb run.
Four runs now freeze at the first content load after the main menu with three
different allocation outcomes: stock (fails at 128 MB and throws), GPU-writeback
patched (no failure, stops at 32 MB), heavy XMA logging (no failure, stops at
16 MB), and no-savegame (no failure, no throw). The memory account on this page
is accurate but describes a symptom; everything treating the 128 MB refusal as
the freeze is superseded.
The savegame is not the variable. --content_root pointed at a profile-only tree
(the real content untouched) boots to the identical pools -- BC220000 Size
03A80000 and B50C0000 Size 070E0000, same addresses and sizes, same 43
allocations by the menu -- then freezes at the same press with zero failures.
Not slow shader compilation either. Every earlier run called it frozen within
~30 s; this one was left six minutes at 0.00% liveness with CPU still 399-479%.
Correction made mid-measurement: the allocation counter creeping 119->124 looked
like "guest running, display stuck". It is not. A broad sample -- 400 extents,
8.2 MB -- changed 18 bytes in 3 seconds. The guest is spinning, with one thread
allocating about once a minute. The earlier "8 MB slab unchanged" reading was
weak for the same reason in reverse: one arbitrary region proves little. Sample
broadly before calling a guest alive or dead, exactly as the screen needed two
frames rather than one.
Now excluded: the heap failure, the leak, rounding, MmQueryStatistics, a
heap-size knob, the exception cvar, a kernel-object wait, a build regression, the
route, the savegame, and shader compilation.
Read from a LIVE guest (main menu, liveness 98.59%) rather than a frozen one,
sampling a page per megabyte:
B50C0000 112.82 MB 89.7% zeros, header all zero
BC220000 58.45 MB 90.8% zeros, header = pointers into itself
Neither holds content at the main menu and neither matches a disc file's size.
They are pre-reserved pools claimed at boot and mostly untouched.
BC220000's first words -- bc220010, bc79c9c0, bcd24c00, bc220040, bd17cda0,
bc79ccc0, all inside [BC220000, BFCA0000) -- interleaved with ordinals 6,0,4,5,
0,1,2,3, are an intrusive free-list. That closes a loop from this morning: the
22 "Release failed because address is not a region start" messages were frees of
interior pointers into this exact block. The game suballocates from its own
pool and hands the sub-pointers to MmFreePhysicalMemory. Independent
confirmation that Xenia is right to refuse them.
It sharpens the contradiction rather than resolving it. The budget adds up --
~171 MB of pools, ~379 MB live at the menu, 128 MB wanted for a content load,
about 495 MB of a 512 MB console against Xenia's fixed 16 MB GPU reservation,
which is the measured ~15 MB shortfall. But the SAME emulator with the SAME
reservation ran a mission for 500 s on 08-10, and that run's evidence is entity
hull values sampled from guest memory at 2 Hz, not screenshots, so it is not the
frozen-frame illusion that fooled this session twice.
So the next thing to vary is the boot inputs -- profile, save data, config --
since B50C0000's request is 0x070D28B0, an odd computed size rather than a round
reservation, and so plausibly depends on something configurable.
Last iteration concluded the freeze is "entering MISSION SELECT" and made
avoiding that screen the next experiment. Ran it; the conclusion was too
narrow.
First, a liveness metric that actually separates the states: two frames five
seconds apart, percentage of pixels changed. The menu animates, so healthy is
99.80-99.97% and frozen is 0.00%. No navigation script needed, and no
classifier. Committed as tools/re-capture/route_liveness_probe.sh; this is what
should have been used from the first run.
Then the menu's FIRST item, NEW GAME, which never touches MISSION SELECT:
main menu 99.80% alive
after A on NEW GAME 7.33%
after the next A 0.00% -- frozen, and screen_id calls it "flight"
with the same 134217728-byte AllocRange failure in the log.
So the correct statement is broader: the game freezes on the first content load
after the main menu, whichever item is taken. MISSION SELECT was just the route
every earlier run used. "Avoid MISSION SELECT" is withdrawn -- there is nothing
to avoid, and that also puts the memory account back at the centre, since ~379
MB live plus a 128 MB content load fails on any route.
Worth repeating because it caught me twice: screen_id.py called a frozen frame
"flight" on a run that never left the menus. Liveness first, classification
second.
The whole page called this a stage-load/take-off freeze. Wrong, and the
evidence was in each run's own screenshots.
nav_to_flight.sh saves a shot per step. Compared CONSECUTIVELY:
01-extras -> 02-missionselect rmse 51.07, 99.42% changed
02-missionselect -> 03-selected rmse 0.00, 0.00% changed
03 -> 04 -> 05 -> 06 -> 07 -> 08-flight all 0.00
Shots 02 through 08 are pixel-identical. The screen stops at MISSION SELECT and
never changes. Every later step the script reports -- selected, readyroom,
takeoff, flight -- is it pressing buttons at a frozen image while screen_id.py
classifies a static frame.
Withdrawn as a result:
* "MISSION SELECT and the stage list worked, the failure came later during the
take-off load". It froze entering MISSION SELECT.
* challenge-mission-gate.md §5.6 already said entering MISSION SELECT fails on
a 128 MB request and shows "Disc Read Error". It was right; I contradicted
it because my navigation script claimed to have got further.
* Every "reached flight" in this session is false, including runs where
screen_id.py returned "flight".
Method note kept on the page: the first comparison ran every shot against shot 1
and returned rmse 51.07 / 99.42% for all seven, identical to two decimals across
seven different images. That is a tell, not a result. I then talked myself out
of it because md5sum showed eight distinct hashes -- which was PNG metadata.
Compare consecutive frames, and treat a suspiciously constant statistic as a bug
in the measurement.
Consequence for the oracle: the allocation failure happens while the display is
already frozen, so it is downstream, which fits the three runs that froze with
the allocation succeeding at 16, 32 and 128 MB. MISSION SELECT is the thing to
avoid -- every route this session used goes EXTRAS -> MISSION SELECT, and
newgame_path.sh / tutorial_launch.sh reach a mission by other menus.
This corpus has a 500-second Stage 02 flight from 2026-08-10, and the canary
tree gained the file-pad driver, the UI-draw capture, a threading_posix resume
fix and log_stuck_waits between then and now, with the running binary rebuilt on
08-24. So a regression was the obvious suspicion.
It is wrong. The container already keeps older builds under
/sylph-home/re/bin/, plus a host build from 08-17 that predates all of the 08-19
changes, and run-canary honours $XENIA_BIN -- so this cost no rebuild. The
08-17 binary, same route, same point: 1 alloc failure, 1 guest throw.
Identical.
So bisecting the emulator is not the way in, and the 08-10 run did not differ by
binary either. Whatever let it reach flight is in the route or the game state.
That sharpens the open question usefully. Both of the two largest live blocks --
112.88 MB at B50C0000 and 58.50 MB at BC220000 -- are allocated at boot, before
any menu, so 171 MB of the 379.5 MB is fixed regardless of route. The remaining
~208 MB is where a route difference could live, and the ledger can measure it:
capture live-bytes at the moment TAKE OFF is pressed for two navigation paths
and compare.
The canary branch already carries log_stuck_waits, written for this exact
question: it names the object a guest thread keeps timing out on, and is silent
on a healthy run because a wait that gets satisfied never builds a streak.
Enabled it and ran to the freeze on the stock build. Not one stuck-wait line,
alongside the usual AllocRange failure and guest throw. So no guest thread is
parked on a kernel object that never gets signalled; combined with 389% CPU
across running threads, the guest is spinning in its own code -- which is what
the throw-that-returns predicts, since execution resumes after the throw and
runs into code that assumed it would not.
Operational note worth having: --log_stuck_waits=true on the command line is
ACCEPTED but the config file value wins, and the first run silently logged
"log_stuck_waits = false" while I thought it was on. The startup dump prints
the effective value -- check the dump, not the flag. The flag is cheap and
silent so it is now left enabled in the container config.
Rebuilt canary twice to test the freeze, and the result demotes my own headline.
1. The game does not size anything from MmQueryStatistics. Xenia reports
kernel_pages = 1 MB under a comment admitting the numbers are guessed, and
the game really does call the export -- sub_82612420 converts
total_physical_pages and title.available_pages to bytes, and its caller holds
available-bytes in r23 while creating render surfaces. Patched it to 32 MB,
twice the shortfall, and rebuilt: 122 allocations, 484.5 MB ever, 379.5 MB
live in 84 blocks, failing at "free 28969/131072" -- byte-identical to stock.
A 32x change moved nothing. Refuted; reverted.
2. The ~19 MB the ledger could not see is Xenia's own startup reservation:
memory.cc:240 pins 16 MB of the parent heap for GPU writeback before the
guest runs, and vC0000000's parent IS the 512 MB physical heap. 16 MB is
more than the 14.84 MB shortfall.
3. Shrinking that reservation to 1 MB removes the failure completely -- zero
AllocRange failures, zero guest throws -- AND THE GAME STILL FREEZES.
Verified with a single emulator after killing the stale one: three frames at
rmse 0.00, an 8 MB guest slab unchanged over 3 s, 389% CPU on 3 running
threads. The ledger shows it dying EARLIER, stopping at the 32 MB doubling
step where stock reached 64 MB, so shrinking a live GPU region hangs the GPU
instead. Reverted.
So "the freeze is the refused 128 MB allocation" was too strong. The refusal is
real and Xenia's 16 MB reservation is the swing factor that decides it, but the
guest hangs without it too, at an earlier point. The refusal is one way this
stage load dies, not the cause.
Also recorded: --eh_dispatch, --mem_watch and --audio are all named in this
corpus's own scripts and docs and NONE exists in this build. Each is silently
rejected, which blocks boot rather than warning.
Built the allocation ledger to test the leak I proposed this morning, and it
refutes it.
* No leak. The books balance: 379.5 MB live in 84 blocks plus 113.2 MB free
is 492.7 MB of the console's 512, with ~19 MB in allocation paths the filter
did not capture. Nothing is missing that a leak would explain.
* The 22 "leaked" releases are correct refusals. All 22 failing frees are
interior pointers into ONE allocation -- the 58.5 MB block at BC220000, at
offsets from 5 to 30 MB. The game sub-allocates out of a physical pool and
frees the sub-blocks. BaseHeap::Release frees whole regions, so honouring
an interior pointer would free 58.5 MB, twenty-two times over. The fix I
was moving toward would have been a catastrophe.
* Rounding is not it either: 480.67 MB requested against 484.50 MB granted is
3.83 MB (0.8%), versus a 14.84 MB shortfall.
What actually happens is a doubling grow that holds both buffers: allocate 32 MB,
free the previous, allocate 64 MB, free the 32, then ask for 128 MB while still
holding the 64. That needs 192 MB live for one buffer on top of ~315 MB held
elsewhere, and comes up 14.84 MB short.
No configuration fixes it. There is no guest memory-size cvar; the 512 MB is
baked into the address map rather than a constant (the aliases at 0xA0000000 and
0xC0000000 are spaced exactly 0x20000000 apart, so growing the heap runs one
window into the next); and this tree has no eh_dispatch cvar, so
RtlRaiseException routes the guest's OOM throw to HandleCppException, which logs
and returns without unwinding. That fall-through is the freeze.
Also worth recording: log_mask DISABLES categories (Kernel=1, Apu=2, Cpu=4,
Gpu=8), so the --log_mask=13 used throughout this corpus has been running with
the kernel log switched off.
The oracle is NOT fixed. Ledger committed as data/heap-ledger-stage01.txt.