The loop re-scored every contact every tick, so the nose chased whichever fighter scored best that instant and aim error wandered 10-40 deg through a pass. A missile lock is time-on-target, so constant switching is the one thing guaranteed to prevent a kill. Commit to a contact until it dies, passes 6000, sits >90 deg off the nose for 2.5 s, or 14 s elapse. Same guns, same ballistics, same escort weighting, same missile cadence: kills 0000 (five gun-only runs) -> 0002 (missiles) -> 0009 (commitment), with 101 missiles vs 98, and the largest hostile-population fall of any run (134->97). Own hull untouched. The ACROPOLIS still ended at 76.6%, so this is lethality, not the mission outcome. Also records a negative result so it is not re-attempted: the selected target is NOT a raw entity pointer. Three searches came up empty — a +-0x1400 window of the player object, a full-RAM sweep of every entity-pointer word tapped through each button (only thread-stack slots churn, which is frame noise), and a delta tally over all 150 entities of the kind that found the definition pointer at +0x130. The selection must be a handle, an index, or in a subsystem outside the entity object.
174 lines
6.9 KiB
Python
174 lines
6.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Find the SELECTED TARGET inside the player object, and the input that changes it.
|
|
|
|
The OPTIONS key-config screen lists `Change Target` and `Padlock Mode Toggle` as
|
|
real bindable actions (docs/re/flight-controls-runtime.md), but a button sweep
|
|
that watched the *ammo counters* could not see either — neither action fires a
|
|
weapon. Screenshots of the reticle were no better: the view keeps moving, so
|
|
"did the selection change" is not legible frame to frame.
|
|
|
|
Guest memory is legible. If the craft holds a selected target, it holds a
|
|
**pointer to that target's object**, and every live entity's address is already
|
|
known from the entity scan. So:
|
|
|
|
1. enumerate live entities and their addresses;
|
|
2. read a window of the player object and keep every word that points at one
|
|
of them (allowing a small fixed delta, since a pointer to an object's base
|
|
is not a pointer to its transform);
|
|
3. tap each candidate input and see which of those words switches to a
|
|
*different* entity.
|
|
|
|
The word that follows the button is the selection, and the button that moves it
|
|
is `Change Target`. Both answers come out of the same run, and neither depends
|
|
on reading pixels.
|
|
|
|
Usage: target_probe.py <config.json> [seconds_per_button]
|
|
"""
|
|
import json
|
|
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 navigator # noqa: E402
|
|
from flight_probe import Pad # noqa: E402
|
|
|
|
BACK, FWD = 0x400, 0x1000
|
|
MAX_DELTA = 0x400 # how far below its transform an object's base may sit
|
|
BUTTONS = ["LB", "X", "B", "A", "LS", "RS", "BACK", "START", "Y", "RB"]
|
|
|
|
|
|
def main():
|
|
cfg = json.load(open(sys.argv[1]))
|
|
dwell = float(sys.argv[2]) if len(sys.argv) > 2 else 1.2
|
|
W = navigator.World(cfg)
|
|
ents = W.scan()
|
|
me = [(off, va) for off, va in ents if "Player" in W.defs[va]]
|
|
if not me:
|
|
sys.exit("player entity not found — not in flight?")
|
|
me_off = me[0][0]
|
|
print(f"# player object at {gmem.primary_va(me_off):#010x}, "
|
|
f"{len(ents)} live entities")
|
|
|
|
# every address that could be a pointer to some entity
|
|
ptr_map = {}
|
|
for off, va in ents:
|
|
pos_va = gmem.primary_va(off)
|
|
if pos_va is None:
|
|
continue
|
|
for d in range(0, MAX_DELTA, 4):
|
|
ptr_map.setdefault(pos_va - d, (W.defs[va], d, off))
|
|
|
|
def window():
|
|
b = os.pread(W.fd, BACK + FWD, me_off - BACK)
|
|
return np.frombuffer(b, dtype=">u4").copy()
|
|
|
|
# ---- global mode: the selection need not live in the player object at all.
|
|
# Scan every mapped extent for words that hold an entity pointer, then see
|
|
# which of THOSE follow a button. A word that is an entity pointer before
|
|
# AND after, pointing at a different entity, is a selection by construction.
|
|
keys = np.array(sorted(ptr_map), dtype=np.uint32)
|
|
|
|
def global_ptrs():
|
|
out = {}
|
|
for a0, b0 in gmem.extents(W.fd, W.size):
|
|
n = (b0 - a0) // 4 * 4
|
|
if n < 64:
|
|
continue
|
|
arr = np.frombuffer(os.pread(W.fd, n, a0), dtype=">u4").astype(np.uint32)
|
|
idx = np.flatnonzero(np.isin(arr, keys))
|
|
for k in idx:
|
|
out[a0 + int(k) * 4] = int(arr[k])
|
|
return out
|
|
|
|
# ---- delta mode: WHERE does an entity keep its target?
|
|
# The AI ships clearly hold pointers to other entities, so the field is a
|
|
# fixed offset from the transform — exactly the situation entities2.py
|
|
# solved for the definition pointer. Tally, over many entities, the delta at
|
|
# which a word points at *another* entity; the offset that repeats is the
|
|
# field, and reading it on the PLAYER gives our selected target.
|
|
if "--delta" in sys.argv:
|
|
from collections import Counter
|
|
votes, examples = Counter(), {}
|
|
for off, va in ents:
|
|
blob = os.pread(W.fd, 0x1000, max(0, off - 0x800))
|
|
arr = np.frombuffer(blob[:len(blob) // 4 * 4], dtype=">u4")
|
|
for i, w in enumerate(arr):
|
|
hit = ptr_map.get(int(w))
|
|
if hit and hit[2] != off:
|
|
d = i * 4 - 0x800
|
|
votes[d] += 1
|
|
examples.setdefault(d, (W.defs[va], hit[0]))
|
|
print(f"# target-pointer delta candidates over {len(ents)} entities:")
|
|
for d, n in votes.most_common(10):
|
|
src, dst = examples[d]
|
|
print(f" pos{d:+#07x} seen {n:4d} e.g. {src[:26]:<26} -> {dst}")
|
|
best = votes.most_common(1)
|
|
if best:
|
|
d = best[0][0]
|
|
pw = struct.unpack(">I", os.pread(W.fd, 4, me_off + d))[0]
|
|
hit = ptr_map.get(pw)
|
|
print(f"\n# PLAYER at that delta: {pw:#010x} -> "
|
|
f"{hit[0] if hit else 'not an entity pointer'}")
|
|
return
|
|
|
|
a = window()
|
|
cands = []
|
|
for i, w in enumerate(a):
|
|
hit = ptr_map.get(int(w))
|
|
if hit and hit[2] != me_off: # a self-pointer is not a target
|
|
cands.append((i, hit[0], hit[1]))
|
|
print(f"# {len(cands)} word(s) in the player object point at a live entity")
|
|
for i, nm, d in cands[:30]:
|
|
print(f" pos{i * 4 - BACK:+#07x} -> {nm} (entity_va - {d:#x})")
|
|
if not cands:
|
|
print("# none — widen BACK/FWD or MAX_DELTA, or nothing is selected")
|
|
|
|
# ---- which input moves the selection?
|
|
pad = Pad()
|
|
if "--global" in sys.argv:
|
|
print("\n# GLOBAL scan: every word in RAM that holds an entity pointer")
|
|
g0 = global_ptrs()
|
|
print(f"# {len(g0)} entity-pointer words in RAM")
|
|
for btn in BUTTONS:
|
|
pad.f.write(f"tap {btn} 200\n")
|
|
time.sleep(dwell)
|
|
g1 = global_ptrs()
|
|
moved = [(o, g0[o], g1[o]) for o in g0
|
|
if o in g1 and g1[o] != g0[o]]
|
|
named = []
|
|
for o, v0, v1 in moved[:4]:
|
|
n0 = ptr_map.get(v0, ("?",))[0]
|
|
n1 = ptr_map.get(v1, ("?",))[0]
|
|
named.append(f"{gmem.primary_va(o):#010x} {n0[:16]}->{n1[:16]}")
|
|
print(f" [{btn:<5}] {len(moved):4d} switched " + " ".join(named),
|
|
flush=True)
|
|
g0 = g1
|
|
pad.reset()
|
|
return
|
|
print("\n# tapping each input; a word that switches to a DIFFERENT entity is"
|
|
" the selection")
|
|
for btn in BUTTONS:
|
|
before = window()
|
|
pad.f.write(f"tap {btn} 200\n")
|
|
time.sleep(dwell)
|
|
after = window()
|
|
moved = []
|
|
for i, nm, d in cands:
|
|
if before[i] == after[i]:
|
|
continue
|
|
hit = ptr_map.get(int(after[i]))
|
|
moved.append((i, nm, hit[0] if hit else f"{int(after[i]):#010x}"))
|
|
tag = " ".join(f"pos{i * 4 - BACK:+#x} {o[:18]}->{n[:18]}"
|
|
for i, o, n in moved[:3])
|
|
print(f" [{btn:<5}] {len(moved):2d} changed {tag}", flush=True)
|
|
pad.reset()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|