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:
7
docs/re/captures/axis-probe-stage02.csv
Normal file
7
docs/re/captures/axis-probe-stage02.csv
Normal file
@@ -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
|
||||
|
131
tools/re-capture/axis_probe.py
Executable file
131
tools/re-capture/axis_probe.py
Executable 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())
|
||||
Reference in New Issue
Block a user