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:
BIN
docs/re/captures/kills-target-commitment.png
Normal file
BIN
docs/re/captures/kills-target-commitment.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
@@ -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
|
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.
|
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 10–40° 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
|
## Reimplementation notes
|
||||||
|
|
||||||
- Defeat conditions for an escort stage are readable as: protected-asset
|
- Defeat conditions for an escort stage are readable as: protected-asset
|
||||||
|
|||||||
@@ -119,6 +119,16 @@ class Pilot:
|
|||||||
HULL_CLEARANCE = 800.0 # clearance ON TOP of a capital ship's own radius
|
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
|
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
|
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):
|
def __init__(self, W, pad, dry=False, log=sys.stdout):
|
||||||
self.W = W
|
self.W = W
|
||||||
@@ -145,6 +155,9 @@ class Pilot:
|
|||||||
self.missile_down = False
|
self.missile_down = False
|
||||||
self.missile_t = -1e9
|
self.missile_t = -1e9
|
||||||
self.missiles = 0
|
self.missiles = 0
|
||||||
|
self.commit_off = None # entity we are committed to
|
||||||
|
self.commit_t = -1e9
|
||||||
|
self.behind_since = None
|
||||||
|
|
||||||
def f32(self, off):
|
def f32(self, off):
|
||||||
b = os.pread(self.W.fd, 4, off)
|
b = os.pread(self.W.fd, 4, off)
|
||||||
@@ -226,6 +239,33 @@ class Pilot:
|
|||||||
return self.CONE_MAX
|
return self.CONE_MAX
|
||||||
return min(self.CONE_MAX, max(self.FIRE_CONE, math.atan2(r + SHELL_RADIUS, d)))
|
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):
|
def pick(self, me_p, me_v, fwd, hos):
|
||||||
"""Nearest *fighter*, weighted by how far off the nose it is."""
|
"""Nearest *fighter*, weighted by how far off the nose it is."""
|
||||||
best, bestscore = None, 1e18
|
best, bestscore = None, 1e18
|
||||||
@@ -343,7 +383,7 @@ class Pilot:
|
|||||||
else:
|
else:
|
||||||
self.mode = "ENGAGE"
|
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)
|
push, worst = self.av.avoidance(me_p, me_v, me_r, ents, me_off)
|
||||||
|
|
||||||
if self.mode == "EVADE":
|
if self.mode == "EVADE":
|
||||||
|
|||||||
173
tools/re-capture/target_probe.py
Normal file
173
tools/re-capture/target_probe.py
Normal 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()
|
||||||
Reference in New Issue
Block a user