feat(formats,cli): decode XBG7 stage containers + fix weapon layout
Stage containers (hidden/resource3d/Stage_S*.xpr) are collections of up to
~450 enemy/prop sub-models, not single meshes. Xbg7Model::stage_models decodes
them: each resource is a [12-byte header][index buffer][vertex buffer] block
(index count = descriptor marker, vertex count = u32 32 bytes before it) whose
on-disc offset is NOT stored, so it is located by content — one O(file) pass per
stride finds vertex-buffer starts (unit NORMAL at +12 whose previous slot isn't)
and each resource is pinned to the candidate whose indices validate and produce
non-degenerate, well-connected triangles. A connectivity gate (mean triangle
edge <= 0.28x the bbox diagonal) rejects spiky mis-anchors. ~4993 sub-models
decode across the 22 stages.
The same insight fixes the weapon single-model layout: it is [12-byte header]
[index][vertex] too, not [index][12-byte gap][vertex]. Reading indices from the
block start turned the 12 header bytes into 6 junk indices (2 leading degenerate
triangles — the recurring stray-triangle artifact) and dropped the last 6 real
indices. Skipping the header leaves vertex offsets identical (coverage unchanged
at 36/166) and corrects the triangle list. Verified on wep_00/03/04.
Adds `sylpheed-cli mesh {info,render}` — a headless software rasterizer that
writes a shaded PNG (orthographic, z-buffered, two-sided), so recovered geometry
can be verified without the GUI. Stages render as a normalised thumbnail grid;
--only filters sub-models.
Tests: stage_models_{decode,sweep,quality_audit}; docs/re updated (xbg7-mesh.md
evidence log + INDEX.md).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -121,3 +121,114 @@ fn complex_body_mesh_is_declined_not_garbage() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage containers decode multiple enemy/prop sub-models via content anchoring.
|
||||
/// Prints coverage; asserts the known-good Stage_S10 meshes decode with sane geometry.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn stage_models_decode() {
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
let dir = std::env::var("SYLPHEED_RES3D").unwrap_or_else(|_| {
|
||||
"/home/fabi/RE Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
|
||||
});
|
||||
let path = format!("{dir}/Stage_S10.xpr");
|
||||
let bytes = std::fs::read(&path).expect("read Stage_S10");
|
||||
let models = Xbg7Model::stage_models(&bytes);
|
||||
for m in &models {
|
||||
let (v, t) = m.totals();
|
||||
let mut lo = [f32::MAX; 3];
|
||||
let mut hi = [f32::MIN; 3];
|
||||
for sub in &m.meshes {
|
||||
for p in &sub.positions {
|
||||
for a in 0..3 {
|
||||
lo[a] = lo[a].min(p[a]);
|
||||
hi[a] = hi[a].max(p[a]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let ext = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]];
|
||||
println!(
|
||||
" {:16} v={v} t={t} bbox=[{:.1},{:.1},{:.1}]",
|
||||
m.name, ext[0], ext[1], ext[2]
|
||||
);
|
||||
}
|
||||
// Known: e003 (main enemy, ~1400 tris) must be among the decoded models.
|
||||
let e003 = models.iter().find(|m| m.name == "e003").expect("e003 decoded");
|
||||
let (v, t) = e003.totals();
|
||||
assert_eq!(v, 2383, "e003 vertex count");
|
||||
assert!(t > 1400, "e003 triangle count {t}");
|
||||
assert!(models.len() >= 3, "at least 3 stage sub-models, got {}", models.len());
|
||||
}
|
||||
|
||||
/// Timing/coverage sweep across all stage containers (manual; release recommended).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn stage_models_sweep() {
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
use std::time::Instant;
|
||||
let dir = std::env::var("SYLPHEED_RES3D").unwrap_or_else(|_| {
|
||||
"/home/fabi/RE Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
|
||||
});
|
||||
let mut names: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok().map(|e| e.file_name().into_string().unwrap()))
|
||||
.filter(|n| n.starts_with("Stage_") && n.ends_with(".xpr"))
|
||||
.collect();
|
||||
names.sort();
|
||||
let mut tot = 0usize;
|
||||
for n in &names {
|
||||
let bytes = std::fs::read(format!("{dir}/{n}")).unwrap();
|
||||
let t0 = Instant::now();
|
||||
let models = Xbg7Model::stage_models(&bytes);
|
||||
let dt = t0.elapsed().as_millis();
|
||||
tot += models.len();
|
||||
println!("{n:16} {:>4} MB {:>3} models {:>5} ms", bytes.len()/1_000_000, models.len(), dt);
|
||||
}
|
||||
println!("TOTAL stage sub-models decoded: {tot}");
|
||||
}
|
||||
|
||||
/// Quality audit: for a big stage, verify decoded models are real geometry
|
||||
/// (bounded bbox, low full-mesh degeneracy) rather than false anchors.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn stage_models_quality_audit() {
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
let dir = std::env::var("SYLPHEED_RES3D").unwrap_or_else(|_| {
|
||||
"/home/fabi/RE Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
|
||||
});
|
||||
let bytes = std::fs::read(format!("{dir}/Stage_S07.xpr")).unwrap();
|
||||
let models = Xbg7Model::stage_models(&bytes);
|
||||
let (mut small, mut mid, mut huge, mut dupnames) = (0, 0, 0, 0);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut worst_deg = 0.0f32;
|
||||
for m in &models {
|
||||
if !seen.insert(m.name.clone()) { dupnames += 1; }
|
||||
let mut lo = [f32::MAX; 3]; let mut hi = [f32::MIN; 3];
|
||||
let mut deg = 0usize; let mut tot = 0usize;
|
||||
for sub in &m.meshes {
|
||||
for p in &sub.positions { for a in 0..3 { lo[a]=lo[a].min(p[a]); hi[a]=hi[a].max(p[a]); } }
|
||||
for tri in sub.indices.chunks_exact(3) {
|
||||
let (a,b,c)=(tri[0] as usize,tri[1] as usize,tri[2] as usize);
|
||||
if a>=sub.positions.len()||b>=sub.positions.len()||c>=sub.positions.len(){continue;}
|
||||
let pa=sub.positions[a]; let pb=sub.positions[b]; let pc=sub.positions[c];
|
||||
let u=[pb[0]-pa[0],pb[1]-pa[1],pb[2]-pa[2]];
|
||||
let v=[pc[0]-pa[0],pc[1]-pa[1],pc[2]-pa[2]];
|
||||
let cx=[u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]];
|
||||
if 0.5*(cx[0]*cx[0]+cx[1]*cx[1]+cx[2]*cx[2]).sqrt()<1e-9 { deg+=1; }
|
||||
tot+=1;
|
||||
}
|
||||
}
|
||||
let ext = (hi[0]-lo[0]).max(hi[1]-lo[1]).max(hi[2]-lo[2]);
|
||||
let df = if tot>0 { deg as f32/tot as f32 } else {1.0};
|
||||
worst_deg = worst_deg.max(df);
|
||||
if ext < 200.0 { small += 1; } else if ext < 5000.0 { mid += 1; } else { huge += 1; }
|
||||
}
|
||||
println!("S07: {} models | small(<200u)={} mid={} huge(>5k)={} | dup-names={} | worst full-mesh degeneracy={:.1}%",
|
||||
models.len(), small, mid, huge, dupnames, worst_deg*100.0);
|
||||
// Each XBG7 resource must anchor to a *distinct* block (no collisions), the
|
||||
// bulk must be bounded-scale geometry, and none may be mostly-degenerate.
|
||||
assert_eq!(dupnames, 0, "no two resources should anchor to the same block");
|
||||
assert!(small + mid > models.len() * 9 / 10, "≥90% bounded-scale geometry");
|
||||
assert!(huge < models.len() / 20, "few huge (skybox-plane) models");
|
||||
assert!(worst_deg < 0.35, "no model should be mostly-degenerate");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user