From d1685d67c90463c17accc9c9c8db61f4b9eee63f Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 29 Aug 2026 16:21:34 +0200 Subject: [PATCH 1/2] viewer: show where a cutscene's voice actually is, and let you hear it The Cutscenes window printed the voice token as text and offered no way to play it, which left the most confusing thing on the disc invisible. The movie voices are one continuous XMA stream chunked into VOICE_*.slb entries whose boundaries do NOT match the cutscene cues, so the bank named after a movie need not hold that movie's audio. Measured, on the retail disc: ADV region 433930240..437044592 inside VOICE_ADV.slb name honest S00A region 452798464..455499120 inside VOICE_S00A.slb name honest RT01A region 437044592..437345648 inside VOICE_ADV.slb NAME LIES RT01A's voice sits in bytes belonging to the entry named after the intro movie. A viewer that played the name-matched bank would be confidently wrong for exactly the cutscenes where it matters, and would look right on the two that are easiest to check. So the window now shows BOTH locations -- the named bank with its byte range, and the resolved region -- and states plainly whether the name is honest, highlighting it when it is not. Play routes through the movie form of RequestAudio, which resolves the region rather than reading the bank. Static data only: sound.pak and tables.pak, both on the disc. --- crates/sylpheed-viewer/src/iso_loader.rs | 76 ++++++++++++++++++- crates/sylpheed-viewer/src/ui.rs | 96 ++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 2 deletions(-) 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. From 1b1a4dfcd371877221f7f7a2b097cca9b2ac5449 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 29 Aug 2026 16:31:53 +0200 Subject: [PATCH 2/2] containers: an expired token could never be replaced Credentials were seeded only when the container's copy was MISSING. So when a session expired, the file still existed, the copy was skipped, and restarting changed nothing -- the one recovery path a human has, re-logging in on the host, could not reach the containers at all. Now re-seeds whenever the host's copy is newer. Newer-wins rather than always-copy, because a container refreshes its own token mid-run and that copy may legitimately be the fresher of the two. Found when both sessions expired: host credentials at 16:30, containers holding 14:20 and 14:24. --- Cargo.lock | 12 ++++++++++++ docker/decoder/entrypoint.sh | 14 ++++++++++++-- docker/port/entrypoint.sh | 14 ++++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 896db78b..c3d303d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4618,6 +4618,18 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "sylpheed-export" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "image", + "serde", + "serde_json", + "sylpheed-formats", +] + [[package]] name = "sylpheed-formats" version = "0.1.0" 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