diff --git a/crates/sylpheed-formats/examples/starts.rs b/crates/sylpheed-formats/examples/starts.rs new file mode 100644 index 0000000..69f8691 --- /dev/null +++ b/crates/sylpheed-formats/examples/starts.rs @@ -0,0 +1,13 @@ +use sylpheed_formats::mesh::{debug_resource_params, debug_vertex_run_starts}; +fn main() { + let a: Vec = std::env::args().collect(); + let bytes = std::fs::read(&a[1]).unwrap(); + let (markers, stride) = debug_resource_params(&bytes, &a[2]).expect("resource"); + println!("{}: stride={stride} markers={markers:?}", a[2]); + let starts = debug_vertex_run_starts(&bytes, stride); + println!("{} candidate starts at stride {stride}", starts.len()); + for off in &a[3..] { + let o = usize::from_str_radix(off.trim_start_matches("0x"), 16).unwrap(); + println!(" 0x{o:x} in candidate list: {}", starts.binary_search(&o).is_ok()); + } +} diff --git a/crates/sylpheed-formats/examples/twin_mirror_audit.rs b/crates/sylpheed-formats/examples/twin_mirror_audit.rs index ecb9ade..6fb5610 100644 --- a/crates/sylpheed-formats/examples/twin_mirror_audit.rs +++ b/crates/sylpheed-formats/examples/twin_mirror_audit.rs @@ -74,6 +74,15 @@ fn main() { related += 1; continue; } + // Identical GEOMETRY is only a collapse if it also comes from the + // same buffer: a container may store a part twice unmirrored, and + // then two twins on two copies decode identically and correctly. + let shared_buffer = m.meshes[0].vbuf_offset.is_some() + && m.meshes[0].vbuf_offset == t.meshes[0].vbuf_offset; + if ident && !shared_buffer { + related += 1; + continue; + } if ident { same += 1; if examples.len() < 6 { @@ -101,7 +110,7 @@ fn main() { } println!("twin pairs of equal vertex count: {}", same + mirrored + unrelated + related); println!(" exact X-mirror (expected) : {mirrored}"); - println!(" IDENTICAL (collapse) : {same}"); + println!(" IDENTICAL, one buffer (collapse) : {same}"); println!(" related other way (Y/Z mirror, reordered): {related}"); println!(" unrelated (mis-anchor?) : {unrelated}"); for e in examples { diff --git a/crates/sylpheed-formats/examples/why_rejected.rs b/crates/sylpheed-formats/examples/why_rejected.rs new file mode 100644 index 0000000..57f420c --- /dev/null +++ b/crates/sylpheed-formats/examples/why_rejected.rs @@ -0,0 +1,14 @@ +//! Why does the decoder refuse a grouped-pool resource at a given pool start? +//! Usage: why_rejected @... +use sylpheed_formats::mesh::debug_grouped_report; +fn main() { + let a: Vec = std::env::args().collect(); + let bytes = std::fs::read(&a[1]).expect("container"); + for pair in &a[2..] { + let (name, off) = pair.split_once('@').expect("name@hex"); + let vb0 = usize::from_str_radix(off.trim_start_matches("0x"), 16).expect("hex"); + for line in debug_grouped_report(&bytes, name, vb0) { + println!("{line}"); + } + } +} diff --git a/crates/sylpheed-formats/src/mesh.rs b/crates/sylpheed-formats/src/mesh.rs index e3087da..a093ecf 100644 --- a/crates/sylpheed-formats/src/mesh.rs +++ b/crates/sylpheed-formats/src/mesh.rs @@ -755,6 +755,60 @@ pub fn debug_try_anchor( None } +/// Diagnostic: why does the decoder refuse a grouped-pool resource at a given +/// pool start? Recomputes the pool layout exactly as [`anchor_grouped_meshes`] +/// does and reports the pivot sub-mesh's verdict for each index/vertex pad — +/// so a capture-proven pool that the decoder rejects names the gate to fix. +pub fn debug_grouped_report(bytes: &[u8], name: &str, vb0: usize) -> Vec { + let Some((decl, markers)) = decl_of(bytes, name) else { + return vec!["no such XBG7 resource".into()]; + }; + let n = markers.len(); + if n == 0 { + return vec!["no index markers".into()]; + } + let (mut rel_ib, mut acc_i) = (Vec::with_capacity(n), 0usize); + for &(_, ic) in &markers { + rel_ib.push(acc_i); + acc_i = align4(acc_i + ic * 2); + } + let span = rel_ib[n - 1] + markers[n - 1].1 * 2; + let kmax = (0..n).max_by_key(|&i| markers[i].1).unwrap_or(0); + let (vck, ick) = markers[kmax]; + let mut off_v = 0usize; + for &(vc, _) in markers.iter().take(kmax) { + off_v += vc * decl.stride; + } + let mut out = vec![format!( + "{name}: {n} sub-meshes, pivot #{kmax} ({vck} verts, {ick} idx), pool span {span}" + )]; + for pad in 0..=3usize { + if vb0 < span + pad { + out.push(format!(" pad {pad}: pool start is before the index pool")); + continue; + } + let ib0 = vb0 - span - pad; + // Same gates the production pivot test uses: strict winding (0.85) + // AND connectivity. Reporting at 0.0 would accept blocks the decoder + // rejects and send the reader chasing the wrong gate. + let verdict = validate_block_report( + bytes, + ib0 + rel_ib[kmax], + vb0 + off_v, + vck, + ick, + &decl, + 0.85, + true, + ); + out.push(match verdict { + Ok(()) => format!(" pad {pad}: ACCEPTED"), + Err(why) => format!(" pad {pad}: {why}"), + }); + } + out +} + /// Diagnostic: for a resource whose vertex buffer is *known* (a runtime capture /// names it), where could its index buffer be? The anchor scan assumes the index /// buffer sits immediately before the vertex buffer; this scans the whole @@ -998,17 +1052,43 @@ fn validate_block( min_consistency: f32, strict_connectivity: bool, ) -> bool { + validate_block_report( + bytes, + ib, + vb, + vtx_count, + index_count, + decl, + min_consistency, + strict_connectivity, + ) + .is_ok() +} + +/// [`validate_block`], but naming the gate that rejected a block. A runtime +/// capture can prove a block is real; when the decoder still refuses it, this +/// says which test is wrong rather than leaving a threshold to be guessed at. +fn validate_block_report( + bytes: &[u8], + ib: usize, + vb: usize, + vtx_count: usize, + index_count: usize, + decl: &VertexDecl, + min_consistency: f32, + strict_connectivity: bool, +) -> Result<(), String> { let stride = decl.stride; let idx_bytes = index_count * 2; if ib + idx_bytes > bytes.len() { - return false; + return Err("index buffer runs past the container".into()); } let vtx_bytes = match vtx_count.checked_mul(stride) { Some(v) => v, - None => return false, + None => return Err("vertex byte count overflows".into()), }; if vb + vtx_bytes > bytes.len() { - return false; + return Err("vertex buffer runs past the container".into()); } // ── Full index validation: every index in range, uses ~all vertices. ── @@ -1016,12 +1096,14 @@ fn validate_block( for k in 0..index_count { let i = be16(bytes, ib + k * 2) as u32; if i >= vtx_count as u32 { - return false; + return Err(format!("index {i} out of range (vtx_count {vtx_count})")); } max_idx = max_idx.max(i); } if (max_idx as usize) + 4 < vtx_count { - return false; + return Err(format!( + "indices reach only {max_idx} of {vtx_count} vertices (buffer not covered)" + )); } // ── Triangle quality: finite, non-degenerate, real spatial extent. ── @@ -1046,7 +1128,7 @@ fn validate_block( for (a, slot) in pc.iter_mut().enumerate() { let x = bef(bytes, base + a * 4); if !x.is_finite() || x.abs() > 1.0e6 { - return false; + return Err(format!("position component {x} is not finite/plausible")); } *slot = x; lo[a] = lo[a].min(x); @@ -1088,7 +1170,9 @@ fn validate_block( } let extent = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(hi[2] - lo[2]); if extent < 0.5 || sampled == 0 || degenerate * 10 > sampled * 3 { - return false; // too flat, or >30% degenerate → not this block + return Err(format!( + "extent {extent:.3} (min 0.5), {degenerate}/{sampled} degenerate (max 30%)" + )); } // Connectivity check: a correctly-anchored mesh has triangle edges that // are SMALL relative to its overall size (~0.05–0.15 of the bbox @@ -1111,7 +1195,10 @@ fn validate_block( // `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; + return Err(format!( + "connectivity: mean_edge/diag {:.3} > cap {cap:.2}", + mean_edge / diag + )); } } // Winding-consistency gate (same as `from_xpr2`): a correctly-anchored @@ -1123,10 +1210,13 @@ fn validate_block( if nrm_counted > 0 && min_consistency > 0.0 { let na = nrm_agree as f32 / nrm_counted as f32; if na.max(1.0 - na) < min_consistency { - return false; + return Err(format!( + "winding consistency {:.3} < {min_consistency:.2}", + na.max(1.0 - na) + )); } } - true + Ok(()) } /// Decode a **grouped-pool** XBG7 resource: one whose descriptor holds *several* diff --git a/crates/sylpheed-formats/tests/mesh_consistency_disc.rs b/crates/sylpheed-formats/tests/mesh_consistency_disc.rs index 639a972..8523fe7 100644 --- a/crates/sylpheed-formats/tests/mesh_consistency_disc.rs +++ b/crates/sylpheed-formats/tests/mesh_consistency_disc.rs @@ -108,10 +108,9 @@ fn shared_resources_decode_identically_in_every_container() { /// A runtime capture showed the container stores both halves of the `e106` hull /// as separate X-reflected buffers, so a `…_01`/`…_02` pair of equal vertex /// count should come out mirrored (or related by another axis / vertex order) — -/// never identical, which is the collapse distinct assignment fixes. Disc-wide -/// this holds for every such pair except `n206`, whose twins are grouped-pool -/// resources (4 sub-meshes) and so are excluded from distinct assignment; that -/// one is the remaining known case, asserted explicitly so it cannot grow. +/// never identical, which is the collapse distinct assignment fixes. Since that +/// fix reached grouped pools too, this holds for **every** twin pair on the disc +/// — including `n206`, which was the last exception. #[test] fn twin_pairs_do_not_share_a_buffer() { let Some(root) = disc_root() else { @@ -141,6 +140,5 @@ fn twin_pairs_do_not_share_a_buffer() { } } } - collapsed.retain(|c| !c.starts_with("n206_01")); assert!(collapsed.is_empty(), "twin pairs sharing one buffer: {collapsed:?}"); } diff --git a/docs/re/structures/xbg7-mesh.md b/docs/re/structures/xbg7-mesh.md index dd9edca..d7fe6ca 100644 --- a/docs/re/structures/xbg7-mesh.md +++ b/docs/re/structures/xbg7-mesh.md @@ -945,13 +945,25 @@ cannot regress). The runtime oracle is unchanged and cross-container inconsistency falls another 10 %. The suite, including the disc- and ISO-gated tests, stays green. -**It does *not* clear the `n206` collapse**, and that is informative: both twins -are grouped, the loser is re-placed past the taken pool, and no alternative pool -**validates** — so it keeps the collided decode. `n206_02` is therefore the same -class as `e106_eng_02_l` was before the cap moved: the correct block is rejected -by the validator, not lost to selection. Its correct pool is one of -`0x342d984` (direct) or `0x33b7754` / `0x342e284` (mirrored); a capture of a -stage containing `n206` would say which, and is the cheapest way to settle it. +**It clears the `n206` collapse too** — a correction to what was written here +first. `n206_02` now anchors at `0x342d984` instead of sharing `0x33b6e54` with +its twin. The earlier "no alternative pool validates" reading was wrong twice +over: `mesh::debug_grouped_report` (`examples/why_rejected.rs`) shows that pool +**ACCEPTED at pad 0** under the production gates, and `0x342d984` is in the +candidate start list. What actually misled the check was the audit itself — +it classified twins by decoded **geometry**, and `0x342d984` is a *direct* +(unmirrored) copy of `0x33b6e54`, so the pair still looked "identical" after it +had been separated. + +The audit now distinguishes the two: identical geometry is only a collapse when +it comes from **one buffer**. Re-run disc-wide, of 34 equal-count twin pairs: +**18 exact X-mirror, 16 related another way, 0 identical-sharing-a-buffer, 0 +unrelated** — and the regression test no longer needs its `n206` exception. + +❔ Left open: whether `n206_01`/`n206_02` *should* be a mirrored pair at all. The +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. ### The twin invariant, checked disc-wide (2026-08-12)