From 95de29f290f8b947acd08ee854b3e1e707d1b938 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 19 Jul 2026 20:15:41 +0200 Subject: [PATCH] feat(formats,viewer): movie subtitles, voice decode, and the movie manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The movie cutscene subtitle + voice pipeline, driven by the ADVERTISE_MOVIE manifest (the authoritative movie -> subtitle -> voice index). Also flushes several sessions of local WIP (async viewer loading, grouped-pool XBG7/hero-ship decode, drawlog tooling). See docs/HANDOFF-movie-voice-subtitles-2026-07-19.md. Subtitles (movie_subtitle.rs): - Full movie->track->text chain; join multi-line captions sharing one timing (fixes S13A dropped "Look at it father" line); overlap-safe active_cues(); Latin-1 accents preserved. Voice (slb.rs): XACT .slb -> XMA1 RIFF; take the FIRST sub-wave bounded by its declared data size (fixes S10-S16 alternate-take garble); list_voice_clips. Manifest (movie_manifest.rs): parse ADVERTISE_MOVIE (0x5B983A08) for the real movie->voice binding (not always VOICE_; e.g. hokyu -> VOICE_D_* in etc\). Resolve the token's sound.pak path via sounds.tbl. DIRECT bindings only — the demo-id shared-clip fallback for unbound hokyu movies was verified WRONG in-game and reverted (unbound hokyu stay unvoiced; correct join key is an OPEN problem). Viewer: manifest-driven voice (movie player toggle + solo button), standalone "Voice Lines" browser, stacked caption overlay. Tests: 46 formats-lib + 11 viewer-lib + movie_manifest/movie_subtitle/slb disc tests; full workspace green. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 40 + crates/sylpheed-cli/src/main.rs | 160 +- crates/sylpheed-formats/Cargo.toml | 6 + crates/sylpheed-formats/src/lib.rs | 10 + crates/sylpheed-formats/src/mesh.rs | 587 ++++- crates/sylpheed-formats/src/movie_manifest.rs | 184 ++ crates/sylpheed-formats/src/movie_subtitle.rs | 417 ++++ crates/sylpheed-formats/src/pak.rs | 12 + crates/sylpheed-formats/src/slb.rs | 249 ++ crates/sylpheed-formats/tests/mesh_disc.rs | 115 +- .../tests/movie_manifest_disc.rs | 102 + .../tests/movie_subtitle_disc.rs | 63 + crates/sylpheed-formats/tests/slb_disc.rs | 104 + crates/sylpheed-viewer/src/camera.rs | 82 +- crates/sylpheed-viewer/src/iso_loader.rs | 2175 +++++++++++++---- crates/sylpheed-viewer/src/lib.rs | 27 +- crates/sylpheed-viewer/src/ui.rs | 299 ++- ...ANDOFF-movie-voice-subtitles-2026-07-19.md | 95 + docs/re/structures/movie-subtitles.md | 174 ++ docs/re/structures/sound-slb.md | 62 + tools/analyze_drawlog_wvp.py | 126 + tools/extract_movie_subtitles.py | 88 + 22 files changed, 4684 insertions(+), 493 deletions(-) create mode 100644 crates/sylpheed-formats/src/movie_manifest.rs create mode 100644 crates/sylpheed-formats/src/movie_subtitle.rs create mode 100644 crates/sylpheed-formats/src/slb.rs create mode 100644 crates/sylpheed-formats/tests/movie_manifest_disc.rs create mode 100644 crates/sylpheed-formats/tests/movie_subtitle_disc.rs create mode 100644 crates/sylpheed-formats/tests/slb_disc.rs create mode 100644 docs/HANDOFF-movie-voice-subtitles-2026-07-19.md create mode 100644 docs/re/structures/movie-subtitles.md create mode 100644 docs/re/structures/sound-slb.md create mode 100644 tools/analyze_drawlog_wvp.py create mode 100644 tools/extract_movie_subtitles.py diff --git a/Cargo.lock b/Cargo.lock index a24c9b9..896db78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1892,6 +1892,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -4107,6 +4126,26 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -4587,6 +4626,7 @@ dependencies = [ "binrw", "flate2", "futures", + "rayon", "serde", "serde_json", "thiserror 2.0.18", diff --git a/crates/sylpheed-cli/src/main.rs b/crates/sylpheed-cli/src/main.rs index 57e9599..36270bf 100644 --- a/crates/sylpheed-cli/src/main.rs +++ b/crates/sylpheed-cli/src/main.rs @@ -523,6 +523,23 @@ fn cmd_mesh_info(file: &Path) -> Result<()> { nv.saturating_sub(1), if oob > 0 { format!(", OOB {oob}") } else { String::new() }, ); + // XDUMPVERT=1 → print the first few vertex positions per sub-mesh, for + // content-matching a decoded sub-mesh against the GPU draw log. + if std::env::var("XDUMPVERT").is_ok() { + let mut lo = [f32::MAX; 3]; + let mut hi = [f32::MIN; 3]; + 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!( + " bbox X[{:.2}..{:.2}] Y[{:.2}..{:.2}] Z[{:.2}..{:.2}] ctr({:.2},{:.2},{:.2})", + lo[0], hi[0], lo[1], hi[1], lo[2], hi[2], + (lo[0]+hi[0])/2.0, (lo[1]+hi[1])/2.0, (lo[2]+hi[2])/2.0 + ); + } } } println!( @@ -568,7 +585,32 @@ fn cmd_mesh_render( // 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; + // XMIRROR=x|y|z → negate that axis, to test an Xbox(LH)→Bevy(RH) handedness + // flip against reference screenshots. + let mirror: [f32; 3] = match std::env::var("XMIRROR").ok().as_deref() { + Some("x") => [-1.0, 1.0, 1.0], + Some("y") => [1.0, -1.0, 1.0], + Some("z") => [1.0, 1.0, -1.0], + _ => [1.0, 1.0, 1.0], + }; let mut tris: Vec<[[f32; 3]; 3]> = Vec::new(); + // XCOLORSUB=1 tints each sub-mesh a distinct colour (to see which sub is + // which part / where the "extra fin" comes from). Parallel to `tris`. + let color_sub = std::env::var("XCOLORSUB").is_ok(); + // XONLYSUB=N renders only the N-th global sub-mesh (to isolate one part). + let only_sub: Option = std::env::var("XONLYSUB").ok().and_then(|s| s.parse().ok()); + let mut tints: Vec<[f32; 3]> = Vec::new(); + const PALETTE: [[f32; 3]; 8] = [ + [1.0, 1.0, 1.0], // sub0 body = white + [1.0, 0.35, 0.35], // sub1 red + [0.35, 1.0, 0.35], // sub2 green + [0.4, 0.55, 1.0], // sub3 blue + [1.0, 0.9, 0.3], // sub4 yellow + [1.0, 0.5, 1.0], // sub5 magenta + [0.3, 1.0, 1.0], // sub6 cyan + [1.0, 0.6, 0.2], // sub7 orange + ]; + let mut sub_gi = 0usize; const CELL: f32 = 10.0; const GAP: f32 = 4.0; let grid_pitch = CELL + GAP; @@ -600,31 +642,105 @@ fn cmd_mesh_render( } else { (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], - ] + // XNODEXFORM=1 applies the XBG7 scene-graph node placement (fins move to + // the tail) — to verify the transforms recovered from the graph. + let placements = if std::env::var("XNODEXFORM").is_ok() { + sylpheed_formats::mesh::node_transforms(&bytes, &m.name) + } else { + Vec::new() + }; + for (sub_local, sub) in m.meshes.iter().enumerate() { + // Every scene-graph instance that draws this sub-mesh (mirrored fin + // pair, L/R winglets…); `None` = no graph placement → identity. + let mine: Vec> = { + let v: Vec<_> = placements + .iter() + .filter(|p| p.sub_index == sub_local) + .map(Some) + .collect(); + if v.is_empty() { + vec![None] + } else { + v + } }; // 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 < n && b < n && c < n { - tris.push([f(a), f(b), f(c)]); + // XSPANONLY=1 renders ONLY long-edge ("spanning") triangles; XSPANHIDE=1 + // renders everything EXCEPT them — to see whether the flagged spanning + // triangles are real geometry or decode artifacts (phantom sheets). + let span_only = std::env::var("XSPANONLY").is_ok(); + let span_hide = std::env::var("XSPANHIDE").is_ok(); + let med = { + let mut e: Vec = sub + .indices + .chunks_exact(3) + .filter(|t| (t[0] as usize) < n && (t[1] as usize) < n && (t[2] as usize) < n) + .map(|t| { + let d = |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() + }; + d(t[0], t[1]).max(d(t[1], t[2])).max(d(t[0], t[2])) + }) + .collect(); + e.sort_by(|a, b| a.partial_cmp(b).unwrap()); + e.get(e.len() / 2).copied().unwrap_or(1.0).max(1e-6) + }; + if let Some(want) = only_sub { + if sub_gi != want { + sub_gi += 1; + continue; } } + let tint = if color_sub { + PALETTE[sub_gi % PALETTE.len()] + } else { + [1.0, 1.0, 1.0] + }; + for place in &mine { + let f = |i: usize| { + let p = place.map(|pl| pl.apply(sub.positions[i])).unwrap_or(sub.positions[i]); + [ + (p[0] - center[0]) * scale * mirror[0] + cell[0], + (p[1] - center[1]) * scale * mirror[1] + cell[1], + (p[2] - center[2]) * scale * mirror[2] + cell[2], + ] + }; + 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 < n && b < n && c < n { + if span_only || span_hide { + let d = |i: usize, j: usize| { + let (p, q) = (sub.positions[i], sub.positions[j]); + ((p[0] - q[0]).powi(2) + + (p[1] - q[1]).powi(2) + + (p[2] - q[2]).powi(2)) + .sqrt() + }; + let spanning = d(a, b).max(d(b, c)).max(d(a, c)) > 6.0 * med; + if span_only && !spanning { + continue; + } + if span_hide && spanning { + continue; + } + } + tris.push([f(a), f(b), f(c)]); + tints.push(tint); + } + } + } + sub_gi += 1; } } if tris.is_empty() { anyhow::bail!("no triangles to render"); } - let rgba = rasterize(&tris, size, yaw, pitch, dist); + let rgba = rasterize(&tris, &tints, size, yaw, pitch, dist); image::save_buffer(output, &rgba, size, size, image::ExtendedColorType::Rgba8) .with_context(|| format!("writing PNG {}", output.display()))?; println!( @@ -643,7 +759,14 @@ 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, dist: f32) -> Vec { +fn rasterize( + tris: &[[[f32; 3]; 3]], + tints: &[[f32; 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()); @@ -691,7 +814,8 @@ fn rasterize(tris: &[[[f32; 3]; 3]], size: u32, yaw_deg: f32, pitch_deg: f32, di [l[0] / m, l[1] / m, l[2] / m] }; - for t in tris { + for (ti, t) in tris.iter().enumerate() { + let tint = tints.get(ti).copied().unwrap_or([1.0, 1.0, 1.0]); let v0 = view(t[0]); let v1 = view(t[1]); let v2 = view(t[2]); @@ -739,9 +863,9 @@ fn rasterize(tris: &[[[f32; 3]; 3]], size: u32, yaw_deg: f32, pitch_deg: f32, di 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[idx * 4] = (shade as f32 * tint[0]).min(255.0) as u8; + color[idx * 4 + 1] = (shade as f32 * tint[1]).min(255.0) as u8; + color[idx * 4 + 2] = (shade as f32 * tint[2] * 1.02).min(255.0) as u8; } } } diff --git a/crates/sylpheed-formats/Cargo.toml b/crates/sylpheed-formats/Cargo.toml index b1456b7..1cd5d22 100644 --- a/crates/sylpheed-formats/Cargo.toml +++ b/crates/sylpheed-formats/Cargo.toml @@ -19,5 +19,11 @@ tracing = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +# Native-only: parallelise the per-resource XBG7 stage anchoring (hundreds of +# independent sub-models per container). rayon needs OS threads, so it is not +# pulled in for the wasm32 build, which falls back to a sequential decode. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rayon = "1" + [dev-dependencies] tokio = { workspace = true } diff --git a/crates/sylpheed-formats/src/lib.rs b/crates/sylpheed-formats/src/lib.rs index 1db1b81..6e04751 100644 --- a/crates/sylpheed-formats/src/lib.rs +++ b/crates/sylpheed-formats/src/lib.rs @@ -50,10 +50,20 @@ pub mod mesh; // Audio parsing — scaffold for XMA handling pub mod audio; +// Movie cutscene subtitles — the movie → track → text chain. +pub mod movie_subtitle; + +// XACT `.slb` sound banks (voice / music) → decodable XMA1 RIFF. +pub mod slb; + +// The movie manifest: authoritative movie → subtitle → voice index. +pub mod movie_manifest; + /// Re-export the most commonly used types at the crate root. pub use font::FontInfo; pub use idxd::{IdxdError, IdxdObject}; pub use ixud::{Cue, Subtitle}; +pub use movie_subtitle::{SubCue, SubLang}; pub use mesh::{GameMesh, Xbg7Model}; pub use ratc::RatcChild; pub use t8ad::T8adImage; diff --git a/crates/sylpheed-formats/src/mesh.rs b/crates/sylpheed-formats/src/mesh.rs index 3812541..fef3ea1 100644 --- a/crates/sylpheed-formats/src/mesh.rs +++ b/crates/sylpheed-formats/src/mesh.rs @@ -385,10 +385,31 @@ impl Xbg7Model { Self::anchor_models(bytes, 0.0) } + /// Like [`Xbg7Model::stage_models`] but abortable: `should_cancel` is polled + /// between resources so a viewer can drop an in-flight decode when the user + /// selects a different file. Returns whatever decoded before the cancel. + pub fn stage_models_cancellable( + bytes: &[u8], + should_cancel: &(dyn Fn() -> bool + Sync), + ) -> Vec { + Self::anchor_models_cancellable(bytes, 0.0, should_cancel) + } + /// Content-anchor every XBG7 resource, rejecting any block whose winding /// consistency (`max(na, 1-na)`) is below `min_consistency`. See /// [`Xbg7Model::stage_models`]. pub fn anchor_models(bytes: &[u8], min_consistency: f32) -> Vec { + Self::anchor_models_cancellable(bytes, min_consistency, &|| false) + } + + /// [`Xbg7Model::anchor_models`] with a cancellation poll checked between + /// resources (a large stage container holds hundreds). See + /// [`Xbg7Model::stage_models_cancellable`]. + pub fn anchor_models_cancellable( + bytes: &[u8], + min_consistency: f32, + should_cancel: &(dyn Fn() -> bool + Sync), + ) -> Vec { let mut out = Vec::new(); if bytes.len() < 16 || &bytes[..4] != b"XPR2" { return out; @@ -471,7 +492,17 @@ impl Xbg7Model { starts_by_stride.insert(s, vertex_run_starts(bytes, data_base, s)); } - for r in &resources { + // Anchor each resource. Resources are independent (the shared + // `starts_by_stride` is read-only from here on), so a big stage's + // hundreds of sub-models are decoded in parallel on native builds — + // the dominant cost of loading a stage container. `filter_map(...).collect()` + // preserves resource order, so the output is identical to the sequential + // decode. `should_cancel()` is polled per resource so a superseded load + // stops promptly. + let decode_one = |r: &Res| -> Option { + if should_cancel() { + return None; + } let starts = &starts_by_stride[&r.decl.stride]; let meshes = if r.markers.len() == 1 { // Single sub-mesh → the proven per-block adjacency anchor @@ -499,12 +530,20 @@ impl Xbg7Model { .collect() } }; - if !meshes.is_empty() { - out.push(Xbg7Model { - name: r.name.clone(), - meshes, - }); - } + (!meshes.is_empty()).then(|| Xbg7Model { + name: r.name.clone(), + meshes, + }) + }; + + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out = resources.par_iter().filter_map(decode_one).collect(); + } + #[cfg(target_arch = "wasm32")] + { + out = resources.iter().filter_map(decode_one).collect(); } out } @@ -1210,6 +1249,540 @@ fn read_cstr(b: &[u8], o: usize) -> Option { Some(String::from_utf8_lossy(&b[o..end]).into_owned()) } +// ── Per-sub-mesh material graph ────────────────────────────────────────────── + +/// Find the next `rou_…_col` NUL-terminated ASCII string in `d` within `limit` +/// bytes of `from`, returned with the `rou_` prefix stripped so it matches the +/// `TX2D` resource name directly (`rou_f001_bdy_04_col` → `f001_bdy_04_col`). +fn next_col_name(d: &[u8], from: usize, limit: usize) -> Option { + let end = (from + limit).min(d.len()); + let mut i = from; + while i + 4 <= end { + if &d[i..i + 4] == b"rou_" { + let mut j = i; + while j < d.len() && d[j] != 0 && d[j].is_ascii_graphic() { + j += 1; + } + if let Ok(s) = std::str::from_utf8(&d[i..j]) { + if s.ends_with("_col") { + return Some(s.trim_start_matches("rou_").to_string()); + } + } + i = j.max(i + 1); + } else { + i += 1; + } + } + None +} + +/// Per-sub-mesh albedo texture name from the XBG7 node/material graph. +/// +/// The XBG7 descriptor is a scene graph in which each geometry record +/// `[vtx:u32][0:u32][idx:u32][tail:u32]` is immediately followed by its material +/// node, whose first `rou_…_col` string names the albedo texture. Stripping the +/// `rou_` prefix yields the exact `TX2D` resource name, so a caller can texture +/// each sub-mesh individually (matching by `(vtx_count, idx_count)`) instead of +/// painting the whole hull with a single map. Returns `(vtx, idx, albedo_name)` +/// per record in descriptor order for the XBG7 resource named `resource_name` +/// (empty when the file/resource/graph can't be read). +pub fn submesh_albedos(bytes: &[u8], resource_name: &str) -> Vec<(usize, usize, String)> { + 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, + }; + const DIR_BASE: usize = 0x10; + let (mut desc, mut desc_end) = (0usize, 0usize); + 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 nm = read_cstr(bytes, e.name_offset as usize + DIR_BASE).unwrap_or_default(); + if nm == resource_name { + desc = e.data_offset as usize + DIR_BASE; + desc_end = (desc + e.descriptor_size as usize).min(bytes.len()); + break; + } + } + if desc == 0 || desc >= desc_end { + return out; + } + let d = &bytes[desc..desc_end]; + let mut rel = 0usize; + while rel + 16 <= d.len() { + let vtx = be32(d, rel); + let z = be32(d, rel + 4); + let idx = be32(d, rel + 8); + let tail = be32(d, rel + 12); + if (3..=65535).contains(&vtx) + && z == 0 + && idx >= 3 + && idx % 3 == 0 + && idx < 400_000 + && tail > 0 + && tail < 0x10_0000 + { + // The material node with the `_col` name sits a few dozen bytes past + // the record (≤ 0x100 in observed ships); bound the scan so a record + // without its own material can't borrow the next part's. + if let Some(name) = next_col_name(d, rel + 16, 0x100) { + out.push((vtx as usize, idx as usize, name)); + } + rel += 16; + continue; + } + rel += 4; + } + out +} + +/// Locate the descriptor byte range of the named XBG7 resource in an XPR2 file. +fn xbg7_descriptor(bytes: &[u8], resource_name: &str) -> Option<(usize, usize)> { + if bytes.len() < 16 || &bytes[..4] != b"XPR2" { + return None; + } + let mut cur = Cursor::new(bytes); + let header = Xpr2Header::read(&mut cur).ok()?; + const DIR_BASE: usize = 0x10; + for _ in 0..header.num_resources { + let e = Xpr2ResourceEntry::read(&mut cur).ok()?; + if &e.type_tag != b"XBG7" { + continue; + } + let nm = read_cstr(bytes, e.name_offset as usize + DIR_BASE).unwrap_or_default(); + if nm == resource_name { + let desc = e.data_offset as usize + DIR_BASE; + let end = (desc + e.descriptor_size as usize).min(bytes.len()); + return (desc < end).then_some((desc, end)); + } + } + None +} + +/// One material draw-group within a (possibly merged) sub-mesh: a slice +/// `indices[idx_offset .. idx_offset + idx_count]` textured with `albedo`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MaterialGroup { + pub idx_offset: usize, + pub idx_count: usize, + /// TX2D albedo resource name (`rou_` prefix stripped). + pub albedo: String, +} + +/// Split a merged sub-mesh into its material draw-groups using the XBG7 scene +/// graph. Each part is drawn as one or more `[vtx][idx_offset][idx_count][4]` +/// records, each tagged with its own `_col` albedo. A single-material part is +/// one record (`idx_offset == 0`); the hull **body** is several records that +/// share one index buffer, their offsets chaining cumulatively (0, 18, 174, …). +/// +/// Given the merged mesh's `target` index count, returns the ordered groups +/// forming the cumulative chain that sums to `target` — so the caller can slice +/// the merged index array and texture each slice correctly (the Delta Saber body +/// = bdy_01a hull + bdy_01b + bdy_02/03 + daiza stand). Empty when no chain +/// matches (caller falls back to a single stem-matched albedo). +pub fn material_groups(bytes: &[u8], resource_name: &str, target: usize) -> Vec { + let Some((desc, desc_end)) = xbg7_descriptor(bytes, resource_name) else { + return Vec::new(); + }; + let d = &bytes[desc..desc_end]; + + // Collect every `[vtx][idx_offset][idx_count][tail=4]` draw record that has a + // `_col` material within reach. + struct Rec { + off: usize, + cnt: usize, + albedo: String, + } + let mut recs: Vec = Vec::new(); + let mut rel = 0usize; + while rel + 16 <= d.len() { + let vtx = be32(d, rel); + let off = be32(d, rel + 8 - 4); // idx_offset at rel+4 + let cnt = be32(d, rel + 8); + let tail = be32(d, rel + 12); + if (1..=70000).contains(&vtx) + && tail == 4 + && cnt >= 3 + && cnt % 3 == 0 + && cnt < 400_000 + && (off as usize) < 4_000_000 + { + if let Some(albedo) = next_col_name(d, rel + 16, 0x140) { + recs.push(Rec { + off: off as usize, + cnt: cnt as usize, + albedo, + }); + rel += 16; + continue; + } + } + rel += 4; + } + + // Follow the cumulative chain from each `idx_offset == 0` start; return the + // one whose running total equals `target`. + for (si, s) in recs.iter().enumerate() { + if s.off != 0 { + continue; + } + let mut chain = vec![MaterialGroup { + idx_offset: 0, + idx_count: s.cnt, + albedo: s.albedo.clone(), + }]; + let mut total = s.cnt; + // Extend while a later record picks up exactly where this one ends. + loop { + if total == target { + return chain; + } + if total > target { + break; + } + let want = total; + match recs + .iter() + .enumerate() + .find(|(j, r)| *j != si && r.off == want && r.cnt > 0) + { + Some((_, r)) => { + chain.push(MaterialGroup { + idx_offset: r.off, + idx_count: r.cnt, + albedo: r.albedo.clone(), + }); + total += r.cnt; + } + None => break, + } + } + } + Vec::new() +} + +#[inline] +fn be_f64(b: &[u8], o: usize) -> f64 { + if o + 8 > b.len() { + return 0.0; + } + f64::from_be_bytes([ + b[o], + b[o + 1], + b[o + 2], + b[o + 3], + b[o + 4], + b[o + 5], + b[o + 6], + b[o + 7], + ]) +} + +/// Placement of one drawn geometry-node *instance* from the XBG7 scene graph. +/// +/// A geometry may be drawn several times (a mirrored fin pair, L/R winglets); +/// each draw is one `NodePlacement`. The world transform is `m·v + t` applied to +/// the local vertex `v` (vertex space: X right, Y up, Z fore/aft). +#[derive(Debug, Clone, PartialEq)] +pub struct NodePlacement { + /// Index of the decoded sub-mesh this instance draws (sub-meshes come out of + /// the decoder in the graph's first-encounter geometry order, so this is + /// stable). Match a placement to `model.meshes[sub_index]`. + pub sub_index: usize, + /// Vertex count of the geometry — a cross-check against the sub-mesh. + pub vtx_count: usize, + /// 3×3 linear part (rotation, and an X-reflection for mirrored instances), + /// row-major: `world[r] = Σ_c m[r][c]·v[c] + t[r]`. + pub m: [[f32; 3]; 3], + /// World translation (vertex space). + pub t: [f32; 3], + /// True when this instance is an X-reflection (its triangle winding is + /// flipped — reverse index order to keep front faces outward). + pub reflect: bool, +} + +impl NodePlacement { + /// Apply the world transform to a local vertex. + pub fn apply(&self, v: [f32; 3]) -> [f32; 3] { + [ + self.m[0][0] * v[0] + self.m[0][1] * v[1] + self.m[0][2] * v[2] + self.t[0], + self.m[1][0] * v[0] + self.m[1][1] * v[1] + self.m[1][2] * v[2] + self.t[1], + self.m[2][0] * v[0] + self.m[2][1] * v[1] + self.m[2][2] * v[2] + self.t[2], + ] + } +} + +// Small 3×3 affine helpers (formats crate is glam-free). +type M3 = [[f32; 3]; 3]; +const M3_ID: M3 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; +fn m3_mul(a: M3, b: M3) -> M3 { + let mut o = [[0.0f32; 3]; 3]; + for r in 0..3 { + for c in 0..3 { + o[r][c] = a[r][0] * b[0][c] + a[r][1] * b[1][c] + a[r][2] * b[2][c]; + } + } + o +} +fn m3_vec(a: M3, v: [f32; 3]) -> [f32; 3] { + [ + a[0][0] * v[0] + a[0][1] * v[1] + a[0][2] * v[2], + a[1][0] * v[0] + a[1][1] * v[1] + a[1][2] * v[2], + a[2][0] * v[0] + a[2][1] * v[1] + a[2][2] * v[2], + ] +} +/// Rotation about X (lateral) — mixes Y and Z (fin/flap pitch). +fn rot_x(a: f32) -> M3 { + let (s, c) = a.sin_cos(); + [[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]] +} +/// Rotation about Z (fore/aft) — mixes X and Y (V-tail cant / roll). +fn rot_z(a: f32) -> M3 { + let (s, c) = a.sin_cos(); + [[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]] +} + +/// The DeltaSaber fin-assembly part names — the structural signature that marks a +/// DeltaSaber-family model (`_T`/`_W`/`_A` = f001/f002/f004; same layout, slightly +/// different vertex counts) so the measured placements below apply to all three. +const SABER_PARTS: [&str; 5] = ["bdy_04", "bdy_06", "bdy_07", "bdy_10", "bdy_11"]; + +/// Ground-truth world placement for a DeltaSaber fin part, keyed by node-name +/// suffix, MEASURED from the running game (Canary draw log: +/// `world = inv(body_WVP)·part_WVP` over the vertex-shader constants — see +/// tools/analyze_drawlog_wvp.py). The static XBG7 node graph places the fin +/// assemblies inboard at the centreline and omits the per-nacelle mount transform +/// the engine applies at runtime; these matrices are that runtime placement (the +/// LEFT instance — the right is its X-reflection). Keyed by part role so it +/// transfers across the identically-laid-out `_T`/`_W`/`_A` variants. +/// +/// Verified: the recovered rotations match the graph exactly (fin = Rz(0.297), +/// flap = Rz(0.297)·Rx(−0.33)); only the translation carried the missing nacelle +/// offset (world X ≈ −4.4..−6.6, absent from the file). +fn saber_measured(part: &str) -> Option<(M3, [f32; 3])> { + Some(match part { + // fin bdy_04 — V-tail, canted; mounted on the nacelle. + "bdy_04" => ( + [[0.9563, -0.2924, 0.0], [0.2924, 0.9563, 0.0], [0.0, 0.0, 1.0]], + [-5.2350, 1.0679, -9.7182], + ), + // flaps bdy_04_2 / bdy_04_3 — ride the fin (compound rotation). + "bdy_04_2" => ( + [[0.9563, -0.2766, -0.0947], [0.2924, 0.9048, 0.3098], [-0.0001, -0.3240, 0.9461]], + [-6.1562, 3.3121, -10.5028], + ), + "bdy_04_3" => ( + [[0.9563, -0.2766, -0.0947], [0.2924, 0.9048, 0.3098], [-0.0001, -0.3240, 0.9461]], + [-5.7261, 3.4436, -10.5026], + ), + // winglet bdy_06 + tip bdy_07 — on the nacelle (no cant). + "bdy_06" => (M3_ID, [-6.5988, -1.9838, -15.4623]), + "bdy_07" => (M3_ID, [-6.5988, -1.9838, -16.2893]), + // small fin bdy_10 + tip bdy_11 — inboard nacelle stub. + "bdy_10" => (M3_ID, [-4.4496, -4.3962, -16.9703]), + "bdy_11" => (M3_ID, [-4.4496, -4.3962, -17.4694]), + _ => return None, + }) +} + +/// Recover per-node placement from the XBG7 scene graph. +/// +/// The graph's head is a node hierarchy: each node stores an 8-value TRS +/// (3 translation, 2 rotation, 3 scale) as big-endian **f64** in a joint table +/// pointed to at `node+0x44`, and the vertex count of its geometry at +/// `node+0x48 (+0x1C)`. The body (`bdy_01`) is identity, but the fins are placed +/// by a translation toward the tail (fore-aft ≈ −15) that the *rigid* vertex +/// buffer omits — the game applies it in the vertex shader, which is why the raw +/// buffer (and our decode) puts the fins at the front. `Fin_*_root` nodes carry +/// the transform for their otherwise-identity geometry children. +/// +/// The hierarchy is a first-child / next-sibling tree: each node record ends +/// with `[child_ptr][sibling_ptr][0xFFFFFFFF]`, and a pointer's target node name +/// starts 4 bytes before it. That lets us recover each node's subtree extent +/// exactly (a node owns every record between it and its next sibling), so a +/// child's transform composes onto its parent's — the flaps ride the fin, the +/// winglets ride their `Fin_*_root` mount. +/// +/// Mirroring: a geometry drawn twice (fin V-tail pair, L/R winglets, L/R small +/// fins) stores identical or sign-flipped data for the two copies; the engine +/// draws the second as an **X-reflection** of the first. We reproduce that — the +/// second and later instances of a geometry reflect the first instance's world. +/// +/// Returns one [`NodePlacement`] per drawn instance (so a mirrored pair yields +/// two). Empty when the graph can't be read. +pub fn node_transforms(bytes: &[u8], resource_name: &str) -> Vec { + let Some((desc, desc_end)) = xbg7_descriptor(bytes, resource_name) else { + return Vec::new(); + }; + let d = &bytes[desc..desc_end]; + // Nodes live in the head, before the first geometry record (~0x1684). + let head_end = d.len().min(0x1684); + let prefix = format!("rou_{resource_name}_"); + + // 8-value TRS from a node's `+0x44` joint table (8 pointers, each f64 @ +8); + // returns identity-safe zeros when the table is absent. + let read_trs = |t44: usize| -> [f64; 8] { + let mut v = [0.0f64; 8]; + for (k, slot) in v.iter_mut().enumerate() { + let p = be32(d, t44 + k * 4) as usize; + if p != 0 && p + 16 <= d.len() { + *slot = be_f64(d, p + 8); + } + } + v + }; + + // Phase 1: collect node records in document order, each with its local + // affine and the offset where its subtree ends (its next sibling). + struct Rec { + name_start: usize, + part: String, // node name with the `rou__` prefix stripped + vtx: usize, + t48: usize, + local_m: M3, + local_t: [f32; 3], + sib_end: Option, + } + let mut recs: Vec = Vec::new(); + let mut i = 0usize; + while i + 4 <= head_end { + if &d[i..i + 4] != b"rou_" { + i += 1; + continue; + } + let mut j = i; + while j < d.len() && d[j] != 0 && d[j].is_ascii_graphic() { + j += 1; + } + let name = std::str::from_utf8(&d[i..j]).unwrap_or(""); + let is_node = name.starts_with(&prefix) + && !name.ends_with("_col") + && !name.ends_with("_spc") + && !name.ends_with("_gls") + && !name.ends_with("_lum"); + if !is_node { + i = j.max(i + 1); + continue; + } + // Each node ends with [child_ptr][sibling_ptr][0xFFFFFFFF]; the two table + // offsets precede the child/sibling pair. + let mut ff = i; + let scan_end = (i + 0x90).min(d.len().saturating_sub(4)); + while ff < scan_end && be32(d, ff) != 0xFFFF_FFFF { + ff += 4; + } + if ff >= scan_end || ff < 0x10 { + i = j.max(i + 1); + continue; + } + let t44 = be32(d, ff - 0x10) as usize; + let t48 = be32(d, ff - 0x0C) as usize; + let sib_ptr = be32(d, ff - 0x04) as usize; + let vtx = if t48 != 0 && t48 + 0x20 <= d.len() { + be32(d, t48 + 0x1C) as usize + } else { + 0 + }; + let trs = if t44 != 0 && t44 + 32 <= d.len() { + read_trs(t44) + } else { + [0.0; 8] + }; + if std::env::var("XNODEDUMP").is_ok() { + eprintln!( + "NODE {name} vtx={vtx} t44={t44:#x} t48={t48:#x} ff@{ff:#x} sib@{sib_ptr:#x} TRS=[{:.4} {:.4} {:.4} | {:.4} {:.4} | {:.4} {:.4} {:.4}]", + trs[0], trs[1], trs[2], trs[3], trs[4], trs[5], trs[6], trs[7] + ); + } + // Local transform: translation (t0=X, t2=up→Y, t1=fore/aft→Z), and a + // rotation Rz(r1)·Rx(r0) (r1 = V-tail cant about fore/aft, r0 = pitch). + let local_t = [trs[0] as f32, trs[2] as f32, trs[1] as f32]; + let local_m = m3_mul(rot_z(trs[4] as f32), rot_x(trs[3] as f32)); + // Next sibling: its name starts 4 bytes before the pointer, and there a + // real node record begins with "rou_". Everything between here and there + // is this node's subtree. + let sib_end = sib_ptr + .checked_sub(4) + .filter(|&s| s > i && s + 4 <= head_end && &d[s..s + 4] == b"rou_"); + let part = name.strip_prefix(&prefix).unwrap_or(name).to_string(); + recs.push(Rec { name_start: i, part, vtx, t48, local_m, local_t, sib_end }); + i = j.max(i + 1); + } + + // A DeltaSaber-family model (its fin assemblies need the measured runtime + // nacelle mount the static graph omits) is identified by its part-name set. + let is_saber = SABER_PARTS + .iter() + .all(|want| recs.iter().any(|r| r.vtx > 0 && r.part == *want)); + + // Phase 2: walk the subtree intervals, composing each node onto its parent's + // world, and emit a placement per drawn geometry instance. + let mut out = Vec::new(); + let mut stack: Vec<(usize, M3, [f32; 3])> = Vec::new(); // (subtree_end, world_m, world_t) + let mut order: Vec = Vec::new(); // unique t48, first-encounter → sub_index + let mut first_world: std::collections::HashMap = + std::collections::HashMap::new(); + for rec in &recs { + while stack.last().is_some_and(|s| s.0 <= rec.name_start) { + stack.pop(); + } + let (pm, pt) = stack.last().map(|s| (s.1, s.2)).unwrap_or((M3_ID, [0.0; 3])); + // world = parent ∘ local + let wm = m3_mul(pm, rec.local_m); + let r = m3_vec(pm, rec.local_t); + let wt = [r[0] + pt[0], r[1] + pt[1], r[2] + pt[2]]; + let end = rec + .sib_end + .unwrap_or_else(|| stack.last().map(|s| s.0).unwrap_or(head_end)); + if rec.vtx > 0 { + let sub_index = match order.iter().position(|&x| x == rec.t48) { + Some(k) => k, + None => { + order.push(rec.t48); + order.len() - 1 + } + }; + // For a DeltaSaber fin part, use the MEASURED runtime placement (the + // nacelle mount the static graph omits); otherwise the graph world. + let (node_m, node_t) = match is_saber.then(|| saber_measured(&rec.part)).flatten() { + Some((mm, mt)) => (mm, mt), + None => (wm, wt), + }; + if let Some((fm, ft)) = first_world.get(&rec.t48).copied() { + // A repeat draw of this geometry → X-reflection of the first. + let s: M3 = [[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + out.push(NodePlacement { + sub_index, + vtx_count: rec.vtx, + m: m3_mul(s, fm), + t: [-ft[0], ft[1], ft[2]], + reflect: true, + }); + } else { + first_world.insert(rec.t48, (node_m, node_t)); + out.push(NodePlacement { + sub_index, + vtx_count: rec.vtx, + m: node_m, + t: node_t, + reflect: false, + }); + } + } + stack.push((end, wm, wt)); + } + out +} + // ── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/crates/sylpheed-formats/src/movie_manifest.rs b/crates/sylpheed-formats/src/movie_manifest.rs new file mode 100644 index 0000000..481f763 --- /dev/null +++ b/crates/sylpheed-formats/src/movie_manifest.rs @@ -0,0 +1,184 @@ +//! The movie manifest: the game's authoritative movie → subtitle → **voice** +//! index (statically reversed). +//! +//! `dat/tables.pak` holds one `IDXD` table (a `Z1`+zlib block) whose string pool +//! lists every cutscene in play order. Each movie contributes a run of ASCII +//! strings, always led by `.wmv` and optionally followed by: +//! - `+…​.prt` — an overlay/print-art reference, +//! - `+SUBTITLE_.tbl` — its caption timing track, +//! - `VOICE_` — the **voice track basename**. +//! +//! The voice token is the payoff: it is the real bank name to look up in +//! `sounds.tbl`, and it is **not** always `VOICE_`. Most story/radio +//! movies use `VOICE_` (in `\Movie\`), but several `hokyu_*` +//! (resupply) movies bind to in-mission radio clips like `VOICE_D_450` (in +//! `\etc\`), and many `hokyu_*` movies + the boot logos have **no** voice +//! token at all — i.e. the game plays no voice-over for them. Guessing +//! `VOICE_` therefore both misses the `VOICE_D_*` cases and invents a +//! non-existent track for the silent ones; this manifest is the ground truth. +//! +//! We read the manifest by grouping the string pool on `.wmv` (the pool is +//! emitted in the same order as the records), which is robust without decoding +//! the `IDXD` record structure. + +use crate::slb::VoiceLang; + +/// One movie's manifest row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MovieEntry { + /// Movie basename without extension, e.g. `S13A`, `hokyu_DS_s13A`. + pub movie: String, + /// Voice bank basename (e.g. `VOICE_S13A`, `VOICE_D_450`), or `None` when the + /// game assigns no voice-over to this movie. + pub voice_token: Option, +} + +/// Does this blob look like the movie manifest? (`IDXD` whose string pool +/// carries movie + subtitle + voice references.) Used to locate it in +/// `tables.pak` without hardcoding a hash. +pub fn is_manifest(bytes: &[u8]) -> bool { + bytes.len() >= 4 + && &bytes[0..4] == b"IDXD" + && contains(bytes, b".wmv") + && contains(bytes, b"VOICE_") + && contains(bytes, b"SUBTITLE_") +} + +/// Parse the manifest's string pool into per-movie rows, in play order. +pub fn parse(bytes: &[u8]) -> Vec { + let mut entries: Vec = Vec::new(); + for run in ascii_runs(bytes, 3) { + if let Some(name) = run.strip_suffix(".wmv") { + entries.push(MovieEntry { + movie: name.to_string(), + voice_token: None, + }); + } else if run.starts_with("VOICE_") { + // Belongs to the movie record currently being built. + if let Some(last) = entries.last_mut() { + if last.voice_token.is_none() { + last.voice_token = Some(run.clone()); + } + } + } + } + entries +} + +/// The voice bank basename bound to `movie`, if the manifest lists one. +/// +/// Returns `None` both when the movie is absent and when it is present with no +/// voice — callers that need to distinguish should use [`parse`]. +pub fn voice_token(bytes: &[u8], movie: &str) -> Option { + parse(bytes) + .into_iter() + .find(|e| e.movie == movie) + .and_then(|e| e.voice_token) +} + +/// Resolve a movie to its full `sound.pak` voice-entry name for `lang`, using the +/// manifest for the *token* and `sounds.tbl` for the token's *directory* (Movie / +/// etc / Voice differ per token). `None` = the movie has no voice-over. +pub fn resolve_voice_entry( + manifest: &[u8], + sounds_tbl: &[u8], + movie: &str, + lang: VoiceLang, +) -> Option { + let token = voice_token(manifest, movie)?; + resolve_token_path(sounds_tbl, &token, lang) +} + +/// Find the full `\…\.slb` path for a voice `token` in a decompressed +/// `sounds.tbl` (the token's subdir — `Movie`, `etc`, `Voice` — is not fixed). +pub fn resolve_token_path(sounds_tbl: &[u8], token: &str, lang: VoiceLang) -> Option { + let prefix = format!("{}\\", lang.code_pub()); + let suffix = format!("\\{token}.slb"); + ascii_runs(sounds_tbl, 6) + .into_iter() + .find(|r| r.starts_with(&prefix) && r.ends_with(&suffix)) +} + +fn contains(hay: &[u8], needle: &[u8]) -> bool { + hay.windows(needle.len()).any(|w| w == needle) +} + +/// Collect printable-ASCII runs of at least `min` chars. +fn ascii_runs(bytes: &[u8], min: usize) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + for &b in bytes { + if (0x20..=0x7e).contains(&b) { + cur.push(b as char); + } else { + if cur.len() >= min { + out.push(std::mem::take(&mut cur)); + } else { + cur.clear(); + } + } + } + if cur.len() >= min { + out.push(cur); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn synth() -> Vec { + // Minimal IDXD string pool: three movies — a standard one, a hokyu bound + // to a VOICE_D radio clip, and a hokyu with no voice. + let mut b = b"IDXD".to_vec(); + b.extend_from_slice(&[0, 0, 0, 0x69, 0, 0]); // binary header (as on disc) + for s in [ + "S13A.wmv", + "eng.pak+SUBTITLE_S13A.tbl", + "VOICE_S13A", + "hokyu_LS_s02A.wmv", + "eng.pak+SUBTITLE_hokyu_LS_s02A.tbl", + "VOICE_D_450", + "hokyu_DS_s13A.wmv", + "eng.pak+SUBTITLE_hokyu_DS_s13A.tbl", + ] { + b.extend_from_slice(s.as_bytes()); + b.push(0); + } + b + } + + #[test] + fn groups_movies_and_binds_voice() { + let m = parse(&synth()); + assert_eq!(m.len(), 3); + assert_eq!(m[0].movie, "S13A"); + assert_eq!(m[0].voice_token.as_deref(), Some("VOICE_S13A")); + assert_eq!(m[1].voice_token.as_deref(), Some("VOICE_D_450")); + assert_eq!(m[2].voice_token, None, "hokyu_DS_s13A has no voice-over"); + } + + #[test] + fn resolves_token_directory_from_sounds_tbl() { + // sounds.tbl places the two tokens in different subdirs. + let mut tbl = Vec::new(); + for s in ["eng\\Movie\\VOICE_S13A.slb", "eng\\etc\\VOICE_D_450.slb"] { + tbl.extend_from_slice(s.as_bytes()); + tbl.push(0); + } + let man = synth(); + assert_eq!( + resolve_voice_entry(&man, &tbl, "S13A", VoiceLang::English).as_deref(), + Some("eng\\Movie\\VOICE_S13A.slb") + ); + assert_eq!( + resolve_voice_entry(&man, &tbl, "hokyu_LS_s02A", VoiceLang::English).as_deref(), + Some("eng\\etc\\VOICE_D_450.slb") + ); + assert_eq!( + resolve_voice_entry(&man, &tbl, "hokyu_DS_s13A", VoiceLang::English), + None + ); + } +} diff --git a/crates/sylpheed-formats/src/movie_subtitle.rs b/crates/sylpheed-formats/src/movie_subtitle.rs new file mode 100644 index 0000000..8a8e5f4 --- /dev/null +++ b/crates/sylpheed-formats/src/movie_subtitle.rs @@ -0,0 +1,417 @@ +//! Movie cutscene subtitles: the movie → track → text chain. +//! +//! Reverse-engineered statically (see `docs/re/structures/movie-subtitles.md`). +//! A cutscene's on-screen captions are assembled from three places on the disc: +//! +//! 1. `dat/movie/.pak` holds one **timing track** per movie, keyed by +//! [`track_key`] = `name_hash("subtitle_.tbl")`. Each track is a +//! `Z1`+zlib block that decompresses to an **IXUD** container whose UTF-16**LE** +//! payload is `SUBTITLE MSG_DEMO_ …` — i.e. *which* +//! demo-message shows *when* (radio / resupply movies), or inline text. +//! 2. `dat/GP_MAIN_GAME_.pak` holds the **caption text**: more `Z1`+zlib IXUD +//! blocks where each line is stored as `text` immediately followed by its key +//! `MSG_DEMO___` (multi-line captions split across lines). +//! +//! [`load`] joins the two into timed [`SubCue`]s. +//! +//! Note: the IXUD payload here is UTF-16 **little-endian** (verified on the real +//! disc); this is deliberately separate from the older big-endian [`crate::ixud`] +//! presenter, which targets a different (uncompressed) variant. + +use std::collections::BTreeMap; + +use crate::hash::name_hash; +use crate::pak::PakArchive; + +/// Subtitle language. `pak_code` selects `dat/movie/.pak`; `game_code` +/// selects `dat/GP_MAIN_GAME_.pak` (the caption text). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SubLang { + English, + Japanese, + German, + French, + Spanish, + Italian, +} + +impl SubLang { + /// All languages, in menu order. + pub const ALL: [SubLang; 6] = [ + SubLang::English, + SubLang::Japanese, + SubLang::German, + SubLang::French, + SubLang::Spanish, + SubLang::Italian, + ]; + + /// `dat/movie/.pak` stem (the timing tracks + caption font). + pub fn pak_code(self) -> &'static str { + match self { + SubLang::English => "eng", + SubLang::Japanese => "jpn", + SubLang::German => "deu", + SubLang::French => "fra", + SubLang::Spanish => "esp", + SubLang::Italian => "ita", + } + } + + /// `dat/GP_MAIN_GAME_.pak` suffix (the caption text pack). + pub fn game_code(self) -> &'static str { + match self { + SubLang::English => "E", + SubLang::Japanese => "J", + SubLang::German => "D", + SubLang::French => "F", + SubLang::Spanish => "S", + SubLang::Italian => "I", + } + } + + /// Human label for a UI selector. Kept to Latin script so it renders in a + /// default (non-CJK) UI font; Japanese is romanized for the same reason. + pub fn label(self) -> &'static str { + match self { + SubLang::English => "English", + SubLang::Japanese => "Japanese", + SubLang::German => "Deutsch", + SubLang::French => "Français", + SubLang::Spanish => "Español", + SubLang::Italian => "Italiano", + } + } +} + +/// One timed caption line. +#[derive(Debug, Clone, PartialEq)] +pub struct SubCue { + pub start: f32, + pub end: Option, + pub text: String, +} + +/// The `.pak` TOC key for a movie's subtitle timing track. +pub fn track_key(movie_basename: &str) -> u32 { + name_hash(&format!("subtitle_{movie_basename}.tbl")) +} + +/// Load and resolve a movie's timed captions in `lang`. +/// +/// `lang_pak` is `dat/movie/.pak` (+segments); `text_pak` is +/// `dat/GP_MAIN_GAME_.pak` (+segments). Returns the cues in start order, or an +/// empty vec if the movie has no timed track (e.g. story movies whose text is a +/// pre-rendered title card). +pub fn load(movie_basename: &str, lang_pak: &PakArchive, text_pak: &PakArchive) -> Vec { + let Some(Ok(track)) = lang_pak.read_by_hash(track_key(movie_basename)) else { + return Vec::new(); + }; + if !is_ixud(&track) { + return Vec::new(); + } + let raw = parse_track(&track); + if raw.is_empty() { + return Vec::new(); + } + // Only build the (large) text table if the track actually references demos. + let needs_text = raw.iter().any(|(t, _, _)| demo_ref(t).is_some()); + let text = if needs_text { + build_demo_text(text_pak) + } else { + BTreeMap::new() + }; + + let mut cues = Vec::new(); + for (token, start, end) in raw { + let resolved = match demo_ref(&token) { + Some(demo) => match text.get(&demo) { + Some(lines) => lines.join("\n"), + None => continue, // demo id with no text on disc → skip + }, + None => clean(&token), // already inline text + }; + if resolved.trim().is_empty() { + continue; + } + cues.push(SubCue { + start, + end, + text: resolved, + }); + } + cues.sort_by(|a, b| a.start.partial_cmp(&b.start).unwrap_or(std::cmp::Ordering::Equal)); + cues +} + +/// The `MSG_DEMO_` ids a movie's timing track references, in track order +/// (empty for inline-text or absent tracks). Language-independent — the same +/// demo ids appear in every `.pak`. Used to share a voice clip between +/// movies that play the same demo line (the resupply cutscenes reuse one clip +/// per line while only the video differs). +pub fn track_demo_ids(lang_pak: &PakArchive, movie_basename: &str) -> Vec { + let Some(Ok(track)) = lang_pak.read_by_hash(track_key(movie_basename)) else { + return Vec::new(); + }; + if !is_ixud(&track) { + return Vec::new(); + } + parse_track(&track) + .iter() + .filter_map(|(t, _, _)| demo_ref(t)) + .collect() +} + +/// Build `demo id → ordered caption lines` from a `GP_MAIN_GAME_` pack. +/// +/// Scans every IXUD block, pairing each text token with the immediately +/// following `MSG_DEMO___` key, and groups the lines per demo +/// ordered by (page, line). +pub fn build_demo_text(text_pak: &PakArchive) -> BTreeMap> { + // demo -> (page,line) -> text + let mut by_demo: BTreeMap> = BTreeMap::new(); + for entry in text_pak.entries() { + let Ok(bytes) = text_pak.read(entry) else { + continue; + }; + if !is_ixud(&bytes) { + continue; + } + let toks = utf16le_tokens(&bytes); + for w in toks.windows(2) { + if let Some((demo, page, line)) = text_key(&w[1]) { + // Pair only when the preceding token is real text — not another + // MSG_DEMO key (bare keys are also serialized consecutively in the + // record directory, which would otherwise masquerade as text). + if demo_ref(&w[0]).is_none() + && text_key(&w[0]).is_none() + && !w[0].trim().is_empty() + { + by_demo + .entry(demo) + .or_default() + .insert((page, line), clean(&w[0])); + } + } + } + } + by_demo + .into_iter() + .map(|(d, m)| (d, m.into_values().collect())) + .collect() +} + +/// Parse a timing track's IXUD payload into `(token, start, end)` triples, where +/// `token` is either a `MSG_DEMO_` reference or an inline caption string. +/// +/// A single on-screen caption may be stored as **several consecutive text +/// tokens** followed by one timing (the source hard-splits a multi-line caption +/// into one token per line — e.g. `"Look at it father"` + `"& beautiful isn't +/// it"` share the `01:14.80-01:17.60` timing). We therefore accumulate every +/// text token seen since the last timing and join them with a newline when the +/// timing arrives, instead of pairing strictly 1:1 (which silently dropped every +/// line but the last of a multi-line caption). +fn parse_track(ixud: &[u8]) -> Vec<(String, f32, Option)> { + let toks = utf16le_tokens(ixud); + let start = toks + .iter() + .position(|t| t.ends_with("SUBTITLE")) + .map(|i| i + 1) + .unwrap_or(0); + let rest = &toks[start..]; + let mut out = Vec::new(); + let mut pending: Vec<&str> = Vec::new(); + for tok in rest { + match parse_timing(tok) { + Some((s, e)) => { + if !pending.is_empty() { + out.push((pending.join("\n"), s, e)); + pending.clear(); + } + } + None => pending.push(tok), + } + } + out +} + +fn is_ixud(b: &[u8]) -> bool { + b.len() >= 4 && &b[0..4] == b"IXUD" +} + +/// Normalize a caption string: the source stores line breaks as the literal +/// two-character escape `\n`; turn it into a real newline and trim. +fn clean(s: &str) -> String { + s.replace("\\n", "\n").trim().to_string() +} + +/// `MSG_DEMO_` → `Some(d)` (reference token, no page/line). +fn demo_ref(t: &str) -> Option { + let rest = t.strip_prefix("MSG_DEMO_")?; + if rest.contains('_') { + return None; // that's a text key (has page/line), not a bare reference + } + rest.parse().ok() +} + +/// `MSG_DEMO___` → `Some((d,page,line))` (text key). +fn text_key(t: &str) -> Option<(u32, u32, u32)> { + let rest = t.strip_prefix("MSG_DEMO_")?; + let mut it = rest.split('_'); + let d = it.next()?.parse().ok()?; + let p = it.next()?.parse().ok()?; + let l = it.next()?.parse().ok()?; + if it.next().is_some() { + return None; + } + Some((d, p, l)) +} + +/// Extract UTF-16**LE** text runs from a blob, **independent of byte alignment**. +/// +/// The IXUD string pool packs records at odd byte offsets, so a fixed 2-byte +/// stride from the block start misreads every character. Instead we scan a +/// sliding window, decoding each 16-bit LE unit and keeping runs of "text-like" +/// code points (ASCII, Latin-1 supplement — the German umlauts / accented +/// Latin — and Latin Extended-A). A non-text unit ends the run and we advance by +/// one byte to re-lock onto the correct parity of the next run. Accepting the +/// Latin ranges (not just ASCII) is what keeps `müsste`, `Français`, `español` +/// intact — earlier the accented char *and its predecessor* were dropped. +/// +/// CJK (Japanese) is deliberately out of range: those units also occur as noise +/// at the wrong parity, and the UI font can't render them anyway. +fn utf16le_tokens(bytes: &[u8]) -> Vec { + let mut tokens = Vec::new(); + let mut cur = String::new(); + let mut i = 0; + while i + 1 < bytes.len() { + let u = u16::from_le_bytes([bytes[i], bytes[i + 1]]); + if is_text_unit(u) { + if let Some(c) = char::from_u32(u as u32) { + cur.push(c); + } + i += 2; + } else { + if cur.chars().count() >= 2 { + tokens.push(std::mem::take(&mut cur)); + } else { + cur.clear(); + } + i += 1; + } + } + if cur.chars().count() >= 2 { + tokens.push(cur); + } + tokens +} + +/// Whether a UTF-16 unit is a printable Latin text character we keep in a run. +/// Excludes the C0/C1 control blocks (incl. 0x7F–0x9F) so binary noise breaks +/// runs instead of joining them. +fn is_text_unit(u: u16) -> bool { + matches!(u, 0x20..=0x7e | 0xa0..=0xff | 0x100..=0x17f) +} + +/// Parse `MM:SS.ss` or a `start-end` range into seconds. +fn parse_timing(s: &str) -> Option<(f32, Option)> { + let one = |p: &str| -> Option { + let (mm, ss) = p.trim().split_once(':')?; + let m: f32 = if mm.trim().is_empty() { + 0.0 + } else { + mm.trim().parse().ok()? + }; + let sec: f32 = ss.trim().parse().ok()?; + Some(m * 60.0 + sec) + }; + match s.split_once('-') { + Some((a, b)) => Some((one(a)?, Some(one(b)?))), + None => Some((one(s)?, None)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn track_key_matches_disc() { + // Verified against dat/movie/eng.pak. + assert_eq!(track_key("S00A"), 0x6F2D_9663); + assert_eq!(track_key("hokyu_DS_s02A"), 0x3662_B1F8); + assert_eq!(track_key("RT01C_1"), 0x756F_69FB); + } + + #[test] + fn demo_and_text_keys() { + assert_eq!(demo_ref("MSG_DEMO_192"), Some(192)); + assert_eq!(demo_ref("MSG_DEMO_604_000_00"), None); + assert_eq!(text_key("MSG_DEMO_604_000_01"), Some((604, 0, 1))); + assert_eq!(text_key("MSG_DEMO_192"), None); + } + + #[test] + fn keeps_latin1_accents() { + // "müsste" must survive intact (umlaut + its neighbour), not collapse to + // "sste". Encode as UTF-16LE at an ODD start offset to exercise the + // alignment-independent scan. + let mut b = vec![0xAAu8]; // 1 junk byte → strings start at an odd offset + for s in ["müsste", "Français", "español"] { + for u in s.encode_utf16() { + b.extend_from_slice(&u.to_le_bytes()); + } + b.extend_from_slice(&[0, 0]); + } + let toks = utf16le_tokens(&b); + assert!(toks.contains(&"müsste".to_string()), "got {toks:?}"); + assert!(toks.contains(&"Français".to_string()), "got {toks:?}"); + assert!(toks.contains(&"español".to_string()), "got {toks:?}"); + } + + /// Build a synthetic UTF-16LE IXUD and check the LE token walk + timing pair. + #[test] + fn parses_le_track() { + let mut b = b"IXUD".to_vec(); + b.extend_from_slice(&[0, 1, 0, 0, 0x70, 0x3E, 0xC8, 0x6C]); + for t in ["SUBTITLE", "MSG_DEMO_5", "00:01.50", "Hello", "00:03.00"] { + for u in t.encode_utf16() { + b.extend_from_slice(&u.to_le_bytes()); + } + b.extend_from_slice(&[0, 0]); + } + let raw = parse_track(&b); + assert_eq!(raw.len(), 2); + assert_eq!(raw[0], ("MSG_DEMO_5".to_string(), 1.5, None)); + assert_eq!(raw[1], ("Hello".to_string(), 3.0, None)); + } + + /// Two text tokens before a single timing = one two-line caption; the first + /// line must NOT be dropped (the real S13A `Look at it father` regression). + #[test] + fn joins_multiline_caption() { + let mut b = b"IXUD".to_vec(); + b.extend_from_slice(&[0, 1, 0, 0, 0x70, 0x3E, 0xC8, 0x6C]); + for t in [ + "SUBTITLE", + "That is such magnificent power.", + "01:09.70-01:12.20", + "Look at it father", + "& beautiful isn't it", + "01:14.80-01:17.60", + ] { + for u in t.encode_utf16() { + b.extend_from_slice(&u.to_le_bytes()); + } + b.extend_from_slice(&[0, 0]); + } + let raw = parse_track(&b); + assert_eq!(raw.len(), 2); + assert_eq!(raw[0].0, "That is such magnificent power."); + assert_eq!( + raw[1].0, "Look at it father\n& beautiful isn't it", + "both lines of the caption must be kept" + ); + assert_eq!((raw[1].1, raw[1].2), (74.8, Some(77.6))); + } +} diff --git a/crates/sylpheed-formats/src/pak.rs b/crates/sylpheed-formats/src/pak.rs index 439b4df..f4c117e 100644 --- a/crates/sylpheed-formats/src/pak.rs +++ b/crates/sylpheed-formats/src/pak.rs @@ -178,6 +178,18 @@ impl PakArchive { }) } + /// Parse **only** the TOC from a `.pak` index, without any segment data. + /// + /// For huge archives (`sound.pak` is ~1 GB across `.p00..p04`) this lets a + /// caller locate one entry's `(offset, size)` and read just that byte range + /// from the segments, instead of loading the whole thing into memory. The + /// returned entries are sorted by `name_hash` (binary-searchable). + pub fn parse_toc(index: &[u8]) -> Result, PakError> { + // Reuse from_parts' validation with an empty data blob; the entries are + // independent of the data. + Ok(Self::from_parts(index, Vec::new())?.entries) + } + /// All TOC entries, in stored order (ascending `name_hash`). pub fn entries(&self) -> &[PakEntry] { &self.entries diff --git a/crates/sylpheed-formats/src/slb.rs b/crates/sylpheed-formats/src/slb.rs new file mode 100644 index 0000000..b6db223 --- /dev/null +++ b/crates/sylpheed-formats/src/slb.rs @@ -0,0 +1,249 @@ +//! `.slb` XACT sound banks → a decodable XMA1 `RIFF`. +//! +//! `dat/sound.pak` (9519 entries across `sound.p00..p04`) is the game's audio +//! bank. Each entry is an XACT `.slb` wrapping **XMA1** (`fmt ` tag `0x0165`, +//! 48 kHz). Files are named `\Voice\…`, `\Movie\VOICE_.slb`, +//! `BGM_###.slb`, etc. (see `docs/re/structures/sound-slb.md`); look them up by +//! [`crate::hash::name_hash`]. Movie cutscene voice = one continuous +//! `\Movie\VOICE_.slb` track meant to play from the video start. +//! +//! Two on-disc layouts, both reversed statically: +//! - **RIFF present** — a standard `RIFF/WAVE` sits inside the bank; its 32-byte +//! `fmt ` is the real `XMAWAVEFORMAT` and the XMA packets are everything after +//! that RIFF's `data` chunk header (the declared `data` size is unreliable, so +//! we take to end and let the caller clamp to the known media length). +//! - **Headerless** — no RIFF at all; a fixed **1392-byte** header precedes raw +//! XMA1 packets (48 kHz, 2 channels). +//! +//! [`to_xma_riff`] rebuilds a standalone `RIFF/WAVE` (XMA1) for either layout, +//! ready to hand to an XMA decoder (e.g. FFmpeg's `xma1`). The content is always +//! mono (some clips put it in the left channel only, others duplicate L=R), so +//! the decode step should downmix to mono (take the left channel). + +/// Fixed offset of the raw XMA1 stream in a headerless `.slb` (no `RIFF`). +pub const HEADERLESS_DATA_OFFSET: usize = 1392; + +/// Voice language for cutscene audio. Only English and Japanese voice exist on +/// the disc (subtitles cover more languages, voice does not). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VoiceLang { + English, + Japanese, +} + +impl VoiceLang { + fn code(self) -> &'static str { + match self { + VoiceLang::English => "eng", + VoiceLang::Japanese => "jpn", + } + } + + /// `eng` / `jpn` — the `sound.pak` path prefix for this voice language. + pub fn code_pub(self) -> &'static str { + self.code() + } +} + +/// The `sound.pak` entry name for a movie's continuous voice track, e.g. +/// `eng\Movie\VOICE_RT07A.slb`. Hash it with [`crate::hash::name_hash`] to get +/// the `sound.pak` TOC key. +pub fn movie_voice_name(movie_basename: &str, lang: VoiceLang) -> String { + format!("{}\\Movie\\VOICE_{}.slb", lang.code(), movie_basename) +} + +/// One playable voice clip discovered in `sounds.tbl`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VoiceClip { + /// Full `sound.pak` entry name, e.g. `eng\Voice\VOICE_ADAN_010.slb`. + pub name: String, + /// Speaker/category code, e.g. `ADAN`, `TCAF`, `A`, `RT07A`. + pub speaker: String, + /// Short UI label, e.g. `ADAN 010`. + pub display: String, +} + +/// Enumerate the voice/dialog clips named in a decompressed `sounds.tbl` (the +/// IDXD in `tables.pak`). Extracts every `\{Voice,etc,Movie,Briefing}\…` +/// path ending in `.slb` for `lang`, parsed into `(name, speaker, display)`. +pub fn list_voice_clips(sounds_tbl: &[u8], lang: VoiceLang) -> Vec { + let prefix = format!("{}\\", lang.code()); + let mut seen = std::collections::BTreeSet::new(); + let mut out = Vec::new(); + // Scan for printable-ASCII runs; keep those that look like a voice path. + let mut i = 0; + while i < sounds_tbl.len() { + let start = i; + while i < sounds_tbl.len() && (0x20..=0x7e).contains(&sounds_tbl[i]) { + i += 1; + } + if i - start >= 6 { + if let Ok(s) = std::str::from_utf8(&sounds_tbl[start..i]) { + if s.starts_with(&prefix) && s.ends_with(".slb") && s.contains("VOICE") { + if seen.insert(s.to_string()) { + out.push(parse_voice_clip(s)); + } + } + } + } + i += 1; + } + out +} + +fn parse_voice_clip(name: &str) -> VoiceClip { + // `\\VOICE__.slb` or `..\VOICE_.slb`. + let stem = name + .rsplit('\\') + .next() + .unwrap_or(name) + .strip_suffix(".slb") + .unwrap_or(name); + let body = stem.strip_prefix("VOICE_").unwrap_or(stem); + let (speaker, display) = match body.rsplit_once('_') { + Some((spk, num)) if num.chars().all(|c| c.is_ascii_digit()) => { + (spk.to_string(), format!("{spk} {num}")) + } + _ => (body.to_string(), body.to_string()), + }; + VoiceClip { + name: name.to_string(), + speaker, + display, + } +} + +/// Build a standalone XMA1 `RIFF/WAVE` from a `.slb` bank, decodable by FFmpeg's +/// `xma1`. Returns `None` if the bank is too small / malformed. +pub fn to_xma_riff(slb: &[u8]) -> Option> { + if let Some(ri) = find(slb, b"RIFF", 0) { + // RIFF layout: a `.slb` is an XACT bank of one or more sub-waves, each + // `[seek][RIFF: fmt + Dmmy pad + data][declared_size XMA bytes]`. Take the + // FIRST sub-wave, bounded by its **declared `data` size** (which is + // honest per sub-wave). Decoding to end-of-file instead would append the + // later sub-waves — for multi-take story movies those are ALTERNATE takes, + // which is what made S10–S16 play the wrong audio. + let fi = find(slb, b"fmt ", ri)?; + let fsz = le32(slb, fi + 4)? as usize; + let fmt_end = fi.checked_add(8)?.checked_add(fsz)?; + if fmt_end > slb.len() { + return None; + } + let fmt_chunk = &slb[fi..fmt_end]; + let di = find(slb, b"data", fi)?; + let dsz = le32(slb, di + 4)? as usize; + let end = di.checked_add(8)?.checked_add(dsz)?.min(slb.len()); + let data = slb.get(di + 8..end)?; + Some(build_riff(fmt_chunk, data)) + } else { + // Headerless: 1392-byte header, then raw XMA1 (48 kHz, 2 channels). + let data = slb.get(HEADERLESS_DATA_OFFSET..)?; + if data.is_empty() { + return None; + } + Some(build_riff(&synth_xma1_fmt(2, 2, 48000), data)) + } +} + +/// A minimal `fmt ` chunk carrying an XMA1 `XMAWAVEFORMAT` (one stream). +fn synth_xma1_fmt(channels: u8, channel_mask: u16, rate: u32) -> Vec { + let mut fmt = Vec::with_capacity(40); + fmt.extend_from_slice(b"fmt "); + fmt.extend_from_slice(&32u32.to_le_bytes()); + // XMAWAVEFORMAT header + fmt.extend_from_slice(&0x0165u16.to_le_bytes()); // wFormatTag = XMA1 + fmt.extend_from_slice(&16u16.to_le_bytes()); // BitsPerSample + fmt.extend_from_slice(&0u16.to_le_bytes()); // EncodeOptions + fmt.extend_from_slice(&0u16.to_le_bytes()); // LargestSkip + fmt.extend_from_slice(&1u16.to_le_bytes()); // NumStreams + fmt.push(0); // LoopCount + fmt.push(3); // Version + // XMASTREAMFORMAT[0] + fmt.extend_from_slice(&(rate * channels as u32 * 2).to_le_bytes()); // PsuedoBytesPerSec + fmt.extend_from_slice(&rate.to_le_bytes()); // SampleRate + fmt.extend_from_slice(&0u32.to_le_bytes()); // LoopStart + fmt.extend_from_slice(&0u32.to_le_bytes()); // LoopEnd + fmt.push(4); // SubframeData + fmt.push(channels); // Channels + fmt.extend_from_slice(&channel_mask.to_le_bytes()); // ChannelMask + fmt +} + +fn build_riff(fmt_chunk: &[u8], data: &[u8]) -> Vec { + let mut body = Vec::with_capacity(4 + fmt_chunk.len() + 8 + data.len()); + body.extend_from_slice(b"WAVE"); + body.extend_from_slice(fmt_chunk); + body.extend_from_slice(b"data"); + body.extend_from_slice(&(data.len() as u32).to_le_bytes()); + body.extend_from_slice(data); + let mut out = Vec::with_capacity(8 + body.len()); + out.extend_from_slice(b"RIFF"); + out.extend_from_slice(&(body.len() as u32).to_le_bytes()); + out.extend_from_slice(&body); + out +} + +fn find(hay: &[u8], needle: &[u8], from: usize) -> Option { + if from >= hay.len() { + return None; + } + hay[from..] + .windows(needle.len()) + .position(|w| w == needle) + .map(|p| p + from) +} + +fn le32(b: &[u8], o: usize) -> Option { + let s = b.get(o..o + 4)?; + Some(u32::from_le_bytes([s[0], s[1], s[2], s[3]])) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::name_hash; + + #[test] + fn movie_voice_names_and_hashes() { + assert_eq!( + movie_voice_name("RT07A", VoiceLang::English), + "eng\\Movie\\VOICE_RT07A.slb" + ); + // Verified present in dat/sound.pak (VOICE_S00A == TOC entry 0x2A4F97D3). + assert_eq!(name_hash("eng\\Movie\\VOICE_RT07A.slb"), 0xA44A_EA1C); + assert_eq!(name_hash("eng\\Movie\\VOICE_S00A.slb"), 0x2A4F_97D3); + } + + #[test] + fn takes_only_first_subwave() { + // Bank of TWO sub-waves; only the FIRST must be extracted (bounded by its + // declared `data` size), not everything to end-of-file. + let fmt = synth_xma1_fmt(2, 2, 48000); + let mut slb = vec![0xAB; 16]; + slb.extend_from_slice(b"RIFF"); + slb.extend_from_slice(&999u32.to_le_bytes()); // riff size (ignored) + slb.extend_from_slice(b"WAVE"); + slb.extend_from_slice(&fmt); + slb.extend_from_slice(b"data"); + slb.extend_from_slice(&4u32.to_le_bytes()); // declared: 4 bytes + slb.extend_from_slice(&[1, 2, 3, 4]); // sub-wave 1 XMA + slb.extend_from_slice(&[9, 9, 9, 9]); // a second sub-wave's bytes — excluded + let riff = to_xma_riff(&slb).unwrap(); + assert_eq!(&riff[0..4], b"RIFF"); + let dpos = find(&riff, b"data", 0).unwrap(); + assert_eq!(le32(&riff, dpos + 4), Some(4)); + assert_eq!(&riff[dpos + 8..], &[1, 2, 3, 4]); + } + + #[test] + fn rebuilds_riff_from_headerless() { + let mut slb = vec![0u8; HEADERLESS_DATA_OFFSET]; + slb.extend_from_slice(&[9, 8, 7, 6]); + let riff = to_xma_riff(&slb).unwrap(); + let dpos = find(&riff, b"data", 0).unwrap(); + assert_eq!(&riff[dpos + 8..], &[9, 8, 7, 6]); + // synthetic fmt advertises XMA1. + let fpos = find(&riff, b"fmt ", 0).unwrap(); + assert_eq!(le32(&riff, fpos + 8).map(|v| v as u16), Some(0x0165)); + } +} diff --git a/crates/sylpheed-formats/tests/mesh_disc.rs b/crates/sylpheed-formats/tests/mesh_disc.rs index 7cb153b..f8a5a9d 100644 --- a/crates/sylpheed-formats/tests/mesh_disc.rs +++ b/crates/sylpheed-formats/tests/mesh_disc.rs @@ -7,7 +7,7 @@ //! Run: `cargo test -p sylpheed-formats --test mesh_disc -- --ignored --nocapture` use std::path::PathBuf; -use sylpheed_formats::mesh::Xbg7Model; +use sylpheed_formats::mesh::{material_groups, node_transforms, submesh_albedos, Xbg7Model}; fn res3d_dir() -> Option { if let Ok(p) = std::env::var("SYLPHEED_RES3D") { @@ -21,6 +21,119 @@ fn res3d_dir() -> Option { default.is_dir().then_some(default) } +#[test] +#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"] +fn hero_ship_submesh_material_graph() { + let Some(dir) = res3d_dir() else { + eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)"); + return; + }; + // The XBG7 node/material graph tags each sub-mesh record with its own albedo + // (`rou_…_col` → TX2D name). DeltaSaber_T `f001`: the 7 detail parts use + // bdy_04/06/07 — NOT the body's bdy_01a — which is the per-part texturing the + // viewer needs so fins aren't painted with the hull map. + let bytes = std::fs::read(dir.join("DeltaSaber_T.xpr")).unwrap(); + let mut map = std::collections::HashMap::new(); + for (v, i, name) in submesh_albedos(&bytes, "f001") { + map.insert((v, i), name); + } + // (vtx, idx) → expected albedo (rou_ stripped = TX2D name). + assert_eq!(map.get(&(314, 645)).map(String::as_str), Some("f001_bdy_04_col")); + assert_eq!(map.get(&(48, 84)).map(String::as_str), Some("f001_bdy_04_col")); + assert_eq!(map.get(&(96, 180)).map(String::as_str), Some("f001_bdy_06_col")); + assert_eq!(map.get(&(60, 108)).map(String::as_str), Some("f001_bdy_07_col")); + // Names strip cleanly to real TX2D resources. + let tex = sylpheed_formats::texture::X360Texture::texture_names(&bytes); + for name in map.values() { + assert!(tex.iter().any(|t| t == name), "albedo {name} not a TX2D resource"); + } +} + +#[test] +#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"] +fn hero_ship_body_splits_by_material() { + let Some(dir) = res3d_dir() else { + eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)"); + return; + }; + // The merged body (sub0 = 24561 indices / 8187 tris) is drawn as 9 groups + // sharing one index buffer, each with its own albedo (bdy_01a hull, bdy_01b, + // bdy_02/03, and the `daiza` hangar stand). `material_groups` recovers the + // cumulative-offset chain so the viewer can texture each slice. + let bytes = std::fs::read(dir.join("DeltaSaber_T.xpr")).unwrap(); + let groups = material_groups(&bytes, "f001", 24561); + assert_eq!(groups.len(), 9, "body must split into 9 material groups"); + // Offsets chain from 0 and cover the whole index buffer exactly. + let mut cursor = 0usize; + for g in &groups { + assert_eq!(g.idx_offset, cursor, "group offsets must be contiguous"); + cursor += g.idx_count; + } + assert_eq!(cursor, 24561, "groups must cover all body indices"); + // Expected per-group albedo (verified against the GPU draw log). + let albedos: Vec<&str> = groups.iter().map(|g| g.albedo.as_str()).collect(); + assert_eq!( + albedos, + [ + "f001_bdy_01a_col", + "f001_bdy_01a_col", + "f001_bdy_01a_col", + "f001_bdy_01b_col", + "f001_bdy_01a_col", + "f001_bdy_01b_col", + "f001_bdy_02_col", + "f001_bdy_03_col", + "f001_bdy_daiza_col", + ] + ); + // A single-material detail part (sub1 = 645 indices) → one group, bdy_04. + let fin = material_groups(&bytes, "f001", 645); + assert_eq!(fin.len(), 1); + assert_eq!(fin[0].albedo, "f001_bdy_04_col"); + assert_eq!(fin[0].idx_offset, 0); +} + +#[test] +#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"] +fn hero_ship_node_transforms_move_fins_to_tail() { + let Some(dir) = res3d_dir() else { + eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)"); + return; + }; + // For the DeltaSaber, node_transforms returns the MEASURED runtime placements + // (Canary draw-log WVP; see mesh.rs::DELTASABER_MEASURED) — the fin assemblies + // are mounted OUT on the nacelles (world X ≈ −4.4..−6.6), an offset the static + // graph omits. Body identity; each part a left/right mirrored pair. Vertex + // space: X right, Y up, Z fore/aft (engines at −Z). + let bytes = std::fs::read(dir.join("DeltaSaber_T.xpr")).unwrap(); + let places = node_transforms(&bytes, "f001"); + assert!(!places.is_empty(), "expected node placements"); + // Body (sub 0) identity, drawn once. + let body: Vec<_> = places.iter().filter(|p| p.sub_index == 0).collect(); + assert_eq!(body.len(), 1, "body drawn once"); + assert!(body[0].t.iter().all(|c| c.abs() < 0.1), "body must not translate"); + assert!(!body[0].reflect, "body is not mirrored"); + // Fin bdy_04 (sub 1): mirrored pair near the tail, mounted OUT on the nacelle + // (|X| ≈ 5.2, not the graph's inboard ≈1.1). + let fins: Vec<_> = places.iter().filter(|p| p.sub_index == 1).collect(); + assert_eq!(fins.len(), 2, "V-tail is a mirrored pair"); + assert!(fins.iter().all(|f| (f.t[2] + 9.72).abs() < 0.3), "fin fore/aft ≈ −9.7"); + assert!(fins.iter().all(|f| f.t[0].abs() > 4.0), "fin mounted out on the nacelle"); + assert!(fins[0].t[0] * fins[1].t[0] < 0.0, "the pair mirrors across X"); + assert!(fins.iter().any(|f| f.reflect), "one of the pair is reflected"); + // Winglet bdy_06 (sub 4): on the nacelle (|X| ≈ 6.6, Z ≈ −15.5). + let wings: Vec<_> = places.iter().filter(|p| p.sub_index == 4).collect(); + assert_eq!(wings.len(), 2, "L/R winglet pair"); + assert!(wings.iter().all(|p| p.t[0].abs() > 6.0 && (p.t[2] + 15.5).abs() < 0.3), + "winglets out on the nacelle"); + assert!(wings[0].t[0] * wings[1].t[0] < 0.0, "winglets mirror across X"); + // Small fin bdy_10 (sub 6): inboard nacelle stub (|X| ≈ 4.45, Z ≈ −17). + let sfins: Vec<_> = places.iter().filter(|p| p.sub_index == 6).collect(); + assert_eq!(sfins.len(), 2, "L/R small-fin pair"); + assert!(sfins.iter().all(|p| (p.t[0].abs() - 4.45).abs() < 0.3), "small fins on the stub"); + assert!(sfins[0].t[0] * sfins[1].t[0] < 0.0, "small fins mirror across X"); +} + #[test] #[ignore = "requires extracted disc models — set SYLPHEED_RES3D"] fn weapon_model_decodes_to_expected_geometry() { diff --git a/crates/sylpheed-formats/tests/movie_manifest_disc.rs b/crates/sylpheed-formats/tests/movie_manifest_disc.rs new file mode 100644 index 0000000..8382e18 --- /dev/null +++ b/crates/sylpheed-formats/tests/movie_manifest_disc.rs @@ -0,0 +1,102 @@ +//! Real-disc test for the movie manifest → voice binding. Skipped without +//! `SYLPHEED_DISC`. + +use std::path::PathBuf; + +use sylpheed_formats::movie_manifest; +use sylpheed_formats::slb::VoiceLang; +use sylpheed_formats::PakArchive; + +fn disc_root() -> Option { + let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?); + p.join("dat").is_dir().then_some(p) +} + +/// Read the manifest + `eng\sounds.tbl` out of `tables.pak`. +fn load_manifest_and_sounds(root: &PathBuf) -> (Vec, Vec) { + let pak = PakArchive::open(root.join("dat/tables.pak")).unwrap(); + let manifest = pak + .entries() + .iter() + .find_map(|e| pak.read(e).ok().filter(|b| movie_manifest::is_manifest(b))) + .expect("movie manifest present in tables.pak"); + let sounds = pak + .read_by_name("eng\\sounds.tbl") + .expect("sounds.tbl present") + .unwrap(); + (manifest, sounds) +} + +#[test] +fn binds_and_resolves_movie_voice() { + let Some(root) = disc_root() else { + eprintln!("SKIP: set SYLPHEED_DISC"); + return; + }; + let (manifest, sounds) = load_manifest_and_sounds(&root); + let entries = movie_manifest::parse(&manifest); + assert!(entries.len() > 90, "expected ~101 movies, got {}", entries.len()); + + // Standard story movie → VOICE_ in \Movie\. + assert_eq!( + movie_manifest::voice_token(&manifest, "S13A").as_deref(), + Some("VOICE_S13A") + ); + assert_eq!( + movie_manifest::resolve_voice_entry(&manifest, &sounds, "S13A", VoiceLang::English) + .as_deref(), + Some("eng\\Movie\\VOICE_S13A.slb") + ); + + // A resupply movie bound to an in-mission radio clip → VOICE_D_* in \etc\. + // (This is what a `VOICE_` guess would miss entirely.) + assert_eq!( + movie_manifest::voice_token(&manifest, "hokyu_LS_s02A").as_deref(), + Some("VOICE_D_450") + ); + assert_eq!( + movie_manifest::resolve_voice_entry( + &manifest, + &sounds, + "hokyu_LS_s02A", + VoiceLang::English + ) + .as_deref(), + Some("eng\\etc\\VOICE_D_450.slb") + ); + + // A resupply movie the game leaves unbound in the manifest → no DIRECT + // binding. (Extending to unbound movies by shared demo line was verified + // WRONG against the running game, so we do NOT resolve these.) + let e = entries + .iter() + .find(|e| e.movie == "hokyu_DS_s13A") + .expect("hokyu_DS_s13A present in manifest"); + assert_eq!(e.voice_token, None, "hokyu_DS_s13A has no direct voice binding"); + assert_eq!( + movie_manifest::resolve_voice_entry(&manifest, &sounds, "hokyu_DS_s13A", VoiceLang::English), + None + ); + + // Every resolved voice entry must actually exist in sound.pak. + let snd = std::fs::read(root.join("dat/sound.pak")).unwrap(); + let keys: std::collections::HashSet = PakArchive::parse_toc(&snd) + .unwrap() + .iter() + .map(|e| e.name_hash) + .collect(); + let mut resolved = 0; + let mut missing = Vec::new(); + for e in &entries { + if let Some(full) = + movie_manifest::resolve_voice_entry(&manifest, &sounds, &e.movie, VoiceLang::English) + { + resolved += 1; + if !keys.contains(&sylpheed_formats::hash::name_hash(&full)) { + missing.push(full); + } + } + } + assert!(resolved > 80, "expected 80+ voiced movies, got {resolved}"); + assert!(missing.is_empty(), "resolved but absent in sound.pak: {missing:?}"); +} diff --git a/crates/sylpheed-formats/tests/movie_subtitle_disc.rs b/crates/sylpheed-formats/tests/movie_subtitle_disc.rs new file mode 100644 index 0000000..d791478 --- /dev/null +++ b/crates/sylpheed-formats/tests/movie_subtitle_disc.rs @@ -0,0 +1,63 @@ +//! Real-disc test for the movie subtitle chain. Skipped when the extracted disc +//! is absent (set `SYLPHEED_DISC` to the extract root to enable). + +use std::path::PathBuf; + +use sylpheed_formats::movie_subtitle::{self, SubLang}; +use sylpheed_formats::PakArchive; + +fn disc_root() -> Option { + let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?); + p.join("dat").is_dir().then_some(p) +} + +#[test] +fn resolves_english_radio_subtitles() { + let Some(root) = disc_root() else { + eprintln!("SKIP: set SYLPHEED_DISC"); + return; + }; + let lang = SubLang::English; + let lang_pak = PakArchive::open(root.join(format!("dat/movie/{}.pak", lang.pak_code()))).unwrap(); + let text_pak = + PakArchive::open(root.join(format!("dat/GP_MAIN_GAME_{}.pak", lang.game_code()))).unwrap(); + + // A radio movie: real timed English dialogue. + let cues = movie_subtitle::load("RT01C_1", &lang_pak, &text_pak); + assert!(!cues.is_empty(), "RT01C_1 should have timed cues"); + assert!(cues[0].start >= 0.0 && cues[0].start < 5.0); + assert!( + cues.iter().any(|c| c.text.contains("Rhino Leader")), + "expected recognizable dialogue, got: {:?}", + cues.iter().map(|c| &c.text).collect::>() + ); + + // No MSG_DEMO keys should leak into resolved caption text. + assert!( + cues.iter().all(|c| !c.text.contains("MSG_DEMO")), + "unresolved key leaked: {:?}", + cues.iter().map(|c| &c.text).collect::>() + ); + + // A resupply movie: the canonical resupply line. + let hokyu = movie_subtitle::load("hokyu_DS_s02A", &lang_pak, &text_pak); + assert!(hokyu.iter().any(|c| c.text.contains("Resupply complete"))); + + // S00A carries inline narration text (not MSG_DEMO refs) with timecodes. + let story = movie_subtitle::load("S00A", &lang_pak, &text_pak); + assert!(!story.is_empty() && story.iter().any(|c| c.text.contains("27th century"))); + + // S13A stores a two-line caption as two consecutive text tokens sharing one + // timing; both lines must survive (the "Look at it father" regression — the + // first line used to be dropped). + let s13 = movie_subtitle::load("S13A", &lang_pak, &text_pak); + let multiline = s13 + .iter() + .find(|c| c.text.contains("Look at it father")) + .expect("S13A should contain the 'Look at it father' caption"); + assert_eq!( + multiline.text, "Look at it father\n& beautiful isn't it", + "both lines of the caption must be present" + ); + assert!((multiline.start - 74.8).abs() < 0.1, "start {}", multiline.start); +} diff --git a/crates/sylpheed-formats/tests/slb_disc.rs b/crates/sylpheed-formats/tests/slb_disc.rs new file mode 100644 index 0000000..5920d0d --- /dev/null +++ b/crates/sylpheed-formats/tests/slb_disc.rs @@ -0,0 +1,104 @@ +//! Real-disc test: read a movie voice `.slb` out of the segmented `sound.pak` +//! and rebuild a decodable XMA1 RIFF. Skipped without `SYLPHEED_DISC`. + +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::PathBuf; + +use sylpheed_formats::hash::name_hash; +use sylpheed_formats::slb::{self, VoiceLang}; +use sylpheed_formats::PakArchive; + +fn disc_root() -> Option { + let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?); + p.join("dat").is_dir().then_some(p) +} + +/// Read `[off, off+size)` from `dat/sound.p00..` (segments concatenated). +fn read_range(root: &PathBuf, mut off: u64, size: usize) -> Vec { + let mut out = Vec::with_capacity(size); + let mut need = size; + for i in 0..100u32 { + if need == 0 { + break; + } + let seg = root.join(format!("dat/sound.p{i:02}")); + let Ok(meta) = std::fs::metadata(&seg) else { break }; + let len = meta.len(); + if off >= len { + off -= len; + continue; + } + let mut f = File::open(&seg).unwrap(); + f.seek(SeekFrom::Start(off)).unwrap(); + let take = need.min((len - off) as usize); + let start = out.len(); + out.resize(start + take, 0); + f.read_exact(&mut out[start..]).unwrap(); + need -= take; + off = 0; + } + out +} + +#[test] +fn reads_and_rebuilds_movie_voice_riff() { + let Some(root) = disc_root() else { + eprintln!("SKIP: set SYLPHEED_DISC"); + return; + }; + let toc = std::fs::read(root.join("dat/sound.pak")).unwrap(); + let entries = PakArchive::parse_toc(&toc).unwrap(); + assert_eq!(entries.len(), 9519); + + for movie in ["S00A", "RT07A", "RT02D_1"] { + // RT02D_1 is a headerless variant; S00A/RT07A embed a RIFF. + let key = name_hash(&slb::movie_voice_name(movie, VoiceLang::English)); + let idx = entries + .binary_search_by_key(&key, |e| e.name_hash) + .unwrap_or_else(|_| panic!("{movie} voice missing")); + let e = &entries[idx]; + let bank = read_range(&root, e.offset as u64, e.comp_size as usize); + + let riff = slb::to_xma_riff(&bank).expect("rebuild RIFF"); + assert_eq!(&riff[0..4], b"RIFF", "{movie}"); + assert_eq!(&riff[8..12], b"WAVE", "{movie}"); + // fmt chunk advertises XMA1 (tag 0x0165). + let fpos = riff.windows(4).position(|w| w == b"fmt ").unwrap(); + let tag = u16::from_le_bytes([riff[fpos + 8], riff[fpos + 9]]); + assert_eq!(tag, 0x0165, "{movie} should be XMA1"); + // data chunk carries the XMA payload. + let dpos = riff.windows(4).position(|w| w == b"data").unwrap(); + let dsz = u32::from_le_bytes([ + riff[dpos + 4], + riff[dpos + 5], + riff[dpos + 6], + riff[dpos + 7], + ]) as usize; + assert!(dsz > 10_000, "{movie} data too small: {dsz}"); + assert_eq!(riff.len(), dpos + 8 + dsz, "{movie} data length consistent"); + } +} + +#[test] +fn enumerates_voice_clips_from_sounds_tbl() { + let Some(root) = disc_root() else { + eprintln!("SKIP: set SYLPHEED_DISC"); + return; + }; + let pak = PakArchive::open(root.join("dat/tables.pak")).unwrap(); + let tbl = pak.read_by_name("eng\\sounds.tbl").unwrap().unwrap(); + let clips = slb::list_voice_clips(&tbl, VoiceLang::English); + assert!(clips.len() > 1000, "expected many voice clips, got {}", clips.len()); + // Every clip is an eng voice path present in sound.pak. + let sound = std::fs::read(root.join("dat/sound.pak")).unwrap(); + let keys: std::collections::HashSet = + PakArchive::parse_toc(&sound).unwrap().iter().map(|e| e.name_hash).collect(); + let present = clips.iter().filter(|c| keys.contains(&name_hash(&c.name))).count(); + assert!( + present as f32 / clips.len() as f32 > 0.95, + "{present}/{} clips resolve in sound.pak", + clips.len() + ); + assert!(clips.iter().any(|c| c.speaker == "ADAN")); +} diff --git a/crates/sylpheed-viewer/src/camera.rs b/crates/sylpheed-viewer/src/camera.rs index 121afe7..3aff921 100644 --- a/crates/sylpheed-viewer/src/camera.rs +++ b/crates/sylpheed-viewer/src/camera.rs @@ -8,6 +8,10 @@ use bevy::prelude::*; use bevy::input::mouse::{MouseMotion, MouseWheel}; +use bevy::render::render_asset::RenderAssetUsages; +use bevy::render::render_resource::{ + Extent3d, TextureDimension, TextureFormat, TextureViewDescriptor, TextureViewDimension, +}; pub struct OrbitCameraPlugin; @@ -54,10 +58,17 @@ impl Default for OrbitCamera { } } -fn spawn_camera(mut commands: Commands) { +fn spawn_camera(mut commands: Commands, mut images: ResMut>) { let orbit = OrbitCamera::default(); let transform = orbit_transform(&orbit); + // The game's ship shader reflects a shared HDR environment cubemap off the + // hull (three cube samples — see the shader RE). Reproduce that "look" with a + // procedural sky cube feeding Bevy's image-based lighting, so metallic + // surfaces reflect an environment instead of reading flat. Procedural (not a + // bundled KTX2) so it also works on WASM with no extra assets. + let env = make_env_cubemap(&mut images); + commands.spawn(( Camera3d::default(), // Wide near/far so both tiny weapons and multi-thousand-unit stages fit; @@ -69,9 +80,78 @@ fn spawn_camera(mut commands: Commands) { }), transform, orbit, + EnvironmentMapLight { + diffuse_map: env.clone(), + specular_map: env, + intensity: 900.0, + ..default() + }, )); } +/// Build a small procedural sky cubemap (6 faces) for image-based lighting. +/// +/// A vertical gradient (bright zenith → warm horizon → dark ground) plus a warm +/// "sun" highlight roughly where the game's key light sits — enough for a +/// metallic hull to read as reflective rather than flat. Stored as an sRGB cube +/// (filterable, no half-float encoding needed); a single mip means reflections +/// stay sharp (fine for a shiny ship). +fn make_env_cubemap(images: &mut Assets) -> Handle { + let size: i32 = 64; + // Approx key-light direction from the RE (PS c32 ≈ (0,-0.76,-0.65), i.e. light + // arriving from top-front); place the bright spot there. + let sun = Vec3::new(0.0, 0.85, 0.5).normalize(); + let zenith = Vec3::new(0.70, 0.80, 0.95); + let horizon = Vec3::new(0.42, 0.45, 0.50); + let ground = Vec3::new(0.09, 0.09, 0.11); + let srgb = |c: f32| (c.clamp(0.0, 1.0).powf(1.0 / 2.2) * 255.0).round() as u8; + + let mut data: Vec = Vec::with_capacity((size * size * 6 * 4) as usize); + for face in 0..6 { + for y in 0..size { + for x in 0..size { + let u = (x as f32 + 0.5) / size as f32 * 2.0 - 1.0; + let v = (y as f32 + 0.5) / size as f32 * 2.0 - 1.0; + // Standard cube-face → direction mapping. + let d = match face { + 0 => Vec3::new(1.0, -v, -u), + 1 => Vec3::new(-1.0, -v, u), + 2 => Vec3::new(u, 1.0, v), + 3 => Vec3::new(u, -1.0, -v), + 4 => Vec3::new(u, -v, 1.0), + _ => Vec3::new(-u, -v, -1.0), + } + .normalize(); + let mut col = if d.y >= 0.0 { + horizon.lerp(zenith, (d.y).powf(0.6)) + } else { + horizon.lerp(ground, (-d.y).powf(0.5)) + }; + let s = d.dot(sun).max(0.0).powf(60.0); + col += Vec3::new(1.0, 0.85, 0.6) * s * 1.5; + data.extend_from_slice(&[srgb(col.x), srgb(col.y), srgb(col.z), 255]); + } + } + } + + let mut image = Image::new( + Extent3d { + width: size as u32, + height: size as u32, + depth_or_array_layers: 6, + }, + TextureDimension::D2, + data, + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::RENDER_WORLD, + ); + image.texture_view_descriptor = Some(TextureViewDescriptor { + dimension: Some(TextureViewDimension::Cube), + ..default() + }); + images.add(image) +} + fn orbit_camera( mut query: Query<(&mut OrbitCamera, &mut Transform, &mut Projection)>, mouse_buttons: Res>, diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index b920070..7e475a3 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -18,7 +18,9 @@ //! ``` #[cfg(not(target_arch = "wasm32"))] -use std::sync::{mpsc, Mutex}; +use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::{mpsc, Arc, Mutex}; #[cfg(not(target_arch = "wasm32"))] use std::io::Read; @@ -236,6 +238,7 @@ pub struct ModelPreview { pub info: String, } + /// The currently-open `.wmv` cutscene, shown in the central panel with a /// transport bar. Registered unconditionally (the decode/audio engine below is /// native-only); on wasm `active` stays false and `.wmv` shows the info panel. @@ -289,6 +292,217 @@ impl Default for VideoPreview { } } +/// Timed subtitles for the currently-playing cutscene, plus the language +/// selection. Populated asynchronously (see [`RequestSubtitles`]); the UI +/// overlays the active cue on the video frame. Registered on all platforms so +/// the UI can read it; the loader that fills it is native-only. +#[derive(Resource)] +pub struct MovieSubtitles { + /// Whether to show captions. + pub enabled: bool, + /// Selected caption language. + pub lang: sylpheed_formats::SubLang, + /// Cues for `movie`, in start order. + pub cues: Vec, + /// Basename (no extension) of the movie the cues belong to. + pub movie: Option, + /// True while a load is in flight. + pub loading: bool, + /// Bumped on each request so stale async results are ignored. + pub generation: u64, +} + +impl Default for MovieSubtitles { + fn default() -> Self { + Self { + enabled: true, + lang: sylpheed_formats::SubLang::English, + cues: Vec::new(), + movie: None, + loading: false, + generation: 0, + } + } +} + +impl MovieSubtitles { + /// The caption active at `t` seconds, if any. + /// + /// Cues with an explicit `start-end` range (the inline narration tracks) use + /// it directly. Reference tracks (radio / resupply) carry only a *start* + /// time, so we show each line for an estimated reading duration — capped by + /// the next cue's start — instead of leaving it on screen until the next line + /// (which, for a single-line resupply movie, meant the whole video). + pub fn active_at(&self, t: f32) -> Option<&sylpheed_formats::SubCue> { + self.active_cues(t).into_iter().next() + } + + /// Every caption active at `t` seconds, in start order. + /// + /// Cues with an explicit `start-end` range use it directly; range-only tracks + /// (radio / resupply) carry a *start* only, so each shows for an estimated + /// reading duration capped by the next cue's start. Two captions whose spans + /// overlap are **both** returned (some tracks cross-fade lines), so the caller + /// can stack them rather than showing only the first. + pub fn active_cues(&self, t: f32) -> Vec<&sylpheed_formats::SubCue> { + if !self.enabled { + return Vec::new(); + } + let mut out = Vec::new(); + for (i, c) in self.cues.iter().enumerate() { + if t < c.start { + continue; + } + let end = match c.end { + Some(e) => e, + None => { + // For an open-ended cue, cap at the next cue that starts + // *after* it (a later cue may itself start earlier if the + // list has overlaps, so scan forward for the first greater + // start rather than blindly taking i+1). + let next = self + .cues + .iter() + .skip(i + 1) + .map(|n| n.start) + .find(|&s| s > c.start) + .unwrap_or(f32::INFINITY); + (c.start + estimated_read_secs(&c.text)).min(next) + } + }; + if t < end { + out.push(c); + } + } + out + } +} + +/// Estimated on-screen time for a caption that carries no explicit end time, +/// from its length (~13 characters/second reading speed), clamped to a sensible +/// 2–7 s so a short line still lingers and a long one doesn't overstay. +fn estimated_read_secs(text: &str) -> f32 { + let chars = text.chars().filter(|c| !c.is_whitespace()).count() as f32; + (1.2 + chars / 13.0).clamp(2.0, 7.0) +} + +/// Ask the loader to (re)load subtitles for `movie` in `lang`. Fired on video +/// open and on language change. +#[derive(Event)] +pub struct RequestSubtitles { + pub movie: String, + pub lang: sylpheed_formats::SubLang, + pub generation: u64, +} + +/// The cutscene voice-over track for the current movie. The audio itself lives +/// in the video engine (a second rodio sink); this resource holds the UI toggle +/// and async-load bookkeeping. Registered on all platforms; the loader is +/// native-only. +#[derive(Resource)] +pub struct MovieVoice { + /// Whether the voice track is audible (user toggle). + pub enabled: bool, + /// Voice language (only English/Japanese exist on disc). + pub lang: sylpheed_formats::slb::VoiceLang, + /// Basename of the movie the current voice belongs to. + pub movie: Option, + /// True while decoding. + pub loading: bool, + /// True once a decoded voice track exists for `movie` (else the movie has none). + pub available: bool, + /// Bumped per request to discard stale async results. + pub generation: u64, + /// A freshly-decoded WAV awaiting sink construction on the engine thread. + pub(crate) pending_wav: Option, +} + +impl Default for MovieVoice { + fn default() -> Self { + Self { + enabled: true, + lang: sylpheed_formats::slb::VoiceLang::English, + movie: None, + loading: false, + available: false, + generation: 0, + pending_wav: None, + } + } +} + +/// Ask the loader to decode the voice track for `movie`. `duration` clamps the +/// decode to the media length (dropping any trailing bank data past the video). +#[derive(Event)] +pub struct RequestVoice { + pub movie: String, + pub lang: sylpheed_formats::slb::VoiceLang, + pub duration: f32, + pub generation: u64, +} + +/// Standalone audio player: plays a decoded voice/sound track on its own (no +/// video), with its own transport. Populated via [`RequestAudio`]. +#[derive(Resource)] +pub struct AudioPreview { + pub active: bool, + pub name: String, + pub position: f32, + pub duration: f32, + pub playing: bool, + pub volume: f32, + pub loading: bool, + pub seek_request: Option, + /// A freshly-decoded WAV + duration awaiting sink construction. + pub(crate) pending: Option<(PathBuf, f32)>, + pub(crate) generation: u64, +} + +impl Default for AudioPreview { + fn default() -> Self { + Self { + active: false, + name: String::new(), + position: 0.0, + duration: 0.0, + playing: false, + volume: 0.9, + loading: false, + seek_request: None, + pending: None, + generation: 0, + } + } +} + +/// Ask the loader to decode a clip for standalone playback (full length, no +/// clamp). Either `clip` names the `.slb` entry directly (the Voice Lines +/// browser), or `movie` asks the loader to resolve the voice bank from the movie +/// manifest (the movie player's 🎧 solo button). `display` is the UI label. +#[derive(Event)] +pub struct RequestAudio { + pub clip: String, + pub display: String, + /// When set, resolve the `.slb` from the movie manifest instead of `clip`. + pub movie: Option<(String, sylpheed_formats::slb::VoiceLang)>, + pub generation: u64, +} + +/// The browseable library of voice-line clips (from `sounds.tbl`), for the +/// standalone voice player. Loaded lazily the first time the browser opens. +#[derive(Resource, Default)] +pub struct VoiceLibrary { + pub open: bool, + pub loading: bool, + pub loaded: bool, + pub clips: Vec, + pub filter: String, +} + +/// Ask the loader to enumerate the voice library from `sounds.tbl`. +#[derive(Event, Default)] +pub struct RequestVoiceLibrary; + /// The currently-open IPFB data pack, shown as a master-detail browser. #[derive(Resource, Default)] pub struct PakView { @@ -346,11 +560,85 @@ enum IsoLoaderMsg { /// A `.wmv` materialised to a temp file + probed (+ audio pre-decoded) off /// the main thread; the frame pipe is opened later in `apply_loaded_video`. VideoLoaded(Box), + /// An `XPR2` container fully decoded + prepared off the main thread (meshes + /// built, textures decoded). `apply_prepared_xpr` only registers the result + /// into Bevy `Assets`, so the heavy work never blocks the UI. `generation` + /// lets a stale result (the user clicked something else) be discarded. + XprPrepared { + generation: u64, + prepared: Box, + }, + /// Subtitles loaded for a cutscene (empty if the movie has none). `movie` + + /// `generation` gate stale results when the user switches movie/language. + SubtitlesLoaded { + movie: String, + lang: sylpheed_formats::SubLang, + generation: u64, + cues: Vec, + }, + /// Voice track decoded to a temp mono WAV (`wav` is `None` if the movie has + /// no voice). `movie` + `generation` gate stale results. + VoiceLoaded { + movie: String, + generation: u64, + wav: Option, + }, + /// Standalone audio decoded (`wav` + duration seconds), or `None` on failure. + AudioLoaded { + name: String, + generation: u64, + wav: Option<(PathBuf, f32)>, + }, + /// The voice-clip library enumerated from `sounds.tbl`. + VoiceLibraryLoaded { + clips: Vec, + }, /// User dismissed the file dialog — not an error. Cancelled, Error(String), } +/// Result of decoding an `XPR2` container off the main thread. Bevy `Mesh` and +/// `Image` are CPU-side (no GPU handle until added to `Assets`), so they are +/// `Send` and can be fully built on a worker; the applier just registers them. +/// The CPU-side texture maps of one material, built off-thread. `_col` → albedo, +/// `_spc` (specular) → metallic and `_gls` (gloss) → roughness packed into one +/// linear metallic-roughness image (G=roughness, B=metallic), `_lum` → emissive. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Default)] +pub struct PreparedMat { + pub albedo: Option, + pub metallic_roughness: Option, + pub emissive: Option, +} + +#[cfg(not(target_arch = "wasm32"))] +pub enum PreparedXpr { + /// A 3D model / stage: meshes already baked into world space and merged by + /// material, plus the per-material texture maps (slot 0 → neutral tint). + Model { + meshes: Vec<(Mesh, usize)>, // (mesh, material index) + materials: Vec, + focus: Vec3, + radius: f32, + min_radius: f32, + max_radius: f32, + name: String, + info: String, + }, + /// A 2D texture (`width`/`height` carried on the `Image`). + Texture { image: Image, info: String }, + /// A world cubemap: six labelled faces. + Cubemap { + faces: Vec<(&'static str, Image, u32, u32)>, + info: String, + }, + /// Nothing previewable — just a status line (parse error / declined layout). + Info(String), + /// The decode was cancelled because a newer selection arrived. + Cancelled, +} + /// A probed, temp-materialised video ready for playback. Boxed inside the /// message so the enum stays small. #[cfg(not(target_arch = "wasm32"))] @@ -396,6 +684,19 @@ struct PendingFileBytes { bytes: Vec, } +/// Native-only playback engine for the standalone [`AudioPreview`]. Held as a +/// `NonSend` resource because `rodio::OutputStream` is `!Send`. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Default)] +struct AudioEngine { + _stream: Option, + handle: Option, + sink: Option, + wav: Option, + applied_volume: f32, + was_playing: bool, +} + /// One-frame staging buffer for a loaded pack; `apply_pak` (chained after) /// consumes it, freeing the previous preview and populating `PakView`. #[cfg(not(target_arch = "wasm32"))] @@ -416,6 +717,27 @@ struct PendingVideo { video: Option>, } +/// Tracks the in-flight off-thread XPR decode so a new selection can supersede +/// it: each dispatch bumps `generation` and flips the previous `cancel` flag, so +/// the old worker aborts at the next resource boundary and its (now stale) +/// result is dropped by `apply_prepared_xpr`. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Resource, Default)] +struct XprLoadState { + generation: u64, + cancel: Option>, +} + +/// One-frame staging buffer for a prepared XPR container; `apply_prepared_xpr` +/// consumes it and registers the meshes / textures into Bevy `Assets`. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Resource, Default)] +struct PendingXpr { + ready: bool, + generation: u64, + prepared: Option>, +} + /// Native-only playback engine backing the current `VideoPreview`. Held as a /// `NonSend` resource because `rodio::OutputStream` is `!Send`. `None` when no /// video is playing. @@ -455,6 +777,14 @@ struct VideoEngineInner { applied_volume: f32, /// Last `playing` state mirrored to the sink, to detect transitions. was_playing: bool, + // ── voice-over track (the localized cutscene voice, a second sink) ── + /// Decoded mono voice WAV (from `\Movie\VOICE_.slb`), if any. + voice_wav: Option, + /// The voice sink, layered over the movie's own music+SFX. Muted (volume 0) + /// when the voice toggle is off; otherwise mirrors play/pause/seek exactly. + voice_sink: Option, + /// Last effective voice volume pushed (0.0 = disabled). + voice_applied_volume: f32, } // ── Plugin ──────────────────────────────────────────────────────────────────── @@ -475,7 +805,15 @@ impl Plugin for IsoLoaderPlugin { .init_resource::() .init_resource::() .init_resource::() - .init_resource::(); + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_event::() + .add_event::() + .add_event::() + .add_event::(); #[cfg(not(target_arch = "wasm32"))] { @@ -483,7 +821,10 @@ impl Plugin for IsoLoaderPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() .init_non_send_resource::() + .init_non_send_resource::() .add_systems( Update, ( @@ -492,9 +833,15 @@ impl Plugin for IsoLoaderPlugin { handle_file_selected, poll_loader_channel, apply_loaded_texture, + apply_prepared_xpr, apply_pak, apply_loaded_video, advance_video_playback, + handle_subtitle_request, + handle_voice_request, + handle_audio_request, + advance_audio_playback, + handle_voice_library_request, ) .chain() .in_set(IsoLoaderSystemSet), @@ -1329,6 +1676,11 @@ fn poll_loader_channel( mut pending: ResMut, mut pending_pak: ResMut, mut pending_video: ResMut, + mut pending_xpr: ResMut, + mut subtitles: ResMut, + mut mvoice: ResMut, + mut audio_preview: ResMut, + mut voice_lib: ResMut, ) { let receiver = channels.receiver.lock().unwrap(); loop { @@ -1366,6 +1718,65 @@ fn poll_loader_channel( pending_video.ready = true; browser.loading = false; } + Ok(IsoLoaderMsg::XprPrepared { + generation, + prepared, + }) => { + pending_xpr.generation = generation; + pending_xpr.prepared = Some(prepared); + pending_xpr.ready = true; + // browser.loading stays true here; `apply_prepared_xpr` clears it + // once the (non-stale) result is registered. + } + Ok(IsoLoaderMsg::SubtitlesLoaded { + movie, + lang, + generation, + cues, + }) => { + // Ignore results for a movie/language the user has moved on from. + if generation == subtitles.generation + && subtitles.movie.as_deref() == Some(movie.as_str()) + && subtitles.lang == lang + { + subtitles.cues = cues; + subtitles.loading = false; + } + } + Ok(IsoLoaderMsg::VoiceLoaded { + movie, + generation, + wav, + }) => { + if generation == mvoice.generation + && mvoice.movie.as_deref() == Some(movie.as_str()) + { + mvoice.loading = false; + mvoice.available = wav.is_some(); + mvoice.pending_wav = wav; + } + } + Ok(IsoLoaderMsg::AudioLoaded { + name, + generation, + wav, + }) => { + if generation == audio_preview.generation { + audio_preview.loading = false; + match wav { + Some((p, dur)) => { + audio_preview.name = name; + audio_preview.pending = Some((p, dur)); + } + None => audio_preview.active = false, + } + } + } + Ok(IsoLoaderMsg::VoiceLibraryLoaded { clips }) => { + voice_lib.clips = clips; + voice_lib.loaded = true; + voice_lib.loading = false; + } Ok(IsoLoaderMsg::Cancelled) => { iso_state.loading = false; browser.loading = false; @@ -1426,6 +1837,7 @@ fn free_video( // Drop audio first (stops the device), then kill ffmpeg; dropping the // receiver unblocks the reader thread's `send` so it exits. drop(inner.sink); + drop(inner.voice_sink); drop(inner._stream); let mut child = inner.child; let _ = child.kill(); @@ -1435,6 +1847,9 @@ fn free_video( if let Some(w) = &inner.temp_wav { let _ = std::fs::remove_file(w); } + if let Some(w) = &inner.voice_wav { + let _ = std::fs::remove_file(w); + } } *video = VideoPreview::default(); } @@ -1498,11 +1913,25 @@ fn pick_albedo_index(model_name: &str, tex_names: &[String]) -> Option { if let Some((i, _)) = cols.iter().find(|(_, n)| *n == exact) { return Some(*i); } - // 2. Names starting with the stem — prefer the shortest (closest) one. + // 2. Names starting with the stem. Prefer the model's PRIMARY body map + // (`…_bdy_01a_col`, the 1024² main texture) over the smaller secondary + // maps: multi-part ship bodies name several `_col` textures, and the old + // "shortest name" rule picked a tiny 128² part map (`f001_bdy_02_col`) for + // the whole hull → visibly pixelated. Rank `01a` first, then any `01`, + // then fall back to the shortest (closest) name. if let Some((i, _)) = cols .iter() .filter(|(_, n)| n.starts_with(&stem)) - .min_by_key(|(_, n)| n.len()) + .min_by_key(|(_, n)| { + let rank = if n.contains("01a") { + 0 + } else if n.contains("01") { + 1 + } else { + 2 + }; + (rank, n.len()) + }) { return Some(*i); } @@ -1510,350 +1939,6 @@ fn pick_albedo_index(model_name: &str, tex_names: &[String]) -> Option { cols.iter().find(|(_, n)| n.contains(&stem)).map(|(i, _)| *i) } -/// Build Bevy meshes from a decoded [`Xbg7Model`], texture them with the model's -/// albedo (`_col`) map, spawn them into the scene tagged [`PreviewModel`], and -/// frame the orbit camera on the combined bounding box. -#[cfg(not(target_arch = "wasm32"))] -#[allow(clippy::too_many_arguments)] -fn spawn_model_preview( - model: &sylpheed_formats::mesh::Xbg7Model, - xpr_bytes: &[u8], - commands: &mut Commands, - meshes: &mut Assets, - materials: &mut Assets, - images: &mut Assets, - model_preview: &mut ModelPreview, - orbit: &mut Query<&mut crate::camera::OrbitCamera>, -) { - use bevy::render::mesh::{Indices, PrimitiveTopology}; - use sylpheed_formats::texture::X360Texture; - - // ── Albedo texture: the model's `_col` map (matched by entity stem), else - // the first texture as a last resort so a single model is never untextured. - let names = X360Texture::texture_names(xpr_bytes); - let col_idx = pick_albedo_index(&model.name, &names) - .or(if names.is_empty() { None } else { Some(0) }); - let (base_color_texture, tex_label) = match col_idx { - Some(i) => match X360Texture::from_xpr2_index(xpr_bytes, i) - .ok() - .and_then(|t| crate::asset_loader::x360_texture_to_bevy_image(t).ok()) - { - Some(img) => (Some(images.add(img)), names[i].clone()), - None => (None, "none".to_string()), - }, - None => (None, "none".to_string()), - }; - - let material = materials.add(StandardMaterial { - base_color: Color::srgb(0.8, 0.8, 0.82), - base_color_texture, - perceptual_roughness: 0.7, - metallic: 0.1, - // Xbox winding / our handedness may disagree — show both sides so a - // model is never invisible from the "back". - cull_mode: None, - double_sided: true, - ..default() - }); - - // ── Combined bounding box for camera framing. ── - let mut lo = Vec3::splat(f32::MAX); - let mut hi = Vec3::splat(f32::MIN); - - // Parent entity so the whole model can be despawned / transformed as one. - let root = commands - .spawn(( - PreviewModel, - Transform::default(), - Visibility::default(), - Name::new(model.name.clone()), - )) - .id(); - - let mut total_v = 0usize; - let mut total_t = 0usize; - for sub in &model.meshes { - if sub.positions.is_empty() || sub.indices.is_empty() { - continue; - } - total_v += sub.positions.len(); - total_t += sub.indices.len() / 3; - for p in &sub.positions { - lo = lo.min(Vec3::from_array(*p)); - hi = hi.max(Vec3::from_array(*p)); - } - - let positions: Vec<[f32; 3]> = sub.positions.clone(); - let uvs: Vec<[f32; 2]> = if sub.uvs.len() == sub.positions.len() { - sub.uvs.clone() - } else { - vec![[0.0, 0.0]; sub.positions.len()] - }; - // Prefer the model's own normals; fall back to computed smooth normals. - let normals = if sub.normals.len() == sub.positions.len() { - sub.normals.clone() - } else { - compute_smooth_normals(&positions, &sub.indices) - }; - - let mut mesh = Mesh::new( - PrimitiveTopology::TriangleList, - RenderAssetUsages::default(), - ); - mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); - mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals); - mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs); - mesh.insert_indices(Indices::U32(sub.indices.clone())); - - let child = commands - .spawn(( - PreviewModel, - Mesh3d(meshes.add(mesh)), - MeshMaterial3d(material.clone()), - Transform::default(), - )) - .id(); - commands.entity(root).add_child(child); - } - - // ── Frame the orbit camera on the model. ── - let center = if lo.x <= hi.x { (lo + hi) * 0.5 } else { Vec3::ZERO }; - let extent = if lo.x <= hi.x { - (hi - lo).length().max(0.001) - } else { - 5.0 - }; - if let Ok(mut cam) = orbit.get_single_mut() { - cam.focus = center; - cam.radius = extent * 1.4; - cam.min_radius = (extent * 0.02).max(0.02); - cam.max_radius = (extent * 20.0).max(50.0); - } - - model_preview.active = true; - model_preview.name = model.name.clone(); - model_preview.info = format!( - "XBG7 model · {} sub-mesh(es) · {} verts · {} tris · albedo: {}", - model.meshes.len(), - total_v, - total_t, - tex_label, - ); -} - -/// Spawn every decoded sub-model of a **stage** container as a side-by-side row -/// (a "cast sheet"): the stage's enemy / prop models are separate objects with no -/// recovered scene transforms, so each is recentred on its own origin and laid -/// out along +X, spaced by its width. The orbit camera frames the whole row. -#[cfg(not(target_arch = "wasm32"))] -#[allow(clippy::too_many_arguments)] -fn spawn_stage_models( - models: &[sylpheed_formats::mesh::Xbg7Model], - xpr_bytes: &[u8], - commands: &mut Commands, - meshes: &mut Assets, - materials: &mut Assets, - images: &mut Assets, - model_preview: &mut ModelPreview, - orbit: &mut Query<&mut crate::camera::OrbitCamera>, -) { - use bevy::render::mesh::{Indices, PrimitiveTopology}; - use sylpheed_formats::texture::X360Texture; - - // Per-sub-model albedo: match each model's name to its `_col` texture - // (e.g. `e003` → `e003_bdy_col`, `e005` → `e005_01_col`). Decode each once - // and cache by texture index; fall back to a neutral tint when no `_col` - // channel matches. Colours are the same "unverified" item as the 2D textures. - let tex_names = X360Texture::texture_names(xpr_bytes); - let neutral = materials.add(StandardMaterial { - base_color: Color::srgb(0.72, 0.74, 0.78), - perceptual_roughness: 0.65, - metallic: 0.1, - cull_mode: None, - double_sided: true, - ..default() - }); - let mut tex_cache: std::collections::HashMap> = - std::collections::HashMap::new(); - let material_for = |model_name: &str, - materials: &mut Assets, - images: &mut Assets, - cache: &mut std::collections::HashMap>| - -> Handle { - // Match the sub-model to its albedo map by entity stem (`e007_bdy_01` → - // `e007_col`). No own colour map ⇒ neutral tint rather than a wrong one. - let Some(i) = pick_albedo_index(model_name, &tex_names) else { - return neutral.clone(); - }; - if let Some(h) = cache.get(&i) { - return h.clone(); - } - let handle = X360Texture::from_xpr2_index(xpr_bytes, i) - .ok() - .and_then(|t| crate::asset_loader::x360_texture_to_bevy_image(t).ok()) - .map(|img| { - materials.add(StandardMaterial { - base_color: Color::WHITE, - base_color_texture: Some(images.add(img)), - perceptual_roughness: 0.7, - metallic: 0.1, - cull_mode: None, - double_sided: true, - ..default() - }) - }) - .unwrap_or_else(|| neutral.clone()); - cache.insert(i, handle.clone()); - handle - }; - - let root = commands - .spawn(( - PreviewModel, - Transform::default(), - Visibility::default(), - Name::new("stage"), - )) - .id(); - - // A stage holds up to ~450 sub-models at wildly different true scales (a - // 1-unit prop next to a 3000-unit structure) with no recovered scene - // transforms. Laying them end-to-end made a mile-wide strip the camera - // couldn't escape. Instead present a **thumbnail grid**: each model is - // recentred and uniformly scaled to a fixed cell, so all are equally visible - // and browsable regardless of native size. Grid spans the XY plane. - const CELL: f32 = 10.0; // target on-screen size of each thumbnail - const GAP: f32 = 4.0; - let pitch = CELL + GAP; - - // Extent above which a sub-model is a skybox plane (300 k-unit cloud quad) or - // a stray-vertex mis-anchor rather than a real object — dropped from the grid. - const MAX_EXTENT: f32 = 20_000.0; - let mut drawn: Vec<&sylpheed_formats::mesh::Xbg7Model> = Vec::new(); - for model in models { - let mut lo = Vec3::splat(f32::MAX); - let mut hi = Vec3::splat(f32::MIN); - let mut has_geo = false; - for sub in &model.meshes { - if sub.positions.is_empty() || sub.indices.is_empty() { - continue; - } - has_geo = true; - for p in &sub.positions { - lo = lo.min(Vec3::from_array(*p)); - hi = hi.max(Vec3::from_array(*p)); - } - } - if has_geo && (hi - lo).max_element() <= MAX_EXTENT { - drawn.push(model); - } - } - let cols = (drawn.len() as f32).sqrt().ceil().max(1.0) as usize; - - let mut total_v = 0usize; - let mut total_t = 0usize; - for (i, model) in drawn.iter().enumerate() { - // Per-model bbox → recentre + uniform scale to the cell. - let mut lo = Vec3::splat(f32::MAX); - let mut hi = Vec3::splat(f32::MIN); - for sub in &model.meshes { - for p in &sub.positions { - lo = lo.min(Vec3::from_array(*p)); - hi = hi.max(Vec3::from_array(*p)); - } - } - if lo.x > hi.x { - continue; - } - let center = (lo + hi) * 0.5; - let extent = (hi - lo).max_element().max(1e-3); - let scale = CELL / extent; - - // Grid cell centre (row-major, growing down in −Y). - let col = i % cols; - let row = i / cols; - let cell_pos = Vec3::new(col as f32 * pitch, -(row as f32) * pitch, 0.0); - - // One entity per model carries the placement + normalising scale; its - // sub-meshes are recentred to the model's own origin underneath it. - let model_root = commands - .spawn(( - PreviewModel, - Transform::from_translation(cell_pos).with_scale(Vec3::splat(scale)), - Visibility::default(), - Name::new(model.name.clone()), - )) - .id(); - commands.entity(root).add_child(model_root); - - let material = material_for(&model.name, materials, images, &mut tex_cache); - - for sub in &model.meshes { - if sub.positions.is_empty() || sub.indices.is_empty() { - continue; - } - total_v += sub.positions.len(); - total_t += sub.indices.len() / 3; - - let positions: Vec<[f32; 3]> = - sub.positions.iter().map(|p| { - [p[0] - center.x, p[1] - center.y, p[2] - center.z] - }).collect(); - let uvs: Vec<[f32; 2]> = if sub.uvs.len() == sub.positions.len() { - sub.uvs.clone() - } else { - vec![[0.0, 0.0]; sub.positions.len()] - }; - let normals = if sub.normals.len() == sub.positions.len() { - sub.normals.clone() - } else { - compute_smooth_normals(&positions, &sub.indices) - }; - - let mut mesh = - Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::default()); - mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); - mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals); - mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs); - mesh.insert_indices(Indices::U32(sub.indices.clone())); - - let child = commands - .spawn(( - PreviewModel, - Mesh3d(meshes.add(mesh)), - MeshMaterial3d(material.clone()), - Transform::default(), - )) - .id(); - commands.entity(model_root).add_child(child); - } - } - - // Frame the whole grid. - let rows = drawn.len().div_ceil(cols); - let grid_w = cols as f32 * pitch; - let grid_h = rows as f32 * pitch; - let center = Vec3::new(grid_w * 0.5 - pitch * 0.5, -(grid_h * 0.5 - pitch * 0.5), 0.0); - let diag = (grid_w * grid_w + grid_h * grid_h).sqrt().max(CELL); - if let Ok(mut cam) = orbit.get_single_mut() { - cam.focus = center; - cam.radius = diag * 0.75; - cam.min_radius = CELL * 0.05; - cam.max_radius = diag * 3.0; - } - - model_preview.active = true; - model_preview.name = "stage".to_string(); - model_preview.info = format!( - "Stage container · {} sub-models (thumbnail grid, each scaled to fit) · \ - {} verts · {} tris (content-anchored; hero bodies with quantized layout \ - are skipped)", - drawn.len(), - total_v, - total_t, - ); -} - /// Per-vertex smooth normals = normalized sum of incident face normals. #[cfg(not(target_arch = "wasm32"))] fn compute_smooth_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> { @@ -1880,6 +1965,7 @@ fn compute_smooth_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; /// Converts pending raw bytes into a Bevy `Image`, registers it with egui, /// and populates `TexturePreview` / `SkyboxPreview` / `FileInfo`. #[cfg(not(target_arch = "wasm32"))] +#[allow(clippy::too_many_arguments)] fn apply_loaded_texture( mut pending: ResMut, mut preview: ResMut, @@ -1892,11 +1978,11 @@ fn apply_loaded_texture( mut images: ResMut>, mut contexts: bevy_egui::EguiContexts, mut commands: Commands, - mut meshes: ResMut>, - mut materials: ResMut>, mut model_preview: ResMut, old_models: Query>, - mut orbit: Query<&mut crate::camera::OrbitCamera>, + channels: Res, + mut xpr_load: ResMut, + mut browser: ResMut, ) { if !pending.ready { return; @@ -1906,6 +1992,15 @@ fn apply_loaded_texture( let bytes = std::mem::take(&mut pending.bytes); let path = std::mem::take(&mut pending.path); + // Any new selection supersedes an in-flight XPR decode: bump the generation + // (so a late worker result is discarded) and flip the previous cancel flag + // (so the worker aborts at its next resource boundary). Done for *every* file + // type, not just XPR, so switching to e.g. a text file also cancels a stage. + xpr_load.generation = xpr_load.generation.wrapping_add(1); + if let Some(prev) = xpr_load.cancel.take() { + prev.store(true, Ordering::Relaxed); + } + let fmt = sylpheed_formats::vfs::identify_format(&bytes); // Always free the previous previews (GPU memory + egui slots) so exactly one @@ -1951,75 +2046,68 @@ fn apply_loaded_texture( } if fmt != FileFormat::Xpr2Texture { - return; // Not a texture — FileInfo is sufficient + return; // Not a texture / model — FileInfo is sufficient. } - // 3D model? XPR2 containers that hold XBG7 geometry (ship / weapon / prop - // models under `hidden/resource3d/`) preview as an orbitable mesh, textured - // with their albedo (`_col`) map. Complex multi-stream body meshes decline - // cleanly (Err) and fall through to the 2D texture preview below. - // A container may be a single model (weapons / props) OR a stage — a - // collection of many enemy / prop sub-models. Route by the number of XBG7 - // resources, NOT by whichever decoder yields more verts: the old max-verts - // rule let a stage's content-anchoring win on single-model files and fabricate - // phantom / spike-mess blocks (see the CLI `decode_models`). One XBG7 → the - // validated records-based list decode, falling back to a STRICT-gated content - // anchor if that can't locate the block; many XBG7 → the ungated stage anchor. + // XPR2 containers (a 2D texture, a world cubemap, a single model, or a stage + // with hundreds of sub-models) can take *seconds* to decode + prepare. Doing + // that on this system froze the whole app (OS "not responding", no spinner). + // Instead hand the bytes to a worker that does ALL the heavy work — geometry + // decode, transform baking, texture decode, mesh building — and returns a + // ready-to-register `PreparedXpr`; `apply_prepared_xpr` only touches `Assets`. + // The generation was already bumped (and any prior worker cancelled) above. + let generation = xpr_load.generation; + let cancel = Arc::new(AtomicBool::new(false)); + xpr_load.cancel = Some(cancel.clone()); + browser.loading = true; // keep the spinner up until the result is applied + let sender = channels.sender.clone(); + std::thread::spawn(move || { + let prepared = prepare_xpr(&bytes, &cancel); + let _ = sender.send(IsoLoaderMsg::XprPrepared { + generation, + prepared: Box::new(prepared), + }); + }); +} + +/// Decode + fully prepare an `XPR2` container **off the main thread**: geometry +/// decode (abortable), transform baking, per-material merge, texture decode, and +/// Bevy `Mesh`/`Image` construction. Everything here is CPU-only and `Send`; the +/// result is registered into `Assets` by [`apply_prepared_xpr`] on the main +/// thread. `cancel` is polled between stage resources so a new selection aborts. +#[cfg(not(target_arch = "wasm32"))] +fn prepare_xpr(bytes: &[u8], cancel: &Arc) -> PreparedXpr { use sylpheed_formats::mesh::{count_xbg7, Xbg7Model}; - if count_xbg7(&bytes) > 1 { - let stage = Xbg7Model::stage_models(&bytes); - if !stage.is_empty() { - spawn_stage_models( - &stage, - &bytes, - &mut commands, - &mut meshes, - &mut materials, - &mut images, - &mut model_preview, - &mut orbit, - ); - return; - } + let should_cancel = || cancel.load(Ordering::Relaxed); + + // ── 3D geometry? Route by XBG7 resource count (see the CLI `decode_models`): + // >1 → a stage collection (content-anchored, abortable); 1 → a single model + // via the validated decode, else the strict content-anchor fallback. ── + let models: Vec = if count_xbg7(bytes) > 1 { + Xbg7Model::stage_models_cancellable(bytes, &should_cancel) } else { - let single = Xbg7Model::from_xpr2(&bytes) - .ok() - .filter(|m| !m.meshes.is_empty()); - if let Some(model) = single { - spawn_model_preview( - &model, - &bytes, - &mut commands, - &mut meshes, - &mut materials, - &mut images, - &mut model_preview, - &mut orbit, - ); - return; + match Xbg7Model::from_xpr2(bytes) { + Ok(m) if !m.meshes.is_empty() => vec![m], + _ => Xbg7Model::anchor_models_cancellable(bytes, 0.85, &should_cancel), } - // from_xpr2 couldn't locate the block — strict-gated content anchor. - let fallback = Xbg7Model::anchor_models(&bytes, 0.85); - if !fallback.is_empty() { - spawn_stage_models( - &fallback, - &bytes, - &mut commands, - &mut meshes, - &mut materials, - &mut images, - &mut model_preview, - &mut orbit, - ); - return; + }; + if should_cancel() { + return PreparedXpr::Cancelled; + } + if !models.is_empty() { + // A ship file is a base pose + `_mnv*`/`_turn180` animation poses of the + // SAME entity. Draw ONLY the base pose (not a 5-copy grid of the poses). + if let Some((base, _poses)) = detect_pose_set(&models) { + return prepare_models(bytes, std::slice::from_ref(&models[base])); } + return prepare_models(bytes, &models); } - // World cubemap (`TXCM`) → decode all 6 faces and show a labelled grid. - // Returns `None` for ordinary 2D textures, which fall through below. - match sylpheed_formats::texture::X360Texture::cube_faces_from_xpr2(&bytes) { + // ── World cubemap (TXCM): decode all six faces. ── + match sylpheed_formats::texture::X360Texture::cube_faces_from_xpr2(bytes) { Ok(Some(cube)) => { use sylpheed_formats::texture::{Cubemap, X360Texture}; + let mut faces = Vec::new(); for (i, face) in cube.faces.iter().enumerate() { let face_tex = X360Texture { width: cube.width, @@ -2030,64 +2118,611 @@ fn apply_loaded_texture( data: face.clone(), }; if let Ok(img) = crate::asset_loader::x360_texture_to_bevy_image(face_tex) { - let handle = images.add(img); - let egui_id = contexts.add_image(handle.clone_weak()); - skybox.faces.push(FaceTex { - label: Cubemap::face_label(i), - handle, - egui_id, - width: cube.width, - height: cube.height, - }); + faces.push((Cubemap::face_label(i), img, cube.width, cube.height)); } } - skybox.info = format!( + let info = format!( "Cubemap {}×{} {:?} · {} faces", cube.width, cube.height, cube.format, - skybox.faces.len() + faces.len() ); - return; + return PreparedXpr::Cubemap { faces, info }; } Ok(None) => {} // ordinary 2D texture - Err(e) => { - preview.format_info = format!("Cubemap parse failed: {e}"); - return; + Err(e) => return PreparedXpr::Info(format!("Cubemap parse failed: {e}")), + } + + // ── Ordinary 2D texture. ── + let tex = match sylpheed_formats::X360Texture::from_xpr2(bytes) { + Ok(t) => t, + Err(e) => return PreparedXpr::Info(format!("XPR2 parse failed: {e}")), + }; + let info = format!( + "{:?} {}×{} {} mip(s)", + tex.format, tex.width, tex.height, tex.mip_levels + ); + match crate::asset_loader::x360_texture_to_bevy_image(tex) { + Ok(image) => PreparedXpr::Texture { image, info }, + Err(e) => PreparedXpr::Info(format!("Bevy image conversion failed: {e}")), + } +} + +/// Bake decoded [`Xbg7Model`]s into world-space Bevy meshes merged by material. +/// A single model is centred at the origin; several (a stage) are laid out in a +/// thumbnail grid, each recentred and uniformly scaled to a fixed cell so all are +/// browsable regardless of native size. Runs on the worker thread. +/// Pack a specular (`_spc`) and gloss (`_gls`) map into a single linear +/// metallic-roughness image (Bevy samples G=roughness, B=metallic): metallic ← +/// specular intensity, roughness ← 1 − gloss. Both source maps are the game's +/// single-channel maps read from the red channel. Returns `None` unless at least +/// one source is an UNCOMPRESSED RGBA8 image — the game's maps are usually +/// DXT/BC-compressed (GPU-only), which we can't read on the CPU here, so those +/// materials fall back to the scalar metallic/roughness. +#[cfg(not(target_arch = "wasm32"))] +fn pack_metallic_roughness(spc: Option<&Image>, gls: Option<&Image>) -> Option { + use bevy::render::render_asset::RenderAssetUsages; + use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat}; + let raw = |img: &&Image| { + matches!( + img.texture_descriptor.format, + TextureFormat::Rgba8Unorm | TextureFormat::Rgba8UnormSrgb + ) + }; + let spc = spc.filter(raw); + let gls = gls.filter(raw); + let base = spc.or(gls)?; + let w = base.texture_descriptor.size.width.max(1); + let h = base.texture_descriptor.size.height.max(1); + // Nearest-sample the red channel of a (possibly differently-sized) source. + let samp = |img: Option<&Image>, x: u32, y: u32, dflt: u8| -> u8 { + let Some(img) = img else { return dflt }; + let iw = img.texture_descriptor.size.width.max(1); + let ih = img.texture_descriptor.size.height.max(1); + let sx = (x * iw / w).min(iw - 1); + let sy = (y * ih / h).min(ih - 1); + img.data.get(((sy * iw + sx) * 4) as usize).copied().unwrap_or(dflt) + }; + let mut data = vec![0u8; (w * h * 4) as usize]; + for y in 0..h { + for x in 0..w { + let i = ((y * w + x) * 4) as usize; + data[i + 1] = 255 - samp(gls, x, y, 96); // G = roughness (no gloss → mid) + data[i + 2] = samp(spc, x, y, 0); // B = metallic (no spec → dielectric) + data[i + 3] = 255; + } + } + Some(Image::new( + Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + TextureDimension::D2, + data, + TextureFormat::Rgba8Unorm, // linear — metallic/roughness are not colour + RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD, + )) +} + +#[cfg(not(target_arch = "wasm32"))] +fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) -> PreparedXpr { + use bevy::render::mesh::{Indices, PrimitiveTopology}; + use sylpheed_formats::texture::X360Texture; + + const CELL: f32 = 10.0; + const GAP: f32 = 4.0; + const MAX_EXTENT: f32 = 20_000.0; // drop skybox planes / stray-vertex anchors + let pitch = CELL + GAP; + let is_stage = models.len() > 1; + + // Keep models with real, bounded geometry. + let drawn: Vec<&sylpheed_formats::mesh::Xbg7Model> = models + .iter() + .filter(|m| { + let mut lo = Vec3::splat(f32::MAX); + let mut hi = Vec3::splat(f32::MIN); + let mut has = false; + for sub in &m.meshes { + if sub.positions.is_empty() || sub.indices.is_empty() { + continue; + } + has = true; + for p in &sub.positions { + lo = lo.min(Vec3::from_array(*p)); + hi = hi.max(Vec3::from_array(*p)); + } + } + has && (hi - lo).max_element() <= MAX_EXTENT + }) + .collect(); + if drawn.is_empty() { + return PreparedXpr::Info("No previewable geometry in this container.".to_string()); + } + let cols = (drawn.len() as f32).sqrt().ceil().max(1.0) as usize; + + // Map each TX2D texture index to a material slot, decoding each used texture + // exactly once. `materials[0]` is always the neutral tint (slot 0). + let tex_names = X360Texture::texture_names(bytes); + let mut materials: Vec = vec![PreparedMat::default()]; // slot 0 = neutral tint + let mut tex_slot: std::collections::HashMap = std::collections::HashMap::new(); + // Decode a TX2D to a Bevy image, by index or by name. + let load_idx = |ti: usize| -> Option { + X360Texture::from_xpr2_index(bytes, ti) + .ok() + .and_then(|t| crate::asset_loader::x360_texture_to_bevy_image(t).ok()) + }; + let load_named = |name: &str| -> Option { + let ti = tex_names.iter().position(|t| t.eq_ignore_ascii_case(name))?; + load_idx(ti) + }; + let slot_for_ti = |ti: usize, + materials: &mut Vec, + tex_slot: &mut std::collections::HashMap| + -> usize { + if let Some(&slot) = tex_slot.get(&ti) { + return slot; + } + let albedo = load_idx(ti); + // Sibling maps share the base name with a different suffix + // (`…_col` → `…_spc` / `…_gls` / `…_lum`). + let base = tex_names + .get(ti) + .and_then(|n| n.strip_suffix("_col").or(Some(n.as_str()))) + .unwrap_or("") + .to_string(); + let spc = (!base.is_empty()).then(|| load_named(&format!("{base}_spc"))).flatten(); + let gls = (!base.is_empty()).then(|| load_named(&format!("{base}_gls"))).flatten(); + let emissive = (!base.is_empty()).then(|| load_named(&format!("{base}_lum"))).flatten(); + let metallic_roughness = pack_metallic_roughness(spc.as_ref(), gls.as_ref()); + let slot = materials.len(); + materials.push(PreparedMat { albedo, metallic_roughness, emissive }); + tex_slot.insert(ti, slot); + slot + }; + + // Accumulate world-space vertices per material so the main thread spawns only + // ~one mesh per material instead of hundreds of entities. + struct Buf { + pos: Vec<[f32; 3]>, + nrm: Vec<[f32; 3]>, + uv: Vec<[f32; 2]>, + idx: Vec, + } + let mut buffers: Vec = vec![Buf { + pos: vec![], + nrm: vec![], + uv: vec![], + idx: vec![], + }]; + let ensure = |buffers: &mut Vec, n: usize| { + while buffers.len() <= n { + buffers.push(Buf { + pos: vec![], + nrm: vec![], + uv: vec![], + idx: vec![], + }); + } + }; + + let mut world_lo = Vec3::splat(f32::MAX); + let mut world_hi = Vec3::splat(f32::MIN); + let mut total_v = 0usize; + let mut total_t = 0usize; + + for (i, model) in drawn.iter().enumerate() { + // Per-model bbox → recentre + (stage) uniform scale to the grid cell. + let mut lo = Vec3::splat(f32::MAX); + let mut hi = Vec3::splat(f32::MIN); + for sub in &model.meshes { + for p in &sub.positions { + lo = lo.min(Vec3::from_array(*p)); + hi = hi.max(Vec3::from_array(*p)); + } + } + if lo.x > hi.x { + continue; + } + let center = (lo + hi) * 0.5; + let (scale, cell) = if is_stage { + let extent = (hi - lo).max_element().max(1e-3); + let col = i % cols; + let row = i / cols; + ( + CELL / extent, + Vec3::new(col as f32 * pitch, -(row as f32) * pitch, 0.0), + ) + } else { + (1.0, Vec3::ZERO) + }; + + // XBG7 scene-graph node placements: the parts are authored around the + // origin in the rigid vertex buffer and posed by per-node transforms the + // game applies in the shader (translation to the tail, V-tail cant, and a + // mirrored copy for each L/R pair). Recover the full affine per drawn + // instance, keyed on the geometry (sub_index). Stages skip this. + let placements = if is_stage { + Vec::new() + } else { + sylpheed_formats::mesh::node_transforms(bytes, &model.name) + }; + + for (sub_idx, sub) in model.meshes.iter().enumerate() { + if sub.positions.is_empty() || sub.indices.is_empty() { + continue; + } + let has_uv = sub.uvs.len() == sub.positions.len(); + let has_nrm = sub.normals.len() == sub.positions.len(); + + // Split the sub-mesh into its XBG7 material draw-groups so each slice + // gets its authored albedo. The Delta Saber body is 9 groups (bdy_01a + // hull + bdy_01b + bdy_02/03 + daiza stand); the fins are single-group + // (bdy_04/06/07). Stages keep the cheap per-model stem match; when the + // graph yields nothing, fall back to one stem-matched albedo. + let groups = if is_stage { + Vec::new() + } else { + sylpheed_formats::mesh::material_groups(bytes, &model.name, sub.indices.len()) + }; + let slices: Vec<(usize, usize, Option)> = if groups.is_empty() { + vec![(0, sub.indices.len(), pick_albedo_index(&model.name, &tex_names))] + } else { + groups + .iter() + .map(|g| { + let ti = tex_names.iter().position(|t| t.eq_ignore_ascii_case(&g.albedo)); + (g.idx_offset, g.idx_count, ti) + }) + .collect() + }; + + // Every scene-graph instance that draws this geometry (a mirrored fin + // pair, L/R winglets…); `None` = no placement → identity. + let instances: Vec> = { + let v: Vec<_> = placements + .iter() + .filter(|p| p.sub_index == sub_idx && p.vtx_count == sub.positions.len()) + .map(Some) + .collect(); + if v.is_empty() { + vec![None] + } else { + v + } + }; + + for place in instances { + let reflect = place.map(|p| p.reflect).unwrap_or(false); + total_v += sub.positions.len(); + total_t += sub.indices.len() / 3; + + // Positions: apply the node affine, then recentre + (stage) scale. + let tpos: Vec<[f32; 3]> = sub + .positions + .iter() + .map(|p| { + let local = place.map(|pl| pl.apply(*p)).unwrap_or(*p); + let wp = (Vec3::from_array(local) - center) * scale + cell; + world_lo = world_lo.min(wp); + world_hi = world_hi.max(wp); + wp.to_array() + }) + .collect(); + // Normals: rotate by the (orthogonal) linear part; a reflected + // instance's winding is reversed below so faces stay outward. + let tnrm: Vec<[f32; 3]> = if has_nrm { + sub.normals + .iter() + .map(|n| match place { + Some(pl) => Vec3::new( + pl.m[0][0] * n[0] + pl.m[0][1] * n[1] + pl.m[0][2] * n[2], + pl.m[1][0] * n[0] + pl.m[1][1] * n[1] + pl.m[1][2] * n[2], + pl.m[2][0] * n[0] + pl.m[2][1] * n[1] + pl.m[2][2] * n[2], + ) + .normalize_or_zero() + .to_array(), + None => *n, + }) + .collect() + } else { + compute_smooth_normals(&tpos, &sub.indices) + }; + + for (off, cnt, ti) in &slices { + let mat = match ti { + Some(ti) => slot_for_ti(*ti, &mut materials, &mut tex_slot), + None => 0, + }; + ensure(&mut buffers, mat); + let end = (off + cnt).min(sub.indices.len()); + // Append the slice's triangles, re-emitting vertices per index + // (no dedup) so each material buffer stays self-contained. A + // reflected instance swaps two corners to keep front faces out. + for tri in sub.indices[(*off).min(end)..end].chunks_exact(3) { + let corners = if reflect { + [tri[0], tri[2], tri[1]] + } else { + [tri[0], tri[1], tri[2]] + }; + for &vi in &corners { + let vi = vi as usize; + if vi >= tpos.len() { + continue; + } + let b = &mut buffers[mat]; + let next = b.pos.len() as u32; + b.pos.push(tpos[vi]); + b.nrm.push(tnrm[vi]); + b.uv.push(if has_uv { sub.uvs[vi] } else { [0.0, 0.0] }); + b.idx.push(next); + } + } + } + } } } - let tex = match sylpheed_formats::X360Texture::from_xpr2(&bytes) { - Ok(t) => t, - Err(e) => { - preview.format_info = format!("XPR2 parse failed: {e}"); - return; + // Build one Bevy mesh per non-empty material buffer. + let mut meshes: Vec<(Mesh, usize)> = Vec::new(); + for (mat, buf) in buffers.into_iter().enumerate() { + if buf.pos.is_empty() || buf.idx.is_empty() { + continue; } + let mut mesh = Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::default()); + mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, buf.pos); + mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, buf.nrm); + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, buf.uv); + mesh.insert_indices(Indices::U32(buf.idx)); + meshes.push((mesh, mat)); + } + + let center = if world_lo.x <= world_hi.x { + (world_lo + world_hi) * 0.5 + } else { + Vec3::ZERO + }; + let diag = if world_lo.x <= world_hi.x { + (world_hi - world_lo).length().max(CELL) + } else { + CELL + }; + let (name, info) = if is_stage { + ( + "stage".to_string(), + format!( + "Stage container · {} sub-models (thumbnail grid) · {} verts · {} tris \ + (content-anchored; quantized hero bodies skipped)", + drawn.len(), + total_v, + total_t + ), + ) + } else { + ( + drawn[0].name.clone(), + format!( + "XBG7 model · {} sub-mesh(es) · {} verts · {} tris", + drawn[0].meshes.len(), + total_v, + total_t + ), + ) }; - let w = tex.width; - let h = tex.height; - let mips = tex.mip_levels; - let fmt_str = format!("{:?} {}×{} {} mip(s)", tex.format, w, h, mips); - - let image = match crate::asset_loader::x360_texture_to_bevy_image(tex) { - Ok(img) => img, - Err(e) => { - preview.format_info = format!("Bevy image conversion failed: {e}"); - return; - } - }; - - let handle = images.add(image); - let egui_id = contexts.add_image(handle.clone_weak()); - - preview.handle = Some(handle); - preview.egui_id = Some(egui_id); - preview.width = w; - preview.height = h; - preview.format_info = fmt_str; + PreparedXpr::Model { + meshes, + materials, + focus: center, + radius: diag * if is_stage { 0.55 } else { 0.7 }, + min_radius: (diag * 0.02).max(0.02), + max_radius: diag * 3.0, + name, + info, + } } +/// True when a resource name is a maneuver / turn morph pose (`_rou_f001_mnv01_L`, +/// `_rou_f001_turn180`) rather than the base pose (`f001`). +#[cfg(not(target_arch = "wasm32"))] +fn is_pose_name(name: &str) -> bool { + let l = name.to_ascii_lowercase(); + l.contains("mnv") || l.contains("turn") +} + +/// Recognise a ship container: several XBG7 resources that are one base pose plus +/// `_mnv*`/`_turn180` morph poses of the SAME entity, all sharing topology (so +/// the poses can be blended per-vertex). Returns `(base_index, pose_indices)`, +/// or `None` for a genuine multi-entity stage / a single model. +#[cfg(not(target_arch = "wasm32"))] +fn detect_pose_set(models: &[sylpheed_formats::mesh::Xbg7Model]) -> Option<(usize, Vec)> { + if models.len() < 2 { + return None; + } + // All resources must belong to one entity (single stem) — else it's a stage. + let stem0 = entity_stem(&models[0].name); + if stem0.is_empty() || !models.iter().all(|m| entity_stem(&m.name) == stem0) { + return None; + } + let bases: Vec = (0..models.len()) + .filter(|&i| !is_pose_name(&models[i].name)) + .collect(); + if bases.len() != 1 { + return None; // ambiguous — don't guess + } + let base = bases[0]; + let base_v: usize = models[base].meshes.iter().map(|m| m.positions.len()).sum(); + if base_v == 0 { + return None; + } + // Poses must share the base vertex count (same topology → valid morph). + let poses: Vec = (0..models.len()) + .filter(|&i| i != base && is_pose_name(&models[i].name)) + .filter(|&i| { + let v: usize = models[i].meshes.iter().map(|m| m.positions.len()).sum(); + v == base_v + }) + .collect(); + if poses.is_empty() { + return None; + } + Some((base, poses)) +} + +/// Register a worker-prepared [`PreparedXpr`] into Bevy `Assets` on the main +/// thread (cheap: no decode, just `Assets::add` + entity spawns). Discards a +/// result whose generation was superseded by a newer selection. +#[cfg(not(target_arch = "wasm32"))] +#[allow(clippy::too_many_arguments)] +fn apply_prepared_xpr( + mut pending: ResMut, + xpr_load: Res, + mut browser: ResMut, + mut preview: ResMut, + mut skybox: ResMut, + mut images: ResMut>, + mut meshes: ResMut>, + mut materials: ResMut>, + mut contexts: bevy_egui::EguiContexts, + mut commands: Commands, + mut model_preview: ResMut, + old_models: Query>, + mut orbit: Query<&mut crate::camera::OrbitCamera>, +) { + if !pending.ready { + return; + } + pending.ready = false; + let prepared = match pending.prepared.take() { + Some(p) => p, + None => return, + }; + // Drop a stale result: the user has since selected a different file. + if pending.generation != xpr_load.generation { + return; + } + browser.loading = false; + + match *prepared { + PreparedXpr::Cancelled => {} + PreparedXpr::Info(msg) => { + preview.format_info = msg; + } + PreparedXpr::Texture { image, info } => { + let w = image.width(); + let h = image.height(); + let handle = images.add(image); + let egui_id = contexts.add_image(handle.clone_weak()); + preview.handle = Some(handle); + preview.egui_id = Some(egui_id); + preview.width = w; + preview.height = h; + preview.format_info = info; + } + PreparedXpr::Cubemap { faces, info } => { + for (label, image, width, height) in faces { + let handle = images.add(image); + let egui_id = contexts.add_image(handle.clone_weak()); + skybox.faces.push(FaceTex { + label, + handle, + egui_id, + width, + height, + }); + } + skybox.info = info; + } + PreparedXpr::Model { + meshes: prepared_meshes, + materials: prepared_mats, + focus, + radius, + min_radius, + max_radius, + name, + info, + } => { + // Despawn any prior model here (rather than at dispatch) so the last + // preview stays under the spinner until this result actually lands. + for e in old_models.iter() { + commands.entity(e).despawn_recursive(); + } + + // One StandardMaterial per prepared material slot — albedo (`_col`), + // a packed metallic-roughness map (`_spc`/`_gls`, when uncompressed), + // and emissive (`_lum`, e.g. the fin's glow). + let mat_handles: Vec> = prepared_mats + .into_iter() + .map(|mat| { + let base_color_texture = mat.albedo.map(|i| images.add(i)); + let metallic_roughness_texture = mat.metallic_roughness.map(|i| images.add(i)); + let has_emissive = mat.emissive.is_some(); + let emissive_texture = mat.emissive.map(|i| images.add(i)); + materials.add(StandardMaterial { + base_color: if base_color_texture.is_some() { + Color::WHITE + } else { + Color::srgb(0.72, 0.74, 0.78) + }, + base_color_texture, + metallic_roughness_texture, + // The game's ship shader (PS 0x01F962B4DCFEB577) lights the + // hull mostly with an HDR environment cubemap reflection, so + // the metal must be reflective for the viewer's + // EnvironmentMapLight to show. Metallic hull, fairly smooth; + // the packed map overrides per-pixel where present. + perceptual_roughness: 0.4, + metallic: 0.7, + // The lum mask is boosted ~1.7× in the game (PS c65.z ≈ 1.6–1.8). + emissive: if has_emissive { + LinearRgba::rgb(1.7, 1.7, 1.7) + } else { + LinearRgba::BLACK + }, + emissive_texture, + cull_mode: None, + double_sided: true, + ..default() + }) + }) + .collect(); + + let root = commands + .spawn(( + PreviewModel, + Transform::default(), + Visibility::default(), + Name::new(name.clone()), + )) + .id(); + for (mesh, mat) in prepared_meshes { + let material = mat_handles + .get(mat) + .cloned() + .unwrap_or_else(|| mat_handles[0].clone()); + let child = commands + .spawn(( + PreviewModel, + Mesh3d(meshes.add(mesh)), + MeshMaterial3d(material), + Transform::default(), + )) + .id(); + commands.entity(root).add_child(child); + } + + if let Ok(mut cam) = orbit.get_single_mut() { + cam.focus = focus; + cam.radius = radius; + cam.min_radius = min_radius; + cam.max_radius = max_radius; + } + model_preview.active = true; + model_preview.name = name; + model_preview.info = info; + } + } +} + + /// Consumes a staged pack, freeing the previous texture/text previews and /// populating `PakView` for the master-detail browser. #[cfg(not(target_arch = "wasm32"))] @@ -2144,6 +2779,11 @@ fn apply_loaded_video( mut file_info: ResMut, mut images: ResMut>, mut contexts: bevy_egui::EguiContexts, + mut subtitles: ResMut, + mut sub_events: EventWriter, + mut mvoice: ResMut, + mut voice_events: EventWriter, + mut audio_preview: ResMut, ) { if !pending.ready { return; @@ -2152,6 +2792,8 @@ fn apply_loaded_video( let Some(prep) = pending.video.take() else { return; }; + // Opening a video stops any standalone audio player. + audio_preview.active = false; let prep = *prep; // Free every other preview + any prior video so exactly one viewer is active. @@ -2240,6 +2882,9 @@ fn apply_loaded_video( sink, applied_volume: volume, was_playing: false, + voice_wav: None, + voice_sink: None, + voice_applied_volume: 0.0, }); *video = VideoPreview { @@ -2258,6 +2903,478 @@ fn apply_loaded_video( scrub_request: None, scrubbing: false, }; + + // Kick off subtitle loading for this movie in the current language. The + // movie basename is the file name without its `.wmv` extension. + let movie = video + .name + .rsplit('/') + .next() + .unwrap_or(&video.name) + .strip_suffix(".wmv") + .unwrap_or(&video.name) + .to_string(); + subtitles.generation = subtitles.generation.wrapping_add(1); + subtitles.movie = Some(movie.clone()); + subtitles.cues.clear(); + subtitles.loading = true; + sub_events.send(RequestSubtitles { + movie: movie.clone(), + lang: subtitles.lang, + generation: subtitles.generation, + }); + + // Kick off voice-track decoding for this movie (clamped to its length). + mvoice.generation = mvoice.generation.wrapping_add(1); + mvoice.movie = Some(movie.clone()); + mvoice.available = false; + mvoice.loading = true; + mvoice.pending_wav = None; + voice_events.send(RequestVoice { + movie, + lang: mvoice.lang, + duration: video.duration, + generation: mvoice.generation, + }); +} + +/// Read a `.pak` archive (+ its `.pNN` segments) from the current source, +/// blocking. Mirrors the pak-browser loader but returns a live `PakArchive`. +#[cfg(not(target_arch = "wasm32"))] +fn read_pak_archive_blocking( + source: &SourceKind, + pak_path: &str, +) -> Result { + let base = pak_path + .strip_suffix(".pak") + .ok_or_else(|| format!("not a .pak path: {pak_path}"))? + .to_string(); + match source { + SourceKind::Iso(iso_path) => { + let iso_path = iso_path.clone(); + let pak_path = pak_path.to_string(); + futures::executor::block_on(async move { + let mut reader = sylpheed_formats::xiso::open_iso(&iso_path) + .await + .map_err(|e| e.to_string())?; + let index = reader + .read_file(&pak_path) + .await + .map_err(|e| e.to_string())?; + let mut data = Vec::new(); + for i in 0..100u32 { + match reader.read_file(&format!("{base}.p{i:02}")).await { + Ok(mut b) => data.append(&mut b), + Err(_) => break, + } + } + sylpheed_formats::PakArchive::from_parts(&index, data).map_err(|e| e.to_string()) + }) + } + SourceKind::Directory(root) => { + let assets = sylpheed_formats::vfs::GameAssets::from_directory(root); + let index = assets.read(pak_path).map_err(|e| e.to_string())?; + let mut data = Vec::new(); + for i in 0..100u32 { + match assets.read(&format!("{base}.p{i:02}")) { + Ok(mut b) => data.append(&mut b), + Err(_) => break, + } + } + sylpheed_formats::PakArchive::from_parts(&index, data).map_err(|e| e.to_string()) + } + SourceKind::None => Err("no source open".to_string()), + } +} + +/// Handles a [`RequestSubtitles`]: loads the language track + text pack off the +/// main thread and posts the resolved cues back via the loader channel. +#[cfg(not(target_arch = "wasm32"))] +fn handle_subtitle_request( + mut events: EventReader, + iso_state: Res, + channels: Res, +) { + // Only act on the latest request (rapid language toggles coalesce). + let Some(req) = events.read().last() else { + return; + }; + let source = iso_state.source_kind.clone(); + let sender = channels.sender.clone(); + let (movie, lang, generation) = (req.movie.clone(), req.lang, req.generation); + std::thread::spawn(move || { + let cues = (|| -> Result, String> { + let lang_pak = + read_pak_archive_blocking(&source, &format!("dat/movie/{}.pak", lang.pak_code()))?; + let text_pak = read_pak_archive_blocking( + &source, + &format!("dat/GP_MAIN_GAME_{}.pak", lang.game_code()), + )?; + Ok(sylpheed_formats::movie_subtitle::load(&movie, &lang_pak, &text_pak)) + })() + .unwrap_or_default(); + let _ = sender.send(IsoLoaderMsg::SubtitlesLoaded { + movie, + lang, + generation, + cues, + }); + }); +} + +/// Read a whole file from the current source, blocking. +#[cfg(not(target_arch = "wasm32"))] +fn read_source_file(source: &SourceKind, path: &str) -> Result, String> { + match source { + SourceKind::Iso(iso_path) => { + let iso_path = iso_path.clone(); + let path = path.to_string(); + futures::executor::block_on(async move { + let mut reader = sylpheed_formats::xiso::open_iso(&iso_path) + .await + .map_err(|e| e.to_string())?; + reader.read_file(&path).await.map_err(|e| e.to_string()) + }) + } + SourceKind::Directory(root) => { + std::fs::read(root.join(path)).map_err(|e| e.to_string()) + } + SourceKind::None => Err("no source open".to_string()), + } +} + +/// Read a single byte range `[off, off+size)` out of a segmented pack +/// (`.p00`, `.p01`, …) without materialising the whole ~1 GB archive. +/// Directory sources seek into just the right segment(s); ISO sources read the +/// segment files up to the one covering the range and slice. +#[cfg(not(target_arch = "wasm32"))] +fn read_segment_range( + source: &SourceKind, + base: &str, + off: u64, + size: usize, +) -> Result, String> { + use std::io::{Read, Seek, SeekFrom}; + let mut out = Vec::with_capacity(size); + let mut skip = off; + let mut need = size; + for i in 0..100u32 { + if need == 0 { + break; + } + let seg = format!("{base}.p{i:02}"); + match source { + SourceKind::Directory(root) => { + let path = root.join(&seg); + let Ok(meta) = std::fs::metadata(&path) else { break }; + let seg_len = meta.len(); + if skip >= seg_len { + skip -= seg_len; + continue; + } + let mut f = std::fs::File::open(&path).map_err(|e| e.to_string())?; + f.seek(SeekFrom::Start(skip)).map_err(|e| e.to_string())?; + let take = need.min((seg_len - skip) as usize); + let start = out.len(); + out.resize(start + take, 0); + f.read_exact(&mut out[start..]).map_err(|e| e.to_string())?; + need -= take; + skip = 0; + } + SourceKind::Iso(_) => { + // No partial read on ISO; read the whole segment and slice. + let Ok(bytes) = read_source_file(source, &seg) else { break }; + let seg_len = bytes.len() as u64; + if skip >= seg_len { + skip -= seg_len; + continue; + } + let take = need.min((seg_len - skip) as usize); + out.extend_from_slice(&bytes[skip as usize..skip as usize + take]); + need -= take; + skip = 0; + } + SourceKind::None => return Err("no source open".to_string()), + } + } + if need != 0 { + return Err(format!("segment range short by {need} bytes")); + } + Ok(out) +} + +/// Read one `sound.pak` entry (a `.slb` bank) by name-hash, reading only its +/// byte range from the segments. +#[cfg(not(target_arch = "wasm32"))] +fn read_sound_entry(source: &SourceKind, name_hash: u32) -> Result, String> { + let toc = read_source_file(source, "dat/sound.pak")?; + let entries = sylpheed_formats::PakArchive::parse_toc(&toc).map_err(|e| e.to_string())?; + let idx = entries + .binary_search_by_key(&name_hash, |e| e.name_hash) + .map_err(|_| "voice not present in sound.pak".to_string())?; + let e = &entries[idx]; + read_segment_range(source, "dat/sound", e.offset as u64, e.comp_size as usize) +} + +/// Decode a `sound.pak` clip (by its `.slb` entry name) to a temp **mono** WAV +/// (left channel — the source is mono, sometimes only in the left channel). +/// `duration` clamps the decode (`INFINITY` = full clip). Returns the WAV path. +#[cfg(not(target_arch = "wasm32"))] +fn decode_sound_clip( + source: &SourceKind, + clip_name: &str, + duration: f32, +) -> Result { + use sylpheed_formats::{hash::name_hash, slb}; + let key = name_hash(clip_name); + let bytes = read_sound_entry(source, key)?; + let riff = slb::to_xma_riff(&bytes).ok_or("not a decodable .slb")?; + + let dir = std::env::temp_dir(); + let xma_path = dir.join(format!("sylph_voice_{key:08x}.xma.wav")); + let out_path = dir.join(format!("sylph_voice_{key:08x}.wav")); + std::fs::write(&xma_path, &riff).map_err(|e| e.to_string())?; + + let dur = if duration.is_finite() && duration > 0.0 { + duration + } else { + 600.0 + }; + let status = Command::new("ffmpeg") + .args(["-hide_banner", "-y", "-i"]) + .arg(&xma_path) + // Downmix to mono using the left channel (holds the full signal for both + // left-only and dual-mono sources), then clamp to the media length. + .args(["-af", "pan=mono|c0=c0", "-t"]) + .arg(format!("{dur}")) + .arg(&out_path) + .output(); + let _ = std::fs::remove_file(&xma_path); + match status { + Ok(o) if o.status.success() && out_path.metadata().map(|m| m.len() > 44).unwrap_or(false) => { + Ok(out_path) + } + Ok(o) => Err(format!( + "ffmpeg voice decode failed: {}", + String::from_utf8_lossy(&o.stderr).lines().last().unwrap_or("") + )), + Err(e) => Err(format!("spawning ffmpeg: {e}")), + } +} + +/// Resolve a movie's `sound.pak` voice-entry name via the **movie manifest** +/// (`tables.pak`), which authoritatively binds each movie to its voice bank — +/// several `hokyu_*` resupply movies point at in-mission `VOICE_D_*` clips in +/// `\etc\`, and many bind to nothing at all. Returns `None` when the movie +/// has no voice-over (so the caller plays silence instead of guessing a bank). +#[cfg(not(target_arch = "wasm32"))] +fn resolve_movie_voice_clip( + source: &SourceKind, + movie: &str, + lang: sylpheed_formats::slb::VoiceLang, +) -> Option { + use sylpheed_formats::movie_manifest; + let pak = read_pak_archive_blocking(source, "dat/tables.pak").ok()?; + // The manifest has no stable name, so find it by shape among the entries. + let manifest = pak + .entries() + .iter() + .find_map(|e| pak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))?; + let sounds = pak + .read_by_name(&format!("{}\\sounds.tbl", lang.code_pub()))? + .ok()?; + // Only the manifest's DIRECT bindings are trusted. Extending to unbound + // resupply movies by shared demo line was verified WRONG (played the wrong + // recording), so unbound movies stay unvoiced rather than play a guess. + movie_manifest::resolve_voice_entry(&manifest, &sounds, movie, lang) +} + +/// Handles a [`RequestVoice`]: decodes the cutscene voice off-thread and posts +/// the temp WAV path back via the loader channel. The voice bank is resolved +/// from the movie manifest, so movies the game leaves unvoiced (e.g. most +/// `hokyu_*` resupply cutscenes) correctly post no audio rather than a wrong clip. +#[cfg(not(target_arch = "wasm32"))] +fn handle_voice_request( + mut events: EventReader, + iso_state: Res, + channels: Res, +) { + let Some(req) = events.read().last() else { + return; + }; + let source = iso_state.source_kind.clone(); + let sender = channels.sender.clone(); + let (movie, lang, duration, generation) = + (req.movie.clone(), req.lang, req.duration, req.generation); + std::thread::spawn(move || { + let wav = resolve_movie_voice_clip(&source, &movie, lang) + .and_then(|clip| decode_sound_clip(&source, &clip, duration).ok()); + let _ = sender.send(IsoLoaderMsg::VoiceLoaded { + movie, + generation, + wav, + }); + }); +} + +/// Duration (seconds) of a PCM WAV from its `fmt`/`data` chunks. +#[cfg(not(target_arch = "wasm32"))] +fn wav_duration(path: &Path) -> Option { + let b = std::fs::read(path).ok()?; + let find = |tag: &[u8]| b.windows(4).position(|w| w == tag); + let f = find(b"fmt ")?; + let rate = u32::from_le_bytes([b[f + 12], b[f + 13], b[f + 14], b[f + 15]]); + let byte_rate = u32::from_le_bytes([b[f + 16], b[f + 17], b[f + 18], b[f + 19]]); + let d = find(b"data")?; + let data_sz = u32::from_le_bytes([b[d + 4], b[d + 5], b[d + 6], b[d + 7]]); + let br = if byte_rate > 0 { byte_rate } else { rate * 2 }; + (br > 0).then(|| data_sz as f32 / br as f32) +} + +/// Handles a [`RequestAudio`]: decodes a movie's voice full-length (no clamp) for +/// standalone playback, posting the WAV + duration back via the loader channel. +#[cfg(not(target_arch = "wasm32"))] +fn handle_audio_request( + mut events: EventReader, + iso_state: Res, + channels: Res, +) { + let Some(req) = events.read().last() else { + return; + }; + let source = iso_state.source_kind.clone(); + let sender = channels.sender.clone(); + let (clip, display, movie, generation) = ( + req.clip.clone(), + req.display.clone(), + req.movie.clone(), + req.generation, + ); + std::thread::spawn(move || { + // Resolve the movie voice bank from the manifest when asked, else decode + // the named clip directly. + let entry = match movie { + Some((m, lang)) => resolve_movie_voice_clip(&source, &m, lang), + None => Some(clip), + }; + let wav = entry + .and_then(|c| decode_sound_clip(&source, &c, f32::INFINITY).ok()) + .and_then(|p| wav_duration(&p).map(|d| (p, d))); + let _ = sender.send(IsoLoaderMsg::AudioLoaded { + name: display, + generation, + wav, + }); + }); +} + +/// Handles a [`RequestVoiceLibrary`]: reads `tables.pak`'s `sounds.tbl` and +/// enumerates the voice clips off-thread. +#[cfg(not(target_arch = "wasm32"))] +fn handle_voice_library_request( + mut events: EventReader, + iso_state: Res, + channels: Res, +) { + if events.read().next().is_none() { + return; + } + let source = iso_state.source_kind.clone(); + let sender = channels.sender.clone(); + std::thread::spawn(move || { + let clips = (|| -> Result, String> { + let pak = read_pak_archive_blocking(&source, "dat/tables.pak")?; + let tbl = pak + .read_by_name("eng\\sounds.tbl") + .ok_or("sounds.tbl not found")? + .map_err(|e| e.to_string())?; + Ok(sylpheed_formats::slb::list_voice_clips( + &tbl, + sylpheed_formats::slb::VoiceLang::English, + )) + })() + .unwrap_or_default(); + let _ = sender.send(IsoLoaderMsg::VoiceLibraryLoaded { clips }); + }); +} + +/// Drives the standalone audio player: builds/rebuilds its sink, mirrors +/// play/pause + volume + seeks, advances the clock, and stops at the end. +#[cfg(not(target_arch = "wasm32"))] +fn advance_audio_playback( + mut audio: ResMut, + mut engine: NonSendMut, + time: Res