#!/usr/bin/env python3 """Memory-driven autopilot: fly and fight from the game's own world state. The earlier screen-scraping autopilot oscillated because it only ever saw a 2-D arrow on the HUD and had no rate feedback. This one reads the actual transform of every entity out of guest RAM, so it can do proper PD control: the derivative term uses the craft's real body angular velocity, recovered from two consecutive rotation matrices, not a differenced pixel position. world state <- /dev/shm/xenia_memory_* (see gworld.py) control -> the vgamepad FIFO, written directly at loop rate Offsets come from flight_analyze.py and are passed in / stored in offsets.json; nothing here hard-codes a value that was not derived from a capture. """ import json import math import os import struct import sys import time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import gworld # noqa: E402 from flight_probe import Pad # noqa: E402 # --------------------------------------------------------------- 3-D helpers def dot(a, b): return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] def sub(a, b): return (a[0] - b[0], a[1] - b[1], a[2] - b[2]) def norm(a): return math.sqrt(dot(a, a)) def clamp(v, lo=-1.0, hi=1.0): return max(lo, min(hi, v)) def body_rates(R_prev, R, dt): """Angular velocity in body axes from two rotation matrices. R rows are the craft's axes in world space, so R_prev @ R^T is the incremental rotation; its skew part is the rotation vector. """ if dt <= 0: return (0.0, 0.0, 0.0) # M = R_prev * R^T (3x3, row-major tuples) M = [[sum(R_prev[i * 3 + k] * R[j * 3 + k] for k in range(3)) for j in range(3)] for i in range(3)] wx = (M[2][1] - M[1][2]) / 2.0 wy = (M[0][2] - M[2][0]) / 2.0 wz = (M[1][0] - M[0][1]) / 2.0 return (wx / dt, wy / dt, wz / dt) # ------------------------------------------------------------------- reader class Flight: def __init__(self, cfg): self.cfg = cfg self.w = gworld.World() self.pos_off = cfg["pos"] self.rot_off = cfg["rot"] self.hp_off = cfg.get("hp") self.player_name = cfg.get("player", "Player") self.entities = [] self.last_scan = 0.0 def rescan(self): self.entities = self.w.refresh() self.last_scan = time.time() def state(self, off): buf = self.w.read_off(off, gworld.WINDOW) if len(buf) < gworld.WINDOW: return None pos = struct.unpack_from(">3f", buf, self.pos_off) rot = struct.unpack_from(">9f", buf, self.rot_off) if not all(map(math.isfinite, pos + rot)): return None hp = struct.unpack_from(">f", buf, self.hp_off)[0] if self.hp_off else None return {"pos": pos, "rot": rot, "hp": hp} def player(self): for va, off, nm in self.entities: if self.player_name in nm: s = self.state(off) if s: s["name"] = nm return s return None def hostiles(self): """ADAN units are the enemy; the disc IDs encode the faction. `UN_e*` / `UN_be*` = ADAN, `UN_f*` / `UN_bf*` = TCAF (ours). """ out = [] for va, off, nm in self.entities: base = nm[3:] if not (base.startswith("e") or base.startswith("be")): continue s = self.state(off) if s: s["name"] = nm s["off"] = off out.append(s) return out # ---------------------------------------------------------------- controller class Autopilot: KP_YAW, KD_YAW = 1.6, 0.35 KP_PITCH, KD_PITCH = 1.6, 0.35 FIRE_CONE = math.radians(6) FIRE_RANGE = 4000.0 def __init__(self, flight, pad, log=sys.stdout): self.f = flight self.pad = pad self.log = log self.prev_rot = None self.prev_t = None self.target = None self.firing = False def pick_target(self, me): hs = self.f.hostiles() if not hs: return None # nearest, mildly preferring what is already ahead of us def score(h): v = sub(h["pos"], me["pos"]) d = norm(v) or 1.0 fwd = me["rot"][6:9] ahead = dot(v, fwd) / d return d * (1.0 if ahead > 0 else 2.0) return min(hs, key=score) def step(self, dt): me = self.f.player() if not me: return "no-player" R = me["rot"] w = body_rates(self.prev_rot, R, dt) if self.prev_rot else (0, 0, 0) self.prev_rot = R tgt = self.pick_target(me) if not tgt: self.pad.axis("LX", 0.0) self.pad.axis("LY", 0.0) self.pad.trig("RT", 0.6) return "no-target" v = sub(tgt["pos"], me["pos"]) dist = norm(v) or 1.0 # into body axes: rows are right / up / forward lx = dot(R[0:3], v) ly = dot(R[3:6], v) lz = dot(R[6:9], v) yaw_err = math.atan2(lx, lz if lz > 1e-3 else 1e-3) pitch_err = math.atan2(ly, lz if lz > 1e-3 else 1e-3) if lz < 0: # behind us: turn the short way, hard yaw_err = math.copysign(math.pi / 2, lx if lx else 1.0) stick_x = clamp(self.KP_YAW * yaw_err - self.KD_YAW * w[1]) stick_y = clamp(-(self.KP_PITCH * pitch_err - self.KD_PITCH * w[0])) self.pad.axis("LX", stick_x) self.pad.axis("LY", stick_y) self.pad.trig("RT", 1.0 if dist > 1500 else 0.3) aligned = abs(yaw_err) < self.FIRE_CONE and abs(pitch_err) < self.FIRE_CONE want_fire = aligned and dist < self.FIRE_RANGE if want_fire != self.firing: (self.pad.press if want_fire else self.pad.release)(self.f.cfg["fire_btn"]) self.firing = want_fire return (f"tgt={tgt['name'][3:20]:<18} d={dist:8.0f} yaw={math.degrees(yaw_err):+6.1f} " f"pit={math.degrees(pitch_err):+6.1f} stick=({stick_x:+.2f},{stick_y:+.2f}) " f"fire={int(self.firing)} hp={me['hp']}") def run(self, seconds, hz=15.0): self.f.rescan() t0 = time.time() last = t0 while time.time() - t0 < seconds: t = time.time() if t - self.f.last_scan > 3.0: self.f.rescan() msg = self.step(t - last) last = t print(f"[{t-t0:6.1f}] {msg}", file=self.log, flush=True) time.sleep(max(0, 1.0 / hz - (time.time() - t))) 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 ap = Autopilot(Flight(cfg), Pad()) ap.run(secs) if __name__ == "__main__": main()