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

The movie cutscene subtitle + voice pipeline, driven by the ADVERTISE_MOVIE
manifest (the authoritative movie -> subtitle -> voice index). Also flushes
several sessions of local WIP (async viewer loading, grouped-pool XBG7/hero-ship
decode, drawlog tooling). See docs/HANDOFF-movie-voice-subtitles-2026-07-19.md.

Subtitles (movie_subtitle.rs):
- Full movie->track->text chain; join multi-line captions sharing one timing
  (fixes S13A dropped "Look at it father" line); overlap-safe active_cues();
  Latin-1 accents preserved.

Voice (slb.rs): XACT .slb -> XMA1 RIFF; take the FIRST sub-wave bounded by its
declared data size (fixes S10-S16 alternate-take garble); list_voice_clips.

Manifest (movie_manifest.rs): parse ADVERTISE_MOVIE (0x5B983A08) for the real
movie->voice binding (not always VOICE_<movie>; e.g. hokyu -> VOICE_D_* in etc\).
Resolve the token's sound.pak path via sounds.tbl. DIRECT bindings only — the
demo-id shared-clip fallback for unbound hokyu movies was verified WRONG in-game
and reverted (unbound hokyu stay unvoiced; correct join key is an OPEN problem).

Viewer: manifest-driven voice (movie player toggle + solo button), standalone
"Voice Lines" browser, stacked caption overlay.

Tests: 46 formats-lib + 11 viewer-lib + movie_manifest/movie_subtitle/slb disc
tests; full workspace green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-19 20:15:41 +02:00
parent 578c71a1b9
commit 95de29f290
22 changed files with 4684 additions and 493 deletions

View File

@@ -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;
}
}
}