diff --git a/crates/sylpheed-formats/examples/edge_cap_sweep.rs b/crates/sylpheed-formats/examples/edge_cap_sweep.rs new file mode 100644 index 0000000..a310ecc --- /dev/null +++ b/crates/sylpheed-formats/examples/edge_cap_sweep.rs @@ -0,0 +1,80 @@ +//! Calibrate the connectivity cap against the whole disc. +//! +//! `XBG7_EDGE_CAP` sets the cap; this reports, for one setting, how much +//! geometry decodes and how self-consistent it is across containers — the two +//! numbers any change to the cap has to trade off. Run it once per cap value. +use sylpheed_formats::mesh::Xbg7Model; +use std::collections::BTreeMap; + +fn main() { + let dir = std::env::args().nth(1).expect("resource3d dir"); + let mut files: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr")) + .collect(); + files.sort(); + + // name -> (verts, tris) -> spans seen, exactly as mesh_consistency_disc.rs. + let mut seen: BTreeMap> = BTreeMap::new(); + let (mut models, mut verts) = (0usize, 0usize); + for f in &files { + let Ok(bytes) = std::fs::read(f) else { continue }; + for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) { + let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]); + for s in &m.meshes { + for q in &s.positions { + for k in 0..3 { + lo[k] = lo[k].min(q[k]); + hi[k] = hi[k].max(q[k]); + } + } + } + if lo[0] == f32::MAX { + continue; + } + let v: usize = m.meshes.iter().map(|s| s.positions.len()).sum(); + let t: usize = m.meshes.iter().map(|s| s.indices.len() / 3).sum(); + models += 1; + verts += v; + if std::env::var("DUMP").is_ok() { + // Per-resource signature, so two cap settings can be diffed: + // a cap change that silently MOVES an existing anchor is the + // risk a coverage count cannot see. + println!( + "{}|{}|{v}|{t}|{}|{}|{}|{}", + f.file_name().unwrap().to_string_lossy(), + m.name, + m.meshes[0].vbuf_offset.unwrap_or(0), + (hi[0] - lo[0]).round() as i64, + (hi[1] - lo[1]).round() as i64, + (hi[2] - lo[2]).round() as i64 + ); + } + seen.entry(m.name.clone()).or_default().push(( + [ + (hi[0] - lo[0]).round() as i64, + (hi[1] - lo[1]).round() as i64, + (hi[2] - lo[2]).round() as i64, + ], + v, + t, + )); + } + } + let (mut shared, mut inconsistent) = (0usize, 0usize); + for (_, list) in &seen { + if list.len() < 2 || !list.iter().all(|e| e.1 == list[0].1 && e.2 == list[0].2) { + continue; + } + shared += 1; + if list.iter().any(|e| e.0 != list[0].0) { + inconsistent += 1; + } + } + println!( + "cap={} models={models} verts={verts} shared={shared} inconsistent={inconsistent}", + std::env::var("XBG7_EDGE_CAP").unwrap_or_else(|_| "0.28 (default)".into()) + ); +} diff --git a/crates/sylpheed-formats/src/mesh.rs b/crates/sylpheed-formats/src/mesh.rs index dde2926..404220b 100644 --- a/crates/sylpheed-formats/src/mesh.rs +++ b/crates/sylpheed-formats/src/mesh.rs @@ -708,6 +708,27 @@ pub fn debug_find_index_buffer(bytes: &[u8], name: &str, vb: usize) -> Vec<(usiz out } +/// The connectivity cap: a searched block whose mean triangle edge exceeds this +/// fraction of its bounding-box diagonal is rejected. `0.28` is the shipped +/// value. A runtime capture names blocks the engine really draws, and one of them +/// (`e106_eng_02_l`, a 24-triangle LOD) measures **0.417** — so the cap has a +/// known false positive. `XBG7_EDGE_CAP` overrides it for calibration sweeps; +/// it is a diagnostic knob, not a setting (see docs/re/structures/xbg7-mesh.md). +fn edge_cap() -> f32 { + std::env::var("XBG7_EDGE_CAP").ok().and_then(|v| v.parse().ok()).unwrap_or(0.28) +} + +/// Triangle count below which the looser [`small_cap`] applies. `0` (default) +/// disables the split, so the flat [`edge_cap`] governs every block. +fn small_tris() -> usize { + std::env::var("XBG7_SMALL_TRIS").ok().and_then(|v| v.parse().ok()).unwrap_or(0) +} + +/// The connectivity cap for blocks below [`small_tris`] triangles. +fn small_cap() -> f32 { + std::env::var("XBG7_EDGE_CAP_SMALL").ok().and_then(|v| v.parse().ok()).unwrap_or(0.45) +} + /// Internal: the descriptor parameters the diagnostics need. fn decl_of(bytes: &[u8], name: &str) -> Option<(VertexDecl, Vec<(usize, usize)>)> { if bytes.len() < 16 || &bytes[..4] != b"XPR2" { @@ -987,7 +1008,13 @@ fn validate_block( .sqrt() .max(1e-6); let mean_edge = edge_sum / (sampled as f32 * 3.0); - if mean_edge / diag > 0.28 { + // A COARSE block is coarse by construction: a 24-triangle LOD's edges + // are a large fraction of its own size, which is why the flat cap has a + // capture-proven false positive (`e106_eng_02_l`, ratio 0.417). Under + // `XBG7_SMALL_TRIS` blocks below that triangle count get the looser + // `XBG7_EDGE_CAP_SMALL` instead — a targeted relaxation, off by default. + let cap = if tris < small_tris() { small_cap() } else { edge_cap() }; + if mean_edge / diag > cap { return false; } } diff --git a/docs/re/structures/xbg7-mesh.md b/docs/re/structures/xbg7-mesh.md index d23625b..e4463be 100644 --- a/docs/re/structures/xbg7-mesh.md +++ b/docs/re/structures/xbg7-mesh.md @@ -748,6 +748,39 @@ fix to reach for blind: the threshold trades against false anchors, and now that a capture can name true blocks, it can be **calibrated** against them rather than guessed. Not changed here. +**7. Calibrating the cap: a real trade, not a free win.** `XBG7_EDGE_CAP` +(and `XBG7_SMALL_TRIS` / `XBG7_EDGE_CAP_SMALL` for a triangle-count-aware +variant) make the threshold sweepable without changing the default; +`examples/edge_cap_sweep.rs` reports coverage and cross-container consistency per +setting. Over the whole `resource3d` directory: + +| cap | resources decoded | anchors moved vs 0.28 | shared | inconsistent | consistent → **inconsistent** | inconsistent → consistent | +|---|---|---|---|---|---|---| +| **0.28** (shipped) | 5 480 | — | 678 | 125 | — | — | +| 0.35 | 5 955 | — | 707 | 140 | — | — | +| 0.42 | 6 069 | 254 | 714 | 153 | **18** | 3 | +| 0.45 | 6 093 | 254 | 716 | 153 | 18 | 3 | +| 0.28, but 0.45 below 64 tris | 6 090 | 236 | 716 | 151 | **16** | 3 | + +**Nothing is ever lost** — every resource that decoded at 0.28 still decodes — +and the capture-proven case is fixed: at any cap above 0.417, `e106_eng_02_l` +anchors at exactly `0x44a32c`, and the four e106 parts that were already correct +stay correct. So on the only ground truth available, relaxing is a strict +improvement (4 → 5 of the ship's drawn buffers correct). + +But it is bought: ~590–610 resources that previously decoded not at all now do, +**~240–254 existing anchors silently move**, and 16–18 shared resources go from +cross-container consistent to inconsistent (against 3 repaired). The +triangle-aware variant barely narrows that — almost everything the looser cap +admits is a small block anyway. + +**So the cap is not changed here.** The evidence says 0.28 is too tight and that +mean-edge-over-diagonal is a weak discriminator for coarse LODs; it does not say +0.45 is right, because the 254 movers have no oracle. Deciding needs either a +capture covering more ships and stages (the same `--truth` method extends to any +container the engine drew from) or a discriminator that does not degrade for +coarse geometry. Both are recorded as the next step rather than guessed at. + Not settled: `e106_brg_01_b_02` ≡ `e106_brg_01_l` (51 verts). A second 51-vertex `vbase` exists in the logs but is **not** from this container, and the container holds three near-identical 51-vertex runs, so the pair has no oracle yet.