#!/usr/bin/env python3 """Fly and fight from guest memory. World state comes from entities2.py's typing rule: a live entity's position triple sits at a fixed offset before its definition pointer, so one scan of the entity heap yields every craft in the scene *with its unit type* — which is what separates enemies from wingmen, capital ships and the thousands of moving particles. Control is PD on the aiming error, with the derivative taken from the craft's own body angular velocity (recovered from two consecutive orientation matrices) rather than from the differenced error — that is what the old screen-scraping autopilot lacked, and why it oscillated. Usage: autopilot3.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 HOSTILE = ("e", "be") # UN_e* / UN_be* are ADAN; UN_f*/UN_bf* are ours def unit_faction(nm): base = nm[3:] return "ADAN" if base.startswith("be") or base.startswith("e") else "TCAF" class World: def __init__(self, cfg): self.cfg = cfg self.w = gworld.World() self.fd, self.size = self.w.fd, self.w.size self.defs = entities2.definitions(self.w) 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.ents = [] def rescan(self): movers = entities2.moving(self.fd, self.size, dt=0.35, va_range=(self.cfg["va_lo"], self.cfg["va_hi"])) ents = entities2.typed(self.fd, self.defs, movers, self.delta) uniq = {} for off, nm, pos, sp in ents: uniq.setdefault(off, (off, nm, pos, sp)) self.ents = list(uniq.values()) return self.ents def pos(self, off): b = os.pread(self.fd, 12, off) return np.array(struct.unpack(">3f", b)) if len(b) == 12 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)): return None if np.max(np.abs(M @ M.T - np.eye(3))) > 5e-3: return None return M def clamp(v, lo=-1.0, hi=1.0): return max(lo, min(hi, v)) def body_rate(Mprev, M, dt): if Mprev is None or M is None or dt <= 0: return np.zeros(3) D = Mprev @ M.T w = np.array([D[2, 1] - D[1, 2], D[0, 2] - D[2, 0], D[1, 0] - D[0, 1]]) / 2.0 return w / dt class Autopilot: KP, KD = 2.2, 0.45 FIRE_CONE = math.radians(10) FIRE_RANGE = 6000.0 def __init__(self, world, pad, dry=False): self.W = world self.pad = pad self.dry = dry self.prevM = None self.firing = False def me(self): for off, nm, pos, sp in self.W.ents: if "Player" in nm: return off, nm return None, None def step(self, dt): off, nm = self.me() if off is None: return "no-player" p = self.W.pos(off) M = self.W.rot(off) if p is None or M is None: return "no-state" fwd = M[self.W.fwd_row] * self.W.fwd_sign rows = [M[i] for i in range(3)] right = rows[(self.W.fwd_row + 1) % 3] up = np.cross(fwd, right) w = body_rate(self.prevM, M, dt) self.prevM = M # nearest hostile, preferring what is already in front best, bestscore = None, 1e18 for eoff, enm, epos, esp in self.W.ents: if unit_faction(enm) != "ADAN": continue q = self.W.pos(eoff) if q is None: continue v = q - p d = float(np.linalg.norm(v)) if d < 1e-3: continue # Prefer targets we can actually bring the nose onto. Nearest-first # picks whatever is closest even at 90 deg off the nose, and closing # on an off-boresight target only raises the bearing rate -- which is # exactly the lag that kept the first run outside its firing cone. ahead = float(v @ fwd) / d ang = math.acos(max(-1.0, min(1.0, ahead))) score = d * (1.0 + 3.0 * (ang / math.pi) ** 2) if score < bestscore: best, bestscore = (eoff, enm, q, v, d), score if best is None: if not self.dry: self.pad.axis("LX", 0.0) self.pad.axis("LY", 0.0) return "no-hostiles" eoff, enm, q, v, d = best lx = float(v @ right) ly = float(v @ up) lz = float(v @ fwd) yaw = math.atan2(lx, lz if abs(lz) > 1e-3 else 1e-3) pitch = math.atan2(ly, lz if abs(lz) > 1e-3 else 1e-3) if lz < 0: yaw = math.copysign(math.pi / 2, lx if lx else 1.0) sx = clamp(self.KP * yaw - self.KD * float(w @ up)) sy = clamp(-(self.KP * pitch - self.KD * float(w @ right))) aligned = abs(yaw) < self.FIRE_CONE and abs(pitch) < self.FIRE_CONE fire = aligned and d < self.FIRE_RANGE 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 self.shots = getattr(self, "shots", 0) + (1 if fire else 0) return (f"tgt={enm[3:24]:<22} d={d:8.0f} yaw={math.degrees(yaw):+6.1f} " f"pit={math.degrees(pitch):+6.1f} stick=({sx:+.2f},{sy:+.2f}) fire={int(fire)}") def run(self, secs, hz=10.0): t0 = time.time() last = t0 last_scan = 0.0 while time.time() - t0 < secs: t = time.time() if t - last_scan > 2.5: ents = self.W.rescan() last_scan = t c = Counter(unit_faction(e[1]) for e in ents) print(f"[{t-t0:6.1f}] rescan: {len(ents)} entities {dict(c)}", flush=True) print(f"[{t-t0:6.1f}] {self.step(t - last)}", flush=True) last = t 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 60.0 dry = "--dry" in sys.argv W = World(cfg) W.rescan() ap = Autopilot(W, Pad(), dry=dry) ap.run(secs) if __name__ == "__main__": main()