[formats,viewer] Decode grouped-pool XBG7 models; fix mesh routing + albedo
Revert the mis-guided "triangle STRIP" reading (a937779): XBG7 index
buffers are triangle LISTS (prim=4 in the GPU capture; stored-normal
agreement 1.000 as a list vs ~0.49 as a strip). Reinstate the
winding-consistency gate.
Crack the grouped-pool layout used by the hero ship and ~150 detailed
models. A resource's sub-meshes share one index pool (buffers 4-byte
aligned, in descriptor-marker order) followed by one vertex pool (each
vtx_count*stride, same order); the vertex pool is 4-byte aligned after
the index pool. The whole resource is derived from one anchored pivot:
ib0 = vb0 - span - pad (pad in 0..=3, alignment)
ib[i] = align4(ib[i-1] + idx_count[i-1]*2)
vb[i] = vb[i-1] + vtx_count[i-1]*stride
vb0 is found via the unit-normal vertex-run scan; the alignment is
confirmed by validating the LARGEST sub-mesh (most reliable), after
which the rest are read/validated. A single marker reduces this to the
existing adjacency anchor, so grouped generalises it.
Results (cross-checked against a Canary GPU draw-log capture of
DeltaSaber_T.xpr):
- DeltaSaber_T f001 = body + 7 detail parts = 8650 tris, every sub-mesh
0-degenerate / full-coverage / winding-agreement 1.000.
- All 19 previously-declined weapon models now decode (they hit pad 2
and/or lead with a tiny bracket that broke a markers[0] pivot). Corpus
audit: 0/146 geometry files fail (was 19 -> flat-texture in the viewer).
- Stages unchanged (single-marker path is byte-identical; grouped falls
back to the old anchor on failure). 7/7 disc tests green incl. the
strict stage quality audit; new hero_ship_grouped_pool_decodes test.
Viewer: route by count_xbg7; --only matches an exact model name (so the
neutral pose renders without the mnv*/turn180 animation poses). Fix the
albedo matcher: match a sub-model to its _col map by entity stem
(e007_bdy_01 -> e007_col) instead of a full-name prefix, lifting stage
sub-model texturing from ~25% to ~95% (the rest were flat grey). Colour
correctness (channel order/sRGB) remains a separate dynamic-RE item.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -423,17 +423,30 @@ fn cmd_texture_export(file: &Path, output: &Path) -> Result<()> {
|
||||
/// a single model (weapon / prop) OR a stage's many sub-models — whichever
|
||||
/// yields more geometry.
|
||||
fn decode_models(bytes: &[u8]) -> Vec<sylpheed_formats::mesh::Xbg7Model> {
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
let single = Xbg7Model::from_xpr2(bytes)
|
||||
use sylpheed_formats::mesh::{count_xbg7, Xbg7Model};
|
||||
// Route by container kind, NOT by whichever decoder yields more verts (that
|
||||
// old heuristic let `stage_models`' content-anchoring win on single-model
|
||||
// files, fabricating phantom / duplicate / mis-anchored blocks). A file with
|
||||
// one XBG7 resource is a single model (weapon / prop) → use only the
|
||||
// validated records-based list decode; content-anchoring a single-model file
|
||||
// invents geometry. Many XBG7 resources → a Stage_* collection → anchor them.
|
||||
if count_xbg7(bytes) > 1 {
|
||||
// Multi-resource Stage_* collection → content-anchor every resource
|
||||
// (each anchor now gated by stored-normal agreement).
|
||||
Xbg7Model::stage_models(bytes)
|
||||
} else if let Some(m) = Xbg7Model::from_xpr2(bytes)
|
||||
.ok()
|
||||
.filter(|m| !m.meshes.is_empty());
|
||||
let single_verts = single.as_ref().map(|m| m.totals().0).unwrap_or(0);
|
||||
let stage = Xbg7Model::stage_models(bytes);
|
||||
let stage_verts: usize = stage.iter().map(|m| m.totals().0).sum();
|
||||
if !stage.is_empty() && stage_verts > single_verts {
|
||||
stage
|
||||
.filter(|m| !m.meshes.is_empty())
|
||||
{
|
||||
// Single model the records-based list decode carved (authoritative).
|
||||
vec![m]
|
||||
} else {
|
||||
single.into_iter().collect()
|
||||
// Single model from_xpr2 couldn't locate (its sequential carve missed
|
||||
// the block) — fall back to content-anchoring, which finds it by shape.
|
||||
// Use a STRICT winding-consistency gate (0.85): on a single-model file a
|
||||
// mis-anchor is an obvious phantom / spike-mess and must be declined,
|
||||
// unlike the large stage corpus which keeps the ungated path.
|
||||
Xbg7Model::anchor_models(bytes, 0.85)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,7 +549,14 @@ fn cmd_mesh_render(
|
||||
let bytes = std::fs::read(file).with_context(|| format!("Cannot read {}", file.display()))?;
|
||||
let mut models = decode_models(&bytes);
|
||||
if let Some(sub) = &only {
|
||||
models.retain(|m| m.name.contains(sub.as_str()));
|
||||
// Prefer an exact name match (e.g. `f001` for the neutral ship pose,
|
||||
// excluding the `_rou_f001_mnv*` animation poses that also *contain*
|
||||
// "f001"); fall back to substring when nothing matches exactly.
|
||||
if models.iter().any(|m| m.name == *sub) {
|
||||
models.retain(|m| m.name == *sub);
|
||||
} else {
|
||||
models.retain(|m| m.name.contains(sub.as_str()));
|
||||
}
|
||||
}
|
||||
if models.is_empty() {
|
||||
anyhow::bail!("no decodable XBG7 geometry in {}", file.display());
|
||||
|
||||
@@ -21,19 +21,27 @@
|
||||
//! the data section is a straight sequence of sub-meshes, each:
|
||||
//!
|
||||
//! ```text
|
||||
//! [ index buffer : idx_count × u16 big-endian ] triangle STRIP
|
||||
//! [ 12-byte vertex-buffer header (contents undecoded) ]
|
||||
//! [ index buffer : idx_count × u16 big-endian ] triangle LIST
|
||||
//! [ vertex buffer : vtx_count × stride bytes ] (declaration-driven)
|
||||
//! (pad to 16 bytes → next sub-mesh)
|
||||
//! ```
|
||||
//!
|
||||
//! The index buffer is a triangle **strip** (a strip of N indices is N-2
|
||||
//! triangles with alternating winding), NOT a list — reading it as a list
|
||||
//! dropped ~⅔ of every hull (the "triangular holes") and produced spanning
|
||||
//! spikes. `expand_triangle_strip` converts it, skipping degenerate triangles.
|
||||
//! Residual: an index buffer may concatenate several strips with no degenerate
|
||||
//! bridge, leaving ~2 spanning triangles at each restart (an index jump to a new
|
||||
//! vertex region) — a small known artifact on some models.
|
||||
//! The index buffer is a triangle **list**: `idx_count` is a multiple of 3 and
|
||||
//! each consecutive triple is one triangle. This is confirmed two ways — the
|
||||
//! Canary GPU draw capture logs `prim=4` (triangle list), and the objective
|
||||
//! `XVERIFY` diagnostic shows stored-normal agreement of **1.000** under the list
|
||||
//! reading (every face normal points the way its vertices' normals do — only
|
||||
//! possible with correct topology + winding) versus **~0.49** (random) under a
|
||||
//! strip reading. A brief 2026-07 attempt to read these as triangle strips was a
|
||||
//! mistake: it over-generated ~2.5× the triangles, filling holes with an
|
||||
//! overlapping garbage soup that *looked* solid but had random normals.
|
||||
//!
|
||||
//! Correctness is enforced by a **winding-consistency gate**: a correctly-carved
|
||||
//! sub-mesh agrees with its stored normals either ≈always (≈1.0) or ≈never (≈0.0,
|
||||
//! inverted winding — still a real single-sided mesh); a mis-carve wires arbitrary
|
||||
//! vertices and scatters to the ≈0.5 middle. Sub-meshes with `max(na, 1-na) <
|
||||
//! 0.90` are declined rather than emitted as a spike-mess.
|
||||
//!
|
||||
//! The vertex layout is **not fixed** — it comes from a **vertex declaration**
|
||||
//! in the descriptor: a table of `{offset, format-code, usage}` triples (usage
|
||||
@@ -51,12 +59,29 @@
|
||||
//! sub-mesh fails to carve cleanly the whole model is rejected
|
||||
//! ([`MeshError::UnsupportedLayout`]) rather than emitting garbage.
|
||||
//!
|
||||
//! ## Not yet decoded
|
||||
//! ## The grouped-pool layout (CONFIRMED — hero ships)
|
||||
//!
|
||||
//! The hero-ship *body* meshes (`DeltaSaber_*.xpr` `f004`, and ~100 other
|
||||
//! models) use a more complex **multi-stream** layout — separate position /
|
||||
//! attribute streams at descriptor-addressed offsets, quantized positions —
|
||||
//! which is not handled here and is cleanly declined.
|
||||
//! Detailed models (`DeltaSaber_*.xpr` and ~100 others) don't interleave each
|
||||
//! sub-mesh's index buffer with its vertex buffer. Instead a resource's *several*
|
||||
//! sub-meshes share **two grouped pools**:
|
||||
//!
|
||||
//! ```text
|
||||
//! index pool : [ ib0 | ib1 | … ] each buffer 4-byte aligned, in marker order
|
||||
//! vertex pool : [ vb0 | vb1 | … ] each pool `vtx_count × stride`, same order
|
||||
//! ```
|
||||
//!
|
||||
//! with the index pool ending exactly where the vertex pool begins. This is the
|
||||
//! **same vertex format** as weapons (stride 24, triangle list) — it was declined
|
||||
//! only for *location*, not format. [`Xbg7Model::anchor_models`] decodes it: it
|
||||
//! pivots on the one unknown, `vb0` (found by the unit-normal vertex-run scan),
|
||||
//! and derives every other index/vertex offset (see [`anchor_grouped_meshes`]).
|
||||
//! Reversed statically and cross-checked against a Canary GPU draw-log capture of
|
||||
//! `DeltaSaber_T.xpr` (`f001` = body + 7 detail parts = 8650 tris, every sub-mesh
|
||||
//! at 0 degenerate / full coverage). See `docs/re/structures/xbg7-mesh.md`.
|
||||
//!
|
||||
//! Resources whose grouped pivot cannot be validated (a few multi-stream /
|
||||
//! quantized bodies remain) are still cleanly declined rather than emitted as
|
||||
//! garbage.
|
||||
|
||||
use crate::texture::{Xpr2Header, Xpr2ResourceEntry};
|
||||
use binrw::BinRead;
|
||||
@@ -100,6 +125,32 @@ pub struct Xbg7Model {
|
||||
pub meshes: Vec<GameMesh>,
|
||||
}
|
||||
|
||||
/// Count the `XBG7` geometry resources in an XPR2 container. Single-model files
|
||||
/// (weapons / props) have exactly one; the multi-resource `Stage_*.xpr`
|
||||
/// collections have many. Used to route decoding: one → the validated
|
||||
/// records-based list decode ([`Xbg7Model::from_xpr2`]); many → the
|
||||
/// content-anchored [`Xbg7Model::stage_models`]. (Content-anchoring a
|
||||
/// single-model file fabricates phantom / duplicate blocks — see `decode`.)
|
||||
pub fn count_xbg7(bytes: &[u8]) -> usize {
|
||||
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||||
return 0;
|
||||
}
|
||||
let mut cur = Cursor::new(bytes);
|
||||
let header = match Xpr2Header::read(&mut cur) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
let mut n = 0usize;
|
||||
for _ in 0..header.num_resources {
|
||||
match Xpr2ResourceEntry::read(&mut cur) {
|
||||
Ok(e) if &e.type_tag == b"XBG7" => n += 1,
|
||||
Ok(_) => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
impl Xbg7Model {
|
||||
/// Total vertex / triangle counts across all sub-meshes.
|
||||
pub fn totals(&self) -> (usize, usize) {
|
||||
@@ -187,21 +238,21 @@ impl Xbg7Model {
|
||||
return Err(MeshError::UnsupportedLayout);
|
||||
}
|
||||
|
||||
// The index buffer is a triangle STRIP (u16 BE), validated against the
|
||||
// sub-mesh vertex count. Reading it as a triangle *list* dropped ~⅔ of
|
||||
// the surface (the classic "triangular holes in the hull") and produced
|
||||
// spanning spikes; a strip of N indices is N-2 triangles with
|
||||
// alternating winding. Expand to a triangle list here, skipping
|
||||
// degenerate triangles (repeated index = strip restart / zero area).
|
||||
let mut strip = Vec::with_capacity(idx_count);
|
||||
// The index buffer is a triangle LIST (u16 BE): `idx_count` is a
|
||||
// multiple of 3, and each consecutive triple is one triangle. This is
|
||||
// the format the game actually draws (Canary GPU capture: prim=4 =
|
||||
// triangle list), and the objective proof is `XVERIFY`: reading it as
|
||||
// a list gives stored-normal agreement 1.000 (every face normal points
|
||||
// the way its vertices' normals do — only possible with correct
|
||||
// topology + winding), whereas a strip reading gives ~0.49 (random).
|
||||
let mut indices = Vec::with_capacity(idx_count);
|
||||
for k in 0..idx_count {
|
||||
let i = be16(bytes, ib + k * 2) as u32;
|
||||
if i >= vtx_count as u32 {
|
||||
return Err(MeshError::UnsupportedLayout);
|
||||
}
|
||||
strip.push(i);
|
||||
indices.push(i);
|
||||
}
|
||||
let indices = expand_triangle_strip(&strip);
|
||||
|
||||
// Vertices per the declaration. The .xpr stores each element in
|
||||
// naive big-endian component order (f32 / f16 read at consecutive
|
||||
@@ -252,6 +303,42 @@ impl Xbg7Model {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Decisive list-vs-strip diagnostic (env XVERIFY) ──────────────
|
||||
// For each sub-mesh compute, under BOTH interpretations, two
|
||||
// topology-correctness metrics that do NOT depend on which is "nicer
|
||||
// looking": stored-normal agreement (a triangle's cross-product face
|
||||
// normal should point the same way as its vertices' stored normals —
|
||||
// correct topology ⇒ ~all agree; wrong topology wires arbitrary
|
||||
// vertices ⇒ ~50%) and edge-manifoldness (a closed surface shares
|
||||
// almost every edge between exactly 2 triangles).
|
||||
if std::env::var("XVERIFY").is_ok() {
|
||||
let (lt, ld, ln, le) = topology_report(&indices, &positions, &normals);
|
||||
let stripped = expand_triangle_strip(&indices);
|
||||
let (st, sd, sn, se) = topology_report(&stripped, &positions, &normals);
|
||||
eprintln!(
|
||||
" submesh v={vtx_count} idx={idx_count}\n\
|
||||
\x20 LIST : {lt:>5} tris, degen {ld:>4}, normal-agree {ln:.3}, edge2 {le:.3}\n\
|
||||
\x20 STRIP: {st:>5} tris, degen {sd:>4}, normal-agree {sn:.3}, edge2 {se:.3}"
|
||||
);
|
||||
}
|
||||
|
||||
// Objective quality gate on winding CONSISTENCY. Each triangle's
|
||||
// cross-product face normal is compared to its vertices' stored
|
||||
// normals; `na` is the fraction that agree. A correctly-carved,
|
||||
// consistently-wound mesh sits at either ≈1.0 (winding matches the
|
||||
// normals) OR ≈0.0 (winding is inverted relative to them — still a
|
||||
// real, single-sided mesh, just authored the other way). A mis-carve
|
||||
// wires arbitrary vertices, so agreement collapses to the ≈0.5 middle.
|
||||
// Gate on `max(na, 1-na)` so both consistent windings pass and only
|
||||
// the random middle is declined (e.g. `rou_f001_wep_23` na=0.398 →
|
||||
// consistency 0.602 → declined; the clean weapons are all 1.000).
|
||||
if !normals.is_empty() {
|
||||
let (_, _, na, _) = topology_report(&indices, &positions, &normals);
|
||||
if na.max(1.0 - na) < 0.90 {
|
||||
return Err(MeshError::UnsupportedLayout);
|
||||
}
|
||||
}
|
||||
|
||||
meshes.push(GameMesh {
|
||||
positions,
|
||||
normals,
|
||||
@@ -287,7 +374,21 @@ impl Xbg7Model {
|
||||
/// same quantized/complex layout that [`Xbg7Model::from_xpr2`] declines.
|
||||
///
|
||||
/// Returns one [`Xbg7Model`] per decoded resource (empty if none decode).
|
||||
///
|
||||
/// Multi-resource stage files use `min_consistency = 0.0` (no winding gate):
|
||||
/// the stage corpus is large and its enemy meshes span a continuous
|
||||
/// consistency range (0.5–1.0), so a hard gate would drop many legitimate
|
||||
/// blocks. The single-model **weapon fallback** ([`decode` in the CLI]) calls
|
||||
/// [`Xbg7Model::anchor_models`] with a strict `0.85`, where a mis-anchor is an
|
||||
/// obvious phantom / spike-mess that must be declined.
|
||||
pub fn stage_models(bytes: &[u8]) -> Vec<Xbg7Model> {
|
||||
Self::anchor_models(bytes, 0.0)
|
||||
}
|
||||
|
||||
/// Content-anchor every XBG7 resource, rejecting any block whose winding
|
||||
/// consistency (`max(na, 1-na)`) is below `min_consistency`. See
|
||||
/// [`Xbg7Model::stage_models`].
|
||||
pub fn anchor_models(bytes: &[u8], min_consistency: f32) -> Vec<Xbg7Model> {
|
||||
let mut out = Vec::new();
|
||||
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||||
return out;
|
||||
@@ -306,8 +407,10 @@ impl Xbg7Model {
|
||||
const DIR_BASE: usize = 0x10;
|
||||
struct Res {
|
||||
name: String,
|
||||
index_count: usize,
|
||||
vtx_count: usize,
|
||||
/// `(vtx_count, idx_count)` for every sub-mesh, in file order. One
|
||||
/// entry → the simple adjacency layout ([`anchor_pool_mesh`]); several
|
||||
/// → the grouped-pool layout ([`anchor_grouped_meshes`]).
|
||||
markers: Vec<(usize, usize)>,
|
||||
decl: VertexDecl,
|
||||
}
|
||||
let mut resources: Vec<Res> = Vec::new();
|
||||
@@ -325,22 +428,14 @@ impl Xbg7Model {
|
||||
continue;
|
||||
}
|
||||
let d = &bytes[desc..desc_end];
|
||||
let (mk_rel, index_count) = match find_index_marker(d) {
|
||||
Some(m) => m,
|
||||
None => continue,
|
||||
};
|
||||
let markers = all_index_markers(d);
|
||||
if markers.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let decl = match parse_vertex_decl(d) {
|
||||
Some(v) => v,
|
||||
None => continue,
|
||||
};
|
||||
// Total vertex count is stored 32 bytes before the index marker.
|
||||
if mk_rel < 32 {
|
||||
continue;
|
||||
}
|
||||
let vtx_count = be32(d, mk_rel - 32) as usize;
|
||||
if !(3..=400_000).contains(&vtx_count) || index_count < 3 {
|
||||
continue;
|
||||
}
|
||||
// Anchoring relies on the unit-normal signature; skip resources with
|
||||
// no NORMAL element (too ambiguous to pin safely).
|
||||
if decl.normal_offset.is_none() {
|
||||
@@ -350,8 +445,7 @@ impl Xbg7Model {
|
||||
.unwrap_or_else(|| "XBG7".to_string());
|
||||
resources.push(Res {
|
||||
name,
|
||||
index_count,
|
||||
vtx_count,
|
||||
markers,
|
||||
decl,
|
||||
});
|
||||
}
|
||||
@@ -379,16 +473,36 @@ impl Xbg7Model {
|
||||
|
||||
for r in &resources {
|
||||
let starts = &starts_by_stride[&r.decl.stride];
|
||||
if let Some(mesh) = anchor_pool_mesh(
|
||||
bytes,
|
||||
starts,
|
||||
r.index_count,
|
||||
r.vtx_count,
|
||||
&r.decl,
|
||||
) {
|
||||
let meshes = if r.markers.len() == 1 {
|
||||
// Single sub-mesh → the proven per-block adjacency anchor
|
||||
// (index buffer immediately before its vertex buffer). Stages and
|
||||
// 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()
|
||||
} else {
|
||||
// Several sub-meshes sharing grouped index/vertex pools → the
|
||||
// deterministic grouped-pool decode (hero ships et al.).
|
||||
let grouped = anchor_grouped_meshes(bytes, data_base, starts, &r.markers, &r.decl);
|
||||
if !grouped.is_empty() {
|
||||
grouped
|
||||
} else {
|
||||
// The grouped pivot didn't validate (the extra markers were a
|
||||
// coincidence, or this isn't a grouped-pool model). Fall back
|
||||
// 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()
|
||||
}
|
||||
};
|
||||
if !meshes.is_empty() {
|
||||
out.push(Xbg7Model {
|
||||
name: r.name.clone(),
|
||||
meshes: vec![mesh],
|
||||
meshes,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -441,111 +555,291 @@ fn anchor_pool_mesh(
|
||||
index_count: usize,
|
||||
vtx_count: usize,
|
||||
decl: &VertexDecl,
|
||||
min_consistency: f32,
|
||||
) -> Option<GameMesh> {
|
||||
let idx_bytes = index_count * 2;
|
||||
for &vb in starts {
|
||||
// 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
|
||||
// far); some weapons need pad 2. A shifted pad reads garbled indices, so
|
||||
// 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.
|
||||
for pad in 0..=3usize {
|
||||
if vb < idx_bytes + pad {
|
||||
continue;
|
||||
}
|
||||
let ib = vb - idx_bytes - pad;
|
||||
let mc = if pad == 0 {
|
||||
min_consistency
|
||||
} else {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Check whether the `index_count` big-endian `u16` indices at `ib`, read against
|
||||
/// the `vtx_count`-vertex pool at `vb`, form a coherent triangle-list sub-mesh:
|
||||
/// every index in range, ~all vertices referenced, non-degenerate triangles with
|
||||
/// a real spatial extent, connected edges, and (when `min_consistency > 0`) a
|
||||
/// winding consistent with the stored normals. This is the shared acceptance test
|
||||
/// for both the adjacency anchor ([`anchor_pool_mesh`]) and the grouped-pool
|
||||
/// anchor ([`anchor_grouped_meshes`]); the two differ only in how they *place*
|
||||
/// `ib`/`vb`, not in how they validate a placement.
|
||||
fn validate_block(
|
||||
bytes: &[u8],
|
||||
ib: usize,
|
||||
vb: usize,
|
||||
vtx_count: usize,
|
||||
index_count: usize,
|
||||
decl: &VertexDecl,
|
||||
min_consistency: f32,
|
||||
strict_connectivity: bool,
|
||||
) -> bool {
|
||||
let stride = decl.stride;
|
||||
let idx_bytes = index_count * 2;
|
||||
let vtx_bytes = vtx_count.checked_mul(stride)?;
|
||||
if ib + idx_bytes > bytes.len() {
|
||||
return false;
|
||||
}
|
||||
let vtx_bytes = match vtx_count.checked_mul(stride) {
|
||||
Some(v) => v,
|
||||
None => return false,
|
||||
};
|
||||
if vb + vtx_bytes > bytes.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
for &vb in starts {
|
||||
// The index buffer sits immediately before the vertex buffer.
|
||||
if vb < idx_bytes {
|
||||
continue;
|
||||
}
|
||||
let ib = vb - idx_bytes;
|
||||
if vb + vtx_bytes > bytes.len() {
|
||||
continue;
|
||||
// ── Full index validation: every index in range, uses ~all vertices. ──
|
||||
let mut max_idx = 0u32;
|
||||
for k in 0..index_count {
|
||||
let i = be16(bytes, ib + k * 2) as u32;
|
||||
if i >= vtx_count as u32 {
|
||||
return false;
|
||||
}
|
||||
max_idx = max_idx.max(i);
|
||||
}
|
||||
if (max_idx as usize) + 4 < vtx_count {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Full index validation: every index in range, uses ~all vertices. ──
|
||||
let mut max_idx = 0u32;
|
||||
let mut ok = true;
|
||||
for k in 0..index_count {
|
||||
let i = be16(bytes, ib + k * 2) as u32;
|
||||
if i >= vtx_count as u32 {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
max_idx = max_idx.max(i);
|
||||
}
|
||||
if !ok || (max_idx as usize) + 4 < vtx_count {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Triangle quality: finite, non-degenerate, real spatial extent. ──
|
||||
let pos = decl.pos_offset;
|
||||
let mut lo = [f32::MAX; 3];
|
||||
let mut hi = [f32::MIN; 3];
|
||||
let mut degenerate = 0usize;
|
||||
let mut sampled = 0usize;
|
||||
let mut edge_sum = 0.0f32;
|
||||
let tris = index_count / 3;
|
||||
let tstep = (tris / 96).max(1);
|
||||
let mut t = 0;
|
||||
let mut bad = false;
|
||||
while t < tris {
|
||||
let mut p = [[0.0f32; 3]; 3];
|
||||
for (c, pc) in p.iter_mut().enumerate() {
|
||||
let vi = be16(bytes, ib + (3 * t + c) * 2) as usize;
|
||||
let base = vb + vi * stride + pos;
|
||||
for (a, slot) in pc.iter_mut().enumerate() {
|
||||
let x = bef(bytes, base + a * 4);
|
||||
if !x.is_finite() || x.abs() > 1.0e6 {
|
||||
bad = true;
|
||||
break;
|
||||
}
|
||||
*slot = x;
|
||||
lo[a] = lo[a].min(x);
|
||||
hi[a] = hi[a].max(x);
|
||||
}
|
||||
if bad {
|
||||
break;
|
||||
// ── Triangle quality: finite, non-degenerate, real spatial extent. ──
|
||||
let pos = decl.pos_offset;
|
||||
let mut lo = [f32::MAX; 3];
|
||||
let mut hi = [f32::MIN; 3];
|
||||
let mut degenerate = 0usize;
|
||||
let mut sampled = 0usize;
|
||||
let mut edge_sum = 0.0f32;
|
||||
let mut nrm_agree = 0usize;
|
||||
let mut nrm_counted = 0usize;
|
||||
let tris = index_count / 3;
|
||||
let tstep = (tris / 96).max(1);
|
||||
let mut t = 0;
|
||||
while t < tris {
|
||||
let mut p = [[0.0f32; 3]; 3];
|
||||
let mut tri_idx = [0usize; 3];
|
||||
for (c, pc) in p.iter_mut().enumerate() {
|
||||
let vi = be16(bytes, ib + (3 * t + c) * 2) as usize;
|
||||
tri_idx[c] = vi;
|
||||
let base = vb + vi * stride + pos;
|
||||
for (a, slot) in pc.iter_mut().enumerate() {
|
||||
let x = bef(bytes, base + a * 4);
|
||||
if !x.is_finite() || x.abs() > 1.0e6 {
|
||||
return false;
|
||||
}
|
||||
*slot = x;
|
||||
lo[a] = lo[a].min(x);
|
||||
hi[a] = hi[a].max(x);
|
||||
}
|
||||
if bad {
|
||||
break;
|
||||
}
|
||||
let u = [p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2]];
|
||||
let w = [p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2]];
|
||||
let cx = [
|
||||
u[1] * w[2] - u[2] * w[1],
|
||||
u[2] * w[0] - u[0] * w[2],
|
||||
u[0] * w[1] - u[1] * w[0],
|
||||
];
|
||||
if 0.5 * (cx[0] * cx[0] + cx[1] * cx[1] + cx[2] * cx[2]).sqrt() < 1.0e-9 {
|
||||
degenerate += 1;
|
||||
} else if let Some(no) = decl.normal_offset {
|
||||
// Stored-normal agreement: the face normal should point the way
|
||||
// the three vertices' stored normals do. A wrong anchor wires
|
||||
// arbitrary vertices → this collapses toward 50%.
|
||||
let mut sn = [0.0f32; 3];
|
||||
for &vi in &tri_idx {
|
||||
let nb = vb + vi * stride + no;
|
||||
sn[0] += half(bytes, nb);
|
||||
sn[1] += half(bytes, nb + 2);
|
||||
sn[2] += half(bytes, nb + 4);
|
||||
}
|
||||
let u = [p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2]];
|
||||
let w = [p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2]];
|
||||
let cx = [
|
||||
u[1] * w[2] - u[2] * w[1],
|
||||
u[2] * w[0] - u[0] * w[2],
|
||||
u[0] * w[1] - u[1] * w[0],
|
||||
];
|
||||
if 0.5 * (cx[0] * cx[0] + cx[1] * cx[1] + cx[2] * cx[2]).sqrt() < 1.0e-9 {
|
||||
degenerate += 1;
|
||||
if cx[0] * sn[0] + cx[1] * sn[1] + cx[2] * sn[2] > 0.0 {
|
||||
nrm_agree += 1;
|
||||
}
|
||||
// Sum the triangle's three edge lengths (for the connectivity check).
|
||||
let e3 = [p[2][0] - p[1][0], p[2][1] - p[1][1], p[2][2] - p[1][2]];
|
||||
edge_sum += (u[0] * u[0] + u[1] * u[1] + u[2] * u[2]).sqrt()
|
||||
+ (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt()
|
||||
+ (e3[0] * e3[0] + e3[1] * e3[1] + e3[2] * e3[2]).sqrt();
|
||||
sampled += 1;
|
||||
t += tstep;
|
||||
nrm_counted += 1;
|
||||
}
|
||||
if bad {
|
||||
continue;
|
||||
}
|
||||
let extent = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(hi[2] - lo[2]);
|
||||
if extent < 0.5 || sampled == 0 || degenerate * 10 > sampled * 3 {
|
||||
continue; // too flat, or >30% degenerate → not this block
|
||||
}
|
||||
// Connectivity check: a correctly-anchored mesh has triangle edges that
|
||||
// are SMALL relative to its overall size (~0.05–0.15 of the bbox
|
||||
// diagonal). A wrong anchor / cross-wired index buffer connects distant
|
||||
// vertices, so its mean edge spans a large fraction of the model (a spiky
|
||||
// mess). Reject those — try another candidate or decline.
|
||||
// Sum the triangle's three edge lengths (for the connectivity check).
|
||||
let e3 = [p[2][0] - p[1][0], p[2][1] - p[1][1], p[2][2] - p[1][2]];
|
||||
edge_sum += (u[0] * u[0] + u[1] * u[1] + u[2] * u[2]).sqrt()
|
||||
+ (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt()
|
||||
+ (e3[0] * e3[0] + e3[1] * e3[1] + e3[2] * e3[2]).sqrt();
|
||||
sampled += 1;
|
||||
t += tstep;
|
||||
}
|
||||
let extent = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(hi[2] - lo[2]);
|
||||
if extent < 0.5 || sampled == 0 || degenerate * 10 > sampled * 3 {
|
||||
return false; // too flat, or >30% degenerate → not this block
|
||||
}
|
||||
// Connectivity check: a correctly-anchored mesh has triangle edges that
|
||||
// are SMALL relative to its overall size (~0.05–0.15 of the bbox
|
||||
// diagonal). A wrong anchor / cross-wired index buffer connects distant
|
||||
// vertices, so its mean edge spans a large fraction of the model (a spiky
|
||||
// mess). Reject those. Only applied when the placement was *searched*
|
||||
// ([`anchor_pool_mesh`] / grouped-pool pivot): a small flat sub-mesh (a fin,
|
||||
// an antenna) legitimately has large edges relative to its own diagonal, so
|
||||
// the check is skipped for *derived* grouped-pool parts, whose in-range +
|
||||
// consistency signature already pins them unambiguously.
|
||||
if strict_connectivity {
|
||||
let diag = ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2))
|
||||
.sqrt()
|
||||
.max(1e-6);
|
||||
let mean_edge = edge_sum / (sampled as f32 * 3.0);
|
||||
if mean_edge / diag > 0.28 {
|
||||
continue;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Accepted: read the full mesh. ──
|
||||
return Some(read_pool_mesh(bytes, ib, vb, index_count, vtx_count, decl));
|
||||
}
|
||||
None
|
||||
// Winding-consistency gate (same as `from_xpr2`): a correctly-anchored
|
||||
// block is internally consistent — face normals agree with stored normals
|
||||
// either almost always (≈1.0) or almost never (≈0.0, inverted winding);
|
||||
// a false anchor / cross-wired buffer scatters to the ≈0.5 middle. Gate on
|
||||
// `max(na,1-na)` so both real windings pass (stage meshes use both) and
|
||||
// only the random middle is rejected. 0.85 tolerates small-block sampling.
|
||||
if nrm_counted > 0 && min_consistency > 0.0 {
|
||||
let na = nrm_agree as f32 / nrm_counted as f32;
|
||||
if na.max(1.0 - na) < min_consistency {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Decode a **grouped-pool** XBG7 resource: one whose descriptor holds *several*
|
||||
/// index markers (sub-meshes), all sharing one contiguous index pool and one
|
||||
/// contiguous vertex pool. This is the hero-ship / detailed-model layout
|
||||
/// (`DeltaSaber_*.xpr` and ~100 others) that the per-block adjacency anchor
|
||||
/// ([`anchor_pool_mesh`]) cannot decode, because a sub-mesh's index buffer is
|
||||
/// **not** immediately before its vertex buffer.
|
||||
///
|
||||
/// The layout, reversed from the retail disc + cross-checked against a Canary GPU
|
||||
/// draw-log capture of `DeltaSaber_T.xpr` (see `docs/re/structures/xbg7-mesh.md`):
|
||||
///
|
||||
/// ```text
|
||||
/// index pool : [ ib0 | ib1 | … ] each buffer 4-byte aligned, in marker order
|
||||
/// vertex pool : [ vb0 | vb1 | … ] each pool `vtx_count × stride` bytes, same order
|
||||
/// ```
|
||||
///
|
||||
/// and crucially **the index pool ends exactly where the vertex pool begins**.
|
||||
/// So the whole resource pivots on a single unknown — the first vertex pool start
|
||||
/// `vb0` (= index-pool end). Everything else is derived:
|
||||
/// `ib0 = vb0 − span`, `ib[i] = align4(ib[i-1] + idx_count[i-1]·2)`,
|
||||
/// `vb[i] = vb[i-1] + vtx_count[i-1]·stride`. `vb0` is found by scanning the
|
||||
/// unit-normal vertex-run `starts` and accepting the first for which sub-mesh 0
|
||||
/// validates ([`validate_block`], strict 0.85 consistency — the pivot must be
|
||||
/// unambiguous). Each derived sub-mesh is validated too; the chain stops at the
|
||||
/// first that fails (emit what validated, never unvalidated geometry).
|
||||
fn anchor_grouped_meshes(
|
||||
bytes: &[u8],
|
||||
data_base: usize,
|
||||
starts: &[usize],
|
||||
markers: &[(usize, usize)], // (vtx_count, idx_count) in descriptor/file order
|
||||
decl: &VertexDecl,
|
||||
) -> Vec<GameMesh> {
|
||||
let n = markers.len();
|
||||
if n == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let stride = decl.stride;
|
||||
|
||||
// Relative index-buffer offsets (ib0 = 0), 4-byte aligned between buffers,
|
||||
// and cumulative vertex offsets (vb0 = 0) — both derived from the marker list.
|
||||
let mut rel_ib = Vec::with_capacity(n);
|
||||
let mut off_v = Vec::with_capacity(n);
|
||||
let (mut acc_i, mut acc_v) = (0usize, 0usize);
|
||||
for &(vc, ic) in markers {
|
||||
rel_ib.push(acc_i);
|
||||
off_v.push(acc_v);
|
||||
acc_i = align4(acc_i + ic * 2);
|
||||
acc_v += vc * stride;
|
||||
}
|
||||
// The index pool spans ib0 .. end-of-last-buffer. The vertex pool that
|
||||
// follows is **4-byte aligned**, so up to 3 bytes of padding can sit between
|
||||
// the last index buffer and vb0: `vb0 = align4(ib0 + span)`, i.e.
|
||||
// `ib0 = vb0 − span − pad` with `pad ∈ 0..=3`. (DeltaSaber's index pool ended
|
||||
// already-aligned → pad 0; many weapons need pad 2.) We try each pad and let
|
||||
// `validate_block` pick the one that yields a coherent sub-mesh.
|
||||
let span = rel_ib[n - 1] + markers[n - 1].1 * 2;
|
||||
|
||||
// Pivot on the **largest** sub-mesh, not `markers[0]`: it's the one whose
|
||||
// triangle-quality + connectivity signature most reliably confirms the
|
||||
// (ib0, vb0) alignment. Some weapons lead with a tiny, elongated bracket that
|
||||
// fails the connectivity gate even when perfectly placed, which would reject
|
||||
// an otherwise-correct pivot.
|
||||
let kmax = (0..n).max_by_key(|&i| markers[i].1).unwrap_or(0);
|
||||
let (vck, ick) = markers[kmax];
|
||||
|
||||
for &vb0 in starts {
|
||||
for pad in 0..=3usize {
|
||||
if vb0 < span + pad {
|
||||
continue;
|
||||
}
|
||||
let ib0 = vb0 - span - pad;
|
||||
if ib0 < data_base {
|
||||
continue;
|
||||
}
|
||||
// Strict anchor on the pivot sub-mesh: require high winding
|
||||
// consistency AND connectivity here regardless of the stage-wide
|
||||
// `min_consistency`. A wrong pad reads shifted indices → agreement
|
||||
// collapses well below 0.85, so only the true pad/pivot passes.
|
||||
let ib_k = ib0 + rel_ib[kmax];
|
||||
let vb_k = vb0 + off_v[kmax];
|
||||
if !validate_block(bytes, ib_k, vb_k, vck, ick, decl, 0.85, true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pivot confirmed the exact alignment ⇒ every marker up to the pivot
|
||||
// is correctly placed; read those unconditionally (a legitimately
|
||||
// tiny/flat lead part may fail the quality gates yet still be real).
|
||||
// Markers after the pivot are validated so a stray trailing marker
|
||||
// ends the chain instead of appending garbage.
|
||||
let mut meshes = Vec::with_capacity(n);
|
||||
let mut vb = vb0;
|
||||
for i in 0..n {
|
||||
let (vc, ic) = markers[i];
|
||||
let ib = ib0 + rel_ib[i];
|
||||
if ib + ic * 2 > bytes.len()
|
||||
|| vc.checked_mul(stride).map_or(true, |b| vb + b > bytes.len())
|
||||
{
|
||||
break;
|
||||
}
|
||||
// Parts are placed deterministically; in-range + consistency pins
|
||||
// them, so the connectivity heuristic (which mis-rejects small
|
||||
// flat fins) is relaxed here.
|
||||
let ok = validate_block(bytes, ib, vb, vc, ic, decl, 0.85, false);
|
||||
if !ok && i > kmax {
|
||||
break; // chain diverged — emit the validated prefix, no garbage
|
||||
}
|
||||
meshes.push(read_pool_mesh(bytes, ib, vb, ic, vc, decl));
|
||||
vb += vc * stride;
|
||||
}
|
||||
return meshes;
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Read positions / normals / uvs / indices for an anchored stage block.
|
||||
@@ -558,10 +852,11 @@ fn read_pool_mesh(
|
||||
decl: &VertexDecl,
|
||||
) -> GameMesh {
|
||||
let stride = decl.stride;
|
||||
let strip: Vec<u32> = (0..index_count)
|
||||
// Triangle LIST (see `from_xpr2`): each consecutive index triple is a
|
||||
// triangle. `anchor_pool_mesh` already validated this block as a list.
|
||||
let indices: Vec<u32> = (0..index_count)
|
||||
.map(|k| be16(bytes, ib + k * 2) as u32)
|
||||
.collect();
|
||||
let indices = expand_triangle_strip(&strip);
|
||||
|
||||
let mut positions = Vec::with_capacity(vtx_count);
|
||||
let mut normals = Vec::with_capacity(vtx_count);
|
||||
@@ -642,6 +937,33 @@ fn find_index_marker(desc: &[u8]) -> Option<(usize, usize)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Collect **all** `(vtx_count, idx_count)` sub-mesh records in a descriptor, in
|
||||
/// file order, for the grouped-pool layout (see [`anchor_grouped_meshes`]).
|
||||
///
|
||||
/// Each record is an `(index_bytes, index_count)` marker (as in
|
||||
/// [`find_index_marker`]) whose vertex count sits 32 bytes earlier — the same
|
||||
/// `(mk_rel − 32)` convention the single-marker anchor uses. Records with an
|
||||
/// implausible vertex count are skipped so a stray `a == c·2` coincidence cannot
|
||||
/// corrupt the derived index/vertex chain.
|
||||
fn all_index_markers(desc: &[u8]) -> Vec<(usize, usize)> {
|
||||
let mut out = Vec::new();
|
||||
let mut rel = 0usize;
|
||||
while rel + 8 <= desc.len() {
|
||||
let a = be32(desc, rel);
|
||||
let c = be32(desc, rel + 4);
|
||||
if c >= 3 && c % 3 == 0 && c < 400_000 && a == c * 2 && rel >= 32 {
|
||||
let vc = be32(desc, rel - 32) as usize;
|
||||
if (3..=200_000).contains(&vc) {
|
||||
out.push((vc, c as usize));
|
||||
rel += 8;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
rel += 4;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_vertex_decl(desc: &[u8]) -> Option<VertexDecl> {
|
||||
let mk = find_index_marker(desc)?.0;
|
||||
|
||||
@@ -742,10 +1064,16 @@ fn align16(x: usize) -> usize {
|
||||
(x + 15) & !15
|
||||
}
|
||||
|
||||
/// Expand a triangle-**strip** index buffer into a triangle **list**, dropping
|
||||
/// degenerate triangles (a repeated index marks a strip restart / zero-area
|
||||
/// triangle). XBG7 sub-mesh index buffers are strips: reading them as lists
|
||||
/// dropped ~⅔ of every hull (triangular holes) and produced spanning spikes.
|
||||
#[inline]
|
||||
fn align4(x: usize) -> usize {
|
||||
(x + 3) & !3
|
||||
}
|
||||
|
||||
/// Expand a triangle-**strip** index buffer into a triangle list (alternating
|
||||
/// winding, degenerates dropped). **Diagnostic only** — XBG7 index buffers are
|
||||
/// triangle LISTS, not strips (proven by `XVERIFY`: normal-agree 1.000 as a list
|
||||
/// vs ~0.49 as a strip, plus the GPU capture's `prim=4`). Retained so `XVERIFY`
|
||||
/// can keep demonstrating that the strip reading is wrong; NOT used in decode.
|
||||
fn expand_triangle_strip(strip: &[u32]) -> Vec<u32> {
|
||||
let mut out = Vec::with_capacity(strip.len().saturating_sub(2) * 3);
|
||||
for w in 0..strip.len().saturating_sub(2) {
|
||||
@@ -760,6 +1088,69 @@ fn expand_triangle_strip(strip: &[u32]) -> Vec<u32> {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Topology-correctness metrics for a flat triangle-list index buffer, used to
|
||||
/// decide list-vs-strip objectively. Returns `(tris, degenerate, normal_agree,
|
||||
/// edge2_frac)`:
|
||||
/// - `normal_agree`: fraction of non-degenerate triangles whose geometric face
|
||||
/// normal (cross product) dots positively with the sum of its three vertices'
|
||||
/// stored normals. Correct topology + consistent winding ⇒ near 1.0; a
|
||||
/// mis-interpreted buffer wires arbitrary vertices ⇒ near 0.5.
|
||||
/// - `edge2_frac`: fraction of distinct undirected edges shared by exactly 2
|
||||
/// triangles. A closed manifold surface ⇒ near 1.0.
|
||||
fn topology_report(
|
||||
indices: &[u32],
|
||||
positions: &[[f32; 3]],
|
||||
normals: &[[f32; 3]],
|
||||
) -> (usize, usize, f32, f32) {
|
||||
use std::collections::HashMap;
|
||||
let tris = indices.len() / 3;
|
||||
let mut degen = 0usize;
|
||||
let mut agree = 0usize;
|
||||
let mut counted = 0usize;
|
||||
let mut edges: HashMap<(u32, u32), u32> = HashMap::new();
|
||||
for t in indices.chunks_exact(3) {
|
||||
let (a, b, c) = (t[0] as usize, t[1] as usize, t[2] as usize);
|
||||
if a == b || b == c || a == c {
|
||||
degen += 1;
|
||||
continue;
|
||||
}
|
||||
for &(i, j) in &[(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
|
||||
let e = if i < j { (i, j) } else { (j, i) };
|
||||
*edges.entry(e).or_insert(0) += 1;
|
||||
}
|
||||
let (p0, p1, p2) = (positions[a], positions[b], positions[c]);
|
||||
let u = [p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]];
|
||||
let w = [p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]];
|
||||
let fx = u[1] * w[2] - u[2] * w[1];
|
||||
let fy = u[2] * w[0] - u[0] * w[2];
|
||||
let fz = u[0] * w[1] - u[1] * w[0];
|
||||
if !normals.is_empty() {
|
||||
let sn = [
|
||||
normals[a][0] + normals[b][0] + normals[c][0],
|
||||
normals[a][1] + normals[b][1] + normals[c][1],
|
||||
normals[a][2] + normals[b][2] + normals[c][2],
|
||||
];
|
||||
let dot = fx * sn[0] + fy * sn[1] + fz * sn[2];
|
||||
if dot > 0.0 {
|
||||
agree += 1;
|
||||
}
|
||||
counted += 1;
|
||||
}
|
||||
}
|
||||
let two = edges.values().filter(|&&c| c == 2).count();
|
||||
let edge2 = if edges.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
two as f32 / edges.len() as f32
|
||||
};
|
||||
let na = if counted == 0 {
|
||||
f32::NAN
|
||||
} else {
|
||||
agree as f32 / counted as f32
|
||||
};
|
||||
(tris, degen, na, edge2)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn be16(b: &[u8], o: usize) -> u16 {
|
||||
u16::from_be_bytes([b[o], b[o + 1]])
|
||||
|
||||
@@ -122,6 +122,85 @@ fn complex_body_mesh_is_declined_not_garbage() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The hero ship's **grouped-pool** layout decodes fully: `DeltaSaber_T.xpr`'s
|
||||
/// `f001` resource is one vertex+index pool shared by 8 sub-meshes (body + 7
|
||||
/// detail parts). Reversed statically and cross-checked against a Canary GPU
|
||||
/// draw-log capture — every sub-mesh's stored normals agree with its triangle
|
||||
/// winding (0 degenerate, full vertex coverage). This is the layout the old
|
||||
/// per-block adjacency anchor rendered as a spiky phantom.
|
||||
#[test]
|
||||
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
|
||||
fn hero_ship_grouped_pool_decodes() {
|
||||
let Some(dir) = res3d_dir() else {
|
||||
eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)");
|
||||
return;
|
||||
};
|
||||
let bytes = std::fs::read(dir.join("DeltaSaber_T.xpr")).unwrap();
|
||||
let models = Xbg7Model::stage_models(&bytes);
|
||||
|
||||
// The neutral pose `f001` (the mnv*/turn180 resources are animation poses).
|
||||
let f001 = models.iter().find(|m| m.name == "f001").expect("f001 decoded");
|
||||
assert_eq!(f001.meshes.len(), 8, "body + 7 detail sub-meshes");
|
||||
let (v, t) = f001.totals();
|
||||
assert_eq!(v, 11607, "vertex total across sub-meshes");
|
||||
assert_eq!(t, 8650, "triangle total (body 8187 + 7 parts)");
|
||||
|
||||
// Sub-mesh 0 is the body: exactly the draw-log-verified geometry.
|
||||
let body = &f001.meshes[0];
|
||||
assert_eq!(body.positions.len(), 10891);
|
||||
assert_eq!(body.indices.len(), 24561);
|
||||
|
||||
for (i, m) in f001.meshes.iter().enumerate() {
|
||||
// Every index in range.
|
||||
assert!(
|
||||
m.indices.iter().all(|&ix| (ix as usize) < m.positions.len()),
|
||||
"sub{i}: index out of range"
|
||||
);
|
||||
// Correct alignment ⇒ unit normals, and the winding agrees with them
|
||||
// (the decisive correctness signal, ~1.0, not the ~0.5 of a mis-carve).
|
||||
let n = m.normals.len();
|
||||
assert_eq!(n, m.positions.len(), "sub{i}: a normal per vertex");
|
||||
let mean_nlen: f32 = m
|
||||
.normals
|
||||
.iter()
|
||||
.map(|nv| (nv[0] * nv[0] + nv[1] * nv[1] + nv[2] * nv[2]).sqrt())
|
||||
.sum::<f32>()
|
||||
/ n as f32;
|
||||
assert!((mean_nlen - 1.0).abs() < 0.05, "sub{i}: mean |normal| {mean_nlen} ≠ 1");
|
||||
|
||||
let mut agree = 0usize;
|
||||
let mut counted = 0usize;
|
||||
for tri in m.indices.chunks_exact(3) {
|
||||
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
|
||||
let (pa, pb, pc) = (m.positions[a], m.positions[b], m.positions[c]);
|
||||
let u = [pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]];
|
||||
let w = [pc[0] - pa[0], pc[1] - pa[1], pc[2] - pa[2]];
|
||||
let f = [
|
||||
u[1] * w[2] - u[2] * w[1],
|
||||
u[2] * w[0] - u[0] * w[2],
|
||||
u[0] * w[1] - u[1] * w[0],
|
||||
];
|
||||
if f[0] * f[0] + f[1] * f[1] + f[2] * f[2] < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let sn = [
|
||||
m.normals[a][0] + m.normals[b][0] + m.normals[c][0],
|
||||
m.normals[a][1] + m.normals[b][1] + m.normals[c][1],
|
||||
m.normals[a][2] + m.normals[b][2] + m.normals[c][2],
|
||||
];
|
||||
if f[0] * sn[0] + f[1] * sn[1] + f[2] * sn[2] > 0.0 {
|
||||
agree += 1;
|
||||
}
|
||||
counted += 1;
|
||||
}
|
||||
let na = agree as f32 / counted.max(1) as f32;
|
||||
assert!(
|
||||
na.max(1.0 - na) > 0.95,
|
||||
"sub{i}: winding-vs-normal agreement {na} — mis-carved (should be ~1.0 or ~0.0)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage containers decode multiple enemy/prop sub-models via content anchoring.
|
||||
/// Prints coverage; asserts the known-good Stage_S10 meshes decode with sane geometry.
|
||||
#[test]
|
||||
|
||||
@@ -1439,6 +1439,77 @@ fn free_video(
|
||||
*video = VideoPreview::default();
|
||||
}
|
||||
|
||||
/// Reduce a sub-model / resource name to its **entity stem** — the leading
|
||||
/// `<letters><digits>` id that its textures are named after. XBG7 resource names
|
||||
/// carry LOD / part / pose decoration (`e_rou_e007_Near`, `e007_bdy_01_l`,
|
||||
/// `f001_bdy_02`) while the matching albedo map is named for the bare entity
|
||||
/// (`e007_col`, `f001_bdy_01a_col`). Stripping to the stem (`e007`, `f001`) is
|
||||
/// what lets the two be matched.
|
||||
fn entity_stem(name: &str) -> String {
|
||||
let mut s = name.trim_start_matches('_');
|
||||
// Leading single-letter group prefix (`e_`, `g_`, `j_`, `n_`) then `rou_`.
|
||||
for p in ["e_", "g_", "j_", "n_"] {
|
||||
if let Some(r) = s.strip_prefix(p) {
|
||||
s = r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(r) = s.strip_prefix("rou_") {
|
||||
s = r;
|
||||
}
|
||||
// Take the leading <letters><digits> token (e.g. `e007`, `f001`).
|
||||
let b = s.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() && b[i].is_ascii_alphabetic() {
|
||||
i += 1;
|
||||
}
|
||||
let mut j = i;
|
||||
while j < b.len() && b[j].is_ascii_digit() {
|
||||
j += 1;
|
||||
}
|
||||
if i > 0 && j > i {
|
||||
s[..j].to_ascii_lowercase()
|
||||
} else {
|
||||
s.to_ascii_lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the index of the albedo (`_col`) texture for a sub-model, matching its
|
||||
/// [`entity_stem`] against the container's texture names. Returns the best `_col`
|
||||
/// candidate (exact `<stem>_col`, else shortest `<stem>…` name, else any name
|
||||
/// mentioning the stem) or `None` when the entity has no own colour map (it uses
|
||||
/// a shared atlas we can't resolve — the caller shows a neutral tint instead of a
|
||||
/// wrong texture). This replaces the old `ends_with("_col") && starts_with(core)`
|
||||
/// rule, which only matched ~25% of stage sub-models (the rest fell back to flat
|
||||
/// grey — "models render without colour").
|
||||
fn pick_albedo_index(model_name: &str, tex_names: &[String]) -> Option<usize> {
|
||||
let stem = entity_stem(model_name);
|
||||
if stem.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let cols: Vec<(usize, String)> = tex_names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| (i, n.to_ascii_lowercase()))
|
||||
.filter(|(_, n)| n.contains("_col"))
|
||||
.collect();
|
||||
// 1. Exact `<stem>_col`.
|
||||
let exact = format!("{stem}_col");
|
||||
if let Some((i, _)) = cols.iter().find(|(_, n)| *n == exact) {
|
||||
return Some(*i);
|
||||
}
|
||||
// 2. Names starting with the stem — prefer the shortest (closest) one.
|
||||
if let Some((i, _)) = cols
|
||||
.iter()
|
||||
.filter(|(_, n)| n.starts_with(&stem))
|
||||
.min_by_key(|(_, n)| n.len())
|
||||
{
|
||||
return Some(*i);
|
||||
}
|
||||
// 3. Stem mentioned anywhere in a colour map's name.
|
||||
cols.iter().find(|(_, n)| n.contains(&stem)).map(|(i, _)| *i)
|
||||
}
|
||||
|
||||
/// Build Bevy meshes from a decoded [`Xbg7Model`], texture them with the model's
|
||||
/// albedo (`_col`) map, spawn them into the scene tagged [`PreviewModel`], and
|
||||
/// frame the orbit camera on the combined bounding box.
|
||||
@@ -1457,11 +1528,10 @@ fn spawn_model_preview(
|
||||
use bevy::render::mesh::{Indices, PrimitiveTopology};
|
||||
use sylpheed_formats::texture::X360Texture;
|
||||
|
||||
// ── Albedo texture: prefer the `_col` map, else the first texture. ──
|
||||
// ── Albedo texture: the model's `_col` map (matched by entity stem), else
|
||||
// the first texture as a last resort so a single model is never untextured.
|
||||
let names = X360Texture::texture_names(xpr_bytes);
|
||||
let col_idx = names
|
||||
.iter()
|
||||
.position(|n| n.ends_with("_col"))
|
||||
let col_idx = pick_albedo_index(&model.name, &names)
|
||||
.or(if names.is_empty() { None } else { Some(0) });
|
||||
let (base_color_texture, tex_label) = match col_idx {
|
||||
Some(i) => match X360Texture::from_xpr2_index(xpr_bytes, i)
|
||||
@@ -1610,15 +1680,11 @@ fn spawn_stage_models(
|
||||
images: &mut Assets<Image>,
|
||||
cache: &mut std::collections::HashMap<usize, Handle<StandardMaterial>>|
|
||||
-> Handle<StandardMaterial> {
|
||||
// Core name = strip leading `_`/`rou_` and trailing `_l` LOD suffix.
|
||||
let core = model_name
|
||||
.trim_start_matches('_')
|
||||
.trim_start_matches("rou_")
|
||||
.trim_end_matches("_l");
|
||||
let idx = tex_names.iter().position(|n| {
|
||||
n.ends_with("_col") && (n.starts_with(core) || n.contains(core))
|
||||
});
|
||||
let Some(i) = idx else { return neutral.clone() };
|
||||
// Match the sub-model to its albedo map by entity stem (`e007_bdy_01` →
|
||||
// `e007_col`). No own colour map ⇒ neutral tint rather than a wrong one.
|
||||
let Some(i) = pick_albedo_index(model_name, &tex_names) else {
|
||||
return neutral.clone();
|
||||
};
|
||||
if let Some(h) = cache.get(&i) {
|
||||
return h.clone();
|
||||
}
|
||||
@@ -1892,45 +1958,61 @@ fn apply_loaded_texture(
|
||||
// models under `hidden/resource3d/`) preview as an orbitable mesh, textured
|
||||
// with their albedo (`_col`) map. Complex multi-stream body meshes decline
|
||||
// cleanly (Err) and fall through to the 2D texture preview below.
|
||||
// A container may decode as a single model (weapons / props, via the
|
||||
// single-block layout) OR as a stage — a collection of many enemy / prop
|
||||
// sub-models. A stage's *first* XBG7 is often a tiny dummy "root" node that
|
||||
// the single-model path decodes into a degenerate cube, so we can't just try
|
||||
// it first and stop. Decode both and keep whichever yields more geometry.
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
let single = Xbg7Model::from_xpr2(&bytes)
|
||||
.ok()
|
||||
.filter(|m| !m.meshes.is_empty());
|
||||
let single_verts = single.as_ref().map(|m| m.totals().0).unwrap_or(0);
|
||||
|
||||
let stage = Xbg7Model::stage_models(&bytes);
|
||||
let stage_verts: usize = stage.iter().map(|m| m.totals().0).sum();
|
||||
|
||||
if !stage.is_empty() && stage_verts > single_verts {
|
||||
spawn_stage_models(
|
||||
&stage,
|
||||
&bytes,
|
||||
&mut commands,
|
||||
&mut meshes,
|
||||
&mut materials,
|
||||
&mut images,
|
||||
&mut model_preview,
|
||||
&mut orbit,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Some(model) = single {
|
||||
spawn_model_preview(
|
||||
&model,
|
||||
&bytes,
|
||||
&mut commands,
|
||||
&mut meshes,
|
||||
&mut materials,
|
||||
&mut images,
|
||||
&mut model_preview,
|
||||
&mut orbit,
|
||||
);
|
||||
return;
|
||||
// A container may be a single model (weapons / props) OR a stage — a
|
||||
// collection of many enemy / prop sub-models. Route by the number of XBG7
|
||||
// resources, NOT by whichever decoder yields more verts: the old max-verts
|
||||
// rule let a stage's content-anchoring win on single-model files and fabricate
|
||||
// phantom / spike-mess blocks (see the CLI `decode_models`). One XBG7 → the
|
||||
// validated records-based list decode, falling back to a STRICT-gated content
|
||||
// anchor if that can't locate the block; many XBG7 → the ungated stage anchor.
|
||||
use sylpheed_formats::mesh::{count_xbg7, Xbg7Model};
|
||||
if count_xbg7(&bytes) > 1 {
|
||||
let stage = Xbg7Model::stage_models(&bytes);
|
||||
if !stage.is_empty() {
|
||||
spawn_stage_models(
|
||||
&stage,
|
||||
&bytes,
|
||||
&mut commands,
|
||||
&mut meshes,
|
||||
&mut materials,
|
||||
&mut images,
|
||||
&mut model_preview,
|
||||
&mut orbit,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
let single = Xbg7Model::from_xpr2(&bytes)
|
||||
.ok()
|
||||
.filter(|m| !m.meshes.is_empty());
|
||||
if let Some(model) = single {
|
||||
spawn_model_preview(
|
||||
&model,
|
||||
&bytes,
|
||||
&mut commands,
|
||||
&mut meshes,
|
||||
&mut materials,
|
||||
&mut images,
|
||||
&mut model_preview,
|
||||
&mut orbit,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// from_xpr2 couldn't locate the block — strict-gated content anchor.
|
||||
let fallback = Xbg7Model::anchor_models(&bytes, 0.85);
|
||||
if !fallback.is_empty() {
|
||||
spawn_stage_models(
|
||||
&fallback,
|
||||
&bytes,
|
||||
&mut commands,
|
||||
&mut meshes,
|
||||
&mut materials,
|
||||
&mut images,
|
||||
&mut model_preview,
|
||||
&mut orbit,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// World cubemap (`TXCM`) → decode all 6 faces and show a labelled grid.
|
||||
@@ -2315,6 +2397,39 @@ fn advance_video_playback(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod albedo_match_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn entity_stem_strips_decoration() {
|
||||
assert_eq!(entity_stem("e_rou_e007_Near"), "e007");
|
||||
assert_eq!(entity_stem("e007_bdy_01_l"), "e007");
|
||||
assert_eq!(entity_stem("f001_bdy_02"), "f001");
|
||||
assert_eq!(entity_stem("f001"), "f001");
|
||||
assert_eq!(entity_stem("g001"), "g001");
|
||||
assert_eq!(entity_stem("_rou_f001_mnv01_L"), "f001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn albedo_matches_entity_col_map() {
|
||||
let texs: Vec<String> = ["e007_col", "e007_lum", "e007_spc", "e010_col"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
// Sub-models decorated with LOD / part suffixes still map to `e007_col`,
|
||||
// where the old `starts_with(full_name)` rule fell back to grey.
|
||||
assert_eq!(pick_albedo_index("e_rou_e007_Near", &texs), Some(0));
|
||||
assert_eq!(pick_albedo_index("e007_bdy_01_l", &texs), Some(0));
|
||||
assert_eq!(pick_albedo_index("e010_bdy", &texs), Some(3));
|
||||
// A hangar-suffixed col map is still recognised as a colour map.
|
||||
let hangar = vec!["f001_wep_00_col_hangar".to_string()];
|
||||
assert_eq!(pick_albedo_index("f001_wep_00_hangar", &hangar), Some(0));
|
||||
// No matching entity map ⇒ None (caller shows a neutral tint).
|
||||
assert_eq!(pick_albedo_index("z999", &texs), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod font_sample_tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user