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 (914ada4 producer, 6aa31e5 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 6aa31e5.
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.
Closes the caveat I flagged in 4ae63cb. 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.
The previous write-up argued the leak from adjacency -- 22 failed releases next
to the failed allocation. memory.cc closes the loop outright:
* the "parent free N/M pages" in the error is
parent_heap_->unreserved_page_count() (memory.cc:1807);
* unreserved_page_count_ is incremented in exactly ONE place, memory.cc:1445,
inside BaseHeap::Release's page-table loop;
* the failing path returns at memory.cc:1399, at the top of that same
function, before the loop -- page table untouched, no free block inserted;
* and PhysicalHeap::Release delegates to parent_heap_->Release, so the release
that fails and the allocation that later comes up short are the same heap.
So every "address is not a region start" returns zero pages to the counter the
allocator consults, and those pages stay reserved for the life of the process.
That is control flow, not correlation.
Magnitude is still open and I am not claiming it: 512 - 113 = 399 MB missing
against only 22-23 failed releases would need ~18 MB average each, which is
implausible as the whole story. Leaked releases are a contributor, maybe not
the dominant one. An allocation ledger -- log every MmAllocatePhysicalMemoryEx
and MmFreePhysicalMemory with sizes and balance them -- would settle it, and is
a better use of a run than reproducing the freeze again.
Also recorded so nobody hunts for it: there is NO cvar for guest memory size.
memory.cc has only protect_zero / protect_on_release / scribble_heap and the
MMIO ones, and xboxkrnl_memory.cc says "We don't support separate devkit
memory, so just ignore this flag". 512 MB is hardcoded to the retail console,
so the freeze cannot be dodged by giving the emulator more -- a fix has to be
the release path itself.
"Allocation failed" invites blaming the box, so I measured the box. Nothing on
the host side is binding: /dev/shm is 2.0 G at 17% used with 1.7 G free (the
guest memory file is 4.5 G apparent but sparse, only 319 MB of real blocks),
host RAM has 12.3 G available of 15.9, and the container cgroup is at 2.9 G of a
7.0 G limit.
The log's own numbers say where it really is:
131072 pages x 4 KB = 512 MB <- the Xbox 360's unified memory, exactly
28969 pages x 4 KB = 113 MB free
0x08000000 = 128 MB requested
So "parent heap" is the emulated console's physical memory. The game runs a
real 512 MB console down to 113 MB free and then asks for 128 MB; the container
is not involved.
Mechanism, recorded as amber rather than settled: 23 and 22 occurrences of
"BaseHeap::Release failed because address is not a region start" in the two
runs, adjacent to the failure (lines 1115-1176 against a failure at 1179). A
release that cannot find its region start returns without freeing, so each leaks
guest physical pages. That is inference from adjacency and count -- I have not
sampled free pages over time, which is the test that would settle it.
And the release failing at all is an emulator-side bug, not a game one: the
guest is freeing at an address Xenia's heap does not recognise as a region base.
challenge-mission-gate.md §5.6 attributes the 128 MB heap failure to a careless
cleared-stage mask poke, concludes that poking only real story ids does not blow
the heap, and ends by asking for the control: repeat without the poke.
Ran it. nav_to_flight.sh gains SYLPH_NO_POKE=1, which skips the write; only
Stage 1 is selectable without it, so the control changes stage too, which makes
the agreement stronger rather than weaker.
poked 0x0001FFFE Stage 02 frozen, 128 MB request refused
control untouched Stage 01 frozen, 128 MB request refused
Both logs carry not merely the same error but the same numbers:
requested 134217728 bytes, parent free 28969/131072 pages
28969 in both, across two stages and two boots. So the poke does not cause it
and neither does the stage; the guest reproducibly arrives at a 128 MB request
with ~113 MB free. An identical free-page count across independent runs also
says the allocation pattern is deterministic -- not a race, not host pressure.
The control was verified three ways, because the first attempt was confounded:
two emulators were alive at once (the previous one survived a pkill). The mask
was read back as 0x0 from the live mapping, the log was confirmed to be this
run's, and the liveness test was repeated after killing the stale process so
exactly one emulator was running -- three frames at rmse 0.00, and an 8 MB slab
of guest RAM with 0 bytes changed over 3 s.
Reproduced on a Stage 02 run and root-caused. Two witnesses, both taken while
screen_id.py was calling the screen "flight": three screenshots over 8 s at rmse
0.00 with 0.00% of pixels changed, and a 4 MB slab of guest RAM with 0 bytes
changed over 2 s. The emulator is not deadlocked -- 399% CPU over 15 running
threads. It spins while the guest does nothing.
The log stops mid-stage-load on:
PhysicalHeap::AllocRange unable to alloc physical memory in parent heap
(requested 134217728 bytes, parent free 28969/131072 pages)
MmAllocatePhysicalMemoryEx: Allocation failed Size: 08000000
Guest attempted to throw a C++ exception!
128 MB requested against ~113 MB free. So it is not only fragmentation, which
is what "failed to find contiguous range" suggests on its own -- there was less
free memory in that heap than the request needed at all. Preceded by repeated
"BaseHeap::Release failed because address is not a region start", which
challenge-mission-gate.md already notes leaks the range; a leak that repeats
through a session supplies the mechanism the freeze's variable onset (27, 45,
83, 183 s) needs.
This refutes a standing claim. challenge-mission-gate.md §5.6 concludes that
poking only real story ids (0x0001FFFE) does not blow the heap. This run poked
exactly that and hit the same 128 MB failure. Bounded, though: there the
failure was on entering MISSION SELECT, here MISSION SELECT worked and the
failure came at the take-off load -- so the reading is that the poke value is
not what decides it. That page's own open question, repeat without the poke,
is now the load-bearing experiment and is still unrun.
Also recorded: a frozen game passes the screen classifier. A single-frame
statistic cannot distinguish flight from frozen-in-flight, which is why the
entity probes returned 0 definitions, 0 movers and 0 vtable hits with no sign
anything was wrong. A second frame costs nothing and is decisive.
Spent a session getting to flight. Each obstacle presents as "the emulator
died" and none of them is.
* --audio prevents boot. run-canary's header already says the flag is not a
cvar in this tree and that an unknown argument blocks in a message box
before logging starts. Measured anyway, because the corpus also holds runs
that passed it and booted: 3 trials each in BOTH orders, 67 565 bytes of log
without the flag and 209 with -- and 209 is run-canary's own banner, not one
line from xenia. Order was reversed on purpose; this corpus has a standing
lesson that an A/B from run order is noise. Eight scripts on branch
auto/idxd-unnamed-keys still pass it; main and this branch are clean, which
reconciles August's successes with today's failures.
* launch_mission.sh's skip_intro deadlocks. It calls the attract loop a
"movie" and refuses to tap, and waited out 600 s of unbroken movie verdicts
before timing out. nav_to_flight.sh, against the same running emulator,
reached the main menu in 12 s and flight in 2 min 20 s by tapping A at the
title. The "wait it out" premise is wrong: the loop does not end.
* "EMULATOR GONE at ~40 s" is this project's own Stop hook killing xenia when
a Claude turn ends. That is recorded further down this same file and I
rediscovered it over three boots because I did not look. Sequential tool
calls within one turn are fine; ending the turn is what kills it.
The world unit is still unmeasured. Flight was reached and the screen
classifier agrees, but entities2.py finds 0 unit definitions -- its committed VA
window does not match this run, the same run-dependent-address problem this file
documents for the OB counter. Next attempt must hunt the range.
An audit of BACKLOG.md turned up a class of error with a single root cause: the
README defines only the CONFIRMED/PROBABLE/HYPOTHESIS confidence scale, while
the pages actually use a second vocabulary -- and 🔴 appears 98 times without
ever being defined. It gets used for two different things, "refuted" and
"blocked", and three entries slid from one into the other.
README now defines ✅/🟡/❔/🔴/❌/🚧 and states the rule the corpus was missing:
🔴 never means "we have not run it yet". That is ❔ or 🚧. Its blocked sense is
only for a real limit of the box -- no push credentials, no hardware Vulkan, a
decision only the user can make -- and since the box can run the emulator,
script input, screenshot and read guest memory, "needs a run" is never blocked.
I made exactly this mistake on the world-unit item earlier today, which is what
prompted looking for others.
Fixed in BACKLOG.md:
* the elimination test, marked 🔴 UNRUN and in fact run and refuted nine
lines further down;
* the frozen capture, marked 🔴 STILL UNRUN and in fact taken eleven lines
down -- 🔴 wrong twice, since "the freeze did not happen this run" is a
scheduling outcome and not a refutation;
* a 🚧 STILL UNRUN item whose stated blocker (the boot-nav bug) is fixed;
* the objective-counter heading, which asserts 0xbdb59668 as the answer while
its own first body line refutes that address -- retitled to say what is
actually solved, the method;
* the paint-order "third measured permutation" question, answered inside its
own entry by a third, fourth and fifth screen;
* the UTF-16 endianness question -- resolved, and it is not a stale comment:
localization.rs both documents LE and decodes with u16::from_le_bytes, so
it is a code bug worth filing.
Also fixes the corpus's only dangling link (INDEX.md pointed at
structures/idxd-unnamed-keys.md, never written).