feat(formats,viewer): decode XBG7 meshes + 3D model preview
Reverse-engineer the single-stream XBG7 geometry layout (clean-room: hex
inspection + geometric validation of the retail disc's
hidden/resource3d/*.xpr, no game code copied) and present models as
textured 3D meshes in the explorer.
Format (docs/re/structures/xbg7-mesh.md): XBG7 geometry resources sit
inside XPR2 containers alongside TX2D textures. For ~25 single-stream
models (weapons, simple props) the data section is a sequence of
sub-meshes, each an u16-BE triangle-list index buffer followed (after a
fixed 12-byte header) by a stride-24 vertex buffer whose declaration is
in the descriptor: POSITION f32x3 @0x00, NORMAL f16x4 @0x0C, TEXCOORD
f16x2 @0x14. Sub-mesh (vtx,idx) counts come from descriptor tuples.
The +12 vertex offset is pinned by the recovered normals being exactly
unit-length (align16 lands 4 bytes early and silently corrupts every
field). A safety gate rejects any model whose indices are out of range
or whose mean |normal| is not ~1, declining garbage (Stage_S*
placeholders, the complex multi-stream hero-ship body) rather than
mis-decoding it.
- mesh.rs: Xbg7Model::from_xpr2 -> GameMesh { positions, normals, uvs,
indices }; standalone f16->f32; unit + real-disc tests (weapon decodes
to 215v/364t with unit normals + in-range UVs; DeltaSaber body
declined).
- texture.rs: from_xpr2_index / texture_names so the viewer can pick a
model's _col albedo map.
- viewer: loose .xpr with decodable XBG7 spawns Bevy meshes (real normals,
double-sided) textured with the albedo, framed by the orbit camera; the
central egui panel goes transparent so the 3D scene shows through.
Complex multi-stream body meshes (DeltaSaber f004, other vertex layouts)
remain undecoded and are cleanly declined — next target is dynamic RE.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
87
crates/sylpheed-formats/tests/mesh_disc.rs
Normal file
87
crates/sylpheed-formats/tests/mesh_disc.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
//! 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 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user