Files
Syplheed-Reborn/tools/re-capture/calibrate.py
Claude (auto-RE) 4623387f6c re: the memory-driven autopilot now flies, tracks and shoots
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.
2026-07-29 19:32:37 +00:00

166 lines
5.3 KiB
Python

#!/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 <config.json> [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()