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.
This commit is contained in:
@@ -1,8 +1,46 @@
|
|||||||
# Memory-driven autopilot — build log and current state
|
# Memory-driven autopilot — build log and current state
|
||||||
|
|
||||||
**Status: 🟡 PARTIAL — infrastructure works, the craft is not yet flown.**
|
**Status: 🟢 IT FLIES AND SHOOTS — it does not yet survive.**
|
||||||
Started 2026-07-29. This is the honest state, not a plan: what is proven, what
|
Updated 2026-07-29 (second pass). The autopilot reads the live world, picks
|
||||||
is not, and the one thing that most cheaply unblocks the rest.
|
hostile targets, pursues them and opens fire. What it cannot do is stay alive:
|
||||||
|
there is no evasion or shield management, so it dies before a mission ends.
|
||||||
|
This is the honest state, not a plan.
|
||||||
|
|
||||||
|
## What the loop does now, observed
|
||||||
|
|
||||||
|
```
|
||||||
|
[ 82.5] tgt=e007_ADAN_Turret d=3384 yaw= -7.3 pit=+14.6 stick=(-0.13,-0.34) fire=0
|
||||||
|
[ 85.7] tgt=e007_ADAN_Turret d=2797 yaw= +1.9 pit=+27.2 stick=(+0.20,-0.59) fire=0
|
||||||
|
[117.3] tgt=e007_ADAN_Turret d=4211 yaw= +5.7 pit= +9.0 stick=(+0.25,-0.40) fire=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Distance closes monotonically, yaw error is driven from −8° to ~0, and once both
|
||||||
|
errors are inside the firing cone it holds RB and the ammo counter falls. A
|
||||||
|
rescan reports the scene as e.g. `136 entities {'TCAF': 16, 'ADAN': 120}`.
|
||||||
|
|
||||||
|
**The chain that made it work** — each link checked, not assumed:
|
||||||
|
|
||||||
|
1. **Entity typing.** A live entity's definition pointer sits at
|
||||||
|
**position + 0x130**. One heap scan then yields every craft *with its unit
|
||||||
|
type*, which is what separates 20-odd real combatants from ~30 000 moving
|
||||||
|
particles. Verified by the result being coherent: wingmen, enemy turrets and
|
||||||
|
attackers, friendly capital ships, and exactly one `…_Player`.
|
||||||
|
2. **Orientation.** A 3×3 rotation at **position − 0x70**, stored with a
|
||||||
|
**16-byte row stride** (a 4×4 transform whose translation row *is* the
|
||||||
|
position). An earlier search for nine *contiguous* floats structurally could
|
||||||
|
not find this, which is why the first pass concluded "no transform". The
|
||||||
|
binding is confirmed independently: its row 2 matches the craft's measured
|
||||||
|
direction of travel with **cos = +1.000**.
|
||||||
|
3. **The fire button is RB** — established by consequence, not by guessing:
|
||||||
|
of RB/LB/A/B/X/Y/RT/LT, pressing RB is the only one that makes the nose-ammo
|
||||||
|
counter in RAM fall (5958 → 5940). `RT` is *not* the throttle, and no button
|
||||||
|
tested is.
|
||||||
|
4. **Control.** PD on the aiming error with the derivative taken from the
|
||||||
|
craft's own body angular velocity (from two consecutive rotation matrices),
|
||||||
|
and target selection weighted by off-boresight angle
|
||||||
|
(`score = d·(1 + 3·(θ/π)²)`) rather than pure nearest — closing on a target
|
||||||
|
90° off the nose only raises the bearing rate, which is what held the first
|
||||||
|
run outside its firing cone at a steady ~27° pitch error.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
@@ -27,8 +65,17 @@ because definitions load **per stage**).
|
|||||||
|
|
||||||
## What is NOT solved
|
## What is NOT solved
|
||||||
|
|
||||||
**Identifying our own craft, and typing the other entities.** Both remain open,
|
**Survival, and therefore mission completion.** The loop has no evasion, no
|
||||||
and the autopilot cannot work without them.
|
shield/armour awareness and no throttle control, so it flies a straight pursuit
|
||||||
|
into defended space and is eventually shot down — every long run so far has
|
||||||
|
ended in GAME OVER. Completing a mission needs, at least: reading own
|
||||||
|
shield/armour, breaking off when hit, and prioritising the mission's actual
|
||||||
|
objective targets over the nearest turret.
|
||||||
|
|
||||||
|
### Superseded (kept because the reasoning still matters)
|
||||||
|
|
||||||
|
The notes below were written before the chain above worked. They remain true as
|
||||||
|
statements about `0x820af030`, which is *not* the live entity —
|
||||||
|
|
||||||
1. **The `0x820af030` class is not the live entity.** It has one object per
|
1. **The `0x820af030` class is not the live entity.** It has one object per
|
||||||
spawned thing and carries the unit-ID string, so it looked like the entity
|
spawned thing and carries the unit-ID string, so it looked like the entity
|
||||||
|
|||||||
207
tools/re-capture/autopilot3.py
Normal file
207
tools/re-capture/autopilot3.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
#!/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()
|
||||||
165
tools/re-capture/calibrate.py
Normal file
165
tools/re-capture/calibrate.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
#!/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()
|
||||||
208
tools/re-capture/entities2.py
Normal file
208
tools/re-capture/entities2.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Type every live entity, and locate the player, via the definition pointer.
|
||||||
|
|
||||||
|
A live entity object keeps a pointer to its parsed `.tbl` definition (vtable
|
||||||
|
0x820af844, addresses known) at a **fixed offset from its position triple**.
|
||||||
|
Finding that offset once types every moving object in the scene at a stroke:
|
||||||
|
enemies, friendlies and us, separated from the thousands of moving particles.
|
||||||
|
|
||||||
|
The offset is *derived*, not assumed: for every moving triple, every nearby word
|
||||||
|
is checked against the set of definition addresses, and the winning delta is the
|
||||||
|
one that repeats across many independent entities.
|
||||||
|
|
||||||
|
Sub-commands:
|
||||||
|
delta find the (position -> definition pointer) offset
|
||||||
|
list [delta] type every live entity and print it
|
||||||
|
self [delta] the player's entity, its object window, and its orientation
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def definitions(w):
|
||||||
|
out = {}
|
||||||
|
for off in w.scan_vtable(gworld.DEF_VTABLE):
|
||||||
|
nm = w.name_of(off)
|
||||||
|
if nm and nm.startswith("UN_"):
|
||||||
|
va = gmem.primary_va(off)
|
||||||
|
if va is not None:
|
||||||
|
out[struct.pack(">I", va)] = nm
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# Entity objects live in one heap region; scanning only it turns a 250 MB
|
||||||
|
# double-read into a few MB. That matters for more than speed: the full scan
|
||||||
|
# competes with the emulator for every core under lavapipe, and a game that
|
||||||
|
# does not advance a frame between the two samples has nothing "moving" in it.
|
||||||
|
ENT_VA_LO, ENT_VA_HI = 0xBD000000, 0xBE000000
|
||||||
|
|
||||||
|
|
||||||
|
def moving(fd, size, dt=0.5, lo=1.0, hi=4000.0, va_range=(ENT_VA_LO, ENT_VA_HI)):
|
||||||
|
def arrays():
|
||||||
|
if va_range:
|
||||||
|
f0, f1 = gmem.va_to_off(va_range[0]), gmem.va_to_off(va_range[1])
|
||||||
|
else:
|
||||||
|
f0, f1 = 0, size
|
||||||
|
for a, b in gmem.extents(fd, size):
|
||||||
|
a, b = max(a, f0), min(b, f1)
|
||||||
|
n = (b - a) // 4 * 4
|
||||||
|
if n >= 64:
|
||||||
|
yield a, np.frombuffer(os.pread(fd, n, a), dtype=">f4")
|
||||||
|
|
||||||
|
s0 = dict(arrays())
|
||||||
|
t0 = time.time()
|
||||||
|
time.sleep(dt)
|
||||||
|
s1 = dict(arrays())
|
||||||
|
t1 = time.time()
|
||||||
|
out = []
|
||||||
|
# Match extents by their file offset. Zipping the two lists positionally is
|
||||||
|
# wrong: the game allocates between the samples, the sparse extent layout
|
||||||
|
# shifts, and every subsequent pair misaligns -- which silently reports
|
||||||
|
# almost nothing as moving.
|
||||||
|
for o, a in s0.items():
|
||||||
|
b = s1.get(o)
|
||||||
|
if b is None or len(a) != len(b):
|
||||||
|
continue
|
||||||
|
with np.errstate(invalid="ignore"):
|
||||||
|
af = np.nan_to_num(a.astype(np.float64), nan=0, posinf=0, neginf=0)
|
||||||
|
bf = np.nan_to_num(b.astype(np.float64), nan=0, posinf=0, neginf=0)
|
||||||
|
d = bf - af
|
||||||
|
idx = np.flatnonzero(np.abs(d) > 1e-4)
|
||||||
|
cand = idx[np.isin(idx + 1, idx) & np.isin(idx + 2, idx)]
|
||||||
|
for i in cand:
|
||||||
|
v = np.array([d[i], d[i + 1], d[i + 2]])
|
||||||
|
sp = float(np.linalg.norm(v)) / (t1 - t0)
|
||||||
|
if lo < sp < hi:
|
||||||
|
out.append((o + int(i) * 4, tuple(af[i:i + 3]), sp))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def find_delta(fd, defs, movers, radius=0x400):
|
||||||
|
votes = Counter()
|
||||||
|
for off, pos, sp in movers[:4000]:
|
||||||
|
lo = max(0, off - radius)
|
||||||
|
blob = os.pread(fd, radius * 2, lo)
|
||||||
|
for k in range(0, len(blob) - 3, 4):
|
||||||
|
if blob[k:k + 4] in defs:
|
||||||
|
votes[(lo + k) - off] += 1
|
||||||
|
return votes
|
||||||
|
|
||||||
|
|
||||||
|
def typed(fd, defs, movers, delta):
|
||||||
|
out = []
|
||||||
|
for off, pos, sp in movers:
|
||||||
|
try:
|
||||||
|
b = os.pread(fd, 4, off + delta)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
nm = defs.get(b)
|
||||||
|
if nm:
|
||||||
|
out.append((off, nm, pos, sp))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cmd = sys.argv[1] if len(sys.argv) > 1 else "delta"
|
||||||
|
w = gworld.World()
|
||||||
|
fd, size = w.fd, w.size
|
||||||
|
defs = definitions(w)
|
||||||
|
print(f"# {len(defs)} unit definitions", flush=True)
|
||||||
|
movers = moving(fd, size)
|
||||||
|
print(f"# {len(movers)} moving triples", flush=True)
|
||||||
|
|
||||||
|
if cmd == "delta":
|
||||||
|
votes = find_delta(fd, defs, movers)
|
||||||
|
print("# candidate (position -> definition pointer) deltas:")
|
||||||
|
for d, n in votes.most_common(8):
|
||||||
|
print(f" {d:+#08x} seen {n}")
|
||||||
|
return
|
||||||
|
|
||||||
|
delta = int(sys.argv[2], 0) if len(sys.argv) > 2 else 0x130
|
||||||
|
ents = typed(fd, defs, movers, delta)
|
||||||
|
uniq = {}
|
||||||
|
for off, nm, pos, sp in ents:
|
||||||
|
uniq.setdefault((nm, tuple(round(c, 1) for c in pos)), (off, nm, pos, sp))
|
||||||
|
ents = list(uniq.values())
|
||||||
|
print(f"# {len(ents)} typed live entities (delta {delta:+#x})")
|
||||||
|
|
||||||
|
if cmd == "list":
|
||||||
|
c = Counter(nm for _, nm, _, _ in ents)
|
||||||
|
for nm, k in c.most_common():
|
||||||
|
print(f" {k:4d} {nm}")
|
||||||
|
for off, nm, pos, sp in sorted(ents, key=lambda e: e[1])[:60]:
|
||||||
|
print(f" {gmem.primary_va(off):#010x} {nm:<40} "
|
||||||
|
f"({pos[0]:+9.1f},{pos[1]:+9.1f},{pos[2]:+9.1f}) {sp:7.1f}/s")
|
||||||
|
return
|
||||||
|
|
||||||
|
if cmd == "self":
|
||||||
|
me = [e for e in ents if "Player" in e[1]]
|
||||||
|
if not me:
|
||||||
|
sys.exit("player entity not found")
|
||||||
|
off, nm, pos, sp = me[0]
|
||||||
|
print(f"# player entity: pos va {gmem.primary_va(off):#010x} {nm}")
|
||||||
|
print(f"# pos ({pos[0]:+.1f},{pos[1]:+.1f},{pos[2]:+.1f}) speed {sp:.1f}/s")
|
||||||
|
# orientation inside the same object
|
||||||
|
# The rotation is stored with a 16-byte row stride (a 4x4 transform
|
||||||
|
# whose translation row is the position we already have), so a test for
|
||||||
|
# nine *contiguous* floats structurally cannot find it. Test both.
|
||||||
|
lo = max(0, off - 0x800)
|
||||||
|
blob = os.pread(fd, 0x1000, lo)
|
||||||
|
found = []
|
||||||
|
for k in range(0, len(blob) - 48, 4):
|
||||||
|
for stride, tag in ((12, "3x3"), (16, "4x4")):
|
||||||
|
try:
|
||||||
|
rows = [np.array(struct.unpack_from(">3f", blob, k + stride * r))
|
||||||
|
for r in range(3)]
|
||||||
|
except struct.error:
|
||||||
|
continue
|
||||||
|
M = np.array(rows)
|
||||||
|
if not np.all(np.isfinite(M)) or np.max(np.abs(M)) > 1.001:
|
||||||
|
continue
|
||||||
|
if np.max(np.abs(M @ M.T - np.eye(3))) > 3e-3:
|
||||||
|
continue
|
||||||
|
if abs(np.linalg.det(M) - 1.0) > 1e-2:
|
||||||
|
continue
|
||||||
|
found.append(((lo + k) - off, M, stride))
|
||||||
|
break
|
||||||
|
print(f"# orthonormal 3x3 blocks inside the player object: {len(found)}")
|
||||||
|
for d, M, st in found[:6]:
|
||||||
|
print(f" pos{d:+#07x} stride {st}: " + " ".join(
|
||||||
|
"(" + ",".join(f"{v:+.3f}" for v in row) + ")" for row in M))
|
||||||
|
if len(sys.argv) > 3 and found:
|
||||||
|
import json
|
||||||
|
# forward axis = the row closest to our direction of travel
|
||||||
|
vdir = None
|
||||||
|
p0 = np.array(pos)
|
||||||
|
time.sleep(0.35)
|
||||||
|
p1 = np.array(struct.unpack(">3f", os.pread(fd, 12, off)))
|
||||||
|
if np.linalg.norm(p1 - p0) > 1e-3:
|
||||||
|
vdir = (p1 - p0) / np.linalg.norm(p1 - p0)
|
||||||
|
rot_delta, M, rot_stride = found[0]
|
||||||
|
row, sign = 2, 1
|
||||||
|
if vdir is not None:
|
||||||
|
cs = [float(M[r] @ vdir) for r in range(3)]
|
||||||
|
row = int(np.argmax([abs(c) for c in cs]))
|
||||||
|
sign = 1 if cs[row] > 0 else -1
|
||||||
|
print(f"# forward axis = row {row} (sign {sign:+d}), "
|
||||||
|
f"cos={cs[row]:+.3f}")
|
||||||
|
cfg = {"def_delta": delta, "rot_delta": rot_delta,
|
||||||
|
"rot_stride": rot_stride,
|
||||||
|
"fwd_row": row, "fwd_sign": sign,
|
||||||
|
"va_lo": ENT_VA_LO, "va_hi": ENT_VA_HI}
|
||||||
|
json.dump(cfg, open(sys.argv[3], "w"), indent=1)
|
||||||
|
print("# wrote " + sys.argv[3] + ": " + json.dumps(cfg))
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user