fix(mesh): filtered decode must not depend on the requested subset

models_named pruned to the wanted set BEFORE distinct assignment, so collision
resolution saw a different resource population and returned different offsets:
27 of 356 resources in Stage_S02 decoded differently when asked for alone,
including real geometry (f001_bdy_30, f106_sld_02_l/m/d, f101_wep_01_l). Both the
viewer and assemble_ship decode subsets, so both could disagree with the
container's own answer. This was a regression from distinct assignment itself.

Fixed by filtering the OUTPUT: the assignment always runs over the whole
container. One-name, three-name and full decodes now agree exactly. Cost: a
single-resource query on a 50MB container goes from near-instant to ~15s;
per-container caching is the follow-up. Ten suites green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 18:47:41 +00:00
parent e8434541f2
commit c79c0f6ccf
3 changed files with 85 additions and 5 deletions

View File

@@ -0,0 +1,53 @@
//! Does a resource's DESCRIPTOR carry its bounding box?
//!
//! The last cross-container disagreements are 24-vertex bound boxes swapping
//! identities; no anchoring rule can pin them (see docs). If the descriptor
//! states the box, that is the missing information. This decodes the resource,
//! takes the box its geometry actually spans, and searches the descriptor for
//! those float values.
//!
//! Usage: bounds_in_descriptor <container.xpr> <resource>...
use sylpheed_formats::mesh::{xbg7_descriptor_range, Xbg7Model};
use std::collections::HashSet;
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container");
let want: HashSet<String> = a[2..].iter().cloned().collect();
for m in Xbg7Model::models_named(&bytes, &want, &|| false) {
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for s in &m.meshes {
for q in &s.positions {
for k in 0..3 {
lo[k] = lo[k].min(q[k]);
hi[k] = hi[k].max(q[k]);
}
}
}
let Some((d0, d1)) = xbg7_descriptor_range(&bytes, &m.name) else { continue };
println!(
"{} descriptor 0x{d0:x}..0x{d1:x} ({} bytes), box lo{:?} hi{:?}",
m.name,
d1 - d0,
lo.map(|v| v.round()),
hi.map(|v| v.round())
);
// Where in the descriptor does each bound value appear (±0.01)?
let targets: Vec<(&str, f32)> = vec![
("lo.x", lo[0]), ("lo.y", lo[1]), ("lo.z", lo[2]),
("hi.x", hi[0]), ("hi.y", hi[1]), ("hi.z", hi[2]),
];
for (label, v) in targets {
let mut at: Vec<usize> = Vec::new();
let mut o = d0;
while o + 4 <= d1 {
let f = f32::from_be_bytes(bytes[o..o + 4].try_into().unwrap());
if (f - v).abs() <= 0.01 * (1.0 + v.abs()) {
at.push(o - d0);
}
o += 4;
}
println!(" {label:5} {v:10.3} at descriptor offsets {:x?}", &at[..at.len().min(6)]);
}
}
}

View File

@@ -523,11 +523,13 @@ impl Xbg7Model {
} }
let name = read_cstr(bytes, e.name_offset as usize + DIR_BASE) let name = read_cstr(bytes, e.name_offset as usize + DIR_BASE)
.unwrap_or_else(|| "XBG7".to_string()); .unwrap_or_else(|| "XBG7".to_string());
if let Some(w) = wanted { // NOTE: `wanted` is NOT applied here. Distinct assignment resolves
if !w.contains(&name) { // collisions against the whole set of resources, so pruning first
continue; // made a filtered decode depend on *which* subset was asked for —
} // measured 2026-08-12: 27 of 356 resources in `Stage_S02` came out
} // at a different offset when requested alone. The filter is applied
// to the OUTPUT instead, so a subset is always a subset of the
// container's own answer.
resources.push(Res { resources.push(Res {
name, name,
markers, markers,
@@ -722,6 +724,9 @@ impl Xbg7Model {
} }
models.push(m); models.push(m);
} }
if let Some(w) = wanted {
models.retain(|m| w.contains(&m.name));
}
out = models; out = models;
out out
} }

View File

@@ -1097,6 +1097,28 @@ that carry it) and `t901_e01_D` is 58×59×3013 (a mast). And `f101_bdy_01` flag
at 6× while being **capture-verified exact** in the truth table — a useful at 6× while being **capture-verified exact** in the truth table — a useful
reminder that a bulky hull is not a bug. reminder that a bulky hull is not a bug.
### ✅ Fixed: a filtered decode no longer depends on what you ask for
Distinct assignment resolves collisions against the set of resources being
decoded — and `models_named` was pruning to the requested subset **before** that
pass. So the answer depended on the request: measured on `Stage_S02`, **27 of 356
resources came out at a different offset when asked for alone** than in a full
decode, and not only boxes — `f001_bdy_30`, `f106_sld_02_l/m/d`, `f101_wep_01_l`
among them. Both the viewer and `ship::assemble_ship` decode subsets, so both
could get geometry the container's own answer disagrees with.
This was a regression introduced by distinct assignment itself, and it is fixed
by applying `wanted` to the **output** instead of the input: the assignment
always runs over the whole container, and a subset is now a subset of the
container's own answer. Verified: one-name, three-name and full decodes now
return the identical offset for the same resource.
**Cost, stated plainly:** a single-resource query on a 50 MB container went from
near-instant to **~15 s**, because it now anchors every resource. The viewer
calls this once per ship view (with cancellation), so it is a slow prepare step
rather than a per-frame cost — but caching the per-container decode is the
obvious follow-up and is not done here.
### The consistency figure is mostly bounding boxes — real disagreement is ONE resource ### The consistency figure is mostly bounding boxes — real disagreement is ONE resource
`examples/consensus_check.rs` sharpens the cross-container test: with three or `examples/consensus_check.rs` sharpens the cross-container test: with three or