fix(xbg7): degenerate index runs 582 -> 1 (grouped path + prefer a clean candidate)

Two follow-ups to the pad-scoring fix, both driven by the same invariant (a
correctly located index run has no degenerate triangles):

- anchor_grouped_meshes picked its pad by first-match too; scoring the pivot run
  the same way cleared every remaining ptc_pack composite (f102/f104/e107).
- anchor_pool_mesh now prefers a degenerate-free candidate over an earlier dirty
  one. examples/better_home.rs showed the last two resources each had exactly one
  degenerate-free, pool-covering block, sitting later in file order than the
  lookalike we took. First-match order is kept for every clean hit, and a dirty
  block is still used if nothing clean exists, so coverage cannot regress.

  degenerate index runs, disc-wide:      582 -> 11 -> 1
  captured index runs identical:         93/93 (unchanged)
  resources decoded / misses:            6 209 / 85 (unchanged)
  index runs changed / anchors moved:    590 / 10 (_rou_f402_dead x8, e201_bdy_03_m x2)

Cross-container minority decodes 89 -> 96, and that is progress: all seven new
rows are _rou_f402_dead, which now has a majority (32x25x8) for the first time, so
its seven wrong copies are named instead of hidden behind "no majority".

The last dirty run (_rou_f402_dead in Stage_S09) is blocked by distinct assignment
— its clean block is claimed by e_rou_f003_Near, both 24-vertex bounding boxes. A
winding-floor escalation for that case was written, measured to fire for nothing,
and reverted; the reasoning is kept as a comment.

Regression threshold tightened to 1. Suite green with --include-ignored apart from
the pre-existing known-failing cross-container consistency test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
This commit is contained in:
2026-08-13 08:52:41 +00:00
parent 33dc12c948
commit 0776beb6f4
7 changed files with 249 additions and 19 deletions

View File

@@ -0,0 +1,123 @@
//! Does a resource have a CLEANER home in its container than the one we picked?
//!
//! After the pad-scoring fix, only two resources on the disc still decode to an
//! index run with degenerate triangles — `e201_bdy_03_m` (2 containers) and
//! `_rou_f402_dead` (9). Degeneracy says the run does not fit the pool, so either
//! the vertex block is wrong or the candidate list never offered the right one.
//! This walks every candidate vertex-run start for the resource's declaration and
//! scores each `(start, pad)` the way the anchor now does — degenerate triangles
//! first, then winding, plus coverage — so the answer is one of:
//! * a strictly cleaner candidate exists (the selection is at fault),
//! * several are equally clean (genuinely ambiguous), or
//! * nothing is clean (the block is not in the candidate list at all).
//!
//! Usage: better_home <container.xpr> <resource-name>
use sylpheed_formats::mesh::{debug_resource_params, debug_vertex_run_starts, Xbg7Model};
fn be16(b: &[u8], at: usize) -> u32 {
((b[at] as u32) << 8) | b[at + 1] as u32
}
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container");
let name = &a[2];
let Some((markers, stride)) = debug_resource_params(&bytes, name) else {
eprintln!("no such XBG7 resource: {name}");
std::process::exit(1);
};
let (vc, ic) = markers[0];
println!("{name}: {} marker(s), first = {vc} verts / {ic} indices, stride {stride}", markers.len());
// Where did the decoder put it?
let ours = Xbg7Model::stage_models(&bytes)
.into_iter()
.find(|m| m.name == *name)
.and_then(|m| m.meshes.first().and_then(|s| s.vbuf_offset));
println!("our anchor: {ours:?}");
let starts = debug_vertex_run_starts(&bytes, stride);
println!("{} candidate vertex-run starts for stride {stride}", starts.len());
// Score every (start, pad): degenerate triangles, winding against the stored
// normals, and whether the run covers the pool exactly.
let mut rows: Vec<(usize, f32, usize, usize, usize, bool)> = Vec::new(); // degen, wind, start, pad, max_idx, covered
for &vb in &starts {
for pad in 0..=3usize {
if vb < ic * 2 + pad {
continue;
}
let ib = vb - ic * 2 - pad;
if ib + ic * 2 > bytes.len() || vb + vc * stride > bytes.len() {
continue;
}
let idx: Vec<u32> = (0..ic).map(|k| be16(&bytes, ib + k * 2)).collect();
let max_idx = *idx.iter().max().unwrap_or(&0) as usize;
if max_idx >= vc {
continue; // out of range — not a candidate at all
}
let pos: Vec<[f32; 3]> = (0..vc)
.map(|v| {
let at = vb + v * stride;
[
f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()),
f32::from_be_bytes(bytes[at + 4..at + 8].try_into().unwrap()),
f32::from_be_bytes(bytes[at + 8..at + 12].try_into().unwrap()),
]
})
.collect();
if pos.iter().any(|p| p.iter().any(|c| !c.is_finite() || c.abs() > 1e6)) {
continue;
}
let mut degen = 0usize;
let (mut agree, mut counted) = (0usize, 0usize);
for t in idx.chunks_exact(3) {
let (x, y, z) = (t[0] as usize, t[1] as usize, t[2] as usize);
if x == y || y == z || x == z {
degen += 1;
continue;
}
// Winding needs normals; use the geometric centroid normal as a
// stand-in so this stays declaration-agnostic: a consistent mesh
// has all faces pointing away from the centroid on a convex-ish
// hull. Weak, so degeneracy leads the sort.
let e1 = [pos[y][0] - pos[x][0], pos[y][1] - pos[x][1], pos[y][2] - pos[x][2]];
let e2 = [pos[z][0] - pos[x][0], pos[z][1] - pos[x][1], pos[z][2] - pos[x][2]];
let f = [
e1[1] * e2[2] - e1[2] * e2[1],
e1[2] * e2[0] - e1[0] * e2[2],
e1[0] * e2[1] - e1[1] * e2[0],
];
let cx: [f32; 3] = {
let mut c = [0.0f32; 3];
for p in &pos {
for k in 0..3 {
c[k] += p[k] / pos.len() as f32;
}
}
c
};
let out = [pos[x][0] - cx[0], pos[x][1] - cx[1], pos[x][2] - cx[2]];
counted += 1;
if f[0] * out[0] + f[1] * out[1] + f[2] * out[2] > 0.0 {
agree += 1;
}
}
let w = if counted == 0 { 0.0 } else { agree as f32 / counted as f32 };
rows.push((degen, w.max(1.0 - w), vb, pad, max_idx, max_idx + 1 == vc));
}
}
rows.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.total_cmp(&a.1)));
println!("\n{} in-range candidates; best 12 by (degenerate, winding):", rows.len());
for (d, w, vb, pad, mx, cov) in rows.iter().take(12) {
let mark = if Some(*vb) == ours { " <-- ours" } else { "" };
println!(
" vb 0x{vb:07X} pad {pad} degen {d:>4} wind {w:.3} max_idx {mx}/{} {}{mark}",
vc - 1,
if *cov { "covers" } else { "SHORT" }
);
}
let clean = rows.iter().filter(|r| r.0 == 0 && r.5).count();
println!("\n{clean} candidates are degenerate-free AND cover the pool exactly");
}

View File

@@ -67,6 +67,16 @@ fn main() {
let where_ = f.file_name().unwrap().to_string_lossy().to_string();
let mut c_moved = 0usize;
for m in Xbg7Model::stage_models(&bytes) {
// Degeneracy is counted over EVERY sub-mesh (grouped pools included —
// they have their own pad choice); the pad-0 comparison below only
// applies to single-block resources, whose run starts at `vb - 2n`.
for sm in &m.meshes {
let d = degenerate(&sm.indices);
if d > 0 {
still_degen += 1;
worst.push((d, m.name.clone(), where_.clone()));
}
}
if m.meshes.len() != 1 {
continue;
}
@@ -98,11 +108,6 @@ fn main() {
weak_wind.push((w0, w2, m.name.clone()));
}
}
let d = degenerate(&sm.indices);
if d > 0 {
still_degen += 1;
worst.push((d, m.name.clone(), where_.clone()));
}
}
if c_moved > 0 {
println!("{where_:<24} {c_moved} resources read from a non-pad-0 index run");

View File

@@ -0,0 +1,6 @@
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).unwrap();
let vb: usize = a[3].parse().unwrap();
println!("{:?}", sylpheed_formats::mesh::debug_try_anchor(&bytes, &a[2], vb, 3));
}

View File

@@ -1281,6 +1281,9 @@ fn anchor_pool_mesh(
min_vb: usize,
) -> Option<GameMesh> {
let idx_bytes = index_count * 2;
// First accepted candidate whose index run still has degenerate triangles —
// used only if no clean candidate exists anywhere (see the end of the loop).
let mut dirty: Option<(usize, usize)> = None;
for &vb in starts {
// Monotone assignment (opt-in): resources of one signature are laid out
// in descriptor order, so a later one may not take an earlier block.
@@ -1339,11 +1342,36 @@ fn anchor_pool_mesh(
}
}
}
if let Some((_, _, pad)) = best {
// ── Accepted: read the full mesh. ──
return Some(read_pool_mesh(bytes, vb - idx_bytes - pad, vb, index_count, vtx_count, decl));
if let Some((degen, _, pad)) = best {
// A candidate whose triangles are degenerate-free is preferred over an
// earlier one that is not. This keeps first-match order for every
// clean hit (the overwhelming majority) and only searches on when the
// first accepted block is provably mis-fitted — the two resources that
// survived the pad fix (`e201_bdy_03_m`, `_rou_f402_dead`) each have
// exactly ONE degenerate-free block in their container, sitting later
// in file order than the lookalike we were taking.
if degen == 0 || pad_first_match() {
return Some(read_pool_mesh(bytes, vb - idx_bytes - pad, vb, index_count, vtx_count, decl));
}
if dirty.is_none() {
dirty = Some((vb, pad));
}
}
}
// NOT DONE, deliberately: when every validating candidate is degenerate one
// could re-scan without the pad-0 winding floor, since degeneracy is the
// stronger witness. Written and measured 2026-08-13 — it fires for **nothing**
// on the disc. The single remaining dirty resource (`_rou_f402_dead` in
// `Stage_S09`) does have a degenerate-free block that validates at
// `XBG7_PAD0_CONSISTENCY=0`, but it is CLAIMED by `e_rou_f003_Near`, so
// distinct assignment — not the winding floor — is what blocks it. Both are
// 24-vertex bounding boxes, i.e. the box-identity class that needs
// descriptor-level data rather than another anchoring heuristic (see the docs).
// Nothing clean anywhere: keep the first accepted block, so this can never
// cost coverage relative to first-match.
if let Some((vb, pad)) = dirty {
return Some(read_pool_mesh(bytes, vb - idx_bytes - pad, vb, index_count, vtx_count, decl));
}
None
}
@@ -1617,6 +1645,12 @@ fn anchor_grouped_meshes(
if taken.contains(&vb0) {
continue;
}
// Which pad? Not the first that validates — the same trap the searched
// path had until 2026-08-13: a pool read one index element late still
// validates but wires every triangle wrongly. Score the pivot's index run
// (degenerate triangles first, then winding) and keep the cleanest pad.
// `XBG7_PAD_FIRST_MATCH=1` restores first-match here too.
let mut best: Option<(usize, f32, usize)> = None;
for pad in 0..=3usize {
if vb0 < span + pad {
continue;
@@ -1634,6 +1668,17 @@ fn anchor_grouped_meshes(
if !validate_block(bytes, ib_k, vb_k, vck, ick, decl, grouped_consistency(), true) {
continue;
}
let (degen, wind) = index_run_quality(bytes, ib_k, vb_k, ick, decl);
let cand = (degen, -wind, pad);
if best.map_or(true, |b| cand < b) {
best = Some(cand);
}
if pad_first_match() {
break;
}
}
if let Some((_, _, pad)) = best {
let ib0 = vb0 - span - pad;
// Pivot confirmed the exact alignment ⇒ every marker up to the pivot
// is correctly placed; read those unconditionally (a legitimately
@@ -1687,7 +1732,6 @@ fn anchor_grouped_meshes(
Vec::new()
}
/// Read positions / normals / uvs / indices for an anchored stage block.
/// How clean is the triangle list at `ib` against the pool at `vb`?
///
/// Returns `(degenerate triangles, winding agreement)` — the two properties that
@@ -1720,6 +1764,7 @@ fn index_run_quality(
(degen, na.max(1.0 - na))
}
/// Read positions / normals / uvs / indices for an anchored stage block.
fn read_pool_mesh(
bytes: &[u8],
ib: usize,

View File

@@ -430,8 +430,10 @@ fn stage_models_quality_audit() {
/// element late still passes every count-based gate but wires arbitrary
/// vertices, which produces triangles with a repeated index. Before the fix
/// **579** decoded runs on the disc carried such triangles (and a runtime
/// capture confirmed 17 of 93 index batches disagreed with the GPU); after it,
/// 16 sub-meshes do — all in resources that are separately known-broken. Locking the
/// capture confirmed 17 of 93 index batches disagreed with the GPU); after it and
/// the two follow-ups (pad scoring in the grouped path, and preferring a
/// degenerate-free candidate over an earlier dirty one), exactly **one** does.
/// Locking the
/// number in, because the defect is invisible to coverage and to the anchor
/// oracle: every count stays correct while the geometry is mis-wired.
#[test]
@@ -466,14 +468,13 @@ fn decoded_index_runs_have_almost_no_degenerate_triangles() {
}
}
}
// The known 16 sub-meshes: the grouped `.dat` break composites in `ptc_pack`
// (`f102`/`f104`/`e107`, whose marker lists are documented not to map onto the
// stored blocks — 5 sub-meshes), plus `e201_bdy_03_m` (2 containers) and
// `_rou_f402_dead` (9). Every one has an independently recorded anchoring
// problem; a NEW name appearing here is a regression.
// The one: `_rou_f402_dead` in `Stage_S09`, whose degenerate-free block is
// claimed by `e_rou_f003_Near` — both 24-vertex bounding boxes, the identity
// class that needs descriptor-level data, not another heuristic. Anything
// else appearing here is a regression.
assert!(
offenders.len() <= 16,
"{} decoded index runs contain degenerate triangles (expected ≤ 16): {:?}",
offenders.len() <= 1,
"{} decoded index runs contain degenerate triangles (expected ≤ 1): {:?}",
offenders.len(),
&offenders[..offenders.len().min(20)]
);