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)]
|
||||
cmd: PakCommands,
|
||||
},
|
||||
|
||||
/// XBG7 mesh tools (inspect / headless render to PNG)
|
||||
Mesh {
|
||||
#[command(subcommand)]
|
||||
cmd: MeshCommands,
|
||||
},
|
||||
}
|
||||
|
||||
#[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)]
|
||||
enum TextureCommands {
|
||||
/// Print information about a texture file
|
||||
@@ -155,6 +192,12 @@ async fn main() -> Result<()> {
|
||||
TextureCommands::Info { file } => cmd_texture_info(&file),
|
||||
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 {
|
||||
PakCommands::List { pak, idxd_only } => cmd_pak_list(&pak, idxd_only),
|
||||
PakCommands::Dump { pak, hash } => cmd_pak_dump(&pak, &hash),
|
||||
@@ -357,6 +400,273 @@ fn cmd_texture_export(file: &Path, output: &Path) -> Result<()> {
|
||||
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.
|
||||
///
|
||||
/// BCn blocks are decompressed with `texpresso`; uncompressed A8R8G8B8/X8R8G8B8
|
||||
|
||||
Reference in New Issue
Block a user