diff --git a/docs/re/captures/kills-target-commitment.png b/docs/re/captures/kills-target-commitment.png new file mode 100644 index 0000000..a32f1c6 Binary files /dev/null and b/docs/re/captures/kills-target-commitment.png differ diff --git a/docs/re/mission-escort-state.md b/docs/re/mission-escort-state.md index 7f48d57..2f3ec1b 100644 --- a/docs/re/mission-escort-state.md +++ b/docs/re/mission-escort-state.md @@ -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 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 - Defeat conditions for an escort stage are readable as: protected-asset diff --git a/tools/re-capture/pilot.py b/tools/re-capture/pilot.py index d247452..ce29d46 100644 --- a/tools/re-capture/pilot.py +++ b/tools/re-capture/pilot.py @@ -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": diff --git a/tools/re-capture/target_probe.py b/tools/re-capture/target_probe.py new file mode 100644 index 0000000..45deaa0 --- /dev/null +++ b/tools/re-capture/target_probe.py @@ -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 [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()