fix(xbg7): the index run was one element late for 575 sub-meshes

Extending the capture comparison from index COUNTS to index VALUES
(`examples/capture_index_bytes.rs`, using the batch offsets the new ib logging
gives) showed 76 of 93 Stage_S02 index runs identical to the GPU's and 17
differing — every difference a shift by exactly one element, on buffers whose
index data sits at pad 2.

`anchor_pool_mesh` returned the FIRST pad that validated, and pad 0 is tried
first with the looser winding gate (0.70 vs 0.85). Read at pad 0, a pad-2 block
yields [true[1], true[2], …, garbage]: every index in range, the pool covered,
the positions right, the winding often just above 0.70 — so it validated, and
every triangle was mis-wired. Nothing count-based could see it.

The signature is decidable without the capture: a shifted run wires arbitrary
vertices, so triangles come out degenerate. 282 of 283 correctly anchored
Stage_S02 blocks have zero degenerate triangles, while the shifted readings carry
1–2 156. So score every validating pad by (degenerate triangles, then winding)
and keep the best. `XBG7_PAD_FIRST_MATCH=1` restores the old behaviour.

  captured index runs identical:            76/93  ->  93/93  (2 025 elements)
  decoded runs with a degenerate triangle:    579  ->     16  (disc-wide)
  sub-meshes whose index run changed:                    575  of 8 850
  resources decoded / vertex anchors / consistency:  unchanged (6 209 / same vb / 89)

Locked in by tests/mesh_disc.rs::decoded_index_runs_have_almost_no_degenerate_triangles.
Suite green with --include-ignored apart from the pre-existing known-failing
cross-container consistency test (the 24-vertex bounding-box class).

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 07:26:52 +00:00
parent 6d92e6c114
commit 41b59faf5f
9 changed files with 655 additions and 3 deletions

View File

@@ -1089,6 +1089,14 @@ fn pad0_consistency() -> f32 {
std::env::var("XBG7_PAD0_CONSISTENCY").ok().and_then(|v| v.parse().ok()).unwrap_or(0.70)
}
/// Revert knob for the 2026-08-13 pad scoring: with `XBG7_PAD_FIRST_MATCH=1`,
/// [`anchor_pool_mesh`] takes the FIRST pad that validates (the pre-fix
/// behaviour) instead of the pad whose index run is cleanest. Kept so the two
/// behaviours can be diffed on the disc; see docs/re/structures/xbg7-mesh.md.
fn pad_first_match() -> bool {
std::env::var("XBG7_PAD_FIRST_MATCH").map(|v| v == "1").unwrap_or(false)
}
/// Triangle count below which the looser [`small_cap`] applies. `0` (default)
/// disables the split, so the flat [`edge_cap`] governs every block.
fn small_tris() -> usize {
@@ -1293,6 +1301,17 @@ fn anchor_pool_mesh(
// pad>0 is gated at a strict 0.85 consistency to avoid a false anchor in
// the ungated (`min_consistency == 0`) stage path — pad 0 keeps its exact
// prior behaviour.
// Do NOT take the first pad that validates. A list read one element late
// still validates — every index is in range, the pool is still covered,
// and the winding can squeak past 0.70 — but it re-wires every triangle.
// The runtime capture caught it (17 draw batches whose captured indices
// equal ours shifted by one, all on pad-2 buffers), and the signature is
// decidable offline: a shift wires vertices arbitrarily, so triangles come
// out DEGENERATE (a repeated index). 282 of 283 correctly anchored
// `Stage_S02` blocks have zero degenerate triangles, against 12 156 for
// their shifted readings. So score every validating pad and keep the
// cleanest. See docs/re/structures/xbg7-mesh.md.
let mut best: Option<(usize, f32, usize)> = None; // (degenerate, -winding, pad)
for pad in 0..=3usize {
if vb < idx_bytes + pad {
continue;
@@ -1310,10 +1329,20 @@ fn anchor_pool_mesh(
min_consistency.max(0.85)
};
if validate_block(bytes, ib, vb, vtx_count, index_count, decl, mc, true) {
// ── Accepted: read the full mesh. ──
return Some(read_pool_mesh(bytes, ib, vb, index_count, vtx_count, decl));
if pad_first_match() {
return Some(read_pool_mesh(bytes, ib, vb, index_count, vtx_count, decl));
}
let (degen, wind) = index_run_quality(bytes, ib, vb, index_count, decl);
let cand = (degen, -wind, pad);
if best.map_or(true, |b| cand < b) {
best = Some(cand);
}
}
}
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));
}
}
None
}
@@ -1659,6 +1688,38 @@ fn anchor_grouped_meshes(
}
/// 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
/// separate a correctly located index run from one read a couple of bytes off.
/// A shifted run wires arbitrary vertices, which shows up as **degenerate**
/// triangles (a repeated index) and a winding agreement drifting toward the 0.5
/// middle; a real block has zero degenerate triangles and agreement ≈1 or ≈0.
/// Used by [`anchor_pool_mesh`] to choose between pads that all validate.
fn index_run_quality(
bytes: &[u8],
ib: usize,
vb: usize,
index_count: usize,
decl: &VertexDecl,
) -> (usize, f32) {
// The pool only needs as many vertices as the run references.
let mut max_idx = 0usize;
for k in 0..index_count {
let at = ib + k * 2;
if at + 2 > bytes.len() {
return (usize::MAX, 0.0);
}
max_idx = max_idx.max(be16(bytes, at) as usize);
}
let m = read_pool_mesh(bytes, ib, vb, index_count, max_idx + 1, decl);
if m.normals.is_empty() {
return (0, 1.0); // no normals: degeneracy alone decides
}
let (_, degen, na, _) = topology_report(&m.indices, &m.positions, &m.normals);
(degen, na.max(1.0 - na))
}
fn read_pool_mesh(
bytes: &[u8],
ib: usize,

View File

@@ -70,6 +70,12 @@ pub struct CapturedIndexBuffer {
/// Highest index value in the buffer — with `vcount` this says whether the
/// draw covers its whole vertex pool or only a sub-range.
pub imax: u32,
/// The first indices, verbatim (the capture prints up to 24). Byte-level
/// ground truth for the offline index decode: a matched block's decoded
/// index prefix must equal this run.
pub head: [u32; 24],
/// How many of `head` the capture actually carried.
pub head_len: u8,
}
/// A ship part to match against the capture. `part` is the **base** part name
@@ -159,11 +165,25 @@ pub fn parse_capture(text: &str) -> Vec<CapturedDraw> {
.next()
.and_then(|s| u32::from_str_radix(s, 16).ok());
if let (Some(ibase), Some(icount)) = (base, f("count=").and_then(|s| s.parse().ok())) {
let mut head = [0u32; 24];
let mut head_len = 0u8;
if let Some((_, list)) = l.split_once("idx:") {
for tok in list.split_whitespace() {
let Ok(v) = tok.parse::<u32>() else { break };
if head_len as usize >= head.len() {
break;
}
head[head_len as usize] = v;
head_len += 1;
}
}
ib = Some(CapturedIndexBuffer {
ibase,
icount,
imin: f("min=").and_then(|s| s.parse().ok()).unwrap_or(0),
imax: f("max=").and_then(|s| s.parse().ok()).unwrap_or(0),
head,
head_len,
});
}
} else if l.starts_with("pos:") || l.starts_with("positions:") {