diff --git a/crates/sylpheed-formats/src/mesh.rs b/crates/sylpheed-formats/src/mesh.rs index 0dcb23c..3e9bb04 100644 --- a/crates/sylpheed-formats/src/mesh.rs +++ b/crates/sylpheed-formats/src/mesh.rs @@ -764,6 +764,7 @@ pub fn debug_try_anchor( pub fn debug_best_rejection(bytes: &[u8], name: &str) -> Option<(usize, String)> { let (decl, markers) = decl_of(bytes, name)?; let starts = debug_vertex_run_starts(bytes, decl.stride); + #[allow(clippy::type_complexity)] let rank = |why: &str| -> usize { if why.contains("out of range") { 1 @@ -779,11 +780,48 @@ pub fn debug_best_rejection(bytes: &[u8], name: &str) -> Option<(usize, String)> 0 } }; - // Only the single-block path is modelled here. A grouped-pool resource is - // placed by its pivot, so running the single-block loop on `markers[0]` - // would report a gate the decoder never consulted. + // A grouped-pool resource is placed by its PIVOT sub-mesh, so it needs the + // grouped candidate loop; running the single-block loop on `markers[0]` + // would report a gate the decoder never consulted for it. if markers.len() > 1 { - return Some((9, format!("grouped pool ({} sub-meshes) — not analysed here", markers.len()))); + let n = markers.len(); + 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 off_v: usize = markers.iter().take(kmax).map(|&(vc, _)| vc * decl.stride).sum(); + let mut best = (0usize, String::from("no pool start reached any gate")); + for &vb0 in &starts { + for pad in 0..=3usize { + if vb0 < span + pad { + continue; + } + let ib0 = vb0 - span - pad; + match validate_block_report( + bytes, + ib0 + rel_ib[kmax], + vb0 + off_v, + vck, + ick, + &decl, + 0.85, + true, + ) { + Ok(()) => return None, // the pivot would have anchored + Err(why) => { + let r = rank(&why); + if r > best.0 { + best = (r, why); + } + } + } + } + } + return Some(best); } let (vtx_count, index_count) = *markers.first()?; let idx_bytes = index_count * 2; @@ -908,7 +946,14 @@ pub fn debug_find_index_buffer(bytes: &[u8], name: &str, vb: usize) -> Vec<(usiz /// The connectivity cap: a searched block whose mean triangle edge exceeds this /// fraction of its bounding-box diagonal is rejected. /// -/// **0.42 since 2026-08-12**, raised from 0.28 on runtime evidence. A capture +/// **1.0 since 2026-08-12 — effectively inert.** The winding-consistency gate +/// ([`pad0_consistency`]) replaced this as the primary structural test: it is an +/// objective topology signal rather than a shape heuristic, and swapping them +/// decodes **143 more** resources with **17 fewer** cross-container +/// inconsistencies while the capture oracle stays at 46/46. The cap is kept as a +/// knob and a backstop against absurd blocks. History below. +/// +/// **0.42 from 2026-08-12**, raised from 0.28 on runtime evidence. A capture /// names the blocks the engine really draws, and the old cap rejected one of them /// outright — `e106_eng_02_l`, a 24-triangle LOD, measures **0.417**, because a /// coarse mesh's edges *are* a large fraction of its own size. Swept against the @@ -918,7 +963,31 @@ pub fn debug_find_index_buffer(bytes: &[u8], name: &str, vb: usize) -> Vec<(usiz /// value that captures the whole measured gain. `XBG7_EDGE_CAP` overrides it (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.42) + std::env::var("XBG7_EDGE_CAP").ok().and_then(|v| v.parse().ok()).unwrap_or(1.0) +} + +/// Winding-consistency floor for the pad-0 single-block anchor. +/// +/// **0.70 since 2026-08-12.** A triangle's face normal should agree with its +/// vertices' stored normals almost always (≈1.0) or almost never (≈0.0, inverted +/// winding); a mis-carve wires arbitrary vertices and lands near 0.5. Gating on +/// `max(na, 1−na)` is therefore an objective topology test, where the +/// connectivity cap it replaces is a shape heuristic that provably rejected a +/// capture-proven block. Measured over the disc, with connectivity inert: +/// +/// | floor | resources decoded | shared inconsistent | +/// |---|---|---| +/// | 0.60 | 6 214 | 53 | +/// | **0.70** | **6 212** | **39** | +/// | 0.80 | 5 770 | 1 | +/// | 0.85 | 5 770 | 0 | +/// +/// 0.70 dominates the previous connectivity-only default (6 069 / 56) on both +/// axes with the capture oracle unchanged, so it ships. The cliff at 0.80 buys +/// perfect cross-container consistency for 442 resources — recorded rather than +/// taken, since consistency is the weaker witness (see the docs). +fn pad0_consistency() -> f32 { + std::env::var("XBG7_PAD0_CONSISTENCY").ok().and_then(|v| v.parse().ok()).unwrap_or(0.70) } /// Triangle count below which the looser [`small_cap`] applies. `0` (default) @@ -1081,8 +1150,14 @@ fn anchor_pool_mesh( continue; } let ib = vb - idx_bytes - pad; + // `XBG7_PAD0_CONSISTENCY` adds a winding requirement to the pad-0 + // path, which has none by default. Winding agreement is an objective + // topology signal (≈1.0 or ≈0.0 for a real mesh, ≈0.5 for a + // mis-carve) where the connectivity cap is a shape heuristic with a + // capture-proven false positive — so it is the candidate replacement + // for that cap. Off by default; see docs/re/structures/xbg7-mesh.md. let mc = if pad == 0 { - min_consistency + min_consistency.max(pad0_consistency()) } else { min_consistency.max(0.85) }; diff --git a/docs/re/captures/stage-s02-capture-truth-offsets.txt b/docs/re/captures/stage-s02-capture-truth-offsets.txt index 5d7dafe..e7dccfe 100644 --- a/docs/re/captures/stage-s02-capture-truth-offsets.txt +++ b/docs/re/captures/stage-s02-capture-truth-offsets.txt @@ -6,7 +6,7 @@ file offset vcount candidate claimed by our decode ou 0x1a0befc 2296 yes f101_bdy_03(2296) f101_bdy_03 f101_bdy_03 @ -0x0 0x1a22c1c 7694 yes f101_eng_01(7694) f101_eng_01 f101_eng_01 @ -0x0 0x1be5df4 628 yes f101_wep_01_m(628) f101_wep_01_m f101_wep_01_m @ -0x0 -0x1bf40f4 261 yes e_rob_f001(24), _rou_f105_break(261), e_rou_f106(24), e_rou_f302_barrel(24), e_rou_f302_base(24), e_rou_f303_barrel(24), e_rou_f303_base(24), e_rou_e007_Far(24), e_rou_e007_Near(24), e_rou_e010_Far(24), e_rou_e010_Near(24), e_rou_e105(24), e_rou_e105_wep_01(24), e_rou_e106(24), e_rou_e106_eng(24), e_rou_e106_wep_02_01(24), e_rou_e106_wep_02_joint(24), e_rou_e108_Missile_open(24), e_rou_e201(24), e_rou_e302_barrel(24), e_rou_e302_base(24), e_rou_e303_barrel_Near(24), e_rou_e303_base_Near(24), e_rou_e501(24) f106_eng_01_l e_rob_f001 @ -0x0 +0x1bf40f4 261 yes e_rob_f001(24), e_rou_f003_Near(24), e_rou_f101_wep_01(24), e_rou_f105(24), _rou_f105_break(261), e_rou_f106(24), e_rou_f302_barrel(24), e_rou_f302_base(24), e_rou_f303_barrel(24), e_rou_f303_base(24), e_rou_e007_Far(24), e_rou_e007_Near(24), e_rou_e010_Far(24), e_rou_e010_Near(24), e_rou_e105(24), e_rou_e105_wep_01(24), e_rou_e106(24), e_rou_e106_eng(24), e_rou_e106_wep_02_01(24), e_rou_e106_wep_02_joint(24), e_rou_e108_Missile_open(24), e_rou_e201(24), e_rou_e302_barrel(24), e_rou_e302_base(24), e_rou_e303_barrel_Near(24), e_rou_e303_base_Near(24), e_rou_e501(24) f106_eng_01_l e_rob_f001 @ -0x0 0x1bf596c 2446 NO _rou_f105_break(2446) f105_bdy_01_m _rou_f105_break @ -0x0 0x1c03ebc 1960 NO _rou_f105_break(1960) f105_bdy_02_m _rou_f105_break @ -0x0 0x1c0f67c 82 NO _rou_f105_break(82) e106_wep_02_01_l _rou_f105_break @ -0x0 diff --git a/docs/re/structures/xbg7-mesh.md b/docs/re/structures/xbg7-mesh.md index 2e61fd8..8742876 100644 --- a/docs/re/structures/xbg7-mesh.md +++ b/docs/re/structures/xbg7-mesh.md @@ -1009,6 +1009,39 @@ 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. +### ✅ Fixed: winding consistency replaces the connectivity heuristic + +With every miss attributed (below), **connectivity accounted for 153 of 225** — +by far the largest blocker, and the one gate already known to reject a +capture-proven block. So it was tested against the alternative the decoder +already trusts elsewhere: **winding consistency**, `max(na, 1−na)`, where a real +mesh sits at ≈1.0 or ≈0.0 and a mis-carve lands near 0.5. It is an objective +topology test; the edge-ratio is a shape heuristic. + +Swapping them (connectivity inert, winding gating the pad-0 path): + +| winding floor | resources decoded | shared inconsistent | capture oracle | +|---|---|---|---| +| — (connectivity 0.42, previous default) | 6 069 | 56 | 46/46 | +| 0.60 | 6 214 | 53 | — | +| **0.70 (now)** | **6 212** | **39** | **46/46** | +| 0.80 | 5 770 | 1 | — | +| 0.85 | 5 770 | 0 | 46/46 | + +**0.70 dominates the previous default on both axes** — 143 more resources decode +*and* 17 fewer shared resources disagree across containers — with the capture +oracle unchanged at 46/46 claimed, 0 unclaimed, the twin invariant still clean +(18 exact mirrors, 22 related, 0 collapses, 0 unrelated across 40 pairs, up from +34), and every decoded sub-mesh still covering its pool. So it ships, and the +connectivity cap drops to an inert 1.0, kept as a knob and a backstop. + +The cliff at 0.80 is recorded rather than taken: it buys **perfect** cross-container +consistency (0 inconsistent) for 442 resources. Consistency is the weaker witness +— a systematic mis-anchor is consistent — so paying that much coverage for it is +not obviously right, and both points are one env var apart +(`XBG7_PAD0_CONSISTENCY`, `XBG7_EDGE_CAP`) for anyone who wants the conservative +end. + ### 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`