diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index ad6e8871..9755aa24 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -843,6 +843,37 @@ pub struct CutsceneBrowser { pub cues_generation: u64, /// Set by the window to ask for the selected row's transcript. pub want_cues: bool, + /// Where this cutscene's voice actually lives, versus where its name says. + pub voice: Option, +} + +/// A cutscene's voice, located two ways — because they disagree. +/// +/// The movie voices are one continuous XMA stream chunked into `VOICE_*.slb` +/// TOC entries whose boundaries do **not** match the cutscene cues. So the bank +/// named after a movie need not hold that movie's audio: `RT01A`'s voice sits +/// inside the byte range of the entry named `VOICE_ADV.slb`. +/// +/// Showing both is the point. A viewer that played the name-matched bank would +/// be confidently wrong for exactly the cutscenes where it matters. +#[derive(Clone)] +pub struct VoiceLocation { + /// The bank the naming convention points at, e.g. `eng\Movie\VOICE_ADV.slb`. + pub named_bank: String, + /// That bank's byte range in the sound stream, if it is in the TOC at all. + pub named_range: Option<(u64, u64)>, + /// Where the audio for this movie actually is. + pub region: Option<(u64, u64)>, +} + +impl VoiceLocation { + /// True when the resolved audio lies inside the bank named after the movie. + pub fn name_is_honest(&self) -> bool { + match (self.named_range, self.region) { + (Some((a, b)), Some((s, e))) => s >= a && e <= b, + _ => false, + } + } } /// Ask the loader to read the cutscene manifest from `tables.pak`. @@ -1018,6 +1049,7 @@ enum IsoLoaderMsg { CutsceneCuesLoaded { generation: u64, cues: Vec, + voice: Option, }, /// The sound-bank library enumerated from `\sounds.tbl`. AudioLibraryLoaded { @@ -2278,10 +2310,15 @@ fn poll_loader_channel( cutscenes.loaded = true; } } - Ok(IsoLoaderMsg::CutsceneCuesLoaded { generation, cues }) => { + Ok(IsoLoaderMsg::CutsceneCuesLoaded { + generation, + cues, + voice, + }) => { if generation == cutscenes.cues_generation { cutscenes.cues_loading = false; cutscenes.cues = cues; + cutscenes.voice = voice; } } Ok(IsoLoaderMsg::AudioLibraryLoaded { entries }) => { @@ -4851,6 +4888,7 @@ fn handle_cutscene_cues_request( cut.cues_generation = cut.cues_generation.wrapping_add(1); cut.cues_loading = true; cut.cues.clear(); + cut.voice = None; let generation = cut.cues_generation; let lang = cut.lang; let source = iso_state.source_kind.clone(); @@ -4868,7 +4906,41 @@ fn handle_cutscene_cues_request( )) })() .unwrap_or_default(); - let _ = sender.send(IsoLoaderMsg::CutsceneCuesLoaded { generation, cues }); + + // Locate the voice BOTH ways, so the window can show that they differ. + // The naming convention is the obvious route and it is wrong often + // enough to matter -- resolving the region is the only reading that + // yields the right audio. + let voice = (|| { + use sylpheed_formats::{hash::name_hash, media, PakArchive}; + let vlang = match lang.pak_code() { + "jpn" => sylpheed_formats::slb::VoiceLang::Japanese, + _ => sylpheed_formats::slb::VoiceLang::English, + }; + let named_bank = sylpheed_formats::slb::movie_voice_name(&movie, vlang); + let named_range = read_source_file(&source, "dat/sound.pak") + .ok() + .and_then(|toc| PakArchive::parse_toc(&toc).ok()) + .and_then(|entries| { + let h = name_hash(&named_bank); + entries + .iter() + .find(|e| e.name_hash == h) + .map(|e| (e.offset as u64, e.offset as u64 + e.comp_size as u64)) + }); + let region = media::resolve_movie_voice_region(&source, &movie, vlang); + (named_range.is_some() || region.is_some()).then_some(VoiceLocation { + named_bank, + named_range, + region, + }) + })(); + + let _ = sender.send(IsoLoaderMsg::CutsceneCuesLoaded { + generation, + cues, + voice, + }); }); } diff --git a/crates/sylpheed-viewer/src/ui.rs b/crates/sylpheed-viewer/src/ui.rs index eb4e2386..f7bbec08 100644 --- a/crates/sylpheed-viewer/src/ui.rs +++ b/crates/sylpheed-viewer/src/ui.rs @@ -2416,6 +2416,8 @@ fn draw_cutscenes_ui( mut requests: EventReader, mut file_selected: EventWriter, mut browser: ResMut, + mut audio: ResMut, + mut compose_audio: EventWriter, ) { if requests.read().next().is_some() { cut.open = true; @@ -2427,6 +2429,7 @@ fn draw_cutscenes_ui( let mut open = true; let mut pick: Option = None; let mut play: Option = None; + let mut play_voice: Option = None; egui::Window::new("🎬 Cutscenes") .default_width(880.0) @@ -2581,6 +2584,79 @@ fn draw_cutscenes_ui( ); }); ui.separator(); + + // ── Voice ─────────────────────────────────────────────────── + // Two locations, shown together because they disagree. The bank + // named after a movie is the obvious place to look and is often + // not where the audio is: RT01A's voice sits inside the byte + // range of the entry named VOICE_ADV.slb. Playing the + // name-matched bank would be confidently wrong for exactly the + // cutscenes where it matters. + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Voice").strong()); + if let Some(v) = &cut.voice { + if v.region.is_some() && ui.button("▶ Play").clicked() { + play_voice = Some(row.movie.clone()); + } + if v.region.is_none() { + ui.colored_label( + egui::Color32::from_rgb(224, 168, 86), + "no region resolves — this cutscene may be unvoiced", + ); + } + } else if cut.cues_loading { + ui.spinner(); + } + }); + if let Some(v) = &cut.voice { + egui::Grid::new("cutscene_voice") + .num_columns(2) + .striped(true) + .show(ui, |ui| { + let mut kv2 = |k: &str, val: String, warn: bool| { + ui.label(egui::RichText::new(k).weak().small()); + let t = egui::RichText::new(val).monospace(); + ui.label(if warn { + t.color(egui::Color32::from_rgb(224, 168, 86)) + } else { + t + }); + ui.end_row(); + }; + kv2("named bank", v.named_bank.clone(), false); + kv2( + "its byte range", + match v.named_range { + Some((a, b)) => format!("{a} .. {b} ({} B)", b - a), + None => "not in the TOC".into(), + }, + false, + ); + let honest = v.name_is_honest(); + kv2( + "resolved region", + match v.region { + Some((a, b)) => format!("{a} .. {b} ({} B)", b - a), + None => "—".into(), + }, + !honest && v.region.is_some(), + ); + if v.region.is_some() { + kv2( + "name honest?", + if honest { + "yes — the audio is inside its own bank".into() + } else { + "NO — the audio is outside the bank named after this movie" + .into() + }, + !honest, + ); + } + }); + } + + ui.separator(); ui.label(egui::RichText::new("Transcript").strong()); if cut.cues_loading { ui.horizontal(|ui| { @@ -2623,6 +2699,26 @@ fn draw_cutscenes_ui( cut.selected = Some(i); cut.want_cues = true; } + if let Some(movie) = play_voice { + // Routed through the movie form of RequestAudio, which resolves the + // continuous region rather than reading the name-matched bank. + audio.generation = audio.generation.wrapping_add(1); + audio.loading = true; + audio.active = true; + audio.error = None; + audio.name = format!("VOICE_{movie}"); + let vlang = match cut.lang.pak_code() { + "jpn" => sylpheed_formats::slb::VoiceLang::Japanese, + _ => sylpheed_formats::slb::VoiceLang::English, + }; + compose_audio.send(RequestAudio { + clip: String::new(), + display: format!("VOICE_{movie}"), + movie: Some((movie, vlang)), + mono: true, + generation: audio.generation, + }); + } if let Some(path) = play { // Route through the normal file-open path, so the existing video player // handles it exactly as it would from the tree. diff --git a/docker/decoder/entrypoint.sh b/docker/decoder/entrypoint.sh index f14b94de..da5ce0e5 100755 --- a/docker/decoder/entrypoint.sh +++ b/docker/decoder/entrypoint.sh @@ -133,13 +133,23 @@ mkdir -p /exchange/files 2>/dev/null || true # Seeded rather than shared because credentials live in .credentials.json and a # token refresh must be able to write. Copying once means each agent refreshes # its own token and neither can corrupt the host's. -if [ -d "$HOME/.claude.seed" ] && [ ! -s "$HOME/.claude/.credentials.json" ]; then +# Re-seed whenever the HOST's credentials are newer than ours, not only when +# ours are missing. The missing-only guard meant an expired token could never be +# replaced: the file existed, so the copy was skipped, and restarting the +# container changed nothing. A human re-logging in on the host is exactly the +# recovery path, and it has to reach here. +# +# Newer-wins rather than always-copy, because the container refreshes its own +# token during a run and that copy may legitimately be the fresher one. +if [ -d "$HOME/.claude.seed" ] && \ + { [ ! -s "$HOME/.claude/.credentials.json" ] || \ + [ "$HOME/.claude.seed/.credentials.json" -nt "$HOME/.claude/.credentials.json" ]; }; then mkdir -p "$HOME/.claude" cp -a "$HOME/.claude.seed/.credentials.json" "$HOME/.claude/" 2>/dev/null || true for f in settings.json CLAUDE.md; do [ -e "$HOME/.claude.seed/$f" ] && cp -a "$HOME/.claude.seed/$f" "$HOME/.claude/" 2>/dev/null || true done - echo "[entrypoint] seeded ~/.claude from the host (credentials only)" + echo "[entrypoint] refreshed ~/.claude credentials from the host" fi # Seed ~/.claude.json from the host's read-only copy, then stamp onboarding as diff --git a/docker/port/entrypoint.sh b/docker/port/entrypoint.sh index 0cd71feb..8fa94dcc 100755 --- a/docker/port/entrypoint.sh +++ b/docker/port/entrypoint.sh @@ -30,13 +30,23 @@ echo "[entrypoint] display $DISPLAY ready ($SCREEN_GEOMETRY)" # Seeded rather than shared because credentials live in .credentials.json and a # token refresh must be able to write. Copying once means each agent refreshes # its own token and neither can corrupt the host's. -if [ -d "$HOME/.claude.seed" ] && [ ! -s "$HOME/.claude/.credentials.json" ]; then +# Re-seed whenever the HOST's credentials are newer than ours, not only when +# ours are missing. The missing-only guard meant an expired token could never be +# replaced: the file existed, so the copy was skipped, and restarting the +# container changed nothing. A human re-logging in on the host is exactly the +# recovery path, and it has to reach here. +# +# Newer-wins rather than always-copy, because the container refreshes its own +# token during a run and that copy may legitimately be the fresher one. +if [ -d "$HOME/.claude.seed" ] && \ + { [ ! -s "$HOME/.claude/.credentials.json" ] || \ + [ "$HOME/.claude.seed/.credentials.json" -nt "$HOME/.claude/.credentials.json" ]; }; then mkdir -p "$HOME/.claude" cp -a "$HOME/.claude.seed/.credentials.json" "$HOME/.claude/" 2>/dev/null || true for f in settings.json CLAUDE.md; do [ -e "$HOME/.claude.seed/$f" ] && cp -a "$HOME/.claude.seed/$f" "$HOME/.claude/" 2>/dev/null || true done - echo "[entrypoint] seeded ~/.claude from the host (credentials only)" + echo "[entrypoint] refreshed ~/.claude credentials from the host" fi # Seed ~/.claude.json from the host's read-only copy, then stamp onboarding as