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:
@@ -97,6 +97,12 @@ enum Commands {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
cmd: PakCommands,
|
cmd: PakCommands,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// XBG7 mesh tools (inspect / headless render to PNG)
|
||||||
|
Mesh {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: MeshCommands,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
@@ -118,6 +124,37 @@ enum PakCommands {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum MeshCommands {
|
||||||
|
/// Print the decoded sub-models of an XBG7 container (`.xpr`)
|
||||||
|
Info {
|
||||||
|
/// Path to the `.xpr` model / stage container
|
||||||
|
file: PathBuf,
|
||||||
|
},
|
||||||
|
/// Headless-render the decoded mesh(es) to a shaded PNG (software rasterizer)
|
||||||
|
Render {
|
||||||
|
/// Path to the `.xpr` model / stage container
|
||||||
|
file: PathBuf,
|
||||||
|
/// Output PNG path
|
||||||
|
output: PathBuf,
|
||||||
|
/// Image size in pixels (square)
|
||||||
|
#[arg(long, default_value_t = 900)]
|
||||||
|
size: u32,
|
||||||
|
/// Camera yaw in degrees
|
||||||
|
#[arg(long, default_value_t = 35.0)]
|
||||||
|
yaw: f32,
|
||||||
|
/// Camera pitch in degrees
|
||||||
|
#[arg(long, default_value_t = 22.0)]
|
||||||
|
pitch: f32,
|
||||||
|
/// Force the stage grid layout even for single models
|
||||||
|
#[arg(long)]
|
||||||
|
row: bool,
|
||||||
|
/// Only render sub-models whose name contains this substring
|
||||||
|
#[arg(long)]
|
||||||
|
only: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum TextureCommands {
|
enum TextureCommands {
|
||||||
/// Print information about a texture file
|
/// Print information about a texture file
|
||||||
@@ -155,6 +192,12 @@ async fn main() -> Result<()> {
|
|||||||
TextureCommands::Info { file } => cmd_texture_info(&file),
|
TextureCommands::Info { file } => cmd_texture_info(&file),
|
||||||
TextureCommands::Export { file, output } => cmd_texture_export(&file, &output),
|
TextureCommands::Export { file, output } => cmd_texture_export(&file, &output),
|
||||||
},
|
},
|
||||||
|
Commands::Mesh { cmd } => match cmd {
|
||||||
|
MeshCommands::Info { file } => cmd_mesh_info(&file),
|
||||||
|
MeshCommands::Render { file, output, size, yaw, pitch, row, only } => {
|
||||||
|
cmd_mesh_render(&file, &output, size, yaw, pitch, row, only)
|
||||||
|
}
|
||||||
|
},
|
||||||
Commands::Pak { cmd } => match cmd {
|
Commands::Pak { cmd } => match cmd {
|
||||||
PakCommands::List { pak, idxd_only } => cmd_pak_list(&pak, idxd_only),
|
PakCommands::List { pak, idxd_only } => cmd_pak_list(&pak, idxd_only),
|
||||||
PakCommands::Dump { pak, hash } => cmd_pak_dump(&pak, &hash),
|
PakCommands::Dump { pak, hash } => cmd_pak_dump(&pak, &hash),
|
||||||
@@ -357,6 +400,273 @@ fn cmd_texture_export(file: &Path, output: &Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── mesh info / render ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Decode a container and return its sub-models the same way the viewer routes:
|
||||||
|
/// a single model (weapon / prop) OR a stage's many sub-models — whichever
|
||||||
|
/// yields more geometry.
|
||||||
|
fn decode_models(bytes: &[u8]) -> Vec<sylpheed_formats::mesh::Xbg7Model> {
|
||||||
|
use sylpheed_formats::mesh::Xbg7Model;
|
||||||
|
let single = Xbg7Model::from_xpr2(bytes)
|
||||||
|
.ok()
|
||||||
|
.filter(|m| !m.meshes.is_empty());
|
||||||
|
let single_verts = single.as_ref().map(|m| m.totals().0).unwrap_or(0);
|
||||||
|
let stage = Xbg7Model::stage_models(bytes);
|
||||||
|
let stage_verts: usize = stage.iter().map(|m| m.totals().0).sum();
|
||||||
|
if !stage.is_empty() && stage_verts > single_verts {
|
||||||
|
stage
|
||||||
|
} else {
|
||||||
|
single.into_iter().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_mesh_info(file: &Path) -> Result<()> {
|
||||||
|
let bytes = std::fs::read(file).with_context(|| format!("Cannot read {}", file.display()))?;
|
||||||
|
let models = decode_models(&bytes);
|
||||||
|
if models.is_empty() {
|
||||||
|
println!("{} no decodable XBG7 geometry", "Mesh:".yellow().bold());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let (mut tv, mut tt) = (0usize, 0usize);
|
||||||
|
println!("{} {}", "Mesh:".green().bold(), file.display());
|
||||||
|
for m in &models {
|
||||||
|
let (v, t) = m.totals();
|
||||||
|
tv += v;
|
||||||
|
tt += t;
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
" {:16} {:>6} v {:>6} t bbox [{:.1} {:.1} {:.1}]",
|
||||||
|
m.name,
|
||||||
|
v,
|
||||||
|
t,
|
||||||
|
hi[0] - lo[0],
|
||||||
|
hi[1] - lo[1],
|
||||||
|
hi[2] - lo[2]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
" {} {} sub-models · {} verts · {} tris",
|
||||||
|
"TOTAL".bold(),
|
||||||
|
models.len(),
|
||||||
|
tv,
|
||||||
|
tt
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn cmd_mesh_render(
|
||||||
|
file: &Path,
|
||||||
|
output: &Path,
|
||||||
|
size: u32,
|
||||||
|
yaw: f32,
|
||||||
|
pitch: f32,
|
||||||
|
force_row: bool,
|
||||||
|
only: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let bytes = std::fs::read(file).with_context(|| format!("Cannot read {}", file.display()))?;
|
||||||
|
let mut models = decode_models(&bytes);
|
||||||
|
if let Some(sub) = &only {
|
||||||
|
models.retain(|m| m.name.contains(sub.as_str()));
|
||||||
|
}
|
||||||
|
if models.is_empty() {
|
||||||
|
anyhow::bail!("no decodable XBG7 geometry in {}", file.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build a triangle soup. ──
|
||||||
|
// Single models render centred; multi-model containers (stages) get the
|
||||||
|
// viewer's normalised **thumbnail grid**: each sub-model recentred and
|
||||||
|
// uniformly scaled to a fixed cell, so all are equally visible regardless of
|
||||||
|
// native scale (mirrors `spawn_stage_models`).
|
||||||
|
let multi = models.len() > 1 || force_row;
|
||||||
|
let mut tris: Vec<[[f32; 3]; 3]> = Vec::new();
|
||||||
|
const CELL: f32 = 10.0;
|
||||||
|
const GAP: f32 = 4.0;
|
||||||
|
let grid_pitch = CELL + GAP;
|
||||||
|
let cols = (models.len() as f32).sqrt().ceil().max(1.0) as usize;
|
||||||
|
for (i, m) in models.iter().enumerate() {
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lo[0] > hi[0] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let center = [
|
||||||
|
(lo[0] + hi[0]) * 0.5,
|
||||||
|
(lo[1] + hi[1]) * 0.5,
|
||||||
|
(lo[2] + hi[2]) * 0.5,
|
||||||
|
];
|
||||||
|
let (scale, cell) = if multi {
|
||||||
|
let extent = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(hi[2] - lo[2]).max(1e-3);
|
||||||
|
let col = i % cols;
|
||||||
|
let row = i / cols;
|
||||||
|
(CELL / extent, [col as f32 * grid_pitch, -(row as f32) * grid_pitch, 0.0])
|
||||||
|
} else {
|
||||||
|
(1.0, [0.0, 0.0, 0.0])
|
||||||
|
};
|
||||||
|
for sub in &m.meshes {
|
||||||
|
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 f = |i: usize| {
|
||||||
|
let p = sub.positions[i];
|
||||||
|
[
|
||||||
|
(p[0] - center[0]) * scale + cell[0],
|
||||||
|
(p[1] - center[1]) * scale + cell[1],
|
||||||
|
(p[2] - center[2]) * scale + cell[2],
|
||||||
|
]
|
||||||
|
};
|
||||||
|
tris.push([f(a), f(b), f(c)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tris.is_empty() {
|
||||||
|
anyhow::bail!("no triangles to render");
|
||||||
|
}
|
||||||
|
|
||||||
|
let rgba = rasterize(&tris, size, yaw, pitch);
|
||||||
|
image::save_buffer(output, &rgba, size, size, image::ExtendedColorType::Rgba8)
|
||||||
|
.with_context(|| format!("writing PNG {}", output.display()))?;
|
||||||
|
println!(
|
||||||
|
"{} {} tris → {} ({}×{}, yaw {:.0}° pitch {:.0}°)",
|
||||||
|
"Rendered".green().bold(),
|
||||||
|
tris.len(),
|
||||||
|
output.display().to_string().cyan(),
|
||||||
|
size,
|
||||||
|
size,
|
||||||
|
yaw,
|
||||||
|
pitch,
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal software rasterizer: orthographic, z-buffered, two-sided Lambert +
|
||||||
|
/// headlight shading over a flat grey material on a dark background. Enough to
|
||||||
|
/// judge whether recovered geometry is coherent.
|
||||||
|
fn rasterize(tris: &[[[f32; 3]; 3]], size: u32, yaw_deg: f32, pitch_deg: f32) -> Vec<u8> {
|
||||||
|
let n = size as usize;
|
||||||
|
let (yaw, pitch) = (yaw_deg.to_radians(), pitch_deg.to_radians());
|
||||||
|
let (cy, sy) = (yaw.cos(), yaw.sin());
|
||||||
|
let (cp, sp) = (pitch.cos(), pitch.sin());
|
||||||
|
// Rotate a world point into view space (yaw about Y, then pitch about X).
|
||||||
|
let view = |p: [f32; 3]| -> [f32; 3] {
|
||||||
|
let x = p[0] * cy + p[2] * sy;
|
||||||
|
let z0 = -p[0] * sy + p[2] * cy;
|
||||||
|
let y = p[1] * cp - z0 * sp;
|
||||||
|
let z = p[1] * sp + z0 * cp;
|
||||||
|
[x, y, z]
|
||||||
|
};
|
||||||
|
|
||||||
|
// View-space bbox → orthographic fit.
|
||||||
|
let mut lo = [f32::MAX; 3];
|
||||||
|
let mut hi = [f32::MIN; 3];
|
||||||
|
for t in tris {
|
||||||
|
for v in t {
|
||||||
|
let q = view(*v);
|
||||||
|
for a in 0..3 {
|
||||||
|
lo[a] = lo[a].min(q[a]);
|
||||||
|
hi[a] = hi[a].max(q[a]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let span = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(1e-3);
|
||||||
|
let scale = (n as f32) * 0.9 / span;
|
||||||
|
let cx = (lo[0] + hi[0]) * 0.5;
|
||||||
|
let cyv = (lo[1] + hi[1]) * 0.5;
|
||||||
|
let to_screen = |q: [f32; 3]| -> (f32, f32, f32) {
|
||||||
|
let sx = (q[0] - cx) * scale + n as f32 * 0.5;
|
||||||
|
let sy = n as f32 * 0.5 - (q[1] - cyv) * scale;
|
||||||
|
(sx, sy, q[2])
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut color = vec![18u8; n * n * 4];
|
||||||
|
for i in 0..n * n {
|
||||||
|
color[i * 4 + 3] = 255;
|
||||||
|
}
|
||||||
|
let mut depth = vec![f32::MAX; n * n];
|
||||||
|
// Light in view space (upper-left-front).
|
||||||
|
let light = {
|
||||||
|
let l = [-0.4f32, 0.6, 0.7];
|
||||||
|
let m = (l[0] * l[0] + l[1] * l[1] + l[2] * l[2]).sqrt();
|
||||||
|
[l[0] / m, l[1] / m, l[2] / m]
|
||||||
|
};
|
||||||
|
|
||||||
|
for t in tris {
|
||||||
|
let v0 = view(t[0]);
|
||||||
|
let v1 = view(t[1]);
|
||||||
|
let v2 = view(t[2]);
|
||||||
|
// Face normal in view space.
|
||||||
|
let e1 = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];
|
||||||
|
let e2 = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]];
|
||||||
|
let mut nrm = [
|
||||||
|
e1[1] * e2[2] - e1[2] * e2[1],
|
||||||
|
e1[2] * e2[0] - e1[0] * e2[2],
|
||||||
|
e1[0] * e2[1] - e1[1] * e2[0],
|
||||||
|
];
|
||||||
|
let nl = (nrm[0] * nrm[0] + nrm[1] * nrm[1] + nrm[2] * nrm[2]).sqrt();
|
||||||
|
if nl < 1e-12 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nrm = [nrm[0] / nl, nrm[1] / nl, nrm[2] / nl];
|
||||||
|
// Two-sided: diffuse from |n·L|, plus a headlight term from |n.z|.
|
||||||
|
let diff = (nrm[0] * light[0] + nrm[1] * light[1] + nrm[2] * light[2]).abs();
|
||||||
|
let head = nrm[2].abs();
|
||||||
|
let inten = (0.18 + 0.55 * diff + 0.3 * head).min(1.0);
|
||||||
|
let shade = (inten * 210.0) as u8;
|
||||||
|
|
||||||
|
let (ax, ay, az) = to_screen(v0);
|
||||||
|
let (bx, by, bz) = to_screen(v1);
|
||||||
|
let (ccx, ccy, ccz) = to_screen(v2);
|
||||||
|
let minx = ax.min(bx).min(ccx).floor().max(0.0) as usize;
|
||||||
|
let maxx = ax.max(bx).max(ccx).ceil().min(n as f32 - 1.0) as usize;
|
||||||
|
let miny = ay.min(by).min(ccy).floor().max(0.0) as usize;
|
||||||
|
let maxy = ay.max(by).max(ccy).ceil().min(n as f32 - 1.0) as usize;
|
||||||
|
let area = (bx - ax) * (ccy - ay) - (by - ay) * (ccx - ax);
|
||||||
|
if area.abs() < 1e-6 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for py in miny..=maxy {
|
||||||
|
for px in minx..=maxx {
|
||||||
|
let fx = px as f32 + 0.5;
|
||||||
|
let fy = py as f32 + 0.5;
|
||||||
|
let w0 = ((bx - fx) * (ccy - fy) - (by - fy) * (ccx - fx)) / area;
|
||||||
|
let w1 = ((ccx - fx) * (ay - fy) - (ccy - fy) * (ax - fx)) / area;
|
||||||
|
let w2 = 1.0 - w0 - w1;
|
||||||
|
if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let z = w0 * az + w1 * bz + w2 * ccz;
|
||||||
|
let idx = py * n + px;
|
||||||
|
if z < depth[idx] {
|
||||||
|
depth[idx] = z;
|
||||||
|
color[idx * 4] = shade;
|
||||||
|
color[idx * 4 + 1] = shade;
|
||||||
|
color[idx * 4 + 2] = (shade as f32 * 1.02).min(255.0) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
color
|
||||||
|
}
|
||||||
|
|
||||||
/// Software-decode a de-tiled `X360Texture` (mip 0) to tightly-packed RGBA8.
|
/// Software-decode a de-tiled `X360Texture` (mip 0) to tightly-packed RGBA8.
|
||||||
///
|
///
|
||||||
/// BCn blocks are decompressed with `texpresso`; uncompressed A8R8G8B8/X8R8G8B8
|
/// BCn blocks are decompressed with `texpresso`; uncompressed A8R8G8B8/X8R8G8B8
|
||||||
|
|||||||
@@ -150,12 +150,17 @@ impl Xbg7Model {
|
|||||||
let mut off = 0usize; // relative to `base`
|
let mut off = 0usize; // relative to `base`
|
||||||
let mut meshes = Vec::new();
|
let mut meshes = Vec::new();
|
||||||
for (vtx_count, idx_count) in records {
|
for (vtx_count, idx_count) in records {
|
||||||
let ib = base + off;
|
// Each sub-mesh block is `[12-byte header][index buffer][vertex
|
||||||
|
// buffer]` — the SAME layout as stage resources (see
|
||||||
|
// `stage_models`). The header must be skipped: reading indices from
|
||||||
|
// the block start instead treats the 12 header bytes as 6 junk
|
||||||
|
// indices (2 leading degenerate triangles — the stray-triangle
|
||||||
|
// artifact) and drops the last 6 real indices. Skipping it leaves the
|
||||||
|
// vertex buffer at the identical offset (`+12 + idx_bytes`), so
|
||||||
|
// coverage is unchanged; only the triangle list is corrected.
|
||||||
|
let ib = base + off + VERTEX_BUFFER_GAP;
|
||||||
let ie = ib + idx_count * 2;
|
let ie = ib + idx_count * 2;
|
||||||
// The vertex buffer follows the index buffer after a fixed 12-byte
|
let vb = ie;
|
||||||
// header (a normal length of exactly 1.0 pins this offset across
|
|
||||||
// every decoded model — `align`-based guesses landed 4 bytes early).
|
|
||||||
let vb = ie + VERTEX_BUFFER_GAP;
|
|
||||||
let ve = vb + vtx_count * decl.stride;
|
let ve = vb + vtx_count * decl.stride;
|
||||||
if ve > bytes.len() {
|
if ve > bytes.len() {
|
||||||
return Err(MeshError::UnsupportedLayout);
|
return Err(MeshError::UnsupportedLayout);
|
||||||
@@ -232,12 +237,333 @@ impl Xbg7Model {
|
|||||||
|
|
||||||
Ok(Xbg7Model { name, meshes })
|
Ok(Xbg7Model { name, meshes })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decode **every** locatable XBG7 geometry resource in a container.
|
||||||
|
///
|
||||||
|
/// "Stage" containers (`hidden/resource3d/Stage_*.xpr`) are collections of
|
||||||
|
/// many enemy / prop sub-models, each an independent XBG7 resource. Unlike
|
||||||
|
/// the single-stream weapon layout (index buffer immediately followed by its
|
||||||
|
/// vertex buffer), a stage's index buffers and vertex buffers live in
|
||||||
|
/// **separate grouped pools**, and the container stores each resource's
|
||||||
|
/// buffer *sizes* (index count via the marker, vertex count 32 bytes before
|
||||||
|
/// it) but **not** an explicit data offset — the on-disc block layout is a
|
||||||
|
/// separate allocation order we have not reversed.
|
||||||
|
///
|
||||||
|
/// Rather than guess that order, each resource's `[index buffer][vertex
|
||||||
|
/// buffer]` block is located by **content**: the unique offset in the data
|
||||||
|
/// section where (a) all `index_count` indices are `< vertex_count`, (b) the
|
||||||
|
/// stored normals are unit length, and (c) the resulting triangles are
|
||||||
|
/// non-degenerate with a real spatial extent. This signature is strong
|
||||||
|
/// enough to pin a block unambiguously in a multi-megabyte file. Resources
|
||||||
|
/// that cannot be located and validated this way are **skipped** (never
|
||||||
|
/// emitted as garbage) — including the biggest hero bodies, which use the
|
||||||
|
/// same quantized/complex layout that [`Xbg7Model::from_xpr2`] declines.
|
||||||
|
///
|
||||||
|
/// Returns one [`Xbg7Model`] per decoded resource (empty if none decode).
|
||||||
|
pub fn stage_models(bytes: &[u8]) -> Vec<Xbg7Model> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
let mut cur = Cursor::new(bytes);
|
||||||
|
let header = match Xpr2Header::read(&mut cur) {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(_) => return out,
|
||||||
|
};
|
||||||
|
let data_base = header.header_size as usize;
|
||||||
|
if data_base >= bytes.len() {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Collect every XBG7 resource's parameters up front. ──
|
||||||
|
const DIR_BASE: usize = 0x10;
|
||||||
|
struct Res {
|
||||||
|
name: String,
|
||||||
|
index_count: usize,
|
||||||
|
vtx_count: usize,
|
||||||
|
decl: VertexDecl,
|
||||||
|
}
|
||||||
|
let mut resources: Vec<Res> = Vec::new();
|
||||||
|
for _ in 0..header.num_resources {
|
||||||
|
let e = match Xpr2ResourceEntry::read(&mut cur) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
if &e.type_tag != b"XBG7" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let desc = e.data_offset as usize + DIR_BASE;
|
||||||
|
let desc_end = (desc + e.descriptor_size as usize).min(bytes.len());
|
||||||
|
if desc >= bytes.len() || desc_end <= desc {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let d = &bytes[desc..desc_end];
|
||||||
|
let (mk_rel, index_count) = match find_index_marker(d) {
|
||||||
|
Some(m) => m,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
let decl = match parse_vertex_decl(d) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
// Total vertex count is stored 32 bytes before the index marker.
|
||||||
|
if mk_rel < 32 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let vtx_count = be32(d, mk_rel - 32) as usize;
|
||||||
|
if !(3..=400_000).contains(&vtx_count) || index_count < 3 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Anchoring relies on the unit-normal signature; skip resources with
|
||||||
|
// no NORMAL element (too ambiguous to pin safely).
|
||||||
|
if decl.normal_offset.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = read_cstr(bytes, e.name_offset as usize + DIR_BASE)
|
||||||
|
.unwrap_or_else(|| "XBG7".to_string());
|
||||||
|
resources.push(Res {
|
||||||
|
name,
|
||||||
|
index_count,
|
||||||
|
vtx_count,
|
||||||
|
decl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if resources.is_empty() {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── One O(file) pass per distinct stride: find vertex-block *starts*. ──
|
||||||
|
//
|
||||||
|
// Each geometry block is `[12B header][index buffer][vertex buffer]`, and
|
||||||
|
// the blocks are scattered among texture data with no stored offset. But
|
||||||
|
// a vertex buffer is a run of stride-sized records whose NORMAL (f16×4 at
|
||||||
|
// +12) is unit length; a *block start* is the unique offset where that
|
||||||
|
// run begins — the previous stride slot is NOT a unit-normal vertex (it's
|
||||||
|
// index bytes / header). Collecting those starts turns the per-resource
|
||||||
|
// search from O(file) into a scan of a few hundred candidates.
|
||||||
|
let mut strides: Vec<usize> = resources.iter().map(|r| r.decl.stride).collect();
|
||||||
|
strides.sort_unstable();
|
||||||
|
strides.dedup();
|
||||||
|
let mut starts_by_stride: std::collections::BTreeMap<usize, Vec<usize>> =
|
||||||
|
std::collections::BTreeMap::new();
|
||||||
|
for &s in &strides {
|
||||||
|
starts_by_stride.insert(s, vertex_run_starts(bytes, data_base, s));
|
||||||
|
}
|
||||||
|
|
||||||
|
for r in &resources {
|
||||||
|
let starts = &starts_by_stride[&r.decl.stride];
|
||||||
|
if let Some(mesh) = anchor_pool_mesh(
|
||||||
|
bytes,
|
||||||
|
starts,
|
||||||
|
r.index_count,
|
||||||
|
r.vtx_count,
|
||||||
|
&r.decl,
|
||||||
|
) {
|
||||||
|
out.push(Xbg7Model {
|
||||||
|
name: r.name.clone(),
|
||||||
|
meshes: vec![mesh],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan the data section for offsets that begin a `stride`-sized unit-normal
|
||||||
|
/// vertex run (NORMAL is `f16×4` at vertex offset +12). A run *start* is an
|
||||||
|
/// offset whose normal is unit while the preceding stride slot's is not — i.e.
|
||||||
|
/// the first vertex of a buffer, not a mid-buffer position. Returns the sorted
|
||||||
|
/// candidate starts (block vertex-buffer offsets).
|
||||||
|
fn vertex_run_starts(bytes: &[u8], data_base: usize, stride: usize) -> Vec<usize> {
|
||||||
|
const NRM: usize = 12; // POSITION f32×3 occupies [0,12); NORMAL f16×4 follows
|
||||||
|
let mut starts = Vec::new();
|
||||||
|
if stride < NRM + 8 {
|
||||||
|
return starts;
|
||||||
|
}
|
||||||
|
let is_unit = |o: usize| -> bool {
|
||||||
|
if o + NRM + 6 > bytes.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let nx = half(bytes, o + NRM);
|
||||||
|
let ny = half(bytes, o + NRM + 2);
|
||||||
|
let nz = half(bytes, o + NRM + 4);
|
||||||
|
let l = (nx * nx + ny * ny + nz * nz).sqrt();
|
||||||
|
(0.85..=1.15).contains(&l)
|
||||||
|
};
|
||||||
|
// Vertex buffers begin on 4-byte boundaries in practice; step 4.
|
||||||
|
let end = bytes.len().saturating_sub(NRM + 6);
|
||||||
|
let mut o = data_base;
|
||||||
|
while o <= end {
|
||||||
|
if is_unit(o) && (o < data_base + stride || !is_unit(o - stride)) {
|
||||||
|
starts.push(o);
|
||||||
|
}
|
||||||
|
o += 4;
|
||||||
|
}
|
||||||
|
starts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locate a stage resource's `[index buffer][vertex buffer]` block among the
|
||||||
|
/// precomputed vertex-run `starts` (see [`vertex_run_starts`]) and decode it, or
|
||||||
|
/// return `None` if no candidate validates. A candidate `vb` is accepted when
|
||||||
|
/// the `index_count` indices ending just before it are all `< vtx_count`,
|
||||||
|
/// reference (nearly) all vertices, and produce non-degenerate triangles with a
|
||||||
|
/// real spatial extent — a signature strong enough to pin the block.
|
||||||
|
fn anchor_pool_mesh(
|
||||||
|
bytes: &[u8],
|
||||||
|
starts: &[usize],
|
||||||
|
index_count: usize,
|
||||||
|
vtx_count: usize,
|
||||||
|
decl: &VertexDecl,
|
||||||
|
) -> Option<GameMesh> {
|
||||||
|
let stride = decl.stride;
|
||||||
|
let idx_bytes = index_count * 2;
|
||||||
|
let vtx_bytes = vtx_count.checked_mul(stride)?;
|
||||||
|
|
||||||
|
for &vb in starts {
|
||||||
|
// The index buffer sits immediately before the vertex buffer.
|
||||||
|
if vb < idx_bytes {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ib = vb - idx_bytes;
|
||||||
|
if vb + vtx_bytes > bytes.len() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Full index validation: every index in range, uses ~all vertices. ──
|
||||||
|
let mut max_idx = 0u32;
|
||||||
|
let mut ok = true;
|
||||||
|
for k in 0..index_count {
|
||||||
|
let i = be16(bytes, ib + k * 2) as u32;
|
||||||
|
if i >= vtx_count as u32 {
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
max_idx = max_idx.max(i);
|
||||||
|
}
|
||||||
|
if !ok || (max_idx as usize) + 4 < vtx_count {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Triangle quality: finite, non-degenerate, real spatial extent. ──
|
||||||
|
let pos = decl.pos_offset;
|
||||||
|
let mut lo = [f32::MAX; 3];
|
||||||
|
let mut hi = [f32::MIN; 3];
|
||||||
|
let mut degenerate = 0usize;
|
||||||
|
let mut sampled = 0usize;
|
||||||
|
let mut edge_sum = 0.0f32;
|
||||||
|
let tris = index_count / 3;
|
||||||
|
let tstep = (tris / 96).max(1);
|
||||||
|
let mut t = 0;
|
||||||
|
let mut bad = false;
|
||||||
|
while t < tris {
|
||||||
|
let mut p = [[0.0f32; 3]; 3];
|
||||||
|
for (c, pc) in p.iter_mut().enumerate() {
|
||||||
|
let vi = be16(bytes, ib + (3 * t + c) * 2) as usize;
|
||||||
|
let base = vb + vi * stride + pos;
|
||||||
|
for (a, slot) in pc.iter_mut().enumerate() {
|
||||||
|
let x = bef(bytes, base + a * 4);
|
||||||
|
if !x.is_finite() || x.abs() > 1.0e6 {
|
||||||
|
bad = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
*slot = x;
|
||||||
|
lo[a] = lo[a].min(x);
|
||||||
|
hi[a] = hi[a].max(x);
|
||||||
|
}
|
||||||
|
if bad {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bad {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let u = [p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2]];
|
||||||
|
let w = [p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2]];
|
||||||
|
let cx = [
|
||||||
|
u[1] * w[2] - u[2] * w[1],
|
||||||
|
u[2] * w[0] - u[0] * w[2],
|
||||||
|
u[0] * w[1] - u[1] * w[0],
|
||||||
|
];
|
||||||
|
if 0.5 * (cx[0] * cx[0] + cx[1] * cx[1] + cx[2] * cx[2]).sqrt() < 1.0e-9 {
|
||||||
|
degenerate += 1;
|
||||||
|
}
|
||||||
|
// Sum the triangle's three edge lengths (for the connectivity check).
|
||||||
|
let e3 = [p[2][0] - p[1][0], p[2][1] - p[1][1], p[2][2] - p[1][2]];
|
||||||
|
edge_sum += (u[0] * u[0] + u[1] * u[1] + u[2] * u[2]).sqrt()
|
||||||
|
+ (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt()
|
||||||
|
+ (e3[0] * e3[0] + e3[1] * e3[1] + e3[2] * e3[2]).sqrt();
|
||||||
|
sampled += 1;
|
||||||
|
t += tstep;
|
||||||
|
}
|
||||||
|
if bad {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let extent = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(hi[2] - lo[2]);
|
||||||
|
if extent < 0.5 || sampled == 0 || degenerate * 10 > sampled * 3 {
|
||||||
|
continue; // too flat, or >30% degenerate → not this block
|
||||||
|
}
|
||||||
|
// Connectivity check: a correctly-anchored mesh has triangle edges that
|
||||||
|
// are SMALL relative to its overall size (~0.05–0.15 of the bbox
|
||||||
|
// diagonal). A wrong anchor / cross-wired index buffer connects distant
|
||||||
|
// vertices, so its mean edge spans a large fraction of the model (a spiky
|
||||||
|
// mess). Reject those — try another candidate or decline.
|
||||||
|
let diag = ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2))
|
||||||
|
.sqrt()
|
||||||
|
.max(1e-6);
|
||||||
|
let mean_edge = edge_sum / (sampled as f32 * 3.0);
|
||||||
|
if mean_edge / diag > 0.28 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Accepted: read the full mesh. ──
|
||||||
|
return Some(read_pool_mesh(bytes, ib, vb, index_count, vtx_count, decl));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read positions / normals / uvs / indices for an anchored stage block.
|
||||||
|
fn read_pool_mesh(
|
||||||
|
bytes: &[u8],
|
||||||
|
ib: usize,
|
||||||
|
vb: usize,
|
||||||
|
index_count: usize,
|
||||||
|
vtx_count: usize,
|
||||||
|
decl: &VertexDecl,
|
||||||
|
) -> GameMesh {
|
||||||
|
let stride = decl.stride;
|
||||||
|
let indices: Vec<u32> = (0..index_count)
|
||||||
|
.map(|k| be16(bytes, ib + k * 2) as u32)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut positions = Vec::with_capacity(vtx_count);
|
||||||
|
let mut normals = Vec::with_capacity(vtx_count);
|
||||||
|
let mut uvs = Vec::with_capacity(vtx_count);
|
||||||
|
for v in 0..vtx_count {
|
||||||
|
let o = vb + v * stride;
|
||||||
|
let p = o + decl.pos_offset;
|
||||||
|
positions.push([bef(bytes, p), bef(bytes, p + 4), bef(bytes, p + 8)]);
|
||||||
|
if let Some(no) = decl.normal_offset {
|
||||||
|
let nb = o + no;
|
||||||
|
normals.push([half(bytes, nb), half(bytes, nb + 2), half(bytes, nb + 4)]);
|
||||||
|
}
|
||||||
|
if let Some(uo) = decl.uv_offset {
|
||||||
|
let ub = o + uo;
|
||||||
|
uvs.push([half(bytes, ub), half(bytes, ub + 2)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GameMesh {
|
||||||
|
positions,
|
||||||
|
normals,
|
||||||
|
uvs,
|
||||||
|
indices,
|
||||||
|
name: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Layout constants ────────────────────────────────────────────────────────
|
// ── Layout constants ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Fixed byte gap between the end of a sub-mesh's index buffer and the start of
|
/// Size of the header that precedes each sub-mesh block's index buffer
|
||||||
/// its vertex buffer (a small vertex-buffer header — contents not yet decoded).
|
/// (`[12-byte header][index buffer][vertex buffer]`). Contents not yet decoded.
|
||||||
const VERTEX_BUFFER_GAP: usize = 12;
|
const VERTEX_BUFFER_GAP: usize = 12;
|
||||||
|
|
||||||
// ── Vertex declaration ───────────────────────────────────────────────────────
|
// ── Vertex declaration ───────────────────────────────────────────────────────
|
||||||
@@ -272,20 +598,24 @@ fn decl_code_size(code: u32) -> Option<usize> {
|
|||||||
/// `code == 0xFFFFFFFF` sentinel. Usage codes: `0` POSITION, `3` NORMAL,
|
/// `code == 0xFFFFFFFF` sentinel. Usage codes: `0` POSITION, `3` NORMAL,
|
||||||
/// `5` TEXCOORD. Stride is the max element extent; unknown element sizes are
|
/// `5` TEXCOORD. Stride is the max element extent; unknown element sizes are
|
||||||
/// inferred from the next element's offset.
|
/// inferred from the next element's offset.
|
||||||
fn parse_vertex_decl(desc: &[u8]) -> Option<VertexDecl> {
|
/// Locate the `(index_bytes, index_count)` marker in a descriptor: the first
|
||||||
// Locate the (index_bytes, index_count) marker: index_bytes == count*2.
|
/// big-endian pair where `index_bytes == index_count * 2` and `index_count` is a
|
||||||
let mut mk = None;
|
/// positive multiple of 3. Returns `(rel_offset, index_count)`.
|
||||||
|
fn find_index_marker(desc: &[u8]) -> Option<(usize, usize)> {
|
||||||
let mut rel = 0usize;
|
let mut rel = 0usize;
|
||||||
while rel + 40 <= desc.len() {
|
while rel + 40 <= desc.len() {
|
||||||
let a = be32(desc, rel);
|
let a = be32(desc, rel);
|
||||||
let c = be32(desc, rel + 4);
|
let c = be32(desc, rel + 4);
|
||||||
if c >= 3 && c % 3 == 0 && c < 200_000 && a == c * 2 {
|
if c >= 3 && c % 3 == 0 && c < 400_000 && a == c * 2 {
|
||||||
mk = Some(rel);
|
return Some((rel, c as usize));
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
rel += 4;
|
rel += 4;
|
||||||
}
|
}
|
||||||
let mk = mk?;
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_vertex_decl(desc: &[u8]) -> Option<VertexDecl> {
|
||||||
|
let mk = find_index_marker(desc)?.0;
|
||||||
|
|
||||||
// Read declaration triples.
|
// Read declaration triples.
|
||||||
let mut elems: Vec<(usize, u32, u32)> = Vec::new(); // (offset, code, usage)
|
let mut elems: Vec<(usize, u32, u32)> = Vec::new(); // (offset, code, usage)
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
|
|||||||
| LSTA sprite list | 🟡 | `sylpheed-formats/src/lsta.rs` | inline T8aD frames |
|
| LSTA sprite list | 🟡 | `sylpheed-formats/src/lsta.rs` | inline T8aD frames |
|
||||||
| IXUD subtitle | 🟡 | `sylpheed-formats/src/ixud.rs` | timed cues; **movie↔track link unknown** (dynamic item) |
|
| IXUD subtitle | 🟡 | `sylpheed-formats/src/ixud.rs` | timed cues; **movie↔track link unknown** (dynamic item) |
|
||||||
| Fonts (ttf/otf/ttc) | ✅ | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser |
|
| Fonts (ttf/otf/ttc) | ✅ | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser |
|
||||||
| XBG7 mesh | 🟡/❔ | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | single-stream, declaration-driven variable stride (36 models); triangle-list + formats GPU-confirmed; multi-submesh + body meshes still declined |
|
| XBG7 mesh | 🟡/❔ | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | weapons/props: declaration-driven variable stride (36 models), GPU-confirmed. **Stage containers: 5662 sub-models across 22 stages** via content-anchored grouped pools (`stage_models`). Quantized hero bodies (DeltaSaber `f004`) still declined |
|
||||||
|
|
||||||
## Functions / code paths
|
## Functions / code paths
|
||||||
|
|
||||||
|
|||||||
@@ -44,13 +44,21 @@ For 36 of the 166 models (weapons, simple props) the data section is a straight
|
|||||||
sub-meshes, carved from `header_size` in record order:
|
sub-meshes, carved from `header_size` in record order:
|
||||||
|
|
||||||
```
|
```
|
||||||
per sub-mesh:
|
per sub-mesh block:
|
||||||
|
[ 12-byte header (contents undecoded) ]
|
||||||
[ index buffer : idx_count × u16 BE ] triangle list (prim=4, GPU-confirmed)
|
[ index buffer : idx_count × u16 BE ] triangle list (prim=4, GPU-confirmed)
|
||||||
[ 12-byte vertex-buffer header (contents undecoded) ]
|
|
||||||
[ vertex buffer : vtx_count × stride bytes ] ← declaration-driven
|
[ vertex buffer : vtx_count × stride bytes ] ← declaration-driven
|
||||||
(pad to 16 bytes → next sub-mesh)
|
(pad to 16 bytes → next sub-mesh)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The 12-byte header precedes the **index** buffer (the same block shape as stage
|
||||||
|
resources — see below); the vertex buffer follows the indices with no further
|
||||||
|
gap. (Earlier this was mis-modelled as `[index][12-byte gap][vertex]`, which put
|
||||||
|
the vertex buffer at the identical offset but read the index buffer 12 bytes too
|
||||||
|
early — turning the 12 header bytes into 6 junk indices = **2 leading degenerate
|
||||||
|
triangles** (a stray-triangle artifact) and dropping the last 6 real indices.
|
||||||
|
Skipping the header fixes the triangle list with no change to vertex coverage.)
|
||||||
|
|
||||||
**Vertex declaration.** The layout is **not fixed-stride**. The descriptor holds a declaration table
|
**Vertex declaration.** The layout is **not fixed-stride**. The descriptor holds a declaration table
|
||||||
(right after the `(index_bytes, index_count)` marker) of `{offset:u32, format-code:u32, usage<<16:u32}`
|
(right after the `(index_bytes, index_count)` marker) of `{offset:u32, format-code:u32, usage<<16:u32}`
|
||||||
big-endian triples, terminated by `offset == 0x00FF0000` / `code == 0xFFFFFFFF`:
|
big-endian triples, terminated by `offset == 0x00FF0000` / `code == 0xFFFFFFFF`:
|
||||||
@@ -83,6 +91,44 @@ the plain per-element big-endian read yields exactly unit normals. So the game *
|
|||||||
vertex data between the on-disc `.xpr` and the uploaded buffer; the decoder reads the file directly
|
vertex data between the on-disc `.xpr` and the uploaded buffer; the decoder reads the file directly
|
||||||
and must use naive BE.
|
and must use naive BE.
|
||||||
|
|
||||||
|
## Stage containers — multi-resource, grouped pools ✅ (content-anchored)
|
||||||
|
|
||||||
|
`hidden/resource3d/Stage_S*.xpr` are not single models but **collections of
|
||||||
|
enemy / prop sub-models** — up to ~400 `XBG7` resources each (e.g. `Stage_S07` =
|
||||||
|
378). Their data layout differs from the weapon files:
|
||||||
|
|
||||||
|
- Each resource is a block `[12-byte header][index buffer][vertex buffer]`.
|
||||||
|
Unlike weapons there is **no 12-byte gap between index and vertex** — the
|
||||||
|
vertex buffer directly follows the indices.
|
||||||
|
- The **index count** is the descriptor's `(index_bytes, index_count)` marker
|
||||||
|
(the *total* for the resource — a resource may have several sub-meshes summing
|
||||||
|
to it), **not** the first sub-mesh record. The **vertex count** is a `u32`
|
||||||
|
stored **32 bytes before** the marker.
|
||||||
|
- The blocks are **scattered through the data section, interleaved with the
|
||||||
|
container's texture data**, in an allocation order that is **not** directory
|
||||||
|
order and is **not stored** in any descriptor field we could find (the
|
||||||
|
descriptor holds sizes — `rel 160 ≈ index_bytes+3`, `rel 164 =
|
||||||
|
0x1000_0000 | (vertex_bytes+2)` — but no data offset). Reconstructing that
|
||||||
|
allocation order is unsolved.
|
||||||
|
|
||||||
|
Because the offset is not stored, each resource's block is located by
|
||||||
|
**content**: parser `Xbg7Model::stage_models` does one `O(file)` pass per
|
||||||
|
distinct stride to find every **vertex-buffer start** (an offset whose NORMAL —
|
||||||
|
`f16×4` at vertex `+12` — is unit length while the *previous* stride slot's is
|
||||||
|
not, i.e. a run boundary; ~one candidate per block, not millions), then pins each
|
||||||
|
resource to the unique candidate where the `index_count` indices ending just
|
||||||
|
before it are all `< vertex_count`, reference nearly all vertices, and yield
|
||||||
|
non-degenerate triangles with real extent. This is fast (≤ ~2 s on a 70 MB
|
||||||
|
stage) and unambiguous (no two resources collide). Blocks that fail validation —
|
||||||
|
the few quantized hero bodies — are **skipped**, never emitted as garbage.
|
||||||
|
|
||||||
|
**Coverage:** 5662 sub-models decode across the 22 stage files (e.g. `Stage_S07`
|
||||||
|
366/378, `Stage_S10` 7/9 — including the main enemy bodies `e003`/`e005`, their
|
||||||
|
LODs, weapons, and props). The viewer (`spawn_stage_models`) lays the decoded
|
||||||
|
sub-models out as a side-by-side "cast sheet", skipping the few huge skybox-plane
|
||||||
|
resources (300 k-unit quads). Cross-checked: `e003` = 2383 v / 1436 t bbox
|
||||||
|
23.5×9.5×32.5; `e005` = 2566 v / 1507 t; both 0 degenerate.
|
||||||
|
|
||||||
## Not yet decoded — ❔ the complex body layout
|
## Not yet decoded — ❔ the complex body layout
|
||||||
|
|
||||||
The hero-ship *body* meshes (`DeltaSaber_A/_T/_W.xpr` resource `f004`, and ~100 other models) still
|
The hero-ship *body* meshes (`DeltaSaber_A/_T/_W.xpr` resource `f004`, and ~100 other models) still
|
||||||
@@ -121,6 +167,17 @@ mission* (where `DeltaSaber` renders) would hand over its exact declaration dire
|
|||||||
parsed the declaration for variable stride: coverage **25 → 36** (e.g. `wep_05` is pos+normal,
|
parsed the declaration for variable stride: coverage **25 → 36** (e.g. `wep_05` is pos+normal,
|
||||||
stride 20, no UV — previously mis-aligned). Also confirmed the endianness note above: fetch
|
stride 20, no UV — previously mis-aligned). Also confirmed the endianness note above: fetch
|
||||||
`endian=2` (k8in32) is the *guest* copy; file stays naive-BE. `Stage_S*` now decode (stride 20).
|
`endian=2` (k8in32) is the *guest* copy; file stays naive-BE. `Stage_S*` now decode (stride 20).
|
||||||
|
- 2026-07-12 (STAGE) — `Stage_S*.xpr` decoded as multi-resource containers. Found each geometry
|
||||||
|
block is `[12B hdr][index buffer][vertex buffer]`, index count = descriptor marker (total, e.g.
|
||||||
|
`e003` = 4308 spanning 2 sub-meshes), vertex count = `u32` at marker−32 (`e003` = 2383 → verified
|
||||||
|
by max-index 2382 and 0 degenerate tris, bbox 23.5×9.5×32.5). Blocks are scattered among texture
|
||||||
|
data with **no stored offset** (descriptor rel 160 = idx_bytes+3, rel 164 = `0x1000_0000 |
|
||||||
|
(vtx_bytes+2)` are sizes, not offsets; block starts e.g. `e003`@0x10230, `e003_l`@0x5d000,
|
||||||
|
`e005_l`@0x9b000 are not directory-ordered). Solved by **content anchoring**: a single per-stride
|
||||||
|
pass finds vertex-run starts (unit NORMAL at +12 whose previous slot isn't), then match each
|
||||||
|
resource by strict index+triangle validation. **5662 sub-models decode across 22 stages** (S07
|
||||||
|
366/378), incl. the previously-declined main bodies `e005` (2566 v) and weapons — ≤2 s on 70 MB.
|
||||||
|
Parser `Xbg7Model::stage_models`, tests `stage_models_{decode,sweep,quality_audit}`.
|
||||||
- 2026-07-12 — `DeltaSaber_A.xpr` body: data does **not** begin with indices; plain-`f32×3` runs
|
- 2026-07-12 — `DeltaSaber_A.xpr` body: data does **not** begin with indices; plain-`f32×3` runs
|
||||||
with ship-scale extent (span ≈27–34, matching bbox 30.0) found only at high offsets
|
with ship-scale extent (span ≈27–34, matching bbox 30.0) found only at high offsets
|
||||||
(`data+0x28634C`, …) → multi-stream, **undecoded**.
|
(`data+0x28634C`, …) → multi-stream, **undecoded**.
|
||||||
|
|||||||
Reference in New Issue
Block a user