[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
|
/// a single model (weapon / prop) OR a stage's many sub-models — whichever
|
||||||
/// yields more geometry.
|
/// yields more geometry.
|
||||||
fn decode_models(bytes: &[u8]) -> Vec<sylpheed_formats::mesh::Xbg7Model> {
|
fn decode_models(bytes: &[u8]) -> Vec<sylpheed_formats::mesh::Xbg7Model> {
|
||||||
use sylpheed_formats::mesh::Xbg7Model;
|
use sylpheed_formats::mesh::{count_xbg7, Xbg7Model};
|
||||||
let single = Xbg7Model::from_xpr2(bytes)
|
// 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()
|
.ok()
|
||||||
.filter(|m| !m.meshes.is_empty());
|
.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);
|
// Single model the records-based list decode carved (authoritative).
|
||||||
let stage_verts: usize = stage.iter().map(|m| m.totals().0).sum();
|
vec![m]
|
||||||
if !stage.is_empty() && stage_verts > single_verts {
|
|
||||||
stage
|
|
||||||
} else {
|
} 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 bytes = std::fs::read(file).with_context(|| format!("Cannot read {}", file.display()))?;
|
||||||
let mut models = decode_models(&bytes);
|
let mut models = decode_models(&bytes);
|
||||||
if let Some(sub) = &only {
|
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() {
|
if models.is_empty() {
|
||||||
anyhow::bail!("no decodable XBG7 geometry in {}", file.display());
|
anyhow::bail!("no decodable XBG7 geometry in {}", file.display());
|
||||||
|
|||||||
@@ -21,19 +21,27 @@
|
|||||||
//! the data section is a straight sequence of sub-meshes, each:
|
//! the data section is a straight sequence of sub-meshes, each:
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! [ index buffer : idx_count × u16 big-endian ] triangle STRIP
|
|
||||||
//! [ 12-byte vertex-buffer header (contents undecoded) ]
|
//! [ 12-byte vertex-buffer header (contents undecoded) ]
|
||||||
|
//! [ index buffer : idx_count × u16 big-endian ] triangle LIST
|
||||||
//! [ vertex buffer : vtx_count × stride bytes ] (declaration-driven)
|
//! [ vertex buffer : vtx_count × stride bytes ] (declaration-driven)
|
||||||
//! (pad to 16 bytes → next sub-mesh)
|
//! (pad to 16 bytes → next sub-mesh)
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! The index buffer is a triangle **strip** (a strip of N indices is N-2
|
//! The index buffer is a triangle **list**: `idx_count` is a multiple of 3 and
|
||||||
//! triangles with alternating winding), NOT a list — reading it as a list
|
//! each consecutive triple is one triangle. This is confirmed two ways — the
|
||||||
//! dropped ~⅔ of every hull (the "triangular holes") and produced spanning
|
//! Canary GPU draw capture logs `prim=4` (triangle list), and the objective
|
||||||
//! spikes. `expand_triangle_strip` converts it, skipping degenerate triangles.
|
//! `XVERIFY` diagnostic shows stored-normal agreement of **1.000** under the list
|
||||||
//! Residual: an index buffer may concatenate several strips with no degenerate
|
//! reading (every face normal points the way its vertices' normals do — only
|
||||||
//! bridge, leaving ~2 spanning triangles at each restart (an index jump to a new
|
//! possible with correct topology + winding) versus **~0.49** (random) under a
|
||||||
//! vertex region) — a small known artifact on some models.
|
//! 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**
|
//! The vertex layout is **not fixed** — it comes from a **vertex declaration**
|
||||||
//! in the descriptor: a table of `{offset, format-code, usage}` triples (usage
|
//! 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
|
//! sub-mesh fails to carve cleanly the whole model is rejected
|
||||||
//! ([`MeshError::UnsupportedLayout`]) rather than emitting garbage.
|
//! ([`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
|
//! Detailed models (`DeltaSaber_*.xpr` and ~100 others) don't interleave each
|
||||||
//! models) use a more complex **multi-stream** layout — separate position /
|
//! sub-mesh's index buffer with its vertex buffer. Instead a resource's *several*
|
||||||
//! attribute streams at descriptor-addressed offsets, quantized positions —
|
//! sub-meshes share **two grouped pools**:
|
||||||
//! which is not handled here and is cleanly declined.
|
//!
|
||||||
|
//! ```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 crate::texture::{Xpr2Header, Xpr2ResourceEntry};
|
||||||
use binrw::BinRead;
|
use binrw::BinRead;
|
||||||
@@ -100,6 +125,32 @@ pub struct Xbg7Model {
|
|||||||
pub meshes: Vec<GameMesh>,
|
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 {
|
impl Xbg7Model {
|
||||||
/// Total vertex / triangle counts across all sub-meshes.
|
/// Total vertex / triangle counts across all sub-meshes.
|
||||||
pub fn totals(&self) -> (usize, usize) {
|
pub fn totals(&self) -> (usize, usize) {
|
||||||
@@ -187,21 +238,21 @@ impl Xbg7Model {
|
|||||||
return Err(MeshError::UnsupportedLayout);
|
return Err(MeshError::UnsupportedLayout);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The index buffer is a triangle STRIP (u16 BE), validated against the
|
// The index buffer is a triangle LIST (u16 BE): `idx_count` is a
|
||||||
// sub-mesh vertex count. Reading it as a triangle *list* dropped ~⅔ of
|
// multiple of 3, and each consecutive triple is one triangle. This is
|
||||||
// the surface (the classic "triangular holes in the hull") and produced
|
// the format the game actually draws (Canary GPU capture: prim=4 =
|
||||||
// spanning spikes; a strip of N indices is N-2 triangles with
|
// triangle list), and the objective proof is `XVERIFY`: reading it as
|
||||||
// alternating winding. Expand to a triangle list here, skipping
|
// a list gives stored-normal agreement 1.000 (every face normal points
|
||||||
// degenerate triangles (repeated index = strip restart / zero area).
|
// the way its vertices' normals do — only possible with correct
|
||||||
let mut strip = Vec::with_capacity(idx_count);
|
// topology + winding), whereas a strip reading gives ~0.49 (random).
|
||||||
|
let mut indices = Vec::with_capacity(idx_count);
|
||||||
for k in 0..idx_count {
|
for k in 0..idx_count {
|
||||||
let i = be16(bytes, ib + k * 2) as u32;
|
let i = be16(bytes, ib + k * 2) as u32;
|
||||||
if i >= vtx_count as u32 {
|
if i >= vtx_count as u32 {
|
||||||
return Err(MeshError::UnsupportedLayout);
|
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
|
// Vertices per the declaration. The .xpr stores each element in
|
||||||
// naive big-endian component order (f32 / f16 read at consecutive
|
// 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 {
|
meshes.push(GameMesh {
|
||||||
positions,
|
positions,
|
||||||
normals,
|
normals,
|
||||||
@@ -287,7 +374,21 @@ impl Xbg7Model {
|
|||||||
/// same quantized/complex layout that [`Xbg7Model::from_xpr2`] declines.
|
/// same quantized/complex layout that [`Xbg7Model::from_xpr2`] declines.
|
||||||
///
|
///
|
||||||
/// Returns one [`Xbg7Model`] per decoded resource (empty if none decode).
|
/// 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> {
|
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();
|
let mut out = Vec::new();
|
||||||
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||||||
return out;
|
return out;
|
||||||
@@ -306,8 +407,10 @@ impl Xbg7Model {
|
|||||||
const DIR_BASE: usize = 0x10;
|
const DIR_BASE: usize = 0x10;
|
||||||
struct Res {
|
struct Res {
|
||||||
name: String,
|
name: String,
|
||||||
index_count: usize,
|
/// `(vtx_count, idx_count)` for every sub-mesh, in file order. One
|
||||||
vtx_count: usize,
|
/// entry → the simple adjacency layout ([`anchor_pool_mesh`]); several
|
||||||
|
/// → the grouped-pool layout ([`anchor_grouped_meshes`]).
|
||||||
|
markers: Vec<(usize, usize)>,
|
||||||
decl: VertexDecl,
|
decl: VertexDecl,
|
||||||
}
|
}
|
||||||
let mut resources: Vec<Res> = Vec::new();
|
let mut resources: Vec<Res> = Vec::new();
|
||||||
@@ -325,22 +428,14 @@ impl Xbg7Model {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let d = &bytes[desc..desc_end];
|
let d = &bytes[desc..desc_end];
|
||||||
let (mk_rel, index_count) = match find_index_marker(d) {
|
let markers = all_index_markers(d);
|
||||||
Some(m) => m,
|
if markers.is_empty() {
|
||||||
None => continue,
|
continue;
|
||||||
};
|
}
|
||||||
let decl = match parse_vertex_decl(d) {
|
let decl = match parse_vertex_decl(d) {
|
||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
None => continue,
|
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
|
// Anchoring relies on the unit-normal signature; skip resources with
|
||||||
// no NORMAL element (too ambiguous to pin safely).
|
// no NORMAL element (too ambiguous to pin safely).
|
||||||
if decl.normal_offset.is_none() {
|
if decl.normal_offset.is_none() {
|
||||||
@@ -350,8 +445,7 @@ impl Xbg7Model {
|
|||||||
.unwrap_or_else(|| "XBG7".to_string());
|
.unwrap_or_else(|| "XBG7".to_string());
|
||||||
resources.push(Res {
|
resources.push(Res {
|
||||||
name,
|
name,
|
||||||
index_count,
|
markers,
|
||||||
vtx_count,
|
|
||||||
decl,
|
decl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -379,16 +473,36 @@ impl Xbg7Model {
|
|||||||
|
|
||||||
for r in &resources {
|
for r in &resources {
|
||||||
let starts = &starts_by_stride[&r.decl.stride];
|
let starts = &starts_by_stride[&r.decl.stride];
|
||||||
if let Some(mesh) = anchor_pool_mesh(
|
let meshes = if r.markers.len() == 1 {
|
||||||
bytes,
|
// Single sub-mesh → the proven per-block adjacency anchor
|
||||||
starts,
|
// (index buffer immediately before its vertex buffer). Stages and
|
||||||
r.index_count,
|
// simple props take this path; `min_consistency` behaviour is
|
||||||
r.vtx_count,
|
// exactly as before.
|
||||||
&r.decl,
|
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 {
|
out.push(Xbg7Model {
|
||||||
name: r.name.clone(),
|
name: r.name.clone(),
|
||||||
meshes: vec![mesh],
|
meshes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -441,111 +555,291 @@ fn anchor_pool_mesh(
|
|||||||
index_count: usize,
|
index_count: usize,
|
||||||
vtx_count: usize,
|
vtx_count: usize,
|
||||||
decl: &VertexDecl,
|
decl: &VertexDecl,
|
||||||
|
min_consistency: f32,
|
||||||
) -> Option<GameMesh> {
|
) -> 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 stride = decl.stride;
|
||||||
let idx_bytes = index_count * 2;
|
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 {
|
// ── Full index validation: every index in range, uses ~all vertices. ──
|
||||||
// The index buffer sits immediately before the vertex buffer.
|
let mut max_idx = 0u32;
|
||||||
if vb < idx_bytes {
|
for k in 0..index_count {
|
||||||
continue;
|
let i = be16(bytes, ib + k * 2) as u32;
|
||||||
}
|
if i >= vtx_count as u32 {
|
||||||
let ib = vb - idx_bytes;
|
return false;
|
||||||
if vb + vtx_bytes > bytes.len() {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
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. ──
|
// ── Triangle quality: finite, non-degenerate, real spatial extent. ──
|
||||||
let mut max_idx = 0u32;
|
let pos = decl.pos_offset;
|
||||||
let mut ok = true;
|
let mut lo = [f32::MAX; 3];
|
||||||
for k in 0..index_count {
|
let mut hi = [f32::MIN; 3];
|
||||||
let i = be16(bytes, ib + k * 2) as u32;
|
let mut degenerate = 0usize;
|
||||||
if i >= vtx_count as u32 {
|
let mut sampled = 0usize;
|
||||||
ok = false;
|
let mut edge_sum = 0.0f32;
|
||||||
break;
|
let mut nrm_agree = 0usize;
|
||||||
}
|
let mut nrm_counted = 0usize;
|
||||||
max_idx = max_idx.max(i);
|
let tris = index_count / 3;
|
||||||
}
|
let tstep = (tris / 96).max(1);
|
||||||
if !ok || (max_idx as usize) + 4 < vtx_count {
|
let mut t = 0;
|
||||||
continue;
|
while t < tris {
|
||||||
}
|
let mut p = [[0.0f32; 3]; 3];
|
||||||
|
let mut tri_idx = [0usize; 3];
|
||||||
// ── Triangle quality: finite, non-degenerate, real spatial extent. ──
|
for (c, pc) in p.iter_mut().enumerate() {
|
||||||
let pos = decl.pos_offset;
|
let vi = be16(bytes, ib + (3 * t + c) * 2) as usize;
|
||||||
let mut lo = [f32::MAX; 3];
|
tri_idx[c] = vi;
|
||||||
let mut hi = [f32::MIN; 3];
|
let base = vb + vi * stride + pos;
|
||||||
let mut degenerate = 0usize;
|
for (a, slot) in pc.iter_mut().enumerate() {
|
||||||
let mut sampled = 0usize;
|
let x = bef(bytes, base + a * 4);
|
||||||
let mut edge_sum = 0.0f32;
|
if !x.is_finite() || x.abs() > 1.0e6 {
|
||||||
let tris = index_count / 3;
|
return false;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
*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]];
|
if cx[0] * sn[0] + cx[1] * sn[1] + cx[2] * sn[2] > 0.0 {
|
||||||
let w = [p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2]];
|
nrm_agree += 1;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
// Sum the triangle's three edge lengths (for the connectivity check).
|
nrm_counted += 1;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
if bad {
|
// Sum the triangle's three edge lengths (for the connectivity check).
|
||||||
continue;
|
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()
|
||||||
let extent = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(hi[2] - lo[2]);
|
+ (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt()
|
||||||
if extent < 0.5 || sampled == 0 || degenerate * 10 > sampled * 3 {
|
+ (e3[0] * e3[0] + e3[1] * e3[1] + e3[2] * e3[2]).sqrt();
|
||||||
continue; // too flat, or >30% degenerate → not this block
|
sampled += 1;
|
||||||
}
|
t += tstep;
|
||||||
// Connectivity check: a correctly-anchored mesh has triangle edges that
|
}
|
||||||
// are SMALL relative to its overall size (~0.05–0.15 of the bbox
|
let extent = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(hi[2] - lo[2]);
|
||||||
// diagonal). A wrong anchor / cross-wired index buffer connects distant
|
if extent < 0.5 || sampled == 0 || degenerate * 10 > sampled * 3 {
|
||||||
// vertices, so its mean edge spans a large fraction of the model (a spiky
|
return false; // too flat, or >30% degenerate → not this block
|
||||||
// mess). Reject those — try another candidate or decline.
|
}
|
||||||
|
// 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))
|
let diag = ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2))
|
||||||
.sqrt()
|
.sqrt()
|
||||||
.max(1e-6);
|
.max(1e-6);
|
||||||
let mean_edge = edge_sum / (sampled as f32 * 3.0);
|
let mean_edge = edge_sum / (sampled as f32 * 3.0);
|
||||||
if mean_edge / diag > 0.28 {
|
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.
|
/// Read positions / normals / uvs / indices for an anchored stage block.
|
||||||
@@ -558,10 +852,11 @@ fn read_pool_mesh(
|
|||||||
decl: &VertexDecl,
|
decl: &VertexDecl,
|
||||||
) -> GameMesh {
|
) -> GameMesh {
|
||||||
let stride = decl.stride;
|
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)
|
.map(|k| be16(bytes, ib + k * 2) as u32)
|
||||||
.collect();
|
.collect();
|
||||||
let indices = expand_triangle_strip(&strip);
|
|
||||||
|
|
||||||
let mut positions = Vec::with_capacity(vtx_count);
|
let mut positions = Vec::with_capacity(vtx_count);
|
||||||
let mut normals = 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
|
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> {
|
fn parse_vertex_decl(desc: &[u8]) -> Option<VertexDecl> {
|
||||||
let mk = find_index_marker(desc)?.0;
|
let mk = find_index_marker(desc)?.0;
|
||||||
|
|
||||||
@@ -742,10 +1064,16 @@ fn align16(x: usize) -> usize {
|
|||||||
(x + 15) & !15
|
(x + 15) & !15
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expand a triangle-**strip** index buffer into a triangle **list**, dropping
|
#[inline]
|
||||||
/// degenerate triangles (a repeated index marks a strip restart / zero-area
|
fn align4(x: usize) -> usize {
|
||||||
/// triangle). XBG7 sub-mesh index buffers are strips: reading them as lists
|
(x + 3) & !3
|
||||||
/// dropped ~⅔ of every hull (triangular holes) and produced spanning spikes.
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
fn expand_triangle_strip(strip: &[u32]) -> Vec<u32> {
|
||||||
let mut out = Vec::with_capacity(strip.len().saturating_sub(2) * 3);
|
let mut out = Vec::with_capacity(strip.len().saturating_sub(2) * 3);
|
||||||
for w in 0..strip.len().saturating_sub(2) {
|
for w in 0..strip.len().saturating_sub(2) {
|
||||||
@@ -760,6 +1088,69 @@ fn expand_triangle_strip(strip: &[u32]) -> Vec<u32> {
|
|||||||
}
|
}
|
||||||
out
|
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]
|
#[inline]
|
||||||
fn be16(b: &[u8], o: usize) -> u16 {
|
fn be16(b: &[u8], o: usize) -> u16 {
|
||||||
u16::from_be_bytes([b[o], b[o + 1]])
|
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.
|
/// Stage containers decode multiple enemy/prop sub-models via content anchoring.
|
||||||
/// Prints coverage; asserts the known-good Stage_S10 meshes decode with sane geometry.
|
/// Prints coverage; asserts the known-good Stage_S10 meshes decode with sane geometry.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1439,6 +1439,77 @@ fn free_video(
|
|||||||
*video = VideoPreview::default();
|
*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
|
/// Build Bevy meshes from a decoded [`Xbg7Model`], texture them with the model's
|
||||||
/// albedo (`_col`) map, spawn them into the scene tagged [`PreviewModel`], and
|
/// albedo (`_col`) map, spawn them into the scene tagged [`PreviewModel`], and
|
||||||
/// frame the orbit camera on the combined bounding box.
|
/// frame the orbit camera on the combined bounding box.
|
||||||
@@ -1457,11 +1528,10 @@ fn spawn_model_preview(
|
|||||||
use bevy::render::mesh::{Indices, PrimitiveTopology};
|
use bevy::render::mesh::{Indices, PrimitiveTopology};
|
||||||
use sylpheed_formats::texture::X360Texture;
|
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 names = X360Texture::texture_names(xpr_bytes);
|
||||||
let col_idx = names
|
let col_idx = pick_albedo_index(&model.name, &names)
|
||||||
.iter()
|
|
||||||
.position(|n| n.ends_with("_col"))
|
|
||||||
.or(if names.is_empty() { None } else { Some(0) });
|
.or(if names.is_empty() { None } else { Some(0) });
|
||||||
let (base_color_texture, tex_label) = match col_idx {
|
let (base_color_texture, tex_label) = match col_idx {
|
||||||
Some(i) => match X360Texture::from_xpr2_index(xpr_bytes, i)
|
Some(i) => match X360Texture::from_xpr2_index(xpr_bytes, i)
|
||||||
@@ -1610,15 +1680,11 @@ fn spawn_stage_models(
|
|||||||
images: &mut Assets<Image>,
|
images: &mut Assets<Image>,
|
||||||
cache: &mut std::collections::HashMap<usize, Handle<StandardMaterial>>|
|
cache: &mut std::collections::HashMap<usize, Handle<StandardMaterial>>|
|
||||||
-> Handle<StandardMaterial> {
|
-> Handle<StandardMaterial> {
|
||||||
// Core name = strip leading `_`/`rou_` and trailing `_l` LOD suffix.
|
// Match the sub-model to its albedo map by entity stem (`e007_bdy_01` →
|
||||||
let core = model_name
|
// `e007_col`). No own colour map ⇒ neutral tint rather than a wrong one.
|
||||||
.trim_start_matches('_')
|
let Some(i) = pick_albedo_index(model_name, &tex_names) else {
|
||||||
.trim_start_matches("rou_")
|
return neutral.clone();
|
||||||
.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() };
|
|
||||||
if let Some(h) = cache.get(&i) {
|
if let Some(h) = cache.get(&i) {
|
||||||
return h.clone();
|
return h.clone();
|
||||||
}
|
}
|
||||||
@@ -1892,45 +1958,61 @@ fn apply_loaded_texture(
|
|||||||
// models under `hidden/resource3d/`) preview as an orbitable mesh, textured
|
// models under `hidden/resource3d/`) preview as an orbitable mesh, textured
|
||||||
// with their albedo (`_col`) map. Complex multi-stream body meshes decline
|
// with their albedo (`_col`) map. Complex multi-stream body meshes decline
|
||||||
// cleanly (Err) and fall through to the 2D texture preview below.
|
// cleanly (Err) and fall through to the 2D texture preview below.
|
||||||
// A container may decode as a single model (weapons / props, via the
|
// A container may be a single model (weapons / props) OR a stage — a
|
||||||
// single-block layout) OR as a stage — a collection of many enemy / prop
|
// collection of many enemy / prop sub-models. Route by the number of XBG7
|
||||||
// sub-models. A stage's *first* XBG7 is often a tiny dummy "root" node that
|
// resources, NOT by whichever decoder yields more verts: the old max-verts
|
||||||
// the single-model path decodes into a degenerate cube, so we can't just try
|
// rule let a stage's content-anchoring win on single-model files and fabricate
|
||||||
// it first and stop. Decode both and keep whichever yields more geometry.
|
// phantom / spike-mess blocks (see the CLI `decode_models`). One XBG7 → the
|
||||||
use sylpheed_formats::mesh::Xbg7Model;
|
// validated records-based list decode, falling back to a STRICT-gated content
|
||||||
let single = Xbg7Model::from_xpr2(&bytes)
|
// anchor if that can't locate the block; many XBG7 → the ungated stage anchor.
|
||||||
.ok()
|
use sylpheed_formats::mesh::{count_xbg7, Xbg7Model};
|
||||||
.filter(|m| !m.meshes.is_empty());
|
if count_xbg7(&bytes) > 1 {
|
||||||
let single_verts = single.as_ref().map(|m| m.totals().0).unwrap_or(0);
|
let stage = Xbg7Model::stage_models(&bytes);
|
||||||
|
if !stage.is_empty() {
|
||||||
let stage = Xbg7Model::stage_models(&bytes);
|
spawn_stage_models(
|
||||||
let stage_verts: usize = stage.iter().map(|m| m.totals().0).sum();
|
&stage,
|
||||||
|
&bytes,
|
||||||
if !stage.is_empty() && stage_verts > single_verts {
|
&mut commands,
|
||||||
spawn_stage_models(
|
&mut meshes,
|
||||||
&stage,
|
&mut materials,
|
||||||
&bytes,
|
&mut images,
|
||||||
&mut commands,
|
&mut model_preview,
|
||||||
&mut meshes,
|
&mut orbit,
|
||||||
&mut materials,
|
);
|
||||||
&mut images,
|
return;
|
||||||
&mut model_preview,
|
}
|
||||||
&mut orbit,
|
} else {
|
||||||
);
|
let single = Xbg7Model::from_xpr2(&bytes)
|
||||||
return;
|
.ok()
|
||||||
}
|
.filter(|m| !m.meshes.is_empty());
|
||||||
if let Some(model) = single {
|
if let Some(model) = single {
|
||||||
spawn_model_preview(
|
spawn_model_preview(
|
||||||
&model,
|
&model,
|
||||||
&bytes,
|
&bytes,
|
||||||
&mut commands,
|
&mut commands,
|
||||||
&mut meshes,
|
&mut meshes,
|
||||||
&mut materials,
|
&mut materials,
|
||||||
&mut images,
|
&mut images,
|
||||||
&mut model_preview,
|
&mut model_preview,
|
||||||
&mut orbit,
|
&mut orbit,
|
||||||
);
|
);
|
||||||
return;
|
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.
|
// 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")))]
|
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||||
mod font_sample_tests {
|
mod font_sample_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -178,6 +178,77 @@ mission* (where `DeltaSaber` renders) would hand over its exact declaration dire
|
|||||||
resource by strict index+triangle validation. **5662 sub-models decode across 22 stages** (S07
|
resource by strict index+triangle validation. **5662 sub-models decode across 22 stages** (S07
|
||||||
366/378), incl. the previously-declined main bodies `e005` (2566 v) and weapons — ≤2 s on 70 MB.
|
366/378), incl. the previously-declined main bodies `e005` (2566 v) and weapons — ≤2 s on 70 MB.
|
||||||
Parser `Xbg7Model::stage_models`, tests `stage_models_{decode,sweep,quality_audit}`.
|
Parser `Xbg7Model::stage_models`, tests `stage_models_{decode,sweep,quality_audit}`.
|
||||||
|
- 2026-07-17 — **triangle-LIST re-confirmed; a strip interlude refuted; winding-consistency gate
|
||||||
|
added.** A 2026-07 change had briefly re-read the index buffers as triangle *strips* (to "fill
|
||||||
|
holes"). Refuted objectively with a new `XVERIFY` diagnostic that compares both readings by
|
||||||
|
**stored-normal agreement** (each triangle's cross-product face normal vs the sum of its vertices'
|
||||||
|
stored normals): the LIST reading gives agreement **1.000** on every clean weapon (`wep_00/03/04/19`
|
||||||
|
= only possible with correct topology + winding), the STRIP reading **~0.49** (random). The strip
|
||||||
|
reading also over-generated ~2.5× the triangles (wep_00: 938 vs 364) — a hole-filling garbage soup.
|
||||||
|
Reverted to LIST in both paths (`from_xpr2`, `read_pool_mesh`), matching the `prim=4` GPU capture.
|
||||||
|
Added an objective **winding-consistency gate** `max(na, 1-na)`: a correct carve is internally
|
||||||
|
consistent (agreement ≈1.0, or ≈0.0 for inverted-but-consistent winding — a real single-sided
|
||||||
|
mesh), a mis-carve scatters to the ≈0.5 middle. `from_xpr2` declines sub-meshes below 0.90 (e.g.
|
||||||
|
`wep_23` na=0.398 → declined instead of a spike-mess); the single-model **content-anchor fallback**
|
||||||
|
gates at 0.85; the large multi-resource **stage** path stays ungated (its enemy meshes span a
|
||||||
|
continuous 0.5–1.0 consistency range — a hard gate there dropped ~48/314 legit S07 blocks).
|
||||||
|
**Routing fixed:** `decode_models` (CLI) and the viewer now route by `count_xbg7` (1 → validated
|
||||||
|
records-based list decode, fallback to strict-gated anchor; >1 → stage anchor) instead of the old
|
||||||
|
"whichever decoder yields more verts" rule — that rule let stage content-anchoring win on
|
||||||
|
single-model weapon files and fabricate **phantom** blocks (a `wep_00` clone appearing inside
|
||||||
|
`wep_19`), duplicates, and spike-mess anchors. Weapons now: 33 clean-decode / 26 declined (declined
|
||||||
|
= genuinely multi-stream or un-carvable, shown as nothing rather than garbage); stage coverage
|
||||||
|
unchanged (S07 314). `expand_triangle_strip` retained as an `XVERIFY`-only diagnostic.
|
||||||
- 2026-07-12 — `DeltaSaber_A.xpr` body: data does **not** begin with indices; plain-`f32×3` runs
|
- 2026-07-12 — `DeltaSaber_A.xpr` body: data does **not** begin with indices; plain-`f32×3` runs
|
||||||
with ship-scale extent (span ≈27–34, matching bbox 30.0) found only at high offsets
|
with ship-scale extent (span ≈27–34, matching bbox 30.0) found only at high offsets
|
||||||
(`data+0x28634C`, …) → multi-stream, **undecoded**.
|
(`data+0x28634C`, …) → multi-stream, **undecoded**.
|
||||||
|
- 2026-07-18 — **GROUPED-POOL layout cracked → the hero ship (Delta Saber) fully decodes.** The
|
||||||
|
detailed models (`DeltaSaber_*.xpr` + ~100 others) were declined for **location**, not format —
|
||||||
|
their vertex format is the standard stride-24 triangle list. A resource's *several* sub-meshes
|
||||||
|
don't interleave `[idx][vtx]` per block; they share **two grouped pools**: an **index pool**
|
||||||
|
(buffers concatenated in descriptor-marker order, each **4-byte aligned**) followed by a **vertex
|
||||||
|
pool** (each sub-pool `vtx_count × stride`, same order), with the index pool ending **exactly**
|
||||||
|
where the vertex pool begins. So the whole resource pivots on one unknown, the first vertex-pool
|
||||||
|
start `vb0` (= index-pool end, found by the unit-normal vertex-run scan); 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`. Reversed statically from `DeltaSaber_T.xpr` and
|
||||||
|
**cross-checked against a Canary GPU draw-log capture** (mission ship = `DeltaSaber_T.xpr`, found
|
||||||
|
via the `--log_file_io` kernel hook): `f001` = body (idx@`data+0xC` = 0x5500C, vtx@0x61ACC, 10891 v
|
||||||
|
/ 8187 t) + **7 detail parts** (fins/cockpit/wingtips, markers at descriptor 0x3BEC…0x58FC) =
|
||||||
|
**8650 tris**, and **every sub-mesh decodes at 0 degenerate / full coverage / winding-agreement
|
||||||
|
1.000**. This is the layout the per-block adjacency anchor (`ib = vb − idx_bytes`) rendered as a
|
||||||
|
**spiky phantom** (it read 24561 indices starting 2782 B too late, agree 0.64, 1277 degenerate).
|
||||||
|
Insight: a single index marker reduces the grouped model to `index_end = vb0`, i.e. the existing
|
||||||
|
adjacency `ib = vb − idx_bytes` — so grouped **generalises** the single-block anchor (n=1 is
|
||||||
|
identical). Implemented as `anchor_grouped_meshes` (mesh.rs): `anchor_models` routes resources with
|
||||||
|
>1 index marker to it (validated per sub-mesh; on failure falls back to the old first-marker
|
||||||
|
adjacency anchor so stage coverage never regresses); single-marker stages/props keep the exact
|
||||||
|
prior path. The shared acceptance test is factored into `validate_block` (the connectivity
|
||||||
|
heuristic is relaxed for *derived* grouped parts, which are pinned by in-range + consistency, so
|
||||||
|
small flat fins aren't mis-rejected). Render self-check: `sylpheed-cli mesh render DeltaSaber_T.xpr
|
||||||
|
--only f001` (exact-name match excludes the `_rou_f001_mnv*` animation poses) → clean complete
|
||||||
|
fighter. Test `hero_ship_grouped_pool_decodes`. Colours/UVs still pending the running-game oracle.
|
||||||
|
- 2026-07-18 (refinement) — **4-byte vertex-pool alignment + weapon recovery.** The grouped-pool
|
||||||
|
rule "index pool ends exactly where the vertex pool begins" is really "the vertex pool is **4-byte
|
||||||
|
aligned** after the index pool": `vb0 = align4(ib0 + span)`, so 0..=3 bytes of padding can sit
|
||||||
|
between them. DeltaSaber's index pool ended already-aligned (pad 0), which hid this; **19
|
||||||
|
weapon/`*_hangar` models** (single- and multi-marker: `wep_08/11/34/58/62/69/81/83…`) have pad 2
|
||||||
|
and so decoded to *nothing* — the viewer then showed them as a flat 2D texture instead of a model.
|
||||||
|
Fix: both anchors try `pad ∈ 0..=3` (`ib = vb − idx_bytes − pad` for the single-block adjacency
|
||||||
|
anchor; `ib0 = vb0 − span − pad` for the grouped pivot), validated — a wrong pad reads shifted
|
||||||
|
indices → agreement collapses < 0.85, so only the true pad passes. pad>0 in the ungated stage path
|
||||||
|
is gated at a strict 0.85 to avoid a false anchor; pad 0 keeps its exact prior behaviour (stages
|
||||||
|
unchanged). Result: all 19 now decode as clean models (e.g. `wep_34` 1243 v / 1233 t, a
|
||||||
|
long-barrelled gun-pod; `wep_08` 3 sub-meshes / 478 t). Viewer routing already falls through
|
||||||
|
`from_xpr2` → `anchor_models(0.85)` for single-XBG7 files, so the recovered grouped/padded weapons
|
||||||
|
now preview as meshes.
|
||||||
|
- 2026-07-18 (refinement 2) — **pivot on the largest sub-mesh; all 19 recovered.** Three weapons
|
||||||
|
(`wep_81`, `wep_81_hangar`, `wep_30_hangar`) still declined because the grouped pivot validated
|
||||||
|
`markers[0]`, which for these is a tiny *elongated* lead bracket that fails the connectivity gate
|
||||||
|
even when perfectly placed. Fixed by pivoting the alignment check on the **largest** marker (max
|
||||||
|
index count) — the sub-mesh whose triangle-quality/connectivity signature most reliably confirms
|
||||||
|
`(ib0, vb0)`. Once the pivot validates, markers up to it are read unconditionally (a legitimately
|
||||||
|
tiny/flat lead part may fail the quality gates yet still be real), and markers after it stay
|
||||||
|
validated so a stray trailing marker ends the chain. Result: **all 19 previously-declined weapons
|
||||||
|
decode** (`wep_81` 460 t missile w/ tail fins; `wep_30_hangar` 334 t). DeltaSaber unchanged (its
|
||||||
|
body IS the largest marker → same pivot). 7/7 disc tests green, stage quality audit unchanged.
|
||||||
|
|||||||
Reference in New Issue
Block a user