re(flight): the rate probe measures a MOVING speed -- pitching bleeds it hard

Measured pitch with the rows properly pinned, against this craft's own disc caps
(AV_PitchPlus_Min 150, AV_PitchPlus_Max 70):

    min speed (LT)  1391.0 deg / 8.00 s, clock x1.326 -> 131.1 deg/game-s  vs 150
    max speed (RT)   989.0 deg / 8.05 s, clock x1.318 ->  93.2 deg/game-s  vs 70

A rate 33% ABOVE a cap is not a finding, it is a broken instrument. The HUD speed
is in the same bracketing screenshots that give the clock, so read it:

    slow phase   102 ->  18
    fast phase  1193 -> 589

The speed is NOT constant during the dwell -- pitching halves it in 8 seconds.
The cap is speed-dependent, so as the craft slowed its cap rose, and an 8-second
average necessarily lands between the max-speed cap and a mid-speed one. The 133%
is entirely the instrument.

This also weakens the roll result committed earlier: same method, so 120.9 vs
AV_Roll_Max 125 is CONSISTENT but is not a tight test -- the true cap could be
lower and still produce that average. Said plainly in the doc rather than left
standing as a clean confirmation. Min-speed figures are less affected; there is
little speed left to lose.

Proper fix, not yet done: dwell ~1-2 s so speed barely moves, or sample HUD speed
continuously and fit rate against INSTANTANEOUS speed -- which yields the whole
rate-vs-speed curve instead of two points.

Separately this is a flight-model finding: TURNING COSTS SPEED, steeply, with the
throttle still at maximum. A reimplementation treating the throttle as a speed the
craft simply holds will be wrong during manoeuvres.
This commit is contained in:
2026-08-13 22:48:29 +00:00
parent dc46339b63
commit 6c7025851e
3 changed files with 450 additions and 0 deletions

94
tools/re-capture/rate_probe.py Executable file
View File

@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Angular rate cap for one axis, at minimum and maximum speed.
Generalises `roll_axis.py`. Two things it does that the probes before it did not:
* **Pins the matrix rows.** `entities2` measures row 2 = forward against velocity,
but up-vs-right was previously taken from the D3D convention — and measuring
world-Y in flight showed the convention is *backwards* here (up = row 1, right =
row 0). Roll is immune to that (rotation of either non-forward row within their
shared plane is roll either way), but **pitch is not**, so a pitch number taken
without this pinning is naming an axis by assumption.
* **Brackets each phase with the HUD clock.** Rates are per GAME second and the
clock ratio is a property of the moment — 1.260, 1.311, 1.383 and 1.247/1.218
across five flights — so it cannot be assumed and must be read per phase.
roll driven by lx, measured as rotation of UP about forward
pitch driven by ly, measured as rotation of FORWARD toward up
Usage: rate_probe.py <roll|pitch> <out.csv> [dwell_s]
Then read HUD TIME off the `rate_<axis>_<phase>_[ab]` screenshots for the ratio.
"""
import json
import math
import os
import struct
import subprocess
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import speed_law # noqa: E402
from axis_probe import alive, pad_state, pin_rows, rows_at, dot # noqa: E402
DRIVER = {"roll": {"lx": 32767}, "pitch": {"ly": 32767}}
def shot(n):
subprocess.run(["screenshot", f"/sylph-home/re/shots/{n}.png"], capture_output=True)
def main():
axis = sys.argv[1]
out_csv = sys.argv[2]
dwell = float(sys.argv[3]) if len(sys.argv) > 3 else 8.0
if axis not in DRIVER:
sys.exit(f"axis must be one of {list(DRIVER)}")
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}, measuring {axis}")
f_i = cfg["fwd_row"]
if not alive(w, off, cfg):
sys.exit("craft is not moving — not in flight, or already dead")
u_i, w_i = pin_rows(w, off, cfg)
rows = []
for label, trig in (("slow", "lt"), ("fast", "rt")):
pad_state(**{trig: 255})
time.sleep(5.0) # settle: 2 s was shown wrong twice
shot(f"rate_{axis}_{label}_a")
prev = rows_at(w.fd, off, cfg)
pad_state(**{trig: 255}, **DRIVER[axis])
t0, swept, seq = time.time(), 0.0, []
while time.time() - t0 < dwell:
time.sleep(0.05)
cur = rows_at(w.fd, off, cfg)
if axis == "roll":
d = math.atan2(dot(cur[u_i], prev[w_i]), dot(cur[u_i], prev[u_i]))
else:
d = math.atan2(dot(cur[f_i], prev[u_i]), dot(cur[f_i], prev[f_i]))
d = math.degrees(d)
swept += abs(d)
seq.append((round(time.time() - t0, 3), round(d, 4)))
prev = cur
pad_state(**{trig: 255})
wall = seq[-1][0]
shot(f"rate_{axis}_{label}_b")
rows += [(label, *s) for s in seq]
print(f"# {label:<5} wall {wall:5.2f}s {axis} swept {swept:7.1f}deg "
f"rate {swept / wall:6.1f} deg/wall-s")
if not alive(w, off, cfg):
print("# CRAFT STOPPED MOVING — aborting rather than reporting zeros")
break
pad_state()
with open(out_csv, "w") as f:
f.write(f"phase,t,d{axis}_deg\n")
for r in rows:
f.write(",".join(str(x) for x in r) + "\n")
print(f"# wrote {out_csv} — read HUD TIME off rate_{axis}_*_[ab] for the clock")
if __name__ == "__main__":
raise SystemExit(main())