#!/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 [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()