Files
Syplheed-Reborn/tools/re-capture/axis_probe.py
Claude (auto-RE) dfa769420d re(flight): axis probe pins the rows and checks liveness; fly_stage waits, not sleeps
Both fixes the previous run's caveats asked for, plus one the run itself forced.

axis_probe.py now:
 - PINS which non-forward row is up and which is right, by comparing world-Y
   across the rows in level flight, and says CONFIDENT or WEAK. entities2
   measures row 2 = forward against velocity, but the other two were labelled by
   the D3D convention, and yaw/pitch SWAP if that is wrong -- so the previous
   run's last two columns were named on an assumption.
 - checks the craft is ALIVE between inputs, and ABORTS with a message instead of
   reporting the clean zeros a destroyed craft produces. The first run ended on
   GAME OVER and only said so afterwards.
 - measures the UNKNOWN inputs (rx, ry, LB, RB) first while the craft is healthy,
   keeping the established lx/ly as controls at the end.

fly_stage.sh now WAITS for the stage load instead of sleeping a fixed guess. The
fixed sleeps worked until they didn't: one load ran long, the script pressed START
into a black screen, and every later step went to nothing while the screenshots
recorded a plausible-looking sequence. It now polls for a non-black frame and
aborts with a pointer to the log if the load hangs (PhysicalHeap::Release
failures) rather than continuing blind.

The probe itself did not run this iteration -- the stage load hung -- so there is
no new axis data, and none is claimed.
2026-08-13 22:22:19 +00:00

181 lines
7.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""Which pad input drives which rotation axis — all three measured at once.
The control mapping was left with **yaw ❔ "no input found"**: `AV_Yaw_*` exists in
the definitions (45/25) but no stick appeared to yaw. That conclusion came from
probes that measured one axis at a time using a non-forward matrix row, which is
exactly the flaw that made roll and pitch produce identical numbers — a row like
that moves under *any* rotation, so "this input yaws" and "this input pitches"
cannot be told apart by it.
So decompose properly. For each small step, with the previous frame's orthonormal
rows `(f, u, w)` = forward, up, right:
roll = atan2(u_new . w_old, u_new . u_old) rotation of UP about forward
yaw = atan2(f_new . w_old, f_new . f_old) rotation of FORWARD about up
pitch = atan2(f_new . u_old, f_new . f_old) rotation of FORWARD about right
Each drops the components the other two produce, so one held input yields three
numbers and the axis it actually drives is whichever is large.
Every candidate is held with the file pad, which writes the WHOLE state at once —
so `rx` really means right-stick-X with every other channel exactly zero, which a
quantising virtual stick could not promise.
⚠️ TWO THINGS THIS PROBE DOES NOT YET HANDLE, both learned the hard way:
1. **The craft can DIE mid-run.** Holding a stick at full deflection spins the ship
at 150-200 deg/s in a live combat mission; two minutes of that and the first run
ended on GAME OVER. Inputs measured after that point are meaningless, and the
symptom is only "0 player candidates, best displacement 0.000" *afterwards* --
the earlier rows still look fine. Check the player is alive BETWEEN inputs, or
probe somewhere nothing shoots back.
2. **Which row is UP and which is RIGHT is not pinned.** `entities2` measures that
row 2 is forward (against velocity), but rows 0 and 1 are assigned by the D3D
convention, not by evidence. Roll is unaffected -- rotation of either non-forward
row within their shared plane is roll either way -- but YAW and PITCH swap if the
convention is wrong, so this probe's last two columns are named on an assumption.
Usage: axis_probe.py <out.csv> [dwell_s]
"""
import json
import math
import os
import struct
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import speed_law # noqa: E402
PAD_FILE = os.environ.get("XENIA_PAD_FILE", "/tmp/xenia_pad.txt")
# Candidates. Sticks at full deflection, shoulders as buttons. Triggers are the
# throttle (already settled) and are left out.
# Order matters: the craft can be shot down mid-run, so the UNKNOWN inputs go
# first while it is healthy and the already-established ones (lx = roll) go last
# as controls. A row measured after death looks like a clean zero.
CANDIDATES = [
("rx+", {"rx": 32767}),
("ry+", {"ry": 32767}),
("LB", {"press": "LB"}),
("RB", {"press": "RB"}),
("lx+", {"lx": 32767}),
("ly+", {"ly": 32767}),
]
def pad_state(**kw):
parts = [f"{k}={v}" for k, v in kw.items() if v is not None]
tmp = PAD_FILE + ".tmp"
with open(tmp, "w") as f:
f.write(" ".join(parts))
os.replace(tmp, PAD_FILE)
def rows_at(fd, off, cfg):
base = off + cfg["rot_delta"]
out = []
for r in range(3):
v = struct.unpack(">3f", os.pread(fd, 12, base + r * cfg["rot_stride"]))
n = math.sqrt(sum(c * c for c in v)) or 1.0
out.append(tuple(c / n for c in v))
return out
def dot(a, b):
return sum(a[i] * b[i] for i in range(3))
def alive(w, off, cfg, secs=1.0):
"""Is the craft still flying? A destroyed craft stops moving, and every
subsequent input then measures a clean, meaningless zero — the failure mode
that invalidated this probe's first run (it ended on GAME OVER and only said
so afterwards)."""
p0 = struct.unpack(">3f", os.pread(w.fd, 12, off))
time.sleep(secs)
p1 = struct.unpack(">3f", os.pread(w.fd, 12, off))
return math.dist(p0, p1) > 1.0
def pin_rows(w, off, cfg):
"""Which non-forward row is UP and which is RIGHT?
`entities2` measures row 2 = forward against the velocity vector, but the other
two are assigned by the D3D convention rather than evidence — and yaw and pitch
SWAP if that is wrong, so naming them without this test is a guess.
Discriminator: in level flight the craft's up-vector points along world +Y and
its right-vector lies near the horizontal plane. Sample at neutral and compare
|world-Y| across the rows."""
f_i = cfg["fwd_row"]
acc = [0.0, 0.0, 0.0]
n = 0
for _ in range(20):
time.sleep(0.05)
rs = rows_at(w.fd, off, cfg)
for r in range(3):
acc[r] += rs[r][1] # world Y component
n += 1
ys = [a / n for a in acc]
cand = [r for r in (0, 1, 2) if r != f_i]
up_i = max(cand, key=lambda r: abs(ys[r]))
right_i = [r for r in cand if r != up_i][0]
print(f"# row world-Y means: {[round(y,3) for y in ys]} (forward = row {f_i})")
print(f"# -> up = row {up_i}, right = row {right_i}"
f" {'CONFIDENT' if abs(ys[up_i]) - abs(ys[right_i]) > 0.3 else 'WEAK — craft may not be level'}")
return up_i, right_i
def main():
out_csv = sys.argv[1]
dwell = float(sys.argv[2]) if len(sys.argv) > 2 else 5.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")
print(f"# locked on {nm}")
f_i = cfg["fwd_row"]
if not alive(w, off, cfg):
sys.exit("craft is not moving at the start — not in flight, or already dead")
u_i, w_i = pin_rows(w, off, cfg)
rows = []
print(f"# {'input':<6} {'roll':>9} {'yaw':>9} {'pitch':>9} (deg over the dwell)")
for label, state in CANDIDATES:
pad_state() # neutral
time.sleep(2.0)
prev = rows_at(w.fd, off, cfg)
pad_state(**state)
t0 = time.time()
sw = [0.0, 0.0, 0.0]
while time.time() - t0 < dwell:
time.sleep(0.05)
cur = rows_at(w.fd, off, cfg)
roll = math.degrees(math.atan2(dot(cur[u_i], prev[w_i]), dot(cur[u_i], prev[u_i])))
yaw = math.degrees(math.atan2(dot(cur[f_i], prev[w_i]), dot(cur[f_i], prev[f_i])))
pitch = math.degrees(math.atan2(dot(cur[f_i], prev[u_i]), dot(cur[f_i], prev[f_i])))
for k, v in enumerate((roll, yaw, pitch)):
sw[k] += abs(v)
prev = cur
pad_state()
wall = time.time() - t0
if not alive(w, off, cfg):
print(f"# CRAFT STOPPED MOVING after {label} — everything from here is "
f"meaningless, aborting rather than reporting zeros")
break
rate = [v / wall for v in sw]
rows.append((label, *[round(v, 2) for v in rate]))
print(f"# {label:<6} {rate[0]:9.1f} {rate[1]:9.1f} {rate[2]:9.1f} deg/wall-s")
time.sleep(1.5)
with open(out_csv, "w") as f:
f.write("input,roll_deg_s,yaw_deg_s,pitch_deg_s\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())