Reads the live world out of guest RAM and drives the pad from it. Working: loop-rate memory reads, whole-RAM float scanning with numpy (1270 orthonormal 3x3 blocks in 6.2 s), entity enumeration by unit type (116 live instances in Stage 02), pad control written straight into the vgamepad FIFO (the CLI spawns a process per command and its tap/hold sleep inside the server, so neither is usable in a control loop), unattended mission entry, and the Hangar loadout -- the "Recommended" control is AUTO SELECT, which at 5 % progress is a no-op because only two weapons are developed and both are already mounted. Not working, and the reason the craft is not yet flown: the class 0x820af030 is NOT the live entity. It has one object per spawned thing and carries the unit-ID string, which is why it looked like the entity list, but every one of its 384 words is constant across a 29 s in-flight capture. No transform lives in it or one pointer hop from it. Input correlation (hard left yaw vs hard right, looking for a turn axis that reverses) does find self-like objects at cos = -0.99, but they cluster in what looks like a camera volume rather than the craft, and with no definition pointer near them the trick of learning one entity's layout and applying it to the rest has nothing to anchor on -- so the 33418 moving triples in a firefight cannot be split into enemies, friendlies and bullets, and there is nothing to aim at. Two dead ends are recorded so they are not repeated: RT is not the throttle (the two-state speed scan therefore found nothing), and comparing orientation matrices 2 s apart is outside the small-angle regime, which is what produced "angular velocities" of 30000. Also corrects the claim in unit-struct-runtime.md that 0x820af030 holds live state. The definition class 0x820af844 and every value derived from it are unaffected. autopilot2.py (a PD controller using body angular velocity from consecutive rotation matrices) is committed but has never had a valid config to run against, and is marked as untested.
213 lines
6.7 KiB
Python
213 lines
6.7 KiB
Python
#!/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()
|