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