From 5f3c618b51eb255d48675dbc44d0cff23e53cd17 Mon Sep 17 00:00:00 2001 From: "Claude (auto-RE)" Date: Thu, 13 Aug 2026 22:10:23 +0000 Subject: [PATCH] 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. --- docs/re/captures/axis-probe-stage02.csv | 7 ++ tools/re-capture/axis_probe.py | 131 ++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 docs/re/captures/axis-probe-stage02.csv create mode 100755 tools/re-capture/axis_probe.py diff --git a/docs/re/captures/axis-probe-stage02.csv b/docs/re/captures/axis-probe-stage02.csv new file mode 100644 index 0000000..2ca597a --- /dev/null +++ b/docs/re/captures/axis-probe-stage02.csv @@ -0,0 +1,7 @@ +input,roll_deg_s,yaw_deg_s,pitch_deg_s +lx+,209.8,0.87,10.07 +ly+,0.0,154.12,0.0 +rx+,0.0,0.0,0.0 +ry+,161.07,86.98,37.06 +LB,0.0,0.0,0.0 +RB,0.0,0.0,0.0 diff --git a/tools/re-capture/axis_probe.py b/tools/re-capture/axis_probe.py new file mode 100755 index 0000000..a072723 --- /dev/null +++ b/tools/re-capture/axis_probe.py @@ -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 [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())