re(flight): short bursts confirm the rate-vs-speed SHAPE, without needing the clock

The design the sweep could not provide: settle the throttle, measure the settled
speed, pitch for ONE second so speed barely moves inside the burst. Three
throttles, two repeats, row pin CONFIDENT, fresh flight.

    LT min      burst speed ~105    rate 113.6, 109.5 deg/wall-s
    cruise                  ~383         100.2,  88.4
    RT max                 ~1483          52.2,  70.5

Rate falls monotonically with speed -- 111.5 -> 94.3 -> 61.4 -- at three KNOWN,
SETTLED speeds instead of smeared across a bleeding one.

The decisive comparison needs no clock. Absolute rates depend on the run's clock
ratio, but the min:max RATIO cancels it:

    measured min:max            = 1.82
    AV_PitchMinus_Min/Max 75/40 = 1.88   ->  3.0% apart
    AV_PitchPlus_Min/Max 150/70 = 2.14   -> 15.1% apart

Two conclusions, neither resting on a clock measurement:
 - _Min/_Max really do mean "at minimum / at maximum speed", with the rate
   interpolating between them: shape confirmed to 3%.
 - ly+ drives pitch-MINUS, not plus. The craft has asymmetric pitch authority
   (75/40 down vs 150/70 up) and the ratio picks the pair cleanly.

Absolute magnitudes remain open: this run did not bracket the HUD clock, so
deg/GAME-second cannot be computed from it, and picking a ratio that makes the
numbers fit would be circular. The probe now screenshots the clock at both ends.

Also: fly_stage.sh now waits for the TAKE-OFF load too. Guarding only the stage
load left a run pressing A into a black screen and then reporting "player entity
not found" from a game that never reached flight.
This commit is contained in:
2026-08-13 23:29:23 +00:00
parent a3f14710a4
commit 92d9683f3a
4 changed files with 168 additions and 1 deletions

113
tools/re-capture/burst_probe.py Executable file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Angular rate at a KNOWN, SETTLED speed — short bursts instead of one sweep.
Why not a sweep: turning bleeds speed hard (1 193 -> 589 in 8 s at full throttle),
so a long hold averages a rate over a moving speed and lands between two different
caps. A sweep *driven* by that bleed also cannot dwell at either extreme, which is
exactly where a speed-dependent law is most testable.
So: settle the throttle, measure the settled speed, then pitch for only ~1 second.
The speed barely moves inside a burst, so each burst is one honest `(speed, rate)`
point. Three throttle settings give three clean points at known speeds.
Row pinning is done ONCE while the craft is still level — the pin decides which
matrix ROW is up and which is right, which is a property of the layout, not of the
current attitude, so it stays valid after the craft has been thrown around.
Accumulation is windowed over the whole burst (never per-read): polling is faster
than the guest updates these fields, so a per-read delta is either zero or a whole
frame divided by a fraction of one — that aliasing once manufactured an entire
rate-vs-speed curve.
Usage: burst_probe.py <roll|pitch> <out.csv> [burst_s] [repeats]
"""
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
from axis_probe import alive, pad_state, pin_rows, rows_at, dot # noqa: E402
DRIVER = {"roll": {"lx": 32767}, "pitch": {"ly": 32767}}
THROTTLES = [("min", {"lt": 255}), ("cruise", {}), ("max", {"rt": 255})]
def pos_at(fd, off):
return struct.unpack(">3f", os.pread(fd, 12, off))
def measure(w, off, cfg, axis, u_i, w_i, f_i, hold, secs):
"""Accumulate path length and swept angle over one interval."""
prev_r, prev_p = rows_at(w.fd, off, cfg), pos_at(w.fd, off)
t0 = time.time()
path = swept = 0.0
while time.time() - t0 < secs:
time.sleep(0.02)
cur_r, cur_p = rows_at(w.fd, off, cfg), pos_at(w.fd, off)
path += math.dist(cur_p, prev_p)
if axis == "roll":
d = math.atan2(dot(cur_r[u_i], prev_r[w_i]), dot(cur_r[u_i], prev_r[u_i]))
else:
d = math.atan2(dot(cur_r[f_i], prev_r[u_i]), dot(cur_r[f_i], prev_r[f_i]))
swept += abs(math.degrees(d))
prev_r, prev_p = cur_r, cur_p
span = time.time() - t0
return path / span, swept / span
def main():
axis, out_csv = sys.argv[1], sys.argv[2]
burst = float(sys.argv[3]) if len(sys.argv) > 3 else 1.0
reps = int(sys.argv[4]) if len(sys.argv) > 4 else 3
cfg = json.load(open("/tmp/nav-live.json"))
w, off, nm = speed_law.find_player()
if not w:
sys.exit("player entity not found")
f_i = cfg["fwd_row"]
if not alive(w, off, cfg):
sys.exit("craft is not moving")
u_i, w_i = pin_rows(w, off, cfg) # once, while level
print(f"# locked on {nm}, {axis} in {burst}s bursts x{reps}")
# ⚠️ Gap in the first run of this probe: it did NOT bracket the HUD clock, so
# the absolute deg/GAME-second could not be computed and only the (clock-free)
# min:max ratio was usable. Screenshot the HUD at the start and end.
import subprocess
subprocess.run(["screenshot", "/sylph-home/re/shots/burst_clock_a.png"],
capture_output=True)
rows = []
for tname, tstate in THROTTLES:
for rep in range(reps):
pad_state(**tstate)
time.sleep(6.0) # settle the throttle, wings level-ish
settled, _ = measure(w, off, cfg, axis, u_i, w_i, f_i, None, 1.0)
pad_state(**tstate, **DRIVER[axis])
bspeed, rate = measure(w, off, cfg, axis, u_i, w_i, f_i, None, burst)
pad_state(**tstate)
rows.append((tname, rep, round(settled, 1), round(bspeed, 1), round(rate, 2)))
print(f"# {tname:<6} rep{rep} settled {settled:7.1f} "
f"during {bspeed:7.1f} rate {rate:6.1f} deg/wall-s")
if not alive(w, off, cfg):
print("# CRAFT STOPPED MOVING — aborting")
pad_state()
break
else:
continue
break
pad_state()
subprocess.run(["screenshot", "/sylph-home/re/shots/burst_clock_b.png"],
capture_output=True)
with open(out_csv, "w") as f:
f.write("throttle,rep,settled_speed,burst_speed,rate_deg_per_wall_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())

View File

@@ -112,9 +112,18 @@ shot 06-readyroom
say "READY ROOM -> TAKE OFF (one up from the pre-highlighted BRIEFINGS)"
pad dpad up 0.06; sleep 1; shot 07-takeoff-hl
pad tap A 0.20; sleep 8
# TAKE OFF starts its own load, and it hangs black just as the stage load can --
# guarding only the first transition left a run pressing A into a black screen and
# then reporting "player entity not found" from a game that never reached flight.
say "waiting for the take-off load"
if ! wait_not_black 180; then
say "STILL BLACK after take-off — load hung, aborting"
shot 08-stuck-black
exit 2
fi
for _ in $(seq 1 6); do pad tap A 0.20; sleep 4; done
say "waiting for flight to settle"
sleep 30
sleep 25
shot 08-flight
SHM=$(ls /dev/shm/xenia_memory_* 2>/dev/null | head -1)
if [ -n "$SHM" ]; then