//! 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}; fn res3d_dir() -> Option { 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 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 = 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::() / 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::() / 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"); }