diff --git a/crates/sylpheed-formats/examples/coverage_audit.rs b/crates/sylpheed-formats/examples/coverage_audit.rs new file mode 100644 index 0000000..e49512f --- /dev/null +++ b/crates/sylpheed-formats/examples/coverage_audit.rs @@ -0,0 +1,48 @@ +//! Do real index buffers address their whole vertex pool? +//! +//! `validate_block` rejects a block whose indices reach fewer than `vtx_count−4` +//! vertices ("buffer not covered"). That gate is the furthest-reached rejection +//! for a handful of resources that never decode — so the question is whether it +//! is well founded. This measures the slack on every block that DOES decode: if +//! real geometry always covers its pool, under-coverage is good evidence of a +//! wrong candidate and the gate stands. +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(); + + let mut hist: BTreeMap = BTreeMap::new(); + let mut worst: Vec<(i64, String)> = Vec::new(); + for f in &files { + let Ok(bytes) = std::fs::read(f) else { continue }; + for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) { + for sub in &m.meshes { + if sub.positions.is_empty() || sub.indices.is_empty() { + continue; + } + let max_idx = *sub.indices.iter().max().unwrap() as i64; + let slack = sub.positions.len() as i64 - 1 - max_idx; + *hist.entry(slack.min(20)).or_default() += 1; + if slack > 4 { + worst.push((slack, format!("{} in {}", m.name, f.file_name().unwrap().to_string_lossy()))); + } + } + } + } + println!("unreferenced tail vertices (vtx_count − 1 − max index), over decoded sub-meshes:"); + for (slack, n) in &hist { + println!(" {:>3}{} : {n}", slack, if *slack == 20 { "+" } else { " " }); + } + worst.sort_by_key(|(s, _)| std::cmp::Reverse(*s)); + for (s, w) in worst.iter().take(5) { + println!(" largest slack {s}: {w}"); + } +} diff --git a/crates/sylpheed-formats/src/mesh.rs b/crates/sylpheed-formats/src/mesh.rs index 563deec..92a952c 100644 --- a/crates/sylpheed-formats/src/mesh.rs +++ b/crates/sylpheed-formats/src/mesh.rs @@ -1391,7 +1391,17 @@ fn anchor_grouped_meshes( if !ok && i > kmax { break; // chain diverged — emit the validated prefix, no garbage } - meshes.push(read_pool_mesh(bytes, ib, vb, ic, vc, decl)); + // Sub-meshes BEFORE the pivot are emitted even when they fail the + // quality gates (a tiny flat lead part is legitimately poor), but + // an index that addresses past its own vertex buffer is not a + // quality question — it is unusable. Measured 2026-08-12: 18 + // sub-meshes disc-wide carried indices up to 364 vertices past + // the end (`coverage_audit`), which any renderer would fault on. + let in_range = + (0..ic).all(|k| (be16(bytes, ib + k * 2) as usize) < vc); + if in_range { + meshes.push(read_pool_mesh(bytes, ib, vb, ic, vc, decl)); + } vb += vc * stride; } return meshes; diff --git a/docs/re/structures/xbg7-mesh.md b/docs/re/structures/xbg7-mesh.md index 652a72d..8b0db1b 100644 --- a/docs/re/structures/xbg7-mesh.md +++ b/docs/re/structures/xbg7-mesh.md @@ -965,6 +965,36 @@ container holds two direct copies **and** two mirrored ones (`0x33b7754`, `0x342e284`); our twins take the two direct copies, which is self-consistent but unverified — `n206` appears in no captured stage. +### ✅ Fixed: grouped pools emitted sub-meshes with out-of-range indices + +Chasing whether the "buffer not covered" gate is well founded turned up a real +defect instead. `examples/coverage_audit.rs` measures, for every decoded +sub-mesh, how many tail vertices its indices never reference: + +| unreferenced tail vertices | sub-meshes | +|---|---| +| **0** (indices reach the last vertex exactly) | **8 586** | +| 1–3 (inside the gate's ±4 tolerance) | 48 | +| 9, 80 (`f102_break.dat`, `f104_break.dat` in `ptc_pack.xpr`) | 2 | +| **negative — indices point PAST the vertex buffer** | **18** | + +The first row answers the original question: real geometry covers its pool +**exactly**, so under-coverage is good evidence of a wrong candidate and the gate +stands as written. + +The last row is the defect. `anchor_grouped_meshes` reads sub-meshes *before* the +pivot unconditionally — deliberately, since a tiny flat lead part legitimately +fails the quality gates — but that also skipped the **index-range** check, which +is not a quality question. Eighteen sub-meshes disc-wide were emitted with +indices reaching up to **364 vertices past the end** of their own buffer, which +any renderer would fault on or draw as garbage. Pre-pivot sub-meshes are now +required to be in range (quality gates still relaxed); out-of-range ones are +dropped and the pool's remaining parts are kept. + +Everything else holds: 6 069/6 294 decoded, cross-container inconsistency 56, the +capture truth table still 46/46 claimed with 0 unclaimed, suite green. The vertex +total falls by 1 546 — exactly the garbage that is no longer emitted. + ### Coverage has a denominator now, and the misses have a cause breakdown Coverage has been quoted as "resources decoded" with no total. `examples/undecoded.rs`