re: the Stage-02 capture drew no capital ship at all — invert the match, then control range

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>
This commit is contained in:
2026-08-10 18:04:58 +00:00
parent bbfeb1c387
commit 1d4b35df0f
5 changed files with 499 additions and 3 deletions

View File

@@ -0,0 +1,156 @@
//! Invert the capture↔part match: instead of asking, per ship part, "is there a
//! draw with this vertex count?", ask of the **capture's** biggest draws "which
//! decoded resource in this stage container has that vertex count?".
//!
//! This is the diagnostic for the 2026-07-31 negative result (Stage_S02 capture,
//! zero parts correlated). It separates three hypotheses:
//! 1. LOD/variant vcount not covered by the correlator's variant list
//! → the big draws DO map to named resources, just not to the `_m`/`_l`/`_d`
//! set the correlator tries;
//! 2. position validation over-rejects
//! → the vcounts match the very parts we asked for (so the vcount key was
//! fine and the rejection happened later);
//! 3. a different draw path (instanced/batched/merged buffers)
//! → the big draws match NO resource in the container at all.
//!
//! Usage:
//! SYLPHEED_ISO=... cargo run --release --example invert_capture -- \
//! <capture.log> <Stage_SNN> [top_n] [--all]
//! `--all` lists every capture vcount, not just the `top_n` (default 40) largest.
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog};
use sylpheed_formats::xiso::open_iso;
use std::collections::{HashMap, HashSet};
use std::path::Path;
fn main() {
let args: Vec<String> = std::env::args().collect();
let positional: Vec<&String> = args[1..].iter().filter(|a| !a.starts_with("--")).collect();
let all = args.iter().any(|a| a == "--all");
if positional.len() < 2 {
eprintln!("usage: invert_capture <capture.log> <Stage_SNN> [top_n] [--all]");
std::process::exit(2);
}
let (log, stage) = (positional[0], positional[1]);
let top_n: usize = positional.get(2).and_then(|s| s.parse().ok()).unwrap_or(40);
let iso = std::env::var("SYLPHEED_ISO").expect("SYLPHEED_ISO");
let text = std::fs::read_to_string(log).expect("read log");
let mut draws = parse_capture(&text);
if draws.is_empty() {
draws = parse_drawlog(&text);
println!("parsed {} draws (draw-logger format)", draws.len());
} else {
println!("parsed {} draws (F10 capture format)", draws.len());
}
// Decode EVERY geometry resource in the stage container, not just one ship's.
let bytes = {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
rt.block_on(async {
let mut r = open_iso(Path::new(&iso)).await.unwrap();
r.read_file(&format!("hidden/resource3d/{stage}.xpr")).await.unwrap()
})
};
let names = xbg7_resource_names(&bytes);
println!("{stage}.xpr: {} XBG7 resources", names.len());
let want: HashSet<String> = names.iter().cloned().collect();
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
println!("decoded {} models", models.len());
// vcount -> resource names with that many vertices.
let mut by_vcount: HashMap<u32, Vec<String>> = HashMap::new();
for m in &models {
let v: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
by_vcount.entry(v as u32).or_default().push(m.name.clone());
}
// Per-submesh counts too: a draw may be one sub-mesh of a multi-mesh resource.
let mut by_sub_vcount: HashMap<u32, Vec<String>> = HashMap::new();
for m in &models {
for (i, s) in m.meshes.iter().enumerate() {
if m.meshes.len() > 1 {
by_sub_vcount
.entry(s.positions.len() as u32)
.or_default()
.push(format!("{}#{i}", m.name));
}
}
}
// Capture vcounts, de-duped by (vbase, vcount) so a re-drawn part counts once
// per distinct buffer.
let mut draw_count: HashMap<u32, usize> = HashMap::new();
let mut bufs: HashMap<u32, HashSet<u32>> = HashMap::new();
for d in &draws {
*draw_count.entry(d.vcount).or_default() += 1;
bufs.entry(d.vcount).or_default().insert(d.vbase);
}
let mut vcounts: Vec<u32> = draw_count.keys().copied().collect();
vcounts.sort_unstable_by(|a, b| b.cmp(a));
let matched_draws: usize = draws
.iter()
.filter(|d| by_vcount.contains_key(&d.vcount) || by_sub_vcount.contains_key(&d.vcount))
.count();
println!(
"\n{} distinct vcounts; {}/{} draws have a vcount present in {stage}.xpr ({:.1}%)",
vcounts.len(),
matched_draws,
draws.len(),
100.0 * matched_draws as f64 / draws.len().max(1) as f64
);
let shown = if all { vcounts.len() } else { top_n.min(vcounts.len()) };
println!("\nlargest capture vcounts (draws / distinct vbufs) → matching resources:");
for &v in vcounts.iter().take(shown) {
let n = draw_count[&v];
let b = bufs[&v].len();
let mut hit: Vec<String> = by_vcount.get(&v).cloned().unwrap_or_default();
let sub: Vec<String> = by_sub_vcount.get(&v).cloned().unwrap_or_default();
hit.extend(sub.into_iter().map(|s| format!("{s} (sub)")));
let label = if hit.is_empty() {
"— no resource".to_string()
} else {
let mut h = hit.clone();
h.sort();
h.truncate(6);
format!("{}{}", h.join(", "), if hit.len() > 6 { ", …" } else { "" })
};
println!(" vcount {v:6} draws {n:4} bufs {b:3} {label}");
}
// `--ship <id>`: every resource of one ship family, with its vertex count and
// whether the capture drew it — this is what shows an all-`_l` (far-LOD) frame.
if let Some(i) = args.iter().position(|a| a == "--ship") {
if let Some(id) = args.get(i + 1) {
let mut rows: Vec<(String, u32, usize)> = models
.iter()
.filter(|m| m.name.contains(id.as_str()))
.map(|m| {
let v = m.meshes.iter().map(|s| s.positions.len()).sum::<usize>() as u32;
(m.name.clone(), v, draw_count.get(&v).copied().unwrap_or(0))
})
.collect();
rows.sort_by(|a, b| a.0.cmp(&b.0));
let drawn = rows.iter().filter(|r| r.2 > 0).count();
println!("\n{id} resources in {stage}.xpr ({drawn}/{} with a drawn vcount):", rows.len());
for (name, v, n) in rows {
println!(" {name:28} vcount {v:6} {}", if n > 0 { format!("DRAWN ×{n}") } else { "".into() });
}
}
}
// The other direction, for orientation: the container's biggest resources and
// whether the capture ever drew that many vertices.
let mut sizes: Vec<(u32, String)> = models
.iter()
.map(|m| (m.meshes.iter().map(|s| s.positions.len()).sum::<usize>() as u32, m.name.clone()))
.collect();
sizes.sort_unstable_by(|a, b| b.0.cmp(&a.0));
println!("\nlargest resources in {stage}.xpr → drawn in the capture?");
for (v, name) in sizes.iter().take(top_n.min(sizes.len())) {
let n = draw_count.get(v).copied().unwrap_or(0);
println!(" {name:28} vcount {v:6} {}", if n > 0 { format!("DRAWN ×{n}") } else { "not drawn".into() });
}
}

View File

@@ -0,0 +1,98 @@
//! Global "which resource has N vertices?" index over every `.xpr` container in
//! an extracted `resource3d` directory, answered for the vcounts a capture log
//! actually drew.
//!
//! Companion to `invert_capture`: that one asks the question inside a single
//! stage container, this one asks it across ALL containers — so a draw whose
//! geometry lives in `Common.xpr`, a `rou_*` weapon pack or a `BG_*` backdrop is
//! still identified instead of coming back "no resource".
//!
//! Usage:
//! cargo run --release --example vcount_index -- <resource3d_dir> <capture.log> [top_n]
//! cargo run --release --example vcount_index -- <resource3d_dir> --vcounts 10891,6000
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog};
use std::collections::{HashMap, HashSet};
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("usage: vcount_index <resource3d_dir> <capture.log|--vcounts a,b,c> [top_n]");
std::process::exit(2);
}
let dir = &args[1];
// Which vertex counts are we asking about, and how often was each drawn?
let mut draw_count: HashMap<u32, usize> = HashMap::new();
let mut bufs: HashMap<u32, HashSet<u32>> = HashMap::new();
if args[2] == "--vcounts" {
for v in args[3].split(',').filter_map(|s| s.trim().parse::<u32>().ok()) {
draw_count.insert(v, 0);
}
} else {
let text = std::fs::read_to_string(&args[2]).expect("read log");
let mut draws = parse_capture(&text);
if draws.is_empty() {
draws = parse_drawlog(&text);
}
eprintln!("parsed {} draws", draws.len());
for d in &draws {
*draw_count.entry(d.vcount).or_default() += 1;
bufs.entry(d.vcount).or_default().insert(d.vbase);
}
}
let top_n: usize = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(usize::MAX);
// Decode every container once; keep only the vcount → names mapping.
let mut by_vcount: HashMap<u32, Vec<String>> = HashMap::new();
let mut files: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
.expect("read dir")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|e| e == "xpr"))
.collect();
files.sort();
let mut total_res = 0usize;
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let names = xbg7_resource_names(&bytes);
if names.is_empty() {
continue;
}
let want: HashSet<String> = names.iter().cloned().collect();
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
let container = f.file_stem().unwrap().to_string_lossy().to_string();
for m in &models {
total_res += 1;
let whole: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
by_vcount.entry(whole as u32).or_default().push(format!("{container}:{}", m.name));
if m.meshes.len() > 1 {
for (i, s) in m.meshes.iter().enumerate() {
by_vcount
.entry(s.positions.len() as u32)
.or_default()
.push(format!("{container}:{}#{i}", m.name));
}
}
}
}
eprintln!("indexed {} resources from {} containers", total_res, files.len());
let mut vcounts: Vec<u32> = draw_count.keys().copied().collect();
vcounts.sort_unstable_by(|a, b| b.cmp(a));
println!("\nvcount draws bufs resources anywhere in resource3d/");
for v in vcounts.into_iter().take(top_n) {
let n = draw_count[&v];
let b = bufs.get(&v).map(|s| s.len()).unwrap_or(0);
let hit = by_vcount.get(&v).cloned().unwrap_or_default();
let label = if hit.is_empty() {
"— NONE".to_string()
} else {
let mut h = hit.clone();
h.sort();
let shown = h.len().min(8);
format!("{}{}", h[..shown].join(", "), if h.len() > shown { format!(", … ({} total)", h.len()) } else { String::new() })
};
println!("{v:6} {n:5} {b:4} {label}");
}
}

View File

@@ -89,12 +89,65 @@ Candidate explanations, **untested**:
decoded part in `Stage_S02.xpr` has that count (invert the match), instead of asking
per-part whether a draw exists. That distinguishes (1)/(2) from (3) immediately.
## 3. The inverted match — the ships were never drawn (2026-08-10) ✅ explained
The inversion was run and it settles the negative result. Two new tools:
```
cargo run --release --example invert_capture -- <capture.log> Stage_S02 [top_n] [--ship f101]
cargo run --release --example vcount_index -- ../sylph_extract/hidden/resource3d <capture.log>
```
`invert_capture` asks, of the capture's own vertex counts, which resource in one stage
container has that count; `vcount_index` asks the same across **all 166 containers**
(5480 resources), so a draw whose geometry lives in `Common.xpr`, a `rou_*` weapon pack
or a `DeltaSaber_*` player-craft pack is identified instead of coming back "unknown".
On `xenia_ship_capture_03.log` (3668 draws, Stage 02):
| capture vcount | draws | what it is |
|---|---|---|
| 10891 | 28 | **`DeltaSaber_T:f001`** — the player's own craft |
| 6000 | 14 | `Stage_S02:n006_02` — backdrop |
| 1096 / 1008 / 841 / 215 / 127 | 14112 | `rou_f001_wep_*` — the player's weapons |
| 417 / 279 / 201 / 167 | 104448 | `Base:j00*`, `ptc_pack:*` — HUD/particles |
| 8 / 4 / 3 / 1 | 317590 | particle quads |
- **Not one capital-ship hull part appears.** Per ship: `f101` **1 of 15** resources had a
drawn vcount (`f101_bdy_03_l`, 90 verts), `e105` 3 of 37, `e106` 3 of 34 — and each of
those hits is a 44225-vertex `_l`/`_b` piece whose count also collides with dozens of
unrelated resources, i.e. probably not even the ship.
- The 3668 draws span **~14 frames** per F10 press and use only **10 distinct vertex
shaders**, and the player's own craft is captured at **full detail with its `c0..c2`
WVP rows** — so the capture path itself is healthy and unfiltered.
- The screenshot ([`captures/shipcap-stage02-launch.png`](captures/shipcap-stage02-launch.png))
agrees once read carefully: the hull "filling the bottom of the frame" is the **player's
own craft** in the chase view. The nearest contact on the HUD is a wingman's engine trail.
**So hypothesis (3) is dead, and (1)/(2) never applied.** The correlator's message
"no draw matches any LOD (culled/off-screen?)" was literally true: the ships were far
enough away that the renderer drew nothing of them. `correlate` additionally cannot
anchor without the reference part, and `f101_bdy_01` was never drawn at any LOD.
**The variable that was never controlled is RANGE.** New tooling closes that gap:
[`tools/re-capture/approach_capture.py`](../../tools/re-capture/approach_capture.py)
locks onto a capital ship (definition size-radius ≥ 150 = not a fighter), flies at it
with navigator.py's drift compensation and CPA avoidance, firing disabled, and presses
F10 as each range band is crossed (8000 / 6000 / 4500 / 3000 / 2000 / 1400 / 900),
stamping every capture with its distance in `approach-bands.jsonl`. Driver:
[`ship_capture_close.sh`](../../tools/re-capture/ship_capture_close.sh). Besides giving
the correlator a full-detail frame, the stamped bands measure the game's own **LOD
ladder** per part, which the reborn renderer needs anyway.
## Honest summary
- ✅ Static assembly is **not** grossly broken across stages — 1 outlier ship
(`f002_bdy_05`), reproducible.
- 🟡 A concrete, testable hypothesis for class-specific breakage exists (multikey joint
tracks on `f104`/`f105`/`f106`/`e102`; `e106` has none).
- ❌ The runtime oracle did **not** reproduce on a second ship class yet. The capture
pipeline works end-to-end (boot → F10 → logs); the correlation step is where it
stops. **NEEDS-HUMAN / next session**, do not assume the `e106` rules generalise.
- ❌ The runtime oracle has **not** reproduced on a second ship class yet — but the
reason is now known and is not a format or correlator bug: **the captures were taken
at ranges where no capital-ship geometry is drawn at all** (§3, verified by inverting
the match against all 5480 decoded resources). Do not assume the `e106` rules
generalise; equally, do not read the zero-match as evidence against them.
- ▶ Next: run `ship_capture_close.sh` so the capture happens with a hull actually on
screen, then re-run `correlate_capture` on the closest band.

View File

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

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# ONE blocking session: boot -> Stage 02 in flight -> fly AT a capital ship and
# dump an F10 draw capture at each distance band (approach_capture.py).
#
# Supersedes ship_capture_session.sh for correlation work: that one pressed F10
# wherever the craft happened to be, and the 2026-07-31 run proved that captures
# nothing — inverting the match showed no capital-ship hull was drawn in any of
# those frames, only the player's own craft and particles. Range is the variable
# that matters, so range is what this controls.
#
# Usage: ship_capture_close.sh [seconds] [out_dir] [target_regex]
set -u
export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98
export PYTHONPATH=/sylph-home/.local/lib/python3.12/site-packages
SD="$(cd "$(dirname "$0")" && pwd)"
SECS="${1:-240}"
OUT="${2:-/sylph-home/re/shipcap-close}"
TARGET="${3:-}"
BINDIR="/home/fabi/RE - Project Sylpheed/xenia-canary-native/build/bin/Linux/Release"
CFG=/tmp/nav-close.json
mkdir -p "$OUT"
rm -f "$BINDIR"/xenia_ship_capture_*.log "$OUT"/approach-bands.jsonl
"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; }
python3 "$SD/entities2.py" self 0x130 "$CFG" || { echo "BIND FAILED"; exit 1; }
echo "--- config: $(cat "$CFG")"
# F10 goes to the emulator window through XTEST; the window must be focused.
win="$(xdotool search --class -- xenia | tail -1)"
[ -z "$win" ] && win="$(xdotool search --name -- Xenia | tail -1)"
echo "WINDOW=$win"
[ -n "$win" ] && { xdotool windowactivate "$win" 2>/dev/null; xdotool windowfocus "$win" 2>/dev/null; }
export SYLPH_CAPTURE_WIN="$win" SYLPH_CAPTURE_OUT="$OUT"
if [ -n "$TARGET" ]; then
python3 "$SD/approach_capture.py" "$CFG" "$SECS" --target "$TARGET" 2>&1 | tail -80
else
python3 "$SD/approach_capture.py" "$CFG" "$SECS" 2>&1 | tail -80
fi
sleep 2
cp -v "$BINDIR"/xenia_ship_capture_*.log "$OUT"/ 2>/dev/null
echo "--- bands ---"; cat "$OUT/approach-bands.jsonl" 2>/dev/null
grep -c '^DRAW' "$OUT"/xenia_ship_capture_*.log 2>/dev/null
echo "CLOSE CAPTURE SESSION DONE"