re(flight): a per-frame sampler, and nav oracles that a menu bar cannot break
WIP toward the residual flight-speed-law question (does a 1 s burst reach the
steady angular rate, or is there a per-axis multiplier?). The write-up already
concluded that host-side polling cannot answer it and named a Canary-side hook
as the tool required; that hook now exists (--frame_probe_log, committed as
auto/re-frame-probe in xenia-canary-native) and this is the harness for it.
- `rebuild_canary.sh` -- the surgical rebuild the box can actually do, kept in
the repo this time instead of in /tmp: compile only the changed objects, `ar`
them into their archive, and re-run the link command lifted out of the
generated ninja. A full `ninja` is impossible here (several TUs need dev
headers the image lacks) and the build cache cannot be re-configured. 31 s.
- `frame_burst.py` -- points the probe at the player craft's transform block
(pos-112, the three 16-byte-strided rows plus the position) and drives full
stick holds, recording each hold's start and end in the same clock the probe
stamps its lines with.
- `frame_session.sh` -- the whole run as ONE blocking foreground call, per the
session-lifetime rule; REUSE=1 drives a Canary that is already up.
- `nav_to_flight.sh` -- fly_stage.sh's navigation, split out so a live emulator
can be re-used. A boot to the title costs minutes under lavapipe and a run
that only failed to NAVIGATE should not pay for it twice.
The navigation change is the one worth reading. Every screen oracle here tested
named pixels ("648,221 is white"), which is only valid while the game image sits
at a known place on the root window -- and it does not: xenia's GTK window has a
menu bar, so on this display the image is ~25 px lower and every constant reads
the wrong row. Nothing errors. One run sat 300 s in front of a plainly visible
MAIN MENU reporting "no main menu"; the next missed the title screen entirely
and let the attract movie loop for ten minutes.
So `screen_id.py` identifies screens by WHOLE-IMAGE statistics instead -- the
fraction of green UI-text pixels, the fraction of near-white pixels, and the
per-channel means -- which no vertical shift, scale or letterbox can move. It is
calibrated against known-good captures and classifies all of them correctly:
title, three different menu screens, in-flight, and four movie frames as
"other". `bin/screenshot` additionally crops the menu bar off saved evidence
shots, deriving the offset from the window's own height rather than a constant.
Not yet a finding: the run has not reached flight, so no rate has been measured.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
109
tools/re-capture/frame_burst.py
Executable file
109
tools/re-capture/frame_burst.py
Executable file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Drive stick bursts while Canary samples the craft's transform ONCE PER FRAME.
|
||||
|
||||
Why this exists. Every earlier rate measurement polled guest RAM from the host
|
||||
through /dev/shm. That read is unsynchronised with the guest: adjacent samples
|
||||
are separated by an unknown number of guest updates, so short windows alias --
|
||||
`ramp_probe.py` got 3x swings between neighbouring 0.25 s windows and could not
|
||||
tell "the rate ramps up after the stick goes over" from "the sampler is lying".
|
||||
See docs/re/flight-speed-law.md, which names a Canary-side hook as the tool the
|
||||
residual needs.
|
||||
|
||||
That hook now exists (`--frame_probe_log`, sampled in VdSwap, one line per guest
|
||||
frame). This script only has to point it at the player craft and drive the pad,
|
||||
recording when each hold starts and ends in the SAME clock the probe stamps its
|
||||
lines with, so the analysis can cut the log at the exact frame the stick moved.
|
||||
|
||||
Usage: frame_burst.py <events.csv> [hold_s] [repeats]
|
||||
Env: XENIA_FRAME_PROBE control file (default /tmp/xenia_frame_probe.txt)
|
||||
"""
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import gmem # noqa: E402
|
||||
import speed_law # noqa: E402
|
||||
from axis_probe import pad_state # noqa: E402
|
||||
|
||||
PROBE = os.environ.get("XENIA_FRAME_PROBE", "/tmp/xenia_frame_probe.txt")
|
||||
|
||||
# The transform block sits BELOW the position triple: three 16-byte-strided rows
|
||||
# starting at pos-112, position at pos+0 (nav-live.json: rot_delta -112,
|
||||
# rot_stride 16). One region covers both.
|
||||
ROT_DELTA = -112
|
||||
REGION_LEN = 128
|
||||
|
||||
# Full deflection on each axis, plus the throttle settings the burst design uses.
|
||||
BURSTS = [
|
||||
("roll_cruise", {"lx": 32767}),
|
||||
("pitch_cruise", {"ly": 32767}),
|
||||
]
|
||||
|
||||
|
||||
def write_regions(regions):
|
||||
tmp = PROBE + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write("# written by frame_burst.py\n")
|
||||
for va, ln in regions:
|
||||
f.write(f"{va:08x} {ln}\n")
|
||||
os.replace(tmp, PROBE)
|
||||
|
||||
|
||||
def pos_at(fd, off):
|
||||
return struct.unpack(">3f", os.pread(fd, 12, off))
|
||||
|
||||
|
||||
def main():
|
||||
out_csv = sys.argv[1]
|
||||
hold = float(sys.argv[2]) if len(sys.argv) > 2 else 3.0
|
||||
reps = int(sys.argv[3]) if len(sys.argv) > 3 else 2
|
||||
|
||||
w, off, nm = speed_law.find_player()
|
||||
if not w:
|
||||
sys.exit("player entity not found")
|
||||
base_off = off + ROT_DELTA
|
||||
va = gmem.primary_va(base_off)
|
||||
if va is None:
|
||||
sys.exit(f"file offset {base_off:#x} has no guest VA")
|
||||
print(f"# locked on {nm}: transform block at VA {va:#010x} (+{REGION_LEN})")
|
||||
write_regions([(va, REGION_LEN)])
|
||||
|
||||
# The probe only starts emitting once the emulator notices the control file,
|
||||
# which is at most one frame. Give it a moment, then prove the craft is
|
||||
# actually flying before spending the run on it.
|
||||
time.sleep(1.0)
|
||||
p0 = pos_at(w.fd, off)
|
||||
time.sleep(1.0)
|
||||
p1 = pos_at(w.fd, off)
|
||||
moved = sum((p1[i] - p0[i]) ** 2 for i in range(3)) ** 0.5
|
||||
if moved < 1.0:
|
||||
sys.exit(f"craft is not moving ({moved:.3f}) — not in flight, or dead")
|
||||
print(f"# craft is flying ({moved:.1f} units/s)")
|
||||
|
||||
rows = []
|
||||
for rep in range(reps):
|
||||
for label, state in BURSTS:
|
||||
pad_state()
|
||||
time.sleep(5.0)
|
||||
t_pre = time.time()
|
||||
pad_state(**state)
|
||||
t_on = time.time()
|
||||
time.sleep(hold)
|
||||
pad_state()
|
||||
t_off = time.time()
|
||||
rows.append((rep, label, f"{t_pre:.6f}", f"{t_on:.6f}", f"{t_off:.6f}"))
|
||||
print(f"# rep{rep} {label}: held {t_on:.3f} -> {t_off:.3f}", flush=True)
|
||||
time.sleep(2.0)
|
||||
pad_state()
|
||||
|
||||
with open(out_csv, "w") as f:
|
||||
f.write("rep,label,t_pre,t_on,t_off\n")
|
||||
for r in rows:
|
||||
f.write(",".join(str(x) for x in r) + "\n")
|
||||
print(f"# wrote {out_csv}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user