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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,6 +221,21 @@ pub struct SkyboxPreview {
|
||||
pub info: String,
|
||||
}
|
||||
|
||||
/// Marker for entities spawned to preview an XBG7 3D model, so they can all be
|
||||
/// despawned when a new file is selected.
|
||||
#[derive(Component)]
|
||||
pub struct PreviewModel;
|
||||
|
||||
/// The currently-previewed XBG7 3D model. When `active`, the central panel is
|
||||
/// drawn transparent so the real Bevy scene (mesh + orbit camera) shows through.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct ModelPreview {
|
||||
pub active: bool,
|
||||
pub name: String,
|
||||
/// Summary line: sub-meshes / vertices / triangles / texture.
|
||||
pub info: String,
|
||||
}
|
||||
|
||||
/// The currently-open `.wmv` cutscene, shown in the central panel with a
|
||||
/// transport bar. Registered unconditionally (the decode/audio engine below is
|
||||
/// native-only); on wasm `active` stays false and `.wmv` shows the info panel.
|
||||
@@ -459,6 +474,7 @@ impl Plugin for IsoLoaderPlugin {
|
||||
.init_resource::<TextPreview>()
|
||||
.init_resource::<PakView>()
|
||||
.init_resource::<SkyboxPreview>()
|
||||
.init_resource::<ModelPreview>()
|
||||
.init_resource::<VideoPreview>();
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -1423,6 +1439,159 @@ fn free_video(
|
||||
*video = VideoPreview::default();
|
||||
}
|
||||
|
||||
/// Build Bevy meshes from a decoded [`Xbg7Model`], texture them with the model's
|
||||
/// albedo (`_col`) map, spawn them into the scene tagged [`PreviewModel`], and
|
||||
/// frame the orbit camera on the combined bounding box.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_model_preview(
|
||||
model: &sylpheed_formats::mesh::Xbg7Model,
|
||||
xpr_bytes: &[u8],
|
||||
commands: &mut Commands,
|
||||
meshes: &mut Assets<Mesh>,
|
||||
materials: &mut Assets<StandardMaterial>,
|
||||
images: &mut Assets<Image>,
|
||||
model_preview: &mut ModelPreview,
|
||||
orbit: &mut Query<&mut crate::camera::OrbitCamera>,
|
||||
) {
|
||||
use bevy::render::mesh::{Indices, PrimitiveTopology};
|
||||
use sylpheed_formats::texture::X360Texture;
|
||||
|
||||
// ── Albedo texture: prefer the `_col` map, else the first texture. ──
|
||||
let names = X360Texture::texture_names(xpr_bytes);
|
||||
let col_idx = names
|
||||
.iter()
|
||||
.position(|n| n.ends_with("_col"))
|
||||
.or(if names.is_empty() { None } else { Some(0) });
|
||||
let (base_color_texture, tex_label) = match col_idx {
|
||||
Some(i) => match X360Texture::from_xpr2_index(xpr_bytes, i)
|
||||
.ok()
|
||||
.and_then(|t| crate::asset_loader::x360_texture_to_bevy_image(t).ok())
|
||||
{
|
||||
Some(img) => (Some(images.add(img)), names[i].clone()),
|
||||
None => (None, "none".to_string()),
|
||||
},
|
||||
None => (None, "none".to_string()),
|
||||
};
|
||||
|
||||
let material = materials.add(StandardMaterial {
|
||||
base_color: Color::srgb(0.8, 0.8, 0.82),
|
||||
base_color_texture,
|
||||
perceptual_roughness: 0.7,
|
||||
metallic: 0.1,
|
||||
// Xbox winding / our handedness may disagree — show both sides so a
|
||||
// model is never invisible from the "back".
|
||||
cull_mode: None,
|
||||
double_sided: true,
|
||||
..default()
|
||||
});
|
||||
|
||||
// ── Combined bounding box for camera framing. ──
|
||||
let mut lo = Vec3::splat(f32::MAX);
|
||||
let mut hi = Vec3::splat(f32::MIN);
|
||||
|
||||
// Parent entity so the whole model can be despawned / transformed as one.
|
||||
let root = commands
|
||||
.spawn((
|
||||
PreviewModel,
|
||||
Transform::default(),
|
||||
Visibility::default(),
|
||||
Name::new(model.name.clone()),
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut total_v = 0usize;
|
||||
let mut total_t = 0usize;
|
||||
for sub in &model.meshes {
|
||||
if sub.positions.is_empty() || sub.indices.is_empty() {
|
||||
continue;
|
||||
}
|
||||
total_v += sub.positions.len();
|
||||
total_t += sub.indices.len() / 3;
|
||||
for p in &sub.positions {
|
||||
lo = lo.min(Vec3::from_array(*p));
|
||||
hi = hi.max(Vec3::from_array(*p));
|
||||
}
|
||||
|
||||
let positions: Vec<[f32; 3]> = sub.positions.clone();
|
||||
let uvs: Vec<[f32; 2]> = if sub.uvs.len() == sub.positions.len() {
|
||||
sub.uvs.clone()
|
||||
} else {
|
||||
vec![[0.0, 0.0]; sub.positions.len()]
|
||||
};
|
||||
// Prefer the model's own normals; fall back to computed smooth normals.
|
||||
let normals = if sub.normals.len() == sub.positions.len() {
|
||||
sub.normals.clone()
|
||||
} else {
|
||||
compute_smooth_normals(&positions, &sub.indices)
|
||||
};
|
||||
|
||||
let mut mesh = Mesh::new(
|
||||
PrimitiveTopology::TriangleList,
|
||||
RenderAssetUsages::default(),
|
||||
);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
|
||||
mesh.insert_indices(Indices::U32(sub.indices.clone()));
|
||||
|
||||
let child = commands
|
||||
.spawn((
|
||||
PreviewModel,
|
||||
Mesh3d(meshes.add(mesh)),
|
||||
MeshMaterial3d(material.clone()),
|
||||
Transform::default(),
|
||||
))
|
||||
.id();
|
||||
commands.entity(root).add_child(child);
|
||||
}
|
||||
|
||||
// ── Frame the orbit camera on the model. ──
|
||||
let center = if lo.x <= hi.x { (lo + hi) * 0.5 } else { Vec3::ZERO };
|
||||
let extent = if lo.x <= hi.x {
|
||||
(hi - lo).length().max(0.001)
|
||||
} else {
|
||||
5.0
|
||||
};
|
||||
if let Ok(mut cam) = orbit.get_single_mut() {
|
||||
cam.focus = center;
|
||||
cam.radius = extent * 1.4;
|
||||
}
|
||||
|
||||
model_preview.active = true;
|
||||
model_preview.name = model.name.clone();
|
||||
model_preview.info = format!(
|
||||
"XBG7 model · {} sub-mesh(es) · {} verts · {} tris · albedo: {}",
|
||||
model.meshes.len(),
|
||||
total_v,
|
||||
total_t,
|
||||
tex_label,
|
||||
);
|
||||
}
|
||||
|
||||
/// Per-vertex smooth normals = normalized sum of incident face normals.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn compute_smooth_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> {
|
||||
let mut acc = vec![Vec3::ZERO; positions.len()];
|
||||
for tri in indices.chunks_exact(3) {
|
||||
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
|
||||
if a >= positions.len() || b >= positions.len() || c >= positions.len() {
|
||||
continue;
|
||||
}
|
||||
let pa = Vec3::from_array(positions[a]);
|
||||
let pb = Vec3::from_array(positions[b]);
|
||||
let pc = Vec3::from_array(positions[c]);
|
||||
let n = (pb - pa).cross(pc - pa);
|
||||
acc[a] += n;
|
||||
acc[b] += n;
|
||||
acc[c] += n;
|
||||
}
|
||||
acc.into_iter()
|
||||
.map(|n| n.normalize_or_zero().to_array())
|
||||
.map(|n| if n == [0.0, 0.0, 0.0] { [0.0, 1.0, 0.0] } else { n })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Converts pending raw bytes into a Bevy `Image`, registers it with egui,
|
||||
/// and populates `TexturePreview` / `SkyboxPreview` / `FileInfo`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -1437,6 +1606,12 @@ fn apply_loaded_texture(
|
||||
mut file_info: ResMut<FileInfo>,
|
||||
mut images: ResMut<Assets<Image>>,
|
||||
mut contexts: bevy_egui::EguiContexts,
|
||||
mut commands: Commands,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||
mut model_preview: ResMut<ModelPreview>,
|
||||
old_models: Query<Entity, With<PreviewModel>>,
|
||||
mut orbit: Query<&mut crate::camera::OrbitCamera>,
|
||||
) {
|
||||
if !pending.ready {
|
||||
return;
|
||||
@@ -1456,6 +1631,12 @@ fn apply_loaded_texture(
|
||||
*text_preview = TextPreview::default();
|
||||
*pak_view = PakView::default();
|
||||
|
||||
// Despawn any previously-previewed 3D model.
|
||||
for e in old_models.iter() {
|
||||
commands.entity(e).despawn_recursive();
|
||||
}
|
||||
*model_preview = ModelPreview::default();
|
||||
|
||||
// Populate FileInfo for the status / info panel.
|
||||
file_info.name = path
|
||||
.split('/')
|
||||
@@ -1488,6 +1669,26 @@ fn apply_loaded_texture(
|
||||
return; // Not a texture — FileInfo is sufficient
|
||||
}
|
||||
|
||||
// 3D model? XPR2 containers that hold XBG7 geometry (ship / weapon / prop
|
||||
// models under `hidden/resource3d/`) preview as an orbitable mesh, textured
|
||||
// with their albedo (`_col`) map. Complex multi-stream body meshes decline
|
||||
// cleanly (Err) and fall through to the 2D texture preview below.
|
||||
if let Ok(model) = sylpheed_formats::mesh::Xbg7Model::from_xpr2(&bytes) {
|
||||
if !model.meshes.is_empty() {
|
||||
spawn_model_preview(
|
||||
&model,
|
||||
&bytes,
|
||||
&mut commands,
|
||||
&mut meshes,
|
||||
&mut materials,
|
||||
&mut images,
|
||||
&mut model_preview,
|
||||
&mut orbit,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// World cubemap (`TXCM`) → decode all 6 faces and show a labelled grid.
|
||||
// Returns `None` for ordinary 2D textures, which fall through below.
|
||||
match sylpheed_formats::texture::X360Texture::cube_faces_from_xpr2(&bytes) {
|
||||
|
||||
@@ -10,7 +10,7 @@ use bevy::prelude::*;
|
||||
use bevy_egui::{egui, EguiContexts};
|
||||
|
||||
use crate::iso_loader::{
|
||||
FileInfo, FileSelected, ImageRgba, IsoState, PakContent, PakView, RequestOpenDir,
|
||||
FileInfo, FileSelected, ImageRgba, IsoState, ModelPreview, PakContent, PakView, RequestOpenDir,
|
||||
RequestOpenIso, SkyboxPreview, TextPreview, TexturePreview, VideoPreview, IsoLoaderSystemSet,
|
||||
};
|
||||
use crate::ViewerState;
|
||||
@@ -132,6 +132,7 @@ fn draw_viewer_ui(
|
||||
text_preview: Res<TextPreview>,
|
||||
mut pak_view: ResMut<PakView>,
|
||||
skybox: Res<SkyboxPreview>,
|
||||
model: Res<ModelPreview>,
|
||||
mut video: ResMut<VideoPreview>,
|
||||
file_info: Res<FileInfo>,
|
||||
mut open_iso_events: EventWriter<RequestOpenIso>,
|
||||
@@ -251,7 +252,30 @@ fn draw_viewer_ui(
|
||||
});
|
||||
|
||||
// ── Central area ──────────────────────────────────────────────────────
|
||||
if browser.selected.is_some() {
|
||||
if browser.selected.is_some() && model.active && !browser.loading {
|
||||
// 3D model: draw the central panel TRANSPARENT so the real Bevy scene
|
||||
// (mesh + orbit camera) shows through, with just an info overlay on top.
|
||||
egui::CentralPanel::default()
|
||||
.frame(egui::Frame::none())
|
||||
.show(ctx, |ui| {
|
||||
egui::Frame::none()
|
||||
.fill(egui::Color32::from_black_alpha(160))
|
||||
.inner_margin(egui::Margin::same(8.0))
|
||||
.rounding(egui::Rounding::same(4.0))
|
||||
.show(ui, |ui| {
|
||||
ui.heading(&model.name);
|
||||
ui.label(&model.info);
|
||||
ui.colored_label(
|
||||
egui::Color32::from_gray(160),
|
||||
"3D preview · LMB orbit · RMB pan · scroll zoom · R reset",
|
||||
);
|
||||
ui.colored_label(
|
||||
egui::Color32::from_gray(140),
|
||||
"position + normal + UV from the XBG7 vertex declaration",
|
||||
);
|
||||
});
|
||||
});
|
||||
} else if browser.selected.is_some() {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
if browser.loading {
|
||||
// A file was just clicked — show immediate feedback while the
|
||||
|
||||
@@ -19,6 +19,7 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
|
||||
| LSTA sprite list | 🟡 | `sylpheed-formats/src/lsta.rs` | inline T8aD frames |
|
||||
| IXUD subtitle | 🟡 | `sylpheed-formats/src/ixud.rs` | timed cues; **movie↔track link unknown** (dynamic item) |
|
||||
| Fonts (ttf/otf/ttc) | ✅ | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser |
|
||||
| XBG7 mesh | 🟡/❔ | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | single-stream layout decoded (~25 models, pos+normal+UV via vertex declaration); complex body layout + other vertex layouts declined |
|
||||
|
||||
## Functions / code paths
|
||||
|
||||
|
||||
103
docs/re/structures/xbg7-mesh.md
Normal file
103
docs/re/structures/xbg7-mesh.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# XBG7 — mesh geometry (inside XPR2 model containers)
|
||||
|
||||
- **Confidence:** 🟡 `PROBABLE` for the single-stream layout (below); ❔ `HYPOTHESIS` / undecoded
|
||||
for the complex multi-stream body layout.
|
||||
- **Parser in:** `sylpheed-formats/src/mesh.rs` (`Xbg7Model::from_xpr2`), tests
|
||||
`tests/mesh_disc.rs`. Container parsing reused from `src/texture.rs` (`Xpr2Header` /
|
||||
`Xpr2ResourceEntry`).
|
||||
- **Applies to:** ship / weapon / prop models in `hidden/resource3d/*.xpr` (166 files).
|
||||
- **Method:** clean-room — static hex inspection of the retail disc + geometric validation of the
|
||||
recovered triangles (non-degenerate area, indices in range, bbox matches the descriptor's stored
|
||||
size). No game code decompiled or copied.
|
||||
|
||||
## Where XBG7 lives
|
||||
|
||||
Models are ordinary **`XPR2`** containers (see the XPR2 texture doc / `texture.rs`). The 16-byte
|
||||
resource-directory entries (from file offset `0x10`) carry `TX2D` texture resources **and** one or
|
||||
more `XBG7` geometry resources:
|
||||
|
||||
```
|
||||
entry = [ tag:4 ][ data_offset:u32 ][ descriptor_size:u32 ][ name_offset:u32 ] (big-endian)
|
||||
```
|
||||
|
||||
Offsets are relative to the directory base `0x10`. The `XBG7` *descriptor* (at `data_offset+0x10`,
|
||||
`descriptor_size` bytes) is a **scene / material / node graph** — it holds node names
|
||||
(`rou_f001_mnt1_root`, `Light`, …), a bounding value (`0x41F00000` = 30.0 ≈ the ship's ~30-unit
|
||||
length), material names matching the `TX2D` channels (`_col` albedo, `_spc` specular, `_gls` gloss,
|
||||
`_lum` luminance), and per-sub-mesh records. The **vertex / index buffers** live in the container's
|
||||
shared **data section** (from `header_size`).
|
||||
|
||||
## Sub-mesh records (in the descriptor)
|
||||
|
||||
Read in file order by a sliding 4-byte scan; each is a big-endian tuple:
|
||||
|
||||
```
|
||||
[ vtx_count:u32 ][ 0:u32 ][ idx_count:u32 ][ tail:u32 ]
|
||||
3..=65535 ==0 mult. of 3 1..=64
|
||||
```
|
||||
|
||||
(For `rou_f001_wep_00`: `vtx_count=215`, `idx_count=1092` — matches the recovered geometry exactly.)
|
||||
|
||||
## The single-stream data layout — 🟡 PROBABLE (decoded)
|
||||
|
||||
For ~25 of the 166 models (weapons `rou_f001_wep_00/02/03/04/09/…`) the data section is a straight
|
||||
sequence of sub-meshes, carved from `header_size` in record order:
|
||||
|
||||
```
|
||||
per sub-mesh:
|
||||
[ index buffer : idx_count × u16 BE ] triangle list
|
||||
[ 12-byte vertex-buffer header (contents undecoded) ]
|
||||
[ vertex buffer : vtx_count × 24 bytes ] ← declaration below
|
||||
(pad to 16 bytes → next sub-mesh)
|
||||
```
|
||||
|
||||
**Vertex declaration** (from the descriptor; usage codes `0x00` POSITION, `0x03` NORMAL,
|
||||
`0x05` TEXCOORD — identical across decoded models):
|
||||
|
||||
```
|
||||
+0x00 POSITION f32 × 3 BE model units
|
||||
+0x0C NORMAL f16 × 4 BE (x, y, z, w); use xyz — unit length
|
||||
+0x14 TEXCOORD f16 × 2 BE (u, v)
|
||||
```
|
||||
|
||||
**Alignment is pinned by the normals.** The vertex buffer starts at `index_end + 12` (a fixed
|
||||
12-byte header — NOT `align16`, which lands 4 bytes early on most models and silently corrupts every
|
||||
field). The correct offset is the unique one where the recovered normals are exactly unit-length; an
|
||||
`align`-based guess produced the small stray-triangle artefacts seen in the first viewer pass.
|
||||
|
||||
**Safety gate:** every index is validated `< vtx_count`, **and** the mean recovered `|normal|` must be
|
||||
≈1 (in `[0.5, 2.0]`). A model failing either is rejected (`MeshError::UnsupportedLayout`) rather than
|
||||
emitting garbage — this correctly declines `Stage_S*` placeholders and models using a different
|
||||
vertex layout.
|
||||
|
||||
## Not yet decoded — ❔ the complex body layout
|
||||
|
||||
The hero-ship *body* meshes (`DeltaSaber_A/_T/_W.xpr` resource `f004`, and ~100 other models) use a
|
||||
**multi-stream** layout: the data section does **not** start with an index buffer; the real
|
||||
geometry sits at descriptor-addressed offsets deep in a multi-MB data section, as **separate
|
||||
position / attribute streams** (a plain `f32×3` position stream at stride 12 was located at a high
|
||||
offset, with attributes in other streams), and positions appear partly quantized. `DeltaSaber_A`'s
|
||||
5 XBG7 blocks are `f004` (body) + `_rou_f004_mnv01_L/_R`, `_mnv02`, `_turn180` (maneuver / pose
|
||||
variants). Decoding this requires parsing the descriptor's vertex-declaration + stream table
|
||||
(offsets `0x0C`/`0x14` with format codes were seen but not resolved). These models are cleanly
|
||||
declined today.
|
||||
|
||||
## Evidence log
|
||||
|
||||
- 2026-07-12 — `rou_f001_wep_00.xpr`: XPR2 dir = 1×XBG7 (`f001_wep_00`) + 3×TX2D
|
||||
(`_col/_gls/_spc`). Data section starts with a u16-BE index run (max 214), then stride-24
|
||||
vertices. Descriptor record `[215,0,1092,4]` at desc `+0x2B0`; index-buffer byte size `0x888`
|
||||
(=2184=1092×2) at desc `+0x1A0`. Recovered mesh = 215 v / 364 t, 362 non-degenerate, median tri
|
||||
area 0.015 — coherent. → single-stream layout **PROBABLE**.
|
||||
- 2026-07-12 — descriptor-driven sequential carving over all 166 models: **42 carve** under the
|
||||
index-only check. Real 3D extent confirmed on `rou_f001_wep_04` (bbox 3.7×1.2×3.5).
|
||||
- 2026-07-12 (refine) — attribute ranges differed per model (`wep_00` UV≈attr0/2, `wep_04`
|
||||
attr4/5, `wep_03` attr1≈±60000 = garbage) → **refuted the fixed "6 half attrs, UV=attr0/2"
|
||||
reading.** Found the descriptor **vertex declaration** (offsets 0x0C/0x14, usages 0x03 NORMAL /
|
||||
0x05 TEXCOORD). Solved the vertex-base offset with a **unit-normal validator**: `index_end + 12`
|
||||
gives median `|normal| = 1.000` on every weapon model (`wep_00/02/03/04`), vs `align16` landing 4
|
||||
bytes early. UVs then land in `[0,1]`. Adding the normal gate: **25 models decode clean +
|
||||
normal-valid** (the rest — incl. `Stage_S*` degenerate blobs — correctly declined).
|
||||
- 2026-07-12 — `DeltaSaber_A.xpr` body: data does **not** begin with indices; plain-`f32×3` runs
|
||||
with ship-scale extent (span ≈27–34, matching bbox 30.0) found only at high offsets
|
||||
(`data+0x28634C`, …) → multi-stream, **undecoded**.
|
||||
Reference in New Issue
Block a user