Fix hokyu voice: resolve by subtitle demo-id, not ship category
The 13 manifest-unbound hokyu (resupply) cutscenes reuse 5 recordings (VOICE_D_450..454). The category guess (ship LS/DS × source carrier/ tanker) was wrong for some. The real selector is the cutscene's subtitle demo-id: 600→450, 601→451, 602→452, 603→453, 604→454 — derived from the 5 BOUND hokyu (each carries both a demo-id and a VOICETRACK), so it self-validates. Fixes hokyu_LS_s11A/s15A (→451, were wrongly 450) and hokyu_LS_s24A/s27A (→silent, no voice cue). User-confirmed all correct. hokyu_voice_token() builds the demo→token map from the bound hokyu and looks up the target movie's demo-id; replaces the category fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
43
crates/sylpheed-formats/examples/hokyu_demo_resolve.rs
Normal file
43
crates/sylpheed-formats/examples/hokyu_demo_resolve.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
use sylpheed_formats::{hash::name_hash, movie_manifest, movie_subtitle as ms, movie_voice, slb, PakArchive};
|
||||||
|
use std::fs;use std::io::{Read,Seek,SeekFrom};use std::process::Command;
|
||||||
|
fn rg(disc:&str,g:u64,n:usize)->Vec<u8>{let mut segs=vec![];let mut cum=0u64;for i in 0..5{let p=format!("{disc}/dat/sound.p{i:02}");if let Ok(m)=fs::metadata(&p){segs.push((cum,m.len(),p));cum+=m.len();}}let mut out=vec![];let(mut need,mut pos)=(n,g);for(base,len,path)in &segs{if need==0||pos>=base+len||pos<*base{continue;}let local=pos-base;let take=need.min((len-local)as usize);let mut f=fs::File::open(path).unwrap();f.seek(SeekFrom::Start(local)).unwrap();let mut b=vec![0u8;take];f.read_exact(&mut b).unwrap();out.extend_from_slice(&b);need-=take;pos+=take as u64;}out}
|
||||||
|
fn ct(h:&[u8],n:&[u8])->bool{h.windows(n.len()).any(|w|w==n)}
|
||||||
|
fn dur(w:&str)->String{let o=Command::new("ffprobe").args(["-v","error","-show_entries","format=duration","-of","default=nw=1:nk=1",w]).output().unwrap();String::from_utf8_lossy(&o.stdout).trim().to_string()}
|
||||||
|
fn main(){
|
||||||
|
let disc=std::env::var("SYLPHEED_DISC").unwrap();
|
||||||
|
let tpak=PakArchive::open(format!("{disc}/dat/tables.pak")).unwrap();
|
||||||
|
let man=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|movie_manifest::is_manifest(b))).unwrap();
|
||||||
|
let lpak=PakArchive::open(format!("{disc}/dat/movie/eng.pak")).unwrap();
|
||||||
|
let reg=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|ct(b,b"eng\\Movie\\VOICE_ADV.slb"))).unwrap();
|
||||||
|
let ids=movie_voice::registry_voice_ids(®);
|
||||||
|
let stoc=fs::read(format!("{disc}/dat/sound.pak")).unwrap();
|
||||||
|
let ents=PakArchive::parse_toc(&stoc).unwrap();
|
||||||
|
// demo->token from bound hokyu
|
||||||
|
let hok:Vec<_>=movie_manifest::parse(&man).into_iter().filter(|m|m.movie.starts_with("hokyu_")).collect();
|
||||||
|
let resolve=|movie:&str|->Option<String>{
|
||||||
|
movie_manifest::voice_token(&man,movie).or_else(||{
|
||||||
|
let want=ms::track_voice_cues(&lpak,movie).first().map(|&(d,_)|d)?;
|
||||||
|
hok.iter().find_map(|e|{let t=e.voice_token.clone().filter(|_|e.movie.starts_with("hokyu_"))?; ms::track_voice_cues(&lpak,&e.movie).iter().any(|&(d,_)|d==want).then_some(t)})
|
||||||
|
})
|
||||||
|
};
|
||||||
|
for e in &hok{
|
||||||
|
let mv=&e.movie;
|
||||||
|
let bound=e.voice_token.is_some();
|
||||||
|
let tok=resolve(mv);
|
||||||
|
let demo=ms::track_voice_cues(&lpak,mv).first().map(|&(d,_)|d);
|
||||||
|
let mut d="—".to_string();
|
||||||
|
if let Some(tok)=&tok{
|
||||||
|
if let Some(&id)=ids.get(tok){
|
||||||
|
if let Some(anchor)=["Movie","etc","Voice"].iter().find_map(|dir|{let h=name_hash(&format!("eng\\{dir}\\{tok}.slb"));ents.binary_search_by_key(&h,|x|x.name_hash).ok().map(|i|ents[i].offset as u64)}){
|
||||||
|
let ws=(anchor.saturating_sub(2*1024*1024))&!3; let win=rg(&disc,ws,8*1024*1024);
|
||||||
|
if let Some(el)=movie_voice::find_descriptor(&win,id){let end=ws+el as u64;
|
||||||
|
let start=movie_voice::find_descriptor(&win,id.wrapping_sub(1)).or_else(||movie_voice::find_descriptor_before(&win,el)).map(|o|ws+o as u64).filter(|&s|s<end&&end-s<1_500_000).unwrap_or(anchor);
|
||||||
|
let region=rg(&disc,start,(end-start)as usize); let mut rf=slb::to_xma_riffs(®ion); if rf.is_empty(){rf=slb::to_xma_riff_best(®ion).into_iter().collect();}
|
||||||
|
if let Some(r)=rf.first(){let xp=format!("/tmp/hd_{mv}.xma.wav");let wp=format!("/tmp/hd_{mv}.wav");fs::write(&xp,r).unwrap();let _=Command::new("ffmpeg").args(["-hide_banner","-v","error","-y","-i",&xp,&wp]).status();d=dur(&wp);}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("{mv:16} {:8} demo={:?} -> {:11} voice={d}s", if bound{"BOUND"}else{"unbound"}, demo, tok.unwrap_or("(silent)".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3213,29 +3213,40 @@ fn decode_riffs_to_wav(riffs: Vec<Vec<u8>>, tag: &str, duration: f32) -> Result<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Categorical fallback voice token for a hokyu (resupply) cutscene the manifest
|
/// Voice token for a hokyu (resupply) cutscene the manifest leaves unbound.
|
||||||
/// leaves unbound. Only 5 of the 18 hokyu movies carry an explicit `VOICETRACK`;
|
///
|
||||||
/// the game reuses those 5 generic recordings across stages, keyed by
|
/// Only 5 of the 18 hokyu movies carry an explicit `VOICETRACK`; the rest reuse
|
||||||
/// ship (`LS`/`DS`) × resupply source (carrier `…A` / tanker `…H`). The four
|
/// those 5 recordings. The selector is the cutscene's **demo id** (from its
|
||||||
/// category→recording pairs are read off the bound examples; `LS`/carrier has two
|
/// subtitle track), NOT the ship/source category: `hokyu_LS_s02A` and
|
||||||
/// takes (450 from s02A, 451 from s09A) — we default unbound LS/carrier to 450.
|
/// `hokyu_LS_s11A` are both LS/carrier but use demos 600 vs 601 → `VOICE_D_450`
|
||||||
/// Returns `None` for non-hokyu movies (they resolve via the manifest only).
|
/// vs `_451` (their lines differ: "Rhino 3 has landed" vs "Rhino Leader has
|
||||||
fn hokyu_fallback_token(movie: &str) -> Option<String> {
|
/// landed"). We derive the demo→token map from the 5 BOUND hokyu (each has both a
|
||||||
|
/// subtitle demo id and a VOICETRACK), then look up the target movie's demo id.
|
||||||
|
/// Returns `None` for hokyu with no voice cue (`hokyu_LS_s24A`/`s27A` — correctly
|
||||||
|
/// silent) and for non-hokyu movies (they resolve via the manifest only).
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn hokyu_voice_token(
|
||||||
|
source: &SourceKind,
|
||||||
|
movie: &str,
|
||||||
|
lang: sylpheed_formats::slb::VoiceLang,
|
||||||
|
manifest: &[u8],
|
||||||
|
) -> Option<String> {
|
||||||
|
use sylpheed_formats::{movie_manifest, movie_subtitle as ms};
|
||||||
if !movie.starts_with("hokyu_") {
|
if !movie.starts_with("hokyu_") {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let ls = movie.contains("_LS_");
|
let lang_pak =
|
||||||
let ds = movie.contains("_DS_");
|
read_pak_archive_blocking(source, &format!("dat/movie/{}.pak", lang.code_pub())).ok()?;
|
||||||
let carrier = movie.ends_with('A');
|
// The target cutscene's demo id (its subtitle's single voice cue).
|
||||||
let tanker = movie.ends_with('H');
|
let want = ms::track_voice_cues(&lang_pak, movie).first().map(|&(d, _)| d)?;
|
||||||
let token = match (ls, ds, carrier, tanker) {
|
// Match it against a bound hokyu carrying the same demo id → that VOICETRACK.
|
||||||
(true, _, true, _) => "VOICE_D_450", // light ship, carrier resupply
|
movie_manifest::parse(manifest).into_iter().find_map(|e| {
|
||||||
(true, _, _, true) => "VOICE_D_453", // light ship, tanker resupply
|
let tok = e.voice_token.filter(|_| e.movie.starts_with("hokyu_"))?;
|
||||||
(_, true, true, _) => "VOICE_D_452", // Delta Saber, carrier resupply
|
ms::track_voice_cues(&lang_pak, &e.movie)
|
||||||
(_, true, _, true) => "VOICE_D_454", // Delta Saber, tanker resupply
|
.iter()
|
||||||
_ => return None,
|
.any(|&(d, _)| d == want)
|
||||||
};
|
.then_some(tok)
|
||||||
Some(token.to_string())
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a movie's cutscene voice to a **continuous byte region** of the
|
/// Resolve a movie's cutscene voice to a **continuous byte region** of the
|
||||||
@@ -3264,7 +3275,7 @@ fn resolve_movie_voice_region(
|
|||||||
.iter()
|
.iter()
|
||||||
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))?;
|
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))?;
|
||||||
let token = movie_manifest::voice_token(&manifest, movie)
|
let token = movie_manifest::voice_token(&manifest, movie)
|
||||||
.or_else(|| hokyu_fallback_token(movie))?;
|
.or_else(|| hokyu_voice_token(source, movie, lang, &manifest))?;
|
||||||
// token → sound-id via the master registry (the large per-language IDXD entry
|
// token → sound-id via the master registry (the large per-language IDXD entry
|
||||||
// carrying the `<lang>\Movie\VOICE_*.slb` paths).
|
// carrying the `<lang>\Movie\VOICE_*.slb` paths).
|
||||||
let marker = format!("{code}\\Movie\\VOICE_ADV.slb");
|
let marker = format!("{code}\\Movie\\VOICE_ADV.slb");
|
||||||
|
|||||||
Reference in New Issue
Block a user