[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:
@@ -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