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:
2026-07-30 05:38:00 +00:00
parent 4623387f6c
commit 2e9903d0fc
15 changed files with 3280 additions and 24 deletions

View File

@@ -31,6 +31,8 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
|-----------|-------|------|-------|
| Live guest-memory read | ✅ | [`tools/re-capture/gmem.py`](../../tools/re-capture/gmem.py) | Canary backs the guest address space with `/dev/shm/xenia_memory_*`; guest VAs map in through Xenia's fixed table. Full-RAM search ~0.2 s (sparse, `SEEK_DATA`). No debugger, no emulator patch, game keeps running |
| IDXD object layout solver | ✅ | [`tools/re-capture/weapon_runtime.py`](../../tools/re-capture/weapon_runtime.py) | Scan RAM for a class's vtable → enumerate its objects → brute-force `(field, offset, encoding)` against the disc records. Accepts a binding only on **zero** contradictions. Generalizes to any IDXD-backed definition |
| Live entity state, anchored on the definition | ✅ | [`tools/re-capture/own_state.py`](../../tools/re-capture/own_state.py) · [autopilot](autopilot-memory-driven.md) | An undamaged craft holds its definition's own numbers, so a *solved definition field* locates the matching live field without a value scan: definition `HP` (1500) → **hull at `position+0x154`**, confirmed by a trace across a death (30/60/90 per hit, negative at 0). Reusable for any live counter whose maximum the definition carries |
| Input → dynamics calibration | ✅ | [`tools/re-capture/ctrl_probe.py`](../../tools/re-capture/ctrl_probe.py) · [`binq.py`](../../tools/re-capture/binq.py) | Hold each pad input in turn and measure the craft's speed as displacement/s of its own position triple — no speed field needed first. Settled the throttle: **`RT` accelerates, `LT` brakes, and the setting persists** (488 → 1510 → 174 units/s), overturning an earlier field-scan conclusion |
## Functions / code paths

View File

@@ -1,12 +1,99 @@
# Memory-driven autopilot — build log and current state
**Status: 🟢 IT FLIES AND SHOOTS — it does not yet survive.**
Updated 2026-07-29 (second pass). The autopilot reads the live world, picks
hostile targets, pursues them and opens fire. What it cannot do is stay alive:
there is no evasion or shield management, so it dies before a mission ends.
This is the honest state, not a plan.
**Status: 🟢 IT FLIES, KILLS AND SURVIVES — it has not yet finished a mission.**
Updated 2026-07-30. `pilot.py` flew Stage 02 for **300 s with the hull untouched
at 1500/1500** and scored the first confirmed autopilot kill (`YOU KILLED
WARPLANES 0001` on the HUD, screenshots `shots/pilot1-*.png`); the scene's
hostile count fell from 134 to 111 over the run. The day before, every run was
dead inside 35 s. What is still missing is the *end* of a mission: the objective
counter (`REMAINING OB`) rises as new waves spawn, and nothing yet tracks which
targets actually close it out.
## What the loop does now, observed
## 2026-07-30 — the numbers survival needs
Three things the loop was missing were measured this session, each by
consequence rather than by reading a field and hoping.
### Hull is `position + 0x154` ✅ CONFIRMED
The unit definition already had `HP` solved at `+0x054`
([unit-struct-runtime](structures/unit-struct-runtime.md)); the Delta Saber's is
**1500**. A craft that has taken no damage must therefore *contain that number*,
which turns "find the HP field" into a two-float lookup rather than a value scan
(`own_state.py`). It appears once in the entity object, at `pos+0x154`, and the
trace across a death settles it (`ctrl_probe.py` capture, `binq.py trace`):
```
t phase hull
0.00 base 1500.00 <- == definition HP
23.25 rest_A 1380.00 <- first hit, -120
26.68 … 27.18 B 1320 … 930 <- seven hits in 0.5 s
31.93 X 150.00
35.21 Y -30.00 <- goes negative
35.26 Y -180.00 -> GAME OVER on screen
```
Damage arrives in 30/60/90-point steps and the field goes *negative* at death,
so it is the raw hull counter, not a clamped display value. **1500 hull lost in
12 s** of sitting in a turret's line of fire is the whole reason every earlier
run died.
### Shield is `position + 0x430` 🟡 PROBABLE — not yet confirmed live
Same anchor trick: the definition's shield `MaxValue` is **400** and
`ChargeSpeed` **25**, and the entity object holds `400.0` at `+0x430`, `+0x434`
and `+0x438`, with `25.0` at `+0x448`. Which of the three is the *current* value
is unproven — the capture that spanned the death used a ±0x400 window and
cropped them out. `ctrl_probe.py` now samples ±0x800.
### `RT` accelerates, `LT` brakes, and the throttle is a *setting* ✅ CONFIRMED
`ctrl_probe.py` holds each input in turn and measures the craft's own speed as
displacement per second from the position triple, so no speed field is needed.
Distance flown / phase duration, one 3 s hold each, sticks neutral:
| phase | speed (units/s) | | phase | speed (units/s) |
|---|---|---|---|---|
| base (no input) | 488 | | A | 287 |
| **RT** | **1510** | | B | 139 |
| rest after RT | 1056 | | X | 125 |
| **LT** | **174** | | Y | (dying) |
| rest after LT | 428 | | LB / LS / RS / RY / RX / dpad | no effect |
RT triples the speed, LT cuts it to a third, and **the braked state persists**:
after the LT phase the craft sat at 125140 units/s with the sticks and triggers
neutral for the remaining 40 s, and nothing but RT brought it back. So these are
a throttle setting, not a momentary boost — which also means a control loop must
send only the *changes*.
This **corrects** the earlier note in this file ("`RT` is *not* the throttle,
and no button tested is"). That conclusion came from `findspeed.py`, which
assumed the control and went looking for a *field* that rose; measuring the
speed directly reverses it.
### Two method corrections
* **Pick the attitude block by the flight path, not by address order.** The
player object contains **20** orthonormal 3×3 blocks (identity frames, bone
or camera frames), and `pos-0x70` and `pos-0x30` hold the *same* matrix.
Taking `found[0]` wrote a config with `rot_delta = -0x764` and a nonsense
forward axis; `entities2.py self` now scores every block against the measured
direction of travel and picks the best (`cos = +1.000`, row 2, sign +1).
* **The entity-heap scan has to be numpy.** A per-word Python loop over the
16 MB entity region costs seconds per scan, which is the whole budget of a
10 Hz control loop; `np.isin` over a `>u4` view is milliseconds.
### Stage 02 as an autopilot testbed (from the in-flight HUD)
`OBJECTIVE: shoot down all invading enemy fighters while watching out for
attacks on the ACROPOLIS` · `DEFEAT: your fighter is shot down, or the ACROPOLIS
is sunk` · `HINT: you can resupply at the ACROPOLIS`. The HUD shows
**`REMAINING OB 004`** — only four objective targets — so this mission is
winnable by an autopilot that survives. It also shows separate **SHIELD** and
**ARMOR** bars (matching a 400-point shield over 1500 hull), `A/B 7,635`
afterburner, and `NOSE BM 06000` / `MAIN MPM 00300` ammo.
## What the loop did before that, observed
```
[ 82.5] tgt=e007_ADAN_Turret d=3384 yaw= -7.3 pit=+14.6 stick=(-0.13,-0.34) fire=0
@@ -33,8 +120,8 @@ rescan reports the scene as e.g. `136 entities {'TCAF': 16, 'ADAN': 120}`.
direction of travel with **cos = +1.000**.
3. **The fire button is RB** — established by consequence, not by guessing:
of RB/LB/A/B/X/Y/RT/LT, pressing RB is the only one that makes the nose-ammo
counter in RAM fall (5958 → 5940). `RT` is *not* the throttle, and no button
tested is.
counter in RAM fall (5958 → 5940). (This entry also claimed `RT` is *not* the
throttle — **wrong**, see the 2026-07-30 measurement above.)
4. **Control.** PD on the aiming error with the derivative taken from the
craft's own body angular velocity (from two consecutive rotation matrices),
and target selection weighted by off-boresight angle
@@ -113,7 +200,36 @@ statements about `0x820af030`, which is *not* the live entity —
(6 Hz) and re-check orthonormality on every read — blocks found by a scan get
overwritten between the scan and the read.
## The next step that unblocks the most
## After survival, the blocker is lethality (2026-07-30)
The 300 s run took **no damage at all** and killed **one** warplane, spending
~800 rounds of nose ammo (`06000``05193`) to do it, while `REMAINING OB` rose
from `004` to `011` as fresh waves spawned. So attrition at this rate never
finishes the mission, and the ranking of open problems has changed:
1. **Hit rate.** It opens fire at 25 km with a 9° cone and a crude lead
(`p + v·d/speed`, no projectile speed). The `Shell` records in
[weapon-struct-runtime](structures/weapon-struct-runtime.md) carry the real
projectile speed and `MaximumRange` per weapon — the lead and the firing
range should come from *those*, not from constants.
2. **Which targets count.** `REMAINING OB` is the mission's own objective
counter and it is on screen, so it is in RAM; finding it turns "shoot
whatever is nearest" into "shoot what closes the mission". Objective-marked
entities also draw an `OB` badge in the HUD, so the flag is likely a word in
the entity object.
3. **Confirming the shield word** — needs a run that actually takes damage; the
pilot is now good enough at avoiding that to make it awkward, so drive
straight at a turret on purpose with `--dry` steering disabled.
4. **Does the ACROPOLIS repair?** RETIRE mode has never triggered (the hull
never fell), so the resupply hint is still untested.
## The next step that unblocks the most (superseded — kept for the reasoning)
**Update 2026-07-30: this is no longer the blocker.** Entity typing via the
definition pointer already solved target selection, so the game's own target
pointer is now a convenience rather than a prerequisite. It would still be the
cheapest route to problem 2 above (objective targets), because whatever the HUD
locks on to is what the game itself considers a target.
**Find the game's own target pointer instead of typing entities ourselves.**
The HUD has a lock-on system (a `TARGET` marker and a target-cycle button), so
@@ -143,6 +259,12 @@ with the pad and watch which pointer-shaped global changes in step.
## Files
`pilot.py` (**the survival loop**) · `ctrl_probe.py` (input → speed calibration,
plus a per-tick window of the player object) · `binq.py` (query that capture) ·
`own_state.py` (definition-anchored hull/shield lookup) · `fly_session.sh`
(boot → mission → bind → fly, one task) · `wait_flight.sh` (wait for the real
HUD instead of a fixed sleep) · `navigator.py` (drift-aware steering + CPA
avoidance, reused by the pilot) ·
`gworld.py` (live reader + entity list) · `flight_probe.py` (scripted inputs +
sampling, and the `Pad` FIFO client) · `flight_analyze.py` · `whatchanges.py`
(encoding-agnostic "which words are live") · `findplayer.py` · `findself.py` ·

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

File diff suppressed because it is too large Load Diff

144
tools/re-capture/binq.py Normal file
View 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()

View 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()

View 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"

View File

@@ -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
View 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"

View File

@@ -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)"

View 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()

View 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
View 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
View 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