Inverting the capture↔part question (invert_capture over one container, vcount_index over all 166) identifies every large draw in the 2026-07-31 capture: the player's own DeltaSaber (10891 verts), its weapon packs, the backdrop and particles. Of f101/e105/e106 only 1-3 of 15-37 resources have a drawn vcount, each a 44-225-vertex far-LOD/effect piece whose count collides with dozens of unrelated resources. So the zero-correlation was not an LOD-list gap, not over-strict position validation and not a different draw path: the ships were too far away to be drawn. approach_capture.py flies at a locked capital ship and presses F10 per range band, stamping each capture with its distance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
145 lines
6.3 KiB
Python
Executable File
145 lines
6.3 KiB
Python
Executable File
#!/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 <config.json> [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()
|