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 font::FontInfo;
|
||||||
pub use idxd::{IdxdError, IdxdObject};
|
pub use idxd::{IdxdError, IdxdObject};
|
||||||
pub use ixud::{Cue, Subtitle};
|
pub use ixud::{Cue, Subtitle};
|
||||||
|
pub use mesh::{GameMesh, Xbg7Model};
|
||||||
pub use ratc::RatcChild;
|
pub use ratc::RatcChild;
|
||||||
pub use t8ad::T8adImage;
|
pub use t8ad::T8adImage;
|
||||||
pub use pak::{PakArchive, PakEntry, PakError};
|
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.
|
//! ## Clean-room note
|
||||||
//! This module is a scaffold: populate these structs as you discover the
|
|
||||||
//! actual binary layout using a hex editor (010 Editor) and Ghidra.
|
|
||||||
//!
|
//!
|
||||||
//! ## 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
|
//! ## Where XBG7 lives
|
||||||
//! 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
|
|
||||||
//!
|
//!
|
||||||
//! ## Useful tools
|
//! Ship / weapon / prop models are `XPR2` containers (see [`crate::texture`]).
|
||||||
//! - 010 Editor with binary templates
|
//! Their resource directory holds `TX2D` texture resources **and** one or more
|
||||||
//! - Noesis (can preview many console formats)
|
//! `XBG7` geometry resources. The `XBG7` *descriptor* (at the resource's
|
||||||
//! - binrw (this project) for writing the parser once the format is known
|
//! `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;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum MeshError {
|
pub enum MeshError {
|
||||||
#[error("Unknown mesh magic: {0:?}")]
|
#[error("Not an XPR2 container")]
|
||||||
UnknownMagic([u8; 4]),
|
NotXpr2,
|
||||||
#[error("Unsupported mesh version: {0}")]
|
#[error("No XBG7 geometry resource in container")]
|
||||||
UnsupportedVersion(u32),
|
NoGeometry,
|
||||||
|
#[error("Mesh layout not supported (multi-stream / quantized body mesh)")]
|
||||||
|
UnsupportedLayout,
|
||||||
#[error("Parse error: {0}")]
|
#[error("Parse error: {0}")]
|
||||||
Parse(String),
|
Parse(#[from] binrw::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A decoded 3D mesh ready for Bevy.
|
/// A decoded 3D mesh ready for Bevy.
|
||||||
/// Vertex positions, normals, UVs, and indices.
|
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct GameMesh {
|
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]>,
|
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]>,
|
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]>,
|
pub uvs: Vec<[f32; 2]>,
|
||||||
/// Triangle list indices
|
/// Triangle-list indices (3 per triangle).
|
||||||
pub indices: Vec<u32>,
|
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>,
|
pub name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GameMesh {
|
/// A model = the set of sub-meshes recovered from one XPR2 container's first
|
||||||
/// Parse a mesh from raw bytes.
|
/// 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.
|
/// Returns [`MeshError::UnsupportedLayout`] for models whose data section
|
||||||
/// Currently returns an error until the format is reverse engineered.
|
/// does not carve cleanly under the simple single-stream layout (the
|
||||||
pub fn from_bytes(_bytes: &[u8]) -> Result<Vec<Self>, MeshError> {
|
/// complex body meshes) — never partial / garbage geometry.
|
||||||
// ┌──────────────────────────────────────────────────────────────┐
|
pub fn from_xpr2(bytes: &[u8]) -> Result<Self, MeshError> {
|
||||||
// │ REVERSE ENGINEERING TODO │
|
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||||||
// │ │
|
return Err(MeshError::NotXpr2);
|
||||||
// │ Steps to implement this: │
|
}
|
||||||
// │ 1. Find mesh files in the extraction (look for .mdl etc.) │
|
let mut cur = Cursor::new(bytes);
|
||||||
// │ 2. Identify the file format using identify_format() in vfs │
|
let header = Xpr2Header::read(&mut cur)?;
|
||||||
// │ 3. Use 010 Editor to map the binary structure │
|
let mut xbg: Option<Xpr2ResourceEntry> = None;
|
||||||
// │ 4. Add binrw #[derive(BinRead)] structs above │
|
for _ in 0..header.num_resources {
|
||||||
// │ 5. Implement this function to parse and return meshes │
|
let e = Xpr2ResourceEntry::read(&mut cur)?;
|
||||||
// └──────────────────────────────────────────────────────────────┘
|
if &e.type_tag == b"XBG7" {
|
||||||
Err(MeshError::Parse(
|
xbg = Some(e);
|
||||||
"Mesh format not yet reverse engineered. \
|
break;
|
||||||
See RE TODO in src/mesh.rs".to_string()
|
}
|
||||||
))
|
}
|
||||||
|
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
|
/// 3. Read its GPUTEXTURE_FETCH_CONSTANT (GPUFC) at descriptor +0x18
|
||||||
/// 4. De-tile the pixel data (if tiled) → linear layout
|
/// 4. De-tile the pixel data (if tiled) → linear layout
|
||||||
pub fn from_xpr2(bytes: &[u8]) -> Result<Self, TextureError> {
|
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;
|
use std::io::Cursor;
|
||||||
let mut cur = Cursor::new(bytes);
|
let mut cur = Cursor::new(bytes);
|
||||||
|
|
||||||
@@ -236,12 +275,6 @@ impl X360Texture {
|
|||||||
// Select a texture resource. TX2D = 2D texture; TXCM = cubemap
|
// Select a texture resource. TX2D = 2D texture; TXCM = cubemap
|
||||||
// (skybox / backdrop) — same 52-byte descriptor + GPUFC layout, but the
|
// (skybox / backdrop) — same 52-byte descriptor + GPUFC layout, but the
|
||||||
// pixel section holds 6 faces. For a preview we decode face 0.
|
// 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()
|
let tex_entry = entries.iter()
|
||||||
.filter(|e| e.is_texture() || e.is_cubemap())
|
.filter(|e| e.is_texture() || e.is_cubemap())
|
||||||
.nth(want)
|
.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,
|
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
|
/// The currently-open `.wmv` cutscene, shown in the central panel with a
|
||||||
/// transport bar. Registered unconditionally (the decode/audio engine below is
|
/// transport bar. Registered unconditionally (the decode/audio engine below is
|
||||||
/// native-only); on wasm `active` stays false and `.wmv` shows the info panel.
|
/// 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::<TextPreview>()
|
||||||
.init_resource::<PakView>()
|
.init_resource::<PakView>()
|
||||||
.init_resource::<SkyboxPreview>()
|
.init_resource::<SkyboxPreview>()
|
||||||
|
.init_resource::<ModelPreview>()
|
||||||
.init_resource::<VideoPreview>();
|
.init_resource::<VideoPreview>();
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
@@ -1423,6 +1439,159 @@ fn free_video(
|
|||||||
*video = VideoPreview::default();
|
*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,
|
/// Converts pending raw bytes into a Bevy `Image`, registers it with egui,
|
||||||
/// and populates `TexturePreview` / `SkyboxPreview` / `FileInfo`.
|
/// and populates `TexturePreview` / `SkyboxPreview` / `FileInfo`.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
@@ -1437,6 +1606,12 @@ fn apply_loaded_texture(
|
|||||||
mut file_info: ResMut<FileInfo>,
|
mut file_info: ResMut<FileInfo>,
|
||||||
mut images: ResMut<Assets<Image>>,
|
mut images: ResMut<Assets<Image>>,
|
||||||
mut contexts: bevy_egui::EguiContexts,
|
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 {
|
if !pending.ready {
|
||||||
return;
|
return;
|
||||||
@@ -1456,6 +1631,12 @@ fn apply_loaded_texture(
|
|||||||
*text_preview = TextPreview::default();
|
*text_preview = TextPreview::default();
|
||||||
*pak_view = PakView::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.
|
// Populate FileInfo for the status / info panel.
|
||||||
file_info.name = path
|
file_info.name = path
|
||||||
.split('/')
|
.split('/')
|
||||||
@@ -1488,6 +1669,26 @@ fn apply_loaded_texture(
|
|||||||
return; // Not a texture — FileInfo is sufficient
|
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.
|
// World cubemap (`TXCM`) → decode all 6 faces and show a labelled grid.
|
||||||
// Returns `None` for ordinary 2D textures, which fall through below.
|
// Returns `None` for ordinary 2D textures, which fall through below.
|
||||||
match sylpheed_formats::texture::X360Texture::cube_faces_from_xpr2(&bytes) {
|
match sylpheed_formats::texture::X360Texture::cube_faces_from_xpr2(&bytes) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use bevy::prelude::*;
|
|||||||
use bevy_egui::{egui, EguiContexts};
|
use bevy_egui::{egui, EguiContexts};
|
||||||
|
|
||||||
use crate::iso_loader::{
|
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,
|
RequestOpenIso, SkyboxPreview, TextPreview, TexturePreview, VideoPreview, IsoLoaderSystemSet,
|
||||||
};
|
};
|
||||||
use crate::ViewerState;
|
use crate::ViewerState;
|
||||||
@@ -132,6 +132,7 @@ fn draw_viewer_ui(
|
|||||||
text_preview: Res<TextPreview>,
|
text_preview: Res<TextPreview>,
|
||||||
mut pak_view: ResMut<PakView>,
|
mut pak_view: ResMut<PakView>,
|
||||||
skybox: Res<SkyboxPreview>,
|
skybox: Res<SkyboxPreview>,
|
||||||
|
model: Res<ModelPreview>,
|
||||||
mut video: ResMut<VideoPreview>,
|
mut video: ResMut<VideoPreview>,
|
||||||
file_info: Res<FileInfo>,
|
file_info: Res<FileInfo>,
|
||||||
mut open_iso_events: EventWriter<RequestOpenIso>,
|
mut open_iso_events: EventWriter<RequestOpenIso>,
|
||||||
@@ -251,7 +252,30 @@ fn draw_viewer_ui(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Central area ──────────────────────────────────────────────────────
|
// ── 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| {
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
if browser.loading {
|
if browser.loading {
|
||||||
// A file was just clicked — show immediate feedback while the
|
// 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 |
|
| 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) |
|
| 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 |
|
| 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
|
## 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