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.
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user