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>
This commit is contained in:
@@ -66,6 +66,42 @@ fn weapon_model_decodes_to_expected_geometry() {
|
||||
);
|
||||
}
|
||||
|
||||
#[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() {
|
||||
|
||||
Reference in New Issue
Block a user