Files
Syplheed-Reborn/tools/re-capture/axis_probe.py
Claude (auto-RE) a3f14710a4 re(flight): clean pitch sweep -- magnitudes agree, the interpolation law does not
Fresh flight, row pinning CONFIDENT (margin 0.413), one sweep and nothing before
it. axis_probe now REFUSES to measure on a WEAK pin (ALLOW_WEAK_PIN=1 overrides)
since it is a precondition, not a warning: roll is immune to the up/right
labelling but pitch and yaw are not.

Clock x1.26. Binned by speed, both in game units, against the linear
interpolation of AV_PitchPlus_Min 150 (at MinimumVelocity 100) to _Max 70 (at
MaximumVelocity 1200):

    speed ~435   measured 100.8   predicted 125.6
    speed ~572            113.8             115.7
    speed ~709            126.3             105.7
    speed ~846             83.1              95.7
    speed ~983             72.7              85.8

Supported: the magnitudes (73-126 measured vs 86-126 predicted) and a falling
high-speed end. NOT supported: the interpolation law. Scatter is +-25%, the two
fastest bins hold 1 and 2 windows (the first moments before the speed bled), and
the slowest bin misses in the wrong direction.

The flaw is structural, not statistical: a sweep DRIVEN by the speed bleeding
cannot dwell at either extreme, which is exactly where the law is most testable.

What would settle it: hold a settled throttle and pitch for ~1 SECOND, so speed
barely moves inside the burst and each burst gives one honest (speed, rate) point;
repeat at LT / neutral / RT for three clean points at known speeds. Recorded as
the next design rather than attempted as a fifth variation of the same sweep.
2026-08-13 23:12:50 +00:00

187 lines
7.5 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})")
margin = abs(ys[up_i]) - abs(ys[right_i])
ok = margin > 0.3
print(f"# -> up = row {up_i}, right = row {right_i} margin {margin:.3f} "
f"{'CONFIDENT' if ok else 'WEAK — craft is not level'}")
if not ok and os.environ.get("ALLOW_WEAK_PIN") != "1":
sys.exit("refusing to measure with a WEAK row pin — fly level first, or set "
"ALLOW_WEAK_PIN=1 if the caller genuinely does not care "
"(roll is immune to the labelling; pitch and yaw are not)")
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())