pilot: target commitment takes kills from 2 to 9; and the target pointer does not exist

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.
This commit is contained in:
claude-re
2026-07-30 19:19:23 +00:00
parent 95ac545b2b
commit 9f41fe08e9
4 changed files with 251 additions and 1 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

View File

@@ -177,6 +177,43 @@ that the game expects a *lock* — holding the target in the reticle before laun
an unlocked launch is wasted. Reading the lock state (or the lock timer) out of RAM is
the next step, and it is the same anchoring trick as everything else here.
## Target commitment — the change that actually moved kills
The pilot re-scored every contact every tick, so the nose chased whichever fighter was
momentarily best-scoring and the aim error wandered 1040° through a pass. Since a
missile lock is time-on-target, constant switching is the one thing guaranteed to
prevent a kill. **Commitment**: stay on the chosen contact until it dies, gets beyond
6000, sits >90° off the nose for 2.5 s, or 14 s elapse.
Nothing else changed — same guns, same ballistics, same escort weighting, same missile
cadence:
| run | kills (`WARPLANES`) | missiles | hostiles |
|---|---|---|---|
| gun-only × 5 | **0000** | 0 | 147→118 … 134→166 |
| + guided missiles | **0002** | 98 | 134→104 |
| + **target commitment** | **0009** | 101 | **134→97** |
4.5× the kills for the same ammunition, and the largest fall in hostile population of
any run. Our own hull finished untouched at 1500/1500.
**The escort is still not saved** — the ACROPOLIS finished at 76.6 % — so this improves
lethality, not the mission outcome, and the two should not be conflated.
### Negative result: the selected target is not a raw entity pointer
Worth recording so it is not re-attempted. `target_probe.py` looked for the selection
three ways: (1) every word in a ±0x1400 window of the player object that points at a
live entity — **none**; (2) every word in *all* of RAM holding an entity pointer, tapped
through each button — only thread-stack slots (`0x70xx_xxxx`) churned, which is frame
noise, not selection; (3) a delta tally over all 150 entities looking for a repeated
offset holding a pointer to *another* entity, the same trick that found the definition
pointer at `+0x130` — **zero candidates**.
So neither the player nor the AI ships keep a raw pointer to their target near their
transform. The selection is a handle, an index, or lives in a targeting subsystem
outside the entity object.
## Reimplementation notes
- Defeat conditions for an escort stage are readable as: protected-asset

View File

@@ -119,6 +119,16 @@ class Pilot:
HULL_CLEARANCE = 800.0 # clearance ON TOP of a capital ship's own radius
CLOSING_WEIGHT = 4.0 # s of closing-rate credit when ranking attackers
MY_RANGE_WEIGHT = 0.35 # how much our own distance discounts a target
# --- target commitment ---
# The loop re-scored every contact every tick, so the nose chased whichever
# fighter was momentarily best and the aim error wandered 10-40 deg through
# a pass. A missile lock is time-on-target (the OPTIONS screen calls it
# Padlock), so switching targets constantly is the one thing guaranteed to
# prevent a kill. Stay on the chosen contact until it dies, leaves range, or
# sits behind us long enough that chasing it is pointless.
COMMIT_MAX = 14.0 # s before we are allowed to reconsider anyway
COMMIT_DROP = 6000.0 # ...or it gets this far away
COMMIT_BEHIND = 2.5 # ...or stays >90 deg off the nose this long
def __init__(self, W, pad, dry=False, log=sys.stdout):
self.W = W
@@ -145,6 +155,9 @@ class Pilot:
self.missile_down = False
self.missile_t = -1e9
self.missiles = 0
self.commit_off = None # entity we are committed to
self.commit_t = -1e9
self.behind_since = None
def f32(self, off):
b = os.pread(self.W.fd, 4, off)
@@ -226,6 +239,33 @@ class Pilot:
return self.CONE_MAX
return min(self.CONE_MAX, max(self.FIRE_CONE, math.atan2(r + SHELL_RADIUS, d)))
def pick_committed(self, t, me_p, me_v, fwd, hos):
"""pick(), but stay on the same contact long enough to actually kill it."""
cur = None
for off, nm, p, v, r, hard in hos:
if off == self.commit_off and not hard:
cur = (off, nm, p, v, r)
break
if cur is not None:
off, nm, p, v, r = cur
rel = p - me_p
d = float(np.linalg.norm(rel))
behind = ang(rel, fwd) > math.pi / 2
self.behind_since = (self.behind_since if behind else None) or (t if behind else None)
stale = (t - self.commit_t > self.COMMIT_MAX
or d > self.COMMIT_DROP
or (self.behind_since is not None
and t - self.behind_since > self.COMMIT_BEHIND))
if not stale:
lead = self.lead_point(p, v, d)
return (off, nm, lead, lead - me_p, d, r)
# commit to a fresh one
tgt = self.pick(me_p, me_v, fwd, hos)
self.commit_off = tgt[0] if tgt else None
self.commit_t = t
self.behind_since = None
return tgt
def pick(self, me_p, me_v, fwd, hos):
"""Nearest *fighter*, weighted by how far off the nose it is."""
best, bestscore = None, 1e18
@@ -343,7 +383,7 @@ class Pilot:
else:
self.mode = "ENGAGE"
tgt = self.pick(me_p, me_v, fwd, hos)
tgt = self.pick_committed(t, me_p, me_v, fwd, hos)
push, worst = self.av.avoidance(me_p, me_v, me_r, ents, me_off)
if self.mode == "EVADE":

View File

@@ -0,0 +1,173 @@
#!/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()