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:
MechaCat02
2026-07-12 17:47:06 +02:00
parent e6da726f7b
commit ef6e448268
8 changed files with 794 additions and 54 deletions

View File

@@ -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};

View File

@@ -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)
));
}
}

View File

@@ -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)