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:
156
crates/sylpheed-formats/examples/invert_capture.rs
Normal file
156
crates/sylpheed-formats/examples/invert_capture.rs
Normal 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() });
|
||||||
|
}
|
||||||
|
}
|
||||||
98
crates/sylpheed-formats/examples/vcount_index.rs
Normal file
98
crates/sylpheed-formats/examples/vcount_index.rs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,12 +89,65 @@ Candidate explanations, **untested**:
|
|||||||
decoded part in `Stage_S02.xpr` has that count (invert the match), instead of asking
|
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.
|
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 | 14–112 | `rou_f001_wep_*` — the player's weapons |
|
||||||
|
| 417 / 279 / 201 / 167 | 104–448 | `Base:j00*`, `ptc_pack:*` — HUD/particles |
|
||||||
|
| 8 / 4 / 3 / 1 | 317–590 | 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 44–225-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
|
## Honest summary
|
||||||
|
|
||||||
- ✅ Static assembly is **not** grossly broken across stages — 1 outlier ship
|
- ✅ Static assembly is **not** grossly broken across stages — 1 outlier ship
|
||||||
(`f002_bdy_05`), reproducible.
|
(`f002_bdy_05`), reproducible.
|
||||||
- 🟡 A concrete, testable hypothesis for class-specific breakage exists (multikey joint
|
- 🟡 A concrete, testable hypothesis for class-specific breakage exists (multikey joint
|
||||||
tracks on `f104`/`f105`/`f106`/`e102`; `e106` has none).
|
tracks on `f104`/`f105`/`f106`/`e102`; `e106` has none).
|
||||||
- ❌ The runtime oracle did **not** reproduce on a second ship class yet. The capture
|
- ❌ The runtime oracle has **not** reproduced on a second ship class yet — but the
|
||||||
pipeline works end-to-end (boot → F10 → logs); the correlation step is where it
|
reason is now known and is not a format or correlator bug: **the captures were taken
|
||||||
stops. **NEEDS-HUMAN / next session**, do not assume the `e106` rules generalise.
|
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.
|
||||||
|
|||||||
144
tools/re-capture/approach_capture.py
Executable file
144
tools/re-capture/approach_capture.py
Executable 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()
|
||||||
45
tools/re-capture/ship_capture_close.sh
Executable file
45
tools/re-capture/ship_capture_close.sh
Executable 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"
|
||||||
Reference in New Issue
Block a user