feat(formats,viewer): decode XBG7 meshes + 3D model preview

Reverse-engineer the single-stream XBG7 geometry layout (clean-room: hex
inspection + geometric validation of the retail disc's
hidden/resource3d/*.xpr, no game code copied) and present models as
textured 3D meshes in the explorer.

Format (docs/re/structures/xbg7-mesh.md): XBG7 geometry resources sit
inside XPR2 containers alongside TX2D textures. For ~25 single-stream
models (weapons, simple props) the data section is a sequence of
sub-meshes, each an u16-BE triangle-list index buffer followed (after a
fixed 12-byte header) by a stride-24 vertex buffer whose declaration is
in the descriptor: POSITION f32x3 @0x00, NORMAL f16x4 @0x0C, TEXCOORD
f16x2 @0x14. Sub-mesh (vtx,idx) counts come from descriptor tuples.

The +12 vertex offset is pinned by the recovered normals being exactly
unit-length (align16 lands 4 bytes early and silently corrupts every
field). A safety gate rejects any model whose indices are out of range
or whose mean |normal| is not ~1, declining garbage (Stage_S*
placeholders, the complex multi-stream hero-ship body) rather than
mis-decoding it.

- mesh.rs: Xbg7Model::from_xpr2 -> GameMesh { positions, normals, uvs,
  indices }; standalone f16->f32; unit + real-disc tests (weapon decodes
  to 215v/364t with unit normals + in-range UVs; DeltaSaber body
  declined).
- texture.rs: from_xpr2_index / texture_names so the viewer can pick a
  model's _col albedo map.
- viewer: loose .xpr with decodable XBG7 spawns Bevy meshes (real normals,
  double-sided) textured with the albedo, framed by the orbit camera; the
  central egui panel goes transparent so the 3D scene shows through.

Complex multi-stream body meshes (DeltaSaber f004, other vertex layouts)
remain undecoded and are cleanly declined — next target is dynamic RE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-12 17:47:06 +02:00
parent e6da726f7b
commit ef6e448268
8 changed files with 794 additions and 54 deletions

View File

@@ -54,6 +54,7 @@ pub mod audio;
pub use font::FontInfo;
pub use idxd::{IdxdError, IdxdObject};
pub use ixud::{Cue, Subtitle};
pub use mesh::{GameMesh, Xbg7Model};
pub use ratc::RatcChild;
pub use t8ad::T8adImage;
pub use pak::{PakArchive, PakEntry, PakError};

View File

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

View File

@@ -221,6 +221,45 @@ impl X360Texture {
/// 3. Read its GPUTEXTURE_FETCH_CONSTANT (GPUFC) at descriptor +0x18
/// 4. De-tile the pixel data (if tiled) → linear layout
pub fn from_xpr2(bytes: &[u8]) -> Result<Self, TextureError> {
// XPR files are frequently PACKS of many textures; XPR_RES_INDEX picks
// the Nth texture resource (default 0) for RE/browse validation.
let want = std::env::var("XPR_RES_INDEX")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(0);
Self::from_xpr2_index(bytes, want)
}
/// List the names of the texture resources (`TX2D` / `TXCM`) in an XPR2
/// container, in directory order — the same order [`from_xpr2_index`]
/// selects by. Non-texture resources (e.g. `XBG7`) are skipped.
pub fn texture_names(bytes: &[u8]) -> Vec<String> {
use std::io::Cursor;
let mut cur = Cursor::new(bytes);
let Ok(header) = Xpr2Header::read(&mut cur) else {
return Vec::new();
};
let mut names = Vec::new();
for _ in 0..header.num_resources {
let Ok(e) = Xpr2ResourceEntry::read(&mut cur) else {
break;
};
if e.is_texture() || e.is_cubemap() {
const DIR_BASE: usize = 0x10;
let no = e.name_offset as usize + DIR_BASE;
let name = bytes
.get(no..)
.and_then(|s| s.iter().position(|&c| c == 0).map(|p| &s[..p]))
.map(|s| String::from_utf8_lossy(s).into_owned())
.unwrap_or_default();
names.push(name);
}
}
names
}
/// Decode the `want`-th texture resource (`TX2D` / `TXCM`, directory order).
pub fn from_xpr2_index(bytes: &[u8], want: usize) -> Result<Self, TextureError> {
use std::io::Cursor;
let mut cur = Cursor::new(bytes);
@@ -236,12 +275,6 @@ impl X360Texture {
// Select a texture resource. TX2D = 2D texture; TXCM = cubemap
// (skybox / backdrop) — same 52-byte descriptor + GPUFC layout, but the
// pixel section holds 6 faces. For a preview we decode face 0.
// XPR files are frequently PACKS of many textures; XPR_RES_INDEX picks
// the Nth texture resource (default 0) for RE/browse validation.
let want = std::env::var("XPR_RES_INDEX")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(0);
let tex_entry = entries.iter()
.filter(|e| e.is_texture() || e.is_cubemap())
.nth(want)

View 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()));
}
}
}
}

View File

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

View File

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