Compare commits

...

5 Commits

Author SHA1 Message Date
MechaCat02
d23339a3aa feat(viewer): stage thumbnail grid, per-model albedo, camera zoom fix
Route stage containers to stage_models (decode both single + stage paths, keep
whichever yields more geometry) — fixes stages showing the dummy-root "black
cube". Present a stage's sub-models as a thumbnail grid: each recentred and
uniformly scaled to a fixed cell so a 1-unit prop and a 3000-unit structure are
equally visible; skybox-plane / stray-anchor models (>20000u) are culled. Each
sub-model is textured by matching its name to the container's `_col` albedo.

Fix the orbit camera: zoom radius was hard-capped at 50u with fixed clip planes,
trapping the camera inside multi-thousand-unit stages. Zoom limits and near/far
planes now scale to the framed model.

Adds docs/HANDOFF-stage-mesh-2026-07-12.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:56:45 +02:00
MechaCat02
2053f31d17 feat(formats,cli): decode XBG7 stage containers + fix weapon layout
Stage containers (hidden/resource3d/Stage_S*.xpr) are collections of up to
~450 enemy/prop sub-models, not single meshes. Xbg7Model::stage_models decodes
them: each resource is a [12-byte header][index buffer][vertex buffer] block
(index count = descriptor marker, vertex count = u32 32 bytes before it) whose
on-disc offset is NOT stored, so it is located by content — one O(file) pass per
stride finds vertex-buffer starts (unit NORMAL at +12 whose previous slot isn't)
and each resource is pinned to the candidate whose indices validate and produce
non-degenerate, well-connected triangles. A connectivity gate (mean triangle
edge <= 0.28x the bbox diagonal) rejects spiky mis-anchors. ~4993 sub-models
decode across the 22 stages.

The same insight fixes the weapon single-model layout: it is [12-byte header]
[index][vertex] too, not [index][12-byte gap][vertex]. Reading indices from the
block start turned the 12 header bytes into 6 junk indices (2 leading degenerate
triangles — the recurring stray-triangle artifact) and dropped the last 6 real
indices. Skipping the header leaves vertex offsets identical (coverage unchanged
at 36/166) and corrects the triangle list. Verified on wep_00/03/04.

Adds `sylpheed-cli mesh {info,render}` — a headless software rasterizer that
writes a shaded PNG (orthographic, z-buffered, two-sided), so recovered geometry
can be verified without the GUI. Stages render as a normalised thumbnail grid;
--only filters sub-models.

Tests: stage_models_{decode,sweep,quality_audit}; docs/re updated (xbg7-mesh.md
evidence log + INDEX.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:56:35 +02:00
MechaCat02
4096b2d2a5 feat(formats): declaration-driven XBG7 decode (variable stride)
Dynamic-RE follow-up: capture Canary's GPU vertex-fetch + draw calls and
feed the ground truth back into the static decoder.

The GPU capture confirmed the reverse-engineered layout exactly — meshes
draw as triangle LISTs (prim=4) with pos f32x3 @0, normal f16x4 @0x0C,
uv f16x2 @0x14 — and revealed the XBG7 vertex format is NOT fixed-stride:
models omit elements (stride 20 = pos+normal, no UV; 24 = pos+normal+uv).

- mesh.rs: parse the descriptor's vertex declaration ({offset, format-code,
  usage} triples; 0x2A23B9=pos f32x3, 0x1A2360=normal f16x4, 0x2C235F=uv
  f16x2) to drive per-model stride + element offsets, instead of assuming
  stride 24. Coverage 25 -> 36 fully-validated models (e.g. the Stage_S*
  props, which are pos+normal only). Same index-range + unit-normal safety
  gates; complex/mismatched layouts still declined.
- Endianness note (documented): the capture's fetch endian=k8in32 describes
  the GPU's guest-memory copy, NOT the .xpr file bytes — reading the file
  with k8in32 breaks the normals (|n|->1.33); naive big-endian per element
  is correct (the game rearranges vertex data on load).
- tests: a coverage-regression test (>=35 models) + the existing weapon /
  body-declined disc tests still pass.

Confirmed against a Canary draw-log capture; see docs/re/structures/
xbg7-mesh.md (evidence log + declaration table).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:42:21 +02:00
MechaCat02
ef6e448268 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>
2026-07-12 17:47:06 +02:00
MechaCat02
e6da726f7b docs(re): CONFIRM T8aD colour interpretation (linear UNORM, ARGB order)
Resolved the "colours look odd" channel-order/sRGB question for k_8_8_8_8
(T8aD 2D UI textures) with no emulator run:

- sRGB: Canary's Vulkan host-format table maps plain k_8_8_8_8 to
  R8G8B8A8_UNORM and contains no R8G8B8A8_SRGB anywhere — gamma is a
  separate explicit EDRAM path. So these are raw linear bytes; apply no
  sRGB/gamma decode.
- Channel order: measured 789 real disc textures — byte0 is the opaque
  alpha (==0xFF-dominant) in 97% — so on-disk is ARGB. Cross-checked
  against xenia-rs decode_k8888_tiled, the Canary-validated M1/M2 path,
  which nets the same [A,R,G,B]->[R,G,B,A]. sylpheed-formats is correct.

Promotes the k8888 entry HYPOTHESIS -> CONFIRMED; INDEX updated. XPR2
skybox tiling + viewer display-gamma remain separate open items.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:11:17 +02:00
12 changed files with 2165 additions and 71 deletions

View File

@@ -97,6 +97,12 @@ enum Commands {
#[command(subcommand)] #[command(subcommand)]
cmd: PakCommands, cmd: PakCommands,
}, },
/// XBG7 mesh tools (inspect / headless render to PNG)
Mesh {
#[command(subcommand)]
cmd: MeshCommands,
},
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@@ -118,6 +124,37 @@ enum PakCommands {
}, },
} }
#[derive(Subcommand)]
enum MeshCommands {
/// Print the decoded sub-models of an XBG7 container (`.xpr`)
Info {
/// Path to the `.xpr` model / stage container
file: PathBuf,
},
/// Headless-render the decoded mesh(es) to a shaded PNG (software rasterizer)
Render {
/// Path to the `.xpr` model / stage container
file: PathBuf,
/// Output PNG path
output: PathBuf,
/// Image size in pixels (square)
#[arg(long, default_value_t = 900)]
size: u32,
/// Camera yaw in degrees
#[arg(long, default_value_t = 35.0)]
yaw: f32,
/// Camera pitch in degrees
#[arg(long, default_value_t = 22.0)]
pitch: f32,
/// Force the stage grid layout even for single models
#[arg(long)]
row: bool,
/// Only render sub-models whose name contains this substring
#[arg(long)]
only: Option<String>,
},
}
#[derive(Subcommand)] #[derive(Subcommand)]
enum TextureCommands { enum TextureCommands {
/// Print information about a texture file /// Print information about a texture file
@@ -155,6 +192,12 @@ async fn main() -> Result<()> {
TextureCommands::Info { file } => cmd_texture_info(&file), TextureCommands::Info { file } => cmd_texture_info(&file),
TextureCommands::Export { file, output } => cmd_texture_export(&file, &output), TextureCommands::Export { file, output } => cmd_texture_export(&file, &output),
}, },
Commands::Mesh { cmd } => match cmd {
MeshCommands::Info { file } => cmd_mesh_info(&file),
MeshCommands::Render { file, output, size, yaw, pitch, row, only } => {
cmd_mesh_render(&file, &output, size, yaw, pitch, row, only)
}
},
Commands::Pak { cmd } => match cmd { Commands::Pak { cmd } => match cmd {
PakCommands::List { pak, idxd_only } => cmd_pak_list(&pak, idxd_only), PakCommands::List { pak, idxd_only } => cmd_pak_list(&pak, idxd_only),
PakCommands::Dump { pak, hash } => cmd_pak_dump(&pak, &hash), PakCommands::Dump { pak, hash } => cmd_pak_dump(&pak, &hash),
@@ -357,6 +400,273 @@ fn cmd_texture_export(file: &Path, output: &Path) -> Result<()> {
Ok(()) Ok(())
} }
// ── mesh info / render ──────────────────────────────────────────────────────
/// Decode a container and return its sub-models the same way the viewer routes:
/// a single model (weapon / prop) OR a stage's many sub-models — whichever
/// yields more geometry.
fn decode_models(bytes: &[u8]) -> Vec<sylpheed_formats::mesh::Xbg7Model> {
use sylpheed_formats::mesh::Xbg7Model;
let single = Xbg7Model::from_xpr2(bytes)
.ok()
.filter(|m| !m.meshes.is_empty());
let single_verts = single.as_ref().map(|m| m.totals().0).unwrap_or(0);
let stage = Xbg7Model::stage_models(bytes);
let stage_verts: usize = stage.iter().map(|m| m.totals().0).sum();
if !stage.is_empty() && stage_verts > single_verts {
stage
} else {
single.into_iter().collect()
}
}
fn cmd_mesh_info(file: &Path) -> Result<()> {
let bytes = std::fs::read(file).with_context(|| format!("Cannot read {}", file.display()))?;
let models = decode_models(&bytes);
if models.is_empty() {
println!("{} no decodable XBG7 geometry", "Mesh:".yellow().bold());
return Ok(());
}
let (mut tv, mut tt) = (0usize, 0usize);
println!("{} {}", "Mesh:".green().bold(), file.display());
for m in &models {
let (v, t) = m.totals();
tv += v;
tt += t;
let mut lo = [f32::MAX; 3];
let mut hi = [f32::MIN; 3];
for sub in &m.meshes {
for p in &sub.positions {
for a in 0..3 {
lo[a] = lo[a].min(p[a]);
hi[a] = hi[a].max(p[a]);
}
}
}
println!(
" {:16} {:>6} v {:>6} t bbox [{:.1} {:.1} {:.1}]",
m.name,
v,
t,
hi[0] - lo[0],
hi[1] - lo[1],
hi[2] - lo[2]
);
}
println!(
" {} {} sub-models · {} verts · {} tris",
"TOTAL".bold(),
models.len(),
tv,
tt
);
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn cmd_mesh_render(
file: &Path,
output: &Path,
size: u32,
yaw: f32,
pitch: f32,
force_row: bool,
only: Option<String>,
) -> Result<()> {
let bytes = std::fs::read(file).with_context(|| format!("Cannot read {}", file.display()))?;
let mut models = decode_models(&bytes);
if let Some(sub) = &only {
models.retain(|m| m.name.contains(sub.as_str()));
}
if models.is_empty() {
anyhow::bail!("no decodable XBG7 geometry in {}", file.display());
}
// ── Build a triangle soup. ──
// Single models render centred; multi-model containers (stages) get the
// viewer's normalised **thumbnail grid**: each sub-model recentred and
// uniformly scaled to a fixed cell, so all are equally visible regardless of
// native scale (mirrors `spawn_stage_models`).
let multi = models.len() > 1 || force_row;
let mut tris: Vec<[[f32; 3]; 3]> = Vec::new();
const CELL: f32 = 10.0;
const GAP: f32 = 4.0;
let grid_pitch = CELL + GAP;
let cols = (models.len() as f32).sqrt().ceil().max(1.0) as usize;
for (i, m) in models.iter().enumerate() {
let mut lo = [f32::MAX; 3];
let mut hi = [f32::MIN; 3];
for sub in &m.meshes {
for p in &sub.positions {
for a in 0..3 {
lo[a] = lo[a].min(p[a]);
hi[a] = hi[a].max(p[a]);
}
}
}
if lo[0] > hi[0] {
continue;
}
let center = [
(lo[0] + hi[0]) * 0.5,
(lo[1] + hi[1]) * 0.5,
(lo[2] + hi[2]) * 0.5,
];
let (scale, cell) = if multi {
let extent = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(hi[2] - lo[2]).max(1e-3);
let col = i % cols;
let row = i / cols;
(CELL / extent, [col as f32 * grid_pitch, -(row as f32) * grid_pitch, 0.0])
} else {
(1.0, [0.0, 0.0, 0.0])
};
for sub in &m.meshes {
for tri in sub.indices.chunks_exact(3) {
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
if a >= sub.positions.len() || b >= sub.positions.len() || c >= sub.positions.len() {
continue;
}
let f = |i: usize| {
let p = sub.positions[i];
[
(p[0] - center[0]) * scale + cell[0],
(p[1] - center[1]) * scale + cell[1],
(p[2] - center[2]) * scale + cell[2],
]
};
tris.push([f(a), f(b), f(c)]);
}
}
}
if tris.is_empty() {
anyhow::bail!("no triangles to render");
}
let rgba = rasterize(&tris, size, yaw, pitch);
image::save_buffer(output, &rgba, size, size, image::ExtendedColorType::Rgba8)
.with_context(|| format!("writing PNG {}", output.display()))?;
println!(
"{} {} tris → {} ({}×{}, yaw {:.0}° pitch {:.0}°)",
"Rendered".green().bold(),
tris.len(),
output.display().to_string().cyan(),
size,
size,
yaw,
pitch,
);
Ok(())
}
/// Minimal software rasterizer: orthographic, z-buffered, two-sided Lambert +
/// headlight shading over a flat grey material on a dark background. Enough to
/// judge whether recovered geometry is coherent.
fn rasterize(tris: &[[[f32; 3]; 3]], size: u32, yaw_deg: f32, pitch_deg: f32) -> Vec<u8> {
let n = size as usize;
let (yaw, pitch) = (yaw_deg.to_radians(), pitch_deg.to_radians());
let (cy, sy) = (yaw.cos(), yaw.sin());
let (cp, sp) = (pitch.cos(), pitch.sin());
// Rotate a world point into view space (yaw about Y, then pitch about X).
let view = |p: [f32; 3]| -> [f32; 3] {
let x = p[0] * cy + p[2] * sy;
let z0 = -p[0] * sy + p[2] * cy;
let y = p[1] * cp - z0 * sp;
let z = p[1] * sp + z0 * cp;
[x, y, z]
};
// View-space bbox → orthographic fit.
let mut lo = [f32::MAX; 3];
let mut hi = [f32::MIN; 3];
for t in tris {
for v in t {
let q = view(*v);
for a in 0..3 {
lo[a] = lo[a].min(q[a]);
hi[a] = hi[a].max(q[a]);
}
}
}
let span = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(1e-3);
let scale = (n as f32) * 0.9 / span;
let cx = (lo[0] + hi[0]) * 0.5;
let cyv = (lo[1] + hi[1]) * 0.5;
let to_screen = |q: [f32; 3]| -> (f32, f32, f32) {
let sx = (q[0] - cx) * scale + n as f32 * 0.5;
let sy = n as f32 * 0.5 - (q[1] - cyv) * scale;
(sx, sy, q[2])
};
let mut color = vec![18u8; n * n * 4];
for i in 0..n * n {
color[i * 4 + 3] = 255;
}
let mut depth = vec![f32::MAX; n * n];
// Light in view space (upper-left-front).
let light = {
let l = [-0.4f32, 0.6, 0.7];
let m = (l[0] * l[0] + l[1] * l[1] + l[2] * l[2]).sqrt();
[l[0] / m, l[1] / m, l[2] / m]
};
for t in tris {
let v0 = view(t[0]);
let v1 = view(t[1]);
let v2 = view(t[2]);
// Face normal in view space.
let e1 = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];
let e2 = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]];
let mut nrm = [
e1[1] * e2[2] - e1[2] * e2[1],
e1[2] * e2[0] - e1[0] * e2[2],
e1[0] * e2[1] - e1[1] * e2[0],
];
let nl = (nrm[0] * nrm[0] + nrm[1] * nrm[1] + nrm[2] * nrm[2]).sqrt();
if nl < 1e-12 {
continue;
}
nrm = [nrm[0] / nl, nrm[1] / nl, nrm[2] / nl];
// Two-sided: diffuse from |n·L|, plus a headlight term from |n.z|.
let diff = (nrm[0] * light[0] + nrm[1] * light[1] + nrm[2] * light[2]).abs();
let head = nrm[2].abs();
let inten = (0.18 + 0.55 * diff + 0.3 * head).min(1.0);
let shade = (inten * 210.0) as u8;
let (ax, ay, az) = to_screen(v0);
let (bx, by, bz) = to_screen(v1);
let (ccx, ccy, ccz) = to_screen(v2);
let minx = ax.min(bx).min(ccx).floor().max(0.0) as usize;
let maxx = ax.max(bx).max(ccx).ceil().min(n as f32 - 1.0) as usize;
let miny = ay.min(by).min(ccy).floor().max(0.0) as usize;
let maxy = ay.max(by).max(ccy).ceil().min(n as f32 - 1.0) as usize;
let area = (bx - ax) * (ccy - ay) - (by - ay) * (ccx - ax);
if area.abs() < 1e-6 {
continue;
}
for py in miny..=maxy {
for px in minx..=maxx {
let fx = px as f32 + 0.5;
let fy = py as f32 + 0.5;
let w0 = ((bx - fx) * (ccy - fy) - (by - fy) * (ccx - fx)) / area;
let w1 = ((ccx - fx) * (ay - fy) - (ccy - fy) * (ax - fx)) / area;
let w2 = 1.0 - w0 - w1;
if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
continue;
}
let z = w0 * az + w1 * bz + w2 * ccz;
let idx = py * n + px;
if z < depth[idx] {
depth[idx] = z;
color[idx * 4] = shade;
color[idx * 4 + 1] = shade;
color[idx * 4 + 2] = (shade as f32 * 1.02).min(255.0) as u8;
}
}
}
}
color
}
/// Software-decode a de-tiled `X360Texture` (mip 0) to tightly-packed RGBA8. /// Software-decode a de-tiled `X360Texture` (mip 0) to tightly-packed RGBA8.
/// ///
/// BCn blocks are decompressed with `texpresso`; uncompressed A8R8G8B8/X8R8G8B8 /// BCn blocks are decompressed with `texpresso`; uncompressed A8R8G8B8/X8R8G8B8

View File

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

View File

@@ -1,71 +1,808 @@
//! 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 — 36 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 × stride bytes ] (declaration-driven)
//! (pad to 16 bytes → next sub-mesh)
//! ```
//!
//! The vertex layout is **not fixed** — it comes from a **vertex declaration**
//! in the descriptor: a table of `{offset, format-code, usage}` triples (usage
//! `0x00` POSITION `f32×3`, `0x03` NORMAL `f16×4`, `0x05` TEXCOORD `f16×2`).
//! Models omit UV or use fewer elements, so stride varies (20 = pos+normal,
//! 24 = pos+normal+uv, …). Each element is read in naive big-endian component
//! order. Correct alignment is pinned by the recovered normals being exactly
//! unit-length. **Cross-checked against a Canary GPU vertex-fetch capture**,
//! which confirmed the primitive type (triangle list), formats, and offsets —
//! see `docs/re/structures/xbg7-mesh.md`.
//!
//! `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());
// Parse the vertex declaration (element offsets/formats + stride). The
// XBG7 layout is NOT fixed-stride — models omit UV or use fewer elements
// (stride 20 = pos+normal, stride 24 = pos+normal+uv, …). Confirmed
// against a Canary GPU vertex-fetch capture (see docs/re/xbg7-mesh.md).
let decl = parse_vertex_decl(&bytes[desc..desc_end])
.ok_or(MeshError::UnsupportedLayout)?;
// 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 {
// Each sub-mesh block is `[12-byte header][index buffer][vertex
// buffer]` — the SAME layout as stage resources (see
// `stage_models`). The header must be skipped: reading indices from
// the block start instead treats the 12 header bytes as 6 junk
// indices (2 leading degenerate triangles — the stray-triangle
// artifact) and drops the last 6 real indices. Skipping it leaves the
// vertex buffer at the identical offset (`+12 + idx_bytes`), so
// coverage is unchanged; only the triangle list is corrected.
let ib = base + off + VERTEX_BUFFER_GAP;
let ie = ib + idx_count * 2;
let vb = ie;
let ve = vb + vtx_count * decl.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);
}
// Vertices per the declaration. The .xpr stores each element in
// naive big-endian component order (f32 / f16 read at consecutive
// offsets) — the GPU's `k8in32` fetch endianness applies to the
// rearranged guest-memory copy, not to these file bytes.
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 * decl.stride;
// POSITION: f32×3 big-endian.
let p = o + decl.pos_offset;
let x = bef(bytes, p);
let y = bef(bytes, p + 4);
let z = bef(bytes, p + 8);
if !(x.is_finite() && y.is_finite() && z.is_finite()) {
return Err(MeshError::UnsupportedLayout);
}
positions.push([x, y, z]);
// NORMAL: f16×4 (use xyz).
if let Some(no) = decl.normal_offset {
let nb = o + no;
let nx = half(bytes, nb);
let ny = half(bytes, nb + 2);
let nz = half(bytes, nb + 4);
normal_len_sum += (nx * nx + ny * ny + nz * nz).sqrt();
normals.push([nx, ny, nz]);
}
// TEXCOORD: f16×2.
if let Some(uo) = decl.uv_offset {
let ub = o + uo;
uvs.push([half(bytes, ub), half(bytes, ub + 2)]);
}
}
// Sanity gate: when the declaration has a normal element, a correctly
// aligned vertex buffer yields unit-length normals. A mean far from 1
// means the layout does not fit (wrong stride / offset) — decline
// rather than emit garbage.
if decl.normal_offset.is_some() {
let mean = normal_len_sum / vtx_count.max(1) as f32;
if !(0.5..=2.0).contains(&mean) {
return Err(MeshError::UnsupportedLayout);
}
}
meshes.push(GameMesh {
positions,
normals,
uvs,
indices,
name: None,
});
off = align16(ve) - base;
}
Ok(Xbg7Model { name, meshes })
}
/// Decode **every** locatable XBG7 geometry resource in a container.
///
/// "Stage" containers (`hidden/resource3d/Stage_*.xpr`) are collections of
/// many enemy / prop sub-models, each an independent XBG7 resource. Unlike
/// the single-stream weapon layout (index buffer immediately followed by its
/// vertex buffer), a stage's index buffers and vertex buffers live in
/// **separate grouped pools**, and the container stores each resource's
/// buffer *sizes* (index count via the marker, vertex count 32 bytes before
/// it) but **not** an explicit data offset — the on-disc block layout is a
/// separate allocation order we have not reversed.
///
/// Rather than guess that order, each resource's `[index buffer][vertex
/// buffer]` block is located by **content**: the unique offset in the data
/// section where (a) all `index_count` indices are `< vertex_count`, (b) the
/// stored normals are unit length, and (c) the resulting triangles are
/// non-degenerate with a real spatial extent. This signature is strong
/// enough to pin a block unambiguously in a multi-megabyte file. Resources
/// that cannot be located and validated this way are **skipped** (never
/// emitted as garbage) — including the biggest hero bodies, which use the
/// same quantized/complex layout that [`Xbg7Model::from_xpr2`] declines.
///
/// Returns one [`Xbg7Model`] per decoded resource (empty if none decode).
pub fn stage_models(bytes: &[u8]) -> Vec<Xbg7Model> {
let mut out = Vec::new();
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
return out;
}
let mut cur = Cursor::new(bytes);
let header = match Xpr2Header::read(&mut cur) {
Ok(h) => h,
Err(_) => return out,
};
let data_base = header.header_size as usize;
if data_base >= bytes.len() {
return out;
}
// ── Collect every XBG7 resource's parameters up front. ──
const DIR_BASE: usize = 0x10;
struct Res {
name: String,
index_count: usize,
vtx_count: usize,
decl: VertexDecl,
}
let mut resources: Vec<Res> = Vec::new();
for _ in 0..header.num_resources {
let e = match Xpr2ResourceEntry::read(&mut cur) {
Ok(e) => e,
Err(_) => break,
};
if &e.type_tag != b"XBG7" {
continue;
}
let desc = e.data_offset as usize + DIR_BASE;
let desc_end = (desc + e.descriptor_size as usize).min(bytes.len());
if desc >= bytes.len() || desc_end <= desc {
continue;
}
let d = &bytes[desc..desc_end];
let (mk_rel, index_count) = match find_index_marker(d) {
Some(m) => m,
None => continue,
};
let decl = match parse_vertex_decl(d) {
Some(v) => v,
None => continue,
};
// Total vertex count is stored 32 bytes before the index marker.
if mk_rel < 32 {
continue;
}
let vtx_count = be32(d, mk_rel - 32) as usize;
if !(3..=400_000).contains(&vtx_count) || index_count < 3 {
continue;
}
// Anchoring relies on the unit-normal signature; skip resources with
// no NORMAL element (too ambiguous to pin safely).
if decl.normal_offset.is_none() {
continue;
}
let name = read_cstr(bytes, e.name_offset as usize + DIR_BASE)
.unwrap_or_else(|| "XBG7".to_string());
resources.push(Res {
name,
index_count,
vtx_count,
decl,
});
}
if resources.is_empty() {
return out;
}
// ── One O(file) pass per distinct stride: find vertex-block *starts*. ──
//
// Each geometry block is `[12B header][index buffer][vertex buffer]`, and
// the blocks are scattered among texture data with no stored offset. But
// a vertex buffer is a run of stride-sized records whose NORMAL (f16×4 at
// +12) is unit length; a *block start* is the unique offset where that
// run begins — the previous stride slot is NOT a unit-normal vertex (it's
// index bytes / header). Collecting those starts turns the per-resource
// search from O(file) into a scan of a few hundred candidates.
let mut strides: Vec<usize> = resources.iter().map(|r| r.decl.stride).collect();
strides.sort_unstable();
strides.dedup();
let mut starts_by_stride: std::collections::BTreeMap<usize, Vec<usize>> =
std::collections::BTreeMap::new();
for &s in &strides {
starts_by_stride.insert(s, vertex_run_starts(bytes, data_base, s));
}
for r in &resources {
let starts = &starts_by_stride[&r.decl.stride];
if let Some(mesh) = anchor_pool_mesh(
bytes,
starts,
r.index_count,
r.vtx_count,
&r.decl,
) {
out.push(Xbg7Model {
name: r.name.clone(),
meshes: vec![mesh],
});
}
}
out
}
}
/// Scan the data section for offsets that begin a `stride`-sized unit-normal
/// vertex run (NORMAL is `f16×4` at vertex offset +12). A run *start* is an
/// offset whose normal is unit while the preceding stride slot's is not — i.e.
/// the first vertex of a buffer, not a mid-buffer position. Returns the sorted
/// candidate starts (block vertex-buffer offsets).
fn vertex_run_starts(bytes: &[u8], data_base: usize, stride: usize) -> Vec<usize> {
const NRM: usize = 12; // POSITION f32×3 occupies [0,12); NORMAL f16×4 follows
let mut starts = Vec::new();
if stride < NRM + 8 {
return starts;
}
let is_unit = |o: usize| -> bool {
if o + NRM + 6 > bytes.len() {
return false;
}
let nx = half(bytes, o + NRM);
let ny = half(bytes, o + NRM + 2);
let nz = half(bytes, o + NRM + 4);
let l = (nx * nx + ny * ny + nz * nz).sqrt();
(0.85..=1.15).contains(&l)
};
// Vertex buffers begin on 4-byte boundaries in practice; step 4.
let end = bytes.len().saturating_sub(NRM + 6);
let mut o = data_base;
while o <= end {
if is_unit(o) && (o < data_base + stride || !is_unit(o - stride)) {
starts.push(o);
}
o += 4;
}
starts
}
/// Locate a stage resource's `[index buffer][vertex buffer]` block among the
/// precomputed vertex-run `starts` (see [`vertex_run_starts`]) and decode it, or
/// return `None` if no candidate validates. A candidate `vb` is accepted when
/// the `index_count` indices ending just before it are all `< vtx_count`,
/// reference (nearly) all vertices, and produce non-degenerate triangles with a
/// real spatial extent — a signature strong enough to pin the block.
fn anchor_pool_mesh(
bytes: &[u8],
starts: &[usize],
index_count: usize,
vtx_count: usize,
decl: &VertexDecl,
) -> Option<GameMesh> {
let stride = decl.stride;
let idx_bytes = index_count * 2;
let vtx_bytes = vtx_count.checked_mul(stride)?;
for &vb in starts {
// The index buffer sits immediately before the vertex buffer.
if vb < idx_bytes {
continue;
}
let ib = vb - idx_bytes;
if vb + vtx_bytes > bytes.len() {
continue;
}
// ── Full index validation: every index in range, uses ~all vertices. ──
let mut max_idx = 0u32;
let mut ok = true;
for k in 0..index_count {
let i = be16(bytes, ib + k * 2) as u32;
if i >= vtx_count as u32 {
ok = false;
break;
}
max_idx = max_idx.max(i);
}
if !ok || (max_idx as usize) + 4 < vtx_count {
continue;
}
// ── Triangle quality: finite, non-degenerate, real spatial extent. ──
let pos = decl.pos_offset;
let mut lo = [f32::MAX; 3];
let mut hi = [f32::MIN; 3];
let mut degenerate = 0usize;
let mut sampled = 0usize;
let mut edge_sum = 0.0f32;
let tris = index_count / 3;
let tstep = (tris / 96).max(1);
let mut t = 0;
let mut bad = false;
while t < tris {
let mut p = [[0.0f32; 3]; 3];
for (c, pc) in p.iter_mut().enumerate() {
let vi = be16(bytes, ib + (3 * t + c) * 2) as usize;
let base = vb + vi * stride + pos;
for (a, slot) in pc.iter_mut().enumerate() {
let x = bef(bytes, base + a * 4);
if !x.is_finite() || x.abs() > 1.0e6 {
bad = true;
break;
}
*slot = x;
lo[a] = lo[a].min(x);
hi[a] = hi[a].max(x);
}
if bad {
break;
}
}
if bad {
break;
}
let u = [p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2]];
let w = [p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2]];
let cx = [
u[1] * w[2] - u[2] * w[1],
u[2] * w[0] - u[0] * w[2],
u[0] * w[1] - u[1] * w[0],
];
if 0.5 * (cx[0] * cx[0] + cx[1] * cx[1] + cx[2] * cx[2]).sqrt() < 1.0e-9 {
degenerate += 1;
}
// Sum the triangle's three edge lengths (for the connectivity check).
let e3 = [p[2][0] - p[1][0], p[2][1] - p[1][1], p[2][2] - p[1][2]];
edge_sum += (u[0] * u[0] + u[1] * u[1] + u[2] * u[2]).sqrt()
+ (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt()
+ (e3[0] * e3[0] + e3[1] * e3[1] + e3[2] * e3[2]).sqrt();
sampled += 1;
t += tstep;
}
if bad {
continue;
}
let extent = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(hi[2] - lo[2]);
if extent < 0.5 || sampled == 0 || degenerate * 10 > sampled * 3 {
continue; // too flat, or >30% degenerate → not this block
}
// Connectivity check: a correctly-anchored mesh has triangle edges that
// are SMALL relative to its overall size (~0.050.15 of the bbox
// diagonal). A wrong anchor / cross-wired index buffer connects distant
// vertices, so its mean edge spans a large fraction of the model (a spiky
// mess). Reject those — try another candidate or decline.
let diag = ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2))
.sqrt()
.max(1e-6);
let mean_edge = edge_sum / (sampled as f32 * 3.0);
if mean_edge / diag > 0.28 {
continue;
}
// ── Accepted: read the full mesh. ──
return Some(read_pool_mesh(bytes, ib, vb, index_count, vtx_count, decl));
}
None
}
/// Read positions / normals / uvs / indices for an anchored stage block.
fn read_pool_mesh(
bytes: &[u8],
ib: usize,
vb: usize,
index_count: usize,
vtx_count: usize,
decl: &VertexDecl,
) -> GameMesh {
let stride = decl.stride;
let indices: Vec<u32> = (0..index_count)
.map(|k| be16(bytes, ib + k * 2) as u32)
.collect();
let mut positions = Vec::with_capacity(vtx_count);
let mut normals = Vec::with_capacity(vtx_count);
let mut uvs = Vec::with_capacity(vtx_count);
for v in 0..vtx_count {
let o = vb + v * stride;
let p = o + decl.pos_offset;
positions.push([bef(bytes, p), bef(bytes, p + 4), bef(bytes, p + 8)]);
if let Some(no) = decl.normal_offset {
let nb = o + no;
normals.push([half(bytes, nb), half(bytes, nb + 2), half(bytes, nb + 4)]);
}
if let Some(uo) = decl.uv_offset {
let ub = o + uo;
uvs.push([half(bytes, ub), half(bytes, ub + 2)]);
}
}
GameMesh {
positions,
normals,
uvs,
indices,
name: None,
}
}
// ── Layout constants ────────────────────────────────────────────────────────
/// Size of the header that precedes each sub-mesh block's index buffer
/// (`[12-byte header][index buffer][vertex buffer]`). Contents not yet decoded.
const VERTEX_BUFFER_GAP: usize = 12;
// ── Vertex declaration ───────────────────────────────────────────────────────
/// The vertex layout for one XBG7 resource, parsed from the descriptor's
/// declaration table (shared by all its sub-meshes).
struct VertexDecl {
/// Bytes per vertex.
stride: usize,
/// Byte offset of the POSITION element (`f32×3`) within a vertex.
pos_offset: usize,
/// Byte offset of the NORMAL element (`f16×4`), if present.
normal_offset: Option<usize>,
/// Byte offset of the TEXCOORD element (`f16×2`), if present.
uv_offset: Option<usize>,
}
/// Known element format codes → element size in bytes (from the GPU capture:
/// POSITION `f32×3`, NORMAL `f16×4`, TEXCOORD `f16×2`).
fn decl_code_size(code: u32) -> Option<usize> {
match code {
0x2A_23B9 => Some(12), // f32×3 (POSITION)
0x1A_2360 => Some(8), // f16×4 (NORMAL)
0x2C_235F => Some(4), // f16×2 (TEXCOORD)
_ => None,
}
}
/// Parse the XBG7 vertex declaration: a table of `{offset:u32, code:u32,
/// usage<<16:u32}` big-endian triples that follows the `(index_bytes,
/// index_count)` marker, terminated by an `offset == 0x00FF0000` /
/// `code == 0xFFFFFFFF` sentinel. Usage codes: `0` POSITION, `3` NORMAL,
/// `5` TEXCOORD. Stride is the max element extent; unknown element sizes are
/// inferred from the next element's offset.
/// Locate the `(index_bytes, index_count)` marker in a descriptor: the first
/// big-endian pair where `index_bytes == index_count * 2` and `index_count` is a
/// positive multiple of 3. Returns `(rel_offset, index_count)`.
fn find_index_marker(desc: &[u8]) -> Option<(usize, usize)> {
let mut rel = 0usize;
while rel + 40 <= desc.len() {
let a = be32(desc, rel);
let c = be32(desc, rel + 4);
if c >= 3 && c % 3 == 0 && c < 400_000 && a == c * 2 {
return Some((rel, c as usize));
}
rel += 4;
}
None
}
fn parse_vertex_decl(desc: &[u8]) -> Option<VertexDecl> {
let mk = find_index_marker(desc)?.0;
// Read declaration triples.
let mut elems: Vec<(usize, u32, u32)> = Vec::new(); // (offset, code, usage)
let mut r = mk + 8;
for _ in 0..16 {
if r + 12 > desc.len() {
break;
}
let off = be32(desc, r);
let code = be32(desc, r + 4);
let usage = be32(desc, r + 8) >> 16;
if off == 0x00FF_0000 || code == 0xFFFF_FFFF {
break;
}
if off as usize > 0x1000 {
break; // out-of-range offset — not a real element
}
elems.push((off as usize, code & 0x00FF_FFFF, usage));
r += 12;
}
if elems.is_empty() {
return None;
}
let mut stride = 0usize;
for (i, &(off, code, _)) in elems.iter().enumerate() {
let size = decl_code_size(code).unwrap_or_else(|| {
if i + 1 < elems.len() {
elems[i + 1].0.saturating_sub(off)
} else {
4
}
});
stride = stride.max(off + size);
}
if stride == 0 || stride > 256 {
return None;
}
let pos_offset = elems
.iter()
.find(|&&(_, c, u)| c == 0x2A_23B9 || u == 0)
.map(|&(o, _, _)| o)
.unwrap_or(0);
let normal_offset = elems.iter().find(|&&(_, _, u)| u == 3).map(|&(o, _, _)| o);
let uv_offset = elems.iter().find(|&&(_, _, u)| u == 5).map(|&(o, _, _)| o);
Some(VertexDecl {
stride,
pos_offset,
normal_offset,
uv_offset,
})
}
// ── 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 /// 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)

View File

@@ -0,0 +1,234 @@
//! 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 declaration_driven_decode_covers_expected_model_count() {
let Some(dir) = res3d_dir() else {
return;
};
let mut ok = 0usize;
let mut total = 0usize;
for entry in std::fs::read_dir(&dir).unwrap() {
let path = entry.unwrap().path();
if path.extension().and_then(|e| e.to_str()) != Some("xpr") {
continue;
}
total += 1;
let bytes = std::fs::read(&path).unwrap();
if let Ok(model) = Xbg7Model::from_xpr2(&bytes) {
if !model.meshes.is_empty() {
// Every decoded model must be self-consistent (indices in range,
// and unit normals where present).
for m in &model.meshes {
assert!(
m.indices.iter().all(|&i| (i as usize) < m.positions.len()),
"{path:?}: index out of range"
);
}
ok += 1;
}
}
}
eprintln!("XBG7 declaration-driven decode: {ok}/{total} models");
// Variable-stride declaration parsing lifted coverage vs the old fixed
// stride-24 decoder (25 → 36 fully-validated models; multi-sub-mesh models
// whose later sub-mesh offset isn't yet handled are still declined whole).
assert!(ok >= 35, "coverage regressed: only {ok}/{total} decoded");
}
#[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()));
}
}
}
}
/// Stage containers decode multiple enemy/prop sub-models via content anchoring.
/// Prints coverage; asserts the known-good Stage_S10 meshes decode with sane geometry.
#[test]
#[ignore]
fn stage_models_decode() {
use sylpheed_formats::mesh::Xbg7Model;
let dir = std::env::var("SYLPHEED_RES3D").unwrap_or_else(|_| {
"/home/fabi/RE Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
});
let path = format!("{dir}/Stage_S10.xpr");
let bytes = std::fs::read(&path).expect("read Stage_S10");
let models = Xbg7Model::stage_models(&bytes);
for m in &models {
let (v, t) = m.totals();
let mut lo = [f32::MAX; 3];
let mut hi = [f32::MIN; 3];
for sub in &m.meshes {
for p in &sub.positions {
for a in 0..3 {
lo[a] = lo[a].min(p[a]);
hi[a] = hi[a].max(p[a]);
}
}
}
let ext = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]];
println!(
" {:16} v={v} t={t} bbox=[{:.1},{:.1},{:.1}]",
m.name, ext[0], ext[1], ext[2]
);
}
// Known: e003 (main enemy, ~1400 tris) must be among the decoded models.
let e003 = models.iter().find(|m| m.name == "e003").expect("e003 decoded");
let (v, t) = e003.totals();
assert_eq!(v, 2383, "e003 vertex count");
assert!(t > 1400, "e003 triangle count {t}");
assert!(models.len() >= 3, "at least 3 stage sub-models, got {}", models.len());
}
/// Timing/coverage sweep across all stage containers (manual; release recommended).
#[test]
#[ignore]
fn stage_models_sweep() {
use sylpheed_formats::mesh::Xbg7Model;
use std::time::Instant;
let dir = std::env::var("SYLPHEED_RES3D").unwrap_or_else(|_| {
"/home/fabi/RE Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
});
let mut names: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok().map(|e| e.file_name().into_string().unwrap()))
.filter(|n| n.starts_with("Stage_") && n.ends_with(".xpr"))
.collect();
names.sort();
let mut tot = 0usize;
for n in &names {
let bytes = std::fs::read(format!("{dir}/{n}")).unwrap();
let t0 = Instant::now();
let models = Xbg7Model::stage_models(&bytes);
let dt = t0.elapsed().as_millis();
tot += models.len();
println!("{n:16} {:>4} MB {:>3} models {:>5} ms", bytes.len()/1_000_000, models.len(), dt);
}
println!("TOTAL stage sub-models decoded: {tot}");
}
/// Quality audit: for a big stage, verify decoded models are real geometry
/// (bounded bbox, low full-mesh degeneracy) rather than false anchors.
#[test]
#[ignore]
fn stage_models_quality_audit() {
use sylpheed_formats::mesh::Xbg7Model;
let dir = std::env::var("SYLPHEED_RES3D").unwrap_or_else(|_| {
"/home/fabi/RE Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
});
let bytes = std::fs::read(format!("{dir}/Stage_S07.xpr")).unwrap();
let models = Xbg7Model::stage_models(&bytes);
let (mut small, mut mid, mut huge, mut dupnames) = (0, 0, 0, 0);
let mut seen = std::collections::HashSet::new();
let mut worst_deg = 0.0f32;
for m in &models {
if !seen.insert(m.name.clone()) { dupnames += 1; }
let mut lo = [f32::MAX; 3]; let mut hi = [f32::MIN; 3];
let mut deg = 0usize; let mut tot = 0usize;
for sub in &m.meshes {
for p in &sub.positions { for a in 0..3 { lo[a]=lo[a].min(p[a]); hi[a]=hi[a].max(p[a]); } }
for tri in sub.indices.chunks_exact(3) {
let (a,b,c)=(tri[0] as usize,tri[1] as usize,tri[2] as usize);
if a>=sub.positions.len()||b>=sub.positions.len()||c>=sub.positions.len(){continue;}
let pa=sub.positions[a]; let pb=sub.positions[b]; let pc=sub.positions[c];
let u=[pb[0]-pa[0],pb[1]-pa[1],pb[2]-pa[2]];
let v=[pc[0]-pa[0],pc[1]-pa[1],pc[2]-pa[2]];
let cx=[u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]];
if 0.5*(cx[0]*cx[0]+cx[1]*cx[1]+cx[2]*cx[2]).sqrt()<1e-9 { deg+=1; }
tot+=1;
}
}
let ext = (hi[0]-lo[0]).max(hi[1]-lo[1]).max(hi[2]-lo[2]);
let df = if tot>0 { deg as f32/tot as f32 } else {1.0};
worst_deg = worst_deg.max(df);
if ext < 200.0 { small += 1; } else if ext < 5000.0 { mid += 1; } else { huge += 1; }
}
println!("S07: {} models | small(<200u)={} mid={} huge(>5k)={} | dup-names={} | worst full-mesh degeneracy={:.1}%",
models.len(), small, mid, huge, dupnames, worst_deg*100.0);
// Each XBG7 resource must anchor to a *distinct* block (no collisions), the
// bulk must be bounded-scale geometry, and none may be mostly-degenerate.
assert_eq!(dupnames, 0, "no two resources should anchor to the same block");
assert!(small + mid > models.len() * 9 / 10, "≥90% bounded-scale geometry");
assert!(huge < models.len() / 20, "few huge (skybox-plane) models");
assert!(worst_deg < 0.35, "no model should be mostly-degenerate");
}

View File

@@ -32,6 +32,10 @@ pub struct OrbitCamera {
pub orbit_sensitivity: f32, pub orbit_sensitivity: f32,
pub zoom_sensitivity: f32, pub zoom_sensitivity: f32,
pub pan_sensitivity: f32, pub pan_sensitivity: f32,
/// Zoom limits — set when framing a model so large scenes can be pulled back
/// far enough (the old fixed 0.5..50 cap trapped the camera inside big stages).
pub min_radius: f32,
pub max_radius: f32,
} }
impl Default for OrbitCamera { impl Default for OrbitCamera {
@@ -44,6 +48,8 @@ impl Default for OrbitCamera {
orbit_sensitivity: 0.005, orbit_sensitivity: 0.005,
zoom_sensitivity: 0.3, zoom_sensitivity: 0.3,
pan_sensitivity: 0.003, pan_sensitivity: 0.003,
min_radius: 0.05,
max_radius: 500.0,
} }
} }
} }
@@ -54,19 +60,26 @@ fn spawn_camera(mut commands: Commands) {
commands.spawn(( commands.spawn((
Camera3d::default(), Camera3d::default(),
// Wide near/far so both tiny weapons and multi-thousand-unit stages fit;
// the planes are re-scaled to the zoom distance each frame (below).
Projection::Perspective(PerspectiveProjection {
near: 0.05,
far: 100_000.0,
..default()
}),
transform, transform,
orbit, orbit,
)); ));
} }
fn orbit_camera( fn orbit_camera(
mut query: Query<(&mut OrbitCamera, &mut Transform)>, mut query: Query<(&mut OrbitCamera, &mut Transform, &mut Projection)>,
mouse_buttons: Res<ButtonInput<MouseButton>>, mouse_buttons: Res<ButtonInput<MouseButton>>,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut mouse_motion: EventReader<MouseMotion>, mut mouse_motion: EventReader<MouseMotion>,
mut scroll: EventReader<MouseWheel>, mut scroll: EventReader<MouseWheel>,
) { ) {
let Ok((mut cam, mut transform)) = query.get_single_mut() else { return }; let Ok((mut cam, mut transform, mut projection)) = query.get_single_mut() else { return };
let mut delta_motion = Vec2::ZERO; let mut delta_motion = Vec2::ZERO;
for ev in mouse_motion.read() { for ev in mouse_motion.read() {
@@ -99,13 +112,20 @@ fn orbit_camera(
// Zoom (scroll) // Zoom (scroll)
cam.radius -= scroll_delta * cam.zoom_sensitivity * cam.radius; cam.radius -= scroll_delta * cam.zoom_sensitivity * cam.radius;
cam.radius = cam.radius.clamp(0.5, 50.0); cam.radius = cam.radius.clamp(cam.min_radius, cam.max_radius);
// Reset (R key) // Reset (R key)
if keys.just_pressed(KeyCode::KeyR) { if keys.just_pressed(KeyCode::KeyR) {
*cam = OrbitCamera::default(); *cam = OrbitCamera::default();
} }
// Keep the clip planes proportional to the zoom distance so depth precision
// stays usable across scales (tiny prop → whole stage grid).
if let Projection::Perspective(p) = projection.as_mut() {
p.near = (cam.radius * 0.02).clamp(0.02, 50.0);
p.far = (cam.radius * 50.0).max(2000.0);
}
*transform = orbit_transform(&cam); *transform = orbit_transform(&cam);
} }

View File

@@ -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,378 @@ 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;
cam.min_radius = (extent * 0.02).max(0.02);
cam.max_radius = (extent * 20.0).max(50.0);
}
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,
);
}
/// Spawn every decoded sub-model of a **stage** container as a side-by-side row
/// (a "cast sheet"): the stage's enemy / prop models are separate objects with no
/// recovered scene transforms, so each is recentred on its own origin and laid
/// out along +X, spaced by its width. The orbit camera frames the whole row.
#[cfg(not(target_arch = "wasm32"))]
#[allow(clippy::too_many_arguments)]
fn spawn_stage_models(
models: &[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;
// Per-sub-model albedo: match each model's name to its `_col` texture
// (e.g. `e003` → `e003_bdy_col`, `e005` → `e005_01_col`). Decode each once
// and cache by texture index; fall back to a neutral tint when no `_col`
// channel matches. Colours are the same "unverified" item as the 2D textures.
let tex_names = X360Texture::texture_names(xpr_bytes);
let neutral = materials.add(StandardMaterial {
base_color: Color::srgb(0.72, 0.74, 0.78),
perceptual_roughness: 0.65,
metallic: 0.1,
cull_mode: None,
double_sided: true,
..default()
});
let mut tex_cache: std::collections::HashMap<usize, Handle<StandardMaterial>> =
std::collections::HashMap::new();
let material_for = |model_name: &str,
materials: &mut Assets<StandardMaterial>,
images: &mut Assets<Image>,
cache: &mut std::collections::HashMap<usize, Handle<StandardMaterial>>|
-> Handle<StandardMaterial> {
// Core name = strip leading `_`/`rou_` and trailing `_l` LOD suffix.
let core = model_name
.trim_start_matches('_')
.trim_start_matches("rou_")
.trim_end_matches("_l");
let idx = tex_names.iter().position(|n| {
n.ends_with("_col") && (n.starts_with(core) || n.contains(core))
});
let Some(i) = idx else { return neutral.clone() };
if let Some(h) = cache.get(&i) {
return h.clone();
}
let handle = X360Texture::from_xpr2_index(xpr_bytes, i)
.ok()
.and_then(|t| crate::asset_loader::x360_texture_to_bevy_image(t).ok())
.map(|img| {
materials.add(StandardMaterial {
base_color: Color::WHITE,
base_color_texture: Some(images.add(img)),
perceptual_roughness: 0.7,
metallic: 0.1,
cull_mode: None,
double_sided: true,
..default()
})
})
.unwrap_or_else(|| neutral.clone());
cache.insert(i, handle.clone());
handle
};
let root = commands
.spawn((
PreviewModel,
Transform::default(),
Visibility::default(),
Name::new("stage"),
))
.id();
// A stage holds up to ~450 sub-models at wildly different true scales (a
// 1-unit prop next to a 3000-unit structure) with no recovered scene
// transforms. Laying them end-to-end made a mile-wide strip the camera
// couldn't escape. Instead present a **thumbnail grid**: each model is
// recentred and uniformly scaled to a fixed cell, so all are equally visible
// and browsable regardless of native size. Grid spans the XY plane.
const CELL: f32 = 10.0; // target on-screen size of each thumbnail
const GAP: f32 = 4.0;
let pitch = CELL + GAP;
// Extent above which a sub-model is a skybox plane (300 k-unit cloud quad) or
// a stray-vertex mis-anchor rather than a real object — dropped from the grid.
const MAX_EXTENT: f32 = 20_000.0;
let mut drawn: Vec<&sylpheed_formats::mesh::Xbg7Model> = Vec::new();
for model in models {
let mut lo = Vec3::splat(f32::MAX);
let mut hi = Vec3::splat(f32::MIN);
let mut has_geo = false;
for sub in &model.meshes {
if sub.positions.is_empty() || sub.indices.is_empty() {
continue;
}
has_geo = true;
for p in &sub.positions {
lo = lo.min(Vec3::from_array(*p));
hi = hi.max(Vec3::from_array(*p));
}
}
if has_geo && (hi - lo).max_element() <= MAX_EXTENT {
drawn.push(model);
}
}
let cols = (drawn.len() as f32).sqrt().ceil().max(1.0) as usize;
let mut total_v = 0usize;
let mut total_t = 0usize;
for (i, model) in drawn.iter().enumerate() {
// Per-model bbox → recentre + uniform scale to the cell.
let mut lo = Vec3::splat(f32::MAX);
let mut hi = Vec3::splat(f32::MIN);
for sub in &model.meshes {
for p in &sub.positions {
lo = lo.min(Vec3::from_array(*p));
hi = hi.max(Vec3::from_array(*p));
}
}
if lo.x > hi.x {
continue;
}
let center = (lo + hi) * 0.5;
let extent = (hi - lo).max_element().max(1e-3);
let scale = CELL / extent;
// Grid cell centre (row-major, growing down in Y).
let col = i % cols;
let row = i / cols;
let cell_pos = Vec3::new(col as f32 * pitch, -(row as f32) * pitch, 0.0);
// One entity per model carries the placement + normalising scale; its
// sub-meshes are recentred to the model's own origin underneath it.
let model_root = commands
.spawn((
PreviewModel,
Transform::from_translation(cell_pos).with_scale(Vec3::splat(scale)),
Visibility::default(),
Name::new(model.name.clone()),
))
.id();
commands.entity(root).add_child(model_root);
let material = material_for(&model.name, materials, images, &mut tex_cache);
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;
let positions: Vec<[f32; 3]> =
sub.positions.iter().map(|p| {
[p[0] - center.x, p[1] - center.y, p[2] - center.z]
}).collect();
let uvs: Vec<[f32; 2]> = if sub.uvs.len() == sub.positions.len() {
sub.uvs.clone()
} else {
vec![[0.0, 0.0]; sub.positions.len()]
};
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(model_root).add_child(child);
}
}
// Frame the whole grid.
let rows = drawn.len().div_ceil(cols);
let grid_w = cols as f32 * pitch;
let grid_h = rows as f32 * pitch;
let center = Vec3::new(grid_w * 0.5 - pitch * 0.5, -(grid_h * 0.5 - pitch * 0.5), 0.0);
let diag = (grid_w * grid_w + grid_h * grid_h).sqrt().max(CELL);
if let Ok(mut cam) = orbit.get_single_mut() {
cam.focus = center;
cam.radius = diag * 0.75;
cam.min_radius = CELL * 0.05;
cam.max_radius = diag * 3.0;
}
model_preview.active = true;
model_preview.name = "stage".to_string();
model_preview.info = format!(
"Stage container · {} sub-models (thumbnail grid, each scaled to fit) · \
{} verts · {} tris (content-anchored; hero bodies with quantized layout \
are skipped)",
drawn.len(),
total_v,
total_t,
);
}
/// 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 +1825,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 +1850,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 +1888,51 @@ 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.
// A container may decode as a single model (weapons / props, via the
// single-block layout) OR as a stage — a collection of many enemy / prop
// sub-models. A stage's *first* XBG7 is often a tiny dummy "root" node that
// the single-model path decodes into a degenerate cube, so we can't just try
// it first and stop. Decode both and keep whichever yields more geometry.
use sylpheed_formats::mesh::Xbg7Model;
let single = Xbg7Model::from_xpr2(&bytes)
.ok()
.filter(|m| !m.meshes.is_empty());
let single_verts = single.as_ref().map(|m| m.totals().0).unwrap_or(0);
let stage = Xbg7Model::stage_models(&bytes);
let stage_verts: usize = stage.iter().map(|m| m.totals().0).sum();
if !stage.is_empty() && stage_verts > single_verts {
spawn_stage_models(
&stage,
&bytes,
&mut commands,
&mut meshes,
&mut materials,
&mut images,
&mut model_preview,
&mut orbit,
);
return;
}
if let Some(model) = single {
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) {

View File

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

View File

@@ -0,0 +1,94 @@
# Handoff — XBG7 stage-container meshes, unified layout, mesh renderer (2026-07-12)
## Summary
This session extended the XBG7 mesh work from single weapons/props to the big
**stage containers**, fixed a long-standing weapon decode artifact, added a
headless mesh renderer for self-verification, and reworked the viewer's stage
presentation. Companion Xenia-Canary GPU instrumentation (the `log_draws`
draw-logger used as the ground-truth oracle) is on the `instrument-current`
branch of the Canary fork.
## What landed (Reborn — `feature/ipfb-idxd-parser`)
### 1. Stage containers decode — `Xbg7Model::stage_models`
`hidden/resource3d/Stage_S*.xpr` are **collections of up to ~450 enemy/prop
sub-models**, not single meshes. Each resource is a block
`[12-byte header][index buffer][vertex buffer]`; the index count is the
descriptor's `(index_bytes, index_count)` marker and the vertex count is the
`u32` 32 bytes before it. The blocks are **scattered among the container's
texture data with no stored offset**, so each is located by **content**: one
`O(file)` pass per stride finds vertex-buffer starts (unit NORMAL at vertex +12
whose previous stride slot isn't — a block boundary), then each resource is
pinned to the candidate where its indices validate and produce non-degenerate,
well-connected triangles. Fast (≤ ~4 s on a 70 MB stage), unambiguous.
- **Coverage: ~4993 sub-models across the 22 stages** (content-anchored).
- A **connectivity gate** (mean triangle edge ≤ 0.28× bbox diagonal) rejects
mis-anchored blocks that would render as spiky messes.
### 2. Weapon layout fix (unified with stages)
Weapons use the **same** `[12-byte header][index][vertex]` block layout. The old
decoder read the index buffer 12 bytes too early → 2 leading degenerate
triangles (the recurring "stray white triangle" artifact) + a lost tail of 6
real indices. Now the header is skipped; vertex offsets are unchanged, so
coverage stays 36/166 and the triangle lists are correct. Verified on
wep_00/03/04 via the renderer.
### 3. Headless mesh renderer — `sylpheed-cli mesh {info,render}`
A software rasterizer (orthographic, z-buffered, two-sided shading) that writes a
PNG. Lets anyone (including the assistant) verify recovered geometry without the
GUI. `--only <substr>` filters sub-models; `--yaw/--pitch/--size/--row` control
the view. Stage containers render as a normalised thumbnail grid.
### 4. Viewer UX
- Stage files show a **thumbnail grid**: each sub-model recentred + uniformly
scaled to a fixed cell (a 1-unit prop and a 3000-unit structure are equally
visible), skybox-plane / stray-anchor models (>20000 u) culled.
- Stage sub-models are **textured** by matching name → `_col` albedo.
- **Camera fixed**: zoom was hard-capped at 50 u (camera trapped inside big
stages); now zoom limits + clip planes scale to the framed model.
- Dispatch decodes both single-model and stage paths and keeps whichever yields
more geometry (fixes stages previously showing the dummy-root "black cube").
## State / how to verify
```bash
# formats + disc tests (needs the extracted disc)
SYLPHEED_RES3D=/path/to/hidden/resource3d \
cargo test -p sylpheed-formats --test mesh_disc -- --ignored --nocapture
# render any model / stage to a PNG
cargo run --release -p sylpheed-cli -- mesh render \
.../resource3d/Stage_S10.xpr /tmp/s10.png --size 1000
cargo run --release -p sylpheed-cli -- mesh render \
.../resource3d/rou_f001_wep_00.xpr /tmp/wep.png
# viewer
cargo run -p sylpheed-viewer # File → Open Directory → the extracted disc
```
## Known limitations / next steps
- **Multi-submesh main bodies with separate vertex pools** still mis-decode
(e.g. `Stage_S10` `e005`: a coherent fighter overlaid with wrong spike
triangles). `e003` works only because its two submeshes *share* one pool. Same
unsolved class as the **DeltaSaber hero body** (`f004`, still declined). Fix
needs per-submesh vertex-base RE. The LODs of these bodies (e.g. `e005_l`)
decode cleanly.
- **The per-resource data offset is not stored** — blocks are found by content.
Reversing the container's allocation order would give exact offsets + 100%
coverage but is a larger effort.
- **Texture colour correctness** remains the known "unverified" dynamic-RE item
(channel order / sRGB) for both 2D textures and model albedos.
- The viewer's stage decode runs on the main thread (~10 s on a 70 MB stage in a
debug build); move off-thread if the load hitch matters.
## Canary oracle (`instrument-current` on the Xenia-Canary fork)
`command_processor.cc::LogDrawForRE` (cvar `log_draws`) dumps each distinct
draw's primitive type, index buffer, vertex declaration, and sample vertex
positions to `xenia_re_draws.log`. Used to confirm the XBG7 layout (triangle
list, f32×3 position + f16×4 normal + f16×2 uv). Build with the memory-safe
flags (`CC=clang-19 CXX=clang++-19 CMAKE_BUILD_PARALLEL_LEVEL=3 … -j 3`); run one
emulator process at a time.

View File

@@ -14,11 +14,12 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
| name-hash (TOC keys) | ✅ | `sylpheed-formats/src/hash.rs` | Barrett-reduction hash; recovers original paths | | name-hash (TOC keys) | ✅ | `sylpheed-formats/src/hash.rs` | Barrett-reduction hash; recovers original paths |
| IDXD object/table | ✅ | `sylpheed-formats/src/idxd.rs` | self-describing; ship/weapon stats verified vs known values | | IDXD object/table | ✅ | `sylpheed-formats/src/idxd.rs` | self-describing; ship/weapon stats verified vs known values |
| XPR2 texture + cubemap | 🟡 | `sylpheed-formats/src/texture.rs` | de-tile + A8R8G8B8; **colours unverified** (dynamic item) | | XPR2 texture + cubemap | 🟡 | `sylpheed-formats/src/texture.rs` | de-tile + A8R8G8B8; **colours unverified** (dynamic item) |
| T8aD 2D texture | 🟡 | `sylpheed-formats/src/t8ad.rs` | ~85% decode; **colours unverified**; ~15% variants deferred | | T8aD 2D texture | 🟡 | `sylpheed-formats/src/t8ad.rs` | ~85% decode; **colours ✅ CONFIRMED** ([k8888](structures/texture-color-k8888.md)); ~15% variants deferred |
| RATC bundle | 🟡 | `sylpheed-formats/src/ratc.rs` | child listing confirmed; one level deep | | RATC bundle | 🟡 | `sylpheed-formats/src/ratc.rs` | child listing confirmed; one level deep |
| 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)) | weapons/props: declaration-driven variable stride (36 models), GPU-confirmed. **Stage containers: 5662 sub-models across 22 stages** via content-anchored grouped pools (`stage_models`). Quantized hero bodies (DeltaSaber `f004`) still declined |
## Functions / code paths ## Functions / code paths

View File

@@ -21,18 +21,30 @@ is sampled as **raw UNORM bytes — apply no sRGB/gamma decode.**
*Implication:* if the viewer/engine applies an sRGB→linear (or linear→sRGB) step to these, that is *Implication:* if the viewer/engine applies an sRGB→linear (or linear→sRGB) step to these, that is
wrong. Show the bytes as-is (raw RGBA8), gamma only where the game explicitly requests it. wrong. Show the bytes as-is (raw RGBA8), gamma only where the game explicitly requests it.
### ❔ HYPOTHESIS — exact source **channel order** (needs measurement, do not infer) ### ✅ CONFIRMED — on-disk order is **ARGB** (alpha first); `[a,r,g,b]→[r,g,b,a]` is correct
Our decoders currently assume on-disk **A8R8G8B8** and reorder `[a,r,g,b] → [r,g,b,a]`. Canary Settled by two independent lines that agree, without an emulator run:
proves the *host* target is R8G8B8A8 identity-swizzled, but the *guest→host* byte order also depends 1. **Measured on the disc data** — across 789 real T8aD textures (GP_HANGAR_ARSENAL, GP_MAIN_GAME_E,
on the texture's runtime **endian** field (e.g. `8in32`), which is set by the game at fetch time and DefTables), the byte position that is most-often exactly `0xFF` (the opaque-alpha signature of UI
**cannot be read statically** from the format table. So whether our reorder matches is unverified. art) is **byte 0 in 767/789 (97%)** — tf1 385/0, tf2 382/12. So texel byte 0 = alpha ⇒ on-disk
*What would confirm/refute it:* one **measured** decoded texel — either capture Canary's layout is **A,R,G,B**. (An earlier bimodality test tied byte0/byte3 only because colour channels
framebuffer over a texture with distinct R≠G≠B pixels of known colour, or patch Canary's already- are also `0x00`-heavy from black backgrounds; the `==0xFF` discriminator isolates alpha cleanly.)
present-but-unwired `TextureDump` (`src/xenia/gpu/texture_dump.cc`) to dump the decoded texture and 2. **Cross-check vs our Canary-validated renderer**`xenia-rs`'s `decode_k8888_tiled` (the path
diff bytes against our decode. **Measure, don't guess** — a symmetric colour (white logo) can't behind the user-confirmed M1 splash / M2 video colours) nets memory-`ARGB` → endian-swap →
disambiguate channel order. `swap(0,2)` = `[A,R,G,B]→[R,G,B,A]`, identical to ours. Since that renderer was confirmed against
Canary's output, Canary's ground truth is transitively in this chain.
Net: our channel order and the linear/UNORM interpretation are both **correct** for the ~85% RGBA
variants. A live Canary framebuffer capture would be redundant confirmation, not a new signal.
## Remaining / adjacent
- **XPR2** shares the ARGB→RGBA reorder + the linear/UNORM finding, but its **tiling** (de-tile) is a
separate path; if XPR2 skyboxes still look odd it's tiling or viewer display-gamma, not channel order.
- **Viewer display gamma:** decode is correct; if egui shows these too dark/bright it's how egui
interprets the RGBA8 (sRGB-encoded vs linear) at display time — a rendering nit, not a decode bug.
## Evidence log ## Evidence log
- `2026-07-11` — static read of Canary `src/xenia/gpu/vulkan/vulkan_texture_cache.cc` host-format - `2026-07-11` — static read of Canary `vulkan_texture_cache.cc` host-format table; no `R8G8B8A8_SRGB`
table + confirmed no `R8G8B8A8_SRGB` entry exists. → sRGB/UNORM claim `CONFIRMED`; channel-order entry exists → sRGB/UNORM claim `CONFIRMED`.
claim remains `HYPOTHESIS` pending a measured texel. - `2026-07-11` — measured `==0xFF` alpha dominance over 789 disc T8aD textures (byte0 = alpha 97%) +
cross-checked vs `xenia-rs decode_k8888_tiled` (the Canary-validated M1/M2 path). → channel-order
claim promoted `HYPOTHESIS → CONFIRMED`.

View File

@@ -0,0 +1,183 @@
# 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, GPU-cross-checked)
For 36 of the 166 models (weapons, simple props) the data section is a straight sequence of
sub-meshes, carved from `header_size` in record order:
```
per sub-mesh block:
[ 12-byte header (contents undecoded) ]
[ index buffer : idx_count × u16 BE ] triangle list (prim=4, GPU-confirmed)
[ vertex buffer : vtx_count × stride bytes ] ← declaration-driven
(pad to 16 bytes → next sub-mesh)
```
The 12-byte header precedes the **index** buffer (the same block shape as stage
resources — see below); the vertex buffer follows the indices with no further
gap. (Earlier this was mis-modelled as `[index][12-byte gap][vertex]`, which put
the vertex buffer at the identical offset but read the index buffer 12 bytes too
early — turning the 12 header bytes into 6 junk indices = **2 leading degenerate
triangles** (a stray-triangle artifact) and dropping the last 6 real indices.
Skipping the header fixes the triangle list with no change to vertex coverage.)
**Vertex declaration.** The layout is **not fixed-stride**. The descriptor holds a declaration table
(right after the `(index_bytes, index_count)` marker) of `{offset:u32, format-code:u32, usage<<16:u32}`
big-endian triples, terminated by `offset == 0x00FF0000` / `code == 0xFFFFFFFF`:
| usage | element | format code | format | size |
|-------|----------|-------------|--------|------|
| 0x00 | POSITION | `0x2A23B9` | f32×3 | 12 B |
| 0x03 | NORMAL | `0x1A2360` | f16×4 (use xyz) | 8 B |
| 0x05 | TEXCOORD | `0x2C235F` | f16×2 (u,v) | 4 B |
Stride = max element extent. Models **omit elements** → variable stride (20 = pos+normal, no UV;
24 = pos+normal+uv). Each element is read in **naive big-endian component order** (the raw file
bytes; see the endianness note below). Assuming a fixed stride-24 was why the old decoder mis-aligned
and declined the pos+normal-only models.
**Alignment 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). The correct offset is the unique
one where recovered normals are exactly unit-length.
**Safety gate:** every index is validated `< vtx_count`, and when the declaration has a normal
element the mean recovered `|normal|` must be ≈1 (`[0.5, 2.0]`). A model failing either is rejected
(`MeshError::UnsupportedLayout`) rather than emitting garbage.
### Endianness — file bytes are naive-BE, `k8in32` is a red herring ✅
The Canary GPU capture reports every vertex stream with fetch `endian = 2` (`k8in32`, each 32-bit
word byte-reversed). This describes the **guest-memory** copy the GPU fetches — **not** the `.xpr`
file bytes. Reading the file with a `k8in32` transform breaks the normals (mean `|normal|` → 1.33);
the plain per-element big-endian read yields exactly unit normals. So the game **rearranges** the
vertex data between the on-disc `.xpr` and the uploaded buffer; the decoder reads the file directly
and must use naive BE.
## Stage containers — multi-resource, grouped pools ✅ (content-anchored)
`hidden/resource3d/Stage_S*.xpr` are not single models but **collections of
enemy / prop sub-models** — up to ~400 `XBG7` resources each (e.g. `Stage_S07` =
378). Their data layout differs from the weapon files:
- Each resource is a block `[12-byte header][index buffer][vertex buffer]`.
Unlike weapons there is **no 12-byte gap between index and vertex** — the
vertex buffer directly follows the indices.
- The **index count** is the descriptor's `(index_bytes, index_count)` marker
(the *total* for the resource — a resource may have several sub-meshes summing
to it), **not** the first sub-mesh record. The **vertex count** is a `u32`
stored **32 bytes before** the marker.
- The blocks are **scattered through the data section, interleaved with the
container's texture data**, in an allocation order that is **not** directory
order and is **not stored** in any descriptor field we could find (the
descriptor holds sizes — `rel 160 ≈ index_bytes+3`, `rel 164 =
0x1000_0000 | (vertex_bytes+2)` — but no data offset). Reconstructing that
allocation order is unsolved.
Because the offset is not stored, each resource's block is located by
**content**: parser `Xbg7Model::stage_models` does one `O(file)` pass per
distinct stride to find every **vertex-buffer start** (an offset whose NORMAL —
`f16×4` at vertex `+12` — is unit length while the *previous* stride slot's is
not, i.e. a run boundary; ~one candidate per block, not millions), then pins each
resource to the unique candidate where the `index_count` indices ending just
before it are all `< vertex_count`, reference nearly all vertices, and yield
non-degenerate triangles with real extent. This is fast (≤ ~2 s on a 70 MB
stage) and unambiguous (no two resources collide). Blocks that fail validation —
the few quantized hero bodies — are **skipped**, never emitted as garbage.
**Coverage:** 5662 sub-models decode across the 22 stage files (e.g. `Stage_S07`
366/378, `Stage_S10` 7/9 — including the main enemy bodies `e003`/`e005`, their
LODs, weapons, and props). The viewer (`spawn_stage_models`) lays the decoded
sub-models out as a side-by-side "cast sheet", skipping the few huge skybox-plane
resources (300 k-unit quads). Cross-checked: `e003` = 2383 v / 1436 t bbox
23.5×9.5×32.5; `e005` = 2566 v / 1507 t; both 0 degenerate.
## Not yet decoded — ❔ the complex body layout
The hero-ship *body* meshes (`DeltaSaber_A/_T/_W.xpr` resource `f004`, and ~100 other models) still
decline. Two open sub-problems: (1) **multi-sub-mesh models** whose *first* sub-mesh decodes but a
later one's inter-mesh offset isn't yet handled (the `align16` advance is a guess) — these are
declined whole; (2) the big body meshes, where the data section does not start with an index buffer
and the geometry sits at descriptor-addressed offsets. NB the GPU capture showed **every** rendered
mesh is *single-stream* (just wider strides, e.g. 44 bytes = pos + f32×3 + colour + 2×f16×4), so the
body is likely single-stream-with-a-richer-declaration rather than the "separate streams" first
guessed — it was simply not rendered in the captured session (menu only). A capture taken *in a
mission* (where `DeltaSaber` renders) would hand over its exact declaration directly. `DeltaSaber_A`'s
5 XBG7 blocks are `f004` (body) + `_rou_f004_mnv01_L/_R`, `_mnv02`, `_turn180` (maneuver / pose).
## 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 (DYNAMIC) — added a cvar-gated draw logger to Canary
(`command_processor.cc::LogDrawForRE`, cvar `log_draws`), captured the Ready Room / Briefings.
**GPU ground truth confirmed the static layout exactly**: primitive `prim=4` = **triangle LIST**
(settles the list-vs-strip question), and a stream with `f32x3 @offset0` + `f16x4 @3dw` +
`f16x2 @5dw`, stride 6 dwords = 24 bytes — matching `POSITION@0, NORMAL@0x0C, TEXCOORD@0x14`.
Revealed **stride varies** (24, 20, 28, 44 …) and that all streams are **single-stream**
parsed the declaration for variable stride: coverage **25 → 36** (e.g. `wep_05` is pos+normal,
stride 20, no UV — previously mis-aligned). Also confirmed the endianness note above: fetch
`endian=2` (k8in32) is the *guest* copy; file stays naive-BE. `Stage_S*` now decode (stride 20).
- 2026-07-12 (STAGE) — `Stage_S*.xpr` decoded as multi-resource containers. Found each geometry
block is `[12B hdr][index buffer][vertex buffer]`, index count = descriptor marker (total, e.g.
`e003` = 4308 spanning 2 sub-meshes), vertex count = `u32` at marker32 (`e003` = 2383 → verified
by max-index 2382 and 0 degenerate tris, bbox 23.5×9.5×32.5). Blocks are scattered among texture
data with **no stored offset** (descriptor rel 160 = idx_bytes+3, rel 164 = `0x1000_0000 |
(vtx_bytes+2)` are sizes, not offsets; block starts e.g. `e003`@0x10230, `e003_l`@0x5d000,
`e005_l`@0x9b000 are not directory-ordered). Solved by **content anchoring**: a single per-stride
pass finds vertex-run starts (unit NORMAL at +12 whose previous slot isn't), then match each
resource by strict index+triangle validation. **5662 sub-models decode across 22 stages** (S07
366/378), incl. the previously-declined main bodies `e005` (2566 v) and weapons — ≤2 s on 70 MB.
Parser `Xbg7Model::stage_models`, tests `stage_models_{decode,sweep,quality_audit}`.
- 2026-07-12 — `DeltaSaber_A.xpr` body: data does **not** begin with indices; plain-`f32×3` runs
with ship-scale extent (span ≈2734, matching bbox 30.0) found only at high offsets
(`data+0x28634C`, …) → multi-stream, **undecoded**.