fix(mesh): distinct anchor assignment -- no two resources may claim one buffer

Selection was per-resource and greedy, so two resources could take one vertex
buffer while a valid one sat unused. A runtime capture proved that wrong for the
mirrored e106 hull twins: the container holds both halves and the engine draws
each from its own address. Now the first claimant keeps a buffer and later
resources re-anchor past everything already claimed (coverage can never regress;
grouped-pool models untouched).

Against the 46 capture-named Stage_S02 buffers: exact anchors 29 -> 40, unclaimed
12 -> 4. Disc-wide: 5480 resources decoded (unchanged), cross-container
inconsistency 125 -> 46.

The twins' mirror therefore lives in the DATA, not in the placement matrix: the
embedded e106_bdy_02 row and the two assertions encoding the old convention are
updated, each with the reason recorded. Full suite green incl. disc/ISO gates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 03:16:51 +00:00
parent 1815490d01
commit d83bdd3b68
5 changed files with 143 additions and 16 deletions

View File

@@ -563,6 +563,7 @@ impl Xbg7Model {
// preserves resource order, so the output is identical to the sequential
// decode. `should_cancel()` is polled per resource so a superseded load
// stops promptly.
let empty_taken: std::collections::HashSet<usize> = std::collections::HashSet::new();
let decode_one = |r: &Res| -> Option<Xbg7Model> {
if should_cancel() {
return None;
@@ -574,9 +575,17 @@ impl Xbg7Model {
// simple props take this path; `min_consistency` behaviour is
// exactly as before.
let (vtx_count, index_count) = r.markers[0];
anchor_pool_mesh(bytes, starts, index_count, vtx_count, &r.decl, min_consistency)
.into_iter()
.collect()
anchor_pool_mesh(
bytes,
starts,
index_count,
vtx_count,
&r.decl,
min_consistency,
&empty_taken,
)
.into_iter()
.collect()
} else {
// Several sub-meshes sharing grouped index/vertex pools → the
// deterministic grouped-pool decode (hero ships et al.).
@@ -589,9 +598,17 @@ impl Xbg7Model {
// to the original single-block adjacency anchor on the first
// marker so coverage is never *below* the pre-grouped decode.
let (vtx_count, index_count) = r.markers[0];
anchor_pool_mesh(bytes, starts, index_count, vtx_count, &r.decl, min_consistency)
.into_iter()
.collect()
anchor_pool_mesh(
bytes,
starts,
index_count,
vtx_count,
&r.decl,
min_consistency,
&empty_taken,
)
.into_iter()
.collect()
}
};
(!meshes.is_empty()).then(|| Xbg7Model {
@@ -601,14 +618,67 @@ impl Xbg7Model {
};
#[cfg(not(target_arch = "wasm32"))]
{
let decoded: Vec<(usize, Xbg7Model)> = {
use rayon::prelude::*;
out = resources.par_iter().filter_map(decode_one).collect();
}
resources
.par_iter()
.enumerate()
.filter_map(|(i, r)| decode_one(r).map(|m| (i, m)))
.collect()
};
#[cfg(target_arch = "wasm32")]
{
out = resources.iter().filter_map(decode_one).collect();
let decoded: Vec<(usize, Xbg7Model)> = resources
.iter()
.enumerate()
.filter_map(|(i, r)| decode_one(r).map(|m| (i, m)))
.collect();
// ── Distinct assignment ──────────────────────────────────────────
//
// Selection above is per-resource and greedy: each takes the first
// candidate that validates, so two resources can claim ONE buffer while
// a valid buffer sits unused. A runtime capture proves that is wrong for
// the mirrored `e106_bdy_0{1,2}_l` twins — the container holds both
// halves (`0x3b3ee8` and its X-mirror `0x3c55d8`) and the engine draws
// each from its own — and both offsets validate for both names, so the
// correct block merely lost the first-match race.
//
// So: walk the decodes in container order, let the first claimant keep a
// buffer, and re-anchor any later resource that wanted the same one,
// skipping everything already claimed. Only single-sub-mesh resources
// (the adjacency-anchor path) take part; grouped-pool models are left
// exactly as they were.
let mut taken: std::collections::HashSet<usize> = std::collections::HashSet::new();
let mut models: Vec<Xbg7Model> = Vec::with_capacity(decoded.len());
for (i, mut m) in decoded {
let r = &resources[i];
if m.meshes.len() == 1 && r.markers.len() == 1 {
if let Some(vb) = m.meshes[0].vbuf_offset {
if taken.contains(&vb) {
let starts = &starts_by_stride[&r.decl.stride];
let (vtx_count, index_count) = r.markers[0];
if let Some(alt) = anchor_pool_mesh(
bytes,
starts,
index_count,
vtx_count,
&r.decl,
min_consistency,
&taken,
) {
m.meshes[0] = alt;
}
// No free candidate → keep the collided decode rather
// than drop the resource; coverage never regresses.
}
if let Some(vb2) = m.meshes[0].vbuf_offset {
taken.insert(vb2);
}
}
}
models.push(m);
}
out = models;
out
}
}
@@ -855,9 +925,17 @@ fn anchor_pool_mesh(
vtx_count: usize,
decl: &VertexDecl,
min_consistency: f32,
taken: &std::collections::HashSet<usize>,
) -> Option<GameMesh> {
let idx_bytes = index_count * 2;
for &vb in starts {
// A buffer another resource already claimed is not a candidate: the
// engine draws each part from its own buffer (proved for the mirrored
// `e106_bdy_0{1,2}_l` twins by a runtime capture), so two resources
// landing on one offset means at least one of them is wrong.
if taken.contains(&vb) {
continue;
}
// The index buffer sits just before the vertex buffer, which is 4-byte
// aligned — so 0..=3 bytes of padding may separate them (`ib = vb
// idx_bytes pad`). pad 0 is the immediate-adjacency case (all stages so