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.
166 lines
5.3 KiB
Python
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()
|