re(flight): the clock factor is universal — angular matches too once corrected in-run

pitch_gametime.py brackets each turn phase with HUD screenshots, so the mission
clock's own advance converts wall seconds to game seconds within the same run:

  this run's clock: TIME 00:33.68 -> 00:44.12 = 10.44 s game in 7.96 s wall = 1.311

  pitch @ min speed  88.9 deg/wall-s  /1.311 -> 67.8 deg/game-s  vs AV_PitchMinus_Min 75
  pitch @ max speed  53.6            /1.311 -> 40.9             vs AV_PitchMinus_Max 40

Both land on the definition (the slow phase 10% low, consistent with including the
AA_* ramp in an 8 s window), so the clock explanation covers angular motion as well:
every stated rate is per GAME second.

The ratio is not a machine constant — 1.260 in the earlier flight, 1.311 here — so it
must be measured in the same run as whatever it corrects. Bonus: the same shots show
the HUD reading 102 at full LT against MinimumVelocity 100.

Also documents the trap that cost three runs: a killed Canary leaves both its shm
image and its last frame on screen, so a dead emulator looks alive and the scans
report "0 moving triples" like a tooling bug. pgrep -x matches zombies, so
speed_law.require_live_emulator() checks the process state letter and refuses to
measure a corpse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
This commit is contained in:
2026-08-13 17:56:16 +00:00
parent ae5f322c03
commit 8b10c0451f
4 changed files with 458 additions and 3 deletions

View File

@@ -14,18 +14,40 @@ position triple, which is why it survives a firefight.
Usage: speed_law.py <out.csv> [hold_s]
"""
import os, struct, subprocess, sys, time
import glob, os, struct, subprocess, sys, time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gworld, entities2
def require_live_emulator():
"""Refuse to measure a corpse.
A killed Canary leaves its `/dev/shm/xenia_memory_*` image behind AND leaves its
last frame on the X display, so a dead emulator looks alive from both of the
obvious angles: the screenshot shows a mission in progress and the memory image
still parses. The scans then report `0 moving triples` / "player entity not
found", which reads like a tooling bug rather than a dead game. Note `pgrep -x
xenia_canary` is NOT enough — zombies match it — so the state letter is checked.
"""
out = subprocess.run(["ps", "-o", "pid=,stat=", "-C", "xenia_canary"],
capture_output=True, text=True).stdout
live = [l for l in out.splitlines() if l.split()[1:2] and not l.split()[1].startswith("Z")]
if not live:
stale = glob.glob("/dev/shm/xenia_memory_*")
sys.exit(f"no live xenia_canary (zombies only); {len(stale)} stale shm image(s) "
f"— the screen and the memory file are both leftovers, relaunch first")
def find_player(retries=8):
"""Lock onto the player object that is actually FLYING.
Verifies the emulator is alive first — see `require_live_emulator`.
A mission holds more than one `*_Player` object — `entities2 list` shows two —
and at least one of them never moves. Taking the first match locks onto that
one, and then every probe reads a speed of exactly 0 while the game is visibly
flying (and the HUD keeps reading 350). So sample each candidate twice and keep
the one that displaces."""
require_live_emulator()
for _ in range(retries):
w = gworld.World()
defs = entities2.definitions(w)
@@ -35,13 +57,18 @@ def find_player(retries=8):
best = None
for off, nm in cands:
a = pos_at(w.fd, off)
time.sleep(0.3)
time.sleep(0.5)
b = pos_at(w.fd, off)
d = sum((b[i] - a[i]) ** 2 for i in range(3)) ** 0.5
if best is None or d > best[0]:
best = (d, off, nm)
if best and best[0] > 0.5:
# A craft can be briefly slow (launch, a hard turn), so do not demand a big
# step — just prefer the candidate that moves at all. The failure this
# guards against is the STATIC duplicate, which never moves by any amount.
if best and best[0] > 0.05:
return w, best[1], best[2]
print(f"# {len(cands)} player candidates, best displacement "
f"{best[0] if best else 0:.3f} — retrying", flush=True)
time.sleep(2)
return None, None, None