Files
Syplheed-Reborn/crates/sylpheed-formats/tests/mesh_disc.rs
MechaCat02 95de29f290 feat(formats,viewer): movie subtitles, voice decode, and the movie manifest
The movie cutscene subtitle + voice pipeline, driven by the ADVERTISE_MOVIE
manifest (the authoritative movie -> subtitle -> voice index). Also flushes
several sessions of local WIP (async viewer loading, grouped-pool XBG7/hero-ship
decode, drawlog tooling). See docs/HANDOFF-movie-voice-subtitles-2026-07-19.md.

Subtitles (movie_subtitle.rs):
- Full movie->track->text chain; join multi-line captions sharing one timing
  (fixes S13A dropped "Look at it father" line); overlap-safe active_cues();
  Latin-1 accents preserved.

Voice (slb.rs): XACT .slb -> XMA1 RIFF; take the FIRST sub-wave bounded by its
declared data size (fixes S10-S16 alternate-take garble); list_voice_clips.

Manifest (movie_manifest.rs): parse ADVERTISE_MOVIE (0x5B983A08) for the real
movie->voice binding (not always VOICE_<movie>; e.g. hokyu -> VOICE_D_* in etc\).
Resolve the token's sound.pak path via sounds.tbl. DIRECT bindings only — the
demo-id shared-clip fallback for unbound hokyu movies was verified WRONG in-game
and reverted (unbound hokyu stay unvoiced; correct join key is an OPEN problem).

Viewer: manifest-driven voice (movie player toggle + solo button), standalone
"Voice Lines" browser, stacked caption overlay.

Tests: 46 formats-lib + 11 viewer-lib + movie_manifest/movie_subtitle/slb disc
tests; full workspace green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:15:41 +02:00

427 lines
19 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<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 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.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");
}