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