From a937779c773a1a7ebf99986d8a24bbd920bcab88 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 18 Jul 2026 13:04:12 +0200 Subject: [PATCH] [formats] XBG7 meshes: index buffers are triangle STRIPS, not lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The weapon/prop models (rou_fxxx_wep_nn.xpr) rendered with triangular holes in the hull and misplaced/extra spikes. Root cause: XBG7 sub-mesh index buffers are triangle STRIPS, but the decoder read them as triangle LISTS — a strip of N indices is N-2 triangles, a list is only N/3, so ~2/3 of every hull was missing (the holes) and each list-triple of strip data spanned unrelated vertices (the spikes). The tell: triangles-per-vertex was 0.5-1.0 with 0 unreferenced vertices (a closed surface needs ~2.0). - mesh.rs: expand_triangle_strip() converts strip -> list with alternating winding, skipping degenerate triangles (repeated index = strip restart). Applied in both from_xpr2 (weapons) and read_pool_mesh (stage sub-models). All 71 weapon submeshes now have healthy ratios; hulls fill in (wep_03 cannon, wep_04 pod, wep_11, wep_37 verified coherent). - Module doc updated (strip, not list). - sylpheed-cli: `mesh info` reports per-submesh degenerate/unref/spanning-triangle counts; `mesh render` gains --dist (camera zoom). XMESHDBG env dumps the descriptor index markers. Known residual: an index buffer may concatenate several strips with no degenerate bridge, leaving ~2 spanning "spike" triangles at each restart (index jump to a new vertex region) — <1% of tris on most models. Decoding the restart mechanism is a follow-up (a blanket spanning-filter is unsafe: clean models have legit elongated tip triangles). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/sylpheed-cli/src/main.rs | 80 +++++++++++++++++++++++------ crates/sylpheed-formats/src/mesh.rs | 57 ++++++++++++++++++-- 2 files changed, 116 insertions(+), 21 deletions(-) diff --git a/crates/sylpheed-cli/src/main.rs b/crates/sylpheed-cli/src/main.rs index cb57f89..fac320f 100644 --- a/crates/sylpheed-cli/src/main.rs +++ b/crates/sylpheed-cli/src/main.rs @@ -157,6 +157,9 @@ enum MeshCommands { /// Camera pitch in degrees #[arg(long, default_value_t = 22.0)] pitch: f32, + /// Camera distance multiplier (1.0 = framed; <1 zooms in, >1 out) + #[arg(long, default_value_t = 1.0)] + dist: f32, /// Force the stage grid layout even for single models #[arg(long)] row: bool, @@ -205,8 +208,8 @@ async fn main() -> Result<()> { }, 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) + MeshCommands::Render { file, output, size, yaw, pitch, dist, row, only } => { + cmd_mesh_render(&file, &output, size, yaw, pitch, dist, row, only) } }, Commands::Pak { cmd } => match cmd { @@ -466,6 +469,48 @@ fn cmd_mesh_info(file: &Path) -> Result<()> { hi[1] - lo[1], hi[2] - lo[2] ); + // Per-sub-mesh integrity diagnostics: degenerate triangles (a zero-area + // "hole"), vertices referenced by no triangle (dropped geometry), and the + // referenced index range vs the vertex count (short/over reads). + for (si, sub) in m.meshes.iter().enumerate() { + let nv = sub.positions.len(); + let mut referenced = vec![false; nv]; + let (mut degen, mut oob, mut imax) = (0usize, 0usize, 0u32); + for tri in sub.indices.chunks_exact(3) { + let (a, b, c) = (tri[0], tri[1], tri[2]); + imax = imax.max(a).max(b).max(c); + if a == b || b == c || a == c { + degen += 1; + } + for &i in tri { + if (i as usize) < nv { + referenced[i as usize] = true; + } else { + oob += 1; + } + } + } + let unref = referenced.iter().filter(|&&r| !r).count(); + // Spanning triangles: longest edge ≫ the median (strip-junction spikes). + let edge = |a: u32, b: u32| { + let (p, q) = (sub.positions[a as usize], sub.positions[b as usize]); + ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt() + }; + let mut maxedges: Vec = sub + .indices + .chunks_exact(3) + .map(|t| edge(t[0], t[1]).max(edge(t[1], t[2])).max(edge(t[0], t[2]))) + .collect(); + maxedges.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let median = maxedges.get(maxedges.len() / 2).copied().unwrap_or(1.0).max(1e-6); + let spanning = maxedges.iter().filter(|&&e| e > 6.0 * median).count(); + println!( + " sub{si}: {nv} v, {} tris | degenerate {degen}, unref-verts {unref}, spanning>6×med {spanning}, idx_max {imax}/{}{}", + sub.indices.len() / 3, + nv.saturating_sub(1), + if oob > 0 { format!(", OOB {oob}") } else { String::new() }, + ); + } } println!( " {} {} sub-models · {} verts · {} tris", @@ -484,6 +529,7 @@ fn cmd_mesh_render( size: u32, yaw: f32, pitch: f32, + dist: f32, force_row: bool, only: Option, ) -> Result<()> { @@ -535,20 +581,22 @@ fn cmd_mesh_render( (1.0, [0.0, 0.0, 0.0]) }; for sub in &m.meshes { + 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], + ] + }; + // Sub-mesh indices are a triangle list (the decoder has already + // expanded the file's triangle strips). + let n = sub.positions.len(); 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; + if a < n && b < n && c < n { + tris.push([f(a), f(b), f(c)]); } - 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)]); } } } @@ -556,7 +604,7 @@ fn cmd_mesh_render( anyhow::bail!("no triangles to render"); } - let rgba = rasterize(&tris, size, yaw, pitch); + let rgba = rasterize(&tris, size, yaw, pitch, dist); image::save_buffer(output, &rgba, size, size, image::ExtendedColorType::Rgba8) .with_context(|| format!("writing PNG {}", output.display()))?; println!( @@ -575,7 +623,7 @@ fn cmd_mesh_render( /// 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 { +fn rasterize(tris: &[[[f32; 3]; 3]], size: u32, yaw_deg: f32, pitch_deg: f32, dist: f32) -> Vec { let n = size as usize; let (yaw, pitch) = (yaw_deg.to_radians(), pitch_deg.to_radians()); let (cy, sy) = (yaw.cos(), yaw.sin()); @@ -602,7 +650,7 @@ fn rasterize(tris: &[[[f32; 3]; 3]], size: u32, yaw_deg: f32, pitch_deg: f32) -> } } let span = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(1e-3); - let scale = (n as f32) * 0.9 / span; + let scale = (n as f32) * 0.9 / (span * dist.max(1e-3)); 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) { diff --git a/crates/sylpheed-formats/src/mesh.rs b/crates/sylpheed-formats/src/mesh.rs index adf2cb1..98bdecc 100644 --- a/crates/sylpheed-formats/src/mesh.rs +++ b/crates/sylpheed-formats/src/mesh.rs @@ -21,12 +21,20 @@ //! the data section is a straight sequence of sub-meshes, each: //! //! ```text -//! [ index buffer : idx_count × u16 big-endian ] triangle list +//! [ index buffer : idx_count × u16 big-endian ] triangle STRIP //! [ 12-byte vertex-buffer header (contents undecoded) ] //! [ vertex buffer : vtx_count × stride bytes ] (declaration-driven) //! (pad to 16 bytes → next sub-mesh) //! ``` //! +//! The index buffer is a triangle **strip** (a strip of N indices is N-2 +//! triangles with alternating winding), NOT a list — reading it as a list +//! dropped ~⅔ of every hull (the "triangular holes") and produced spanning +//! spikes. `expand_triangle_strip` converts it, skipping degenerate triangles. +//! Residual: an index buffer may concatenate several strips with no degenerate +//! bridge, leaving ~2 spanning triangles at each restart (an index jump to a new +//! vertex region) — a small known artifact on some models. +//! //! The vertex layout is **not fixed** — it comes from a **vertex declaration** //! in the descriptor: a table of `{offset, format-code, usage}` triples (usage //! `0x00` POSITION `f32×3`, `0x03` NORMAL `f16×4`, `0x05` TEXCOORD `f16×2`). @@ -145,6 +153,19 @@ impl Xbg7Model { return Err(MeshError::UnsupportedLayout); } + if std::env::var("XMESHDBG").is_ok() { + let d = &bytes[desc..desc_end]; + eprintln!("[{name}] stride={} records={records:?}", decl.stride); + let mut rel = 0usize; + while rel + 8 <= d.len() { + let (a, c) = (be32(d, rel), be32(d, rel + 4)); + if c >= 3 && c % 3 == 0 && c < 400_000 && a == c * 2 { + eprintln!(" idx-marker @0x{rel:03x}: idx_count={c}"); + } + rel += 4; + } + } + // Carve the data section sequentially. let base = header.header_size as usize; let mut off = 0usize; // relative to `base` @@ -166,15 +187,21 @@ impl Xbg7Model { return Err(MeshError::UnsupportedLayout); } - // Indices (u16 BE), validated against the sub-mesh vertex count. - let mut indices = Vec::with_capacity(idx_count); + // The index buffer is a triangle STRIP (u16 BE), validated against the + // sub-mesh vertex count. Reading it as a triangle *list* dropped ~⅔ of + // the surface (the classic "triangular holes in the hull") and produced + // spanning spikes; a strip of N indices is N-2 triangles with + // alternating winding. Expand to a triangle list here, skipping + // degenerate triangles (repeated index = strip restart / zero area). + let mut strip = Vec::with_capacity(idx_count); for k in 0..idx_count { let i = be16(bytes, ib + k * 2) as u32; if i >= vtx_count as u32 { return Err(MeshError::UnsupportedLayout); } - indices.push(i); + strip.push(i); } + let indices = expand_triangle_strip(&strip); // Vertices per the declaration. The .xpr stores each element in // naive big-endian component order (f32 / f16 read at consecutive @@ -531,9 +558,10 @@ fn read_pool_mesh( decl: &VertexDecl, ) -> GameMesh { let stride = decl.stride; - let indices: Vec = (0..index_count) + let strip: Vec = (0..index_count) .map(|k| be16(bytes, ib + k * 2) as u32) .collect(); + let indices = expand_triangle_strip(&strip); let mut positions = Vec::with_capacity(vtx_count); let mut normals = Vec::with_capacity(vtx_count); @@ -713,6 +741,25 @@ fn submesh_records(desc: &[u8]) -> Vec<(usize, usize)> { fn align16(x: usize) -> usize { (x + 15) & !15 } + +/// Expand a triangle-**strip** index buffer into a triangle **list**, dropping +/// degenerate triangles (a repeated index marks a strip restart / zero-area +/// triangle). XBG7 sub-mesh index buffers are strips: reading them as lists +/// dropped ~⅔ of every hull (triangular holes) and produced spanning spikes. +fn expand_triangle_strip(strip: &[u32]) -> Vec { + let mut out = Vec::with_capacity(strip.len().saturating_sub(2) * 3); + for w in 0..strip.len().saturating_sub(2) { + let (a, b, c) = if w % 2 == 0 { + (strip[w], strip[w + 1], strip[w + 2]) + } else { + (strip[w + 1], strip[w], strip[w + 2]) + }; + if a != b && b != c && a != c { + out.extend_from_slice(&[a, b, c]); + } + } + out +} #[inline] fn be16(b: &[u8], o: usize) -> u16 { u16::from_be_bytes([b[o], b[o + 1]])