Merge remote-tracking branch 'origin/main' into auto/port-p6-audio

This commit is contained in:
Sylpheed port agent
2026-08-29 14:34:39 +00:00
4 changed files with 194 additions and 6 deletions

View File

@@ -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<VoiceLocation>,
}
/// 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<sylpheed_formats::SubCue>,
voice: Option<VoiceLocation>,
},
/// The sound-bank library enumerated from `<lang>\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,
});
});
}

View File

@@ -2416,6 +2416,8 @@ fn draw_cutscenes_ui(
mut requests: EventReader<RequestCutscenes>,
mut file_selected: EventWriter<FileSelected>,
mut browser: ResMut<FileBrowserState>,
mut audio: ResMut<AudioPreview>,
mut compose_audio: EventWriter<RequestAudio>,
) {
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<usize> = None;
let mut play: Option<String> = None;
let mut play_voice: Option<String> = 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.

View File

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

View File

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