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

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

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

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

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

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

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

View File

@@ -221,6 +221,21 @@ pub struct SkyboxPreview {
pub info: String,
}
/// Marker for entities spawned to preview an XBG7 3D model, so they can all be
/// despawned when a new file is selected.
#[derive(Component)]
pub struct PreviewModel;
/// The currently-previewed XBG7 3D model. When `active`, the central panel is
/// drawn transparent so the real Bevy scene (mesh + orbit camera) shows through.
#[derive(Resource, Default)]
pub struct ModelPreview {
pub active: bool,
pub name: String,
/// Summary line: sub-meshes / vertices / triangles / texture.
pub info: String,
}
/// The currently-open `.wmv` cutscene, shown in the central panel with a
/// transport bar. Registered unconditionally (the decode/audio engine below is
/// native-only); on wasm `active` stays false and `.wmv` shows the info panel.
@@ -459,6 +474,7 @@ impl Plugin for IsoLoaderPlugin {
.init_resource::<TextPreview>()
.init_resource::<PakView>()
.init_resource::<SkyboxPreview>()
.init_resource::<ModelPreview>()
.init_resource::<VideoPreview>();
#[cfg(not(target_arch = "wasm32"))]
@@ -1423,6 +1439,159 @@ fn free_video(
*video = VideoPreview::default();
}
/// Build Bevy meshes from a decoded [`Xbg7Model`], texture them with the model's
/// albedo (`_col`) map, spawn them into the scene tagged [`PreviewModel`], and
/// frame the orbit camera on the combined bounding box.
#[cfg(not(target_arch = "wasm32"))]
#[allow(clippy::too_many_arguments)]
fn spawn_model_preview(
model: &sylpheed_formats::mesh::Xbg7Model,
xpr_bytes: &[u8],
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
images: &mut Assets<Image>,
model_preview: &mut ModelPreview,
orbit: &mut Query<&mut crate::camera::OrbitCamera>,
) {
use bevy::render::mesh::{Indices, PrimitiveTopology};
use sylpheed_formats::texture::X360Texture;
// ── Albedo texture: prefer the `_col` map, else the first texture. ──
let names = X360Texture::texture_names(xpr_bytes);
let col_idx = names
.iter()
.position(|n| n.ends_with("_col"))
.or(if names.is_empty() { None } else { Some(0) });
let (base_color_texture, tex_label) = match col_idx {
Some(i) => match X360Texture::from_xpr2_index(xpr_bytes, i)
.ok()
.and_then(|t| crate::asset_loader::x360_texture_to_bevy_image(t).ok())
{
Some(img) => (Some(images.add(img)), names[i].clone()),
None => (None, "none".to_string()),
},
None => (None, "none".to_string()),
};
let material = materials.add(StandardMaterial {
base_color: Color::srgb(0.8, 0.8, 0.82),
base_color_texture,
perceptual_roughness: 0.7,
metallic: 0.1,
// Xbox winding / our handedness may disagree — show both sides so a
// model is never invisible from the "back".
cull_mode: None,
double_sided: true,
..default()
});
// ── Combined bounding box for camera framing. ──
let mut lo = Vec3::splat(f32::MAX);
let mut hi = Vec3::splat(f32::MIN);
// Parent entity so the whole model can be despawned / transformed as one.
let root = commands
.spawn((
PreviewModel,
Transform::default(),
Visibility::default(),
Name::new(model.name.clone()),
))
.id();
let mut total_v = 0usize;
let mut total_t = 0usize;
for sub in &model.meshes {
if sub.positions.is_empty() || sub.indices.is_empty() {
continue;
}
total_v += sub.positions.len();
total_t += sub.indices.len() / 3;
for p in &sub.positions {
lo = lo.min(Vec3::from_array(*p));
hi = hi.max(Vec3::from_array(*p));
}
let positions: Vec<[f32; 3]> = sub.positions.clone();
let uvs: Vec<[f32; 2]> = if sub.uvs.len() == sub.positions.len() {
sub.uvs.clone()
} else {
vec![[0.0, 0.0]; sub.positions.len()]
};
// Prefer the model's own normals; fall back to computed smooth normals.
let normals = if sub.normals.len() == sub.positions.len() {
sub.normals.clone()
} else {
compute_smooth_normals(&positions, &sub.indices)
};
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
mesh.insert_indices(Indices::U32(sub.indices.clone()));
let child = commands
.spawn((
PreviewModel,
Mesh3d(meshes.add(mesh)),
MeshMaterial3d(material.clone()),
Transform::default(),
))
.id();
commands.entity(root).add_child(child);
}
// ── Frame the orbit camera on the model. ──
let center = if lo.x <= hi.x { (lo + hi) * 0.5 } else { Vec3::ZERO };
let extent = if lo.x <= hi.x {
(hi - lo).length().max(0.001)
} else {
5.0
};
if let Ok(mut cam) = orbit.get_single_mut() {
cam.focus = center;
cam.radius = extent * 1.4;
}
model_preview.active = true;
model_preview.name = model.name.clone();
model_preview.info = format!(
"XBG7 model · {} sub-mesh(es) · {} verts · {} tris · albedo: {}",
model.meshes.len(),
total_v,
total_t,
tex_label,
);
}
/// Per-vertex smooth normals = normalized sum of incident face normals.
#[cfg(not(target_arch = "wasm32"))]
fn compute_smooth_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> {
let mut acc = vec![Vec3::ZERO; positions.len()];
for tri in indices.chunks_exact(3) {
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
if a >= positions.len() || b >= positions.len() || c >= positions.len() {
continue;
}
let pa = Vec3::from_array(positions[a]);
let pb = Vec3::from_array(positions[b]);
let pc = Vec3::from_array(positions[c]);
let n = (pb - pa).cross(pc - pa);
acc[a] += n;
acc[b] += n;
acc[c] += n;
}
acc.into_iter()
.map(|n| n.normalize_or_zero().to_array())
.map(|n| if n == [0.0, 0.0, 0.0] { [0.0, 1.0, 0.0] } else { n })
.collect()
}
/// Converts pending raw bytes into a Bevy `Image`, registers it with egui,
/// and populates `TexturePreview` / `SkyboxPreview` / `FileInfo`.
#[cfg(not(target_arch = "wasm32"))]
@@ -1437,6 +1606,12 @@ fn apply_loaded_texture(
mut file_info: ResMut<FileInfo>,
mut images: ResMut<Assets<Image>>,
mut contexts: bevy_egui::EguiContexts,
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut model_preview: ResMut<ModelPreview>,
old_models: Query<Entity, With<PreviewModel>>,
mut orbit: Query<&mut crate::camera::OrbitCamera>,
) {
if !pending.ready {
return;
@@ -1456,6 +1631,12 @@ fn apply_loaded_texture(
*text_preview = TextPreview::default();
*pak_view = PakView::default();
// Despawn any previously-previewed 3D model.
for e in old_models.iter() {
commands.entity(e).despawn_recursive();
}
*model_preview = ModelPreview::default();
// Populate FileInfo for the status / info panel.
file_info.name = path
.split('/')
@@ -1488,6 +1669,26 @@ fn apply_loaded_texture(
return; // Not a texture — FileInfo is sufficient
}
// 3D model? XPR2 containers that hold XBG7 geometry (ship / weapon / prop
// models under `hidden/resource3d/`) preview as an orbitable mesh, textured
// with their albedo (`_col`) map. Complex multi-stream body meshes decline
// cleanly (Err) and fall through to the 2D texture preview below.
if let Ok(model) = sylpheed_formats::mesh::Xbg7Model::from_xpr2(&bytes) {
if !model.meshes.is_empty() {
spawn_model_preview(
&model,
&bytes,
&mut commands,
&mut meshes,
&mut materials,
&mut images,
&mut model_preview,
&mut orbit,
);
return;
}
}
// World cubemap (`TXCM`) → decode all 6 faces and show a labelled grid.
// Returns `None` for ordinary 2D textures, which fall through below.
match sylpheed_formats::texture::X360Texture::cube_faces_from_xpr2(&bytes) {

View File

@@ -10,7 +10,7 @@ use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts};
use crate::iso_loader::{
FileInfo, FileSelected, ImageRgba, IsoState, PakContent, PakView, RequestOpenDir,
FileInfo, FileSelected, ImageRgba, IsoState, ModelPreview, PakContent, PakView, RequestOpenDir,
RequestOpenIso, SkyboxPreview, TextPreview, TexturePreview, VideoPreview, IsoLoaderSystemSet,
};
use crate::ViewerState;
@@ -132,6 +132,7 @@ fn draw_viewer_ui(
text_preview: Res<TextPreview>,
mut pak_view: ResMut<PakView>,
skybox: Res<SkyboxPreview>,
model: Res<ModelPreview>,
mut video: ResMut<VideoPreview>,
file_info: Res<FileInfo>,
mut open_iso_events: EventWriter<RequestOpenIso>,
@@ -251,7 +252,30 @@ fn draw_viewer_ui(
});
// ── Central area ──────────────────────────────────────────────────────
if browser.selected.is_some() {
if browser.selected.is_some() && model.active && !browser.loading {
// 3D model: draw the central panel TRANSPARENT so the real Bevy scene
// (mesh + orbit camera) shows through, with just an info overlay on top.
egui::CentralPanel::default()
.frame(egui::Frame::none())
.show(ctx, |ui| {
egui::Frame::none()
.fill(egui::Color32::from_black_alpha(160))
.inner_margin(egui::Margin::same(8.0))
.rounding(egui::Rounding::same(4.0))
.show(ui, |ui| {
ui.heading(&model.name);
ui.label(&model.info);
ui.colored_label(
egui::Color32::from_gray(160),
"3D preview · LMB orbit · RMB pan · scroll zoom · R reset",
);
ui.colored_label(
egui::Color32::from_gray(140),
"position + normal + UV from the XBG7 vertex declaration",
);
});
});
} else if browser.selected.is_some() {
egui::CentralPanel::default().show(ctx, |ui| {
if browser.loading {
// A file was just clicked — show immediate feedback while the