`mesh_consistency_disc.rs` had the only conflict: this branch added a `Sightings` type alias where #22 replaced the file's private `disc_root()` with the shared `common::disc_root`. Both kept — they are unrelated edits that happened to land in the same lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
588 lines
22 KiB
Rust
588 lines
22 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::{material_groups, node_transforms, submesh_albedos, Xbg7Model};
|
||
|
||
mod common;
|
||
use common::res3d_dir;
|
||
|
||
#[test]
|
||
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
|
||
fn hero_ship_submesh_material_graph() {
|
||
let Some(dir) = res3d_dir() else {
|
||
eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)");
|
||
return;
|
||
};
|
||
// The XBG7 node/material graph tags each sub-mesh record with its own albedo
|
||
// (`rou_…_col` → TX2D name). DeltaSaber_T `f001`: the 7 detail parts use
|
||
// bdy_04/06/07 — NOT the body's bdy_01a — which is the per-part texturing the
|
||
// viewer needs so fins aren't painted with the hull map.
|
||
let bytes = std::fs::read(dir.join("DeltaSaber_T.xpr")).unwrap();
|
||
let mut map = std::collections::HashMap::new();
|
||
for (v, i, name) in submesh_albedos(&bytes, "f001") {
|
||
map.insert((v, i), name);
|
||
}
|
||
// (vtx, idx) → expected albedo (rou_ stripped = TX2D name).
|
||
assert_eq!(
|
||
map.get(&(314, 645)).map(String::as_str),
|
||
Some("f001_bdy_04_col")
|
||
);
|
||
assert_eq!(
|
||
map.get(&(48, 84)).map(String::as_str),
|
||
Some("f001_bdy_04_col")
|
||
);
|
||
assert_eq!(
|
||
map.get(&(96, 180)).map(String::as_str),
|
||
Some("f001_bdy_06_col")
|
||
);
|
||
assert_eq!(
|
||
map.get(&(60, 108)).map(String::as_str),
|
||
Some("f001_bdy_07_col")
|
||
);
|
||
// Names strip cleanly to real TX2D resources.
|
||
let tex = sylpheed_formats::texture::X360Texture::texture_names(&bytes);
|
||
for name in map.values() {
|
||
assert!(
|
||
tex.iter().any(|t| t == name),
|
||
"albedo {name} not a TX2D resource"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
|
||
fn hero_ship_body_splits_by_material() {
|
||
let Some(dir) = res3d_dir() else {
|
||
eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)");
|
||
return;
|
||
};
|
||
// The merged body (sub0 = 24561 indices / 8187 tris) is drawn as 9 groups
|
||
// sharing one index buffer, each with its own albedo (bdy_01a hull, bdy_01b,
|
||
// bdy_02/03, and the `daiza` hangar stand). `material_groups` recovers the
|
||
// cumulative-offset chain so the viewer can texture each slice.
|
||
let bytes = std::fs::read(dir.join("DeltaSaber_T.xpr")).unwrap();
|
||
let groups = material_groups(&bytes, "f001", 24561);
|
||
assert_eq!(groups.len(), 9, "body must split into 9 material groups");
|
||
// Offsets chain from 0 and cover the whole index buffer exactly.
|
||
let mut cursor = 0usize;
|
||
for g in &groups {
|
||
assert_eq!(g.idx_offset, cursor, "group offsets must be contiguous");
|
||
cursor += g.idx_count;
|
||
}
|
||
assert_eq!(cursor, 24561, "groups must cover all body indices");
|
||
// Expected per-group albedo (verified against the GPU draw log).
|
||
let albedos: Vec<&str> = groups.iter().map(|g| g.albedo.as_str()).collect();
|
||
assert_eq!(
|
||
albedos,
|
||
[
|
||
"f001_bdy_01a_col",
|
||
"f001_bdy_01a_col",
|
||
"f001_bdy_01a_col",
|
||
"f001_bdy_01b_col",
|
||
"f001_bdy_01a_col",
|
||
"f001_bdy_01b_col",
|
||
"f001_bdy_02_col",
|
||
"f001_bdy_03_col",
|
||
"f001_bdy_daiza_col",
|
||
]
|
||
);
|
||
// A single-material detail part (sub1 = 645 indices) → one group, bdy_04.
|
||
let fin = material_groups(&bytes, "f001", 645);
|
||
assert_eq!(fin.len(), 1);
|
||
assert_eq!(fin[0].albedo, "f001_bdy_04_col");
|
||
assert_eq!(fin[0].idx_offset, 0);
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
|
||
fn hero_ship_node_transforms_move_fins_to_tail() {
|
||
let Some(dir) = res3d_dir() else {
|
||
eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)");
|
||
return;
|
||
};
|
||
// For the DeltaSaber, node_transforms returns the MEASURED runtime placements
|
||
// (Canary draw-log WVP; see mesh.rs::DELTASABER_MEASURED) — the fin assemblies
|
||
// are mounted OUT on the nacelles (world X ≈ −4.4..−6.6), an offset the static
|
||
// graph omits. Body identity; each part a left/right mirrored pair. Vertex
|
||
// space: X right, Y up, Z fore/aft (engines at −Z).
|
||
let bytes = std::fs::read(dir.join("DeltaSaber_T.xpr")).unwrap();
|
||
let places = node_transforms(&bytes, "f001");
|
||
assert!(!places.is_empty(), "expected node placements");
|
||
// Body (sub 0) identity, drawn once.
|
||
let body: Vec<_> = places.iter().filter(|p| p.sub_index == 0).collect();
|
||
assert_eq!(body.len(), 1, "body drawn once");
|
||
assert!(
|
||
body[0].t.iter().all(|c| c.abs() < 0.1),
|
||
"body must not translate"
|
||
);
|
||
assert!(!body[0].reflect, "body is not mirrored");
|
||
// Fin bdy_04 (sub 1): mirrored pair near the tail, mounted OUT on the nacelle
|
||
// (|X| ≈ 5.2, not the graph's inboard ≈1.1).
|
||
let fins: Vec<_> = places.iter().filter(|p| p.sub_index == 1).collect();
|
||
assert_eq!(fins.len(), 2, "V-tail is a mirrored pair");
|
||
assert!(
|
||
fins.iter().all(|f| (f.t[2] + 9.72).abs() < 0.3),
|
||
"fin fore/aft ≈ −9.7"
|
||
);
|
||
assert!(
|
||
fins.iter().all(|f| f.t[0].abs() > 4.0),
|
||
"fin mounted out on the nacelle"
|
||
);
|
||
assert!(
|
||
fins[0].t[0] * fins[1].t[0] < 0.0,
|
||
"the pair mirrors across X"
|
||
);
|
||
assert!(
|
||
fins.iter().any(|f| f.reflect),
|
||
"one of the pair is reflected"
|
||
);
|
||
// Winglet bdy_06 (sub 4): on the nacelle (|X| ≈ 6.6, Z ≈ −15.5).
|
||
let wings: Vec<_> = places.iter().filter(|p| p.sub_index == 4).collect();
|
||
assert_eq!(wings.len(), 2, "L/R winglet pair");
|
||
assert!(
|
||
wings
|
||
.iter()
|
||
.all(|p| p.t[0].abs() > 6.0 && (p.t[2] + 15.5).abs() < 0.3),
|
||
"winglets out on the nacelle"
|
||
);
|
||
assert!(
|
||
wings[0].t[0] * wings[1].t[0] < 0.0,
|
||
"winglets mirror across X"
|
||
);
|
||
// Small fin bdy_10 (sub 6): inboard nacelle stub (|X| ≈ 4.45, Z ≈ −17).
|
||
let sfins: Vec<_> = places.iter().filter(|p| p.sub_index == 6).collect();
|
||
assert_eq!(sfins.len(), 2, "L/R small-fin pair");
|
||
assert!(
|
||
sfins.iter().all(|p| (p.t[0].abs() - 4.45).abs() < 0.3),
|
||
"small fins on the stub"
|
||
);
|
||
assert!(
|
||
sfins[0].t[0] * sfins[1].t[0] < 0.0,
|
||
"small fins mirror across X"
|
||
);
|
||
}
|
||
|
||
#[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.as_chunks::<3>().0 {
|
||
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 Some(dir) = res3d_dir() else {
|
||
eprintln!("SKIP: set SYLPHEED_RES3D to the extracted resource3d directory");
|
||
return;
|
||
};
|
||
let path = format!("{}/Stage_S10.xpr", dir.display());
|
||
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 std::time::Instant;
|
||
use sylpheed_formats::mesh::Xbg7Model;
|
||
let Some(dir) = res3d_dir() else {
|
||
eprintln!("SKIP: set SYLPHEED_RES3D to the extracted resource3d directory");
|
||
return;
|
||
};
|
||
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!("{}/{n}", dir.display())).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 Some(dir) = res3d_dir() else {
|
||
eprintln!("SKIP: set SYLPHEED_RES3D to the extracted resource3d directory");
|
||
return;
|
||
};
|
||
let bytes = std::fs::read(format!("{}/Stage_S07.xpr", dir.display())).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.as_chunks::<3>().0 {
|
||
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");
|
||
}
|
||
|
||
/// A correctly located index run has **no degenerate triangles**. That is the
|
||
/// signature the 2026-08-13 pad-scoring fix keys on: an index list read one
|
||
/// element late still passes every count-based gate but wires arbitrary
|
||
/// vertices, which produces triangles with a repeated index. Before the fix
|
||
/// **579** decoded runs on the disc carried such triangles (and a runtime
|
||
/// capture confirmed 17 of 93 index batches disagreed with the GPU); after it and
|
||
/// the two follow-ups (pad scoring in the grouped path, and preferring a
|
||
/// degenerate-free candidate over an earlier dirty one), exactly **one** does.
|
||
/// Locking the
|
||
/// number in, because the defect is invisible to coverage and to the anchor
|
||
/// oracle: every count stays correct while the geometry is mis-wired.
|
||
#[test]
|
||
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
|
||
fn decoded_index_runs_have_almost_no_degenerate_triangles() {
|
||
let Some(dir) = res3d_dir() else {
|
||
eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)");
|
||
return;
|
||
};
|
||
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
|
||
.expect("resource3d/")
|
||
.flatten()
|
||
.map(|e| e.path())
|
||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
|
||
.collect();
|
||
files.sort();
|
||
|
||
let mut offenders: Vec<(String, String, usize)> = Vec::new();
|
||
for f in &files {
|
||
let Ok(bytes) = std::fs::read(f) else {
|
||
continue;
|
||
};
|
||
let where_ = f.file_name().unwrap().to_string_lossy().to_string();
|
||
for m in Xbg7Model::stage_models(&bytes) {
|
||
for sm in &m.meshes {
|
||
let d = sm
|
||
.indices
|
||
.as_chunks::<3>()
|
||
.0
|
||
.iter()
|
||
.filter(|t| t[0] == t[1] || t[1] == t[2] || t[0] == t[2])
|
||
.count();
|
||
if d > 0 {
|
||
offenders.push((m.name.clone(), where_.clone(), d));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// The one at default settings: `_rou_f402_dead` in `Stage_S09`, whose
|
||
// degenerate-free block is claimed by `e_rou_f003_Near` — both 24-vertex
|
||
// bounding boxes, the identity class that needs descriptor-level data. (With
|
||
// `XBG7_SUBMESH_DECLS=1` four newly decoded `ptc_pack` `.dat` composites join
|
||
// it; that knob is off by default.) Anything else here is a regression.
|
||
assert!(
|
||
offenders.len() <= 1,
|
||
"{} decoded index runs contain degenerate triangles (expected ≤ 1): {:?}",
|
||
offenders.len(),
|
||
&offenders[..offenders.len().min(20)]
|
||
);
|
||
}
|