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:
@@ -523,6 +523,23 @@ fn cmd_mesh_info(file: &Path) -> Result<()> {
|
||||
nv.saturating_sub(1),
|
||||
if oob > 0 { format!(", OOB {oob}") } else { String::new() },
|
||||
);
|
||||
// XDUMPVERT=1 → print the first few vertex positions per sub-mesh, for
|
||||
// content-matching a decoded sub-mesh against the GPU draw log.
|
||||
if std::env::var("XDUMPVERT").is_ok() {
|
||||
let mut lo = [f32::MAX; 3];
|
||||
let mut hi = [f32::MIN; 3];
|
||||
for p in &sub.positions {
|
||||
for a in 0..3 {
|
||||
lo[a] = lo[a].min(p[a]);
|
||||
hi[a] = hi[a].max(p[a]);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
" bbox X[{:.2}..{:.2}] Y[{:.2}..{:.2}] Z[{:.2}..{:.2}] ctr({:.2},{:.2},{:.2})",
|
||||
lo[0], hi[0], lo[1], hi[1], lo[2], hi[2],
|
||||
(lo[0]+hi[0])/2.0, (lo[1]+hi[1])/2.0, (lo[2]+hi[2])/2.0
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
@@ -568,7 +585,32 @@ fn cmd_mesh_render(
|
||||
// uniformly scaled to a fixed cell, so all are equally visible regardless of
|
||||
// native scale (mirrors `spawn_stage_models`).
|
||||
let multi = models.len() > 1 || force_row;
|
||||
// XMIRROR=x|y|z → negate that axis, to test an Xbox(LH)→Bevy(RH) handedness
|
||||
// flip against reference screenshots.
|
||||
let mirror: [f32; 3] = match std::env::var("XMIRROR").ok().as_deref() {
|
||||
Some("x") => [-1.0, 1.0, 1.0],
|
||||
Some("y") => [1.0, -1.0, 1.0],
|
||||
Some("z") => [1.0, 1.0, -1.0],
|
||||
_ => [1.0, 1.0, 1.0],
|
||||
};
|
||||
let mut tris: Vec<[[f32; 3]; 3]> = Vec::new();
|
||||
// XCOLORSUB=1 tints each sub-mesh a distinct colour (to see which sub is
|
||||
// which part / where the "extra fin" comes from). Parallel to `tris`.
|
||||
let color_sub = std::env::var("XCOLORSUB").is_ok();
|
||||
// XONLYSUB=N renders only the N-th global sub-mesh (to isolate one part).
|
||||
let only_sub: Option<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 GAP: f32 = 4.0;
|
||||
let grid_pitch = CELL + GAP;
|
||||
@@ -600,31 +642,105 @@ fn cmd_mesh_render(
|
||||
} else {
|
||||
(1.0, [0.0, 0.0, 0.0])
|
||||
};
|
||||
for sub in &m.meshes {
|
||||
let f = |i: usize| {
|
||||
let p = sub.positions[i];
|
||||
[
|
||||
(p[0] - center[0]) * scale + cell[0],
|
||||
(p[1] - center[1]) * scale + cell[1],
|
||||
(p[2] - center[2]) * scale + cell[2],
|
||||
]
|
||||
// XNODEXFORM=1 applies the XBG7 scene-graph node placement (fins move to
|
||||
// the tail) — to verify the transforms recovered from the graph.
|
||||
let placements = if std::env::var("XNODEXFORM").is_ok() {
|
||||
sylpheed_formats::mesh::node_transforms(&bytes, &m.name)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
for (sub_local, sub) in m.meshes.iter().enumerate() {
|
||||
// Every scene-graph instance that draws this sub-mesh (mirrored fin
|
||||
// pair, L/R winglets…); `None` = no graph placement → identity.
|
||||
let mine: Vec<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
|
||||
// expanded the file's triangle strips).
|
||||
let n = sub.positions.len();
|
||||
for tri in sub.indices.chunks_exact(3) {
|
||||
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
|
||||
if a < n && b < n && c < n {
|
||||
tris.push([f(a), f(b), f(c)]);
|
||||
// XSPANONLY=1 renders ONLY long-edge ("spanning") triangles; XSPANHIDE=1
|
||||
// renders everything EXCEPT them — to see whether the flagged spanning
|
||||
// triangles are real geometry or decode artifacts (phantom sheets).
|
||||
let span_only = std::env::var("XSPANONLY").is_ok();
|
||||
let span_hide = std::env::var("XSPANHIDE").is_ok();
|
||||
let med = {
|
||||
let mut e: Vec<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() {
|
||||
anyhow::bail!("no triangles to render");
|
||||
}
|
||||
|
||||
let rgba = rasterize(&tris, size, yaw, pitch, dist);
|
||||
let rgba = rasterize(&tris, &tints, size, yaw, pitch, dist);
|
||||
image::save_buffer(output, &rgba, size, size, image::ExtendedColorType::Rgba8)
|
||||
.with_context(|| format!("writing PNG {}", output.display()))?;
|
||||
println!(
|
||||
@@ -643,7 +759,14 @@ fn cmd_mesh_render(
|
||||
/// Minimal software rasterizer: orthographic, z-buffered, two-sided Lambert +
|
||||
/// headlight shading over a flat grey material on a dark background. Enough to
|
||||
/// judge whether recovered geometry is coherent.
|
||||
fn rasterize(tris: &[[[f32; 3]; 3]], size: u32, yaw_deg: f32, pitch_deg: f32, dist: f32) -> Vec<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 (yaw, pitch) = (yaw_deg.to_radians(), pitch_deg.to_radians());
|
||||
let (cy, sy) = (yaw.cos(), yaw.sin());
|
||||
@@ -691,7 +814,8 @@ fn rasterize(tris: &[[[f32; 3]; 3]], size: u32, yaw_deg: f32, pitch_deg: f32, di
|
||||
[l[0] / m, l[1] / m, l[2] / m]
|
||||
};
|
||||
|
||||
for t in tris {
|
||||
for (ti, t) in tris.iter().enumerate() {
|
||||
let tint = tints.get(ti).copied().unwrap_or([1.0, 1.0, 1.0]);
|
||||
let v0 = view(t[0]);
|
||||
let v1 = view(t[1]);
|
||||
let v2 = view(t[2]);
|
||||
@@ -739,9 +863,9 @@ fn rasterize(tris: &[[[f32; 3]; 3]], size: u32, yaw_deg: f32, pitch_deg: f32, di
|
||||
let idx = py * n + px;
|
||||
if z < depth[idx] {
|
||||
depth[idx] = z;
|
||||
color[idx * 4] = shade;
|
||||
color[idx * 4 + 1] = shade;
|
||||
color[idx * 4 + 2] = (shade as f32 * 1.02).min(255.0) as u8;
|
||||
color[idx * 4] = (shade as f32 * tint[0]).min(255.0) as u8;
|
||||
color[idx * 4 + 1] = (shade as f32 * tint[1]).min(255.0) as u8;
|
||||
color[idx * 4 + 2] = (shade as f32 * tint[2] * 1.02).min(255.0) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,5 +19,11 @@ tracing = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# Native-only: parallelise the per-resource XBG7 stage anchoring (hundreds of
|
||||
# independent sub-models per container). rayon needs OS threads, so it is not
|
||||
# pulled in for the wasm32 build, which falls back to a sequential decode.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rayon = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -50,10 +50,20 @@ pub mod mesh;
|
||||
// Audio parsing — scaffold for XMA handling
|
||||
pub mod audio;
|
||||
|
||||
// Movie cutscene subtitles — the movie → track → text chain.
|
||||
pub mod movie_subtitle;
|
||||
|
||||
// XACT `.slb` sound banks (voice / music) → decodable XMA1 RIFF.
|
||||
pub mod slb;
|
||||
|
||||
// The movie manifest: authoritative movie → subtitle → voice index.
|
||||
pub mod movie_manifest;
|
||||
|
||||
/// Re-export the most commonly used types at the crate root.
|
||||
pub use font::FontInfo;
|
||||
pub use idxd::{IdxdError, IdxdObject};
|
||||
pub use ixud::{Cue, Subtitle};
|
||||
pub use movie_subtitle::{SubCue, SubLang};
|
||||
pub use mesh::{GameMesh, Xbg7Model};
|
||||
pub use ratc::RatcChild;
|
||||
pub use t8ad::T8adImage;
|
||||
|
||||
@@ -385,10 +385,31 @@ impl Xbg7Model {
|
||||
Self::anchor_models(bytes, 0.0)
|
||||
}
|
||||
|
||||
/// Like [`Xbg7Model::stage_models`] but abortable: `should_cancel` is polled
|
||||
/// between resources so a viewer can drop an in-flight decode when the user
|
||||
/// selects a different file. Returns whatever decoded before the cancel.
|
||||
pub fn stage_models_cancellable(
|
||||
bytes: &[u8],
|
||||
should_cancel: &(dyn Fn() -> bool + Sync),
|
||||
) -> Vec<Xbg7Model> {
|
||||
Self::anchor_models_cancellable(bytes, 0.0, should_cancel)
|
||||
}
|
||||
|
||||
/// Content-anchor every XBG7 resource, rejecting any block whose winding
|
||||
/// consistency (`max(na, 1-na)`) is below `min_consistency`. See
|
||||
/// [`Xbg7Model::stage_models`].
|
||||
pub fn anchor_models(bytes: &[u8], min_consistency: f32) -> Vec<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();
|
||||
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||||
return out;
|
||||
@@ -471,7 +492,17 @@ impl Xbg7Model {
|
||||
starts_by_stride.insert(s, vertex_run_starts(bytes, data_base, s));
|
||||
}
|
||||
|
||||
for r in &resources {
|
||||
// Anchor each resource. Resources are independent (the shared
|
||||
// `starts_by_stride` is read-only from here on), so a big stage's
|
||||
// hundreds of sub-models are decoded in parallel on native builds —
|
||||
// the dominant cost of loading a stage container. `filter_map(...).collect()`
|
||||
// preserves resource order, so the output is identical to the sequential
|
||||
// decode. `should_cancel()` is polled per resource so a superseded load
|
||||
// stops promptly.
|
||||
let decode_one = |r: &Res| -> Option<Xbg7Model> {
|
||||
if should_cancel() {
|
||||
return None;
|
||||
}
|
||||
let starts = &starts_by_stride[&r.decl.stride];
|
||||
let meshes = if r.markers.len() == 1 {
|
||||
// Single sub-mesh → the proven per-block adjacency anchor
|
||||
@@ -499,12 +530,20 @@ impl Xbg7Model {
|
||||
.collect()
|
||||
}
|
||||
};
|
||||
if !meshes.is_empty() {
|
||||
out.push(Xbg7Model {
|
||||
name: r.name.clone(),
|
||||
meshes,
|
||||
});
|
||||
}
|
||||
(!meshes.is_empty()).then(|| Xbg7Model {
|
||||
name: r.name.clone(),
|
||||
meshes,
|
||||
})
|
||||
};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
out = resources.par_iter().filter_map(decode_one).collect();
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
out = resources.iter().filter_map(decode_one).collect();
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1210,6 +1249,540 @@ fn read_cstr(b: &[u8], o: usize) -> Option<String> {
|
||||
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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
184
crates/sylpheed-formats/src/movie_manifest.rs
Normal file
184
crates/sylpheed-formats/src/movie_manifest.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
417
crates/sylpheed-formats/src/movie_subtitle.rs
Normal file
417
crates/sylpheed-formats/src/movie_subtitle.rs
Normal 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. 0x7F–0x9F) so binary noise breaks
|
||||
/// runs instead of joining them.
|
||||
fn is_text_unit(u: u16) -> bool {
|
||||
matches!(u, 0x20..=0x7e | 0xa0..=0xff | 0x100..=0x17f)
|
||||
}
|
||||
|
||||
/// Parse `MM:SS.ss` or a `start-end` range into seconds.
|
||||
fn parse_timing(s: &str) -> Option<(f32, Option<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)));
|
||||
}
|
||||
}
|
||||
@@ -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`).
|
||||
pub fn entries(&self) -> &[PakEntry] {
|
||||
&self.entries
|
||||
|
||||
249
crates/sylpheed-formats/src/slb.rs
Normal file
249
crates/sylpheed-formats/src/slb.rs
Normal 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 S10–S16 play the wrong audio.
|
||||
let fi = find(slb, b"fmt ", ri)?;
|
||||
let fsz = le32(slb, fi + 4)? as usize;
|
||||
let fmt_end = fi.checked_add(8)?.checked_add(fsz)?;
|
||||
if fmt_end > slb.len() {
|
||||
return None;
|
||||
}
|
||||
let fmt_chunk = &slb[fi..fmt_end];
|
||||
let di = find(slb, b"data", fi)?;
|
||||
let dsz = le32(slb, di + 4)? as usize;
|
||||
let end = di.checked_add(8)?.checked_add(dsz)?.min(slb.len());
|
||||
let data = slb.get(di + 8..end)?;
|
||||
Some(build_riff(fmt_chunk, data))
|
||||
} else {
|
||||
// Headerless: 1392-byte header, then raw XMA1 (48 kHz, 2 channels).
|
||||
let data = slb.get(HEADERLESS_DATA_OFFSET..)?;
|
||||
if data.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(build_riff(&synth_xma1_fmt(2, 2, 48000), data))
|
||||
}
|
||||
}
|
||||
|
||||
/// A minimal `fmt ` chunk carrying an XMA1 `XMAWAVEFORMAT` (one stream).
|
||||
fn synth_xma1_fmt(channels: u8, channel_mask: u16, rate: u32) -> Vec<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));
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
//! Run: `cargo test -p sylpheed-formats --test mesh_disc -- --ignored --nocapture`
|
||||
|
||||
use std::path::PathBuf;
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
use sylpheed_formats::mesh::{material_groups, node_transforms, submesh_albedos, Xbg7Model};
|
||||
|
||||
fn res3d_dir() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("SYLPHEED_RES3D") {
|
||||
@@ -21,6 +21,119 @@ fn res3d_dir() -> Option<PathBuf> {
|
||||
default.is_dir().then_some(default)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
|
||||
fn hero_ship_submesh_material_graph() {
|
||||
let Some(dir) = res3d_dir() else {
|
||||
eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)");
|
||||
return;
|
||||
};
|
||||
// The XBG7 node/material graph tags each sub-mesh record with its own albedo
|
||||
// (`rou_…_col` → TX2D name). DeltaSaber_T `f001`: the 7 detail parts use
|
||||
// bdy_04/06/07 — NOT the body's bdy_01a — which is the per-part texturing the
|
||||
// viewer needs so fins aren't painted with the hull map.
|
||||
let bytes = std::fs::read(dir.join("DeltaSaber_T.xpr")).unwrap();
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for (v, i, name) in submesh_albedos(&bytes, "f001") {
|
||||
map.insert((v, i), name);
|
||||
}
|
||||
// (vtx, idx) → expected albedo (rou_ stripped = TX2D name).
|
||||
assert_eq!(map.get(&(314, 645)).map(String::as_str), Some("f001_bdy_04_col"));
|
||||
assert_eq!(map.get(&(48, 84)).map(String::as_str), Some("f001_bdy_04_col"));
|
||||
assert_eq!(map.get(&(96, 180)).map(String::as_str), Some("f001_bdy_06_col"));
|
||||
assert_eq!(map.get(&(60, 108)).map(String::as_str), Some("f001_bdy_07_col"));
|
||||
// Names strip cleanly to real TX2D resources.
|
||||
let tex = sylpheed_formats::texture::X360Texture::texture_names(&bytes);
|
||||
for name in map.values() {
|
||||
assert!(tex.iter().any(|t| t == name), "albedo {name} not a TX2D resource");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
|
||||
fn hero_ship_body_splits_by_material() {
|
||||
let Some(dir) = res3d_dir() else {
|
||||
eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)");
|
||||
return;
|
||||
};
|
||||
// The merged body (sub0 = 24561 indices / 8187 tris) is drawn as 9 groups
|
||||
// sharing one index buffer, each with its own albedo (bdy_01a hull, bdy_01b,
|
||||
// bdy_02/03, and the `daiza` hangar stand). `material_groups` recovers the
|
||||
// cumulative-offset chain so the viewer can texture each slice.
|
||||
let bytes = std::fs::read(dir.join("DeltaSaber_T.xpr")).unwrap();
|
||||
let groups = material_groups(&bytes, "f001", 24561);
|
||||
assert_eq!(groups.len(), 9, "body must split into 9 material groups");
|
||||
// Offsets chain from 0 and cover the whole index buffer exactly.
|
||||
let mut cursor = 0usize;
|
||||
for g in &groups {
|
||||
assert_eq!(g.idx_offset, cursor, "group offsets must be contiguous");
|
||||
cursor += g.idx_count;
|
||||
}
|
||||
assert_eq!(cursor, 24561, "groups must cover all body indices");
|
||||
// Expected per-group albedo (verified against the GPU draw log).
|
||||
let albedos: Vec<&str> = groups.iter().map(|g| g.albedo.as_str()).collect();
|
||||
assert_eq!(
|
||||
albedos,
|
||||
[
|
||||
"f001_bdy_01a_col",
|
||||
"f001_bdy_01a_col",
|
||||
"f001_bdy_01a_col",
|
||||
"f001_bdy_01b_col",
|
||||
"f001_bdy_01a_col",
|
||||
"f001_bdy_01b_col",
|
||||
"f001_bdy_02_col",
|
||||
"f001_bdy_03_col",
|
||||
"f001_bdy_daiza_col",
|
||||
]
|
||||
);
|
||||
// A single-material detail part (sub1 = 645 indices) → one group, bdy_04.
|
||||
let fin = material_groups(&bytes, "f001", 645);
|
||||
assert_eq!(fin.len(), 1);
|
||||
assert_eq!(fin[0].albedo, "f001_bdy_04_col");
|
||||
assert_eq!(fin[0].idx_offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
|
||||
fn hero_ship_node_transforms_move_fins_to_tail() {
|
||||
let Some(dir) = res3d_dir() else {
|
||||
eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)");
|
||||
return;
|
||||
};
|
||||
// For the DeltaSaber, node_transforms returns the MEASURED runtime placements
|
||||
// (Canary draw-log WVP; see mesh.rs::DELTASABER_MEASURED) — the fin assemblies
|
||||
// are mounted OUT on the nacelles (world X ≈ −4.4..−6.6), an offset the static
|
||||
// graph omits. Body identity; each part a left/right mirrored pair. Vertex
|
||||
// space: X right, Y up, Z fore/aft (engines at −Z).
|
||||
let bytes = std::fs::read(dir.join("DeltaSaber_T.xpr")).unwrap();
|
||||
let places = node_transforms(&bytes, "f001");
|
||||
assert!(!places.is_empty(), "expected node placements");
|
||||
// Body (sub 0) identity, drawn once.
|
||||
let body: Vec<_> = places.iter().filter(|p| p.sub_index == 0).collect();
|
||||
assert_eq!(body.len(), 1, "body drawn once");
|
||||
assert!(body[0].t.iter().all(|c| c.abs() < 0.1), "body must not translate");
|
||||
assert!(!body[0].reflect, "body is not mirrored");
|
||||
// Fin bdy_04 (sub 1): mirrored pair near the tail, mounted OUT on the nacelle
|
||||
// (|X| ≈ 5.2, not the graph's inboard ≈1.1).
|
||||
let fins: Vec<_> = places.iter().filter(|p| p.sub_index == 1).collect();
|
||||
assert_eq!(fins.len(), 2, "V-tail is a mirrored pair");
|
||||
assert!(fins.iter().all(|f| (f.t[2] + 9.72).abs() < 0.3), "fin fore/aft ≈ −9.7");
|
||||
assert!(fins.iter().all(|f| f.t[0].abs() > 4.0), "fin mounted out on the nacelle");
|
||||
assert!(fins[0].t[0] * fins[1].t[0] < 0.0, "the pair mirrors across X");
|
||||
assert!(fins.iter().any(|f| f.reflect), "one of the pair is reflected");
|
||||
// Winglet bdy_06 (sub 4): on the nacelle (|X| ≈ 6.6, Z ≈ −15.5).
|
||||
let wings: Vec<_> = places.iter().filter(|p| p.sub_index == 4).collect();
|
||||
assert_eq!(wings.len(), 2, "L/R winglet pair");
|
||||
assert!(wings.iter().all(|p| p.t[0].abs() > 6.0 && (p.t[2] + 15.5).abs() < 0.3),
|
||||
"winglets out on the nacelle");
|
||||
assert!(wings[0].t[0] * wings[1].t[0] < 0.0, "winglets mirror across X");
|
||||
// Small fin bdy_10 (sub 6): inboard nacelle stub (|X| ≈ 4.45, Z ≈ −17).
|
||||
let sfins: Vec<_> = places.iter().filter(|p| p.sub_index == 6).collect();
|
||||
assert_eq!(sfins.len(), 2, "L/R small-fin pair");
|
||||
assert!(sfins.iter().all(|p| (p.t[0].abs() - 4.45).abs() < 0.3), "small fins on the stub");
|
||||
assert!(sfins[0].t[0] * sfins[1].t[0] < 0.0, "small fins mirror across X");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
|
||||
fn weapon_model_decodes_to_expected_geometry() {
|
||||
|
||||
102
crates/sylpheed-formats/tests/movie_manifest_disc.rs
Normal file
102
crates/sylpheed-formats/tests/movie_manifest_disc.rs
Normal 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:?}");
|
||||
}
|
||||
63
crates/sylpheed-formats/tests/movie_subtitle_disc.rs
Normal file
63
crates/sylpheed-formats/tests/movie_subtitle_disc.rs
Normal 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);
|
||||
}
|
||||
104
crates/sylpheed-formats/tests/slb_disc.rs
Normal file
104
crates/sylpheed-formats/tests/slb_disc.rs
Normal 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"));
|
||||
}
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::input::mouse::{MouseMotion, MouseWheel};
|
||||
use bevy::render::render_asset::RenderAssetUsages;
|
||||
use bevy::render::render_resource::{
|
||||
Extent3d, TextureDimension, TextureFormat, TextureViewDescriptor, TextureViewDimension,
|
||||
};
|
||||
|
||||
pub struct OrbitCameraPlugin;
|
||||
|
||||
@@ -54,10 +58,17 @@ impl Default for OrbitCamera {
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_camera(mut commands: Commands) {
|
||||
fn spawn_camera(mut commands: Commands, mut images: ResMut<Assets<Image>>) {
|
||||
let orbit = OrbitCamera::default();
|
||||
let transform = orbit_transform(&orbit);
|
||||
|
||||
// The game's ship shader reflects a shared HDR environment cubemap off the
|
||||
// hull (three cube samples — see the shader RE). Reproduce that "look" with a
|
||||
// procedural sky cube feeding Bevy's image-based lighting, so metallic
|
||||
// surfaces reflect an environment instead of reading flat. Procedural (not a
|
||||
// bundled KTX2) so it also works on WASM with no extra assets.
|
||||
let env = make_env_cubemap(&mut images);
|
||||
|
||||
commands.spawn((
|
||||
Camera3d::default(),
|
||||
// Wide near/far so both tiny weapons and multi-thousand-unit stages fit;
|
||||
@@ -69,9 +80,78 @@ fn spawn_camera(mut commands: Commands) {
|
||||
}),
|
||||
transform,
|
||||
orbit,
|
||||
EnvironmentMapLight {
|
||||
diffuse_map: env.clone(),
|
||||
specular_map: env,
|
||||
intensity: 900.0,
|
||||
..default()
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
/// Build a small procedural sky cubemap (6 faces) for image-based lighting.
|
||||
///
|
||||
/// A vertical gradient (bright zenith → warm horizon → dark ground) plus a warm
|
||||
/// "sun" highlight roughly where the game's key light sits — enough for a
|
||||
/// metallic hull to read as reflective rather than flat. Stored as an sRGB cube
|
||||
/// (filterable, no half-float encoding needed); a single mip means reflections
|
||||
/// stay sharp (fine for a shiny ship).
|
||||
fn make_env_cubemap(images: &mut Assets<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(
|
||||
mut query: Query<(&mut OrbitCamera, &mut Transform, &mut Projection)>,
|
||||
mouse_buttons: Res<ButtonInput<MouseButton>>,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -72,9 +72,28 @@ pub fn run() {
|
||||
}
|
||||
|
||||
fn setup_scene(mut commands: Commands) {
|
||||
commands.spawn(DirectionalLight {
|
||||
illuminance: 10_000.0,
|
||||
shadows_enabled: true,
|
||||
..default()
|
||||
// Bright ambient so surfaces facing away from every light aren't pure black.
|
||||
commands.insert_resource(AmbientLight {
|
||||
color: Color::srgb(0.9, 0.93, 1.0),
|
||||
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),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,13 @@ use bevy::prelude::*;
|
||||
use bevy_egui::{egui, EguiContexts};
|
||||
|
||||
use crate::iso_loader::{
|
||||
FileInfo, FileSelected, ImageRgba, IsoState, ModelPreview, PakContent, PakView, RequestOpenDir,
|
||||
RequestOpenIso, SkyboxPreview, TextPreview, TexturePreview, VideoPreview, IsoLoaderSystemSet,
|
||||
AudioPreview, FileInfo, FileSelected, ImageRgba, IsoState, ModelPreview, MovieSubtitles,
|
||||
MovieVoice, PakContent, PakView, RequestAudio, RequestOpenDir, RequestOpenIso, RequestSubtitles,
|
||||
RequestVoiceLibrary, SkyboxPreview, TextPreview, TexturePreview, VideoPreview, VoiceLibrary,
|
||||
IsoLoaderSystemSet,
|
||||
};
|
||||
use crate::ViewerState;
|
||||
use sylpheed_formats::SubLang;
|
||||
|
||||
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(
|
||||
mut contexts: EguiContexts,
|
||||
mut viewer: ResMut<ViewerState>,
|
||||
@@ -135,9 +150,11 @@ fn draw_viewer_ui(
|
||||
model: Res<ModelPreview>,
|
||||
mut video: ResMut<VideoPreview>,
|
||||
file_info: Res<FileInfo>,
|
||||
mut open_iso_events: EventWriter<RequestOpenIso>,
|
||||
mut open_dir_events: EventWriter<RequestOpenDir>,
|
||||
mut file_selected_events: EventWriter<FileSelected>,
|
||||
mut subtitles: ResMut<MovieSubtitles>,
|
||||
mut movie_voice: ResMut<MovieVoice>,
|
||||
mut audio: ResMut<AudioPreview>,
|
||||
mut voice_lib: ResMut<VoiceLibrary>,
|
||||
mut events: UiEvents,
|
||||
) {
|
||||
let ctx = contexts.ctx_mut();
|
||||
|
||||
@@ -148,11 +165,11 @@ fn draw_viewer_ui(
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if ui.button("Open ISO disc image…").clicked() {
|
||||
open_iso_events.send_default();
|
||||
events.open_iso.send_default();
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("Open extracted folder…").clicked() {
|
||||
open_dir_events.send_default();
|
||||
events.open_dir.send_default();
|
||||
ui.close_menu();
|
||||
}
|
||||
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| {
|
||||
if ui.button("Controls…").clicked() {
|
||||
// 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 ──────────────────────────────────────────
|
||||
egui::SidePanel::left("file_browser")
|
||||
.resizable(true)
|
||||
@@ -226,7 +313,7 @@ fn draw_viewer_ui(
|
||||
force_open,
|
||||
selected,
|
||||
loading,
|
||||
&mut file_selected_events,
|
||||
&mut events.file_selected,
|
||||
&files,
|
||||
);
|
||||
}
|
||||
@@ -252,7 +339,13 @@ fn draw_viewer_ui(
|
||||
});
|
||||
|
||||
// ── 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
|
||||
// (mesh + orbit camera) shows through, with just an info overlay on top.
|
||||
egui::CentralPanel::default()
|
||||
@@ -280,6 +373,9 @@ fn draw_viewer_ui(
|
||||
if browser.loading {
|
||||
// A file was just clicked — show immediate feedback while the
|
||||
// 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
|
||||
.selected
|
||||
.and_then(|i| browser.files.get(i))
|
||||
@@ -292,7 +388,43 @@ fn draw_viewer_ui(
|
||||
});
|
||||
});
|
||||
} 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() {
|
||||
draw_skybox_grid(ui, &skybox);
|
||||
} 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
|
||||
/// lives in `VideoPreview`; the decode/audio engine reacts to it in
|
||||
/// `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). ──
|
||||
let (mut toggle_play, mut skip) = (false, 0.0_f32);
|
||||
ui.input_mut(|i| {
|
||||
@@ -862,6 +1001,47 @@ fn draw_video_player(ui: &mut egui::Ui, video: &mut VideoPreview) {
|
||||
ui.separator();
|
||||
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();
|
||||
|
||||
@@ -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 (disp_w, disp_h) = (w * scale, h * scale);
|
||||
|
||||
let mut frame_rect = None;
|
||||
if let Some(egui_id) = video.egui_id {
|
||||
ui.allocate_ui(egui::vec2(avail.x, frame_h), |ui| {
|
||||
ui.vertical_centered(|ui| {
|
||||
@@ -884,10 +1065,26 @@ fn draw_video_player(ui: &mut egui::Ui, video: &mut VideoPreview) {
|
||||
if resp.clicked() {
|
||||
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 ──
|
||||
ui.horizontal(|ui| {
|
||||
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 ─────────────────────────────────────────────
|
||||
|
||||
/// Show a world cubemap's 6 faces in a labelled grid (D3D9 order). A true
|
||||
|
||||
Reference in New Issue
Block a user