re: the autopilot now survives, and kills
Three measurements, then a pilot built on them. * Hull is position+0x154. Found by anchoring on a field the definition already had solved (HP = 1500) rather than scanning for a value that falls: an undamaged craft must contain its own definition's number. Confirmed by the trace across a death -- 30/60/90 per hit, negative at 0, GAME OVER on screen. * RT accelerates, LT brakes, and the throttle is a persistent setting (488 -> 1510 -> 174 units/s, measured as displacement per second of the craft's own position, so no speed field was needed). This overturns the earlier "RT is not the throttle", which came from assuming the control and hunting for a field. * Shield is probably position+0x430 (== definition MaxValue 400), not yet confirmed live -- nothing had damaged it. pilot.py is a state machine on damage (ENGAGE / EVADE / RETIRE) that treats turrets as keep-out zones instead of targets. It flew Stage 02 for 300 s with the hull untouched at 1500/1500 and took the first confirmed kill (WARPLANES 0001); every run the day before was dead inside 35 s. Two bugs the live run exposed and this fixes: gating the guns on the commanded direction keeps them cold whenever avoidance is steering (gate on the target instead), and an orbit-plus-brake rule made it circle one attacker for 40 s outside its own firing cone. Also: boot to in-flight is now ~100 s unattended, because wait_flight.sh waits for the HUD's own shield bar instead of a fixed 75 s sleep that lavapipe does not honour; entities2.py picks the attitude block by matching the measured flight path (taking the first orthonormal block gave a bone/camera frame); and the entity-heap scan is numpy instead of a per-word Python loop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
144
tools/re-capture/binq.py
Normal file
144
tools/re-capture/binq.py
Normal file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ask questions of a ctrl_probe.py capture: which words answer to which input?
|
||||
|
||||
The capture is a window of the player entity object sampled every tick, tagged
|
||||
with the pad state that produced it. That makes the interesting question
|
||||
mechanical: for each 4-byte offset, does its value during phase X differ from
|
||||
its value during the rest phases either side? A word that only moves while `RT`
|
||||
is held is that input's state — a throttle setting, an afterburner tank, a heat
|
||||
gauge — and one that moves in *every* phase is just live physics.
|
||||
|
||||
Sub-commands
|
||||
phases per-phase mean of every offset that moves at all
|
||||
respond <phase> offsets that move during <phase> and not at rest
|
||||
near <value> [tol] offsets whose first sample is ~= value (HUD anchor)
|
||||
trace <off> [off...] full time series of specific offsets (pos-relative hex)
|
||||
"""
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
HDR = b"SYLPHCTR"
|
||||
REC = struct.Struct("<d32sff3f")
|
||||
|
||||
|
||||
def load(path):
|
||||
with open(path, "rb") as f:
|
||||
blob = f.read()
|
||||
assert blob[:8] == HDR, "not a ctrl_probe capture"
|
||||
n, win, back = struct.unpack_from("<III", blob, 8)
|
||||
stride = REC.size + win
|
||||
base = 8 + 12
|
||||
ts, phase, sp, dodge, pos, wins = [], [], [], [], [], []
|
||||
for i in range(n):
|
||||
o = base + i * stride
|
||||
t, ph, s, dg, x, y, z = REC.unpack_from(blob, o)
|
||||
ts.append(t)
|
||||
phase.append(ph.split(b"\0")[0].decode())
|
||||
sp.append(s)
|
||||
dodge.append(dg)
|
||||
pos.append((x, y, z))
|
||||
wins.append(blob[o + REC.size:o + REC.size + win])
|
||||
# A run that ended in GAME OVER keeps sampling a dead object, and those
|
||||
# frames dominate every statistic. $BINQ_TMAX truncates the capture to the
|
||||
# part that was still flying.
|
||||
tmax = float(os.environ.get("BINQ_TMAX", "inf"))
|
||||
if tmax < float("inf"):
|
||||
keep = [i for i, t in enumerate(ts) if t <= tmax]
|
||||
n = len(keep)
|
||||
ts = [ts[i] for i in keep]
|
||||
phase = [phase[i] for i in keep]
|
||||
sp = [sp[i] for i in keep]
|
||||
dodge = [dodge[i] for i in keep]
|
||||
pos = [pos[i] for i in keep]
|
||||
wins = [wins[i] for i in keep]
|
||||
A = np.frombuffer(b"".join(wins), dtype=">f4").reshape(n, win // 4).astype(np.float64)
|
||||
U = np.frombuffer(b"".join(wins), dtype=">u4").reshape(n, win // 4)
|
||||
return dict(n=n, win=win, back=back, t=np.array(ts), phase=phase,
|
||||
speed=np.array(sp), dodge=np.array(dodge),
|
||||
pos=np.array(pos), F=A, U=U)
|
||||
|
||||
|
||||
def label(d, i):
|
||||
return f"pos{i * 4 - d['back']:+#07x}"
|
||||
|
||||
|
||||
def finite(d):
|
||||
F = d["F"]
|
||||
return np.all(np.isfinite(F), axis=0) & (np.max(np.abs(F), axis=0) < 1e12)
|
||||
|
||||
|
||||
def cmd_phases(d, args):
|
||||
ok = finite(d)
|
||||
phases = []
|
||||
for p in d["phase"]:
|
||||
if p not in phases:
|
||||
phases.append(p)
|
||||
F = d["F"]
|
||||
mv = np.zeros(F.shape[1])
|
||||
means = {}
|
||||
for p in phases:
|
||||
m = np.array([x == p for x in d["phase"]])
|
||||
means[p] = F[m].mean(axis=0)
|
||||
for p in phases:
|
||||
mv = np.maximum(mv, np.abs(means[p] - means[phases[0]]))
|
||||
idx = np.flatnonzero(ok & (mv > 1e-3))
|
||||
order = idx[np.argsort(-mv[idx])][:int(args[0]) if args else 25]
|
||||
print("offset " + "".join(f"{p[:8]:>10}" for p in phases))
|
||||
for i in order:
|
||||
print(f"{label(d, i):<10}" + "".join(f"{means[p][i]:10.2f}" for p in phases))
|
||||
|
||||
|
||||
def cmd_respond(d, args):
|
||||
want = args[0]
|
||||
F, ok = d["F"], finite(d)
|
||||
inp = np.array([p == want for p in d["phase"]])
|
||||
rest = np.array([p.startswith("rest") or p == "base" for p in d["phase"]])
|
||||
if not inp.any():
|
||||
sys.exit(f"no phase {want!r}")
|
||||
# A word answering to this input must move *while it is held* and be quiet
|
||||
# at rest; a word that also moves at rest is live physics, not the input.
|
||||
a = F[inp]
|
||||
r = F[rest]
|
||||
d_in = a.max(axis=0) - a.min(axis=0)
|
||||
d_rest = r.max(axis=0) - r.min(axis=0)
|
||||
score = d_in - 2.0 * d_rest
|
||||
idx = np.flatnonzero(ok & (d_in > 1e-3) & (score > 0))
|
||||
for i in idx[np.argsort(-score[idx])][:20]:
|
||||
print(f"{label(d, i):<10} in-phase {a[:, i].min():12.3f}..{a[:, i].max():12.3f}"
|
||||
f" at-rest {r[:, i].min():12.3f}..{r[:, i].max():12.3f}")
|
||||
if not len(idx):
|
||||
print("(nothing moves under this input that is quiet at rest)")
|
||||
|
||||
|
||||
def cmd_near(d, args):
|
||||
v = float(args[0])
|
||||
tol = float(args[1]) if len(args) > 1 else max(1e-3, abs(v) * 1e-3)
|
||||
F, U = d["F"], d["U"]
|
||||
for i in np.flatnonzero(np.abs(F[0] - v) <= tol):
|
||||
print(f"{label(d, i):<10} f32 {F[0, i]:.4f} -> {F[-1, i]:.4f}")
|
||||
for i in np.flatnonzero(np.abs(U[0].astype(np.float64) - v) <= tol):
|
||||
print(f"{label(d, i):<10} u32 {U[0, i]} -> {U[-1, i]}")
|
||||
|
||||
|
||||
def cmd_trace(d, args):
|
||||
offs = [int(a, 0) for a in args]
|
||||
idx = [(o + d["back"]) // 4 for o in offs]
|
||||
print("t phase speed " + " ".join(f"{o:+#07x}" for o in offs))
|
||||
for k in range(d["n"]):
|
||||
print(f"{d['t'][k]:6.2f} {d['phase'][k]:<12} {d['speed'][k]:6.0f} "
|
||||
+ " ".join(f"{d['F'][k, i]:8.2f}" for i in idx))
|
||||
|
||||
|
||||
def main():
|
||||
d = load(sys.argv[1])
|
||||
print(f"# {d['n']} ticks, window {d['win']:#x} bytes, back {d['back']:#x}")
|
||||
cmd = sys.argv[2] if len(sys.argv) > 2 else "phases"
|
||||
{"phases": cmd_phases, "respond": cmd_respond, "near": cmd_near,
|
||||
"trace": cmd_trace}[cmd](d, sys.argv[3:])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
272
tools/re-capture/ctrl_probe.py
Normal file
272
tools/re-capture/ctrl_probe.py
Normal file
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Which control is the throttle, and where does the craft keep its own state?
|
||||
|
||||
Two questions, one flight. Both are answered by *consequence* rather than by
|
||||
reading a field we hope is the right one:
|
||||
|
||||
* **Throttle.** The craft's speed is measured from its own position — a finite
|
||||
difference on the position triple in guest RAM — so no speed field has to be
|
||||
found first. The probe then holds each candidate input in turn and asks which
|
||||
one changes that measured speed. (`findspeed.py` failed the other way round:
|
||||
it assumed `RT` was the throttle and went looking for a field that rose.)
|
||||
|
||||
* **Own state.** Every tick also copies a window of the player entity object.
|
||||
Afterwards, offsets whose float value tracks the measured speed are candidate
|
||||
speed/throttle fields, and offsets that only ever *fall* are candidate
|
||||
hull/shield/ammo — the numbers survival needs.
|
||||
|
||||
Flying straight into a firefight for 90 s is how earlier runs died, so the loop
|
||||
keeps the collision avoidance from navigator.py armed the whole time and marks
|
||||
any sample where it had to intervene: a phase that had to dodge is not a clean
|
||||
speed measurement, and says so rather than being quietly averaged in.
|
||||
|
||||
Usage: ctrl_probe.py <config.json> <out-prefix> [hold_s] [settle_s]
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import gmem # noqa: E402
|
||||
import navigator # noqa: E402
|
||||
from flight_probe import Pad # noqa: E402
|
||||
|
||||
# 0x800 each way: the shield lives at pos+0x430 (own_state.py), which a
|
||||
# 0x400 window silently cropped out of the first capture.
|
||||
WIN_BACK = 0x800 # bytes of the player object kept before the position
|
||||
WIN_FWD = 0x800 # ...and after
|
||||
WIN = WIN_BACK + WIN_FWD
|
||||
HZ = 20.0
|
||||
|
||||
# (label, [pad commands]) — everything the pad can do that might be a throttle.
|
||||
# RB is left out: it is the fire button (autopilot-memory-driven.md) and firing
|
||||
# during a speed measurement only invites return fire.
|
||||
CANDIDATES = [
|
||||
("RT", [("trig", "RT", 1.0)]),
|
||||
("LT", [("trig", "LT", 1.0)]),
|
||||
("RT+LT", [("trig", "RT", 1.0), ("trig", "LT", 1.0)]),
|
||||
("A", [("press", "A")]),
|
||||
("B", [("press", "B")]),
|
||||
("X", [("press", "X")]),
|
||||
("Y", [("press", "Y")]),
|
||||
("LB", [("press", "LB")]),
|
||||
("LS", [("press", "LS")]),
|
||||
("RS", [("press", "RS")]),
|
||||
("RY_up", [("axis", "RY", -1.0)]),
|
||||
("RY_down", [("axis", "RY", 1.0)]),
|
||||
("RX_right", [("axis", "RX", 1.0)]),
|
||||
("dpad_up", [("dpad", "up")]),
|
||||
("dpad_down", [("dpad", "down")]),
|
||||
("dpad_left", [("dpad", "left")]),
|
||||
("dpad_right", [("dpad", "right")]),
|
||||
]
|
||||
|
||||
|
||||
def apply(pad, cmds):
|
||||
for c in cmds:
|
||||
if c[0] == "trig":
|
||||
pad.trig(c[1], c[2])
|
||||
elif c[0] == "axis":
|
||||
pad.axis(c[1], c[2])
|
||||
elif c[0] == "press":
|
||||
pad.press(c[1])
|
||||
elif c[0] == "dpad":
|
||||
pad.f.write(f"dpad {c[1]}\n")
|
||||
|
||||
|
||||
def clear(pad):
|
||||
pad.reset()
|
||||
pad.f.write("dpad center\n")
|
||||
|
||||
|
||||
class Run:
|
||||
def __init__(self, cfg, prefix, hold, settle):
|
||||
self.W = navigator.World(cfg)
|
||||
self.nav = navigator.Navigator(self.W, None, dry=True)
|
||||
self.prefix = prefix
|
||||
self.hold = hold
|
||||
self.settle = settle
|
||||
self.pad = Pad()
|
||||
self.rows = [] # (t, phase, pos, speed, dodged)
|
||||
self.win = [] # raw window bytes per tick
|
||||
ents = self.W.scan()
|
||||
me = [(off, va) for off, va in ents if "Player" in self.W.defs[va]]
|
||||
if not me:
|
||||
sys.exit("player entity not in the scan — not in flight?")
|
||||
self.me_off = me[0][0]
|
||||
self.me_name = self.W.defs[me[0][1]]
|
||||
print(f"# player {self.me_name} pos off {self.me_off:#x} "
|
||||
f"va {gmem.primary_va(self.me_off):#x} | {len(ents)} entities",
|
||||
flush=True)
|
||||
|
||||
# ------------------------------------------------------------- helpers
|
||||
def pos(self):
|
||||
return self.W.pos(self.me_off)
|
||||
|
||||
def sticks_for(self, vec):
|
||||
"""Stick deflections that point the nose along `vec` (escape steering)."""
|
||||
M = self.W.rot(self.me_off)
|
||||
if M is None:
|
||||
return 0.0, 0.0
|
||||
fwd = M[self.W.fwd_row] * self.W.fwd_sign
|
||||
right = M[(self.W.fwd_row + 1) % 3]
|
||||
up = np.cross(fwd, right)
|
||||
ez = float(np.dot(vec, fwd))
|
||||
yaw = math.atan2(float(np.dot(vec, right)), ez if abs(ez) > 1e-3 else 1e-3)
|
||||
pitch = math.atan2(float(np.dot(vec, up)), ez if abs(ez) > 1e-3 else 1e-3)
|
||||
return (max(-1.0, min(1.0, 2.0 * yaw)), max(-1.0, min(1.0, -2.0 * pitch)))
|
||||
|
||||
# ---------------------------------------------------------------- phase
|
||||
def phase(self, label, cmds, secs):
|
||||
clear(self.pad)
|
||||
apply(self.pad, cmds)
|
||||
t_end = time.time() + secs
|
||||
dodged = False
|
||||
while time.time() < t_end:
|
||||
t = time.time()
|
||||
p = self.pos()
|
||||
if p is None:
|
||||
break
|
||||
self.rows.append([t, label, p, 0.0, 0])
|
||||
self.win.append(os.pread(self.W.fd, WIN, self.me_off - WIN_BACK))
|
||||
|
||||
# avoidance runs at 5 Hz; a real threat overrides the phase and the
|
||||
# samples from here on are flagged
|
||||
if len(self.rows) % 4 == 0:
|
||||
ents = self.W.sample(t)
|
||||
me = [e for e in ents if e[0] == self.me_off]
|
||||
if me:
|
||||
_, _, mp, mv, mr = me[0]
|
||||
push, worst = self.nav.avoidance(mp, mv, mr, ents, self.me_off)
|
||||
if float(np.linalg.norm(push)) > 0.6:
|
||||
sx, sy = self.sticks_for(navigator.norm(push))
|
||||
self.pad.axis("LX", sx)
|
||||
self.pad.axis("LY", sy)
|
||||
dodged = True
|
||||
self.rows[-1][4] = 1
|
||||
elif dodged:
|
||||
self.pad.axis("LX", 0.0)
|
||||
self.pad.axis("LY", 0.0)
|
||||
time.sleep(max(0.0, 1.0 / HZ - (time.time() - t)))
|
||||
return dodged
|
||||
|
||||
def run(self):
|
||||
t0 = time.time()
|
||||
self.phase("base", [], self.settle * 2)
|
||||
for label, cmds in CANDIDATES:
|
||||
d = self.phase(label, cmds, self.hold)
|
||||
self.phase(f"rest_{label}", [], self.settle)
|
||||
sp = self.phase_speed(label)
|
||||
print(f"[{time.time()-t0:6.1f}] {label:<10} "
|
||||
f"v0={sp[0]:7.1f} v1={sp[1]:7.1f} d={sp[1]-sp[0]:+7.1f}"
|
||||
f"{' (dodged)' if d else ''}", flush=True)
|
||||
clear(self.pad)
|
||||
self.speeds()
|
||||
self.dump()
|
||||
|
||||
# ------------------------------------------------------------ analysis
|
||||
def speeds(self):
|
||||
"""Fill in per-tick speed by central difference on position."""
|
||||
for i, r in enumerate(self.rows):
|
||||
j, k = max(0, i - 2), min(len(self.rows) - 1, i + 2)
|
||||
dt = self.rows[k][0] - self.rows[j][0]
|
||||
if dt > 1e-3:
|
||||
r[3] = float(np.linalg.norm(self.rows[k][2] - self.rows[j][2])) / dt
|
||||
|
||||
def phase_speed(self, label):
|
||||
"""(speed early, speed late) within a phase — needs speeds() first."""
|
||||
self.speeds()
|
||||
v = [r[3] for r in self.rows if r[1] == label]
|
||||
if len(v) < 6:
|
||||
return (0.0, 0.0)
|
||||
n = max(2, len(v) // 4)
|
||||
return (float(np.mean(v[:n])), float(np.mean(v[-n:])))
|
||||
|
||||
def dump(self):
|
||||
with open(self.prefix + ".csv", "w") as f:
|
||||
f.write("t,phase,x,y,z,speed,dodged\n")
|
||||
t0 = self.rows[0][0]
|
||||
for t, ph, p, sp, dg in self.rows:
|
||||
f.write(f"{t-t0:.3f},{ph},{p[0]:.3f},{p[1]:.3f},{p[2]:.3f},"
|
||||
f"{sp:.3f},{dg}\n")
|
||||
with open(self.prefix + ".bin", "wb") as f:
|
||||
f.write(b"SYLPHCTR")
|
||||
f.write(struct.pack("<III", len(self.rows), WIN, WIN_BACK))
|
||||
t0 = self.rows[0][0]
|
||||
for (t, ph, p, sp, dg), w in zip(self.rows, self.win):
|
||||
f.write(struct.pack("<d32sff3f", t - t0, ph.encode()[:32], sp,
|
||||
float(dg), *[float(c) for c in p]))
|
||||
f.write(w.ljust(WIN, b"\0"))
|
||||
print(f"# wrote {self.prefix}.csv and {self.prefix}.bin "
|
||||
f"({len(self.rows)} ticks)", flush=True)
|
||||
|
||||
# ---- per-phase summary
|
||||
print("\n# phase n v_early v_late delta dodged")
|
||||
order, seen = [], set()
|
||||
for r in self.rows:
|
||||
if r[1] not in seen:
|
||||
seen.add(r[1])
|
||||
order.append(r[1])
|
||||
for ph in order:
|
||||
v = [r[3] for r in self.rows if r[1] == ph]
|
||||
dg = sum(r[4] for r in self.rows if r[1] == ph)
|
||||
if len(v) < 6:
|
||||
continue
|
||||
n = max(2, len(v) // 4)
|
||||
a, b = float(np.mean(v[:n])), float(np.mean(v[-n:]))
|
||||
print(f" {ph:<12} {len(v):4d} {a:9.1f} {b:9.1f} {b-a:+9.1f} {dg:5d}")
|
||||
|
||||
# ---- which words in the object track speed, and which only fall
|
||||
W_ = np.frombuffer(b"".join(x.ljust(WIN, b"\0") for x in self.win),
|
||||
dtype=">f4").reshape(len(self.win), WIN // 4)
|
||||
sp = np.array([r[3] for r in self.rows], dtype=np.float64)
|
||||
with np.errstate(invalid="ignore", over="ignore"):
|
||||
A = W_.astype(np.float64)
|
||||
ok = np.all(np.isfinite(A), axis=0) & (np.max(np.abs(A), axis=0) < 1e9)
|
||||
var = np.std(A, axis=0)
|
||||
cand = np.flatnonzero(ok & (var > 1e-6))
|
||||
cor = []
|
||||
for i in cand:
|
||||
c = np.corrcoef(A[:, i], sp)[0, 1]
|
||||
if np.isfinite(c):
|
||||
cor.append((abs(c), c, i))
|
||||
cor.sort(reverse=True)
|
||||
print("\n# object words correlating with measured speed "
|
||||
"(offset relative to the position triple)")
|
||||
for ac, c, i in cor[:12]:
|
||||
off = i * 4 - WIN_BACK
|
||||
print(f" pos{off:+#07x} r={c:+.3f} "
|
||||
f"range {A[:, i].min():.3f} .. {A[:, i].max():.3f}")
|
||||
|
||||
print("\n# words that never rise (candidate hull / shield / ammo)")
|
||||
shown = 0
|
||||
for i in cand:
|
||||
col = A[:, i]
|
||||
if col[-1] >= col[0] - 1e-6:
|
||||
continue
|
||||
if np.max(np.diff(col)) > 1e-6:
|
||||
continue
|
||||
off = i * 4 - WIN_BACK
|
||||
print(f" pos{off:+#07x} {col[0]:.3f} -> {col[-1]:.3f}")
|
||||
shown += 1
|
||||
if shown >= 12:
|
||||
break
|
||||
if not shown:
|
||||
print(" (none — nothing in the window decreased monotonically)")
|
||||
|
||||
|
||||
def main():
|
||||
cfg = json.load(open(sys.argv[1]))
|
||||
prefix = sys.argv[2]
|
||||
hold = float(sys.argv[3]) if len(sys.argv) > 3 else 4.0
|
||||
settle = float(sys.argv[4]) if len(sys.argv) > 4 else 2.5
|
||||
Run(cfg, prefix, hold, settle).run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
31
tools/re-capture/ctrl_session.sh
Executable file
31
tools/re-capture/ctrl_session.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# One background task: bind the player's transform, then run the control probe.
|
||||
#
|
||||
# Everything that must not be interrupted lives in a single task here, because
|
||||
# the display and the emulator both die on their own every few minutes in this
|
||||
# container and nothing may depend on surviving between tool calls.
|
||||
#
|
||||
# Assumes the game is ALREADY in flight (launch_mission.sh). Pass --boot to
|
||||
# have it get there itself.
|
||||
set -u
|
||||
export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98
|
||||
export PYTHONPATH=/sylph-home/.local/lib/python3.12/site-packages
|
||||
SD="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
CFG=/tmp/nav-live.json
|
||||
PRE=/tmp/ctrl
|
||||
HOLD=3.0
|
||||
SETTLE=2.0
|
||||
if [ "${1:-}" = "--boot" ]; then
|
||||
shift
|
||||
"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; }
|
||||
fi
|
||||
[ $# -ge 1 ] && CFG="$1"
|
||||
[ $# -ge 2 ] && PRE="$2"
|
||||
[ $# -ge 3 ] && HOLD="$3"
|
||||
[ $# -ge 4 ] && SETTLE="$4"
|
||||
|
||||
python3 "$SD/entities2.py" self 0x130 "$CFG" || { echo "TRANSFORM BIND FAILED"; exit 1; }
|
||||
echo "--- config: $(cat "$CFG")"
|
||||
python3 "$SD/ctrl_probe.py" "$CFG" "$PRE" "$HOLD" "$SETTLE"
|
||||
echo "PROBE DONE"
|
||||
@@ -180,24 +180,35 @@ def main():
|
||||
"(" + ",".join(f"{v:+.3f}" for v in row) + ")" for row in M))
|
||||
if len(sys.argv) > 3 and found:
|
||||
import json
|
||||
# forward axis = the row closest to our direction of travel
|
||||
vdir = None
|
||||
# Which of the orthonormal blocks is the CRAFT's attitude? The one
|
||||
# with a row along the direction of travel. Taking found[0] is what
|
||||
# produced a config with rot_delta -0x764 and a nonsense forward
|
||||
# axis: several blocks inside the object are orthonormal (bone or
|
||||
# camera frames), and only the craft's own has a row that tracks
|
||||
# where the craft is going.
|
||||
p0 = np.array(pos)
|
||||
time.sleep(0.35)
|
||||
p1 = np.array(struct.unpack(">3f", os.pread(fd, 12, off)))
|
||||
if np.linalg.norm(p1 - p0) > 1e-3:
|
||||
vdir = (p1 - p0) / np.linalg.norm(p1 - p0)
|
||||
rot_delta, M, rot_stride = found[0]
|
||||
row, sign = 2, 1
|
||||
if vdir is not None:
|
||||
step = p1 - p0
|
||||
if np.linalg.norm(step) < 1e-3:
|
||||
sys.exit("craft is not moving — cannot bind the forward axis")
|
||||
vdir = step / np.linalg.norm(step)
|
||||
best = None
|
||||
for d, M, st in found:
|
||||
cs = [float(M[r] @ vdir) for r in range(3)]
|
||||
row = int(np.argmax([abs(c) for c in cs]))
|
||||
sign = 1 if cs[row] > 0 else -1
|
||||
print(f"# forward axis = row {row} (sign {sign:+d}), "
|
||||
f"cos={cs[row]:+.3f}")
|
||||
r = int(np.argmax([abs(c) for c in cs]))
|
||||
if best is None or abs(cs[r]) > abs(best[3]):
|
||||
best = (d, M, st, cs[r], r)
|
||||
rot_delta, M, rot_stride, cos, row = best
|
||||
sign = 1 if cos > 0 else -1
|
||||
print(f"# attitude block pos{rot_delta:+#07x} stride {rot_stride}: "
|
||||
f"forward = row {row} (sign {sign:+d}), cos={cos:+.3f}")
|
||||
if abs(cos) < 0.9:
|
||||
print("# WARNING: no block tracks the flight path (|cos| < 0.9)"
|
||||
" — the craft may be drifting hard; re-run while flying straight")
|
||||
cfg = {"def_delta": delta, "rot_delta": rot_delta,
|
||||
"rot_stride": rot_stride,
|
||||
"fwd_row": row, "fwd_sign": sign,
|
||||
"fwd_row": row, "fwd_sign": sign, "fwd_cos": round(cos, 4),
|
||||
"va_lo": ENT_VA_LO, "va_hi": ENT_VA_HI}
|
||||
json.dump(cfg, open(sys.argv[3], "w"), indent=1)
|
||||
print("# wrote " + sys.argv[3] + ": " + json.dumps(cfg))
|
||||
|
||||
27
tools/re-capture/fly_session.sh
Executable file
27
tools/re-capture/fly_session.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# One background task: boot -> mission -> bind the transform -> fly the pilot,
|
||||
# with periodic screenshots so the outcome has visual evidence and not just a log.
|
||||
#
|
||||
# It is one task on purpose: the display and the emulator both die on their own
|
||||
# every few minutes in this container, so nothing may depend on surviving
|
||||
# between tool calls.
|
||||
set -u
|
||||
export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98
|
||||
export PYTHONPATH=/sylph-home/.local/lib/python3.12/site-packages
|
||||
SD="$(cd "$(dirname "$0")" && pwd)"
|
||||
SECS="${1:-240}"
|
||||
TAG="${2:-pilot}"
|
||||
SHOTS=/sylph-home/re/shots
|
||||
CFG=/tmp/nav-live.json
|
||||
|
||||
"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; }
|
||||
python3 "$SD/entities2.py" self 0x130 "$CFG" || { echo "BIND FAILED"; exit 1; }
|
||||
echo "--- config: $(cat "$CFG")"
|
||||
python3 "$SD/own_state.py" "$CFG" 3 "/tmp/$TAG-own.json"
|
||||
|
||||
( for i in $(seq 1 12); do sleep 25; screenshot "$SHOTS/$TAG-$i.png" >/dev/null 2>&1; done ) &
|
||||
SHOTTER=$!
|
||||
python3 "$SD/pilot.py" "$CFG" "$SECS"
|
||||
kill $SHOTTER 2>/dev/null
|
||||
screenshot "$SHOTS/$TAG-end.png" >/dev/null 2>&1
|
||||
echo "SESSION DONE"
|
||||
@@ -55,8 +55,8 @@ fi
|
||||
|
||||
step up # BRIEFINGS -> TAKE OFF
|
||||
vgamepad tap A 250
|
||||
sleep 75 # launch cinematic + stage load + objective card
|
||||
shot "lm-objective.png"
|
||||
vgamepad tap A 250; sleep 6 # dismiss the OBJECTIVE panel
|
||||
# The launch cinematic + stage load + objective card is NOT a fixed 75 s under
|
||||
# lavapipe; wait for the flight HUD itself.
|
||||
"$SD/wait_flight.sh" 300 || { echo "NEVER REACHED FLIGHT"; exit 1; }
|
||||
shot "lm-flight.png"
|
||||
echo "IN FLIGHT (emulator left running)"
|
||||
|
||||
397
tools/re-capture/navigator.py
Normal file
397
tools/re-capture/navigator.py
Normal file
@@ -0,0 +1,397 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Drift-aware navigation with collision avoidance, driven from guest memory.
|
||||
|
||||
Three things the pursuit loop in autopilot3.py did not do:
|
||||
|
||||
* **See everything.** It enumerated entities by looking for things that *move*,
|
||||
so stations, hulls and parked structures were invisible — exactly the objects
|
||||
you crash into. This scans the entity heap for words that equal a known unit
|
||||
definition address and takes `position = hit - 0x130`, which finds every
|
||||
entity whether it is moving or not.
|
||||
|
||||
* **Know how big they are.** Each entity's definition carries `Size_Radius`
|
||||
(+0x50) and `Size_X/Y/Z` (+0x30/34/38) — fields already solved in
|
||||
docs/re/structures/unit-struct-runtime.md — so the avoidance radius is the
|
||||
game's own number, not a guess.
|
||||
|
||||
* **Account for drift.** The craft does not turn where it points: velocity lags
|
||||
the nose like an aircraft with sideslip. Steering the *nose* at a target
|
||||
therefore steers the *flight path* somewhere else, wide and late. This
|
||||
measures the lag online (the angle between nose and velocity, and how fast
|
||||
the velocity vector is actually swinging) and commands the nose *ahead* of
|
||||
where the flight path should go, by that lag.
|
||||
|
||||
Avoidance is closest-point-of-approach, not distance: what matters is whether
|
||||
the two paths will intersect within a horizon, which is why a fast crossing
|
||||
target is dangerous at 2 km and a station drifting away is not at 300 m.
|
||||
|
||||
Usage: navigator.py <config.json> [seconds] [--dry]
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import gmem # noqa: E402
|
||||
import gworld # noqa: E402
|
||||
import entities2 # noqa: E402
|
||||
from flight_probe import Pad # noqa: E402
|
||||
|
||||
# confirmed fields of the parsed unit definition (unit-struct-runtime.md)
|
||||
DEF_SIZE_X, DEF_SIZE_Y, DEF_SIZE_Z, DEF_SIZE_R = 0x30, 0x34, 0x38, 0x50
|
||||
|
||||
|
||||
def norm(v):
|
||||
n = float(np.linalg.norm(v))
|
||||
return v / n if n > 1e-9 else v * 0.0
|
||||
|
||||
|
||||
def ang(a, b):
|
||||
return math.acos(max(-1.0, min(1.0, float(np.dot(norm(a), norm(b))))))
|
||||
|
||||
|
||||
class World:
|
||||
def __init__(self, cfg):
|
||||
self.cfg = cfg
|
||||
self.w = gworld.World()
|
||||
self.fd, self.size = self.w.fd, self.w.size
|
||||
self.delta = cfg["def_delta"]
|
||||
self.rot_delta = cfg["rot_delta"]
|
||||
self.rot_stride = cfg.get("rot_stride", 12)
|
||||
self.fwd_row = cfg["fwd_row"]
|
||||
self.fwd_sign = cfg["fwd_sign"]
|
||||
self.defs = {} # def_va -> name
|
||||
self.def_word = {} # 4-byte BE -> def_va
|
||||
self.radius = {} # def_va -> collision radius
|
||||
self.prev = {} # pos_off -> (t, pos) for velocity
|
||||
self.vel = {} # pos_off -> smoothed velocity
|
||||
self.ents = []
|
||||
self._load_defs()
|
||||
|
||||
def _load_defs(self):
|
||||
for off in self.w.scan_vtable(gworld.DEF_VTABLE):
|
||||
nm = self.w.name_of(off)
|
||||
if not (nm and nm.startswith("UN_")):
|
||||
continue
|
||||
va = gmem.primary_va(off)
|
||||
if va is None:
|
||||
continue
|
||||
self.defs[va] = nm
|
||||
self.def_word[struct.pack(">I", va)] = va
|
||||
b = os.pread(self.fd, 0x60, off)
|
||||
try:
|
||||
sx, sy, sz = (struct.unpack_from(">f", b, o)[0]
|
||||
for o in (DEF_SIZE_X, DEF_SIZE_Y, DEF_SIZE_Z))
|
||||
sr = struct.unpack_from(">f", b, DEF_SIZE_R)[0]
|
||||
except struct.error:
|
||||
sx = sy = sz = sr = 0.0
|
||||
vals = [v for v in (sr, sx, sy, sz) if math.isfinite(v) and 0 < v < 1e5]
|
||||
self.radius[va] = max(vals) if vals else 50.0
|
||||
|
||||
# ------------------------------------------------------------- entities
|
||||
def scan(self):
|
||||
"""Every entity in the heap — moving or not — by its definition pointer."""
|
||||
lo = gmem.va_to_off(self.cfg["va_lo"])
|
||||
hi = gmem.va_to_off(self.cfg["va_hi"])
|
||||
found = []
|
||||
# numpy, not a per-word python loop: the entity heap is 16 MB, so
|
||||
# stepping it 4 bytes at a time costs seconds per scan and the control
|
||||
# loop spends its whole budget scanning instead of flying.
|
||||
want = np.array(sorted(self.defs.keys()), dtype=np.uint32)
|
||||
for a, b in gmem.extents(self.fd, self.size):
|
||||
a, b = max(a, lo), min(b, hi)
|
||||
n = (b - a) // 4 * 4
|
||||
if n < 64:
|
||||
continue
|
||||
arr = np.frombuffer(os.pread(self.fd, n, a), dtype=">u4").astype(np.uint32)
|
||||
for k4 in np.flatnonzero(np.isin(arr, want)):
|
||||
k = int(k4) * 4
|
||||
va = int(arr[k4])
|
||||
poff = a + k - self.delta
|
||||
if poff < 0:
|
||||
continue
|
||||
pb = os.pread(self.fd, 12, poff)
|
||||
if len(pb) < 12:
|
||||
continue
|
||||
p = np.array(struct.unpack(">3f", pb))
|
||||
if not np.all(np.isfinite(p)) or np.max(np.abs(p)) > 1e7:
|
||||
continue
|
||||
found.append((poff, va))
|
||||
# De-duplicate by POSITION: one entity is mirrored at several
|
||||
# addresses, so keying on the address keeps every copy and the scene
|
||||
# looks several times more crowded than it is.
|
||||
seen, uniq = {}, {}
|
||||
for poff, va in found:
|
||||
p = self.pos(poff)
|
||||
if p is None:
|
||||
continue
|
||||
key = (va, tuple(np.round(p, 0)))
|
||||
if key in seen:
|
||||
continue
|
||||
seen[key] = poff
|
||||
uniq[poff] = va
|
||||
self.ents = list(uniq.items())
|
||||
return self.ents
|
||||
|
||||
def pos(self, off):
|
||||
b = os.pread(self.fd, 12, off)
|
||||
if len(b) < 12:
|
||||
return None
|
||||
p = np.array(struct.unpack(">3f", b))
|
||||
return p if np.all(np.isfinite(p)) else None
|
||||
|
||||
def rot(self, off):
|
||||
n = self.rot_stride * 2 + 12
|
||||
b = os.pread(self.fd, n, off + self.rot_delta)
|
||||
if len(b) < n:
|
||||
return None
|
||||
M = np.array([struct.unpack_from(">3f", b, self.rot_stride * r) for r in range(3)])
|
||||
if not np.all(np.isfinite(M)) or np.max(np.abs(M @ M.T - np.eye(3))) > 5e-3:
|
||||
return None
|
||||
return M
|
||||
|
||||
def sample(self, t):
|
||||
"""[(off, name, pos, vel, radius)] with velocity by finite difference."""
|
||||
out = []
|
||||
for off, va in self.ents:
|
||||
p = self.pos(off)
|
||||
if p is None:
|
||||
continue
|
||||
# A frame the emulator did not advance gives an identical position
|
||||
# and a bogus zero velocity, which then reads as "stopped" and
|
||||
# wrecks both the drift estimate and every closing-rate. Keep the
|
||||
# last good velocity instead, and smooth it.
|
||||
prev = self.prev.get(off)
|
||||
v = self.vel.get(off, np.zeros(3))
|
||||
if prev is not None:
|
||||
dt = t - prev[0]
|
||||
moved = float(np.linalg.norm(p - prev[1]))
|
||||
if dt > 0.02 and moved > 1e-4:
|
||||
inst = (p - prev[1]) / dt
|
||||
if float(np.linalg.norm(inst)) < 5000.0:
|
||||
v = 0.5 * v + 0.5 * inst if np.any(v) else inst
|
||||
self.prev[off] = (t, p)
|
||||
else:
|
||||
self.prev[off] = (t, p)
|
||||
self.vel[off] = v
|
||||
out.append((off, self.defs[va], p, v, self.radius[va]))
|
||||
return out
|
||||
|
||||
|
||||
def faction(nm):
|
||||
b = nm[3:]
|
||||
return "ADAN" if b.startswith("be") or b.startswith("e") else "TCAF"
|
||||
|
||||
|
||||
class Navigator:
|
||||
KP, KD = 2.2, 0.45
|
||||
FIRE_CONE = math.radians(9)
|
||||
FIRE_RANGE = 6000.0
|
||||
HORIZON = 6.0 # s of look-ahead for collision checks
|
||||
MARGIN_BIG = 220.0 # clearance around hulls and structures
|
||||
MARGIN_SMALL = 45.0 # ...around fighters, which manoeuvre themselves
|
||||
BIG_RADIUS = 120.0 # above this an entity counts as a structure
|
||||
SELF_MIRROR = 25.0 # the same object is mirrored at several addresses
|
||||
|
||||
def __init__(self, W, pad, dry=False, log=sys.stdout):
|
||||
self.W = W
|
||||
self.pad = pad
|
||||
self.dry = dry
|
||||
self.log = log
|
||||
self.prevM = None
|
||||
self.firing = False
|
||||
self.tau = 0.8 # velocity-lag time constant, refined online
|
||||
|
||||
# ------------------------------------------------------------ drift
|
||||
def update_tau(self, fwd, vel, prev_vhat, dt):
|
||||
"""How long the flight path takes to catch the nose.
|
||||
|
||||
The velocity vector swings toward the nose; the angle between them
|
||||
divided by the rate the velocity is actually swinging is that lag, and
|
||||
it is what the nose has to be commanded ahead by.
|
||||
"""
|
||||
if prev_vhat is None or dt <= 1e-3:
|
||||
return
|
||||
vh = norm(vel)
|
||||
if np.linalg.norm(vh) < 1e-6:
|
||||
return
|
||||
swing = ang(prev_vhat, vh) / dt # rad/s the path is turning
|
||||
lag = ang(fwd, vh) # rad the path is behind
|
||||
if swing > 0.02 and lag > 0.02:
|
||||
tau = lag / swing
|
||||
if 0.05 < tau < 5.0:
|
||||
self.tau = 0.9 * self.tau + 0.1 * tau
|
||||
|
||||
# ------------------------------------------------- collision avoidance
|
||||
def avoidance(self, me_p, me_v, me_r, ents, me_off):
|
||||
"""Sum of escape directions, weighted by how soon and how close."""
|
||||
push = np.zeros(3)
|
||||
worst = None
|
||||
for off, nm, p, v, r in ents:
|
||||
if off == me_off:
|
||||
continue
|
||||
rel_p = p - me_p
|
||||
rel_v = v - me_v
|
||||
d = float(np.linalg.norm(rel_p))
|
||||
# The same entity exists at several mirrored addresses, so our own
|
||||
# copy shows up as an obstacle at zero distance and pins the sticks
|
||||
# at full deflection forever. Anything this close is us.
|
||||
if d < self.SELF_MIRROR:
|
||||
continue
|
||||
# A wingman flying formation is metres away by design and steers
|
||||
# itself; giving it a hull-sized margin makes the loop thrash.
|
||||
big = r >= self.BIG_RADIUS
|
||||
margin = self.MARGIN_BIG if big else self.MARGIN_SMALL
|
||||
if not big and faction(nm) == "TCAF":
|
||||
margin *= 0.5
|
||||
safe = me_r + r + margin
|
||||
if d > 1e4:
|
||||
continue
|
||||
vv = float(np.dot(rel_v, rel_v))
|
||||
t_cpa = 0.0 if vv < 1e-6 else -float(np.dot(rel_p, rel_v)) / vv
|
||||
t_cpa = max(0.0, min(self.HORIZON, t_cpa))
|
||||
cpa = rel_p + rel_v * t_cpa
|
||||
miss = float(np.linalg.norm(cpa))
|
||||
if miss >= safe:
|
||||
continue
|
||||
urgency = (1.0 - miss / safe) * (1.0 - t_cpa / self.HORIZON)
|
||||
if urgency <= 0:
|
||||
continue
|
||||
esc = -norm(cpa) if miss > 1e-3 else norm(np.cross(rel_p, me_v))
|
||||
if np.linalg.norm(esc) < 1e-6:
|
||||
esc = norm(np.cross(rel_p, np.array([0.0, 1.0, 0.0])))
|
||||
push += esc * urgency
|
||||
if worst is None or urgency > worst[0]:
|
||||
worst = (urgency, nm, d, miss, t_cpa)
|
||||
return push, worst
|
||||
|
||||
# -------------------------------------------------------------- target
|
||||
def pick(self, me_p, me_v, fwd, ents, me_off):
|
||||
speed = max(float(np.linalg.norm(me_v)), 1.0)
|
||||
best, bestscore = None, 1e18
|
||||
for off, nm, p, v, r in ents:
|
||||
if off == me_off or faction(nm) != "ADAN":
|
||||
continue
|
||||
rel = p - me_p
|
||||
d = float(np.linalg.norm(rel))
|
||||
if d < 1e-3:
|
||||
continue
|
||||
# lead: where it will be when a shot gets there
|
||||
lead = p + v * (d / max(speed, 200.0))
|
||||
rel_l = lead - me_p
|
||||
theta = ang(rel_l, fwd)
|
||||
score = d * (1.0 + 3.0 * (theta / math.pi) ** 2)
|
||||
if score < bestscore:
|
||||
best, bestscore = (off, nm, lead, rel_l, d), score
|
||||
return best
|
||||
|
||||
# ---------------------------------------------------------------- step
|
||||
def step(self, t, dt, prev_vhat):
|
||||
ents = self.W.sample(t)
|
||||
me = None
|
||||
for e in ents:
|
||||
if "Player" in e[1]:
|
||||
me = e
|
||||
break
|
||||
if me is None:
|
||||
return "no-player", prev_vhat
|
||||
me_off, me_nm, me_p, me_v, me_r = me
|
||||
M = self.W.rot(me_off)
|
||||
if M is None:
|
||||
return "no-orientation", prev_vhat
|
||||
fwd = M[self.W.fwd_row] * self.W.fwd_sign
|
||||
right = M[(self.W.fwd_row + 1) % 3]
|
||||
up = np.cross(fwd, right)
|
||||
|
||||
speed = float(np.linalg.norm(me_v))
|
||||
vhat = norm(me_v) if speed > 1.0 else fwd
|
||||
self.update_tau(fwd, me_v, prev_vhat, dt)
|
||||
|
||||
# body angular velocity for the damping term
|
||||
w = np.zeros(3)
|
||||
if self.prevM is not None and dt > 1e-3:
|
||||
D = self.prevM @ M.T
|
||||
w = np.array([D[2, 1] - D[1, 2], D[0, 2] - D[2, 0], D[1, 0] - D[0, 1]]) / (2 * dt)
|
||||
self.prevM = M
|
||||
|
||||
tgt = self.pick(me_p, me_v, fwd, ents, me_off)
|
||||
goal = norm(tgt[3]) if tgt else fwd
|
||||
|
||||
push, worst = self.avoidance(me_p, me_v, me_r, ents, me_off)
|
||||
pn = float(np.linalg.norm(push))
|
||||
# avoidance outranks the target when it is urgent
|
||||
want = norm(goal + push * (3.0 if pn > 0.6 else 1.5)) if pn > 1e-6 else goal
|
||||
|
||||
# Command the NOSE ahead of where the flight path must go, by the
|
||||
# measured lag -- steering the nose straight at the target makes the
|
||||
# path arrive wide and late.
|
||||
nose_cmd = norm(want + (want - vhat) * min(self.tau * 1.6, 2.5))
|
||||
|
||||
ex = float(np.dot(nose_cmd, right))
|
||||
ey = float(np.dot(nose_cmd, up))
|
||||
ez = float(np.dot(nose_cmd, fwd))
|
||||
yaw = math.atan2(ex, ez if abs(ez) > 1e-3 else 1e-3)
|
||||
pitch = math.atan2(ey, ez if abs(ez) > 1e-3 else 1e-3)
|
||||
if ez < 0:
|
||||
yaw = math.copysign(math.pi / 2, ex if ex else 1.0)
|
||||
|
||||
sx = max(-1.0, min(1.0, self.KP * yaw - self.KD * float(np.dot(w, up))))
|
||||
sy = max(-1.0, min(1.0, -(self.KP * pitch - self.KD * float(np.dot(w, right)))))
|
||||
|
||||
# fire only when the *flight path* is clear and the nose is on target
|
||||
aim_ok = tgt and abs(yaw) < self.FIRE_CONE and abs(pitch) < self.FIRE_CONE
|
||||
fire = bool(aim_ok and tgt[4] < self.FIRE_RANGE and pn < 1.2)
|
||||
|
||||
if not self.dry:
|
||||
self.pad.axis("LX", sx)
|
||||
self.pad.axis("LY", sy)
|
||||
if fire != self.firing:
|
||||
(self.pad.press if fire else self.pad.release)("RB")
|
||||
self.firing = fire
|
||||
|
||||
drift = math.degrees(ang(fwd, vhat))
|
||||
msg = (f"spd={speed:6.0f} drift={drift:5.1f}d tau={self.tau:4.2f} "
|
||||
f"yaw={math.degrees(yaw):+6.1f} pit={math.degrees(pitch):+6.1f} "
|
||||
f"stick=({sx:+.2f},{sy:+.2f}) fire={int(fire)}")
|
||||
if tgt:
|
||||
msg += f" tgt={tgt[1][3:22]:<20} d={tgt[4]:7.0f}"
|
||||
if worst:
|
||||
msg += (f" | AVOID {worst[1][3:20]} miss={worst[3]:6.0f} "
|
||||
f"t={worst[4]:4.1f}s u={worst[0]:.2f}")
|
||||
return msg, vhat
|
||||
|
||||
def run(self, secs, hz=10.0):
|
||||
self.W.scan()
|
||||
t0 = time.time()
|
||||
last, last_scan, prev_vhat = t0, 0.0, None
|
||||
while time.time() - t0 < secs:
|
||||
t = time.time()
|
||||
if t - last_scan > 4.0:
|
||||
ents = self.W.scan()
|
||||
last_scan = t
|
||||
c = Counter(faction(self.W.defs[va]) for _, va in ents)
|
||||
print(f"[{t-t0:6.1f}] scan: {len(ents)} entities {dict(c)}",
|
||||
file=self.log, flush=True)
|
||||
msg, prev_vhat = self.step(t, t - last, prev_vhat)
|
||||
last = t
|
||||
print(f"[{t-t0:6.1f}] {msg}", file=self.log, flush=True)
|
||||
time.sleep(max(0, 1.0 / hz - (time.time() - t)))
|
||||
if not self.dry:
|
||||
self.pad.reset()
|
||||
|
||||
|
||||
def main():
|
||||
cfg = json.load(open(sys.argv[1]))
|
||||
secs = float(sys.argv[2]) if len(sys.argv) > 2 else 90.0
|
||||
W = World(cfg)
|
||||
Navigator(W, Pad(), dry="--dry" in sys.argv).run(secs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
117
tools/re-capture/own_state.py
Normal file
117
tools/re-capture/own_state.py
Normal file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Where does the craft keep its hull and shield? Anchor on the definition.
|
||||
|
||||
The parsed unit definition already has solved fields (unit-struct-runtime.md):
|
||||
`HP` at `+0x054`, shield `MaxValue` at `+0x238`, shield `ChargeSpeed` at
|
||||
`+0x244`. A craft that has taken no damage is at full hull and full shield, so
|
||||
its live entity object must *contain those very numbers*. That turns "find the
|
||||
HP field" from a value-scan over 4 GB into: read two floats from the definition,
|
||||
then look for them inside the entity object.
|
||||
|
||||
Then watch the candidates while the craft is under fire. The live field is the
|
||||
one that falls; a copy of the definition value that never moves is not it.
|
||||
|
||||
Usage: own_state.py <config.json> [watch_seconds] [out.json]
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import gmem # noqa: E402
|
||||
import navigator # noqa: E402
|
||||
|
||||
DEF_HP, DEF_SHIELD_MAX, DEF_SHIELD_CHG = 0x054, 0x238, 0x244
|
||||
BACK, FWD = 0x800, 0x800
|
||||
|
||||
|
||||
def near(a, v):
|
||||
return np.abs(a - v) <= max(1e-3, abs(v) * 1e-4)
|
||||
|
||||
|
||||
def main():
|
||||
cfg = json.load(open(sys.argv[1]))
|
||||
secs = float(sys.argv[2]) if len(sys.argv) > 2 else 40.0
|
||||
out = sys.argv[3] if len(sys.argv) > 3 else None
|
||||
|
||||
W = navigator.World(cfg)
|
||||
ents = W.scan()
|
||||
me = [(off, va) for off, va in ents if "Player" in W.defs[va]]
|
||||
if not me:
|
||||
sys.exit("player entity not found — not in flight?")
|
||||
me_off, def_va = me[0]
|
||||
print(f"# player {W.defs[def_va]} pos off {me_off:#x} def {def_va:#010x}")
|
||||
|
||||
d = os.pread(W.fd, 0x300, gmem.va_to_off(def_va))
|
||||
want = {}
|
||||
for nm, o in (("HP", DEF_HP), ("Shield_MaxValue", DEF_SHIELD_MAX),
|
||||
("Shield_ChargeSpeed", DEF_SHIELD_CHG)):
|
||||
(v,) = struct.unpack_from(">f", d, o)
|
||||
want[nm] = v
|
||||
print(f"# definition {nm:<18} = {v:g}")
|
||||
|
||||
blob = os.pread(W.fd, BACK + FWD, me_off - BACK)
|
||||
arr = np.frombuffer(blob, dtype=">f4").astype(np.float64)
|
||||
cands = [] # (label, delta_from_position)
|
||||
for nm, v in want.items():
|
||||
if not np.isfinite(v) or v == 0.0:
|
||||
continue
|
||||
for i in np.flatnonzero(near(arr, v)):
|
||||
cands.append((nm, int(i) * 4 - BACK))
|
||||
print(f"# {len(cands)} candidate offsets inside the entity object:")
|
||||
for nm, dlt in cands:
|
||||
print(f" pos{dlt:+#07x} == definition {nm}")
|
||||
if not cands:
|
||||
print("# none — the object does not carry the definition's own numbers"
|
||||
" at this offset window; widen BACK/FWD or the craft is damaged")
|
||||
|
||||
# ---- watch them; the live field is the one that moves
|
||||
hist = {c: [] for c in cands}
|
||||
t0 = time.time()
|
||||
last_print = 0.0
|
||||
while time.time() - t0 < secs:
|
||||
p = W.pos(me_off)
|
||||
if p is None:
|
||||
print("# player object gone (death / stage change?)")
|
||||
break
|
||||
w = os.pread(W.fd, BACK + FWD, me_off - BACK)
|
||||
a = np.frombuffer(w, dtype=">f4").astype(np.float64)
|
||||
for c in cands:
|
||||
i = (c[1] + BACK) // 4
|
||||
hist[c].append(float(a[i]))
|
||||
t = time.time() - t0
|
||||
if t - last_print > 4.0:
|
||||
last_print = t
|
||||
cur = " ".join(f"{c[0][:4]}{c[1]:+#x}={hist[c][-1]:.1f}" for c in cands[:6])
|
||||
print(f"[{t:6.1f}] {cur}", flush=True)
|
||||
time.sleep(0.2)
|
||||
|
||||
print("\n# offset anchor first last min moved")
|
||||
moving = []
|
||||
for c in cands:
|
||||
h = np.array(hist[c])
|
||||
if not len(h):
|
||||
continue
|
||||
mv = float(h.max() - h.min())
|
||||
print(f" pos{c[1]:+#07x} {c[0]:<18} {h[0]:8.1f} {h[-1]:8.1f} "
|
||||
f"{h.min():8.1f} {mv:7.3f}")
|
||||
if mv > 1e-3:
|
||||
moving.append({"anchor": c[0], "delta": c[1],
|
||||
"first": h[0], "last": float(h[-1]),
|
||||
"min": float(h.min())})
|
||||
if not moving:
|
||||
print("# nothing moved — the craft took no damage during the window")
|
||||
if out:
|
||||
json.dump({"player": W.defs[def_va], "def_va": def_va,
|
||||
"definition": want,
|
||||
"candidates": [{"anchor": a, "delta": b} for a, b in cands],
|
||||
"moved": moving}, open(out, "w"), indent=1)
|
||||
print(f"# wrote {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
319
tools/re-capture/pilot.py
Normal file
319
tools/re-capture/pilot.py
Normal file
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A pilot that tries to stay alive, not just to shoot.
|
||||
|
||||
Every earlier loop flew a straight pursuit and was shot down; the trace from
|
||||
ctrl_probe.py shows why — 1500 hull points gone in twelve seconds while sitting
|
||||
in a turret's line of fire, with no reaction of any kind. Three measured facts
|
||||
make a reaction possible:
|
||||
|
||||
* **Hull is `position + 0x154`** — at spawn it equals the unit definition's own
|
||||
`HP` (1500 for the Delta Saber), it steps down 30/60/90 per hit, and it goes
|
||||
negative at death. So damage is observable *as it happens*, not inferred.
|
||||
* **`RT` accelerates and `LT` brakes**, and the setting persists: measured
|
||||
ground speed went 488 → 1510 under RT, 488 → 174 under LT and then *stayed*
|
||||
near 130 with the sticks neutral. (The older note "RT is not the throttle" was
|
||||
drawn from a value-scan for a speed field, not from measuring the speed.)
|
||||
* **`RB` fires** (autopilot-memory-driven.md).
|
||||
|
||||
So the loop is a state machine on damage rather than a pure pursuit:
|
||||
|
||||
ENGAGE chase and shoot the nearest hostile fighter
|
||||
EVADE entered the moment the hull drops — turn away from the threats,
|
||||
full throttle, jink; leave only after several quiet seconds
|
||||
RETIRE hull below a floor: break for the friendly capital ship, which the
|
||||
mission's own hint says is where you resupply
|
||||
|
||||
Turrets are treated as threats to be *kept at a distance*, not as targets: the
|
||||
objective is the invading fighters, and the turret is what killed every previous
|
||||
run.
|
||||
|
||||
Usage: pilot.py <config.json> [seconds] [--dry]
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import navigator # noqa: E402
|
||||
from navigator import ang, norm # noqa: E402
|
||||
from flight_probe import Pad # noqa: E402
|
||||
|
||||
HULL_OFF = 0x154 # confirmed: == definition HP at spawn, falls when hit
|
||||
SHIELD_OFF = 0x430 # candidate: == definition Shield MaxValue at spawn
|
||||
|
||||
|
||||
class Pilot:
|
||||
KP, KD = 2.2, 0.45
|
||||
FIRE_CONE = math.radians(9)
|
||||
FIRE_RANGE = 5000.0
|
||||
TURRET_KEEPOUT = 2500.0 # ...and stay this far from things that shoot back
|
||||
EVADE_QUIET = 5.0 # seconds without damage before re-engaging
|
||||
RETIRE_FRAC = 0.30 # hull fraction that sends us home
|
||||
HZ = 8.0
|
||||
|
||||
def __init__(self, W, pad, dry=False, log=sys.stdout):
|
||||
self.W = W
|
||||
self.pad = pad
|
||||
self.dry = dry
|
||||
self.log = log
|
||||
# closest-point-of-approach avoidance is navigator.py's, reused as-is
|
||||
self.av = navigator.Navigator(W, pad, dry=True, log=log)
|
||||
self.prevM = None
|
||||
self.firing = False
|
||||
self.throttle = 0 # -1 brake, 0 coast, +1 accelerate
|
||||
self.mode = "ENGAGE"
|
||||
self.hp_hist = deque(maxlen=64)
|
||||
self.hp0 = None
|
||||
self.last_hit = -1e9
|
||||
self.threat_dir = None
|
||||
|
||||
# ------------------------------------------------------------ own state
|
||||
def own(self, off):
|
||||
b = os.pread(self.W.fd, 8, off + HULL_OFF)
|
||||
hull = struct.unpack_from(">f", b, 0)[0] if len(b) >= 4 else float("nan")
|
||||
b2 = os.pread(self.W.fd, 4, off + SHIELD_OFF)
|
||||
shield = struct.unpack(">f", b2)[0] if len(b2) == 4 else float("nan")
|
||||
return hull, shield
|
||||
|
||||
def set_throttle(self, want):
|
||||
"""RT / LT are a persistent setting, so only send the change."""
|
||||
if want == self.throttle or self.dry:
|
||||
return
|
||||
self.pad.trig("RT", 1.0 if want > 0 else 0.0)
|
||||
self.pad.trig("LT", 1.0 if want < 0 else 0.0)
|
||||
self.throttle = want
|
||||
|
||||
# -------------------------------------------------------------- targets
|
||||
def hostiles(self, ents, me_off):
|
||||
out = []
|
||||
for off, nm, p, v, r in ents:
|
||||
if off == me_off or navigator.faction(nm) != "ADAN":
|
||||
continue
|
||||
out.append((off, nm, p, v, r, "Turret" in nm or r >= navigator.Navigator.BIG_RADIUS))
|
||||
return out
|
||||
|
||||
def pick(self, me_p, me_v, fwd, hos):
|
||||
"""Nearest *fighter*, weighted by how far off the nose it is."""
|
||||
speed = max(float(np.linalg.norm(me_v)), 1.0)
|
||||
best, bestscore = None, 1e18
|
||||
for off, nm, p, v, r, hard in hos:
|
||||
if hard:
|
||||
continue # turrets and hulls are not the objective
|
||||
rel = p - me_p
|
||||
d = float(np.linalg.norm(rel))
|
||||
if d < 1e-3:
|
||||
continue
|
||||
lead = p + v * (d / max(speed, 300.0))
|
||||
theta = ang(lead - me_p, fwd)
|
||||
score = d * (1.0 + 3.0 * (theta / math.pi) ** 2)
|
||||
if score < bestscore:
|
||||
best, bestscore = (off, nm, lead, lead - me_p, d), score
|
||||
return best
|
||||
|
||||
def threat_vector(self, me_p, hos):
|
||||
"""Where the danger is: inverse-square weighted direction to shooters."""
|
||||
acc = np.zeros(3)
|
||||
for off, nm, p, v, r, hard in hos:
|
||||
rel = p - me_p
|
||||
d = float(np.linalg.norm(rel))
|
||||
if d < 1.0 or d > 8000.0:
|
||||
continue
|
||||
w = (1500.0 / d) ** 2 * (3.0 if hard else 1.0)
|
||||
acc += norm(rel) * w
|
||||
return norm(acc) if np.linalg.norm(acc) > 1e-6 else None
|
||||
|
||||
def friendly_base(self, ents, me_off):
|
||||
"""The biggest friendly — the carrier the briefing says to resupply at."""
|
||||
best = None
|
||||
for off, nm, p, v, r in ents:
|
||||
if off == me_off or navigator.faction(nm) != "TCAF":
|
||||
continue
|
||||
if best is None or r > best[4]:
|
||||
best = (off, nm, p, v, r)
|
||||
return best
|
||||
|
||||
# ------------------------------------------------------------ steering
|
||||
def sticks(self, want, M, w):
|
||||
fwd = M[self.W.fwd_row] * self.W.fwd_sign
|
||||
right = M[(self.W.fwd_row + 1) % 3]
|
||||
up = np.cross(fwd, right)
|
||||
ex, ey, ez = (float(np.dot(want, right)), float(np.dot(want, up)),
|
||||
float(np.dot(want, fwd)))
|
||||
yaw = math.atan2(ex, ez if abs(ez) > 1e-3 else 1e-3)
|
||||
pitch = math.atan2(ey, ez if abs(ez) > 1e-3 else 1e-3)
|
||||
if ez < 0: # target behind: commit to a full turn
|
||||
yaw = math.copysign(math.pi / 2, ex if ex else 1.0)
|
||||
sx = max(-1.0, min(1.0, self.KP * yaw - self.KD * float(np.dot(w, up))))
|
||||
sy = max(-1.0, min(1.0, -(self.KP * pitch - self.KD * float(np.dot(w, right)))))
|
||||
return sx, sy, yaw, pitch
|
||||
|
||||
# ---------------------------------------------------------------- step
|
||||
def step(self, t, dt):
|
||||
ents = self.W.sample(t)
|
||||
me = next((e for e in ents if "Player" in e[1]), None)
|
||||
if me is None:
|
||||
return None
|
||||
me_off, me_nm, me_p, me_v, me_r = me
|
||||
M = self.W.rot(me_off)
|
||||
if M is None:
|
||||
return "no-orientation"
|
||||
fwd = M[self.W.fwd_row] * self.W.fwd_sign
|
||||
right = M[(self.W.fwd_row + 1) % 3]
|
||||
up = np.cross(fwd, right)
|
||||
speed = float(np.linalg.norm(me_v))
|
||||
|
||||
hull, shield = self.own(me_off)
|
||||
if self.hp0 is None and math.isfinite(hull) and hull > 0:
|
||||
self.hp0 = hull
|
||||
self.hp_hist.append((t, hull))
|
||||
# damage over the last ~2 s; the hull only ever falls, so any drop is a hit
|
||||
recent = [h for (ts, h) in self.hp_hist if t - ts <= 2.0]
|
||||
dmg = (max(recent) - hull) if recent else 0.0
|
||||
if dmg > 0.5:
|
||||
self.last_hit = t
|
||||
if hull <= 0:
|
||||
return "DEAD"
|
||||
|
||||
# body angular velocity, for the damping term
|
||||
w = np.zeros(3)
|
||||
if self.prevM is not None and dt > 1e-3:
|
||||
D = self.prevM @ M.T
|
||||
w = np.array([D[2, 1] - D[1, 2], D[0, 2] - D[2, 0], D[1, 0] - D[0, 1]]) / (2 * dt)
|
||||
self.prevM = M
|
||||
|
||||
hos = self.hostiles(ents, me_off)
|
||||
self.threat_dir = self.threat_vector(me_p, hos)
|
||||
frac = hull / self.hp0 if self.hp0 else 1.0
|
||||
|
||||
# ---- mode
|
||||
if frac <= self.RETIRE_FRAC:
|
||||
self.mode = "RETIRE"
|
||||
elif t - self.last_hit < self.EVADE_QUIET:
|
||||
self.mode = "EVADE"
|
||||
else:
|
||||
self.mode = "ENGAGE"
|
||||
|
||||
tgt = self.pick(me_p, me_v, fwd, hos)
|
||||
push, worst = self.av.avoidance(me_p, me_v, me_r, ents, me_off)
|
||||
|
||||
if self.mode == "EVADE":
|
||||
# Away from the guns, plus a jink so a straight escape line is not
|
||||
# itself an easy solution for whatever is shooting.
|
||||
away = -self.threat_dir if self.threat_dir is not None else fwd
|
||||
jink = right * math.sin(t * 1.7) * 0.5 + up * math.cos(t * 2.3) * 0.35
|
||||
want = norm(away + jink)
|
||||
self.set_throttle(+1)
|
||||
fire = False
|
||||
elif self.mode == "RETIRE":
|
||||
base = self.friendly_base(ents, me_off)
|
||||
if base is not None:
|
||||
rel = base[2] - me_p
|
||||
d = float(np.linalg.norm(rel))
|
||||
want = norm(rel) if d > base[4] + 400.0 else norm(np.cross(rel, up))
|
||||
else:
|
||||
want = -self.threat_dir if self.threat_dir is not None else fwd
|
||||
self.set_throttle(+1)
|
||||
fire = False
|
||||
else:
|
||||
# Straight lead pursuit and fly *through*. An earlier version orbited
|
||||
# once inside a standoff radius and braked while doing it: it then
|
||||
# circled one attacker for 40 s at ~700 m, at 60-100 units/s, never
|
||||
# inside the firing cone. Overshooting and re-acquiring is better
|
||||
# than a stall in the middle of a battle; collision avoidance already
|
||||
# keeps a fighter-sized margin.
|
||||
want = norm(tgt[3]) if tgt else fwd
|
||||
if tgt and tgt[4] > 2500.0:
|
||||
self.set_throttle(+1)
|
||||
elif tgt and tgt[4] < 500.0 and speed > 900.0:
|
||||
self.set_throttle(-1)
|
||||
else:
|
||||
self.set_throttle(0)
|
||||
fire = True
|
||||
|
||||
# a turret inside its keep-out radius outranks the target
|
||||
for off, nm, p, v, r, hard in hos:
|
||||
if not hard:
|
||||
continue
|
||||
d = float(np.linalg.norm(p - me_p))
|
||||
if d < self.TURRET_KEEPOUT:
|
||||
want = norm(want + norm(me_p - p) * (2.0 * (1.0 - d / self.TURRET_KEEPOUT)))
|
||||
break
|
||||
|
||||
pn = float(np.linalg.norm(push))
|
||||
if pn > 1e-6:
|
||||
want = norm(want + push * (3.0 if pn > 0.6 else 1.5))
|
||||
|
||||
sx, sy, yaw, pitch = self.sticks(want, M, w)
|
||||
# The firing gate has to be measured against the TARGET, not against the
|
||||
# commanded direction: `want` carries the avoidance and keep-out terms,
|
||||
# so gating on it means the guns stay cold exactly when the loop is
|
||||
# manoeuvring — which is most of a dogfight.
|
||||
aim = self.sticks(norm(tgt[3]), M, w)[2:] if tgt else (math.pi, math.pi)
|
||||
aim_ok = abs(aim[0]) < self.FIRE_CONE and abs(aim[1]) < self.FIRE_CONE
|
||||
fire = bool(fire and tgt and aim_ok and tgt[4] < self.FIRE_RANGE and pn < 1.2)
|
||||
|
||||
if not self.dry:
|
||||
self.pad.axis("LX", sx)
|
||||
self.pad.axis("LY", sy)
|
||||
if fire != self.firing:
|
||||
(self.pad.press if fire else self.pad.release)("RB")
|
||||
self.firing = fire
|
||||
|
||||
msg = (f"{self.mode:<7} hull={hull:6.0f} shd={shield:6.0f} spd={speed:6.0f} "
|
||||
f"thr={self.throttle:+d} yaw={math.degrees(yaw):+6.1f} "
|
||||
f"pit={math.degrees(pitch):+6.1f} aim={math.degrees(aim[0]):+6.1f}"
|
||||
f"/{math.degrees(aim[1]):+6.1f} fire={int(fire)}")
|
||||
if dmg > 0.5:
|
||||
msg += f" HIT -{dmg:.0f}"
|
||||
if tgt:
|
||||
msg += f" tgt={tgt[1][3:24]:<21} d={tgt[4]:6.0f}"
|
||||
if worst:
|
||||
msg += f" | AVOID {worst[1][3:18]} miss={worst[3]:5.0f} t={worst[4]:4.1f}"
|
||||
return msg
|
||||
|
||||
def run(self, secs):
|
||||
self.W.scan()
|
||||
t0 = time.time()
|
||||
last, last_scan = t0, 0.0
|
||||
hostiles0 = None
|
||||
while time.time() - t0 < secs:
|
||||
t = time.time()
|
||||
if t - last_scan > 5.0:
|
||||
ents = self.W.scan()
|
||||
last_scan = t
|
||||
n_ad = sum(1 for _, va in ents
|
||||
if navigator.faction(self.W.defs[va]) == "ADAN")
|
||||
if hostiles0 is None:
|
||||
hostiles0 = n_ad
|
||||
print(f"[{t-t0:6.1f}] scan: {len(ents)} entities, {n_ad} ADAN "
|
||||
f"(start {hostiles0})", file=self.log, flush=True)
|
||||
msg = self.step(t, t - last)
|
||||
last = t
|
||||
if msg is None:
|
||||
print(f"[{t-t0:6.1f}] player object gone — stopping",
|
||||
file=self.log, flush=True)
|
||||
break
|
||||
print(f"[{t-t0:6.1f}] {msg}", file=self.log, flush=True)
|
||||
if msg == "DEAD":
|
||||
break
|
||||
time.sleep(max(0.0, 1.0 / self.HZ - (time.time() - t)))
|
||||
if not self.dry:
|
||||
self.pad.reset()
|
||||
print(f"# flew {time.time()-t0:.0f}s", file=self.log, flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
cfg = json.load(open(sys.argv[1]))
|
||||
secs = float(sys.argv[2]) if len(sys.argv) > 2 else 180.0
|
||||
W = navigator.World(cfg)
|
||||
Pilot(W, Pad(), dry="--dry" in sys.argv).run(secs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
33
tools/re-capture/wait_flight.sh
Executable file
33
tools/re-capture/wait_flight.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wait until the game is actually FLYING, then return — do not guess with sleeps.
|
||||
#
|
||||
# `launch_mission.sh` used to allow a fixed 75 s for the launch cinematic, the
|
||||
# stage load and the objective card. Under lavapipe that is not a constant: one
|
||||
# run needed 75 s, the next was still in Raymond's dialogue at 140 s, so the A
|
||||
# meant for the OBJECTIVE card was swallowed by the cutscene and the card sat
|
||||
# there forever.
|
||||
#
|
||||
# The in-flight HUD is unmistakable: the SHIELD bar is a solid bright green
|
||||
# block at the bottom of the screen, and no cutscene or menu has anything green
|
||||
# there. So poll that pixel, and tap A every few seconds until it appears (which
|
||||
# dismisses the objective card whenever it happens to be up).
|
||||
set -u
|
||||
export HOME=/sylph-home/re
|
||||
DEADLINE=$(( SECONDS + ${1:-240} ))
|
||||
PX=450; PY=640 # inside the SHIELD bar of the flight HUD
|
||||
last_tap=0
|
||||
while [ $SECONDS -lt $DEADLINE ]; do
|
||||
screenshot /tmp/wf.png >/dev/null 2>&1 || { sleep 2; continue; }
|
||||
read -r r g b < <(convert /tmp/wf.png -format \
|
||||
"%[fx:int(255*p{$PX,$PY}.r)] %[fx:int(255*p{$PX,$PY}.g)] %[fx:int(255*p{$PX,$PY}.b)]" info:)
|
||||
if [ "${g:-0}" -gt 140 ] && [ $(( g - r )) -gt 60 ] && [ $(( g - b )) -gt 60 ]; then
|
||||
echo "IN FLIGHT at ${SECONDS}s (HUD shield bar visible)"
|
||||
exit 0
|
||||
fi
|
||||
if [ $(( SECONDS - last_tap )) -ge 6 ]; then
|
||||
vgamepad tap A 250
|
||||
last_tap=$SECONDS
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "NO FLIGHT HUD within ${1:-240}s"; exit 1
|
||||
Reference in New Issue
Block a user