diff --git a/crates/sylpheed-formats/examples/submesh_dump.rs b/crates/sylpheed-formats/examples/submesh_dump.rs new file mode 100644 index 0000000..cb8497a --- /dev/null +++ b/crates/sylpheed-formats/examples/submesh_dump.rs @@ -0,0 +1,24 @@ +//! Per-sub-mesh vertex/index/coverage dump for one resource. +//! Usage: submesh_dump ... +use sylpheed_formats::mesh::{debug_resource_params, Xbg7Model}; +use std::collections::HashSet; +fn main() { + let a: Vec = std::env::args().collect(); + let bytes = std::fs::read(&a[1]).expect("container"); + let want: HashSet = a[2..].iter().cloned().collect(); + for m in Xbg7Model::models_named(&bytes, &want, &|| false) { + let markers = debug_resource_params(&bytes, &m.name).map(|(mk, _)| mk).unwrap_or_default(); + println!("{} — {} sub-meshes decoded, {} markers declared", m.name, m.meshes.len(), markers.len()); + for (i, s) in m.meshes.iter().enumerate() { + let max_idx = s.indices.iter().max().copied().unwrap_or(0) as usize; + println!( + " #{i:<2} at 0x{:<9x} verts {:<6} idx {:<6} max_idx {:<6} slack {}", + s.vbuf_offset.unwrap_or(0), + s.positions.len(), + s.indices.len(), + max_idx, + s.positions.len() as i64 - 1 - max_idx as i64 + ); + } + } +} diff --git a/crates/sylpheed-formats/src/mesh.rs b/crates/sylpheed-formats/src/mesh.rs index 92a952c..0dcb23c 100644 --- a/crates/sylpheed-formats/src/mesh.rs +++ b/crates/sylpheed-formats/src/mesh.rs @@ -1397,9 +1397,20 @@ fn anchor_grouped_meshes( // 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 { + // Same two structural requirements the searched path enforces: + // every index inside the buffer, and the indices reaching the + // end of it. Real geometry covers its pool exactly — 8 586 of + // 8 636 decoded sub-meshes reference their last vertex, none + // more than 3 short (`coverage_audit`) — so a sub-mesh whose + // indices stop well short is reading the wrong block, not a + // sparse one. + let mut max_idx = 0usize; + let in_range = (0..ic).all(|k| { + let i = be16(bytes, ib + k * 2) as usize; + max_idx = max_idx.max(i); + i < vc + }); + if in_range && max_idx + 4 >= vc { meshes.push(read_pool_mesh(bytes, ib, vb, ic, vc, decl)); } vb += vc * stride; diff --git a/docs/re/structures/xbg7-mesh.md b/docs/re/structures/xbg7-mesh.md index 8b0db1b..2e61fd8 100644 --- a/docs/re/structures/xbg7-mesh.md +++ b/docs/re/structures/xbg7-mesh.md @@ -995,6 +995,20 @@ Everything else holds: 6 069/6 294 decoded, cross-container inconsistency 56, th 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. +**The same reasoning finished the job.** The two remaining outliers were the only +decoded blocks that did not cover their pool: `f102_break.dat` had a sub-mesh +declared 414 vertices whose indices stopped at 404, and `f104_break.dat` one +declared 160 whose indices stopped at 79 — both reading a *neighbouring* block's +index buffer against the wrong declaration. These are grouped `.dat` composites +in `ptc_pack.xpr` whose marker lists (9 and 10 entries) clearly do not map 1:1 +onto the stored blocks; only 2 of 9 and 4 of 10 sub-meshes ever decoded. Applying +the **coverage** requirement to pre-pivot sub-meshes as well drops exactly those +two mismatched pieces and keeps the rest. + +Every decoded sub-mesh on the disc now covers its own vertex pool: **8 580 at +slack 0, 49 within the ±4 tolerance, none beyond it, none negative.** Coverage, +consistency and the capture oracle are all unchanged by the tightening. + ### 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`