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>
314 lines
13 KiB
Rust
314 lines
13 KiB
Rust
//! Integration test: decode XBG7 geometry from REAL `.xpr` model files.
|
|
//!
|
|
//! Uses loose files extracted from the retail disc (models live in
|
|
//! `hidden/resource3d/*.xpr`). Skipped unless the directory is found; point
|
|
//! `SYLPHEED_RES3D` at it to override.
|
|
//!
|
|
//! Run: `cargo test -p sylpheed-formats --test mesh_disc -- --ignored --nocapture`
|
|
|
|
use std::path::PathBuf;
|
|
use sylpheed_formats::mesh::Xbg7Model;
|
|
|
|
fn res3d_dir() -> Option<PathBuf> {
|
|
if let Ok(p) = std::env::var("SYLPHEED_RES3D") {
|
|
let p = PathBuf::from(p);
|
|
if p.is_dir() {
|
|
return Some(p);
|
|
}
|
|
}
|
|
let default =
|
|
PathBuf::from("/home/fabi/RE Project Sylpheed/sylph_extract/hidden/resource3d");
|
|
default.is_dir().then_some(default)
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
|
|
fn weapon_model_decodes_to_expected_geometry() {
|
|
let Some(dir) = res3d_dir() else {
|
|
eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)");
|
|
return;
|
|
};
|
|
|
|
// rou_f001_wep_00 = the player ship's first weapon: 1 sub-mesh,
|
|
// 215 vertices, 364 triangles (verified by hex analysis).
|
|
let bytes = std::fs::read(dir.join("rou_f001_wep_00.xpr")).unwrap();
|
|
let model = Xbg7Model::from_xpr2(&bytes).expect("weapon must decode");
|
|
assert_eq!(model.meshes.len(), 1);
|
|
let (v, t) = model.totals();
|
|
assert_eq!(v, 215, "vertex count");
|
|
assert_eq!(t, 364, "triangle count");
|
|
|
|
let m = &model.meshes[0];
|
|
assert_eq!(m.positions.len(), 215);
|
|
assert_eq!(m.uvs.len(), 215);
|
|
assert_eq!(m.normals.len(), 215);
|
|
assert_eq!(m.indices.len(), 1092);
|
|
// every index in range
|
|
assert!(m.indices.iter().all(|&i| (i as usize) < m.positions.len()));
|
|
// positions are real geometry within the model's ~2-unit bbox
|
|
let ys: Vec<f32> = m.positions.iter().map(|p| p[1]).collect();
|
|
let span = ys.iter().cloned().fold(f32::MIN, f32::max)
|
|
- ys.iter().cloned().fold(f32::MAX, f32::min);
|
|
assert!(span > 1.0 && span < 10.0, "y-span {span} out of range");
|
|
|
|
// Correct vertex alignment ⇒ normals are unit-length (the pin for the
|
|
// +12 vertex offset) and UVs land in a sane texture range.
|
|
let mean_nlen: f32 = m
|
|
.normals
|
|
.iter()
|
|
.map(|n| (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt())
|
|
.sum::<f32>()
|
|
/ m.normals.len() as f32;
|
|
assert!((mean_nlen - 1.0).abs() < 0.05, "mean |normal| {mean_nlen} ≠ 1");
|
|
assert!(
|
|
m.uvs.iter().all(|uv| uv[0] > -0.1 && uv[0] < 2.0 && uv[1] > -0.1 && uv[1] < 2.0),
|
|
"UVs out of expected [0,1]-ish range"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
|
|
fn declaration_driven_decode_covers_expected_model_count() {
|
|
let Some(dir) = res3d_dir() else {
|
|
return;
|
|
};
|
|
let mut ok = 0usize;
|
|
let mut total = 0usize;
|
|
for entry in std::fs::read_dir(&dir).unwrap() {
|
|
let path = entry.unwrap().path();
|
|
if path.extension().and_then(|e| e.to_str()) != Some("xpr") {
|
|
continue;
|
|
}
|
|
total += 1;
|
|
let bytes = std::fs::read(&path).unwrap();
|
|
if let Ok(model) = Xbg7Model::from_xpr2(&bytes) {
|
|
if !model.meshes.is_empty() {
|
|
// Every decoded model must be self-consistent (indices in range,
|
|
// and unit normals where present).
|
|
for m in &model.meshes {
|
|
assert!(
|
|
m.indices.iter().all(|&i| (i as usize) < m.positions.len()),
|
|
"{path:?}: index out of range"
|
|
);
|
|
}
|
|
ok += 1;
|
|
}
|
|
}
|
|
}
|
|
eprintln!("XBG7 declaration-driven decode: {ok}/{total} models");
|
|
// Variable-stride declaration parsing lifted coverage vs the old fixed
|
|
// stride-24 decoder (25 → 36 fully-validated models; multi-sub-mesh models
|
|
// whose later sub-mesh offset isn't yet handled are still declined whole).
|
|
assert!(ok >= 35, "coverage regressed: only {ok}/{total} decoded");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
|
|
fn complex_body_mesh_is_declined_not_garbage() {
|
|
let Some(dir) = res3d_dir() else {
|
|
return;
|
|
};
|
|
// The hero-ship body uses the multi-stream layout we do not decode; it must
|
|
// be cleanly rejected, never returned as partial geometry.
|
|
let bytes = std::fs::read(dir.join("DeltaSaber_A.xpr")).unwrap();
|
|
match Xbg7Model::from_xpr2(&bytes) {
|
|
Err(_) => {} // expected: declined
|
|
Ok(m) => {
|
|
// If it ever does decode, it must at least be self-consistent.
|
|
for mesh in &m.meshes {
|
|
assert!(mesh.indices.iter().all(|&i| (i as usize) < mesh.positions.len()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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]
|
|
#[ignore]
|
|
fn stage_models_decode() {
|
|
use sylpheed_formats::mesh::Xbg7Model;
|
|
let dir = std::env::var("SYLPHEED_RES3D").unwrap_or_else(|_| {
|
|
"/home/fabi/RE Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
|
|
});
|
|
let path = format!("{dir}/Stage_S10.xpr");
|
|
let bytes = std::fs::read(&path).expect("read Stage_S10");
|
|
let models = Xbg7Model::stage_models(&bytes);
|
|
for m in &models {
|
|
let (v, t) = m.totals();
|
|
let mut lo = [f32::MAX; 3];
|
|
let mut hi = [f32::MIN; 3];
|
|
for sub in &m.meshes {
|
|
for p in &sub.positions {
|
|
for a in 0..3 {
|
|
lo[a] = lo[a].min(p[a]);
|
|
hi[a] = hi[a].max(p[a]);
|
|
}
|
|
}
|
|
}
|
|
let ext = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]];
|
|
println!(
|
|
" {:16} v={v} t={t} bbox=[{:.1},{:.1},{:.1}]",
|
|
m.name, ext[0], ext[1], ext[2]
|
|
);
|
|
}
|
|
// Known: e003 (main enemy, ~1400 tris) must be among the decoded models.
|
|
let e003 = models.iter().find(|m| m.name == "e003").expect("e003 decoded");
|
|
let (v, t) = e003.totals();
|
|
assert_eq!(v, 2383, "e003 vertex count");
|
|
assert!(t > 1400, "e003 triangle count {t}");
|
|
assert!(models.len() >= 3, "at least 3 stage sub-models, got {}", models.len());
|
|
}
|
|
|
|
/// Timing/coverage sweep across all stage containers (manual; release recommended).
|
|
#[test]
|
|
#[ignore]
|
|
fn stage_models_sweep() {
|
|
use sylpheed_formats::mesh::Xbg7Model;
|
|
use std::time::Instant;
|
|
let dir = std::env::var("SYLPHEED_RES3D").unwrap_or_else(|_| {
|
|
"/home/fabi/RE Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
|
|
});
|
|
let mut names: Vec<_> = std::fs::read_dir(&dir)
|
|
.unwrap()
|
|
.filter_map(|e| e.ok().map(|e| e.file_name().into_string().unwrap()))
|
|
.filter(|n| n.starts_with("Stage_") && n.ends_with(".xpr"))
|
|
.collect();
|
|
names.sort();
|
|
let mut tot = 0usize;
|
|
for n in &names {
|
|
let bytes = std::fs::read(format!("{dir}/{n}")).unwrap();
|
|
let t0 = Instant::now();
|
|
let models = Xbg7Model::stage_models(&bytes);
|
|
let dt = t0.elapsed().as_millis();
|
|
tot += models.len();
|
|
println!("{n:16} {:>4} MB {:>3} models {:>5} ms", bytes.len()/1_000_000, models.len(), dt);
|
|
}
|
|
println!("TOTAL stage sub-models decoded: {tot}");
|
|
}
|
|
|
|
/// Quality audit: for a big stage, verify decoded models are real geometry
|
|
/// (bounded bbox, low full-mesh degeneracy) rather than false anchors.
|
|
#[test]
|
|
#[ignore]
|
|
fn stage_models_quality_audit() {
|
|
use sylpheed_formats::mesh::Xbg7Model;
|
|
let dir = std::env::var("SYLPHEED_RES3D").unwrap_or_else(|_| {
|
|
"/home/fabi/RE Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
|
|
});
|
|
let bytes = std::fs::read(format!("{dir}/Stage_S07.xpr")).unwrap();
|
|
let models = Xbg7Model::stage_models(&bytes);
|
|
let (mut small, mut mid, mut huge, mut dupnames) = (0, 0, 0, 0);
|
|
let mut seen = std::collections::HashSet::new();
|
|
let mut worst_deg = 0.0f32;
|
|
for m in &models {
|
|
if !seen.insert(m.name.clone()) { dupnames += 1; }
|
|
let mut lo = [f32::MAX; 3]; let mut hi = [f32::MIN; 3];
|
|
let mut deg = 0usize; let mut tot = 0usize;
|
|
for sub in &m.meshes {
|
|
for p in &sub.positions { for a in 0..3 { lo[a]=lo[a].min(p[a]); hi[a]=hi[a].max(p[a]); } }
|
|
for tri in sub.indices.chunks_exact(3) {
|
|
let (a,b,c)=(tri[0] as usize,tri[1] as usize,tri[2] as usize);
|
|
if a>=sub.positions.len()||b>=sub.positions.len()||c>=sub.positions.len(){continue;}
|
|
let pa=sub.positions[a]; let pb=sub.positions[b]; let pc=sub.positions[c];
|
|
let u=[pb[0]-pa[0],pb[1]-pa[1],pb[2]-pa[2]];
|
|
let v=[pc[0]-pa[0],pc[1]-pa[1],pc[2]-pa[2]];
|
|
let cx=[u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]];
|
|
if 0.5*(cx[0]*cx[0]+cx[1]*cx[1]+cx[2]*cx[2]).sqrt()<1e-9 { deg+=1; }
|
|
tot+=1;
|
|
}
|
|
}
|
|
let ext = (hi[0]-lo[0]).max(hi[1]-lo[1]).max(hi[2]-lo[2]);
|
|
let df = if tot>0 { deg as f32/tot as f32 } else {1.0};
|
|
worst_deg = worst_deg.max(df);
|
|
if ext < 200.0 { small += 1; } else if ext < 5000.0 { mid += 1; } else { huge += 1; }
|
|
}
|
|
println!("S07: {} models | small(<200u)={} mid={} huge(>5k)={} | dup-names={} | worst full-mesh degeneracy={:.1}%",
|
|
models.len(), small, mid, huge, dupnames, worst_deg*100.0);
|
|
// Each XBG7 resource must anchor to a *distinct* block (no collisions), the
|
|
// bulk must be bounded-scale geometry, and none may be mostly-degenerate.
|
|
assert_eq!(dupnames, 0, "no two resources should anchor to the same block");
|
|
assert!(small + mid > models.len() * 9 / 10, "≥90% bounded-scale geometry");
|
|
assert!(huge < models.len() / 20, "few huge (skybox-plane) models");
|
|
assert!(worst_deg < 0.35, "no model should be mostly-degenerate");
|
|
}
|