feat(formats,viewer): movie subtitles, voice decode, and the movie manifest

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_<movie>; 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 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-19 20:15:41 +02:00
parent 578c71a1b9
commit 95de29f290
22 changed files with 4684 additions and 493 deletions

40
Cargo.lock generated
View File

@@ -1892,6 +1892,25 @@ dependencies = [
"crossbeam-utils", "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]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.21" version = "0.8.21"
@@ -4107,6 +4126,26 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" 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]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.4.1" version = "0.4.1"
@@ -4587,6 +4626,7 @@ dependencies = [
"binrw", "binrw",
"flate2", "flate2",
"futures", "futures",
"rayon",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.18", "thiserror 2.0.18",

View File

@@ -523,6 +523,23 @@ fn cmd_mesh_info(file: &Path) -> Result<()> {
nv.saturating_sub(1), nv.saturating_sub(1),
if oob > 0 { format!(", OOB {oob}") } else { String::new() }, 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!( println!(
@@ -568,7 +585,32 @@ fn cmd_mesh_render(
// uniformly scaled to a fixed cell, so all are equally visible regardless of // uniformly scaled to a fixed cell, so all are equally visible regardless of
// native scale (mirrors `spawn_stage_models`). // native scale (mirrors `spawn_stage_models`).
let multi = models.len() > 1 || force_row; 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(); 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<usize> = 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 CELL: f32 = 10.0;
const GAP: f32 = 4.0; const GAP: f32 = 4.0;
let grid_pitch = CELL + GAP; let grid_pitch = CELL + GAP;
@@ -600,31 +642,105 @@ fn cmd_mesh_render(
} else { } else {
(1.0, [0.0, 0.0, 0.0]) (1.0, [0.0, 0.0, 0.0])
}; };
for sub in &m.meshes { // XNODEXFORM=1 applies the XBG7 scene-graph node placement (fins move to
let f = |i: usize| { // the tail) — to verify the transforms recovered from the graph.
let p = sub.positions[i]; let placements = if std::env::var("XNODEXFORM").is_ok() {
[ sylpheed_formats::mesh::node_transforms(&bytes, &m.name)
(p[0] - center[0]) * scale + cell[0], } else {
(p[1] - center[1]) * scale + cell[1], Vec::new()
(p[2] - center[2]) * scale + cell[2], };
] 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<Option<&sylpheed_formats::mesh::NodePlacement>> = {
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 // Sub-mesh indices are a triangle list (the decoder has already
// expanded the file's triangle strips). // expanded the file's triangle strips).
let n = sub.positions.len(); let n = sub.positions.len();
for tri in sub.indices.chunks_exact(3) { // XSPANONLY=1 renders ONLY long-edge ("spanning") triangles; XSPANHIDE=1
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize); // renders everything EXCEPT them — to see whether the flagged spanning
if a < n && b < n && c < n { // triangles are real geometry or decode artifacts (phantom sheets).
tris.push([f(a), f(b), f(c)]); let span_only = std::env::var("XSPANONLY").is_ok();
let span_hide = std::env::var("XSPANHIDE").is_ok();
let med = {
let mut e: Vec<f32> = 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() { if tris.is_empty() {
anyhow::bail!("no triangles to render"); 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) image::save_buffer(output, &rgba, size, size, image::ExtendedColorType::Rgba8)
.with_context(|| format!("writing PNG {}", output.display()))?; .with_context(|| format!("writing PNG {}", output.display()))?;
println!( println!(
@@ -643,7 +759,14 @@ fn cmd_mesh_render(
/// Minimal software rasterizer: orthographic, z-buffered, two-sided Lambert + /// Minimal software rasterizer: orthographic, z-buffered, two-sided Lambert +
/// headlight shading over a flat grey material on a dark background. Enough to /// headlight shading over a flat grey material on a dark background. Enough to
/// judge whether recovered geometry is coherent. /// judge whether recovered geometry is coherent.
fn rasterize(tris: &[[[f32; 3]; 3]], size: u32, yaw_deg: f32, pitch_deg: f32, dist: f32) -> Vec<u8> { fn rasterize(
tris: &[[[f32; 3]; 3]],
tints: &[[f32; 3]],
size: u32,
yaw_deg: f32,
pitch_deg: f32,
dist: f32,
) -> Vec<u8> {
let n = size as usize; let n = size as usize;
let (yaw, pitch) = (yaw_deg.to_radians(), pitch_deg.to_radians()); let (yaw, pitch) = (yaw_deg.to_radians(), pitch_deg.to_radians());
let (cy, sy) = (yaw.cos(), yaw.sin()); 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] [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 v0 = view(t[0]);
let v1 = view(t[1]); let v1 = view(t[1]);
let v2 = view(t[2]); 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; let idx = py * n + px;
if z < depth[idx] { if z < depth[idx] {
depth[idx] = z; depth[idx] = z;
color[idx * 4] = shade; color[idx * 4] = (shade as f32 * tint[0]).min(255.0) as u8;
color[idx * 4 + 1] = shade; color[idx * 4 + 1] = (shade as f32 * tint[1]).min(255.0) as u8;
color[idx * 4 + 2] = (shade as f32 * 1.02).min(255.0) as u8; color[idx * 4 + 2] = (shade as f32 * tint[2] * 1.02).min(255.0) as u8;
} }
} }
} }

View File

@@ -19,5 +19,11 @@ tracing = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { 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] [dev-dependencies]
tokio = { workspace = true } tokio = { workspace = true }

View File

@@ -50,10 +50,20 @@ pub mod mesh;
// Audio parsing — scaffold for XMA handling // Audio parsing — scaffold for XMA handling
pub mod audio; 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. /// Re-export the most commonly used types at the crate root.
pub use font::FontInfo; pub use font::FontInfo;
pub use idxd::{IdxdError, IdxdObject}; pub use idxd::{IdxdError, IdxdObject};
pub use ixud::{Cue, Subtitle}; pub use ixud::{Cue, Subtitle};
pub use movie_subtitle::{SubCue, SubLang};
pub use mesh::{GameMesh, Xbg7Model}; pub use mesh::{GameMesh, Xbg7Model};
pub use ratc::RatcChild; pub use ratc::RatcChild;
pub use t8ad::T8adImage; pub use t8ad::T8adImage;

View File

@@ -385,10 +385,31 @@ impl Xbg7Model {
Self::anchor_models(bytes, 0.0) 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<Xbg7Model> {
Self::anchor_models_cancellable(bytes, 0.0, should_cancel)
}
/// Content-anchor every XBG7 resource, rejecting any block whose winding /// Content-anchor every XBG7 resource, rejecting any block whose winding
/// consistency (`max(na, 1-na)`) is below `min_consistency`. See /// consistency (`max(na, 1-na)`) is below `min_consistency`. See
/// [`Xbg7Model::stage_models`]. /// [`Xbg7Model::stage_models`].
pub fn anchor_models(bytes: &[u8], min_consistency: f32) -> Vec<Xbg7Model> { pub fn anchor_models(bytes: &[u8], min_consistency: f32) -> Vec<Xbg7Model> {
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<Xbg7Model> {
let mut out = Vec::new(); let mut out = Vec::new();
if bytes.len() < 16 || &bytes[..4] != b"XPR2" { if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
return out; return out;
@@ -471,7 +492,17 @@ impl Xbg7Model {
starts_by_stride.insert(s, vertex_run_starts(bytes, data_base, s)); 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<Xbg7Model> {
if should_cancel() {
return None;
}
let starts = &starts_by_stride[&r.decl.stride]; let starts = &starts_by_stride[&r.decl.stride];
let meshes = if r.markers.len() == 1 { let meshes = if r.markers.len() == 1 {
// Single sub-mesh → the proven per-block adjacency anchor // Single sub-mesh → the proven per-block adjacency anchor
@@ -499,12 +530,20 @@ impl Xbg7Model {
.collect() .collect()
} }
}; };
if !meshes.is_empty() { (!meshes.is_empty()).then(|| Xbg7Model {
out.push(Xbg7Model { name: r.name.clone(),
name: r.name.clone(), meshes,
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 out
} }
@@ -1210,6 +1249,540 @@ fn read_cstr(b: &[u8], o: usize) -> Option<String> {
Some(String::from_utf8_lossy(&b[o..end]).into_owned()) 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<String> {
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<MaterialGroup> {
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<Rec> = 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<NodePlacement> {
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_<model>_` prefix stripped
vtx: usize,
t48: usize,
local_m: M3,
local_t: [f32; 3],
sib_end: Option<usize>,
}
let mut recs: Vec<Rec> = 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<usize> = Vec::new(); // unique t48, first-encounter → sub_index
let mut first_world: std::collections::HashMap<usize, (M3, [f32; 3])> =
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 ─────────────────────────────────────────────────────────────────── // ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)] #[cfg(test)]

View File

@@ -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 `<movie>.wmv` and optionally followed by:
//! - `<pak>+…​.prt` — an overlay/print-art reference,
//! - `<pak>+SUBTITLE_<movie>.tbl` — its caption timing track,
//! - `VOICE_<token>` — 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_<movie>`. Most story/radio
//! movies use `VOICE_<movie>` (in `<lang>\Movie\`), but several `hokyu_*`
//! (resupply) movies bind to in-mission radio clips like `VOICE_D_450` (in
//! `<lang>\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_<movie>` 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<String>,
}
/// 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<MovieEntry> {
let mut entries: Vec<MovieEntry> = 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<String> {
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<String> {
let token = voice_token(manifest, movie)?;
resolve_token_path(sounds_tbl, &token, lang)
}
/// Find the full `<lang>\…\<token>.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<String> {
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<String> {
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<u8> {
// 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
);
}
}

View File

@@ -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/<lang>.pak` holds one **timing track** per movie, keyed by
//! [`track_key`] = `name_hash("subtitle_<movie>.tbl")`. Each track is a
//! `Z1`+zlib block that decompresses to an **IXUD** container whose UTF-16**LE**
//! payload is `SUBTITLE MSG_DEMO_<demo> <mm:ss.cc> …` — i.e. *which*
//! demo-message shows *when* (radio / resupply movies), or inline text.
//! 2. `dat/GP_MAIN_GAME_<L>.pak` holds the **caption text**: more `Z1`+zlib IXUD
//! blocks where each line is stored as `text` immediately followed by its key
//! `MSG_DEMO_<demo>_<page>_<line>` (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/<code>.pak`; `game_code`
/// selects `dat/GP_MAIN_GAME_<code>.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/<code>.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_<code>.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<f32>,
pub text: String,
}
/// The `<lang>.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/<lang>.pak` (+segments); `text_pak` is
/// `dat/GP_MAIN_GAME_<L>.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<SubCue> {
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_<id>` 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 `<lang>.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<u32> {
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_<L>` pack.
///
/// Scans every IXUD block, pairing each text token with the immediately
/// following `MSG_DEMO_<demo>_<page>_<line>` key, and groups the lines per demo
/// ordered by (page, line).
pub fn build_demo_text(text_pak: &PakArchive) -> BTreeMap<u32, Vec<String>> {
// demo -> (page,line) -> text
let mut by_demo: BTreeMap<u32, BTreeMap<(u32, u32), String>> = 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_<d>` 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<f32>)> {
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_<d>` → `Some(d)` (reference token, no page/line).
fn demo_ref(t: &str) -> Option<u32> {
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_<d>_<page>_<line>` → `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<String> {
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. 0x7F0x9F) 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<f32>)> {
let one = |p: &str| -> Option<f32> {
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)));
}
}

View File

@@ -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<Vec<PakEntry>, 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`). /// All TOC entries, in stored order (ascending `name_hash`).
pub fn entries(&self) -> &[PakEntry] { pub fn entries(&self) -> &[PakEntry] {
&self.entries &self.entries

View File

@@ -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 `<lang>\Voice\…`, `<lang>\Movie\VOICE_<movie>.slb`,
//! `BGM_###.slb`, etc. (see `docs/re/structures/sound-slb.md`); look them up by
//! [`crate::hash::name_hash`]. Movie cutscene voice = one continuous
//! `<eng|jpn>\Movie\VOICE_<movie>.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 `<lang>\{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<VoiceClip> {
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 {
// `<lang>\<cat>\VOICE_<SPK>_<NNN>.slb` or `..\VOICE_<movie>.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<Vec<u8>> {
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 S10S16 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<u8> {
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<u8> {
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<usize> {
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<u32> {
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));
}
}

View File

@@ -7,7 +7,7 @@
//! Run: `cargo test -p sylpheed-formats --test mesh_disc -- --ignored --nocapture` //! Run: `cargo test -p sylpheed-formats --test mesh_disc -- --ignored --nocapture`
use std::path::PathBuf; use std::path::PathBuf;
use sylpheed_formats::mesh::Xbg7Model; use sylpheed_formats::mesh::{material_groups, node_transforms, submesh_albedos, Xbg7Model};
fn res3d_dir() -> Option<PathBuf> { fn res3d_dir() -> Option<PathBuf> {
if let Ok(p) = std::env::var("SYLPHEED_RES3D") { if let Ok(p) = std::env::var("SYLPHEED_RES3D") {
@@ -21,6 +21,119 @@ fn res3d_dir() -> Option<PathBuf> {
default.is_dir().then_some(default) 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] #[test]
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"] #[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
fn weapon_model_decodes_to_expected_geometry() { fn weapon_model_decodes_to_expected_geometry() {

View File

@@ -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<PathBuf> {
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<u8>, Vec<u8>) {
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_<movie> in <lang>\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_<movie>` 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<u32> = 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:?}");
}

View File

@@ -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<PathBuf> {
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::<Vec<_>>()
);
// 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::<Vec<_>>()
);
// 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);
}

View File

@@ -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<PathBuf> {
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<u8> {
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<u32> =
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"));
}

View File

@@ -8,6 +8,10 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::input::mouse::{MouseMotion, MouseWheel}; 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; 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<Assets<Image>>) {
let orbit = OrbitCamera::default(); let orbit = OrbitCamera::default();
let transform = orbit_transform(&orbit); 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(( commands.spawn((
Camera3d::default(), Camera3d::default(),
// Wide near/far so both tiny weapons and multi-thousand-unit stages fit; // Wide near/far so both tiny weapons and multi-thousand-unit stages fit;
@@ -69,9 +80,78 @@ fn spawn_camera(mut commands: Commands) {
}), }),
transform, transform,
orbit, 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<Image>) -> Handle<Image> {
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<u8> = 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( fn orbit_camera(
mut query: Query<(&mut OrbitCamera, &mut Transform, &mut Projection)>, mut query: Query<(&mut OrbitCamera, &mut Transform, &mut Projection)>,
mouse_buttons: Res<ButtonInput<MouseButton>>, mouse_buttons: Res<ButtonInput<MouseButton>>,

File diff suppressed because it is too large Load Diff

View File

@@ -72,9 +72,28 @@ pub fn run() {
} }
fn setup_scene(mut commands: Commands) { fn setup_scene(mut commands: Commands) {
commands.spawn(DirectionalLight { // Bright ambient so surfaces facing away from every light aren't pure black.
illuminance: 10_000.0, commands.insert_resource(AmbientLight {
shadows_enabled: true, color: Color::srgb(0.9, 0.93, 1.0),
..default() brightness: 900.0,
}); });
// A multi-directional rig (key + fills + rim + underside) so an orbiting
// camera always has some light on the visible side — the single front light
// left the backside unreadable.
let lights = [
(Vec3::new(1.0, 2.0, 1.5), 10_000.0, true), // key: front-top-right
(Vec3::new(-2.0, 1.0, 0.5), 4_500.0, false), // fill: left
(Vec3::new(0.5, 0.8, -2.0), 6_000.0, false), // rim: behind
(Vec3::new(0.0, -1.5, 0.5), 2_500.0, false), // underside fill
];
for (from, lux, shadows) in lights {
commands.spawn((
DirectionalLight {
illuminance: lux,
shadows_enabled: shadows,
..default()
},
Transform::from_translation(from).looking_at(Vec3::ZERO, Vec3::Y),
));
}
} }

View File

@@ -10,10 +10,13 @@ use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts}; use bevy_egui::{egui, EguiContexts};
use crate::iso_loader::{ use crate::iso_loader::{
FileInfo, FileSelected, ImageRgba, IsoState, ModelPreview, PakContent, PakView, RequestOpenDir, AudioPreview, FileInfo, FileSelected, ImageRgba, IsoState, ModelPreview, MovieSubtitles,
RequestOpenIso, SkyboxPreview, TextPreview, TexturePreview, VideoPreview, IsoLoaderSystemSet, MovieVoice, PakContent, PakView, RequestAudio, RequestOpenDir, RequestOpenIso, RequestSubtitles,
RequestVoiceLibrary, SkyboxPreview, TextPreview, TexturePreview, VideoPreview, VoiceLibrary,
IsoLoaderSystemSet,
}; };
use crate::ViewerState; use crate::ViewerState;
use sylpheed_formats::SubLang;
pub struct ViewerUiPlugin; pub struct ViewerUiPlugin;
@@ -123,6 +126,18 @@ fn render_dir(
} }
} }
/// Bundled event writers for the UI, to keep `draw_viewer_ui` under Bevy's
/// 16-parameter system limit.
#[derive(bevy::ecs::system::SystemParam)]
struct UiEvents<'w> {
open_iso: EventWriter<'w, RequestOpenIso>,
open_dir: EventWriter<'w, RequestOpenDir>,
file_selected: EventWriter<'w, FileSelected>,
subtitles: EventWriter<'w, RequestSubtitles>,
audio: EventWriter<'w, RequestAudio>,
voice_lib: EventWriter<'w, RequestVoiceLibrary>,
}
fn draw_viewer_ui( fn draw_viewer_ui(
mut contexts: EguiContexts, mut contexts: EguiContexts,
mut viewer: ResMut<ViewerState>, mut viewer: ResMut<ViewerState>,
@@ -135,9 +150,11 @@ fn draw_viewer_ui(
model: Res<ModelPreview>, model: Res<ModelPreview>,
mut video: ResMut<VideoPreview>, mut video: ResMut<VideoPreview>,
file_info: Res<FileInfo>, file_info: Res<FileInfo>,
mut open_iso_events: EventWriter<RequestOpenIso>, mut subtitles: ResMut<MovieSubtitles>,
mut open_dir_events: EventWriter<RequestOpenDir>, mut movie_voice: ResMut<MovieVoice>,
mut file_selected_events: EventWriter<FileSelected>, mut audio: ResMut<AudioPreview>,
mut voice_lib: ResMut<VoiceLibrary>,
mut events: UiEvents,
) { ) {
let ctx = contexts.ctx_mut(); let ctx = contexts.ctx_mut();
@@ -148,11 +165,11 @@ fn draw_viewer_ui(
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
if ui.button("Open ISO disc image…").clicked() { if ui.button("Open ISO disc image…").clicked() {
open_iso_events.send_default(); events.open_iso.send_default();
ui.close_menu(); ui.close_menu();
} }
if ui.button("Open extracted folder…").clicked() { if ui.button("Open extracted folder…").clicked() {
open_dir_events.send_default(); events.open_dir.send_default();
ui.close_menu(); ui.close_menu();
} }
ui.separator(); ui.separator();
@@ -167,6 +184,17 @@ fn draw_viewer_ui(
); );
}); });
ui.menu_button("View", |ui| {
if ui.button("🎙 Voice Lines…").clicked() {
voice_lib.open = true;
if !voice_lib.loaded && !voice_lib.loading {
voice_lib.loading = true;
events.voice_lib.send_default();
}
ui.close_menu();
}
});
ui.menu_button("Help", |ui| { ui.menu_button("Help", |ui| {
if ui.button("Controls…").clicked() { if ui.button("Controls…").clicked() {
// TODO: controls popup // TODO: controls popup
@@ -178,6 +206,65 @@ fn draw_viewer_ui(
}); });
}); });
// ── Standalone voice-line browser (floating window) ───────────────────
if voice_lib.open {
let mut open = true;
egui::Window::new("🎙 Voice Lines")
.default_width(360.0)
.default_height(480.0)
.open(&mut open)
.show(ctx, |ui| {
if voice_lib.loading {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Reading sounds.tbl…");
});
ctx.request_repaint();
} else if !voice_lib.loaded {
ui.label("Open a game source first.");
} else {
ui.horizontal(|ui| {
ui.label("Filter:");
ui.text_edit_singleline(&mut voice_lib.filter);
});
ui.label(
egui::RichText::new(format!("{} clips", voice_lib.clips.len()))
.weak()
.small(),
);
ui.separator();
let f = voice_lib.filter.to_lowercase();
let clips: Vec<_> = voice_lib
.clips
.iter()
.filter(|c| f.is_empty() || c.name.to_lowercase().contains(&f))
.take(2000)
.cloned()
.collect();
egui::ScrollArea::vertical().show(ui, |ui| {
for c in &clips {
ui.horizontal(|ui| {
if ui.button("").on_hover_text(&c.name).clicked() {
audio.generation = audio.generation.wrapping_add(1);
audio.loading = true;
audio.active = true;
audio.name = c.display.clone();
events.audio.send(RequestAudio {
clip: c.name.clone(),
display: c.display.clone(),
movie: None,
generation: audio.generation,
});
}
ui.label(&c.display);
});
}
});
}
});
voice_lib.open = open;
}
// ── Left panel: file browser ────────────────────────────────────────── // ── Left panel: file browser ──────────────────────────────────────────
egui::SidePanel::left("file_browser") egui::SidePanel::left("file_browser")
.resizable(true) .resizable(true)
@@ -226,7 +313,7 @@ fn draw_viewer_ui(
force_open, force_open,
selected, selected,
loading, loading,
&mut file_selected_events, &mut events.file_selected,
&files, &files,
); );
} }
@@ -252,7 +339,13 @@ fn draw_viewer_ui(
}); });
// ── Central area ────────────────────────────────────────────────────── // ── Central area ──────────────────────────────────────────────────────
if browser.selected.is_some() && model.active && !browser.loading { if audio.active {
// Standalone audio player takes over the central panel.
egui::CentralPanel::default().show(ctx, |ui| {
ctx.request_repaint();
draw_audio_player(ui, &mut audio);
});
} else if browser.selected.is_some() && model.active && !browser.loading {
// 3D model: draw the central panel TRANSPARENT so the real Bevy scene // 3D model: draw the central panel TRANSPARENT so the real Bevy scene
// (mesh + orbit camera) shows through, with just an info overlay on top. // (mesh + orbit camera) shows through, with just an info overlay on top.
egui::CentralPanel::default() egui::CentralPanel::default()
@@ -280,6 +373,9 @@ fn draw_viewer_ui(
if browser.loading { if browser.loading {
// A file was just clicked — show immediate feedback while the // A file was just clicked — show immediate feedback while the
// background read + decode runs, instead of the previous item. // background read + decode runs, instead of the previous item.
// Keep repainting so the spinner animates even if the app is in a
// reactive (idle) winit mode while the worker thread churns.
ctx.request_repaint();
let name = browser let name = browser
.selected .selected
.and_then(|i| browser.files.get(i)) .and_then(|i| browser.files.get(i))
@@ -292,7 +388,43 @@ fn draw_viewer_ui(
}); });
}); });
} else if video.active { } else if video.active {
draw_video_player(ui, &mut video); let mut lang_changed = false;
let mut play_voice_solo = false;
draw_video_player(
ui,
&mut video,
&mut subtitles,
&mut movie_voice,
&mut lang_changed,
&mut play_voice_solo,
);
if play_voice_solo {
if let Some(movie) = movie_voice.movie.clone() {
video.playing = false; // pause the video; audio takes over
audio.generation = audio.generation.wrapping_add(1);
audio.loading = true;
audio.active = true; // show the panel immediately (spinner)
audio.name = format!("VOICE_{movie}");
events.audio.send(RequestAudio {
clip: String::new(),
display: format!("VOICE_{movie}"),
movie: Some((movie.clone(), movie_voice.lang)),
generation: audio.generation,
});
}
}
if lang_changed {
if let Some(movie) = subtitles.movie.clone() {
subtitles.generation = subtitles.generation.wrapping_add(1);
subtitles.cues.clear();
subtitles.loading = true;
events.subtitles.send(RequestSubtitles {
movie,
lang: subtitles.lang,
generation: subtitles.generation,
});
}
}
} else if !skybox.faces.is_empty() { } else if !skybox.faces.is_empty() {
draw_skybox_grid(ui, &skybox); draw_skybox_grid(ui, &skybox);
} else if pak_view.loaded { } else if pak_view.loaded {
@@ -830,7 +962,14 @@ fn fmt_time(secs: f32) -> String {
/// keyboard shortcuts (space = play/pause, ←/→ = skip 10 s). Playback state /// keyboard shortcuts (space = play/pause, ←/→ = skip 10 s). Playback state
/// lives in `VideoPreview`; the decode/audio engine reacts to it in /// lives in `VideoPreview`; the decode/audio engine reacts to it in
/// `advance_video_playback`. /// `advance_video_playback`.
fn draw_video_player(ui: &mut egui::Ui, video: &mut VideoPreview) { fn draw_video_player(
ui: &mut egui::Ui,
video: &mut VideoPreview,
subs: &mut MovieSubtitles,
voice: &mut MovieVoice,
lang_changed: &mut bool,
play_voice_solo: &mut bool,
) {
// ── Keyboard shortcuts (consumed so focused widgets don't also act). ── // ── Keyboard shortcuts (consumed so focused widgets don't also act). ──
let (mut toggle_play, mut skip) = (false, 0.0_f32); let (mut toggle_play, mut skip) = (false, 0.0_f32);
ui.input_mut(|i| { ui.input_mut(|i| {
@@ -862,6 +1001,47 @@ fn draw_video_player(ui: &mut egui::Ui, video: &mut VideoPreview) {
ui.separator(); ui.separator();
ui.colored_label(egui::Color32::YELLOW, "no audio"); ui.colored_label(egui::Color32::YELLOW, "no audio");
} }
// Subtitle + voice controls (right-aligned): language, CC, and Voice.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let before = subs.lang;
egui::ComboBox::from_id_source("subtitle_lang")
.selected_text(subs.lang.label())
.show_ui(ui, |ui| {
for lang in SubLang::ALL {
ui.selectable_value(&mut subs.lang, lang, lang.label());
}
});
if subs.lang != before {
*lang_changed = true;
}
ui.toggle_value(&mut subs.enabled, "CC")
.on_hover_text("Show subtitles");
if subs.loading {
ui.spinner();
} else if subs.enabled && subs.cues.is_empty() {
ui.weak("(no subtitles)");
}
ui.separator();
// Voice-over track: the movie's own stream carries only music+SFX, so
// this layers in the localized cutscene voice.
ui.toggle_value(&mut voice.enabled, "🗣 Voice")
.on_hover_text("Play the cutscene voice track over the movie");
if voice.loading {
ui.spinner();
} else if !voice.available {
ui.weak("(no voice)");
}
if voice.available
&& ui
.button("🎧")
.on_hover_text("Listen to the voice track on its own")
.clicked()
{
*play_voice_solo = true;
}
});
}); });
ui.separator(); ui.separator();
@@ -873,6 +1053,7 @@ fn draw_video_player(ui: &mut egui::Ui, video: &mut VideoPreview) {
let scale = (avail.x / w).min(frame_h / h); let scale = (avail.x / w).min(frame_h / h);
let (disp_w, disp_h) = (w * scale, h * scale); let (disp_w, disp_h) = (w * scale, h * scale);
let mut frame_rect = None;
if let Some(egui_id) = video.egui_id { if let Some(egui_id) = video.egui_id {
ui.allocate_ui(egui::vec2(avail.x, frame_h), |ui| { ui.allocate_ui(egui::vec2(avail.x, frame_h), |ui| {
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| {
@@ -884,10 +1065,26 @@ fn draw_video_player(ui: &mut egui::Ui, video: &mut VideoPreview) {
if resp.clicked() { if resp.clicked() {
video.playing = !video.playing; video.playing = !video.playing;
} }
frame_rect = Some(resp.rect);
}); });
}); });
} }
// Caption overlay: every cue active at the current position, stacked over
// the lower third of the frame (overlapping spans show together, newest —
// last in start order — lowest).
if let Some(rect) = frame_rect {
let active = subs.active_cues(video.position);
if !active.is_empty() {
let joined = active
.iter()
.map(|c| c.text.as_str())
.collect::<Vec<_>>()
.join("\n");
paint_caption(ui, rect, &joined);
}
}
// ── Transport bar ── // ── Transport bar ──
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui.button(if video.playing { "" } else { "" }).clicked() { if ui.button(if video.playing { "" } else { "" }).clicked() {
@@ -931,6 +1128,86 @@ fn draw_video_player(ui: &mut egui::Ui, video: &mut VideoPreview) {
}); });
} }
/// Draw a subtitle caption centered along the bottom of `rect`, with a
/// semi-transparent backing box so it stays legible over any frame.
fn paint_caption(ui: &egui::Ui, rect: egui::Rect, text: &str) {
let painter = ui.painter_at(rect);
// Font scales with the frame; clamped so it's readable but not huge.
let size = (rect.height() * 0.045).clamp(13.0, 30.0);
let font = egui::FontId::proportional(size);
let wrap = rect.width() * 0.9;
let galley = painter.layout(
text.to_string(),
font,
egui::Color32::WHITE,
wrap,
);
let margin = egui::vec2(10.0, 6.0);
let box_size = galley.size() + margin * 2.0;
let top_left = egui::pos2(
rect.center().x - box_size.x / 2.0,
rect.bottom() - box_size.y - rect.height() * 0.04,
);
let bg = egui::Rect::from_min_size(top_left, box_size);
painter.rect_filled(bg, 4.0, egui::Color32::from_black_alpha(160));
painter.galley(top_left + margin, galley, egui::Color32::WHITE);
}
/// Standalone audio player: a transport bar for a decoded voice/sound track with
/// no video. Playback state lives in `AudioPreview`; `advance_audio_playback`
/// reacts to it.
fn draw_audio_player(ui: &mut egui::Ui, audio: &mut AudioPreview) {
ui.horizontal(|ui| {
ui.heading(&audio.name);
ui.separator();
if audio.loading {
ui.spinner();
ui.label("decoding…");
} else {
ui.label("voice track");
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("✖ Close").clicked() {
audio.active = false;
}
});
});
ui.separator();
ui.add_space(ui.available_height() * 0.4);
// Big centered play/pause + a speaker glyph, since there's nothing to show.
ui.vertical_centered(|ui| {
ui.label(egui::RichText::new("🔊").size(64.0));
});
ui.add_space(12.0);
// Transport bar.
ui.horizontal(|ui| {
if ui
.button(if audio.playing { "" } else { "" })
.clicked()
{
audio.playing = !audio.playing;
}
ui.label(fmt_time(audio.position));
let tl_width = (ui.available_width() - 170.0).max(80.0);
ui.spacing_mut().slider_width = tl_width;
let mut pos = audio.position;
let resp = ui.add(
egui::Slider::new(&mut pos, 0.0..=audio.duration.max(0.1)).show_value(false),
);
if resp.changed() {
audio.position = pos;
audio.seek_request = Some(pos);
}
ui.label(fmt_time(audio.duration));
ui.separator();
ui.label("🔊");
ui.spacing_mut().slider_width = 90.0;
ui.add(egui::Slider::new(&mut audio.volume, 0.0..=1.0).show_value(false));
});
}
// ── World cubemap (skybox) viewer ───────────────────────────────────────────── // ── World cubemap (skybox) viewer ─────────────────────────────────────────────
/// Show a world cubemap's 6 faces in a labelled grid (D3D9 order). A true /// Show a world cubemap's 6 faces in a labelled grid (D3D9 order). A true

View File

@@ -0,0 +1,95 @@
# Handoff — movie subtitles, voice, and the movie manifest (2026-07-19)
Branch `feature/ipfb-idxd-parser`. This commit flushes several sessions of local
WIP; the **new, finished** work is the movie subtitle + voice + audio pipeline
and the movie manifest. One item is deliberately **left open** (see §4).
## 1. What's DONE and verified (static RE + tests)
### Movie subtitles (`crates/sylpheed-formats/src/movie_subtitle.rs`)
- Full movie→track→text chain (see `docs/re/structures/movie-subtitles.md`).
- **Multi-line caption fix**: a caption stored as several consecutive text tokens
sharing one timing (S13A: `"Look at it father"` + `"& beautiful isn't it"` @
`01:14.80`) — `parse_track` now joins them with `\n`. Previously all but the
last line were dropped. Disc test asserts both lines survive.
- **Overlap rendering**: `MovieSubtitles::active_cues(t)` returns every cue active
at `t`; the viewer stacks them (was: only the first cue shown).
- Umlauts / Latin-1 accents preserved (`utf16le_tokens`); Japanese label romanized
(CJK font still a known gap — see §5).
### Voice decode (`crates/sylpheed-formats/src/slb.rs`)
- `sound.pak` entries are XACT `.slb` banks wrapping **XMA1** (fmt tag `0x0165`,
48 kHz, mono content). `to_xma_riff` rebuilds a decodable RIFF; FFmpeg `xma1`
decodes it. Downmix mono via `-af pan=mono|c0=c0`.
- **Multi-subwave fix**: take the FIRST sub-wave bounded by its declared `data`
size — NOT `data..EOF` (which appended later takes = the S10S16 garble).
Verified: S13A→83.78s == video.
- `list_voice_clips` enumerates the voice library from `sounds.tbl`.
### Movie manifest (`crates/sylpheed-formats/src/movie_manifest.rs`) — the index
- `tables.pak` entry `ADVERTISE_MOVIE` (IDXD, hash `0x5B983A08`) is the
authoritative **movie → subtitle → voice** map. Located by shape (`is_manifest`),
parsed by grouping the string pool on `.wmv`.
- **Authoritative voice binding** replaces the old `VOICE_<movie>` guess:
101 movies, 83 with a `VOICE_` token, 18 without. The token is NOT always
`VOICE_<movie>` — 5 `hokyu_*` movies bind to `VOICE_D_450..454` in `\etc\`,
which a guess would miss. Token's subdir varies (Movie/etc/Voice) → resolve via
`sounds.tbl`. Disc test: all 83 resolved entries exist in `sound.pak`.
### Viewer wiring (`iso_loader.rs`, `ui.rs`)
- `resolve_movie_voice_clip` reads the manifest + `sounds.tbl` (DIRECT bindings
only) → `handle_voice_request` (movie player 🗣 toggle) and the 🎧 solo button
(`RequestAudio.movie`) use it. Unbound movies correctly post no audio.
- Standalone **View → 🎙 Voice Lines** browser (filter + ▶ play any `sound.pak`
clip) via `VoiceLibrary` + `AudioPreview`.
Tests: 46 formats-lib + 11 viewer-lib + disc (`movie_manifest_disc`,
`movie_subtitle_disc`, `slb_disc`) all green. Full `cargo test --workspace` green.
## 2. Also bundled in this commit (prior local WIP, not this session's focus)
- Viewer **async stage/model loading** (off-thread `prepare_xpr`, cancellable) —
`iso_loader.rs`/`ui.rs`/`camera.rs`. See memory `reborn-viewer-async-loading`.
- **Grouped-pool XBG7 / hero-ship** decode refinements in `mesh.rs`, `cli/main.rs`,
`mesh_disc.rs`. See memory `reborn-ship-drawlog` / `reborn-saber-texture-investigation`.
- `tools/analyze_drawlog_wvp.py`, `tools/extract_movie_subtitles.py`.
These build and test green but were not re-verified for behaviour this session.
## 3. USER TO VERIFY IN GUI (couldn't be tested from CLI)
1. S13A cutscene shows BOTH lines of the "Look at it father" caption.
2. Story-movie voice (S13A etc.) lines up with the video after the subwave fix.
3. The 5 directly-bound `hokyu_*` movies play voice; other hokyu are silent (see §4).
4. View → 🎙 Voice Lines browser lists and plays clips.
## 4. OPEN PROBLEM — unbound-hokyu resupply voice (do NOT re-guess)
The resupply cutscenes clearly SHARE voice recordings (video-only varies per
mission), but the correct join key for the **13 unbound `hokyu_*`** movies is
**unknown**:
- Manifest DIRECTLY binds only 5: `LS_s02A→450, LS_s09A→451, DS_s02A→452,
LS_s02H→453, DS_s07H→454` (grouping verified against the raw layout).
- Keying unbound movies by subtitle **demo id** (600→450…604→454, so
`hokyu_DS_s13A` demo602→`VOICE_D_452`) was implemented, tested against the
running game by the user, and is **WRONG** (wrong line). It has been **reverted**
— unbound hokyu now stay unvoiced rather than play wrong audio.
- Red flag: only `VOICE_D_450..454` exist; decoded durations `450=2.8s 451=1.6s
452=2.2s` but `453=0.14s 454=0.43s` — far too short for the spoken line, so
these `.slb` are likely **multi-subwave / not cleanly sliced** (same class as
the deferred B/C banks below).
**Next-box options:** (a) get the real join key from mission-event data (mission
scripts/IDXD tables that trigger resupply), or (b) build a proper multi-subwave
`.slb` decoder and verify audio by ear. Needs a concrete calibration clue from the
user (e.g. "s13A should sound like <movie X>" or the actual spoken words).
## 5. Other deferred items (unchanged)
- ~49 "layout-B/C" movie/voice banks (most RT + S07B/S11A/S12B/S13B) + some
individual clips need an XMA1 packet/seek-table reassembler.
- CJK/Japanese text rendering in the viewer (needs a CJK TTF via egui FontDefinitions).
- Texture colours (channel order / sRGB) still on the dynamic-RE backlog.
## 6. Resume checklist (next box)
1. `git pull` on `feature/ipfb-idxd-parser`; set `SYLPHEED_DISC=<extract root>`
(had `.../sylph_extract`).
2. Build guardrail: `CARGO_BUILD_JOBS=4` always (15 GB box; full `-j` OOM'd before).
3. `cargo test --workspace` (with `SYLPHEED_DISC`) should be green.
4. Pick up §4 (unbound-hokyu voice) — the only open thread from this session.

View File

@@ -0,0 +1,174 @@
# Movie subtitles & the movie ↔ mission ↔ text chain
Reverse-engineered 2026-07-19 (static, from the extracted disc). The full chain
that links a cutscene movie to its on-screen subtitle text is now closed.
## Files involved
- `dat/movie/*.wmv` — the cutscene videos. **Named by mission** (see below).
- `dat/movie/<lang>.pak` + `<lang>.p00` — per-language **subtitle timing tracks**
(`eng`, `jpn`, `deu`, `fra`, `esp`, `ita`) plus the caption font.
- `dat/GP_MAIN_GAME_<L>.pak` + `.p00` — per-language **caption TEXT**
(`E`=Eng, `J`=Jpn, `D`=Deu, `F`=Fra, `I`=Ita, `S`=Esp).
- `dat/tables.pak` entry `ADVERTISE_MOVIE` (hash `0x5B983A08`) — the master
**movie manifest**: all 101 `.wmv` names in mission-progression order, each
bound to its subtitle track and (optionally) its **voice bank** (see below).
## Movie filename → mission
Purely from the filename:
| Pattern | Meaning |
|---------|---------|
| `S<NN><P>.wmv` | Stage `NN` **story** cutscene, part `P` (A/B/C…) — e.g. `S02C` = stage 2, 3rd story scene |
| `RT<NN><P>.wmv` | Stage `NN` **radio / briefing** transmission, part `P` (`_1`/`_2` = split clips) |
| `hokyu_<LS\|DS>_s<NN><P>.wmv` | Stage `NN` **resupply** scene (`hokyu` = 補給). `LS`/`DS` = the two resupply-ship variants |
| `ADV.wmv` | Intro / title movie |
97 movies total: 27 story, 50 radio, 19 resupply, 1 intro. (Some referenced
stages — s24, s27, RT16 — exist as keys but the .wmv isn't in this extract.)
## Subtitle timing track — `<lang>.pak`
IPFB archive (`IPFB`, BE-u32 count, 16-byte header; TOC of
`[name_hash u32][offset u32][size u32]` triples, sorted by hash, into `.p00`).
- **Track key = `name_hash("subtitle_<movie_basename>.tbl")`** (the hash
lowercases internally, so basename case is irrelevant). This is the movie→track
link. Verified: `subtitle_S00A.tbl``0x6F2D9663`, `subtitle_hokyu_DS_s02A.tbl`
`0x3662B1F8`, `subtitle_RT01C_1.tbl``0x756F69FB`.
- The archive also holds **RATC** pre-rendered title-card / number textures
(`pwterop_s01a1.t32`, `pwrt_rt01_str.t32`, `pwnum0-9.t32`) + one TrueType font.
- **Each track data block is `Z1`+zlib**: bytes `5A 31` ("Z1"), a small header,
then a raw zlib stream (`78 DA`/`78 9C`). `zlib.decompress(blob[blob.find(b"\x78\xda"):])`.
- Decompressed = an **IXUD** container. Payload (UTF-16LE) is the timing sheet:
`SUBTITLE MSG_DEMO_<demo> <mm:ss.cc> MSG_DEMO_<demo> <mm:ss.cc> …`.
So the track says *which* demo-message shows *when*, not the text itself.
## Caption text — `GP_MAIN_GAME_<L>.pak`
Same IPFB+`.p00`. Among its ~1119 entries, **32 blocks are `Z1`+zlib → IXUD**
string containers holding the movie caption text. Layout: `IXUD`, u32 version,
hash@0x08, count@0x14, then `(recordhash,offset,len)` triples, then a UTF-16LE
string region where **each line is stored as `text` immediately followed by its
key** `MSG_DEMO_<demo>_<page>_<line>` (captions wrap across `_00`,`_01`, …).
537 English lines recovered. Entries 1 & 19 are the IDXD *schema* records
(`ID`, `PageCount`, `Character`=speaker e.g. `TCAFSUPPLY`, `Face`, line refs) —
no text, just structure.
## Movie → voice track: the manifest binding (`ADVERTISE_MOVIE`)
The `ADVERTISE_MOVIE` manifest is **also the authoritative movie→voice index**.
Its string pool emits, per movie, a run led by `<movie>.wmv` optionally followed
by `<pak>+….prt` (overlay art), `<pak>+SUBTITLE_<movie>.tbl`, and a bare
`VOICE_<token>`. Grouping the pool on `.wmv` (records are emitted in order)
recovers `movie → Option<voice_token>` without decoding the IDXD record binary
(`crate::movie_manifest`).
The voice token is **not** always `VOICE_<movie>`, so the manifest is required —
guessing both misses real bindings and invents tracks for silent movies:
- **83 / 101 movies have a voice token.** Story/radio movies use `VOICE_<movie>`
in `<lang>\Movie\`.
- **5 `hokyu_*` resupply movies bind to in-mission radio clips** — e.g.
`hokyu_LS_s02A → VOICE_D_450`, which lives in `<lang>\etc\`, *not* `Movie`.
A `VOICE_<movie>` guess would never find these.
- **18 movies have no direct voice token** = 4 boot logos + 1 HD test pattern +
**13 `hokyu_*` movies** (incl. `hokyu_DS_s13A`). Only the manifest's **direct**
bindings are trusted for playback.
### Shared resupply voice — UNRESOLVED for unbound movies
The manifest directly binds only **5** resupply movies, each to a shared
`VOICE_D_45x` clip in `<lang>\etc\`:
| bound movie | subtitle demo | clip |
|-------------|---------------|------|
| `hokyu_LS_s02A` | 600 | `VOICE_D_450` |
| `hokyu_LS_s09A` | 601 | `VOICE_D_451` |
| `hokyu_DS_s02A` | 602 | `VOICE_D_452` |
| `hokyu_LS_s02H` | 603 | `VOICE_D_453` |
| `hokyu_DS_s07H` | 604 | `VOICE_D_454` |
The resupply cutscenes clearly **share** voice recordings (only the video varies
per mission), so the 13 unbound `hokyu_*` movies must reuse one of these — but
the **correct join key is not yet known**:
- Keying by subtitle **demo id** (so `hokyu_DS_s13A`, demo 602 → `VOICE_D_452`)
was tried and is **WRONG** — it plays the wrong recording in-game. Do not use.
- Only `VOICE_D_450..454` exist (no 44x/45x neighbours). Decoded durations are
suspicious — `450`=2.8s, `451`=1.6s, `452`=2.2s, but `453`=**0.14s**,
`454`=**0.43s** — far too short for the spoken line, so these `.slb` banks are
likely **multi-subwave / not cleanly sliced** by the current extractor (same
class as the deferred B/C banks).
⇒ The unbound-hokyu voice mapping is **open** (needs either the real join key
from mission data, or a proper multi-subwave `.slb` decode + audio verification).
`hokyu_LS_s24A`/`s27A` have no subtitle track at all (stages absent from this
extract).
The token's `sound.pak` **subdirectory is not fixed** (`Movie` / `etc` / `Voice`),
so resolve it via `sounds.tbl` (which lists the full `<lang>\…\<token>.slb` path)
rather than assuming a directory. `movie_manifest::resolve_voice_entry` does the
full chain. Verified: all 83 resolved entries exist in `sound.pak`.
## Caption packing quirks (parser must handle)
- **Multi-line captions are split into consecutive text tokens** that share one
trailing timing, e.g. S13A stores `"Look at it father"` + `"& beautiful isn't
it"` before `01:14.80-01:17.60`. Accumulate every text token since the last
timing and join with `\n`; pairing strictly 1:1 silently drops all but the last
line.
- **Overlapping spans**: some tracks show two captions at once (an open-ended
radio line still up when the next range line starts). The viewer stacks every
cue active at `t` (`MovieSubtitles::active_cues`) instead of showing only the
first.
## The full join
```
tables.pak / ADVERTISE_MOVIE → list of movies (mission order)
<movie> ─ nh("subtitle_<movie>.tbl") ─→ <lang>.pak track
track (IXUD) → [ (MSG_DEMO_<d>, timecode), … ]
MSG_DEMO_<d> → GP_MAIN_GAME_<L> → "the localized caption line(s)"
```
## Coverage
- 92 / 97 movies have a subtitle track.
- **66 movies carry timed `MSG_DEMO` captions** — the **radio (`RT*`)** and
**resupply (`hokyu_*`)** movies. These fully decode to timed text.
- The **27 story (`S*`) movies have a track but 0 timed captions** — their text
is delivered as the **pre-rendered title-card textures** (`pwterop_*`, burned
styling), not MSG_DEMO lines.
## Worked examples (English)
```
RT01C_1.wmv (Stage 1 radio, part C):
00:00.50 [14] We did it! Okay, all pilots follow my lead!
00:06.80 [15] Rhino Leader to ACROPOLIS. We made it through and we're coming
home. Roger. It's good to see you're all safe.
00:19.30 [17] Yeah, but Brandon ... Damn. There's only seven of us. …
hokyu_DS_s02A.wmv (Stage 2 resupply):
00:00.00 [602] Resupply complete. You are cleared for take-off!
```
## Reusable extractor
`tools/extract_movie_subtitles.py` — parses `<lang>.pak`, resolves each movie's
track, cross-references `GP_MAIN_GAME_<L>` text, and prints per-movie timed
transcripts + the movie→mission table.
## In-mission dialogue (future work)
`GP_MAIN_GAME_<L>.pak` is the **global** message store, not just movie captions:
its `MSG_DEMO_*` table also holds the in-mission radio/dialogue lines (same demo
id space). So the *text* of gameplay dialogue is already decodable with
[`crate::movie_subtitle::build_demo_text`]. What's missing is the **trigger**
which demo id fires at which mission event — and that lives in the **mission
data** (mission scripts / `GP_MAIN_GAME` IDXD tables), not in the text pack. When
reversing mission data, look for demo-id references there to bind dialogue to
events; the IDXD "Message" schema records also carry `Character` (speaker) and
`Face` (portrait) per line.

View File

@@ -0,0 +1,62 @@
# Sound bank audio — `sound.pak` / `.slb` / XMA1
Reverse-engineered 2026-07-19 (static). `dat/sound.pak` (+ `sound.p00..p04`,
~1.08 GB) is the game's entire audio bank: **9519** IPFB entries, each an XACT
`.slb` sound bank wrapping **XMA1** audio.
## Names
Entry keys are `name_hash(<string>)` of filenames stored verbatim in the IDXD
string pools of `tables.pak``eng\sounds.tbl` (entry 39) and `jpn\sounds.tbl`
(entry 45). All 9519 recovered. Families:
- `<lang>\Voice\VOICE_<SPK>_<NNN>.slb` — per-line character voice (SPK = ADAN,
TCAF, RHIN, ADPL, BIRD, ACRO, ZZZZ…). Only `eng`/`jpn` voice exists.
- `<lang>\etc\VOICE_{A,B,C,D}_<NNN>.slb`, `<lang>\Briefing\BR<n>_<m>.slb`.
- **`<lang>\Movie\VOICE_<movie>.slb`** — the continuous voice track for cutscene
`<movie>.wmv` (e.g. `eng\Movie\VOICE_RT07A.slb`). Played from the video start.
- `BGM_###.slb` (32) — music. `Static.slb`, `JNGL_###` — SFX/ambience.
Only English and Japanese have voice; subtitles cover more languages, voice does
not. See [`crate::slb::movie_voice_name`].
## Codec
XMA1: RIFF `fmt ` tag **0x0165**, a 32-byte `XMAWAVEFORMAT`, **48000 Hz**. The
content is **mono** — some clips carry it in the **left channel only** (right is
silence), others are **dual-mono** (L = R). Downmix to mono by taking the left
channel (it always holds the full signal).
`XMAWAVEFORMAT` (one stream, 32-byte `fmt `): SampleRate @ fmt+16 (u32 LE),
Channels @ fmt+29 (u8), ChannelMask @ fmt+30 (u16). Movie voices report 2
channels / mask 0x2 despite the mono content.
## `.slb` layouts → decodable RIFF
Two on-disc layouts (`sound.pak` movie voices split ≈ 63 RIFF / 15 headerless):
- **RIFF present** — a `RIFF/WAVE` sits inside the bank; its 32-byte `fmt ` is
the real header. The XMA packets are **everything after that RIFF's `data`
chunk header** (`slb[data_pos+8..]`); the declared `data` size is unreliable
(sometimes half the real length), so take to end and clamp to the media length
downstream. Some banks have trailing bank data past the real audio — always
beyond the video length, so clamping to the video duration drops it.
- **Headerless** — no `RIFF` anywhere; a fixed **1392-byte** header precedes raw
XMA1 packets (48 kHz, 2 channels). Data = `slb[1392..]`.
[`crate::slb::to_xma_riff`] rebuilds a standalone XMA1 `RIFF/WAVE` for either
layout, ready for FFmpeg's `xma1` decoder.
## Decode (FFmpeg)
`ffmpeg -i rebuilt.riff -af "pan=mono|c0=c0" -t <media_len> out.wav` — the RIFF
is fed to the `xma1` decoder, downmixed to the left channel, clamped to length.
Verified: `VOICE_S00A` → 93.9 s == `S00A.wmv` 93.87 s; `VOICE_ADV` → 137.3 s ==
137.7 s; `VOICE_RT07A` → 49 s ≈ 50 s.
## Reading one entry cheaply
`sound.pak` is ~1 GB. Use [`crate::PakArchive::parse_toc`] on the small `.pak`
index, binary-search the name-hash, then read just `[offset, offset+size)` from
the `.pNN` segments (the viewer's `read_segment_range` seeks into the right
segment) instead of loading the whole archive.

View File

@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Recover each ship part's runtime WORLD transform from a Canary draw log.
The draw logger (branch capture-drawlog) now dumps, per distinct draw:
- the first ~8 object-space vertex positions (pre-transform), and
- the vertex-shader float constants c0..c31 (which hold the transform matrices).
The game bakes each part's WORLD matrix into the constants, so the shader's
World*View*Proj (WVP) for a part is VP * part_World. The body (bdy_01) is drawn
with an identity world, so body_WVP = VP. Hence for any part drawn in the SAME
frame (the ship's parts always are):
part_World = inv(body_WVP) * part_WVP (View/Proj cancel)
We don't know a-priori which 4 consecutive constants form the WVP, so we try every
block c[i..i+3] and keep the one for which inv(body_WVP)*part_WVP is a RIGID
transform (orthonormal 3x3 + translation) for the parts — that uniquely identifies
the matrix block. The translation column of each part's world matrix is the
placement we want (esp. the ±X nacelle offset the static XBG7 graph lacks).
Usage: analyze_drawlog_wvp.py xenia_re_draws.log
Identify the body draw via --body-indices (the draw with the most indices) or by
matching object-space positions to our decoded sub-meshes.
"""
import sys, re
import numpy as np
def parse(path):
draws = []
cur = None
consts = {}
mode = None
for line in open(path):
if line.startswith("DRAW "):
if cur is not None:
cur["consts"] = consts
draws.append(cur)
cur = {"header": line.strip(), "positions": [], "textures": [],
"size_words": 0}
consts = {}
mode = None
m = re.search(r"index\[base=0x([0-9A-Fa-f]+) count=(\d+)", line)
cur["idx_base"] = int(m.group(1), 16) if m else None
cur["idx_count"] = int(m.group(2)) if m else None
m = re.search(r"indices=(\d+)", line)
cur["indices"] = int(m.group(1)) if m else None
elif cur is not None and line.strip().startswith("stream "):
# Largest vertex buffer (size_words) = the hull (body, identity world).
m = re.search(r"size_words=(\d+)", line)
if m:
cur["size_words"] = max(cur["size_words"], int(m.group(1)))
elif cur is None:
continue
elif line.strip().startswith("positions:"):
cur["positions"] = [tuple(map(float, t.split(",")))
for t in re.findall(r"\(([^)]+)\)", line)]
elif line.strip().startswith("textures:"):
cur["textures"] = re.findall(r"base=0x([0-9A-Fa-f]+)", line)
elif line.strip().startswith("vsconst"):
mode = "vsconst"
elif mode == "vsconst":
m = re.match(r"\s*c(\d+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)", line)
if m:
consts[int(m.group(1))] = np.array([float(m.group(i)) for i in range(2, 6)])
else:
mode = None
if cur is not None:
cur["consts"] = consts
draws.append(cur)
return draws
def wvp_from(consts, i):
"""4x4 from constants c[i..i+3] as rows."""
if not all(k in consts for k in (i, i+1, i+2, i+3)):
return None
return np.array([consts[i], consts[i+1], consts[i+2], consts[i+3]])
def is_rigid(M):
"""3x3 upper-left orthonormal (rotation/reflection), within tolerance."""
R = M[:3, :3]
if not np.all(np.isfinite(R)):
return False
G = R @ R.T
return np.allclose(G, np.eye(3), atol=1e-2) or np.allclose(G / (np.trace(G)/3), np.eye(3), atol=1e-2)
def main():
if len(sys.argv) < 2:
print(__doc__); return
draws = parse(sys.argv[1])
print(f"parsed {len(draws)} draws")
# Body = the draw with the largest vertex buffer (the ~10891-vert hull, drawn
# with an identity world). idx_count is unreliable (the hull's 9 material
# groups dedup to the first group's partial count).
body = max(draws, key=lambda d: d.get("size_words") or 0)
print(f"body draw: {body['header'][:90]} size_words={body['size_words']}")
# Try every constant block; report the one giving rigid part transforms.
for i in range(0, 29):
bwvp = wvp_from(body["consts"], i)
if bwvp is None:
continue
try:
binv = np.linalg.inv(bwvp)
except np.linalg.LinAlgError:
continue
worlds = []
rigid = 0
for d in draws:
pw = wvp_from(d["consts"], i)
if pw is None:
continue
W = binv @ pw # column-vector convention; may need transpose
worlds.append((d, W))
if is_rigid(W) or is_rigid(W.T):
rigid += 1
if rigid >= max(2, len(worlds)//2):
print(f"\n=== WVP block c{i}..c{i+3}: {rigid}/{len(worlds)} rigid ===")
for d, W in worlds:
# translation is the last column (or last row, convention-dependent)
tcol = W[:3, 3]
trow = W[3, :3]
print(f" idx={d['idx_count']:>6} base={d['idx_base'] and hex(d['idx_base'])}"
f" t_col=({tcol[0]:+.2f},{tcol[1]:+.2f},{tcol[2]:+.2f})"
f" t_row=({trow[0]:+.2f},{trow[1]:+.2f},{trow[2]:+.2f})")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
import struct, zlib, re, os
MOD=0x00FFF9D7; RECIP=0x80031493
def rol(v,n,b=32):
v&=(1<<b)-1; return ((v<<n)|(v>>(b-n)))&((1<<b)-1)
def nh(name):
bs=bytearray(name.encode('latin-1'))
for i,b in enumerate(bs):
if 0x41<=b<=0x5A: bs[i]=b+0x20
a=0;bb=0
for byte in bs:
c=(byte if byte<0x80 else byte-0x100)&0xFFFFFFFF
a=((rol(a,8)&0xFFFFFF00)+c)&0xFFFFFFFF
bb=(bb+c)&0xFFFFFFFF
hi=((a*RECIP)>>32)&0xFFFFFFFF; q=rol(hi,9)&0x1FF
a=(a-(q*MOD))&0xFFFFFFFF
return (((bb<<24)&0xFF000000)|(a&0xFFFFFF))&0xFFFFFFFF
EX="/home/fabi/RE Project Sylpheed/sylph_extract"
def load_pak(base):
"""base like dat/movie/eng ; returns list of (hash,off,size), data bytes"""
pak=open(f"{EX}/{base}.pak","rb").read()
n=struct.unpack(">I",pak[4:8])[0]
es=[struct.unpack(">III",pak[16+12*i:28+12*i]) for i in range(n)]
p00=f"{EX}/{base}.p00"
data=open(p00,"rb").read() if os.path.exists(p00) else pak
return es, data
def unz(blob):
if blob[:2]==b'Z1':
zi=blob.find(b'\x78\xda');
if zi<0: zi=blob.find(b'\x78\x9c')
return zlib.decompress(blob[zi:])
return blob
# --- verify the track key scheme ---
es,data=load_pak("dat/movie/eng")
keys={h for h,_,_ in es}
for mv,exp in [("S00A",0x6F2D9663),("hokyu_DS_s02A",0x3662B1F8),("RT01C_1",0x756F69FB),("S16A",0xEEB85377)]:
k=nh(f"subtitle_{mv}.tbl")
print(f"subtitle_{mv}.tbl -> {k:08X} expect {exp:08X} inpak={k in keys} {'OK' if k==exp else 'MISMATCH'}")
# --- build MSG_DEMO text table from GP_MAIN_GAME_E ---
es2,data2=load_pak("dat/GP_MAIN_GAME_E")
text={} # 'MSG_DEMO_ddd_ppp_ll' -> string
keyrx=re.compile(r'^MSG_DEMO_\d+(_\d+)*$')
for h,o,s in es2:
dec=unz(data2[o:o+s])
if dec[:4]!=b'IXUD': continue
# walk null-terminated utf-16le strings in the whole block
toks=re.findall(rb'(?:[\x20-\x7e]\x00){2,}', dec)
toks=[t.decode('utf-16-le') for t in toks]
# pair: text followed by its key
for i in range(len(toks)-1):
if keyrx.match(toks[i+1]) and not keyrx.match(toks[i]):
text[toks[i+1]]=toks[i]
print(f"\nMSG_DEMO text lines loaded: {len(text)}")
# --- render a movie's subtitle transcript ---
def track_for(mv):
k=nh(f"subtitle_{mv}.tbl")
for h,o,s in es:
if h==k: return unz(data[o:o+s])
return None
# index text by integer demo id -> ordered list of lines
bydemo={}
for k,v in text.items():
m=re.match(r'MSG_DEMO_(\d+)_',k)
if m: bydemo.setdefault(int(m.group(1)),[]).append((k,v))
for d in bydemo: bydemo[d].sort()
def transcript(mv):
dec=track_for(mv)
if not dec: return f"[no track for {mv}]"
toks=re.findall(rb'(?:[\x20-\x7e]\x00){2,}', dec)
toks=[t.decode('utf-16-le') for t in toks]
out=[f"=== {mv}.wmv ==="]
# collect all MSG_DEMO ids and timecodes in order; pair positionally
seq=[('id',int(re.match(r'MSG_DEMO_(\d+)$',t).group(1))) if re.match(r'MSG_DEMO_(\d+)$',t)
else ('tc',t) if re.match(r'\d\d:\d\d\.\d\d$',t) else ('x',t) for t in toks]
ids=[v for k,v in seq if k=='id']; tcs=[v for k,v in seq if k=='tc']
for j,demo in enumerate(ids):
tc=tcs[j] if j<len(tcs) else "--:--.--"
lines=[v for _,v in bydemo.get(demo,[])]
out.append(f" {tc} [{demo:3d}] "+(" ".join(lines) if lines else "(no text on disc)"))
return "\n".join(out)
for mv in ["S00A","S16A","hokyu_DS_s02A"]:
print(); print(transcript(mv))