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