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:
72
tools/re-capture/pitch_gametime.py
Normal file
72
tools/re-capture/pitch_gametime.py
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Is the clock factor universal, or does it apply only to linear motion?
|
||||
|
||||
The mission clock runs ~1.26x wall time, which fully explains the linear
|
||||
measurements. Applied to the angular ones it predicts a wall-time pitch rate of
|
||||
~94 deg/s for `AV_PitchMinus_Min` 75 — but the settled measurement read 74.9.
|
||||
Either that was luck inside a noisy sample or angular integration is frame-based.
|
||||
|
||||
The flaw in comparing across runs is that the emulator's speed depends on scene
|
||||
load, so the clock ratio is not a constant of the machine. This measures BOTH in
|
||||
the same run: a HUD screenshot brackets each turn phase, so the mission clock's
|
||||
own advance over that phase converts wall seconds to game seconds.
|
||||
|
||||
Usage: pitch_gametime.py <out.csv> [dwell_s] (then read the HUD TIME off the shots)
|
||||
"""
|
||||
import json, math, os, struct, subprocess, sys, time
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import speed_law
|
||||
|
||||
def pad(*a):
|
||||
subprocess.run(["vgamepad", *a], capture_output=True)
|
||||
|
||||
def shot(n):
|
||||
subprocess.run(["screenshot", f"/sylph-home/re/shots/{n}.png"], capture_output=True)
|
||||
|
||||
def row_at(fd, off, cfg, row):
|
||||
at = off + cfg["rot_delta"] + row * cfg["rot_stride"]
|
||||
v = struct.unpack(">3f", os.pread(fd, 12, at))
|
||||
n = math.sqrt(sum(c * c for c in v)) or 1.0
|
||||
return tuple(c / n for c in v)
|
||||
|
||||
def ang(a, b):
|
||||
return math.degrees(math.acos(max(-1.0, min(1.0, sum(a[i] * b[i] for i in range(3))))))
|
||||
|
||||
def main():
|
||||
out_csv = sys.argv[1]
|
||||
dwell = float(sys.argv[2]) if len(sys.argv) > 2 else 8.0
|
||||
cfg = json.load(open("/tmp/nav-live.json"))
|
||||
w, off, nm = speed_law.find_player()
|
||||
if not w:
|
||||
sys.exit("player entity not found after retries")
|
||||
print(f"# locked on {nm}")
|
||||
fwd = cfg.get("fwd_row", 0)
|
||||
rows = []
|
||||
for label, (trig, tv) in (("slow", ("LT", 1.0)), ("fast", ("RT", 1.0))):
|
||||
pad("trig", "RT", "0.0"); pad("trig", "LT", "0.0"); pad("axis", "LY", "0.0")
|
||||
pad("trig", trig, str(tv))
|
||||
time.sleep(5.0)
|
||||
shot(f"gt_{label}_a") # HUD TIME at the start of the phase
|
||||
t0 = time.time()
|
||||
pad("axis", "LY", "1.0") # nose down = PitchMinus
|
||||
seq = []
|
||||
while time.time() - t0 < dwell:
|
||||
seq.append((round(time.time() - t0, 3), *row_at(w.fd, off, cfg, fwd)))
|
||||
time.sleep(0.05)
|
||||
pad("axis", "LY", "0.0")
|
||||
wall = seq[-1][0]
|
||||
shot(f"gt_{label}_b") # HUD TIME at the end
|
||||
rows += [(label, *s) for s in seq]
|
||||
total = sum(ang(seq[i][1:], seq[i + 1][1:]) for i in range(len(seq) - 1))
|
||||
print(f"# {label:<5} wall {wall:5.2f}s swept {total:7.1f}deg "
|
||||
f"rate {total / wall:6.1f} deg/wall-s "
|
||||
f"(x1.26 -> {total / wall * 1.26:6.1f} deg/game-s if the clock applies)")
|
||||
pad("trig", "RT", "0.0"); pad("trig", "LT", "0.0"); pad("reset")
|
||||
with open(out_csv, "w") as f:
|
||||
f.write("phase,t,fx,fy,fz\n")
|
||||
for r in rows:
|
||||
f.write(",".join(str(x) for x in r) + "\n")
|
||||
print("# read HUD TIME off gt_slow_a/b and gt_fast_a/b to get the run's own ratio")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user