#!/usr/bin/env python3 """Fly TO a capital ship and dump a draw capture at several ranges. Why this exists: the 2026-07-31 Stage-02 capture correlated **zero** parts, and inverting the match (`cargo run --example invert_capture`) showed why — at the captured frames no capital-ship hull was drawn at all. The only large draw was the player's own craft (`DeltaSaber_T:f001`, 10891 verts); of `f101`/`e105`/ `e106` only a handful of tiny far-LOD/effect pieces appeared. The ships were simply too far away. Pressing F10 wherever the craft happens to be is therefore not a capture strategy. So: pick a capital ship, fly at it, and press F10 as each distance band is crossed. That gives (a) frames where the full-detail hull is actually drawn — what the correlator needs — and (b) as a by-product, the game's own **LOD ladder**, because each capture is stamped with the range it was taken at. Firing is disabled (the target is usually a friendly), and navigator.py's closest-point-of-approach avoidance is inherited unchanged, so closing on a hull does not end in a collision. Usage: approach_capture.py [seconds] [--target REGEX] [--dry] Env: SYLPH_CAPTURE_WIN xdotool window id to send F10 to (unset = no capture) SYLPH_CAPTURE_OUT where to write the band log and screenshots """ import json import math import os import re import subprocess import sys import time from collections import Counter import numpy as np sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import navigator # noqa: E402 from navigator import Navigator, ang, norm # noqa: E402 from flight_probe import Pad # noqa: E402 # Ranges (guest units) at which to dump a capture, largest first. Chosen to # straddle the plausible LOD switches: the far-LOD pieces seen in the 2026-07-31 # capture were drawn at whatever range the craft sat at, and the one validated # capture (e106, Stage_S01) had the ship close. BANDS = [8000.0, 6000.0, 4500.0, 3000.0, 2000.0, 1400.0, 900.0] # A capital ship, not a fighter: the definition's own size radius says which. CAPITAL_RADIUS = 150.0 class Approach(Navigator): # Never shoot: the approach target is usually the escorted asset, and a # negative cone makes the inherited fire gate unsatisfiable. FIRE_CONE = -1.0 HOLD = 700.0 # stop closing inside this; the capture is already made def __init__(self, W, pad, target_re=None, dry=False, log=sys.stdout, win=None, out=None): super().__init__(W, pad, dry=dry, log=log) self.target_re = re.compile(target_re, re.I) if target_re else None self.win = win self.out = out or "/sylph-home/re/shipcap" self.locked = None # (off, name) — stay on one ship self.pending = list(BANDS) self.captures = [] self.throttle = None # -------------------------------------------------------------- target def pick(self, me_p, me_v, fwd, ents, me_off): """The chosen capital ship — locked once, so the run is one approach.""" cands = [e for e in ents if e[0] != me_off and "Player" not in e[1] and e[4] >= CAPITAL_RADIUS and (self.target_re is None or self.target_re.search(e[1]))] if not cands: return None if self.locked is not None: same = [e for e in cands if e[0] == self.locked] if same: e = same[0] return (e[0], e[1], e[2], e[2] - me_p, float(np.linalg.norm(e[2] - me_p))) # First lock: the biggest ship that is not absurdly far. cands.sort(key=lambda e: (-e[4], float(np.linalg.norm(e[2] - me_p)))) e = cands[0] self.locked = e[0] print(f"LOCK {e[1]} radius={e[4]:.0f} d={np.linalg.norm(e[2]-me_p):.0f}", file=self.log, flush=True) return (e[0], e[1], e[2], e[2] - me_p, float(np.linalg.norm(e[2] - me_p))) # ------------------------------------------------------------- capture def capture(self, band, dist, name): idx = len(self.captures) + 1 shot = f"{self.out}/approach-{idx:02d}.png" if self.win: subprocess.run(["screenshot", shot], capture_output=True) subprocess.run(["xdotool", "key", "--window", self.win, "F10"], capture_output=True) rec = {"index": idx, "band": band, "distance": round(dist, 1), "target": name, "shot": shot, "t": round(time.time(), 3)} self.captures.append(rec) print(f"CAPTURE {idx:02d} band={band:.0f} d={dist:.0f} {name}", file=self.log, flush=True) with open(f"{self.out}/approach-bands.jsonl", "a") as f: f.write(json.dumps(rec) + "\n") # ---------------------------------------------------------------- loop def step(self, t, dt, prev_vhat): msg, vhat = super().step(t, dt, prev_vhat) # Distance to the locked ship drives both the throttle and the captures. ents = self.W.sample(t) me = next((e for e in ents if "Player" in e[1]), None) tgt = next((e for e in ents if e[0] == self.locked), None) if self.locked else None if me is None or tgt is None: return msg, vhat d = float(np.linalg.norm(tgt[2] - me[2])) # Throttle: RT to close, LT to hold off once we are as near as we want. want = 1 if d > self.HOLD * 2 else (-1 if d < self.HOLD else 0) if want != self.throttle and not self.dry: self.pad.trig("RT", 1.0 if want > 0 else 0.0) self.pad.trig("LT", 1.0 if want < 0 else 0.0) self.throttle = want while self.pending and d <= self.pending[0]: band = self.pending.pop(0) self.capture(band, d, tgt[1]) return f"{msg} | d={d:7.0f} thr={want:+d} left={len(self.pending)}", vhat def main(): cfg = json.load(open(sys.argv[1])) secs = float(sys.argv[2]) if len(sys.argv) > 2 and not sys.argv[2].startswith("-") else 240.0 target = None if "--target" in sys.argv: target = sys.argv[sys.argv.index("--target") + 1] W = navigator.World(cfg) a = Approach(W, Pad(), target_re=target, dry="--dry" in sys.argv, win=os.environ.get("SYLPH_CAPTURE_WIN"), out=os.environ.get("SYLPH_CAPTURE_OUT")) a.run(secs) print(f"CAPTURES {json.dumps(a.captures)}", flush=True) if __name__ == "__main__": main()