Files
Syplheed-Reborn/tools/re-capture/speed_law.py
Claude (auto-RE) 0cb81b6200 re(flight): the linear discrepancy is a world-unit vs displayed-speed difference
Screenshotting the HUD speed readout at each throttle step, beside the
position-derived measurement of the same moment:

  RT 0.00   HUD 350 (= CruisingVelocity)   position ~447   ratio 1.28
  RT 0.25   HUD 507                        position ~652   ratio 1.29
  RT 0.75   HUD 963                        position ~1141  ratio 1.19

So (a) the HUD speaks the definition's units — exactly CruisingVelocity at neutral,
963 at three-quarters against the 987 the interpolation predicts — confirming the
throttle law in the game's own numbers without any position sampling; and (b) world
displacement runs ~1.2x the displayed speed. Since settled angular rates need no such
factor, this is a unit difference between the position triple and the velocity
fields, not a clock effect: a reimplementation moving entities at MaximumVelocity in
world coordinates will be ~20% slow.

Also fixes speed_law.find_player: a mission holds more than one *_Player object and
at least one never moves, so the finder now samples each candidate twice and keeps
the one that displaces. Locking onto the static one is what produced a run of exact
zeros while the game was visibly flying.

🟡 The ratio is 1.19-1.29 rather than a clean constant and every sample was taken in
a firefight; pinning it wants a quiet map.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 17:06:35 +00:00

103 lines
4.3 KiB
Python

#!/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):
"""Lock onto the player object that is actually FLYING.
A mission holds more than one `*_Player` object — `entities2 list` shows two —
and at least one of them never moves. Taking the first match locks onto that
one, and then every probe reads a speed of exactly 0 while the game is visibly
flying (and the HUD keeps reading 350). So sample each candidate twice and keep
the one that displaces."""
for _ in range(retries):
w = gworld.World()
defs = entities2.definitions(w)
movers = entities2.moving(w.fd, w.size)
cands = [(off, nm) for off, nm, _, _ in entities2.typed(w.fd, defs, movers, 0x130)
if nm.endswith("_Player")]
best = None
for off, nm in cands:
a = pos_at(w.fd, off)
time.sleep(0.3)
b = pos_at(w.fd, off)
d = sum((b[i] - a[i]) ** 2 for i in range(3)) ** 0.5
if best is None or d > best[0]:
best = (d, off, nm)
if best and best[0] > 0.5:
return w, best[1], best[2]
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()