From 845b63d7dd69eeb3d9bba0383751b5ca0098ec93 Mon Sep 17 00:00:00 2001 From: "Claude (auto-RE)" Date: Wed, 12 Aug 2026 02:31:53 +0000 Subject: [PATCH] re: eng_02_l's real block is adjacent -- the connectivity heuristic rejects it debug_find_index_buffer scans the container for an index buffer that validates against a capture-proven vertex buffer. For eng_02_l nothing validates with the connectivity test on; with it off the nearest hit is exact pad-0 adjacency (vb-ib = 144 = 72*2). The block's mean_edge/diag is 0.417 against a 0.28 cap -- the documented false positive for coarse LODs, now caught with ground truth. Co-Authored-By: Claude Opus 5 (1M context) --- crates/sylpheed-formats/examples/find_ib.rs | 27 ++++++ crates/sylpheed-formats/src/mesh.rs | 94 +++++++++++++++++++++ docs/re/structures/xbg7-mesh.md | 34 ++++++++ 3 files changed, 155 insertions(+) create mode 100644 crates/sylpheed-formats/examples/find_ib.rs diff --git a/crates/sylpheed-formats/examples/find_ib.rs b/crates/sylpheed-formats/examples/find_ib.rs new file mode 100644 index 0000000..e8beef7 --- /dev/null +++ b/crates/sylpheed-formats/examples/find_ib.rs @@ -0,0 +1,27 @@ +//! Where could a resource's index buffer be, given the vertex buffer a runtime +//! capture proves the engine drew from? Tests the anchor scan's adjacency +//! assumption against ground truth. +use sylpheed_formats::mesh::{debug_find_index_buffer, debug_resource_params}; +fn main() { + let a: Vec = std::env::args().collect(); + let bytes = std::fs::read(&a[1]).unwrap(); + for pair in a[2..].iter() { + let (name, off) = pair.split_once('@').unwrap(); + let vb = usize::from_str_radix(off.trim_start_matches("0x"), 16).unwrap(); + let params = debug_resource_params(&bytes, name); + let markers = params.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); + println!("{name} @ 0x{vb:x} markers={markers:?}"); + let hits = debug_find_index_buffer(&bytes, name, vb); + if hits.is_empty() { + println!(" no index buffer anywhere in the container validates this block"); + } + let mut hits = hits; + hits.sort_by_key(|(_, d)| d.abs()); + for (ib, d) in hits.iter().take(8) { + println!(" ib 0x{ib:x} vb-ib = {d} bytes"); + } + if hits.len() > 8 { + println!(" … {} total", hits.len()); + } + } +} diff --git a/crates/sylpheed-formats/src/mesh.rs b/crates/sylpheed-formats/src/mesh.rs index 27023bc..dde2926 100644 --- a/crates/sylpheed-formats/src/mesh.rs +++ b/crates/sylpheed-formats/src/mesh.rs @@ -673,6 +673,100 @@ pub fn debug_try_anchor( None } +/// 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 +/// container instead and returns every offset that validates as this resource's +/// index buffer. An empty result means the block is unreadable at that `vb` for +/// another reason; a hit far from `vb` means the adjacency assumption is what +/// fails. Returns `(ib_offset, signed distance vb - ib)` pairs. +pub fn debug_find_index_buffer(bytes: &[u8], name: &str, vb: usize) -> Vec<(usize, i64)> { + let Some(decl) = decl_of(bytes, name) else { + return Vec::new(); + }; + let markers = decl.1; + let decl = decl.0; + let Some(&(vtx_count, index_count)) = markers.first() else { + return Vec::new(); + }; + let mut out = Vec::new(); + let end = bytes.len().saturating_sub(index_count * 2); + let mut ib = 0usize; + while ib < end { + // Cheap prefilter: the first few indices must be in range, and a real + // index buffer is not a run of zeros. + let ok = (0..6).all(|k| (be16(bytes, ib + k * 2) as usize) < vtx_count) + && (0..6).any(|k| be16(bytes, ib + k * 2) != 0); + // `SOFT_IB=1` drops the connectivity requirement, to separate "no index + // buffer fits" from "our connectivity test is too strict". + let strict = std::env::var("SOFT_IB").is_err(); + if ok && validate_block(bytes, ib, vb, vtx_count, index_count, &decl, 0.0, strict) { + out.push((ib, vb as i64 - ib as i64)); + } + ib += 2; + } + out +} + +/// 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" { + return None; + } + let mut cur = Cursor::new(bytes); + let header = Xpr2Header::read(&mut cur).ok()?; + const DIR_BASE: usize = 0x10; + for _ in 0..header.num_resources { + let Ok(e) = Xpr2ResourceEntry::read(&mut cur) else { break }; + if &e.type_tag != b"XBG7" { + continue; + } + let desc = e.data_offset as usize + DIR_BASE; + let desc_end = (desc + e.descriptor_size as usize).min(bytes.len()); + if desc >= bytes.len() || desc_end <= desc { + continue; + } + let rname = read_cstr(bytes, e.name_offset as usize + DIR_BASE) + .unwrap_or_else(|| "XBG7".to_string()); + if rname != name { + continue; + } + let d = &bytes[desc..desc_end]; + return Some((parse_vertex_decl(d)?, all_index_markers(d))); + } + None +} + +/// Diagnostic: a resource's `(vtx_count, idx_count)` markers and vertex stride, +/// as the stage anchor scan reads them from the descriptor. +pub fn debug_resource_params(bytes: &[u8], name: &str) -> Option<(Vec<(usize, usize)>, usize)> { + if bytes.len() < 16 || &bytes[..4] != b"XPR2" { + return None; + } + let mut cur = Cursor::new(bytes); + let header = Xpr2Header::read(&mut cur).ok()?; + const DIR_BASE: usize = 0x10; + for _ in 0..header.num_resources { + let Ok(e) = Xpr2ResourceEntry::read(&mut cur) else { break }; + if &e.type_tag != b"XBG7" { + continue; + } + let desc = e.data_offset as usize + DIR_BASE; + let desc_end = (desc + e.descriptor_size as usize).min(bytes.len()); + if desc >= bytes.len() || desc_end <= desc { + continue; + } + let rname = read_cstr(bytes, e.name_offset as usize + DIR_BASE) + .unwrap_or_else(|| "XBG7".to_string()); + if rname != name { + continue; + } + let d = &bytes[desc..desc_end]; + return Some((all_index_markers(d), parse_vertex_decl(d)?.stride)); + } + None +} + /// Diagnostic: the candidate vertex-buffer starts the stage anchor scan will /// consider for a given `stride`, for one container. A runtime capture names the /// offsets the engine really drew from (see `examples/shared_vbase_check.rs`), so diff --git a/docs/re/structures/xbg7-mesh.md b/docs/re/structures/xbg7-mesh.md index d0bb720..d23625b 100644 --- a/docs/re/structures/xbg7-mesh.md +++ b/docs/re/structures/xbg7-mesh.md @@ -714,6 +714,40 @@ sub-range, a different unit, or a Xenia-side artifact is unknown — it may matt for `eng_02_l`, whose block validation is exactly what an index-count mismatch would break. +**6. Why `eng_02_l`'s real block is rejected: the connectivity heuristic.** +`mesh::debug_find_index_buffer` scans the *whole container* for an index buffer +that validates against a known vertex buffer, instead of assuming adjacency +(`examples/find_ib.rs`). For `e106_eng_02_l` at the capture-proven `0x44a32c`, +with the connectivity test on, **nothing in the container validates**. With it +off (`SOFT_IB=1`) the nearest hit is `ib 0x44a29c` — `vb − ib = 144 = 72 × 2`, +i.e. **exact pad-0 adjacency**. So the index buffer is exactly where the decoder +assumes it is; the block is thrown out by one heuristic. + +That heuristic is `mean_edge / bbox_diag > 0.28 → reject`. Measured on the real +blocks: + +| block | mean edge | bbox diagonal | ratio | verdict | +|---|---|---|---|---| +| `eng_02_l` (24 tris) `ib 0x44a29c → vb 0x44a32c` | 109.21 | 261.96 | **0.417** | rejected (cap 0.28) | +| `bdy_02_l` (82 tris) `ib 0x3c53ec → vb 0x3c55d8` | 131.70 | 786.66 | 0.167 | passes | +| `bdy_01_l` (82 tris) `ib 0x3b3cfc → vb 0x3b3ee8` | 131.70 | 786.66 | 0.167 | passes | + +This is precisely the false positive the check's own comment predicts — "a small +flat sub-mesh legitimately has large edges relative to its own diagonal" — caught +in the wild for the first time, with the runtime naming the block it rejects. A +24-triangle engine LOD is coarse by construction, so its edges *are* a large +fraction of its size. + +(The twins' two real blocks having **identical** mean edge and diagonal is a free +corroboration that they are mirror images: reflection preserves lengths.) + +So the residual class is not one bug. `eng_02_l` has its vertex start in the +candidate list *and* its index buffer exactly adjacent, and still fails — a +**validator** problem, not a scan or selection one. Raising the cap is not the +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. + 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.