Three findings turned the previous dead end into a working loop, each checked
against something independent rather than assumed:
* a live entity's definition pointer sits at position + 0x130, so one heap
scan types every craft in the scene -- which is what separates ~20 real
combatants from ~30000 moving particles. The result is coherent: wingmen,
enemy turrets and attackers, friendly capital ships, one Player.
* orientation is a 3x3 at position - 0x70 stored with a 16-BYTE ROW STRIDE
(a 4x4 whose translation row is the position). The earlier search for nine
contiguous floats could not find this by construction, which is why the
first pass wrongly concluded there was no transform. Confirmed by its row 2
matching measured direction of travel at cos = +1.000.
* RB is the fire button, established by consequence: of RB/LB/A/B/X/Y/RT/LT
it is the only one that makes the nose-ammo counter in RAM fall.
Control is PD on the aiming error with the derivative from body angular
velocity, and target selection weighted by off-boresight angle -- pure
nearest-first kept picking targets 90 deg off the nose, whose bearing rate then
outran the turn rate and held the craft outside its firing cone at a steady 27
deg pitch error.
Observed: distance to target closing monotonically, yaw error driven from -8 deg
to ~0, fire=1 once inside the cone, ammo counter falling.
Not solved: survival. There is no evasion, no shield/armour awareness and no
throttle control, so it flies a straight pursuit into defended space and is
shot down; every long run has ended in GAME OVER. Mission completion needs
those, plus objective-aware target priority.
208 lines
6.8 KiB
Python
208 lines
6.8 KiB
Python
#!/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 <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
|
|
|
|
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()
|