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())