re(flight): axis probe -- lx is roll, ly drives one clean axis, rest not trustworthy

New probe (axis_probe.py) decomposes every held input into all THREE rotation
components at once, instead of measuring one axis at a time through a non-forward
matrix row -- the flaw that once made roll and pitch produce identical numbers.
For previous rows (f,u,w): roll = atan2(u.w_old, u.u_old), and forward's rotation
toward each of the other two rows gives the remaining pair.

Stage 02, file pad, full deflection on exactly one channel at a time:

    lx+   roll 209.8   b 0.9    c 10.1     deg/wall-s
    ly+   roll   0.0   b 154.1  c  0.0
    rx+   roll   0.0   b 0.0    c  0.0
    ry+   roll 161.1   b 87.0   c 37.1
    LB/RB all zero

What this supports: lx = ROLL, cleanly (~0 on both other channels), agreeing with
the independent roll measurement. ly drives ONE axis, cleanly.

What it does NOT support, and I am not claiming:
 - WHICH axis ly drives. entities2 measures row 2 = forward against velocity, but
   rows 0 and 1 are labelled up/right by the D3D convention rather than by
   evidence, and yaw/pitch SWAP if that is wrong. Roll is immune (rotation of
   either non-forward row in their shared plane is roll either way).
 - anything about rx/ry/LB/RB. The run ended on GAME OVER: full-deflection spin in
   a live combat mission gets the craft destroyed, and the only symptom is "0
   player candidates" AFTERWARDS, so late rows may be post-death. rx+ reading all
   zeros and ry+ reading mixed are exactly what a dying craft would produce.

So "yaw: no input found" is NOT resolved. Both gaps are now written into the
probe's header with what would fix them: a liveness check between inputs, and
pinning up-vs-right against world Y.
This commit is contained in:
2026-08-13 22:10:23 +00:00
parent 850b04c606
commit 5f3c618b51
2 changed files with 138 additions and 0 deletions

131
tools/re-capture/axis_probe.py Executable file
View File

@@ -0,0 +1,131 @@
#!/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.
CANDIDATES = [
("lx+", {"lx": 32767}),
("ly+", {"ly": 32767}),
("rx+", {"rx": 32767}),
("ry+", {"ry": 32767}),
("LB", {"press": "LB"}),
("RB", {"press": "RB"}),
]
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 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"]
u_i, w_i = [r for r in (0, 1, 2) if r != f_i]
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
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())