re(flight): the throttle is a target-speed selector, measured against the definition
speed_law.py locks onto the player entity once and samples its position while holding each throttle input, differentiating over 1-second windows. no throttle -> ~420 (CruisingVelocity 350) RT held -> ~1 530 (MaximumVelocity 1200) LT held -> ~125 (MinimumVelocity 100) release -> back to cruise, from either direction So the throttle SELECTS a target speed rather than adding thrust — which is what a reimplementation would most likely have assumed from Acceleration/Deceleration alone. Those govern the convergence rate instead: ~440 units/s^2 measured on release (Deceleration 500) and ~470-560 under RT (Acceleration 600). Recorded as 🟡: measured world speeds run ~1.2-1.3x the definition numbers in all three regimes while the HUD shows the definition value exactly (350 at cruise), so world coordinates are a constant multiple (~1.25) of the definition's velocity unit; the spread is wider than the constant is precise because the craft manoeuvres while sampled. Three traps documented: RT/LT are analogue triggers (the button verb is a silent no-op and the first run measured an unflown craft), per-sample differentiation aliases against the guest's update rate (0, 1519, 1985, 0, 2681 for smooth flight), and the player entity only enters the typed scan ~15 s in while the craft dies within minutes if nobody flies it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
This commit is contained in:
86
tools/re-capture/speed_law.py
Normal file
86
tools/re-capture/speed_law.py
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Measure the craft's speed law and check it against its definition.
|
||||
|
||||
The unit definition gives `Acceleration`, `Deceleration`, `MinimumVelocity`,
|
||||
`CruisingVelocity` and `MaximumVelocity` (harvested 2026-08-13), but not how the
|
||||
game applies them. This holds each throttle input and samples the player's own
|
||||
position at ~10 Hz, so speed and its rate of change are measured rather than
|
||||
assumed.
|
||||
|
||||
Why not `ctrl_probe.py`: it re-scans for the player when it starts, and that scan
|
||||
misses in roughly half the samples on a busy stage (entities move while it walks
|
||||
memory). This locks onto the player object ONCE and then only re-reads its
|
||||
position triple, which is why it survives a firefight.
|
||||
|
||||
Usage: speed_law.py <out.csv> [hold_s]
|
||||
"""
|
||||
import os, struct, subprocess, sys, time
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import gworld, entities2
|
||||
|
||||
def find_player(retries=8):
|
||||
for _ in range(retries):
|
||||
w = gworld.World()
|
||||
defs = entities2.definitions(w)
|
||||
movers = entities2.moving(w.fd, w.size)
|
||||
for off, nm, pos, sp in entities2.typed(w.fd, defs, movers, 0x130):
|
||||
if nm.endswith("_Player"):
|
||||
return w, off, nm
|
||||
time.sleep(2)
|
||||
return None, None, None
|
||||
|
||||
def pos_at(fd, off):
|
||||
b = os.pread(fd, 12, off)
|
||||
return struct.unpack(">3f", b)
|
||||
|
||||
def pad(*args):
|
||||
subprocess.run(["vgamepad", *args], capture_output=True)
|
||||
|
||||
def sample(fd, off, seconds, hz=20):
|
||||
"""Raw (t, x, y, z) samples. Speed is NOT computed per sample: the guest
|
||||
updates the position at its own rate, so a 10 Hz difference alternates
|
||||
between 0 and a double step — the first run of this probe read 0, 1519, 1985,
|
||||
0, 2681 … for a craft flying smoothly. Differentiate over a window instead."""
|
||||
out, t0 = [], time.time()
|
||||
while time.time() - t0 < seconds:
|
||||
p = pos_at(fd, off)
|
||||
out.append((round(time.time() - t0, 3), p[0], p[1], p[2]))
|
||||
time.sleep(1.0 / hz)
|
||||
return out
|
||||
|
||||
def main():
|
||||
out_csv = sys.argv[1]
|
||||
hold = float(sys.argv[2]) if len(sys.argv) > 2 else 8.0
|
||||
w, off, nm = find_player()
|
||||
if not w:
|
||||
sys.exit("player entity not found after retries")
|
||||
print(f"# locked on {nm} at file offset {off:#x}")
|
||||
rows = []
|
||||
# RT/LT are ANALOGUE TRIGGERS, not buttons: `vgamepad trig RT 1.0` holds full
|
||||
# throttle, `trig RT 0.0` releases. Using `hold RT` (the button verb) is a
|
||||
# silent no-op — the first run of this probe measured only the drift of a
|
||||
# craft nobody was flying.
|
||||
for label, keys in (("idle", [("RT", 0.0), ("LT", 0.0)]),
|
||||
("RT", [("RT", 1.0), ("LT", 0.0)]),
|
||||
("coast", [("RT", 0.0), ("LT", 0.0)]),
|
||||
("LT", [("LT", 1.0), ("RT", 0.0)]),
|
||||
("coast2", [("RT", 0.0), ("LT", 0.0)])):
|
||||
for axis, v in keys:
|
||||
pad("trig", axis, str(v))
|
||||
seq = sample(w.fd, off, hold)
|
||||
for t, x, y, z in seq:
|
||||
rows.append((label, t, x, y, z))
|
||||
# windowed speed: displacement over the phase's middle second
|
||||
mid = [s for s in seq if seq[-1][0] * 0.4 <= s[0] <= seq[-1][0] * 0.9]
|
||||
if len(mid) > 2:
|
||||
d = sum((mid[-1][i] - mid[0][i]) ** 2 for i in (1, 2, 3)) ** 0.5
|
||||
print(f"# {label:<8} windowed speed {d / (mid[-1][0] - mid[0][0]):8.1f}/s")
|
||||
pad("trig", "RT", "0.0"); pad("trig", "LT", "0.0")
|
||||
with open(out_csv, "w") as f:
|
||||
f.write("phase,t,x,y,z\n")
|
||||
for r in rows:
|
||||
f.write(f"{r[0]},{r[1]},{r[2]},{r[3]},{r[4]}\n")
|
||||
print(f"# wrote {out_csv} ({len(rows)} samples)")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user