Compare commits
8 Commits
auto/re-mi
...
auto/re-es
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c3e3dfe47 | |||
| f4d59c5783 | |||
| 530555de9f | |||
| 69b4a2e569 | |||
| ca500c171e | |||
| 3d9f21f030 | |||
| 1d4b35df0f | |||
|
|
bbfeb1c387 |
280
crates/sylpheed-formats/examples/correlate_frames.rs
Normal file
280
crates/sylpheed-formats/examples/correlate_frames.rs
Normal file
@@ -0,0 +1,280 @@
|
||||
//! Correlate a capture **per frame** and cross-check the frames against each
|
||||
//! other — the placement is only believable if independent frames agree.
|
||||
//!
|
||||
//! `correlate_capture` treats one capture log as one set of draws. It is not:
|
||||
//! an F10 press dumps ~14 frames with no delimiter, and `WV_ref⁻¹ · WV_p` only
|
||||
//! cancels the camera within a single frame (see
|
||||
//! [`sylpheed_formats::ship_capture::segment_frames`]). With a moving camera the
|
||||
//! mixed-frame answer is wrong, and — worse — it is wrong *silently*.
|
||||
//!
|
||||
//! So: segment, correlate each frame independently, then report per part the
|
||||
//! median translation and the spread across frames. A part whose spread is a
|
||||
//! few units is measured; a part that swings by hundreds is not, whatever the
|
||||
//! single-shot number said.
|
||||
//!
|
||||
//! Usage:
|
||||
//! SYLPHEED_ISO=... cargo run --release --example correlate_frames -- \
|
||||
//! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--min-parts N]
|
||||
|
||||
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
|
||||
use sylpheed_formats::ship::{is_base_part, ship_id_of};
|
||||
use sylpheed_formats::ship_capture::{
|
||||
correlate, parse_capture, parse_drawlog, segment_frames, PartKey,
|
||||
};
|
||||
use sylpheed_formats::xiso::open_iso;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
fn median(mut v: Vec<f32>) -> f32 {
|
||||
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let n = v.len();
|
||||
if n % 2 == 1 { v[n / 2] } else { 0.5 * (v[n / 2 - 1] + v[n / 2]) }
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let positional: Vec<&String> = args[1..].iter().filter(|a| !a.starts_with("--")).collect();
|
||||
if positional.len() < 3 {
|
||||
eprintln!("usage: correlate_frames <capture.log> <Stage_SNN> <ship_id> [ref_part] [--min-parts N]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let (log, stage, id) = (positional[0], positional[1], positional[2]);
|
||||
let ref_sub = positional.get(3).map(|s| s.as_str()).unwrap_or("bdy_01");
|
||||
let min_parts: usize = args
|
||||
.iter()
|
||||
.position(|a| a == "--min-parts")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(3);
|
||||
|
||||
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);
|
||||
}
|
||||
let frames = segment_frames(&draws);
|
||||
println!("{} draws → {} camera-consistent blocks", draws.len(), frames.len());
|
||||
|
||||
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);
|
||||
let base_parts: Vec<String> = names
|
||||
.iter()
|
||||
.filter(|n| is_base_part(n) && ship_id_of(n) == Some(id.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut want: HashSet<String> = base_parts.iter().cloned().collect();
|
||||
for p in &base_parts {
|
||||
for suf in ["_m", "_l", "_d"] {
|
||||
let c = format!("{p}{suf}");
|
||||
if names.contains(&c) {
|
||||
want.insert(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||
let positions_of = |name: &str| -> Option<Vec<[f32; 3]>> {
|
||||
let m = models.iter().find(|m| m.name == name)?;
|
||||
Some(m.meshes.iter().flat_map(|s| s.positions.iter().copied()).collect())
|
||||
};
|
||||
|
||||
// part -> [T per frame], and how many frames placed it at all.
|
||||
let mut samples: BTreeMap<String, Vec<[f32; 3]>> = BTreeMap::new();
|
||||
let mut rots: BTreeMap<String, Vec<[[f32; 3]; 3]>> = BTreeMap::new();
|
||||
let mut used_frames = 0usize;
|
||||
for (fi, fr) in frames.iter().enumerate() {
|
||||
let mut keys: Vec<PartKey> = Vec::new();
|
||||
for part in &base_parts {
|
||||
let variants =
|
||||
[part.clone(), format!("{part}_m"), format!("{part}_l"), format!("{part}_d")];
|
||||
let union: Vec<[f32; 3]> =
|
||||
variants.iter().filter_map(|v| positions_of(v)).flatten().collect();
|
||||
for cand in &variants {
|
||||
if let Some(pos) = positions_of(cand) {
|
||||
let vcount = pos.len() as u32;
|
||||
if fr.iter().any(|d| d.vcount == vcount) {
|
||||
keys.push(PartKey { part: part.clone(), vcount, ref_pos: union.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(ship) = correlate(id, fr, &keys, ref_sub) else { continue };
|
||||
if ship.parts.len() < min_parts {
|
||||
continue;
|
||||
}
|
||||
// Placements are expressed in the REFERENCE part's frame, so blocks that
|
||||
// fell back to a different reference (because the requested one was not
|
||||
// drawn in that block) are in a different coordinate system entirely.
|
||||
// Averaging them together is what makes an otherwise clean result look
|
||||
// like it disagrees by exactly the distance between the two references.
|
||||
if !ship.reference.contains(ref_sub) {
|
||||
println!(" block {fi:2}: skipped — reference fell back to {}", ship.reference);
|
||||
continue;
|
||||
}
|
||||
used_frames += 1;
|
||||
println!(
|
||||
" block {fi:2} ({:4} draws): ref={} parts={}",
|
||||
fr.len(),
|
||||
ship.reference,
|
||||
ship.parts.len()
|
||||
);
|
||||
for p in &ship.parts {
|
||||
samples.entry(p.part.clone()).or_default().push(p.t);
|
||||
rots.entry(p.part.clone()).or_default().push(p.m);
|
||||
}
|
||||
}
|
||||
|
||||
if used_frames == 0 {
|
||||
println!("\nno block placed {min_parts}+ parts — the ship is not drawn close enough");
|
||||
return;
|
||||
}
|
||||
// Aggregate by CONSENSUS, not by average. A stage holds several ships of the
|
||||
// same class, they share vertex buffers, and a block can therefore contain
|
||||
// one instance's full-LOD part next to another instance's `_m` copy — two
|
||||
// different buffers, so nothing splits them, and the recovered translation
|
||||
// then belongs to whichever instance the correlator happened to pick. Those
|
||||
// are outliers by thousands of units, so a mean or a median over all blocks
|
||||
// is meaningless; the largest cluster of blocks that agree with each other
|
||||
// is the placement, and the rest are honestly reported as other instances.
|
||||
const TOL: f32 = 25.0; // float noise in the WV products, measured ≤0.4
|
||||
let cluster = |ts: &Vec<[f32; 3]>| -> (Vec<usize>, usize) {
|
||||
let mut best: Vec<usize> = Vec::new();
|
||||
for seed in ts {
|
||||
let near: Vec<usize> = ts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, t)| (0..3).all(|i| (t[i] - seed[i]).abs() < TOL))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
if near.len() > best.len() {
|
||||
best = near;
|
||||
}
|
||||
}
|
||||
let out = ts.len() - best.len();
|
||||
(best, out)
|
||||
};
|
||||
println!("\nacross {used_frames} blocks — consensus T (largest agreeing cluster):");
|
||||
let mut agree = 0usize;
|
||||
let mut consensus: BTreeMap<String, ([f32; 3], [[f32; 3]; 3])> = BTreeMap::new();
|
||||
for (part, ts) in &samples {
|
||||
let (cl_idx, outliers) = cluster(ts);
|
||||
let cl: Vec<[f32; 3]> = cl_idx.iter().map(|&i| ts[i]).collect();
|
||||
let med = [
|
||||
median(cl.iter().map(|t| t[0]).collect()),
|
||||
median(cl.iter().map(|t| t[1]).collect()),
|
||||
median(cl.iter().map(|t| t[2]).collect()),
|
||||
];
|
||||
let spread: Vec<f32> = (0..3)
|
||||
.map(|a| {
|
||||
let v: Vec<f32> = cl.iter().map(|t| t[a]).collect();
|
||||
v.iter().cloned().fold(f32::MIN, f32::max) - v.iter().cloned().fold(f32::MAX, f32::min)
|
||||
})
|
||||
.collect();
|
||||
let verdict = if cl.len() < 2 {
|
||||
"single block — unverified"
|
||||
} else {
|
||||
agree += 1;
|
||||
"AGREES"
|
||||
};
|
||||
// How much the ROTATION varies between blocks that agree on position.
|
||||
// A part bolted to the hull reads 0 here; a part that is articulating
|
||||
// (turret aiming, engine gimballing) does not — which is what separates
|
||||
// "the assembler has the rotation wrong" from "the part moved".
|
||||
let all_ms = rots.get(part).cloned().unwrap_or_default();
|
||||
let ms: Vec<[[f32; 3]; 3]> =
|
||||
cl_idx.iter().filter_map(|&i| all_ms.get(i).copied()).collect();
|
||||
// Keep a rotation from INSIDE the cluster: the first sample overall can
|
||||
// belong to another instance, and diffing static against that reads as a
|
||||
// rotation error that is really an instance mix-up.
|
||||
consensus.insert(
|
||||
part.clone(),
|
||||
(med, ms.first().copied().unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])),
|
||||
);
|
||||
let rot_var = ms
|
||||
.iter()
|
||||
.flat_map(|a| ms.iter().map(move |b| (a, b)))
|
||||
.map(|(a, b)| {
|
||||
(0..3)
|
||||
.flat_map(|i| (0..3).map(move |j| (i, j)))
|
||||
.map(|(i, j)| (a[i][j] - b[i][j]).abs())
|
||||
.fold(0.0f32, f32::max)
|
||||
})
|
||||
.fold(0.0f32, f32::max);
|
||||
println!(
|
||||
" {part:18} {:2}/{:2} blocks T=[{:9.1}{:9.1}{:9.1}] spread=[{:6.2}{:6.2}{:6.2}] rotVar={rot_var:5.3} {verdict}{}",
|
||||
cl.len(), ts.len(), med[0], med[1], med[2], spread[0], spread[1], spread[2],
|
||||
if outliers > 0 { format!(" (+{outliers} other-instance)") } else { String::new() }
|
||||
);
|
||||
}
|
||||
println!("\n{agree}/{} parts reproduce across blocks", samples.len());
|
||||
|
||||
// `--static <Stage_SNN.xpr>`: diff the offline assembler against this
|
||||
// ground truth. Static placements are in ship space, so both sides are
|
||||
// re-expressed in the reference part's frame before comparing — and the
|
||||
// rotation is compared too, because "wrong orientation" is half of the
|
||||
// reported viewer symptom and a translation-only check cannot see it.
|
||||
let Some(si) = args.iter().position(|a| a == "--static") else { return };
|
||||
let Some(spath) = args.get(si + 1) else { return };
|
||||
let sbytes = std::fs::read(spath).expect("read stage container");
|
||||
// `include_external = true` — the engine cluster, the bridge and cross-id
|
||||
// turrets live in SEPARATE composites (`e_rou_e106_eng`, 3 nodes) that the
|
||||
// primary-composite pass does not reach. With `false` an e106 assembles as
|
||||
// 5 parts and the runtime capture's bridge/nacelles read as "not produced by
|
||||
// assemble_ship", which is a property of the caller, not of the format.
|
||||
let scene = sylpheed_formats::ship::assemble_ship(&sbytes, id, true);
|
||||
let Some(sref) = scene.iter().find(|p| p.resource.contains(ref_sub)) else {
|
||||
println!("\nstatic: no part matching '{ref_sub}' — cannot align frames");
|
||||
return;
|
||||
};
|
||||
// The reference is placed axis-aligned in every ship seen so far; if that
|
||||
// ever stops holding, the rotation would have to be unwound here too.
|
||||
println!("\nstatic vs runtime (both relative to {}):", sref.resource);
|
||||
let mut worst_t = 0.0f32;
|
||||
let mut worst_r = 0.0f32;
|
||||
for (part, (med, rm)) in &consensus {
|
||||
let (med, rm) = (*med, *rm);
|
||||
// A part may be instanced (mirrored twins share a resource name); take
|
||||
// the static copy that lands nearest the captured one.
|
||||
let cands: Vec<&sylpheed_formats::mesh::ScenePart> =
|
||||
scene.iter().filter(|p| &p.resource == part).collect();
|
||||
if cands.is_empty() {
|
||||
println!(" {part:18} — not produced by assemble_ship");
|
||||
continue;
|
||||
}
|
||||
let rel = |p: &sylpheed_formats::mesh::ScenePart| {
|
||||
[p.t[0] - sref.t[0], p.t[1] - sref.t[1], p.t[2] - sref.t[2]]
|
||||
};
|
||||
let best = cands
|
||||
.iter()
|
||||
.min_by(|a, b| {
|
||||
let d = |p: &sylpheed_formats::mesh::ScenePart| {
|
||||
let r = rel(p);
|
||||
(0..3).map(|i| (r[i] - med[i]).powi(2)).sum::<f32>()
|
||||
};
|
||||
d(a).partial_cmp(&d(b)).unwrap()
|
||||
})
|
||||
.unwrap();
|
||||
let r = rel(best);
|
||||
let dt: Vec<f32> = (0..3).map(|i| r[i] - med[i]).collect();
|
||||
let dtm = dt.iter().map(|v| v.abs()).fold(0.0f32, f32::max);
|
||||
let drm = (0..3)
|
||||
.flat_map(|i| (0..3).map(move |j| (i, j)))
|
||||
.map(|(i, j)| (best.m[i][j] - rm[i][j]).abs())
|
||||
.fold(0.0f32, f32::max);
|
||||
worst_t = worst_t.max(dtm);
|
||||
worst_r = worst_r.max(drm);
|
||||
let mark = if dtm < 1.0 && drm < 0.02 { "MATCH" } else { "DIFFERS" };
|
||||
println!(
|
||||
" {part:18} static=[{:9.1}{:9.1}{:9.1}] dT={dtm:7.2} dR={drm:6.3} {mark}",
|
||||
r[0], r[1], r[2]
|
||||
);
|
||||
}
|
||||
println!("\nworst dT={worst_t:.2} worst dR={worst_r:.3} ({} static parts, {} captured)",
|
||||
scene.len(), samples.len());
|
||||
}
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -349,9 +349,21 @@ pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec<Scen
|
||||
let Some((_, gncat)) = CATS.iter().find(|(c, _)| *c == cat) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(frame) =
|
||||
frames.iter().find(|f| f.resource.contains(gncat) && trailing_index(&f.resource) == idx)
|
||||
{
|
||||
// An index-less part (`e105_brg`, against a `GN_Bridge_01` frame) used to
|
||||
// compare `"01" == ""` and fall through, so the bridge was silently
|
||||
// dropped from the assembly while the game draws it — caught by a runtime
|
||||
// capture, which places `e105_brg` at the `GN_Bridge_01` frame exactly.
|
||||
// With no index to match on, take the lowest-numbered frame of the
|
||||
// category; an indexed part still matches its own index only.
|
||||
let mut cands: Vec<&ScenePart> = frames
|
||||
.iter()
|
||||
.filter(|f| {
|
||||
f.resource.contains(gncat)
|
||||
&& (idx.is_empty() || trailing_index(&f.resource) == idx)
|
||||
})
|
||||
.collect();
|
||||
cands.sort_by_key(|f| trailing_index(&f.resource).parse::<u32>().unwrap_or(u32::MAX));
|
||||
if let Some(frame) = cands.first().copied() {
|
||||
placed.push(ScenePart { resource: part.clone(), m: frame.m, t: frame.t, s: frame.s });
|
||||
placed_res.insert(part.clone());
|
||||
}
|
||||
|
||||
@@ -183,6 +183,41 @@ pub const SHIP_VS_HASH: &str = "0xC7F781F4C1D58054";
|
||||
/// with `vs=`[`SHIP_VS_HASH`] are kept (the ship shader), so HUD/skybox draws are
|
||||
/// ignored. (The player fighter shares ONE buffer across its fin draws and so
|
||||
/// collapses to a single entry here — fine, capital ships are the target.)
|
||||
/// Split a capture into blocks that are guaranteed to share one camera.
|
||||
///
|
||||
/// **Why this is not optional.** One F10 press dumps a flat list of draws with
|
||||
/// no frame delimiter, and it spans ~14 frames (the same vertex buffer recurs
|
||||
/// that many times). The placement math is `WV_ref⁻¹ · WV_p`, which cancels the
|
||||
/// camera **only when both draws come from the same frame** — mix frames and
|
||||
/// the residual is the camera's motion between them. With a static ship and a
|
||||
/// static camera that error is invisible, which is how the single validated
|
||||
/// `e106` capture passed; closing on a cruiser at ~760 u/s it is hundreds of
|
||||
/// units, and two frames of the same ship then disagree about where its parts
|
||||
/// are (measured 2026-08-10: `f105_bdy_02` at `[488, 736, -620]` vs
|
||||
/// `[0, 0, -1090]`).
|
||||
///
|
||||
/// The split rule is the recurrence itself: a vertex buffer that appears again
|
||||
/// starts a new block. Splitting too eagerly is harmless (a block is still one
|
||||
/// camera, just with fewer parts in it) and it separates two instances of the
|
||||
/// same class as a bonus; failing to split is what corrupts the result.
|
||||
pub fn segment_frames(draws: &[CapturedDraw]) -> Vec<Vec<CapturedDraw>> {
|
||||
let mut out: Vec<Vec<CapturedDraw>> = Vec::new();
|
||||
let mut cur: Vec<CapturedDraw> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
for d in draws {
|
||||
if !seen.insert(d.vbase) {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
seen.clear();
|
||||
seen.insert(d.vbase);
|
||||
}
|
||||
cur.push(d.clone());
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
out.push(cur);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn parse_drawlog(text: &str) -> Vec<CapturedDraw> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
|
||||
@@ -8,7 +8,21 @@ unknown, what evidence exists, and what the first step would be. Move an item in
|
||||
|
||||
## Capital ships assemble wrong in the viewer
|
||||
|
||||
**Reported:** 2026-07-30, by the user. **Status:** ❔ open, not investigated.
|
||||
**Reported:** 2026-07-30, by the user. **Status:** 🔎 **diagnosed 2026-08-10 — the
|
||||
format layer is exonerated.** Runtime captures of three classes (`f105`, `e105`,
|
||||
`e106`) at controlled range reproduce `assemble_ship` to ≤0.43 units in translation
|
||||
and to 0.000 in rotation for every part that does not move; see
|
||||
[`ship-placement-capture-generalisation.md`](ship-placement-capture-generalisation.md)
|
||||
§4. So look at **the viewer**: first that it passes `include_external = true`
|
||||
(`iso_loader.rs:4012` — with `false` an e106 loses its bridge and both nacelles,
|
||||
5 parts instead of 11), then its own transform stack.
|
||||
|
||||
One real format-side bug was found on the way and is **fixed**: index-less parts
|
||||
(`e105_brg`) never matched their `GN_Bridge_01` hardpoint, so 34 (stage, ship) entries
|
||||
— `e102`, `e104`, `e105` across Stages 02–29 — assembled without a bridge. The other
|
||||
apparent exception (`e105_eng_01` rotation) was an aggregation artefact and is 0.000.
|
||||
|
||||
The original report and its reasoning follow.
|
||||
|
||||
The reborn viewer builds capital ships from the split XBG7 parts via
|
||||
`sylpheed-formats::ship::assemble_ship`, and they come out **wrong** — parts in the
|
||||
@@ -44,3 +58,26 @@ class cannot hide behind `e106` passing.
|
||||
handedness, node-instance recursion) is not re-breaking a correct assembly — compare
|
||||
the viewer's placement against `assemble_ship`'s output directly before blaming the
|
||||
format layer.
|
||||
|
||||
---
|
||||
|
||||
## Viewer: `include_external` is already on — that hypothesis is dead
|
||||
|
||||
**Checked 2026-08-11.** The item above names "first that it passes
|
||||
`include_external = true` (`iso_loader.rs:4012`)" as the cheap first step. It
|
||||
does: `ShipBrowser::show_external` defaults to `true`
|
||||
(`iso_loader.rs:643`), the checkbox reads it (`ui.rs:1593`) and it is threaded
|
||||
through `RequestShipRender` → `build_ship_model` → `assemble_ship` unchanged
|
||||
(`ui.rs:1689`, `iso_loader.rs:4012`). So a ship rendered by the viewer is the
|
||||
full external assembly, not the bare hull.
|
||||
|
||||
The viewer also does not have a transform stack of its own to blame: it bakes
|
||||
`ScenePart::apply` straight into the vertices and rotates normals by the same
|
||||
`p.m` (`iso_loader.rs:4030-4062`), so its placement is `assemble_ship`'s output
|
||||
by construction. What remains unexcluded, in order of cheapness: the mirror
|
||||
handling (`det < 0` reverses triangle winding only — a reflected part keeps its
|
||||
reflected geometry), `Xbg7Model::models_named` resolving the wrong sub-model when
|
||||
a resource name repeats, and the exhaust cones. **Next step is a visual**: the
|
||||
diagnosis has run out of things it can settle by reading, so the viewer needs to
|
||||
be run against a known-good class (`e106`) and its render compared with
|
||||
`ship_render`'s.
|
||||
|
||||
@@ -22,7 +22,7 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
|
||||
| XBG7 mesh | 🟡/❔ | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | weapons/props: declaration-driven variable stride (36 models), GPU-confirmed. **Stage containers: 5662 sub-models across 22 stages** via content-anchored grouped pools (`stage_models`). Quantized hero bodies (DeltaSaber `f004`) still declined |
|
||||
| Capital-ship part placement | 🟡 | `sylpheed-formats/src/ship.rs` (static) + [runtime capture](ship-placement-runtime-capture.md) | hull placement static-exact; external parts approximate statically. **Runtime capture** (Canary F10 → VS-constant WorldView) gives ground truth — validated on `e106` destroyer; not yet baked into the viewer |
|
||||
| Weapon fields defaulted on disc | ✅ | [runtime struct](structures/weapon-struct-runtime.md) · [DATA SHEET route](weapon-datasheet-runtime.md) | **Solved.** Canary maps guest RAM into `/dev/shm`, so the parsed `Weapon`/`Shell` objects are readable live; their layout is solved against disc ground truth (zero contradictions over 100+ records). All 126 weapons, exact numbers, no story progress needed — [4 393 values](captures/weapon-runtime-fields.csv) the disc does not carry. Supersedes the letter-bucket limit of the DATA SHEET route, which now serves as the independent cross-check |
|
||||
| Unit (craft/vessel) fields defaulted on disc | ✅/🟡 | [runtime struct](structures/unit-struct-runtime.md) | The parsed `unit\UN_*.tbl` definition object, vtable `0x820af844`, ≥`0x380` bytes, one per unit — **discovered, not assumed** (`unit_discover.py`), and distinguished from the spawned-entity class `0x820af030` by being one-per-ID and byte-constant within a run. Across runs only pointer words move — `--crosscheck` proves **no reported field offset is run-dependent** (two words, `+0x2c8`/`+0x2d0`, are stage-dependent and remain unidentified). 27 fields ✅ (21 units, 7 runs); the `Maneuver` block is **schema declaration order, 4 bytes/field, base `0x9c` with a two-slot gap after `AA_Roll_Min`** (29 anchors, 0 conflicts), which also pins 5 fields *no* disc record ever values. Angles are **radians at runtime, degrees on disc**. Unlike weapons, unit definitions are instantiated **per stage**, so coverage (21/110) grows by visiting missions — [values](captures/unit-runtime-fields.csv) |
|
||||
| Unit (craft/vessel) fields defaulted on disc | ✅/🟡 | [runtime struct](structures/unit-struct-runtime.md) | The parsed `unit\UN_*.tbl` definition object, vtable `0x820af844`, ≥`0x380` bytes, one per unit — **discovered, not assumed** (`unit_discover.py`), and distinguished from the spawned-entity class `0x820af030` by being one-per-ID and byte-constant within a run. Across runs only pointer words move — `--crosscheck` proves **no reported field offset is run-dependent** (two words, `+0x2c8`/`+0x2d0`, are stage-dependent and remain unidentified). 27 fields ✅ (21 units, 7 runs); the `Maneuver` block is **schema declaration order, 4 bytes/field, base `0x9c` with a two-slot gap after `AA_Roll_Min`** (29 anchors, 0 conflicts), which also pins 5 fields *no* disc record ever values. Angles are **radians at runtime, degrees on disc**. Unlike weapons, unit definitions are instantiated **per stage**, so coverage (21/110) grows by visiting missions — but a defaulted field is **not** a global constant: `Size_Y` provably inherits `Size_X` (7 independent units, 6 distinct values), and three more sibling rules are recorded ❔, recovering 65 values in units never visited — [values](captures/unit-runtime-fields.csv) |
|
||||
| UI screen layout (`.rat`) | ✅/🟡 | [ui-rat-layout](structures/ui-rat-layout.md) | One pak per UI screen; each RATC = one (context × language) build; every `<name>.t32` sprite has a `<name>.rat` **layout record** (BE u32; 1280×720 design space; scale/tint/X/Y, keyframes for animated elements, `opt ` link to the focused state). **The tutorial PAUSE menu and the title main menu both rebuild pixel-accurately from the disc.** `loop1.rat` (screen-level draw order) not yet decoded |
|
||||
|
||||
## Runtime / dynamic-capture technique
|
||||
|
||||
161
docs/re/captures/escort-decay-ab.csv
Normal file
161
docs/re/captures/escort-decay-ab.csv
Normal file
@@ -0,0 +1,161 @@
|
||||
run,turret_rule,t_s,acropolis_hull,e007_alive,e007_killed_cum
|
||||
mission01,off,0,25000.0,0,0
|
||||
mission01,off,10,25000.0,5,0
|
||||
mission01,off,20,25000.0,6,0
|
||||
mission01,off,30,25000.0,6,0
|
||||
mission01,off,40,25000.0,6,0
|
||||
mission01,off,50,25000.0,8,0
|
||||
mission01,off,60,25000.0,11,0
|
||||
mission01,off,70,25000.0,11,3
|
||||
mission01,off,80,25000.0,8,3
|
||||
mission01,off,90,25000.0,8,3
|
||||
mission01,off,100,25000.0,10,3
|
||||
mission01,off,110,25000.0,14,3
|
||||
mission01,off,120,25000.0,16,3
|
||||
mission01,off,130,25000.0,17,3
|
||||
mission01,off,140,25000.0,20,3
|
||||
mission01,off,150,25000.0,23,3
|
||||
mission01,off,160,25000.0,26,4
|
||||
mission01,off,170,24509.5,33,4
|
||||
mission01,off,180,24509.5,37,4
|
||||
mission01,off,190,24419.5,38,5
|
||||
mission01,off,200,24212.5,41,5
|
||||
mission01,off,210,23447.5,40,5
|
||||
mission01,off,220,23447.5,40,5
|
||||
mission01,off,220,23447.5,40,5
|
||||
mission01,off,230,23447.5,41,5
|
||||
mission01,off,230,23447.5,41,5
|
||||
mission01,off,240,22912.0,43,5
|
||||
mission01,off,240,22867.0,43,5
|
||||
mission01,off,250,22667.0,42,6
|
||||
mission01,off,250,22647.0,42,6
|
||||
mission01,off,260,22334.5,44,6
|
||||
mission01,off,260,22177.9,44,6
|
||||
mission01,off,270,21101.2,46,6
|
||||
mission01,off,270,21101.2,45,6
|
||||
mission01,off,280,20292.6,46,6
|
||||
mission01,off,280,20279.1,46,6
|
||||
mission01,off,290,19598.5,58,6
|
||||
mission01,off,290,19598.5,58,6
|
||||
mission01,off,300,18725.2,58,6
|
||||
mission01,off,300,18658.5,47,6
|
||||
mission01,off,310,18231.9,47,6
|
||||
mission01,off,310,18231.9,47,6
|
||||
mission01,off,320,17425.2,47,6
|
||||
mission01,off,330,17046.9,49,6
|
||||
mission01,off,340,16044.8,49,7
|
||||
mission01,off,350,15461.5,50,7
|
||||
mission01,off,360,14956.3,51,7
|
||||
mission01,off,370,14425.5,51,8
|
||||
mission01,off,380,13910.5,51,8
|
||||
mission01,off,390,13418.9,49,11
|
||||
mission01,off,400,13218.9,45,12
|
||||
mission01,off,410,12517.9,44,12
|
||||
mission01,off,420,12161.3,44,13
|
||||
mission01,off,430,11601.3,44,13
|
||||
mission02,on,0,25000.0,0,0
|
||||
mission02,on,10,25000.0,5,0
|
||||
mission02,on,20,25000.0,6,0
|
||||
mission02,on,30,25000.0,5,0
|
||||
mission02,on,40,25000.0,5,1
|
||||
mission02,on,50,25000.0,7,1
|
||||
mission02,on,60,25000.0,10,1
|
||||
mission02,on,70,25000.0,7,4
|
||||
mission02,on,80,25000.0,7,4
|
||||
mission02,on,90,25000.0,7,4
|
||||
mission02,on,100,25000.0,7,4
|
||||
mission02,on,110,25000.0,9,4
|
||||
mission02,on,120,25000.0,14,4
|
||||
mission02,on,130,25000.0,16,4
|
||||
mission02,on,140,25000.0,19,4
|
||||
mission02,on,150,25000.0,19,4
|
||||
mission02,on,160,25000.0,23,5
|
||||
mission02,on,170,24730.0,28,5
|
||||
mission02,on,180,24730.0,32,5
|
||||
mission02,on,190,24730.0,36,5
|
||||
mission02,on,200,24640.0,43,6
|
||||
mission02,on,210,24190.0,46,6
|
||||
mission02,on,220,24055.0,47,6
|
||||
mission02,on,220,24055.0,47,6
|
||||
mission02,on,230,24055.0,49,6
|
||||
mission02,on,230,24055.0,49,6
|
||||
mission02,on,240,23474.5,52,6
|
||||
mission02,on,240,23474.5,52,6
|
||||
mission02,on,250,23474.5,53,6
|
||||
mission02,on,250,23474.5,53,6
|
||||
mission02,on,260,23474.5,54,6
|
||||
mission02,on,260,23474.5,54,6
|
||||
mission02,on,270,22697.0,54,6
|
||||
mission02,on,270,22697.0,54,6
|
||||
mission02,on,280,22091.2,56,6
|
||||
mission02,on,280,22091.2,55,7
|
||||
mission02,on,290,21577.9,55,7
|
||||
mission02,on,290,21555.4,55,7
|
||||
mission02,on,300,20799.1,56,7
|
||||
mission02,on,310,19781.1,55,7
|
||||
mission02,on,320,18925.8,56,8
|
||||
mission02,on,330,18592.5,56,8
|
||||
mission02,on,340,17432.5,57,8
|
||||
mission02,on,350,16932.6,59,8
|
||||
mission02,on,360,16171.6,58,9
|
||||
mission02,on,370,15017.6,58,11
|
||||
mission02,on,380,14217.2,59,11
|
||||
mission02,on,390,13322.2,59,12
|
||||
mission02,on,400,12808.9,58,13
|
||||
mission02,on,410,12090.6,61,16
|
||||
mission02,on,420,11440.6,66,19
|
||||
mission02,on,430,11127.3,55,19
|
||||
mission03,on,0,25000.0,0,0
|
||||
mission03,on,10,25000.0,3,0
|
||||
mission03,on,20,25000.0,8,0
|
||||
mission03,on,30,25000.0,5,0
|
||||
mission03,on,40,25000.0,6,0
|
||||
mission03,on,50,25000.0,8,0
|
||||
mission03,on,60,25000.0,9,0
|
||||
mission03,on,70,25000.0,16,0
|
||||
mission03,on,80,25000.0,10,0
|
||||
mission03,on,90,25000.0,11,0
|
||||
mission03,on,100,25000.0,10,2
|
||||
mission03,on,110,25000.0,15,2
|
||||
mission03,on,120,25000.0,12,2
|
||||
mission03,on,130,25000.0,10,4
|
||||
mission03,on,140,25000.0,11,6
|
||||
mission03,on,150,25000.0,14,6
|
||||
mission03,on,160,25000.0,17,7
|
||||
mission03,on,170,24685.0,24,7
|
||||
mission03,on,180,24640.0,23,7
|
||||
mission03,on,190,24640.0,27,7
|
||||
mission03,on,200,24460.0,26,8
|
||||
mission03,on,210,24100.0,28,8
|
||||
mission03,on,220,24100.0,29,9
|
||||
mission03,on,230,24100.0,28,11
|
||||
mission03,on,230,24100.0,28,11
|
||||
mission03,on,240,23810.0,35,11
|
||||
mission03,on,240,23810.0,36,11
|
||||
mission03,on,250,23630.0,36,12
|
||||
mission03,on,250,23630.0,36,12
|
||||
mission03,on,260,23630.0,38,13
|
||||
mission03,on,260,23630.0,38,13
|
||||
mission03,on,270,22998.0,39,13
|
||||
mission03,on,270,22908.0,39,13
|
||||
mission03,on,280,22197.5,40,13
|
||||
mission03,on,280,22188.5,40,13
|
||||
mission03,on,290,21602.4,38,13
|
||||
mission03,on,290,21602.4,38,13
|
||||
mission03,on,300,21042.4,39,13
|
||||
mission03,on,300,20975.8,39,13
|
||||
mission03,on,310,20529.1,41,15
|
||||
mission03,on,310,20529.1,41,15
|
||||
mission03,on,320,19812.5,38,17
|
||||
mission03,on,320,19767.5,38,17
|
||||
mission03,on,330,19434.2,39,18
|
||||
mission03,on,340,18785.9,37,19
|
||||
mission03,on,350,18339.2,37,19
|
||||
mission03,on,360,17528.1,38,19
|
||||
mission03,on,370,16901.5,37,19
|
||||
mission03,on,380,16260.7,35,19
|
||||
mission03,on,390,15708.2,37,19
|
||||
mission03,on,400,15346.2,37,19
|
||||
mission03,on,410,14412.1,36,20
|
||||
mission03,on,420,13951.9,37,22
|
||||
mission03,on,430,13211.9,35,23
|
||||
|
BIN
docs/re/captures/mission01-t485.png
Normal file
BIN
docs/re/captures/mission01-t485.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/re/captures/mission03-turretrule-t430.png
Normal file
BIN
docs/re/captures/mission03-turretrule-t430.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
BIN
docs/re/captures/shipcap-close-f105-2994.png
Normal file
BIN
docs/re/captures/shipcap-close-f105-2994.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 562 KiB |
BIN
docs/re/captures/shipcap-stage02-launch.png
Normal file
BIN
docs/re/captures/shipcap-stage02-launch.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 543 KiB |
181
docs/re/mission-outcome-stage02.md
Normal file
181
docs/re/mission-outcome-stage02.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# Why Stage 02 is never won — the escort sinks at ~11 minutes (2026-08-10)
|
||||
|
||||
**Status: ✅ measured, one 500 s run.** The standing open item since 2026-07-29 was
|
||||
"no mission completed". This is the first session whose deliverable was the
|
||||
*ending* rather than a measurement, and it settles why: **the mission is lost
|
||||
before it can be won, and the pilot's survival policy is what guarantees it.**
|
||||
|
||||
Run: `tools/re-capture/mission_run.sh 500 mission01` — boot → Stage 02 in flight →
|
||||
`pilot.py` (escort-weighted targeting, target commitment, guided missiles) for
|
||||
500 s, with every entity's hull sampled at 2 Hz and a screenshot every 30 s.
|
||||
Artifacts at `/sylph-home/re/mission01/` (9 MB `mission.jsonl`, not committed).
|
||||
|
||||
## The escort's decay is linear, and it ends the mission
|
||||
|
||||
| t (s) | ACROPOLIS hull | % |
|
||||
|---|---|---|
|
||||
| 0–160 | 25000 | 100 % |
|
||||
| 180 | 24510 | 98.0 |
|
||||
| 280 | 20279 | 81.1 |
|
||||
| 380 | 13799 | 55.2 |
|
||||
| 480 | 8541 | 34.2 |
|
||||
| 485 (end) | 8182 | 32.7 |
|
||||
|
||||
Untouched until **t ≈ 170 s**, then **≈53 HP/s** with no let-up — so the asset
|
||||
reaches zero at **t ≈ 640 s**, and the whole-run average rate puts it at 722 s.
|
||||
Either way the escort is dead at **10–12 minutes**, and "the ACROPOLIS is sunk"
|
||||
is a defeat condition ([mission-escort-state](mission-escort-state.md)).
|
||||
|
||||
This also retires a suspicion: the 240 s time-box of earlier runs was *not*
|
||||
hiding a win, and the ~500 s ceiling of a single blocking tool call is **not**
|
||||
the binding constraint. A longer session would simply watch the loss arrive.
|
||||
|
||||
## What is actually killing it — and the conflict that follows
|
||||
|
||||
Attributing damage by co-presence (which hostiles are within 3000 units of the
|
||||
asset in the sample where its hull drops, damage split evenly among the classes
|
||||
present — suggestive, not per-shot proof), only **two** classes are ever near it:
|
||||
|
||||
| class | samples present | attributed damage |
|
||||
|---|---|---|
|
||||
| `UN_e007_ADAN_Turret` | 200 | 8483 |
|
||||
| `UN_e010_ADAN_Attacker_S` | 194 | 8334 |
|
||||
|
||||
Roughly half the damage comes from **turrets** — and `pilot.py` treats turrets as
|
||||
**keep-out zones at 2500 units, never as targets**. That rule is not arbitrary: a
|
||||
turret is what shot down every pilot before 2026-07-30, and it is why the craft
|
||||
now survives. But it means **the policy that keeps the pilot alive also
|
||||
guarantees the escort dies.** Survival and the objective are in direct conflict,
|
||||
and the pilot currently resolves it entirely in favour of survival.
|
||||
|
||||
The HUD at t≈485 s says the same thing from the game's side:
|
||||
|
||||
- `YOU KILLED WARSHIPS` **0000** — not one warship in 500 s, across every run ever;
|
||||
- `YOU KILLED WARPLANES` **0009** — fighters only;
|
||||
- `REMAINING OB` **004 → 008** — objectives are being *added* by waves faster than
|
||||
any are cleared, so the pilot is not touching the objective set at all;
|
||||
- SHIELD and ARMOR bars full, hull **1500/1500**, 120 missiles spent.
|
||||
|
||||

|
||||
|
||||
## The conclusion that matters
|
||||
|
||||
The pilot optimises the wrong thing. It maximises survival and fighter kills;
|
||||
the mission scores **objectives** and **the escort**, and the fighter population
|
||||
(134 → 92) is close to irrelevant to both. An untouched 1500/1500 hull at the
|
||||
moment the escort passes 33 % is not a good run — it is **unspent risk budget**.
|
||||
|
||||
Concretely, for the next attempt, in priority order:
|
||||
(**Step 1 below was run on 2026-08-11 and did not hold — see the follow-up A/B at
|
||||
the end of this file before acting on it.**)
|
||||
|
||||
1. **Turrets near the asset must become targets**, not keep-out zones — accepting
|
||||
hull damage is the only way to cut ~50 % of the incoming escort damage. The
|
||||
keep-out rule should be scoped to turrets that are *not* threatening the
|
||||
asset, rather than applied globally.
|
||||
2. **Engage warships.** `WARSHIPS 0000` forever means the objective class has
|
||||
never been attacked; `REMAINING OB` rising is the scoreboard saying so.
|
||||
3. Re-check whether the escort damage rate actually falls once turrets die —
|
||||
that is the experiment that tells us whether (1) is sufficient or whether the
|
||||
bombers need dedicated intercept too.
|
||||
|
||||
## Method note, learned the hard way
|
||||
|
||||
A harness-tracked **background** task does *not* protect the display: the same
|
||||
run launched in the background lost Xvfb 11 s in, at the turn boundary
|
||||
(`skip_intro` exit 3, "DISPLAY LOST"). The comment in `launch_mission.sh` saying
|
||||
the script may be run as a tracked background task is **wrong**; the
|
||||
one-blocking-foreground-call rule still stands, which caps a single attempt at
|
||||
the tool's 600 s timeout. And do not pipe a long run through `tail` — the first
|
||||
attempt printed nothing because `timeout` killed the pipeline before it flushed;
|
||||
the on-disk artifacts are what survived.
|
||||
|
||||
---
|
||||
|
||||
# Follow-up A/B (2026-08-11): making turrets targets does **not** save the escort
|
||||
|
||||
**Status: ✅ measured (3 runs), and it falsifies the causal claim above.** The
|
||||
section above ends by naming step 1 — "turrets near the asset must become
|
||||
targets" — as the fix worth ~50 % of the escort damage. That was an *inference
|
||||
from an attribution*, never a measurement. It has now been run, and it does not
|
||||
hold.
|
||||
|
||||
## First, a correction to the run labelling
|
||||
|
||||
`pilot.py` gained the `SYLPH_KILL_TURRETS` gate at 21:20:28 on 2026-08-10;
|
||||
`/sylph-home/re/mission02` started at **21:22:25**, i.e. *after* it, with the
|
||||
gate **on**. So `mission02` was never a second baseline — it is a treatment run
|
||||
that the previous session produced but never reported. The arms are:
|
||||
|
||||
| run | turret rule | evidence (`pilot.log`) |
|
||||
|---|---|---|
|
||||
| `mission01` | **off** (baseline) | `tgt=e007` on **0** of 3968 lines |
|
||||
| `mission02` | **on** | `tgt=e007` on 1588 of 3844 lines |
|
||||
| `mission03` | **on** (this session) | `tgt=e007` on 1218 of 3514 lines |
|
||||
|
||||
`mission01` predates the edit, so the *only* difference between the arms is the
|
||||
gate. Everything else — build, save slot, nav route, loadout — is identical.
|
||||
|
||||
## The result, all three runs truncated to a common t = 428 s
|
||||
|
||||
| run | rule | ACROPOLIS @428 s | % | decay rate | turrets seen | turrets killed | attack onset |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `mission01` | off | 11735 | 46.9 % | 50.3 HP/s | 92 | 30 | 166 s |
|
||||
| `mission02` | on | 11127 | 44.5 % | 52.7 HP/s | 98 | 34 | 167 s |
|
||||
| `mission03` | on | 13257 | 53.0 % | 44.4 HP/s | 88 | 41 | 166 s |
|
||||
|
||||
**The two runs of the *same* arm differ by 8.5 percentage points (2130 HP) —
|
||||
more than either differs from the baseline.** The baseline sits *between* the two
|
||||
treatment runs on escort hull and on decay rate. So the effect of the turret rule
|
||||
is **not resolvable at n=1 per arm**, and the honest statement is: it was not
|
||||
demonstrated. Extrapolating each run's own rate, the escort still reaches zero at
|
||||
**t ≈ 590–670 s** in every arm — the mission is lost in all three.
|
||||
|
||||
The rule does do the mechanical thing it was written to do (turret kills 30 → 34,
|
||||
41; 1218–1588 frames spent with a turret as the committed target). It just does
|
||||
not convert into escort hull.
|
||||
|
||||

|
||||
|
||||
## What *is* reproducible across all three runs
|
||||
|
||||
These are the numbers to build on, because they repeat to within a percent:
|
||||
|
||||
- **The assault on the ACROPOLIS is scripted, not emergent.** Onset at
|
||||
**166 / 167 / 166 s** — ±1 s across three runs with completely different pilot
|
||||
behaviour in between. Nothing the pilot does moves it.
|
||||
- **The damage split is a property of the scenario, not of our targeting.**
|
||||
`e007` turrets vs `e010` bombers is **50.6/49.4, 50.0/50.0, 51.0/49.0** — and
|
||||
the baseline, in which we never fired at a turret at all, splits the same way.
|
||||
A co-presence attribution that is invariant to whether we attack one of the two
|
||||
classes is measuring the wave script, not our contribution.
|
||||
- **Turrets die anyway**: 30 of 92 are already dead by t=428 s in the baseline,
|
||||
from friendly fire. The turret rule adds ~4–11 kills on top of that, which is
|
||||
why its escort-hull effect is small enough to be buried in run-to-run noise.
|
||||
- `WARSHIPS 0000` and `REMAINING OB` rising (004 → 008) in **every** run,
|
||||
including this one.
|
||||
|
||||
Per-run series: [`captures/escort-decay-ab.csv`](captures/escort-decay-ab.csv)
|
||||
(10 s grid, hull + turret alive/killed counts for all three runs).
|
||||
|
||||
## The methodological finding, which is the transferable one
|
||||
|
||||
**A single 430 s flight cannot resolve an escort-hull effect smaller than ~9
|
||||
percentage points.** Every pilot conclusion drawn from one run — including step 1
|
||||
above, and including anything drawn from `mission03` alone — is inside the noise.
|
||||
Each run costs ~10 minutes of wall clock and one blocking foreground call, so a
|
||||
properly powered A/B is ~6 runs ≈ 1 hour. **Any future pilot tuning must budget
|
||||
that, or not be believed.**
|
||||
|
||||
## What this means for the reason we wanted a win
|
||||
|
||||
Story progress was wanted only because unit definitions instantiate per stage, so
|
||||
unit-field coverage is stuck at 21/110 ([unit struct](structures/unit-struct-runtime.md)).
|
||||
This result says the pilot route to that coverage is expensive and unproven: the
|
||||
escort dies on a script at ~10–11 minutes, our best lever moved it by less than
|
||||
the noise, and the objective class (`WARSHIPS`) has never been touched in any run
|
||||
ever. **Before spending another hour on pilot tuning, the cheaper lever to price
|
||||
is the save file** — if the profile's save data can be read and understood, stage
|
||||
unlock state is a much shorter path to the same coverage, and the save format is
|
||||
itself something the reimplementation needs. Marked `NEEDS-HUMAN` as a direction
|
||||
choice; no save-file modification has been attempted.
|
||||
247
docs/re/ship-placement-capture-generalisation.md
Normal file
247
docs/re/ship-placement-capture-generalisation.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# Capital-ship placement — does the `e106` result generalise? (WIP, 2026-07-31)
|
||||
|
||||
**Status:** 🚧 **WIP, time-boxed session.** Two results so far: a static audit across all
|
||||
22 stage containers (done, below) and a first in-mission F10 capture run in Stage 02
|
||||
(done — three capture logs, but **no capital-ship part correlated**; see "Open").
|
||||
|
||||
Context: [`BACKLOG.md`](BACKLOG.md) — "Capital ships assemble wrong in the viewer",
|
||||
reported 2026-07-30. The oracle and the correlator already exist
|
||||
([`ship-placement-runtime-capture.md`](ship-placement-runtime-capture.md)); the open
|
||||
question is whether the rules derived from the one validated ship (`e106`, Stage_S01)
|
||||
hold for other classes.
|
||||
|
||||
## 1. Static audit across all stages (offline, reproducible)
|
||||
|
||||
```
|
||||
cargo run --release --example ship_audit -- ../sylph_extract/hidden/resource3d
|
||||
```
|
||||
|
||||
87 lines of output, of which:
|
||||
|
||||
- **Only two OUTLIER lines, and they are the same ship twice**:
|
||||
`Stage_S03`/`Stage_S27`, `f002_bdy_05` centroid `[-1398 6251 918]`, `dist=6540`
|
||||
vs a cluster spread of `1071`. Every other assembled ship in every other stage
|
||||
has all parts inside its own cluster.
|
||||
→ **The user-visible breakage is NOT a gross static-placement outlier for most
|
||||
classes.** Whatever is wrong in the viewer is either subtler than "part flung far
|
||||
away" (wrong rotation, wrong mirror, missing part) or lives in the viewer, not in
|
||||
`assemble_ship`. `f002_bdy_05` is a genuine, separate, reproducible static bug.
|
||||
- **MULTIKEY**: joint tracks with more than one keyframe, which `read_trs9`'s
|
||||
single-key read does not model. Recurring rigs: `e_rou_f104` (3), `e_rou_f105` (2),
|
||||
`e_rou_f106` (2), `e_rou_e102` (6), `e_rou_e108_Missile_open` (2), `e_rou_e501` (1),
|
||||
and the `e901` boss with 2–15 tracks per pose. Several of these (`Missile_open`, the
|
||||
`e901_attack*` poses) are obviously *animation* and harmless for a static pose; the
|
||||
plain hull rigs `f104`/`f105`/`f106`/`e102` are **not** obviously animation and are
|
||||
the best hypothesis for a class-specific assembly error. ❔ **HYPOTHESIS — not
|
||||
verified.** `e106`, the one validated ship, has **no** multikey tracks, which is
|
||||
exactly how a rule that only works for single-key rigs could have passed unnoticed.
|
||||
|
||||
Raw audit output is reproducible with the command above (not checked in; it is
|
||||
deterministic from the disc).
|
||||
|
||||
## 2. First in-mission capture run (Stage 02)
|
||||
|
||||
New tool: [`tools/re-capture/ship_capture_session.sh`](../../tools/re-capture/ship_capture_session.sh)
|
||||
— one blocking session (per the session-lifetime rule): boot → Stage 02 in flight →
|
||||
N× {screenshot, F10, small yaw}. Each F10 writes its own
|
||||
`xenia_ship_capture_NN.log` next to the binary.
|
||||
|
||||
Run 2026-07-31, 5 presses requested:
|
||||
|
||||
- **Boot to in-flight took 24 s** (`skip_intro.sh` skipped the movie at 1 s and 6 s,
|
||||
title at 11 s, HUD shield bar at 24 s) — much faster than the ~100 s in the notes.
|
||||
- **3 of 5 F10 presses produced a log** (`_01`…`_03`, 2964 / 3111 / 3668 draws).
|
||||
Logs (8–10 MB each) and the screenshots are at `/sylph-home/re/shipcap/`; not
|
||||
committed for size. One screenshot is checked in as
|
||||
[`captures/shipcap-stage02-launch.png`](captures/shipcap-stage02-launch.png).
|
||||
- The screenshot confirms the capture frames are real in-mission combat frames
|
||||
(HUD live, `REMAINING OB 004`, ACROPOLIS + a Destroyer labelled on screen, a
|
||||
capital-ship hull filling the bottom of the frame).
|
||||
|
||||
### Result: no correlation yet ❌
|
||||
|
||||
```
|
||||
correlate_capture xenia_ship_capture_03.log Stage_S02 <id> bdy_01
|
||||
```
|
||||
for `f101` (ACROPOLIS), `f105`, `f106`, `e105` reports *"no draw matches any LOD
|
||||
(culled/off-screen?)"* for essentially every part — only two speculative LOD tries
|
||||
(`f101_bdy_03` vcount 90 `[l]`, `e105_wep_01` vcount 60 `[l]`) and **zero accepted
|
||||
matches**.
|
||||
|
||||
That is a **negative result, and it is not yet explained**. Facts collected:
|
||||
|
||||
- The capture is not empty or degenerate: 3668 draws in `_03`, top shaders
|
||||
`0xDA51B0745ABF85D2` (1258), `0xE0BAFB4F520FE441` (1091), `0xEEA84C59D7F95371` (770).
|
||||
None is the `e106` ship-shader hash from the 2026-07-26 capture; the F10 path does
|
||||
not filter by hash, so this alone is not the cause.
|
||||
- Large vertex counts *are* present (3024, 2772, 1736, 1612, 1240 …), so capital-ship-
|
||||
sized geometry is being drawn.
|
||||
|
||||
Candidate explanations, **untested**:
|
||||
1. the Stage-02 capital ships on screen are drawn from LOD/damage variants
|
||||
(`_d00`, `_m`, `_l`) whose vcounts the correlator's variant list does not cover;
|
||||
2. the position-validation step rejects otherwise-correct vcount hits (the capture
|
||||
dumps ≤64 positions — a set-membership test against the wrong variant fails);
|
||||
3. the ships in view at launch are drawn by a *different* draw path than `e106` in
|
||||
Stage_S01 (e.g. instanced/batched), so no single draw equals one part.
|
||||
|
||||
**First step next session:** take the largest few vcounts in the capture and ask which
|
||||
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 | 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.
|
||||
|
||||
## 4. Controlled-range capture — three classes verified (2026-08-10) ✅
|
||||
|
||||
`ship_capture_close.sh 240` ran one blocking session: boot → Stage 02 in flight →
|
||||
lock the `f105` cruiser → close on it at full throttle, F10 at each range band.
|
||||
Six bands fired (7375 / 5937 / 4394 / 2994 / 1944 / 1259 units, stamped in
|
||||
`approach-bands.jsonl`); **3 of 6 presses produced a log** — the same 3-of-N as the
|
||||
earlier session, so a press during a previous 8 MB dump is still lost. Logs at
|
||||
`/sylph-home/re/shipcap-close/` (not committed, ~9 MB each).
|
||||
|
||||
The difference from every earlier capture is immediate: `f105_bdy_01` (10926 verts),
|
||||
`bdy_02`, `bdy_03`, `eng_01`, `sld_01` are all **drawn at full detail**, and the far
|
||||
log additionally caught the `e105` and `e106` hulls.
|
||||
|
||||
### 4a. One log is ~14 frames, and mixing them silently corrupts the result
|
||||
|
||||
`WV_ref⁻¹ · WV_p` cancels the camera **only within one frame**. The capture log has no
|
||||
frame delimiter, so `correlate_capture` was mixing ~14 frames; with the camera closing
|
||||
at ~760 u/s that is not a small error — two logs of the same cruiser disagreed by
|
||||
1090 units on `f105_bdy_02`. New `ship_capture::segment_frames` splits the log wherever
|
||||
a vertex buffer recurs (over-splitting is harmless — a block is still one camera;
|
||||
under-splitting is what corrupts), and new
|
||||
[`correlate_frames`](../../crates/sylpheed-formats/examples/correlate_frames.rs)
|
||||
correlates each block independently and **cross-checks the blocks against each other**.
|
||||
|
||||
Two aggregation rules had to be right, and both were wrong first:
|
||||
- **Only blocks with the requested reference part count.** A block that fell back to
|
||||
another reference expresses its parts in a different frame — averaging them in
|
||||
produces a "disagreement" of exactly the distance between the two references.
|
||||
- **Consensus, not median.** A stage holds several ships of one class; they share
|
||||
vertex buffers, and a block can hold one instance's full-LOD part beside another's
|
||||
`_m` copy (different buffers, so nothing splits them). The largest cluster of
|
||||
mutually-agreeing blocks is the placement; the rest are reported as
|
||||
`(+N other-instance)` rather than averaged into nonsense.
|
||||
|
||||
With that, every part reproduces across independent frames to **≤1 unit** (typical
|
||||
spread 0.03–0.2).
|
||||
|
||||
### 4b. Static assembly matches the runtime on all three classes ✅
|
||||
|
||||
`correlate_frames … --static <Stage_S02.xpr>` diffs `assemble_ship` against the capture,
|
||||
translation **and rotation**, both re-expressed in the reference part's frame:
|
||||
|
||||
| ship | rig | parts compared | worst dT | worst dR |
|
||||
|---|---|---|---|---|
|
||||
| `f105` TCAF cruiser | 1 engine, mirrored `sld` pair | 5 | **0.12** | **0.000** |
|
||||
| `e105` ADAN cruiser | 6 hull bodies, bridge, engine | 7 | **0.05** | 1.711 (`eng_01` only) |
|
||||
| `e106` ADAN destroyer | 2 nacelles + centre, turret | 8 | **0.43** | 0.098 (`eng`/`wep` only) |
|
||||
|
||||
**So the `e106` rules DO generalise.** Translation is exact for every part of every
|
||||
class — 20 of 21 comparisons under 0.5 units. This is the answer the BACKLOG item asked
|
||||
for, and it is the opposite of the assumption in it: `assemble_ship` is right, so the
|
||||
viewer's "capital ships assemble wrong" is the viewer's own transform stack (the
|
||||
backlog's own "worth ruling out first, cheaply").
|
||||
|
||||
Both first-pass exceptions were chased down, and neither survives as an open question:
|
||||
|
||||
- ✅ **`e105_brg` was genuinely missing — a real assembler bug, now fixed.** Tier 3
|
||||
matched a part to its `GN_*` hardpoint by trailing index, so an index-less part
|
||||
(`e105_brg`) compared `"01" == ""` against `GN_Bridge_01` and fell through silently.
|
||||
With no index to match on, take the lowest-numbered frame of the category. The
|
||||
runtime is the check: `e105_brg` now assembles at `[0.0, 70.0, -1850.0]` relative to
|
||||
`e105_bdy_01`, **dT 0.03, dR 0.000** against the capture.
|
||||
Reach measured by diffing `assemble_ship` part counts over all containers before and
|
||||
after: **34 (stage, ship) entries gain parts** — `e102` +2 (bridge *and* engine),
|
||||
`e104` +1, `e105` +1, across Stages 02–29. Every one of those ships was assembling
|
||||
without its bridge. `ship_audit` is unchanged (still exactly the `f002_bdy_05`
|
||||
outlier), so nothing regressed.
|
||||
- ✅ **The rotation deltas were an artefact of my own aggregation, plus one real
|
||||
articulation.** The static diff was comparing against a rotation taken from the
|
||||
first sampled block, which can belong to *another instance* of the class; scoping it
|
||||
to the position-agreeing cluster drops `e105_eng_01` from dR 1.711 to **0.000** and
|
||||
both `e106` nacelles to **0.000**. What remains is `e106_wep_02_01` at dR 0.134 — and
|
||||
that part's rotation varies by **0.182 between blocks that agree on its position**,
|
||||
i.e. the runtime disagrees with itself more than it disagrees with the assembler.
|
||||
It is a turret aiming, not an assembly error. `correlate_frames` now prints that
|
||||
`rotVar` column precisely so "the part moved" cannot be mistaken for "the rotation
|
||||
is wrong".
|
||||
|
||||
Final numbers, three classes, 21 parts: **worst dT 0.43, worst dR 0.000** for every
|
||||
part that is not articulating.
|
||||
|
||||
`include_external` matters and is a caller-side trap: with `false` an `e106` assembles
|
||||
as **5** parts and with `true` as **11** — the engine cluster, the bridge and the
|
||||
cross-id `e303_wep_01` turrets live in separate composites (`e_rou_e106_eng`, 3 nodes)
|
||||
that the primary-composite pass never reaches. The viewer takes it as a parameter
|
||||
(`iso_loader.rs:4012`); if it is ever passed `false`, ships lose their engines and
|
||||
bridge — which looks exactly like "assembles wrong".
|
||||
|
||||
## 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 earlier zero-match was **range**, not a format or correlator bug (§3): the
|
||||
captures were taken where no capital-ship geometry is drawn at all.
|
||||
- ✅ With range controlled (§4), **three classes** — `f105`, `e105`, `e106` — reproduce
|
||||
static assembly to **≤0.43 units** in translation, cross-checked across independent
|
||||
frames. The `e106`-derived rules generalise; the MULTIKEY hypothesis in §1 is *not*
|
||||
needed to explain anything observed so far (`f105` has 2 multikey tracks and still
|
||||
matches exactly).
|
||||
- ✅ One real assembler bug found and fixed by this route: index-less `brg`/`eng`/`sld`
|
||||
parts never matched their `GN_*` frame, so **34 (stage, ship) entries** assembled
|
||||
without a bridge (and `e102` also without its engine). Verified against the capture.
|
||||
- ▶ Next: the viewer itself (`include_external`, node-instance recursion) — the format
|
||||
layer is now measured, not assumed. A per-ship regression table over the checked-in
|
||||
captures would keep it that way.
|
||||
@@ -272,3 +272,84 @@ take-off, ~12 minutes in mid-combat, and after GAME OVER. 14 objects, the same
|
||||
is no need to play it, and no need to survive it.
|
||||
|
||||
Stages captured so far: `Ttrl` (BASIC CONTROLS), Stage 02.
|
||||
|
||||
## A defaulted unit field is not a global constant — some inherit from a sibling
|
||||
|
||||
**Confidence: 🟡 for `Size_Y`, ❔ for the rest. Analysis 2026-08-10, offline, from
|
||||
[`captures/unit-runtime-fields.csv`](../captures/unit-runtime-fields.csv).**
|
||||
|
||||
The coverage limit above (21 of 110 units, growing only with story progress) is
|
||||
worth attacking from the other side first: *if* a field the disc leaves unset
|
||||
always took the same runtime value, the 21 captured units would pin that default
|
||||
for all 110 and no further missions would be needed.
|
||||
|
||||
**It does not.** Restricting to the 150 values that are both ✅ CONFIRMED and
|
||||
come from a field the disc leaves defaulted, only 6 of 24 fields have a single
|
||||
value across every unit that defaults them (`HP`→10, `MassScore`→0,
|
||||
`MaximumVelocity`→0, `RadarRange`→0, `DestroyMotionTime`→0, `Size_Z`→0.1). The
|
||||
other 18 take several distinct values — so the default is computed per unit.
|
||||
|
||||
Where from? For each defaulted value, ask which *other* field of the same unit
|
||||
holds exactly that value. Counting only cases where the value is **non-zero**
|
||||
(otherwise `0 == 0` inflates every pair) and checking that the two fields are at
|
||||
**different offsets** (so the match is not the layout solver aliasing them):
|
||||
|
||||
| defaulted field | takes the value of | support | independent units |
|
||||
|---|---|---|---|
|
||||
| `Size_Y` (`0x034`) | `Size_X` (`0x030`) | 9/9 | **7**, 6 distinct values |
|
||||
| `Size_Radius` (`0x050`) | `min(Size_X, Size_Z)` | 4/4 | 4, 3 distinct values |
|
||||
| `FCSRange` (`0x2a4`) | `RadarRange` (`0x2a0`) | 4/4 | 2 |
|
||||
| `DefencePoint` (`0x2bc`) | `AttackVesselPoint` (`0x2b4`) | 6/6 | 2 |
|
||||
|
||||
`Size_Y ← Size_X` is the one to trust: seven unrelated ships (`e105` 600,
|
||||
`e106` 300, `e108` 80, `e201` 300, `f101` 400, `f105` 700, `f106` 200) each omit
|
||||
`Size_Y` on disc and each shows its own `Size_X` at runtime. When both fields
|
||||
*are* on disc they differ freely (14 distinct `Size_Y` values against 13 of
|
||||
`Size_X`), so this is a default rule, not one value stored twice.
|
||||
|
||||
`Size_Radius`'s formula is **not yet separable**: `min(Size_X, Size_Z)` and "the
|
||||
median of the three axes" fit all four units identically. `UN_e010_ADAN_Attacker_S`
|
||||
is what rules out the simpler `Size_Radius ← Size_X` (X=100, Y=40, Z=50, radius
|
||||
**50**). The last two rules rest on two independent units each and are ❔ —
|
||||
recorded so they can be falsified, not relied on.
|
||||
|
||||
**Why it matters for the reimplementation:** filling a missing `Size_Y` with `0`
|
||||
or with a global constant gives the game's largest hulls a wrong lateral extent
|
||||
(`f105` 700, `e105` 600, `f101` 400 — all defaulted on disc). Applied across the
|
||||
disc, the rules recover **65 (unit, field) values in units that have never been
|
||||
visited**: `Size_Y` in 21 of the 21 units that omit it, `Size_Radius` in 22 of 26,
|
||||
`FCSRange` in 14 of 56, `DefencePoint` in 8 of 60.
|
||||
|
||||
### Cross-check against the weapons: this is NOT an engine-wide mechanism
|
||||
|
||||
The obvious worry is that four rules from 21 units are coincidence. The
|
||||
`Weapon`/`Shell` capture is the control: **complete coverage, 126 records**, with
|
||||
the same "defaulted on disc" classification. Running the identical sweep there
|
||||
(confirmed rows, non-zero values, offsets required to differ) finds **no sibling
|
||||
rule at all** — the single 100 %-agreement candidate (`Shell.Length ←
|
||||
`Shell.Volume`, 5 records) has one distinct value, i.e. it is really the constant
|
||||
`Length → 10` coinciding with `Volume = 10`. Weapon defaults vary per record just
|
||||
as unit defaults do (10 of 14 `Weapon` fields, 16 of 17 `Shell` fields), so the
|
||||
phenomenon is general; the *sibling* explanation is not.
|
||||
|
||||
So `Size_Y ← Size_X` is **specific to the unit schema** (plausibly the size block
|
||||
defaulting its axes), not a property of IDXD default resolution. Two consequences:
|
||||
the rule cannot be justified by appeal to a general mechanism, and the two
|
||||
two-unit hypotheses (`FCSRange`, `DefencePoint`) lose the support they would have
|
||||
borrowed from one — treat them as **coincidence-not-excluded** until a new stage
|
||||
tests them.
|
||||
|
||||
`Size_Y ← Size_X` itself survives this scrutiny, and was re-checked at the raw
|
||||
token level rather than through the sub-record merge: `UN_e105_ADAN_Cruiser`,
|
||||
`UN_f105_TCAF_Cruiser` and `UN_f101_TCAF_Acropolis` each declare `Size_X`,
|
||||
`Size_Z` and `Size_Radius` and **no `Size_Y` at all**, and each reads back its own
|
||||
`Size_X` (600 / 700 / 400) at runtime.
|
||||
|
||||
**How to falsify:** the rules predict a specific number for units in stages not
|
||||
yet captured. Load any new stage, snapshot, and compare — one disagreement kills
|
||||
the rule. Note what is *not* a useful test: Stage 01, the only other reachable
|
||||
stage, adds just four uncaptured units (`e010`/`e106` variants) whose predictions
|
||||
are the same numbers their already-captured base variants gave, so it would
|
||||
re-measure rather than test. A real test needs a stage with unfamiliar classes,
|
||||
i.e. story progress — which is now the *only* thing story progress is needed for
|
||||
here.
|
||||
|
||||
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()
|
||||
@@ -19,7 +19,9 @@ alive(){ ps -o pid=,stat= -C xenia_canary 2>/dev/null | awk '$2 !~ /^Z/ {print $
|
||||
# that actually bought was the opposite: a process nothing owns is a process
|
||||
# nothing keeps alive, and both were being reaped a couple of minutes in — the
|
||||
# long-standing "Xvfb and the emulator die on their own every few minutes" note.
|
||||
# Run this whole script as ONE tracked background task and leave Xvfb, openbox
|
||||
# MEASURED WRONG 2026-08-10: a harness-tracked BACKGROUND task does not protect
|
||||
# them either — the display was lost 11 s in, at the turn boundary. Run this
|
||||
# whole script as ONE BLOCKING FOREGROUND call and leave Xvfb, openbox
|
||||
# and xenia as its children: they then live exactly as long as the session does.
|
||||
# `nohup` still shields them from a stray HUP; the exit-status wrapper means a
|
||||
# death is reported with the server's own account instead of being inferred.
|
||||
|
||||
60
tools/re-capture/mission_run.sh
Executable file
60
tools/re-capture/mission_run.sh
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# Attempt to COMPLETE a mission and record how it ends.
|
||||
#
|
||||
# Every previous flight session was time-boxed to 240 s to measure something
|
||||
# (escort hull, lethality, ship placement) and none ever reached a mission
|
||||
# outcome — "no mission completed" has been the standing open item. Unit
|
||||
# definitions are instantiated per stage, so story progress is the only thing
|
||||
# that grows unit coverage past 21/110, and that needs a WIN, not a survival.
|
||||
#
|
||||
# So this run is deliberately long and its only deliverable is the ENDING:
|
||||
# screenshots throughout, every entity's hull sampled, and the pilot log kept
|
||||
# whole (never tail-piped — a frozen log tail is what mission-end looks like
|
||||
# from outside, and tailing throws away the transition).
|
||||
#
|
||||
# Runs as ONE tracked background task with Xvfb/openbox/xenia as plain nohup
|
||||
# children — see docs/re/session-lifetime notes; do NOT setsid anything.
|
||||
#
|
||||
# Usage: mission_run.sh [flight_seconds] [tag]
|
||||
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:-900}"
|
||||
TAG="${2:-mission}"
|
||||
SHOTS=/sylph-home/re/shots
|
||||
OUT="/sylph-home/re/$TAG"
|
||||
mkdir -p "$SHOTS" "$OUT"
|
||||
|
||||
"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; }
|
||||
python3 "$SD/entities2.py" self 0x130 "$OUT/cfg.json" || { echo "BIND FAILED"; exit 1; }
|
||||
|
||||
echo "=== initial entity table ==="
|
||||
python3 "$SD/mission_state.py" scan "$OUT/cfg.json"
|
||||
|
||||
# A screenshot every 30 s for the WHOLE run: the outcome card (MISSION COMPLETE
|
||||
# / GAME OVER) is on screen only briefly, so sampling must not stop early.
|
||||
( n=$(( SECS / 30 + 4 ))
|
||||
for i in $(seq 1 "$n"); do
|
||||
printf '%s SHOT %03d\n' "$(date +%s)" "$i" >> "$OUT/shots.log"
|
||||
screenshot "$SHOTS/$TAG-$(printf %03d "$i").png" >/dev/null 2>&1
|
||||
sleep 30
|
||||
done ) &
|
||||
SHOTTER=$!
|
||||
|
||||
date +%s > "$OUT/t0"
|
||||
python3 "$SD/mission_state.py" watch "$OUT/cfg.json" "$SECS" 2 "$OUT/mission.jsonl" \
|
||||
> "$OUT/mission.log" 2>&1 &
|
||||
WATCHER=$!
|
||||
|
||||
SYLPH_KILL_TURRETS="${SYLPH_KILL_TURRETS:-0}" python3 "$SD/pilot.py" "$OUT/cfg.json" "$SECS" > "$OUT/pilot.log" 2>&1
|
||||
PILOT_RC=$?
|
||||
wait $WATCHER 2>/dev/null
|
||||
kill $SHOTTER 2>/dev/null
|
||||
screenshot "$SHOTS/$TAG-end.png" >/dev/null 2>&1
|
||||
cp -f "$SHOTS/$TAG-end.png" "$OUT/end.png" 2>/dev/null
|
||||
|
||||
echo "PILOT_RC=$PILOT_RC"
|
||||
echo "--- last 5 pilot lines ---"; tail -5 "$OUT/pilot.log"
|
||||
echo "--- shots: $(ls "$SHOTS/$TAG-"*.png 2>/dev/null | wc -l) ---"
|
||||
echo "MISSION RUN DONE ($TAG, ${SECS}s)"
|
||||
@@ -100,6 +100,18 @@ MISSILE_PERIOD = 2.0 # s between launches; 300 rounds is not unlimited
|
||||
# configuration here — override with $SYLPH_ASSET for another stage.
|
||||
ASSET_NAME = os.environ.get("SYLPH_ASSET", "Acropolis")
|
||||
|
||||
# Turrets are keep-out zones everywhere else in this file, and that rule is what
|
||||
# stopped the pilot being shot down. But the 2026-08-10 outcome run measured the
|
||||
# cost of it: of the damage that sinks the ACROPOLIS, roughly half comes from
|
||||
# e007 turrets sitting 1.5-3 km off it (the other half from e010 bombers), and a
|
||||
# turret is 100 HP -- one missile, or ~7 nose-gun hits. Several were already at
|
||||
# 45-95 HP from stray fire and were never finished off, because nothing ever
|
||||
# targets them. So the rule that keeps us alive is also what loses the escort.
|
||||
# Gated rather than simply changed, so the measured baseline stays reproducible:
|
||||
# SYLPH_KILL_TURRETS=1 lets DEFEND -- and only DEFEND -- treat a turret that is
|
||||
# near the asset as a target. ENGAGE still avoids them.
|
||||
KILL_TURRETS = os.environ.get("SYLPH_KILL_TURRETS") == "1"
|
||||
|
||||
|
||||
class Pilot:
|
||||
KP, KD = 2.2, 0.45
|
||||
@@ -230,7 +242,9 @@ class Pilot:
|
||||
"""
|
||||
out = []
|
||||
for off, nm, p, v, r, hard in hos:
|
||||
if hard:
|
||||
# A turret near the asset IS a killable objective (100 HP) when
|
||||
# SYLPH_KILL_TURRETS is set; a capital hull never is.
|
||||
if hard and not (KILL_TURRETS and "Turret" in nm):
|
||||
continue
|
||||
rel = a_p - p
|
||||
d = float(np.linalg.norm(rel))
|
||||
@@ -520,9 +534,12 @@ class Pilot:
|
||||
self.set_throttle(0)
|
||||
fire = True
|
||||
|
||||
# a turret inside its keep-out radius outranks the target
|
||||
# A turret inside its keep-out radius outranks the target -- except the
|
||||
# one we are deliberately attacking, or the keep-out would steer us off
|
||||
# the very thing we chose to kill and neither goal would be served.
|
||||
tgt_off = tgt[0] if tgt else None
|
||||
for off, nm, p, v, r, hard in hos:
|
||||
if not hard:
|
||||
if not hard or (KILL_TURRETS and off == tgt_off):
|
||||
continue
|
||||
d = float(np.linalg.norm(p - me_p))
|
||||
if d < self.TURRET_KEEPOUT:
|
||||
|
||||
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"
|
||||
45
tools/re-capture/ship_capture_session.sh
Executable file
45
tools/re-capture/ship_capture_session.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# ONE blocking session: boot -> Stage 02 in flight -> sweep the view and press
|
||||
# F10 several times, so each press dumps xenia_ship_capture_NN.log with whatever
|
||||
# capital ships are on screen at that moment.
|
||||
#
|
||||
# Why a sweep and not a single press: the 2026-07-26 e106 capture missed the
|
||||
# bridge because it was culled at that camera angle. Several presses at
|
||||
# different headings cost nothing (the capture is a one-frame draw dump) and
|
||||
# each one is independently correlatable.
|
||||
#
|
||||
# Usage: ship_capture_session.sh [presses] [out_dir]
|
||||
set -u
|
||||
export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98
|
||||
SD="$(cd "$(dirname "$0")" && pwd)"
|
||||
PRESSES="${1:-6}"
|
||||
OUT="${2:-/sylph-home/re/shipcap}"
|
||||
BINDIR="/home/fabi/RE - Project Sylpheed/xenia-canary-native/build/bin/Linux/Release"
|
||||
SHOTS=/sylph-home/re/shots
|
||||
mkdir -p "$OUT" "$SHOTS"
|
||||
|
||||
rm -f "$BINDIR"/xenia_ship_capture_*.log
|
||||
|
||||
"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; }
|
||||
|
||||
# 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; }
|
||||
|
||||
for i in $(seq 1 "$PRESSES"); do
|
||||
screenshot "$SHOTS/shipcap-$i.png" >/dev/null 2>&1
|
||||
if [ -n "$win" ]; then xdotool key --window "$win" F10; else xdotool key F10; fi
|
||||
sleep 3
|
||||
# Yaw a little between presses so a culled part gets another chance, and the
|
||||
# craft keeps closing on the friendly formation (the capital ships).
|
||||
vgamepad axis LX 0.45; sleep 1.2; vgamepad axis LX 0.0
|
||||
sleep 3
|
||||
done
|
||||
|
||||
sleep 2
|
||||
cp -v "$BINDIR"/xenia_ship_capture_*.log "$OUT"/ 2>/dev/null
|
||||
cp -v "$SHOTS"/shipcap-*.png "$OUT"/ 2>/dev/null
|
||||
grep -c '^DRAW' "$OUT"/xenia_ship_capture_*.log 2>/dev/null
|
||||
echo "CAPTURE SESSION DONE"
|
||||
Reference in New Issue
Block a user