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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user