#!/usr/bin/env python3 """Learn which body axis each stick drives, and which button fires. The first autopilot run nulled yaw but held a steady ~27 deg pitch error, which is the signature of a wrong pitch axis or sign — guessing `right = row0` and `up = fwd x right` assumes a handedness the game need not share. So measure it: hold each stick axis, read the craft's body angular velocity from consecutive orientation matrices, and see which body axis actually responds and in which direction. The fire button is found the same way — by consequence, not assumption: the nose ammo counter in the player's object must go down. Usage: calibrate.py [out.json] """ import json import math import os import struct import sys import time 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 def player_off(w, fd, size, delta): defs = entities2.definitions(w) for _ in range(6): mv = entities2.moving(fd, size, dt=0.5) ents = entities2.typed(fd, defs, mv, delta) for off, nm, pos, sp in ents: if "Player" in nm: return off time.sleep(0.5) return None def read_rot(fd, off, rot_delta, stride): n = stride * 2 + 12 b = os.pread(fd, n, off + rot_delta) if len(b) < n: return None M = np.array([struct.unpack_from(">3f", b, 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 mean_body_rate(fd, off, cfg, secs=2.0, hz=8.0): """Mean angular velocity expressed in the craft's own axes.""" prev, t_prev = None, None acc, n = np.zeros(3), 0 end = time.time() + secs while time.time() < end: t = time.time() M = read_rot(fd, off, cfg["rot_delta"], cfg.get("rot_stride", 12)) if M is not None and prev is not None: dt = t - t_prev D = prev @ 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 if dt > 0 and np.linalg.norm(w) < 0.5: # into body axes acc += M @ (w / dt) n += 1 if M is not None: prev, t_prev = M, t time.sleep(max(0, 1.0 / hz - (time.time() - t))) return acc / max(n, 1) def main(): cfg = json.load(open(sys.argv[1])) out = sys.argv[2] if len(sys.argv) > 2 else sys.argv[1] w = gworld.World() fd, size = w.fd, w.size off = player_off(w, fd, size, cfg["def_delta"]) if off is None: sys.exit("player not found") print(f"# player pos va {gmem.primary_va(off):#010x}") pad = Pad() res = {} for axis, name in (("LX", "yaw"), ("LY", "pitch")): pad.reset() time.sleep(1.0) base = mean_body_rate(fd, off, cfg, 1.2) pad.axis(axis, 0.9) time.sleep(0.4) wpos = mean_body_rate(fd, off, cfg, 2.0) pad.axis(axis, 0.0) time.sleep(1.2) d = wpos - base k = int(np.argmax(np.abs(d))) res[name] = {"axis": k, "sign": 1 if d[k] > 0 else -1, "mag": float(abs(d[k]))} print(f"# {axis} (+0.9) -> body rate {d.round(3)} => {name} axis {k} " f"sign {res[name]['sign']:+d}") pad.reset() # ---- fire button: the nose ammo counter must fall ----------------------- blob = os.pread(fd, 0x1000, max(0, off - 0x800)) ammo_offs = [] for i in range(0, len(blob) - 3, 4): (u,) = struct.unpack_from(">I", blob, i) (f,) = struct.unpack_from(">f", blob, i) if u == 6000 or (math.isfinite(f) and abs(f - 6000.0) < 0.5): ammo_offs.append((max(0, off - 0x800) + i) - off) print(f"# ammo-like words (==6000) near the player: " f"{[hex(o) for o in ammo_offs] or 'none'}") def ammo(): vals = [] for d in ammo_offs: b = os.pread(fd, 4, off + d) if len(b) == 4: vals.append(struct.unpack(">I", b)[0]) return vals fire_btn = None if ammo_offs: for btn in ("RB", "LB", "A", "B", "X", "Y"): before = ammo() pad.press(btn) time.sleep(1.2) pad.release(btn) time.sleep(0.5) after = ammo() drop = [a - b for a, b in zip(before, after)] print(f"# {btn}: ammo delta {drop}") if any(x > 0 for x in drop): fire_btn = btn break for trig in ("RT", "LT"): if fire_btn: break before = ammo() pad.trig(trig, 1.0) time.sleep(1.2) pad.trig(trig, 0.0) time.sleep(0.5) after = ammo() drop = [a - b for a, b in zip(before, after)] print(f"# {trig}: ammo delta {drop}") if any(x > 0 for x in drop): fire_btn = trig pad.reset() cfg["yaw_axis"] = res["yaw"]["axis"] cfg["yaw_sign"] = res["yaw"]["sign"] cfg["pitch_axis"] = res["pitch"]["axis"] cfg["pitch_sign"] = res["pitch"]["sign"] cfg["fire"] = fire_btn cfg["ammo_offs"] = ammo_offs json.dump(cfg, open(out, "w"), indent=1) print("\n" + json.dumps(cfg, indent=1)) if __name__ == "__main__": main()