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