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:
@@ -54,6 +54,7 @@ pub mod audio;
|
||||
pub use font::FontInfo;
|
||||
pub use idxd::{IdxdError, IdxdObject};
|
||||
pub use ixud::{Cue, Subtitle};
|
||||
pub use mesh::{GameMesh, Xbg7Model};
|
||||
pub use ratc::RatcChild;
|
||||
pub use t8ad::T8adImage;
|
||||
pub use pak::{PakArchive, PakEntry, PakError};
|
||||
|
||||
@@ -1,71 +1,361 @@
|
||||
//! Mesh format parsing — TO BE REVERSE ENGINEERED.
|
||||
//! XBG7 mesh geometry decoder (geometry resources inside XPR2 containers).
|
||||
//!
|
||||
//! Project Sylpheed uses a completely custom engine with unknown mesh formats.
|
||||
//! This module is a scaffold: populate these structs as you discover the
|
||||
//! actual binary layout using a hex editor (010 Editor) and Ghidra.
|
||||
//! ## Clean-room note
|
||||
//!
|
||||
//! ## RE Strategy for Meshes
|
||||
//! This format was reverse-engineered **purely by static observation of the
|
||||
//! retail disc's `hidden/resource3d/*.xpr` files** (hex inspection + geometric
|
||||
//! validation of the recovered triangles). No game code was decompiled or
|
||||
//! copied. See `docs/re/structures/xbg7-mesh.md` for the evidence log.
|
||||
//!
|
||||
//! 1. Extract the game files using xdvdfs
|
||||
//! 2. Look for files with extensions like .mdl, .mesh, .geo, .obj, .pak
|
||||
//! 3. Open them in a hex editor — look for:
|
||||
//! - Repeating patterns of 12 bytes (XYZ float vertices)
|
||||
//! - Groups of 3 uint16s (triangle indices)
|
||||
//! - Header magic bytes
|
||||
//! 4. Cross-reference with Ghidra's XEX analysis to find the load functions
|
||||
//! ## Where XBG7 lives
|
||||
//!
|
||||
//! ## Useful tools
|
||||
//! - 010 Editor with binary templates
|
||||
//! - Noesis (can preview many console formats)
|
||||
//! - binrw (this project) for writing the parser once the format is known
|
||||
//! Ship / weapon / prop models are `XPR2` containers (see [`crate::texture`]).
|
||||
//! Their resource directory holds `TX2D` texture resources **and** one or more
|
||||
//! `XBG7` geometry resources. The `XBG7` *descriptor* (at the resource's
|
||||
//! `data_offset`) is a scene/material graph; the actual vertex and index
|
||||
//! buffers live in the container's shared data section (from `header_size`).
|
||||
//!
|
||||
//! ## The "simple" layout decoded here (CONFIRMED)
|
||||
//!
|
||||
//! For single-stream models (weapons, simple props — ~40 of the 166 disc
|
||||
//! models) the data section is a straight sequence of sub-meshes, each:
|
||||
//!
|
||||
//! ```text
|
||||
//! [ index buffer : idx_count × u16 big-endian ] triangle list
|
||||
//! [ 12-byte vertex-buffer header (contents undecoded) ]
|
||||
//! [ vertex buffer : vtx_count × 24 bytes ] (declaration below)
|
||||
//! offset 0x00 POSITION : f32 × 3 big-endian (model units)
|
||||
//! offset 0x0C NORMAL : f16 × 4 big-endian (x, y, z, w; unit)
|
||||
//! offset 0x14 TEXCOORD : f16 × 2 big-endian (u, v)
|
||||
//! (pad to 16 bytes → next sub-mesh)
|
||||
//! ```
|
||||
//!
|
||||
//! The per-vertex element offsets / usages come from a **vertex declaration**
|
||||
//! in the descriptor (usage `0x00` POSITION, `0x03` NORMAL, `0x05` TEXCOORD),
|
||||
//! identical across the decoded models. Correct alignment is pinned by the
|
||||
//! recovered normals being exactly unit-length.
|
||||
//!
|
||||
//! `vtx_count` / `idx_count` come from per-sub-mesh records in the descriptor:
|
||||
//! a `[vtx_count:u32][0:u32][idx_count:u32][tail:u32]` tuple (big-endian), read
|
||||
//! in file order. Every index is validated to be `< vtx_count`; if any
|
||||
//! sub-mesh fails to carve cleanly the whole model is rejected
|
||||
//! ([`MeshError::UnsupportedLayout`]) rather than emitting garbage.
|
||||
//!
|
||||
//! ## Not yet decoded
|
||||
//!
|
||||
//! The hero-ship *body* meshes (`DeltaSaber_*.xpr` `f004`, and ~100 other
|
||||
//! models) use a more complex **multi-stream** layout — separate position /
|
||||
//! attribute streams at descriptor-addressed offsets, quantized positions —
|
||||
//! which is not handled here and is cleanly declined.
|
||||
|
||||
use crate::texture::{Xpr2Header, Xpr2ResourceEntry};
|
||||
use binrw::BinRead;
|
||||
use std::io::Cursor;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MeshError {
|
||||
#[error("Unknown mesh magic: {0:?}")]
|
||||
UnknownMagic([u8; 4]),
|
||||
#[error("Unsupported mesh version: {0}")]
|
||||
UnsupportedVersion(u32),
|
||||
#[error("Not an XPR2 container")]
|
||||
NotXpr2,
|
||||
#[error("No XBG7 geometry resource in container")]
|
||||
NoGeometry,
|
||||
#[error("Mesh layout not supported (multi-stream / quantized body mesh)")]
|
||||
UnsupportedLayout,
|
||||
#[error("Parse error: {0}")]
|
||||
Parse(String),
|
||||
Parse(#[from] binrw::Error),
|
||||
}
|
||||
|
||||
/// A decoded 3D mesh ready for Bevy.
|
||||
/// Vertex positions, normals, UVs, and indices.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct GameMesh {
|
||||
/// Interleaved vertex positions [x, y, z, x, y, z, ...]
|
||||
/// Vertex positions in model space `[x, y, z]`.
|
||||
pub positions: Vec<[f32; 3]>,
|
||||
/// Vertex normals [nx, ny, nz, ...]
|
||||
/// Vertex normals `[nx, ny, nz]` — empty when not stored (compute smooth
|
||||
/// normals from geometry instead).
|
||||
pub normals: Vec<[f32; 3]>,
|
||||
/// UV texture coordinates [u, v, ...]
|
||||
/// Texture coordinates `[u, v]`. **Best-guess channel** (attr halves 0 & 2)
|
||||
/// pending in-game visual confirmation — see the module doc.
|
||||
pub uvs: Vec<[f32; 2]>,
|
||||
/// Triangle list indices
|
||||
/// Triangle-list indices (3 per triangle).
|
||||
pub indices: Vec<u32>,
|
||||
/// Name of this mesh (if available in the file)
|
||||
/// Sub-mesh / node name from the descriptor, when available.
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl GameMesh {
|
||||
/// Parse a mesh from raw bytes.
|
||||
/// A model = the set of sub-meshes recovered from one XPR2 container's first
|
||||
/// XBG7 resource, plus the resource's name.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Xbg7Model {
|
||||
pub name: String,
|
||||
pub meshes: Vec<GameMesh>,
|
||||
}
|
||||
|
||||
impl Xbg7Model {
|
||||
/// Total vertex / triangle counts across all sub-meshes.
|
||||
pub fn totals(&self) -> (usize, usize) {
|
||||
let v = self.meshes.iter().map(|m| m.positions.len()).sum();
|
||||
let t = self.meshes.iter().map(|m| m.indices.len() / 3).sum();
|
||||
(v, t)
|
||||
}
|
||||
|
||||
/// Decode the geometry of the first XBG7 resource in an XPR2 container.
|
||||
///
|
||||
/// TODO: implement once the actual file format is identified.
|
||||
/// Currently returns an error until the format is reverse engineered.
|
||||
pub fn from_bytes(_bytes: &[u8]) -> Result<Vec<Self>, MeshError> {
|
||||
// ┌──────────────────────────────────────────────────────────────┐
|
||||
// │ REVERSE ENGINEERING TODO │
|
||||
// │ │
|
||||
// │ Steps to implement this: │
|
||||
// │ 1. Find mesh files in the extraction (look for .mdl etc.) │
|
||||
// │ 2. Identify the file format using identify_format() in vfs │
|
||||
// │ 3. Use 010 Editor to map the binary structure │
|
||||
// │ 4. Add binrw #[derive(BinRead)] structs above │
|
||||
// │ 5. Implement this function to parse and return meshes │
|
||||
// └──────────────────────────────────────────────────────────────┘
|
||||
Err(MeshError::Parse(
|
||||
"Mesh format not yet reverse engineered. \
|
||||
See RE TODO in src/mesh.rs".to_string()
|
||||
))
|
||||
/// Returns [`MeshError::UnsupportedLayout`] for models whose data section
|
||||
/// does not carve cleanly under the simple single-stream layout (the
|
||||
/// complex body meshes) — never partial / garbage geometry.
|
||||
pub fn from_xpr2(bytes: &[u8]) -> Result<Self, MeshError> {
|
||||
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||||
return Err(MeshError::NotXpr2);
|
||||
}
|
||||
let mut cur = Cursor::new(bytes);
|
||||
let header = Xpr2Header::read(&mut cur)?;
|
||||
let mut xbg: Option<Xpr2ResourceEntry> = None;
|
||||
for _ in 0..header.num_resources {
|
||||
let e = Xpr2ResourceEntry::read(&mut cur)?;
|
||||
if &e.type_tag == b"XBG7" {
|
||||
xbg = Some(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let xbg = xbg.ok_or(MeshError::NoGeometry)?;
|
||||
|
||||
const DIR_BASE: usize = 0x10;
|
||||
let desc = xbg.data_offset as usize + DIR_BASE;
|
||||
let desc_end = (desc + xbg.descriptor_size as usize).min(bytes.len());
|
||||
if desc >= bytes.len() {
|
||||
return Err(MeshError::UnsupportedLayout);
|
||||
}
|
||||
|
||||
// Resource name (for labelling).
|
||||
let name = read_cstr(bytes, xbg.name_offset as usize + DIR_BASE)
|
||||
.unwrap_or_else(|| "XBG7".to_string());
|
||||
|
||||
// Extract the ordered list of sub-mesh (vtx_count, idx_count) records.
|
||||
let records = submesh_records(&bytes[desc..desc_end]);
|
||||
if records.is_empty() {
|
||||
return Err(MeshError::UnsupportedLayout);
|
||||
}
|
||||
|
||||
// Carve the data section sequentially.
|
||||
let base = header.header_size as usize;
|
||||
let mut off = 0usize; // relative to `base`
|
||||
let mut meshes = Vec::new();
|
||||
for (vtx_count, idx_count) in records {
|
||||
let ib = base + off;
|
||||
let ie = ib + idx_count * 2;
|
||||
// The vertex buffer follows the index buffer after a fixed 12-byte
|
||||
// header (a normal length of exactly 1.0 pins this offset across
|
||||
// every decoded model — `align`-based guesses landed 4 bytes early).
|
||||
let vb = ie + VERTEX_BUFFER_GAP;
|
||||
let ve = vb + vtx_count * VERTEX_STRIDE;
|
||||
if ve > bytes.len() {
|
||||
return Err(MeshError::UnsupportedLayout);
|
||||
}
|
||||
|
||||
// Indices (u16 BE), validated against the sub-mesh vertex count.
|
||||
let mut indices = Vec::with_capacity(idx_count);
|
||||
for k in 0..idx_count {
|
||||
let i = be16(bytes, ib + k * 2) as u32;
|
||||
if i >= vtx_count as u32 {
|
||||
return Err(MeshError::UnsupportedLayout);
|
||||
}
|
||||
indices.push(i);
|
||||
}
|
||||
|
||||
// Vertex layout (stride 24), per the descriptor's vertex declaration
|
||||
// (usage codes: 0x00 POSITION, 0x03 NORMAL, 0x05 TEXCOORD):
|
||||
// +0x00 POSITION f32 × 3 (big-endian)
|
||||
// +0x0C NORMAL f16 × 4 (x, y, z, w; use xyz — unit length)
|
||||
// +0x14 TEXCOORD f16 × 2 (u, v)
|
||||
let mut positions = Vec::with_capacity(vtx_count);
|
||||
let mut normals = Vec::with_capacity(vtx_count);
|
||||
let mut uvs = Vec::with_capacity(vtx_count);
|
||||
let mut normal_len_sum = 0.0f32;
|
||||
for v in 0..vtx_count {
|
||||
let o = vb + v * VERTEX_STRIDE;
|
||||
let x = bef(bytes, o);
|
||||
let y = bef(bytes, o + 4);
|
||||
let z = bef(bytes, o + 8);
|
||||
if !(x.is_finite() && y.is_finite() && z.is_finite()) {
|
||||
return Err(MeshError::UnsupportedLayout);
|
||||
}
|
||||
positions.push([x, y, z]);
|
||||
|
||||
let nx = half(bytes, o + 0x0C);
|
||||
let ny = half(bytes, o + 0x0E);
|
||||
let nz = half(bytes, o + 0x10);
|
||||
normal_len_sum += (nx * nx + ny * ny + nz * nz).sqrt();
|
||||
normals.push([nx, ny, nz]);
|
||||
|
||||
let u = half(bytes, o + 0x14);
|
||||
let vv = half(bytes, o + 0x16);
|
||||
uvs.push([u, vv]);
|
||||
}
|
||||
|
||||
// Sanity gate: a correctly-aligned vertex buffer in this layout has
|
||||
// unit-length normals. If the mean is far off, the model does not
|
||||
// fit the simple layout (wrong padding / different format) — decline
|
||||
// rather than emit garbage. (Catches e.g. `Stage_S*` placeholders.)
|
||||
let mean_normal_len = normal_len_sum / vtx_count.max(1) as f32;
|
||||
if !(0.5..=2.0).contains(&mean_normal_len) {
|
||||
return Err(MeshError::UnsupportedLayout);
|
||||
}
|
||||
|
||||
meshes.push(GameMesh {
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
indices,
|
||||
name: None,
|
||||
});
|
||||
off = align16(ve) - base;
|
||||
}
|
||||
|
||||
Ok(Xbg7Model { name, meshes })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Layout constants ────────────────────────────────────────────────────────
|
||||
|
||||
/// Bytes per vertex: `pos f32×3 (12) + normal f16×4 (8) + uv f16×2 (4)`.
|
||||
const VERTEX_STRIDE: usize = 24;
|
||||
|
||||
/// Fixed byte gap between the end of a sub-mesh's index buffer and the start of
|
||||
/// its vertex buffer (a small vertex-buffer header — contents not yet decoded).
|
||||
const VERTEX_BUFFER_GAP: usize = 12;
|
||||
|
||||
// ── Descriptor sub-mesh record scan ─────────────────────────────────────────
|
||||
|
||||
/// Scan an XBG7 descriptor for the ordered list of per-sub-mesh
|
||||
/// `(vtx_count, idx_count)` records.
|
||||
///
|
||||
/// The record is a big-endian tuple `[vtx:u32][0:u32][idx:u32][tail:u32]` with
|
||||
/// `3 ≤ vtx ≤ 65535`, the second word zero, `idx` a positive multiple of 3, and
|
||||
/// a small non-zero `tail`. Found by a sliding 4-byte scan (records are not on
|
||||
/// a fixed stride in the scene graph).
|
||||
fn submesh_records(desc: &[u8]) -> Vec<(usize, usize)> {
|
||||
let mut out = Vec::new();
|
||||
if desc.len() < 16 {
|
||||
return out;
|
||||
}
|
||||
let mut rel = 0usize;
|
||||
while rel + 16 <= desc.len() {
|
||||
let a = be32(desc, rel);
|
||||
let z = be32(desc, rel + 4);
|
||||
let c = be32(desc, rel + 8);
|
||||
let t = be32(desc, rel + 12);
|
||||
if (3..=65535).contains(&a)
|
||||
&& z == 0
|
||||
&& c >= 3
|
||||
&& c <= 200_000
|
||||
&& c % 3 == 0
|
||||
&& (1..=64).contains(&t)
|
||||
{
|
||||
out.push((a as usize, c as usize));
|
||||
rel += 16; // consume the record
|
||||
} else {
|
||||
rel += 4;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ── Little primitive readers ────────────────────────────────────────────────
|
||||
|
||||
#[inline]
|
||||
fn align16(x: usize) -> usize {
|
||||
(x + 15) & !15
|
||||
}
|
||||
#[inline]
|
||||
fn be16(b: &[u8], o: usize) -> u16 {
|
||||
u16::from_be_bytes([b[o], b[o + 1]])
|
||||
}
|
||||
#[inline]
|
||||
fn be32(b: &[u8], o: usize) -> u32 {
|
||||
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
|
||||
}
|
||||
#[inline]
|
||||
fn bef(b: &[u8], o: usize) -> f32 {
|
||||
f32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
|
||||
}
|
||||
/// Big-endian IEEE-754 half → f32.
|
||||
#[inline]
|
||||
fn half(b: &[u8], o: usize) -> f32 {
|
||||
f16_to_f32(be16(b, o))
|
||||
}
|
||||
|
||||
/// Minimal IEEE-754 binary16 → binary32 (no external dep).
|
||||
fn f16_to_f32(h: u16) -> f32 {
|
||||
let sign = (h >> 15) & 1;
|
||||
let exp = (h >> 10) & 0x1F;
|
||||
let mant = h & 0x3FF;
|
||||
let bits: u32 = match exp {
|
||||
0 if mant == 0 => (sign as u32) << 31, // ±0
|
||||
0 => {
|
||||
// subnormal → normalize
|
||||
let mut e: i32 = -1;
|
||||
let mut m = mant as u32;
|
||||
loop {
|
||||
e += 1;
|
||||
m <<= 1;
|
||||
if m & 0x400 != 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let exp32 = (127 - 15 - e) as u32;
|
||||
((sign as u32) << 31) | (exp32 << 23) | ((m & 0x3FF) << 13)
|
||||
}
|
||||
0x1F => ((sign as u32) << 31) | (0xFF << 23) | ((mant as u32) << 13), // Inf/NaN
|
||||
_ => {
|
||||
let exp32 = (exp as i32 - 15 + 127) as u32;
|
||||
((sign as u32) << 31) | (exp32 << 23) | ((mant as u32) << 13)
|
||||
}
|
||||
};
|
||||
f32::from_bits(bits)
|
||||
}
|
||||
|
||||
fn read_cstr(b: &[u8], o: usize) -> Option<String> {
|
||||
if o >= b.len() {
|
||||
return None;
|
||||
}
|
||||
let end = b[o..].iter().position(|&c| c == 0).map(|p| o + p)?;
|
||||
if end == o {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&b[o..end]).into_owned())
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn half_roundtrip_known_values() {
|
||||
assert_eq!(f16_to_f32(0x3C00), 1.0); // 1.0
|
||||
assert_eq!(f16_to_f32(0x0000), 0.0); // +0
|
||||
assert_eq!(f16_to_f32(0xBC00), -1.0); // -1.0
|
||||
assert_eq!(f16_to_f32(0x4000), 2.0); // 2.0
|
||||
assert!((f16_to_f32(0x3800) - 0.5).abs() < 1e-6); // 0.5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submesh_record_scan_finds_tuple() {
|
||||
// [vtx=215][0][idx=1092][tail=4]
|
||||
let mut d = vec![0u8; 32];
|
||||
d[0..4].copy_from_slice(&215u32.to_be_bytes());
|
||||
d[8..12].copy_from_slice(&1092u32.to_be_bytes());
|
||||
d[12..16].copy_from_slice(&4u32.to_be_bytes());
|
||||
let recs = submesh_records(&d);
|
||||
assert_eq!(recs, vec![(215, 1092)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_xpr2() {
|
||||
assert!(matches!(
|
||||
Xbg7Model::from_xpr2(b"NOPEnotacontainerXXXXXXXX"),
|
||||
Err(MeshError::NotXpr2)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +221,45 @@ impl X360Texture {
|
||||
/// 3. Read its GPUTEXTURE_FETCH_CONSTANT (GPUFC) at descriptor +0x18
|
||||
/// 4. De-tile the pixel data (if tiled) → linear layout
|
||||
pub fn from_xpr2(bytes: &[u8]) -> Result<Self, TextureError> {
|
||||
// XPR files are frequently PACKS of many textures; XPR_RES_INDEX picks
|
||||
// the Nth texture resource (default 0) for RE/browse validation.
|
||||
let want = std::env::var("XPR_RES_INDEX")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
Self::from_xpr2_index(bytes, want)
|
||||
}
|
||||
|
||||
/// List the names of the texture resources (`TX2D` / `TXCM`) in an XPR2
|
||||
/// container, in directory order — the same order [`from_xpr2_index`]
|
||||
/// selects by. Non-texture resources (e.g. `XBG7`) are skipped.
|
||||
pub fn texture_names(bytes: &[u8]) -> Vec<String> {
|
||||
use std::io::Cursor;
|
||||
let mut cur = Cursor::new(bytes);
|
||||
let Ok(header) = Xpr2Header::read(&mut cur) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut names = Vec::new();
|
||||
for _ in 0..header.num_resources {
|
||||
let Ok(e) = Xpr2ResourceEntry::read(&mut cur) else {
|
||||
break;
|
||||
};
|
||||
if e.is_texture() || e.is_cubemap() {
|
||||
const DIR_BASE: usize = 0x10;
|
||||
let no = e.name_offset as usize + DIR_BASE;
|
||||
let name = bytes
|
||||
.get(no..)
|
||||
.and_then(|s| s.iter().position(|&c| c == 0).map(|p| &s[..p]))
|
||||
.map(|s| String::from_utf8_lossy(s).into_owned())
|
||||
.unwrap_or_default();
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Decode the `want`-th texture resource (`TX2D` / `TXCM`, directory order).
|
||||
pub fn from_xpr2_index(bytes: &[u8], want: usize) -> Result<Self, TextureError> {
|
||||
use std::io::Cursor;
|
||||
let mut cur = Cursor::new(bytes);
|
||||
|
||||
@@ -236,12 +275,6 @@ impl X360Texture {
|
||||
// Select a texture resource. TX2D = 2D texture; TXCM = cubemap
|
||||
// (skybox / backdrop) — same 52-byte descriptor + GPUFC layout, but the
|
||||
// pixel section holds 6 faces. For a preview we decode face 0.
|
||||
// XPR files are frequently PACKS of many textures; XPR_RES_INDEX picks
|
||||
// the Nth texture resource (default 0) for RE/browse validation.
|
||||
let want = std::env::var("XPR_RES_INDEX")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
let tex_entry = entries.iter()
|
||||
.filter(|e| e.is_texture() || e.is_cubemap())
|
||||
.nth(want)
|
||||
|
||||
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