The port asked for two wall-clock timestamps across the boot splashes. Measured, and the measurement's own result is that timestamps are not the invariant. The dwells are the bundles' own declared timelines: publisher t=0..255 = 4.250 s at 60 units/s, developer t=0..210 = 3.500 s. The corpus's independent screenshot timing over three cold boots gives 4.30/4.60/4.37 and 3.51/3.50/3.37 -- the developer agreeing to 1.1%, two of its three runs to 0.3%. A fresh no-input boot with a frame->wall-clock map puts the same two dwells at 5.10-5.61 s and 3.83-4.30 s, 15-20% longer than both the declared values and the corpus's runs, on the same disc and the same declared timeline. So the wall-clock dwell is an emulator-pacing artefact that varies run to run, and a port authoring seconds is authoring one run's pacing. Boundaries from the draw stream, read per quad: publisher glow frame 1, wordmark 6-119, three frames with NO sprite drawn, developer glows 123, wordmarks 140-209, intro video 216. The 3-frame gap replicates the earlier 4-frame measurement within the +-1 both are quantised to. New tool frame_clock.sh, and its limitation found by its own control: it resolves to one BUFFER FLUSH, not one frame. The capture writes through a C++ ofstream, so tail sees the log in bursts -- 69 of 125 samples showed no advance and the rest jumped 7-15 frames. Naive interpolation inside a burst made the apparent rate swing between 0.0164 and 0.0316 s/frame, which is the flush and not the guest. Frames 119 and 123 fall in one burst, so the inter-splash gap is not separable by this clock at all. Everything is quoted as brackets and the point estimates were withdrawn before being reported. palogo_anima never appears in the log and is NOT reported as undrawn: the developer bundle batches 7 elements into one draw and only the first two quads are logged. That is the trap that produced the eff3 false negative, so it is named rather than claimed. Also records the port's correction: ptcopyright has 105 instants with alpha >= 1 (t=139..243) against 105.89 units of span; I had quoted the rounded span. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
Runtime-capture harness (sylph-re container)
Screenshot-driven scripts for reading the running retail game's menus under Xenia
Canary + lavapipe, headless. They assume the container helpers screenshot,
vgamepad, pad are on $PATH and HOME=/sylph-home/re.
| Script | What it does |
|---|---|
skip_intro.sh |
Boot → main menu, unattended. Taps A only while the intro movie is actually playing (frame-to-frame RMSE), then once at the PRESS Ⓐ BUTTON title. Static logo screens are left alone, so a stray tap can never land on NEW GAME. |
wait_title.sh |
Older variant: wait for the title (green Ⓐ glyph at px 625,618) and tap A. Superseded by skip_intro.sh. |
step.sh |
One Arsenal navigation step (down/up/next/prev/none) + a compact capture: weapon list stacked over the DATA SHEET. |
sweep.sh |
Walk a whole weapon-type list, capturing only rows that show a DATA SHEET — locked rows (a "Conditions to Develop" panel) are detected by the brightness of the Range Class label box and skipped. |
type.sh |
Change weapon-type tab N times (RB) and report the header strip. |
hp.sh / cyc.sh |
Hangar hard-point carousel: cyc.sh steps it (d-pad down, not left/right) and captures the Name + DATA SHEET. |
Input timing under lavapipe: the game polls input at its own low frame rate, so a 60 ms d-pad tap is dropped roughly half the time. 200 ms is reliable; 300 ms starts to auto-repeat (two rows per press).
Findings produced with these: docs/re/weapon-datasheet-runtime.md.
Guest-memory tools (no screenshots)
| Script | What it does |
|---|---|
gmem.py |
Read the live guest address space out of /dev/shm/xenia_memory_* (Xenia's backing file), addressed by guest VA. find / read / words. |
weapon_runtime.py |
Solve the Weapon/Shell struct layouts against the disc records and read the fields the disc defaults. |
unit_discover.py |
Find which runtime class carries a set of ID strings, assuming no vtable: tallies the word at pointer_site - k across distinct IDs. |
unit_runtime.py |
Same solver for the unit\UN_*.tbl definition objects (vtable 0x820af844). Unions several snapshots — unit definitions are per-stage. |
schema_order.py |
Merge a sub-record's field-declaration order across all tables (topological sort); the layout check that pins fields no table ever values. |
order_check.py |
Test offset = base + 4*index for one table's sub-record against solver output. |
grab_tutorial.sh |
Cold-boot Canary, walk to the Nth TUTORIAL entry, wait for the stage load, snapshot guest RAM. One emulator per capture — backing out of a loaded mission wedges it. |
Snapshot first — cp --sparse=always /dev/shm/xenia_memory_* snap.bin (~2 s) — and
point $GMEM_FILE at the copy; the running emulator pegs every core under lavapipe.
🔴 pgrep -f / pkill -f match YOUR OWN shell
An agent driving this toolkit runs its commands through a wrapper shell whose command line contains the pattern being searched for. So
pgrep -f 'pilot\.py' # matches the wrapper running this very command
pkill -f 'fly_session|pilot\.py' # kills that wrapper — the script dies mid-way
This has cost four separate mistakes in one session: two scripts killed
mid-execution, and twice a "is it already running?" guard that answered yes
because it had found itself. The [p]ilot bracket trick does not help when
the literal invocation (python3 pilot.py …) also appears on the wrapper's
command line.
What works:
pgrep -x xenia_canary # exact NAME match, no -f
ps -eo pid,args | grep 'python3 pilot.py' | grep -v snapshot-bash
kill -9 <explicit pid> # look it up first, then kill by pid
The snapshot-bash filter is the reliable tell: the wrapper's command line
always contains the shell-snapshot path.
🔴 The emulator does not survive the end of an agent turn
/work/.claude/settings.json defines a Stop hook that kill -9s every
xenia_canary when a turn ends, printing "Stop hook killed N stale xenia
process(es)".
So:
- never launch a run intending to read it in a later turn — it will be dead;
- an experiment has to produce its evidence within the turn that starts it;
- prefer measurements that land in the first minutes of flight.
REMAINING OBsteps4 → 8 → 12inside the first few minutes, which is why a two-pass differential works in one turn while a 25-minute freeze watch does not.
This cost three iterations of investigating a "mysterious external SIGKILL", complete with cgroup and host memory forensics, before the hook was found. When a process dies at a session boundary, check the harness first.