From 92d9683f3a1ba7db176af726d556df2244ecdcdf Mon Sep 17 00:00:00 2001 From: "Claude (auto-RE)" Date: Thu, 13 Aug 2026 23:29:23 +0000 Subject: [PATCH] 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. --- .../captures/pitch-burst-settled-speeds.csv | 7 ++ docs/re/flight-speed-law.md | 38 ++++++ tools/re-capture/burst_probe.py | 113 ++++++++++++++++++ tools/re-capture/fly_stage.sh | 11 +- 4 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 docs/re/captures/pitch-burst-settled-speeds.csv create mode 100755 tools/re-capture/burst_probe.py diff --git a/docs/re/captures/pitch-burst-settled-speeds.csv b/docs/re/captures/pitch-burst-settled-speeds.csv new file mode 100644 index 0000000..5e26c19 --- /dev/null +++ b/docs/re/captures/pitch-burst-settled-speeds.csv @@ -0,0 +1,7 @@ +throttle,rep,settled_speed,burst_speed,rate_deg_per_wall_s +min,0,121.3,102.6,113.64 +min,1,152.0,108.8,109.46 +cruise,0,428.5,374.7,100.19 +cruise,1,361.6,391.4,88.39 +max,0,1564.4,1352.0,52.22 +max,1,1249.3,1614.6,70.52 diff --git a/docs/re/flight-speed-law.md b/docs/re/flight-speed-law.md index 1fb2a53..9a280f6 100644 --- a/docs/re/flight-speed-law.md +++ b/docs/re/flight-speed-law.md @@ -620,3 +620,41 @@ inside a 1 s burst, so each burst yields one honest `(speed, rate)` point. Repea `LT` / neutral / `RT` for three clean points at known speeds, instead of one smeared sweep. Data: [`captures/pitch-rate-curve-clean.csv`](captures/pitch-rate-curve-clean.csv). + + +## ✅ Short bursts at settled speeds: the law's SHAPE is confirmed, clock-free + +The design the sweep could not provide: settle the throttle, measure the settled +speed, then pitch for **one second** so the speed barely moves inside the burst. +Three throttle settings, two repeats each, row pin **CONFIDENT**, fresh flight. + +| throttle | burst speed (wall) | rate (°/wall-s) | +|---|---|---| +| `LT` min | ~105 | 113.6, 109.5 | +| none, cruise | ~383 | 100.2, 88.4 | +| `RT` max | ~1 483 | 52.2, 70.5 | + +**Rate falls monotonically with speed** — 111.5 → 94.3 → 61.4 — measured at three +*known, settled* speeds rather than smeared across a bleeding one. + +**And the decisive comparison needs no clock at all.** 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 +``` + +So two things follow, neither resting on a clock measurement: + +1. **`_Min` / `_Max` really do mean "at minimum / at maximum speed"**, with the rate + interpolating between them — the shape is confirmed to 3 %. +2. **`ly+` drives pitch-MINUS**, not pitch-plus. The craft has asymmetric pitch + authority (75/40 down versus 150/70 up), and the ratio picks the pair cleanly. + +🟡 **Absolute magnitudes are still open** — this run did not bracket the HUD clock, so +deg/*game*-second cannot be computed from it, and choosing a ratio that makes the +numbers fit would be circular. The probe now takes clock screenshots at both ends so +the next run closes it. Data: +[`captures/pitch-burst-settled-speeds.csv`](captures/pitch-burst-settled-speeds.csv). diff --git a/tools/re-capture/burst_probe.py b/tools/re-capture/burst_probe.py new file mode 100755 index 0000000..deb50ed --- /dev/null +++ b/tools/re-capture/burst_probe.py @@ -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 [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()) diff --git a/tools/re-capture/fly_stage.sh b/tools/re-capture/fly_stage.sh index c39f110..925dd7f 100755 --- a/tools/re-capture/fly_stage.sh +++ b/tools/re-capture/fly_stage.sh @@ -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