Compare commits
3 Commits
95de29f290
...
053aa81953
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
053aa81953 | ||
|
|
ed5c2f24c3 | ||
|
|
eee1607e1b |
36
crates/sylpheed-formats/examples/hokyu_final.rs
Normal file
36
crates/sylpheed-formats/examples/hokyu_final.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use sylpheed_formats::{hash::name_hash, movie_manifest, 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 contains(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 hokyu_fallback(m:&str)->Option<String>{if !m.starts_with("hokyu_"){return None}let ls=m.contains("_LS_");let ds=m.contains("_DS_");let a=m.ends_with('A');let h=m.ends_with('H');Some(match(ls,ds,a,h){(true,_,true,_)=>"VOICE_D_450",(true,_,_,true)=>"VOICE_D_453",(_,true,true,_)=>"VOICE_D_452",(_,true,_,true)=>"VOICE_D_454",_=>return None}.into())}
|
||||
fn main(){
|
||||
let disc=std::env::var("SYLPHEED_DISC").unwrap();
|
||||
let tpak=PakArchive::open(format!("{disc}/dat/tables.pak")).unwrap();
|
||||
let manifest=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|movie_manifest::is_manifest(b))).unwrap();
|
||||
let code="eng";
|
||||
let registry=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|contains(b,format!("{code}\\Movie\\VOICE_ADV.slb").as_bytes()))).unwrap();
|
||||
let ids=movie_voice::registry_voice_ids(®istry);
|
||||
let stoc=fs::read(format!("{disc}/dat/sound.pak")).unwrap();
|
||||
let entries=PakArchive::parse_toc(&stoc).unwrap();
|
||||
let hokyu:Vec<String>=movie_manifest::parse(&manifest).into_iter().filter(|m|m.movie.starts_with("hokyu_")).map(|m|m.movie).collect();
|
||||
for movie in &hokyu{
|
||||
let bound=movie_manifest::voice_token(&manifest,movie);
|
||||
let token=bound.clone().or_else(||hokyu_fallback(movie));
|
||||
let Some(token)=token else{println!("{movie:16} NO TOKEN"); continue};
|
||||
let src = if bound.is_some(){"bound"}else{"fallback"};
|
||||
let Some(&id)=ids.get(&token) else{println!("{movie:16} {token} not in registry");continue};
|
||||
let Some(anchor)=["Movie","etc","Voice"].iter().find_map(|d|{let h=name_hash(&format!("{code}\\{d}\\{token}.slb"));entries.binary_search_by_key(&h,|e|e.name_hash).ok().map(|i|entries[i].offset as u64)}) else{println!("{movie:16} no anchor");continue};
|
||||
let win_start=(anchor.saturating_sub(2*1024*1024))&!3;
|
||||
let window=rg(&disc,win_start,8*1024*1024);
|
||||
let Some(el)=movie_voice::find_descriptor(&window,id) else{println!("{movie:16} desc not found");continue};
|
||||
let end=win_start+el as u64;
|
||||
let start=movie_voice::find_descriptor(&window,id.wrapping_sub(1)).or_else(||movie_voice::find_descriptor_before(&window,el)).map(|o|win_start+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 riffs=slb::to_xma_riffs(®ion); if riffs.is_empty(){riffs=slb::to_xma_riff_best(®ion).into_iter().collect();}
|
||||
let mut d="?".into();
|
||||
if let Some(r)=riffs.first(){let xp=format!("/tmp/hf_{movie}.xma.wav");let wp=format!("/tmp/hf_{movie}.wav");fs::write(&xp,r).unwrap();let _=Command::new("ffmpeg").args(["-hide_banner","-v","error","-y","-i",&xp,&wp]).status();d=dur(&wp);}
|
||||
let mv=dur(&format!("{disc}/dat/movie/{movie}.wmv"));
|
||||
println!("{movie:16} {src:8} {token:12} id={id} voice={d}s movie={mv}s");
|
||||
}
|
||||
}
|
||||
46
crates/sylpheed-formats/examples/manifest_map.rs
Normal file
46
crates/sylpheed-formats/examples/manifest_map.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
//! Validation: dump the parsed mission/phase cutscene map from the real manifest.
|
||||
use sylpheed_formats::movie_manifest::{self, MovieKind};
|
||||
use sylpheed_formats::pak::PakArchive;
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC");
|
||||
let arc = PakArchive::open(format!("{disc}/dat/tables.pak")).unwrap();
|
||||
let man = arc
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| arc.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
.expect("manifest");
|
||||
let entries = movie_manifest::parse(&man);
|
||||
let mut counts = [0usize; 5];
|
||||
for e in &entries {
|
||||
counts[match e.kind {
|
||||
MovieKind::System => 0,
|
||||
MovieKind::Intro => 1,
|
||||
MovieKind::Phase => 2,
|
||||
MovieKind::PhaseEnd => 3,
|
||||
MovieKind::Supply => 4,
|
||||
}] += 1;
|
||||
let m = e.mission.map(|x| x.to_string()).unwrap_or_default();
|
||||
let p = e.phase.map(|x| x.to_string()).unwrap_or_default();
|
||||
println!(
|
||||
"{:<22} {:<9} m={:<2} p={:<1} {:<16} {:<14} tel={:<14} sub={}",
|
||||
e.slot,
|
||||
format!("{:?}", e.kind),
|
||||
m,
|
||||
p,
|
||||
e.movie,
|
||||
e.voice_token.as_deref().unwrap_or("-"),
|
||||
e.telop.as_deref().unwrap_or("-"),
|
||||
e.subtitle.as_deref().unwrap_or("-"),
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
"TOTAL {} entries — System={} Intro={} Phase={} PhaseEnd={} Supply={}",
|
||||
entries.len(),
|
||||
counts[0],
|
||||
counts[1],
|
||||
counts[2],
|
||||
counts[3],
|
||||
counts[4]
|
||||
);
|
||||
}
|
||||
36
crates/sylpheed-formats/examples/resolve_all.rs
Normal file
36
crates/sylpheed-formats/examples/resolve_all.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use sylpheed_formats::{hash::name_hash, movie_manifest, 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 contains(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 manifest=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|movie_manifest::is_manifest(b))).unwrap();
|
||||
let code="eng";
|
||||
let registry=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|contains(b,format!("{code}\\Movie\\VOICE_ADV.slb").as_bytes()))).unwrap();
|
||||
let ids=movie_voice::registry_voice_ids(®istry);
|
||||
let stoc=fs::read(format!("{disc}/dat/sound.pak")).unwrap();
|
||||
let entries=PakArchive::parse_toc(&stoc).unwrap();
|
||||
for movie in ["ADV","RT01A","RT01B","RT01C_1","S00A","S01A","hokyu_LS_s02A","hokyu_LS_s09A","hokyu_LS_s02H","hokyu_DS_s07H"]{
|
||||
let Some(token)=movie_manifest::voice_token(&manifest,movie) else { println!("{movie:16} no token"); continue };
|
||||
let Some(&id)=ids.get(&token) else { println!("{movie:16} token {token} not in registry"); continue };
|
||||
let Some(anchor)=["Movie","etc","Voice"].iter().find_map(|d|{let h=name_hash(&format!("{code}\\{d}\\{token}.slb"));entries.binary_search_by_key(&h,|e|e.name_hash).ok().map(|i|entries[i].offset as u64)}) else { println!("{movie:16} no anchor for {token}"); continue };
|
||||
let win_start=(anchor.saturating_sub(2*1024*1024))&!3;
|
||||
let window=rg(&disc,win_start,8*1024*1024);
|
||||
let Some(end_local)=movie_voice::find_descriptor(&window,id) else { println!("{movie:16} id {id}: desc NOT FOUND"); continue };
|
||||
let end=win_start+end_local as u64;
|
||||
let start=movie_voice::find_descriptor(&window,id.wrapping_sub(1))
|
||||
.or_else(||movie_voice::find_descriptor_before(&window,end_local))
|
||||
.map(|o|win_start+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 riffs=slb::to_xma_riffs(®ion);
|
||||
if riffs.is_empty(){ riffs=slb::to_xma_riff_best(®ion).into_iter().collect(); }
|
||||
let mut d="?".into();
|
||||
if let Some(r)=riffs.first(){ let xp=format!("/tmp/ra2_{movie}.xma.wav");let wp=format!("/tmp/ra2_{movie}.wav");fs::write(&xp,r).unwrap();let _=Command::new("ffmpeg").args(["-hide_banner","-v","error","-y","-i",&xp,&wp]).status();d=dur(&wp);}
|
||||
let mv=dur(&format!("{disc}/dat/movie/{movie}.wmv"));
|
||||
println!("{movie:16} id={id:5} region={}KB riffs={} voice={d}s movie={mv}s", (end-start)/1024, riffs.len());
|
||||
}
|
||||
}
|
||||
32
crates/sylpheed-formats/examples/validate_cues.rs
Normal file
32
crates/sylpheed-formats/examples/validate_cues.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use sylpheed_formats::slb;
|
||||
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 dur(w:&str)->String{let o=Command::new("ffprobe").args(["-v","error","-show_entries","format=duration:stream=channels","-of","default=nw=1:nk=1",w]).output().unwrap();String::from_utf8_lossy(&o.stdout).replace('\n'," ")}
|
||||
fn movdur(disc:&str,m:&str)->String{ dur(&format!("{disc}/dat/movie/{m}")) }
|
||||
fn main(){
|
||||
let disc=std::env::var("SYLPHEED_DISC").unwrap();
|
||||
// descriptor trailer global offsets (from scan) => cue N data = [desc(N-1)..desc(N)]
|
||||
// (id, end_off, movie)
|
||||
let cues=[(1600u32,437044592u64,"ADV.wmv"),(1601,437345648,"RT01A.wmv"),(1602,437712240,"RT01B.wmv"),
|
||||
(1603,438080880,"RT01C_1.wmv"),(1604,438451568,"RT01C_2.wmv"),(1605,438789488,"RT02A.wmv")];
|
||||
for k in 1..cues.len(){
|
||||
let (id,end,mov)=cues[k];
|
||||
let start=cues[k-1].1;
|
||||
let region=rg(&disc,start,(end-start) as usize + 12000); // +tail to include full data before next trailer
|
||||
let riffs=slb::to_xma_riffs(®ion);
|
||||
let mut total=0.0f32; let mut parts=vec![];
|
||||
for (j,r) in riffs.iter().enumerate(){
|
||||
let xp=format!("/tmp/cue{id}_{j}.xma.wav"); let wp=format!("/tmp/cue{id}_{j}.wav");
|
||||
fs::write(&xp,r).unwrap();
|
||||
let _=Command::new("ffmpeg").args(["-hide_banner","-v","error","-y","-i",&xp,&wp]).status();
|
||||
let d=dur(&wp); parts.push(d.clone());
|
||||
total+=d.split_whitespace().next().unwrap_or("0").parse::<f32>().unwrap_or(0.0);
|
||||
}
|
||||
// spanning?
|
||||
let span_adv_end=437547264u64;
|
||||
let spans = start < span_adv_end && end > span_adv_end || (start/1_000 != end/1_000);
|
||||
println!("cue {id} ({mov}): region[{start}..{end}] {} bytes, {} riff(s), Σ={:.1}s | movie={} parts={:?}",
|
||||
end-start, riffs.len(), total, movdur(&disc,mov), parts);
|
||||
}
|
||||
println!("\nWAVs at /tmp/cue16XX_*.wav (listen: RT01B=1602, RT01C_1=1603, RT01C_2=1604)");
|
||||
}
|
||||
@@ -59,6 +59,8 @@ pub mod slb;
|
||||
// The movie manifest: authoritative movie → subtitle → voice index.
|
||||
pub mod movie_manifest;
|
||||
|
||||
pub mod movie_voice;
|
||||
|
||||
/// Re-export the most commonly used types at the crate root.
|
||||
pub use font::FontInfo;
|
||||
pub use idxd::{IdxdError, IdxdObject};
|
||||
|
||||
@@ -1,36 +1,81 @@
|
||||
//! The movie manifest: the game's authoritative movie → subtitle → **voice**
|
||||
//! index (statically reversed).
|
||||
//! The movie manifest: the game's authoritative **mission → phase → cutscene**
|
||||
//! table (statically reversed). Beyond the movie→voice binding, it defines the
|
||||
//! whole cutscene *structure*: which video (+ subtitle, + on-screen text overlay,
|
||||
//! + voice) plays at each mission phase, and of what kind (story intro, in-mission
|
||||
//! phase cutscene, resupply, …).
|
||||
//!
|
||||
//! `dat/tables.pak` holds one `IDXD` table (a `Z1`+zlib block) whose string pool
|
||||
//! lists every cutscene in play order. Each movie contributes a run of ASCII
|
||||
//! strings, always led by `<movie>.wmv` and optionally followed by:
|
||||
//! - `<pak>+….prt` — an overlay/print-art reference,
|
||||
//! - `<pak>+SUBTITLE_<movie>.tbl` — its caption timing track,
|
||||
//! - `VOICE_<token>` — the **voice track basename**.
|
||||
//! ## On-disc structure (`dat/tables.pak`, entry hash `0x5b983a08`)
|
||||
//!
|
||||
//! The voice token is the payoff: it is the real bank name to look up in
|
||||
//! `sounds.tbl`, and it is **not** always `VOICE_<movie>`. Most story/radio
|
||||
//! movies use `VOICE_<movie>` (in `<lang>\Movie\`), but several `hokyu_*`
|
||||
//! (resupply) movies bind to in-mission radio clips like `VOICE_D_450` (in
|
||||
//! `<lang>\etc\`), and many `hokyu_*` movies + the boot logos have **no** voice
|
||||
//! token at all — i.e. the game plays no voice-over for them. Guessing
|
||||
//! `VOICE_<movie>` therefore both misses the `VOICE_D_*` cases and invents a
|
||||
//! non-existent track for the silent ones; this manifest is the ground truth.
|
||||
//! One `IDXD` table (`Z1`+zlib block), schema `0x067025b9`. Unlike a flat IDXD
|
||||
//! object, this one is an **array of records**, and its string pool is laid out
|
||||
//! as **two consecutive arrays**:
|
||||
//! 1. the **slot KEYS** — `LOGO1`, `ADVERTISE_MOVIE`, `STAFF_ROLL`, then one
|
||||
//! per cutscene: `MS<NN><X>`, `STAGE<NN>_PHASE0<N>`, `STAGE<NN>_PHASE_END…`,
|
||||
//! `S<NN>_SUPPLY_<ACROPOLIS|TANKER>`, in play order;
|
||||
//! 2. the **value RECORDS**, in the same order — each a run led by `<movie>.wmv`
|
||||
//! and (optionally) a `<pak>+….prt` overlay (field `TELOP`), a
|
||||
//! `<pak>+SUBTITLE_<movie>.tbl` (field `SUBTITLE`), and a `VOICE_<token>`
|
||||
//! (field `VOICETRACK`). A few slot keys are **valueless** (e.g. stages 5 and
|
||||
//! 12 have no resupply video) — those slots have no record and are skipped.
|
||||
//!
|
||||
//! We read the manifest by grouping the string pool on `.wmv` (the pool is
|
||||
//! emitted in the same order as the records), which is robust without decoding
|
||||
//! the `IDXD` record structure.
|
||||
//! The **slot key is the semantic role** — this is how the game separates a
|
||||
//! cutscene voice from in-mission radio chatter (structurally, by which slot/bank,
|
||||
//! not one boolean): `MS*` = story intro (`VOICE_S*`), `STAGE*_PHASE*` = radio-box
|
||||
//! cutscene (`VOICE_RT*`), `S*_SUPPLY_*` = resupply (`VOICE_D_45x` or none).
|
||||
//! Resupply videos are **reused across stages** (`S04_SUPPLY_ACROPOLIS` →
|
||||
//! `hokyu_DS_s02A`), so the mapping is genuinely a table, not derivable from names.
|
||||
//!
|
||||
//! The voice token is likewise **not** always `VOICE_<movie>`: most movies use
|
||||
//! `VOICE_<movie>` (in `<lang>\Movie\`), but the resupply cutscenes bind to
|
||||
//! in-mission radio clips like `VOICE_D_450` (in `<lang>\etc\`), and some have no
|
||||
//! voice at all. This manifest is the ground truth.
|
||||
//!
|
||||
//! ## Alignment
|
||||
//!
|
||||
//! Slot keys and value records would be a 1:1 positional zip but for the valueless
|
||||
//! slots. We recover the gaps without decoding the binary node/index region by
|
||||
//! using the fully-derivable **`MS<NN><X>` → `S<NN><X>` anchors**: a non-`MS` slot
|
||||
//! whose record would be an `MS` target actually belongs to the upcoming `MS`
|
||||
//! slot, so the non-`MS` slot is a gap. (Decoding the node region would make this
|
||||
//! exact and also unlock the defaulted numeric fields in other IDXD tables.)
|
||||
|
||||
use crate::slb::VoiceLang;
|
||||
|
||||
/// One movie's manifest row.
|
||||
/// The role a cutscene slot plays in the mission flow, from its slot key.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MovieKind {
|
||||
/// `LOGO*`, `ADVERTISE_MOVIE`, `STAFF_ROLL` — boot/system movies.
|
||||
System,
|
||||
/// `MS<NN><X>` — story intro movie (camera on the speaker; stereo `VOICE_S*`).
|
||||
Intro,
|
||||
/// `STAGE<NN>_PHASE0<N>` — in-mission phase cutscene (radio box; `VOICE_RT*`).
|
||||
Phase,
|
||||
/// `STAGE<NN>_PHASE_END[_0N]` — phase-end cutscene.
|
||||
PhaseEnd,
|
||||
/// `S<NN>_SUPPLY_<ACROPOLIS|TANKER>` — resupply (hokyu) cutscene.
|
||||
Supply,
|
||||
}
|
||||
|
||||
/// One cutscene slot's manifest row: its semantic slot plus the bound assets.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MovieEntry {
|
||||
/// Movie basename without extension, e.g. `S13A`, `hokyu_DS_s13A`.
|
||||
/// The slot key, e.g. `STAGE01_PHASE01`, `MS00A`, `S02_SUPPLY_ACROPOLIS`.
|
||||
/// Empty when parsed from a blob without the slot-key array (fallback).
|
||||
pub slot: String,
|
||||
/// The slot's role in the mission flow (intro vs. phase cutscene vs. supply …).
|
||||
pub kind: MovieKind,
|
||||
/// Mission/stage number (`STAGE<NN>` / `MS<NN>` / `S<NN>_SUPPLY`), if any.
|
||||
pub mission: Option<u8>,
|
||||
/// Phase number for [`MovieKind::Phase`]/[`MovieKind::PhaseEnd`], if any.
|
||||
pub phase: Option<u8>,
|
||||
/// Movie basename without extension, e.g. `S13A`, `RT01A`, `hokyu_DS_s13A`.
|
||||
pub movie: String,
|
||||
/// Voice bank basename (e.g. `VOICE_S13A`, `VOICE_D_450`), or `None` when the
|
||||
/// game assigns no voice-over to this movie.
|
||||
/// Voice bank basename (`VOICE_S13A`, `VOICE_D_450`), or `None` for no voice.
|
||||
pub voice_token: Option<String>,
|
||||
/// Subtitle table basename (`SUBTITLE_S13A.tbl`), pak prefix stripped, if any.
|
||||
pub subtitle: Option<String>,
|
||||
/// On-screen text-overlay (`TELOP`) basename (`pwrt01.prt`), if any.
|
||||
pub telop: Option<String>,
|
||||
}
|
||||
|
||||
/// Does this blob look like the movie manifest? (`IDXD` whose string pool
|
||||
@@ -44,25 +89,154 @@ pub fn is_manifest(bytes: &[u8]) -> bool {
|
||||
&& contains(bytes, b"SUBTITLE_")
|
||||
}
|
||||
|
||||
/// Parse the manifest's string pool into per-movie rows, in play order.
|
||||
/// The field-type keys that appear once in the value region as a schema template.
|
||||
const FIELD_KEYS: [&str; 4] = ["MOVIE", "VOICETRACK", "SUBTITLE", "TELOP"];
|
||||
|
||||
/// Parse the manifest into per-slot cutscene rows, in play order.
|
||||
///
|
||||
/// Each returned [`MovieEntry`] carries a movie; valueless slots (e.g. stage 5's
|
||||
/// absent resupply) are omitted. When the two-array slot/record layout is present
|
||||
/// (the real manifest) every row is tagged with its authoritative slot/kind/
|
||||
/// mission/phase; otherwise (a blob without the key array) the kind is inferred
|
||||
/// from the movie name and `slot` is empty.
|
||||
pub fn parse(bytes: &[u8]) -> Vec<MovieEntry> {
|
||||
let mut entries: Vec<MovieEntry> = Vec::new();
|
||||
for run in ascii_runs(bytes, 3) {
|
||||
if let Some(name) = run.strip_suffix(".wmv") {
|
||||
entries.push(MovieEntry {
|
||||
let toks = ascii_runs(bytes, 3);
|
||||
|
||||
// Locate the slot-key array (`LOGO1` … first `<movie>.wmv`) and the value
|
||||
// region (from the first `.wmv` onward). Fall back to a keyless scrape.
|
||||
let first_wmv = toks.iter().position(|t| t.ends_with(".wmv"));
|
||||
let key_start = toks.iter().position(|t| t == "LOGO1");
|
||||
let (slot_keys, value_toks): (Vec<&str>, &[String]) = match (key_start, first_wmv) {
|
||||
(Some(k), Some(w)) if k < w => (
|
||||
toks[k..w].iter().map(String::as_str).collect(),
|
||||
&toks[w..],
|
||||
),
|
||||
_ => (Vec::new(), toks.as_slice()),
|
||||
};
|
||||
|
||||
// Build the value records (drop the schema-template field keys).
|
||||
let mut records: Vec<MovieEntry> = Vec::new();
|
||||
for t in value_toks.iter().filter(|t| !FIELD_KEYS.contains(&t.as_str())) {
|
||||
if let Some(name) = t.strip_suffix(".wmv") {
|
||||
records.push(MovieEntry {
|
||||
slot: String::new(),
|
||||
kind: kind_from_movie(name),
|
||||
mission: None,
|
||||
phase: None,
|
||||
movie: name.to_string(),
|
||||
voice_token: None,
|
||||
subtitle: None,
|
||||
telop: None,
|
||||
});
|
||||
} else if run.starts_with("VOICE_") {
|
||||
// Belongs to the movie record currently being built.
|
||||
if let Some(last) = entries.last_mut() {
|
||||
if last.voice_token.is_none() {
|
||||
last.voice_token = Some(run.clone());
|
||||
} else if let Some(rec) = records.last_mut() {
|
||||
if let Some(v) = t.strip_prefix("VOICE_") {
|
||||
if rec.voice_token.is_none() {
|
||||
rec.voice_token = Some(format!("VOICE_{v}"));
|
||||
}
|
||||
} else if t.ends_with(".tbl") {
|
||||
rec.subtitle.get_or_insert_with(|| after_plus(t));
|
||||
} else if t.ends_with(".prt") {
|
||||
rec.telop.get_or_insert_with(|| after_plus(t));
|
||||
}
|
||||
}
|
||||
}
|
||||
entries
|
||||
|
||||
// Attach slot keys to records, recovering valueless-slot gaps via MS anchors.
|
||||
if !slot_keys.is_empty() {
|
||||
let ms_targets: std::collections::HashSet<String> = slot_keys
|
||||
.iter()
|
||||
.filter_map(|k| ms_target(k))
|
||||
.collect();
|
||||
let mut j = 0usize;
|
||||
for slot in &slot_keys {
|
||||
if j >= records.len() {
|
||||
break;
|
||||
}
|
||||
// A gap: this slot's "record" is actually the next MS slot's movie.
|
||||
let would_be = &records[j].movie;
|
||||
let is_gap = match ms_target(slot) {
|
||||
// MS slot: only binds if its record is the expected `S<NN><X>`.
|
||||
Some(expected) => *would_be != expected,
|
||||
// Non-MS slot: a gap iff the record belongs to an upcoming MS slot.
|
||||
None => ms_targets.contains(would_be),
|
||||
};
|
||||
if is_gap {
|
||||
continue;
|
||||
}
|
||||
let (kind, mission, phase) = classify_slot(slot);
|
||||
let rec = &mut records[j];
|
||||
rec.slot = (*slot).to_string();
|
||||
rec.kind = kind;
|
||||
rec.mission = mission;
|
||||
rec.phase = phase;
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
/// If `slot` is an `MS<NN><X>` intro slot, the movie it must bind to (`S<NN><X>`).
|
||||
fn ms_target(slot: &str) -> Option<String> {
|
||||
let rest = slot.strip_prefix("MS")?;
|
||||
// `<NN><X>`: two digits then a letter suffix.
|
||||
if rest.len() >= 3 && rest[..2].bytes().all(|b| b.is_ascii_digit()) {
|
||||
Some(format!("S{rest}"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic role + mission/phase from a slot key.
|
||||
fn classify_slot(slot: &str) -> (MovieKind, Option<u8>, Option<u8>) {
|
||||
if let Some(rest) = slot.strip_prefix("MS") {
|
||||
if rest.len() >= 2 {
|
||||
return (MovieKind::Intro, rest[..2].parse().ok(), None);
|
||||
}
|
||||
}
|
||||
if let Some(rest) = slot.strip_prefix("STAGE") {
|
||||
let nn: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if let Ok(m) = nn.parse::<u8>() {
|
||||
if slot.contains("PHASE_END") {
|
||||
// trailing `_NN` if present (`STAGE12_PHASE_END_02`)
|
||||
let phase = slot.rsplit('_').next().and_then(|x| x.parse().ok());
|
||||
return (MovieKind::PhaseEnd, Some(m), phase);
|
||||
}
|
||||
// `STAGE<NN>_PHASE0<N>`
|
||||
let phase = rest[nn.len()..]
|
||||
.strip_prefix("_PHASE")
|
||||
.and_then(|p| p.parse().ok());
|
||||
return (MovieKind::Phase, Some(m), phase);
|
||||
}
|
||||
}
|
||||
if slot.contains("_SUPPLY") {
|
||||
if let Some(rest) = slot.strip_prefix('S') {
|
||||
let nn: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
return (MovieKind::Supply, nn.parse().ok(), None);
|
||||
}
|
||||
}
|
||||
(MovieKind::System, None, None)
|
||||
}
|
||||
|
||||
/// Fallback kind from a movie basename (used when no slot-key array is present).
|
||||
fn kind_from_movie(movie: &str) -> MovieKind {
|
||||
if movie.starts_with("hokyu_") {
|
||||
MovieKind::Supply
|
||||
} else if movie.starts_with("RT") {
|
||||
MovieKind::Phase
|
||||
} else if movie.len() >= 3
|
||||
&& movie.starts_with('S')
|
||||
&& movie[1..3].bytes().all(|b| b.is_ascii_digit())
|
||||
{
|
||||
MovieKind::Intro
|
||||
} else {
|
||||
MovieKind::System
|
||||
}
|
||||
}
|
||||
|
||||
/// The part of a `<pak>+<name>` reference after the `+` (or the whole string).
|
||||
fn after_plus(s: &str) -> String {
|
||||
s.rsplit('+').next().unwrap_or(s).to_string()
|
||||
}
|
||||
|
||||
/// The voice bank basename bound to `movie`, if the manifest lists one.
|
||||
@@ -151,12 +325,82 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn groups_movies_and_binds_voice() {
|
||||
// Keyless fallback path: movie + voice + subtitle + inferred kind.
|
||||
let m = parse(&synth());
|
||||
assert_eq!(m.len(), 3);
|
||||
assert_eq!(m[0].movie, "S13A");
|
||||
assert_eq!(m[0].voice_token.as_deref(), Some("VOICE_S13A"));
|
||||
assert_eq!(m[0].kind, MovieKind::Intro);
|
||||
assert_eq!(m[0].subtitle.as_deref(), Some("SUBTITLE_S13A.tbl"));
|
||||
assert_eq!(m[1].voice_token.as_deref(), Some("VOICE_D_450"));
|
||||
assert_eq!(m[1].kind, MovieKind::Supply);
|
||||
assert_eq!(m[2].voice_token, None, "hokyu_DS_s13A has no voice-over");
|
||||
assert_eq!(m[2].kind, MovieKind::Supply);
|
||||
}
|
||||
|
||||
/// Build a two-array (keys-then-records) manifest like the real one.
|
||||
fn synth_two_array(keys: &[&str], records: &[&[&str]]) -> Vec<u8> {
|
||||
let mut b = b"IDXD".to_vec();
|
||||
b.extend_from_slice(&[0, 0, 0, 0x05, 0, 0]);
|
||||
for k in keys {
|
||||
b.extend_from_slice(k.as_bytes());
|
||||
b.push(0);
|
||||
}
|
||||
for rec in records {
|
||||
for s in *rec {
|
||||
b.extend_from_slice(s.as_bytes());
|
||||
b.push(0);
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_array_slots_kind_mission_phase() {
|
||||
let blob = synth_two_array(
|
||||
&["LOGO1", "MS01A", "STAGE01_PHASE01", "STAGE01_PHASE_END_02"],
|
||||
&[
|
||||
&["logo1.wmv", "MOVIE"],
|
||||
&["S01A.wmv", "eng.pak+SUBTITLE_S01A.tbl", "VOICE_S01A"],
|
||||
&["RT01A.wmv", "eng.pak+pwrt01.prt", "VOICE_RT01A", "VOICETRACK"],
|
||||
&["RT01C_2.wmv", "VOICE_RT01C_2"],
|
||||
],
|
||||
);
|
||||
let m = parse(&blob);
|
||||
assert_eq!(m.len(), 4);
|
||||
assert_eq!((m[0].slot.as_str(), m[0].kind), ("LOGO1", MovieKind::System));
|
||||
assert_eq!(
|
||||
(m[1].slot.as_str(), m[1].kind, m[1].mission),
|
||||
("MS01A", MovieKind::Intro, Some(1))
|
||||
);
|
||||
assert_eq!(
|
||||
(m[2].slot.as_str(), m[2].kind, m[2].mission, m[2].phase),
|
||||
("STAGE01_PHASE01", MovieKind::Phase, Some(1), Some(1))
|
||||
);
|
||||
assert_eq!(m[2].telop.as_deref(), Some("pwrt01.prt"));
|
||||
assert_eq!(
|
||||
(m[3].slot.as_str(), m[3].kind, m[3].phase),
|
||||
("STAGE01_PHASE_END_02", MovieKind::PhaseEnd, Some(2))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valueless_supply_slot_is_skipped_via_ms_anchor() {
|
||||
// S05_SUPPLY has no record; its "record" (S06A) belongs to the next MS slot.
|
||||
let blob = synth_two_array(
|
||||
&["LOGO1", "MS05A", "S05_SUPPLY_ACROPOLIS", "MS06A"],
|
||||
&[
|
||||
&["logo1.wmv"],
|
||||
&["S05A.wmv", "VOICE_S05A"],
|
||||
&["S06A.wmv", "VOICE_S06A"],
|
||||
],
|
||||
);
|
||||
let m = parse(&blob);
|
||||
assert_eq!(m.len(), 3, "the valueless supply slot yields no entry");
|
||||
assert_eq!((m[1].slot.as_str(), m[1].movie.as_str()), ("MS05A", "S05A"));
|
||||
// S06A binds to MS06A, NOT to the intervening valueless supply slot.
|
||||
assert_eq!((m[2].slot.as_str(), m[2].movie.as_str()), ("MS06A", "S06A"));
|
||||
assert!(m.iter().all(|e| e.slot != "S05_SUPPLY_ACROPOLIS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -162,6 +162,43 @@ pub fn track_demo_ids(lang_pak: &PakArchive, movie_basename: &str) -> Vec<u32> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Per-line `(demo id, start seconds)` for a movie's timing track — the radio-box
|
||||
/// voice schedule. Each `MSG_DEMO_<id>` reference takes the start time of the
|
||||
/// timing that follows it (a caption may list two demo lines under one timing;
|
||||
/// both then share that start). Empty for inline-text / absent tracks (those are
|
||||
/// story movies whose voice is the single manifest `VOICETRACK`, not per-line).
|
||||
pub fn track_voice_cues(lang_pak: &PakArchive, movie_basename: &str) -> Vec<(u32, f32)> {
|
||||
let Some(Ok(track)) = lang_pak.read_by_hash(track_key(movie_basename)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !is_ixud(&track) {
|
||||
return Vec::new();
|
||||
}
|
||||
let toks = utf16le_tokens(&track);
|
||||
let start = toks
|
||||
.iter()
|
||||
.position(|t| t.ends_with("SUBTITLE"))
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(0);
|
||||
let mut out = Vec::new();
|
||||
let mut pending: Vec<u32> = Vec::new();
|
||||
for tok in &toks[start..] {
|
||||
match parse_timing(tok) {
|
||||
Some((s, _)) => {
|
||||
for d in pending.drain(..) {
|
||||
out.push((d, s));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let Some(d) = demo_ref(tok) {
|
||||
pending.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Build `demo id → ordered caption lines` from a `GP_MAIN_GAME_<L>` pack.
|
||||
///
|
||||
/// Scans every IXUD block, pairing each text token with the immediately
|
||||
|
||||
142
crates/sylpheed-formats/src/movie_voice.rs
Normal file
142
crates/sylpheed-formats/src/movie_voice.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
//! Cutscene-voice resolution: movie → sound-id → continuous-stream byte region.
|
||||
//!
|
||||
//! Reverse-engineered 2026-07-22 (Canary file-I/O trace, user-confirmed by ear).
|
||||
//! The movie voices form **one continuous XMA stream**, physically chunked into
|
||||
//! `<lang>\Movie\VOICE_*.slb` `sound.pak` TOC entries whose boundaries do **not**
|
||||
//! align with the cutscene cues — a single cue routinely spans two `.slb` chunks,
|
||||
//! so a `.slb` does not necessarily hold the track its name claims. Each cue's
|
||||
//! audio ends at an inline **trailer descriptor** `(sound_id: u32be, 0x11, …)`
|
||||
//! whose id repeats at +0x800. Cue N's audio = the stream bytes
|
||||
//! `[descriptor(N-1) .. descriptor(N)]`, read continuously across chunk
|
||||
//! boundaries. Verified: decoded voice length matches each movie within ~0.3 s,
|
||||
//! including cross-boundary cues (RT01B/RT01C span `VOICE_ADV`→`VOICE_RT01A`→…).
|
||||
//!
|
||||
//! Resolution chain:
|
||||
//! 1. movie → cue name (`ADVERTISE_MOVIE` manifest `VOICETRACK`, e.g. `VOICE_RT01A`)
|
||||
//! 2. cue name → sound-id (master sound registry, [`registry_voice_ids`], → 1601)
|
||||
//! 3. sound-id → `[start, end]` global offsets via [`find_descriptor`] over the
|
||||
//! `sound.pNN` stream (the caller supplies the bytes + the physical anchor).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Parse the master sound registry — the large per-language `tables.pak` IDXD
|
||||
/// entry (schema `0x13cb84ba`) that carries the `<lang>\Movie\VOICE_*.slb` paths —
|
||||
/// into `cue-name → sound-id` from its `NAME\0 ID\0` string pool
|
||||
/// (e.g. `VOICE_ADV`→1600, `VOICE_RT01A`→1601).
|
||||
pub fn registry_voice_ids(registry: &[u8]) -> HashMap<String, u32> {
|
||||
let mut toks: Vec<&[u8]> = Vec::new();
|
||||
let mut start = 0usize;
|
||||
for i in 0..registry.len() {
|
||||
if registry[i] == 0 {
|
||||
if i > start {
|
||||
toks.push(®istry[start..i]);
|
||||
}
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
let mut out = HashMap::new();
|
||||
for w in toks.windows(2) {
|
||||
let (name, num) = (w[0], w[1]);
|
||||
if name.starts_with(b"VOICE_")
|
||||
&& !num.is_empty()
|
||||
&& num.iter().all(u8::is_ascii_digit)
|
||||
{
|
||||
if let (Ok(n), Ok(id)) = (
|
||||
std::str::from_utf8(name),
|
||||
std::str::from_utf8(num).unwrap().parse::<u32>(),
|
||||
) {
|
||||
out.entry(n.to_string()).or_insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The constant marker following a cue's sound-id in its inline trailer.
|
||||
const DESC_MARK: u32 = 0x11;
|
||||
/// The trailer repeats its id this many bytes later; used to reject a coincidental
|
||||
/// `(id, 0x11)` pair inside XMA audio (probability of a false match is ~2⁻⁶⁴).
|
||||
const DESC_REPEAT: usize = 0x800;
|
||||
|
||||
/// Byte offset (within `buf`, a slice of the continuous voice stream) of cue
|
||||
/// `id`'s trailer descriptor: the first big-endian `(id, 0x11)` pair whose id
|
||||
/// repeats at `+0x800`. `None` if absent in the slice.
|
||||
pub fn find_descriptor(buf: &[u8], id: u32) -> Option<usize> {
|
||||
if buf.len() < DESC_REPEAT + 4 {
|
||||
return None;
|
||||
}
|
||||
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
|
||||
let end = buf.len() - (DESC_REPEAT + 4);
|
||||
let mut o = 0;
|
||||
while o <= end {
|
||||
if be(o) == id && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
|
||||
return Some(o);
|
||||
}
|
||||
o += 4;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Plausible sound-id range for a trailer (all real cue ids are well under this).
|
||||
const ID_MAX: u32 = 0x1_0000;
|
||||
|
||||
/// Offset of the nearest cue trailer strictly **before** `before` — any plausible
|
||||
/// id. Used to bound a cue's START when its `id-1` predecessor doesn't exist
|
||||
/// (the sound-id sequence has gaps, e.g. `VOICE_D_453`=7142 → `VOICE_D_454`=7188).
|
||||
/// Scans downward and returns the first (largest-offset) valid `(id, 0x11)` with
|
||||
/// the `+0x800` id-repeat. `None` if there is no trailer below `before`.
|
||||
pub fn find_descriptor_before(buf: &[u8], before: usize) -> Option<usize> {
|
||||
if before < 4 {
|
||||
return None;
|
||||
}
|
||||
let cap = buf.len().checked_sub(DESC_REPEAT + 4)?;
|
||||
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
|
||||
// Start strictly below `before` (4-aligned) so the target's own trailer is
|
||||
// never returned as its predecessor.
|
||||
let mut o = (before - 1).min(cap) & !3;
|
||||
loop {
|
||||
let id = be(o);
|
||||
if id >= 1 && id < ID_MAX && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
|
||||
return Some(o);
|
||||
}
|
||||
if o < 4 {
|
||||
return None;
|
||||
}
|
||||
o -= 4;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn registry_parses_name_id_pairs() {
|
||||
let mut b = Vec::new();
|
||||
for tok in [
|
||||
b"VOICE_ADV".as_slice(),
|
||||
b"1600",
|
||||
b"VOICE_RT01A",
|
||||
b"1601",
|
||||
b"NOT_A_VOICE",
|
||||
b"9",
|
||||
] {
|
||||
b.extend_from_slice(tok);
|
||||
b.push(0);
|
||||
}
|
||||
let ids = registry_voice_ids(&b);
|
||||
assert_eq!(ids.get("VOICE_ADV"), Some(&1600));
|
||||
assert_eq!(ids.get("VOICE_RT01A"), Some(&1601));
|
||||
assert_eq!(ids.get("NOT_A_VOICE"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_descriptor_matches_repeat() {
|
||||
let mut b = vec![0u8; 0x900];
|
||||
b[0..4].copy_from_slice(&1601u32.to_be_bytes());
|
||||
b[4..8].copy_from_slice(&0x11u32.to_be_bytes());
|
||||
b[DESC_REPEAT..DESC_REPEAT + 4].copy_from_slice(&1601u32.to_be_bytes());
|
||||
assert_eq!(find_descriptor(&b, 1601), Some(0));
|
||||
assert_eq!(find_descriptor(&b, 1600), None);
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,12 @@ pub fn list_voice_clips(sounds_tbl: &[u8], lang: VoiceLang) -> Vec<VoiceClip> {
|
||||
}
|
||||
if i - start >= 6 {
|
||||
if let Ok(s) = std::str::from_utf8(&sounds_tbl[start..i]) {
|
||||
if s.starts_with(&prefix) && s.ends_with(".slb") && s.contains("VOICE") {
|
||||
// Every spoken-line category, so the standalone player covers them
|
||||
// all: in-mission radio (`\Voice\`, `\etc\`) and bound movie voices
|
||||
// (`\Movie\`) all carry `VOICE_`; mission-briefing lines live in
|
||||
// `\Briefing\` as `BR<NN>_<MM>.slb` (no `VOICE` in the name).
|
||||
let is_voice = s.contains("VOICE") || s.contains("\\Briefing\\");
|
||||
if s.starts_with(&prefix) && s.ends_with(".slb") && is_voice {
|
||||
if seen.insert(s.to_string()) {
|
||||
out.push(parse_voice_clip(s));
|
||||
}
|
||||
@@ -113,8 +118,58 @@ fn parse_voice_clip(name: &str) -> VoiceClip {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build one standalone XMA1 `RIFF/WAVE` **per sub-wave** in a `.slb` bank, in
|
||||
/// on-disc order. A `.slb` is an XACT bank of one or more sub-waves; the sub-waves
|
||||
/// are EITHER alternate takes (the first is the whole track — e.g. `VOICE_S01A`,
|
||||
/// `VOICE_ADV`: sub0 ≈ the movie length) OR sequential segments that must be
|
||||
/// concatenated (e.g. `VOICE_RT07A`: 24s + 14s + 11s ≈ the 50s movie). The caller
|
||||
/// decodes each to PCM, concatenates in order, and clamps to the movie length —
|
||||
/// that yields the full track for segment banks while the clamp drops the
|
||||
/// duplicate takes for alternate-take banks. (Dynamic RE via Canary file-I/O
|
||||
/// tracing confirmed the movie→voice binding; this fixes the *decode* of `RT*`.)
|
||||
pub fn to_xma_riffs(slb: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
if find(slb, b"RIFF", 0).is_none() {
|
||||
// Headerless single-stream bank.
|
||||
if let Some(data) = slb.get(HEADERLESS_DATA_OFFSET..) {
|
||||
if !data.is_empty() {
|
||||
out.push(build_riff(&synth_xma1_fmt(2, 2, 48000), data));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let mut pos = 0usize;
|
||||
while let Some(ri) = find(slb, b"RIFF", pos) {
|
||||
// Parse this sub-wave's fmt + data (declared size is honest per sub-wave).
|
||||
let Some(fi) = find(slb, b"fmt ", ri) else { break };
|
||||
let Some(fsz) = le32(slb, fi + 4) else { break };
|
||||
let Some(fmt_end) = fi.checked_add(8).and_then(|v| v.checked_add(fsz as usize)) else {
|
||||
break;
|
||||
};
|
||||
if fmt_end > slb.len() {
|
||||
break;
|
||||
}
|
||||
let Some(di) = find(slb, b"data", fi) else { break };
|
||||
let Some(dsz) = le32(slb, di + 4) else { break };
|
||||
let Some(ds) = di.checked_add(8) else { break };
|
||||
let de = ds
|
||||
.checked_add(dsz as usize)
|
||||
.unwrap_or(slb.len())
|
||||
.min(slb.len());
|
||||
if let Some(data) = slb.get(ds..de) {
|
||||
if !data.is_empty() {
|
||||
out.push(build_riff(&slb[fi..fmt_end], data));
|
||||
}
|
||||
}
|
||||
// Advance past this sub-wave's data to find the next RIFF.
|
||||
pos = de.max(ri + 4);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a standalone XMA1 `RIFF/WAVE` from a `.slb` bank, decodable by FFmpeg's
|
||||
/// `xma1`. Returns `None` if the bank is too small / malformed.
|
||||
/// `xma1`. Returns `None` if the bank is too small / malformed. This is the
|
||||
/// FIRST sub-wave only; prefer [`to_xma_riffs`] for correct multi-segment banks.
|
||||
pub fn to_xma_riff(slb: &[u8]) -> Option<Vec<u8>> {
|
||||
if let Some(ri) = find(slb, b"RIFF", 0) {
|
||||
// RIFF layout: a `.slb` is an XACT bank of one or more sub-waves, each
|
||||
@@ -145,6 +200,38 @@ pub fn to_xma_riff(slb: &[u8]) -> Option<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild a standalone XMA1 `RIFF/WAVE` from a single-stream `.slb`, robust to
|
||||
/// the layout variants seen in `<lang>\etc\` radio clips. Unlike [`to_xma_riff`]
|
||||
/// (which assumes `RIFF → fmt → data` in order), this picks the **largest `data`
|
||||
/// chunk anywhere** in the bank — some radio banks store the audio *before* the
|
||||
/// trailing `RIFF`/`fmt` metadata (an empty post-`RIFF` `data` chunk), which the
|
||||
/// ordered scan misses. Pairs it with the first `fmt ` chunk; falls back to the
|
||||
/// headerless layout. Returns `None` only when no usable audio can be found.
|
||||
pub fn to_xma_riff_best(slb: &[u8]) -> Option<Vec<u8>> {
|
||||
// Largest usable `data` chunk (bounded by its declared size and the buffer).
|
||||
let mut best: Option<(usize, usize)> = None; // (data offset, usable payload len)
|
||||
let mut i = 0;
|
||||
while let Some(di) = find(slb, b"data", i) {
|
||||
let declared = le32(slb, di + 4).unwrap_or(0) as usize;
|
||||
let usable = declared.min(slb.len().saturating_sub(di + 8));
|
||||
if best.map_or(true, |(_, b)| usable > b) {
|
||||
best = Some((di, usable));
|
||||
}
|
||||
i = di + 4;
|
||||
}
|
||||
if let (Some(fi), Some((di, sz))) = (find(slb, b"fmt ", 0), best) {
|
||||
if sz > 512 {
|
||||
let fsz = le32(slb, fi + 4)? as usize;
|
||||
let fmt_end = (fi + 8 + fsz).min(slb.len());
|
||||
let data = slb.get(di + 8..di + 8 + sz)?;
|
||||
return Some(build_riff(slb.get(fi..fmt_end)?, data));
|
||||
}
|
||||
}
|
||||
// Headerless fallback: fixed data offset, synthesized XMA1 stereo/48k fmt.
|
||||
let data = slb.get(HEADERLESS_DATA_OFFSET..)?;
|
||||
(!data.is_empty()).then(|| build_riff(&synth_xma1_fmt(2, 2, 48000), data))
|
||||
}
|
||||
|
||||
/// A minimal `fmt ` chunk carrying an XMA1 `XMAWAVEFORMAT` (one stream).
|
||||
fn synth_xma1_fmt(channels: u8, channel_mask: u16, rate: u32) -> Vec<u8> {
|
||||
let mut fmt = Vec::with_capacity(40);
|
||||
@@ -235,6 +322,40 @@ mod tests {
|
||||
assert_eq!(&riff[dpos + 8..], &[1, 2, 3, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_voice_clips_covers_movie_radio_and_briefing() {
|
||||
// The standalone player must enumerate every spoken-line category: bound
|
||||
// movie voices, in-mission radio (\etc\ + \Voice\), and briefing (\Briefing\,
|
||||
// whose BR<NN>_<MM> names lack "VOICE"). Music (BGM_*) must stay excluded.
|
||||
let mut tbl = Vec::new();
|
||||
for s in [
|
||||
"eng\\Movie\\VOICE_S13A.slb",
|
||||
"eng\\etc\\VOICE_D_450.slb",
|
||||
"eng\\Voice\\VOICE_ADAN_010.slb",
|
||||
"eng\\Briefing\\BR01_01.slb",
|
||||
"eng\\bgm\\BGM_001.slb",
|
||||
] {
|
||||
tbl.extend_from_slice(s.as_bytes());
|
||||
tbl.push(0);
|
||||
}
|
||||
let names: Vec<String> = list_voice_clips(&tbl, VoiceLang::English)
|
||||
.into_iter()
|
||||
.map(|c| c.name)
|
||||
.collect();
|
||||
for want in [
|
||||
"eng\\Movie\\VOICE_S13A.slb",
|
||||
"eng\\etc\\VOICE_D_450.slb",
|
||||
"eng\\Voice\\VOICE_ADAN_010.slb",
|
||||
"eng\\Briefing\\BR01_01.slb",
|
||||
] {
|
||||
assert!(names.iter().any(|n| n == want), "missing {want}");
|
||||
}
|
||||
assert!(
|
||||
!names.iter().any(|n| n.contains("BGM_")),
|
||||
"music must not be listed as a voice clip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuilds_riff_from_headerless() {
|
||||
let mut slb = vec![0u8; HEADERLESS_DATA_OFFSET];
|
||||
|
||||
@@ -1698,6 +1698,10 @@ fn poll_loader_channel(
|
||||
browser.files = files;
|
||||
browser.selected = None;
|
||||
browser.filter.clear();
|
||||
// A new game source is loaded — invalidate the voice library so the
|
||||
// browser re-reads sounds.tbl from THIS source next time it opens
|
||||
// (otherwise a stale/failed empty result from before load persists).
|
||||
*voice_lib = VoiceLibrary::default();
|
||||
info!("Loaded {} files", browser.files.len());
|
||||
}
|
||||
Ok(IsoLoaderMsg::FileLoaded { path, bytes }) => {
|
||||
@@ -1748,9 +1752,13 @@ fn poll_loader_channel(
|
||||
generation,
|
||||
wav,
|
||||
}) => {
|
||||
if generation == mvoice.generation
|
||||
&& mvoice.movie.as_deref() == Some(movie.as_str())
|
||||
{
|
||||
let accept = generation == mvoice.generation
|
||||
&& mvoice.movie.as_deref() == Some(movie.as_str());
|
||||
info!(
|
||||
"[voice] loaded movie={movie:?} gen={generation} (cur movie={:?} gen={}) accept={accept} wav={wav:?}",
|
||||
mvoice.movie, mvoice.generation
|
||||
);
|
||||
if accept {
|
||||
mvoice.loading = false;
|
||||
mvoice.available = wav.is_some();
|
||||
mvoice.pending_wav = wav;
|
||||
@@ -1773,9 +1781,17 @@ fn poll_loader_channel(
|
||||
}
|
||||
}
|
||||
Ok(IsoLoaderMsg::VoiceLibraryLoaded { clips }) => {
|
||||
voice_lib.clips = clips;
|
||||
voice_lib.loaded = true;
|
||||
voice_lib.loading = false;
|
||||
// sounds.tbl always has thousands of clips, so an empty result means
|
||||
// the read failed (e.g. no game source, or opened only a bare pak).
|
||||
// Leave `loaded=false` so re-opening the menu retries instead of
|
||||
// latching an empty list forever.
|
||||
if clips.is_empty() {
|
||||
voice_lib.loaded = false;
|
||||
} else {
|
||||
voice_lib.clips = clips;
|
||||
voice_lib.loaded = true;
|
||||
}
|
||||
}
|
||||
Ok(IsoLoaderMsg::Cancelled) => {
|
||||
iso_state.loading = false;
|
||||
@@ -3128,28 +3144,63 @@ fn decode_sound_clip(
|
||||
use sylpheed_formats::{hash::name_hash, slb};
|
||||
let key = name_hash(clip_name);
|
||||
let bytes = read_sound_entry(source, key)?;
|
||||
let riff = slb::to_xma_riff(&bytes).ok_or("not a decodable .slb")?;
|
||||
// A `.slb` is an XACT bank of one or more sub-waves. Decode EVERY sub-wave and
|
||||
// concatenate them, then clamp to the movie length. This is robust to both bank
|
||||
// shapes we see: segment banks (e.g. VOICE_RT07A = 3 sub-waves 24+14+11s ≈ the
|
||||
// 50s movie) yield the full dialogue, while the clamp drops the extra ALTERNATE
|
||||
// takes of full-length banks (e.g. VOICE_S00A, whose sub-wave 0 already spans
|
||||
// the 94s movie). Playing only sub-wave 0 (the old behaviour) dropped 2/3 of the
|
||||
// dialogue for segment banks — the "RT voice wrong" bug. Verified vs real durations.
|
||||
let mut riffs = slb::to_xma_riffs(&bytes);
|
||||
if riffs.is_empty() {
|
||||
// Some banks (data-before-header `\etc\` radio clips) confuse the
|
||||
// multi-sub-wave scanner; the robust single-stream decoder handles them,
|
||||
// so the standalone browser can still play them.
|
||||
riffs = slb::to_xma_riff_best(&bytes).into_iter().collect();
|
||||
}
|
||||
decode_riffs_to_wav(riffs, &format!("{key:08x}"), duration)
|
||||
}
|
||||
|
||||
/// Decode a set of XMA sub-wave RIFFs into a single mono WAV: concatenate them,
|
||||
/// downmix to mono (left channel holds the signal for dual-mono sources), and
|
||||
/// clamp to `duration` seconds. Shared by the per-`.slb` decoder and the
|
||||
/// continuous movie-voice decoder.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn decode_riffs_to_wav(riffs: Vec<Vec<u8>>, tag: &str, duration: f32) -> Result<PathBuf, String> {
|
||||
if riffs.is_empty() {
|
||||
return Err("no decodable audio".into());
|
||||
}
|
||||
let dir = std::env::temp_dir();
|
||||
let xma_path = dir.join(format!("sylph_voice_{key:08x}.xma.wav"));
|
||||
let out_path = dir.join(format!("sylph_voice_{key:08x}.wav"));
|
||||
std::fs::write(&xma_path, &riff).map_err(|e| e.to_string())?;
|
||||
let out_path = dir.join(format!("sylph_voice_{tag}.wav"));
|
||||
let mut inputs = Vec::with_capacity(riffs.len());
|
||||
for (i, riff) in riffs.iter().enumerate() {
|
||||
let p = dir.join(format!("sylph_voice_{tag}_{i}.xma.wav"));
|
||||
std::fs::write(&p, riff).map_err(|e| e.to_string())?;
|
||||
inputs.push(p);
|
||||
}
|
||||
|
||||
let dur = if duration.is_finite() && duration > 0.0 {
|
||||
duration
|
||||
} else {
|
||||
600.0
|
||||
};
|
||||
let status = Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-y", "-i"])
|
||||
.arg(&xma_path)
|
||||
// Downmix to mono using the left channel (holds the full signal for both
|
||||
// left-only and dual-mono sources), then clamp to the media length.
|
||||
.args(["-af", "pan=mono|c0=c0", "-t"])
|
||||
let filter = format!(
|
||||
"{}concat=n={}:v=0:a=1,pan=mono|c0=c0[a]",
|
||||
(0..inputs.len()).map(|i| format!("[{i}:a]")).collect::<String>(),
|
||||
inputs.len(),
|
||||
);
|
||||
let mut cmd = Command::new("ffmpeg");
|
||||
cmd.args(["-hide_banner", "-y"]);
|
||||
for p in &inputs {
|
||||
cmd.arg("-i").arg(p);
|
||||
}
|
||||
cmd.args(["-filter_complex", &filter, "-map", "[a]", "-t"])
|
||||
.arg(format!("{dur}"))
|
||||
.arg(&out_path)
|
||||
.output();
|
||||
let _ = std::fs::remove_file(&xma_path);
|
||||
.arg(&out_path);
|
||||
let status = cmd.output();
|
||||
for p in &inputs {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
match status {
|
||||
Ok(o) if o.status.success() && out_path.metadata().map(|m| m.len() > 44).unwrap_or(false) => {
|
||||
Ok(out_path)
|
||||
@@ -3162,6 +3213,115 @@ fn decode_sound_clip(
|
||||
}
|
||||
}
|
||||
|
||||
/// Categorical fallback voice token for a hokyu (resupply) cutscene the manifest
|
||||
/// leaves unbound. Only 5 of the 18 hokyu movies carry an explicit `VOICETRACK`;
|
||||
/// the game reuses those 5 generic recordings across stages, keyed by
|
||||
/// ship (`LS`/`DS`) × resupply source (carrier `…A` / tanker `…H`). The four
|
||||
/// category→recording pairs are read off the bound examples; `LS`/carrier has two
|
||||
/// takes (450 from s02A, 451 from s09A) — we default unbound LS/carrier to 450.
|
||||
/// Returns `None` for non-hokyu movies (they resolve via the manifest only).
|
||||
fn hokyu_fallback_token(movie: &str) -> Option<String> {
|
||||
if !movie.starts_with("hokyu_") {
|
||||
return None;
|
||||
}
|
||||
let ls = movie.contains("_LS_");
|
||||
let ds = movie.contains("_DS_");
|
||||
let carrier = movie.ends_with('A');
|
||||
let tanker = movie.ends_with('H');
|
||||
let token = match (ls, ds, carrier, tanker) {
|
||||
(true, _, true, _) => "VOICE_D_450", // light ship, carrier resupply
|
||||
(true, _, _, true) => "VOICE_D_453", // light ship, tanker resupply
|
||||
(_, true, true, _) => "VOICE_D_452", // Delta Saber, carrier resupply
|
||||
(_, true, _, true) => "VOICE_D_454", // Delta Saber, tanker resupply
|
||||
_ => return None,
|
||||
};
|
||||
Some(token.to_string())
|
||||
}
|
||||
|
||||
/// Resolve a movie's cutscene voice to a **continuous byte region** of the
|
||||
/// `sound.pNN` voice stream, in `[start, end)` global offsets.
|
||||
///
|
||||
/// The movie voices are one continuous XMA stream chunked into `VOICE_*.slb` TOC
|
||||
/// entries whose boundaries do NOT match the cutscene cues (a cue routinely spans
|
||||
/// two `.slb` chunks — so a `.slb` need not hold the track its name claims). Each
|
||||
/// cue ends at an inline `(sound_id, 0x11, …)` trailer; cue N = the bytes between
|
||||
/// trailer N-1 and trailer N. Chain: movie → cue name (manifest VOICETRACK) →
|
||||
/// sound-id (master registry) → region (scan the stream for the two trailers).
|
||||
/// Returns `None` for movies whose voice is not a `\Movie\` bank (e.g. hokyu
|
||||
/// `\etc\` clips), which the caller then resolves the legacy per-clip way.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn resolve_movie_voice_region(
|
||||
source: &SourceKind,
|
||||
movie: &str,
|
||||
lang: sylpheed_formats::slb::VoiceLang,
|
||||
) -> Option<(u64, u64)> {
|
||||
use sylpheed_formats::{hash::name_hash, movie_manifest, movie_voice, PakArchive};
|
||||
let code = lang.code_pub();
|
||||
let tpak = read_pak_archive_blocking(source, "dat/tables.pak").ok()?;
|
||||
// movie → cue token (e.g. "VOICE_RT01A")
|
||||
let manifest = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))?;
|
||||
let token = movie_manifest::voice_token(&manifest, movie)
|
||||
.or_else(|| hokyu_fallback_token(movie))?;
|
||||
// token → sound-id via the master registry (the large per-language IDXD entry
|
||||
// carrying the `<lang>\Movie\VOICE_*.slb` paths).
|
||||
let marker = format!("{code}\\Movie\\VOICE_ADV.slb");
|
||||
let registry = tpak.entries().iter().find_map(|e| {
|
||||
tpak.read(e)
|
||||
.ok()
|
||||
.filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
|
||||
})?;
|
||||
let id = *movie_voice::registry_voice_ids(®istry).get(&token)?;
|
||||
// physical anchor: the TOC offset of this token's own `.slb` chunk — a start
|
||||
// point near the cue's trailers (the cue itself may sit before/after it). The
|
||||
// token's subdir varies: `Movie` (ADV/RT/S cutscenes) or `etc` (hokyu VOICE_D).
|
||||
let stoc = read_source_file(source, "dat/sound.pak").ok()?;
|
||||
let entries = PakArchive::parse_toc(&stoc).ok()?;
|
||||
let anchor = ["Movie", "etc", "Voice"].iter().find_map(|dir| {
|
||||
let h = name_hash(&format!("{code}\\{dir}\\{token}.slb"));
|
||||
entries
|
||||
.binary_search_by_key(&h, |e| e.name_hash)
|
||||
.ok()
|
||||
.map(|i| entries[i].offset as u64)
|
||||
})?;
|
||||
// Scan a window spanning both directions from the anchor for this cue's trailer
|
||||
// and its predecessor. The window must cover the largest bank (ADV ≈ 3.6 MB).
|
||||
let win_start = anchor.saturating_sub(2 * 1024 * 1024) & !3;
|
||||
let window = read_segment_range(source, "dat/sound", win_start, 8 * 1024 * 1024).ok()?;
|
||||
let end_local = movie_voice::find_descriptor(&window, id)?;
|
||||
let end = win_start + end_local as u64;
|
||||
// Cue start = its predecessor trailer. Prefer the exact `id-1` trailer; if the
|
||||
// id sequence has a gap (e.g. VOICE_D_453→454), fall back to the nearest trailer
|
||||
// below this one — but only if it's within one bank (~1.5 MB), else this is the
|
||||
// first cue in its block and the audio starts at the bank anchor itself.
|
||||
let start = movie_voice::find_descriptor(&window, id.wrapping_sub(1))
|
||||
.or_else(|| movie_voice::find_descriptor_before(&window, end_local))
|
||||
.map(|o| win_start + o as u64)
|
||||
.filter(|&s| s < end && end - s < 1_500_000)
|
||||
.unwrap_or(anchor);
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
/// Decode a continuous movie-voice byte region (from [`resolve_movie_voice_region`])
|
||||
/// into a mono WAV, clamped to `duration`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn decode_voice_region(
|
||||
source: &SourceKind,
|
||||
start: u64,
|
||||
end: u64,
|
||||
duration: f32,
|
||||
) -> Result<PathBuf, String> {
|
||||
use sylpheed_formats::slb;
|
||||
let bytes = read_segment_range(source, "dat/sound", start, (end - start) as usize)?;
|
||||
let mut riffs = slb::to_xma_riffs(&bytes);
|
||||
if riffs.is_empty() {
|
||||
riffs = slb::to_xma_riff_best(&bytes).into_iter().collect();
|
||||
}
|
||||
decode_riffs_to_wav(riffs, &format!("mv_{start:x}"), duration)
|
||||
}
|
||||
|
||||
/// Resolve a movie's `sound.pak` voice-entry name via the **movie manifest**
|
||||
/// (`tables.pak`), which authoritatively binds each movie to its voice bank —
|
||||
/// several `hokyu_*` resupply movies point at in-mission `VOICE_D_*` clips in
|
||||
@@ -3207,8 +3367,24 @@ fn handle_voice_request(
|
||||
let (movie, lang, duration, generation) =
|
||||
(req.movie.clone(), req.lang, req.duration, req.generation);
|
||||
std::thread::spawn(move || {
|
||||
let wav = resolve_movie_voice_clip(&source, &movie, lang)
|
||||
.and_then(|clip| decode_sound_clip(&source, &clip, duration).ok());
|
||||
// The cutscene voices live in one continuous XMA stream, addressed by cue
|
||||
// byte-region (movie → sound-id → [trailer(N-1)..trailer(N)]); this gives
|
||||
// the CORRECT, complete voice for ADV / S / RT movies, including the many
|
||||
// cues that span two `.slb` chunks. Movies whose voice is not a `\Movie\`
|
||||
// bank (hokyu resupply → `\etc\` demo clips) resolve to no region and fall
|
||||
// through to the legacy per-clip decoder. A `\Movie\` token that failed
|
||||
// region resolution must NOT play its raw `.slb` — that off-by-one chunk is
|
||||
// the wrong track — so it stays silent rather than wrong.
|
||||
let wav = resolve_movie_voice_region(&source, &movie, lang)
|
||||
.and_then(|(s, e)| decode_voice_region(&source, s, e, duration).ok())
|
||||
.or_else(|| {
|
||||
let clip = resolve_movie_voice_clip(&source, &movie, lang)?;
|
||||
if clip.contains("\\Movie\\") {
|
||||
return None;
|
||||
}
|
||||
decode_sound_clip(&source, &clip, duration).ok()
|
||||
});
|
||||
info!("[voice] movie={movie:?} gen={generation} -> wav={wav:?}");
|
||||
let _ = sender.send(IsoLoaderMsg::VoiceLoaded {
|
||||
movie,
|
||||
generation,
|
||||
@@ -3251,15 +3427,22 @@ fn handle_audio_request(
|
||||
req.generation,
|
||||
);
|
||||
std::thread::spawn(move || {
|
||||
// Resolve the movie voice bank from the manifest when asked, else decode
|
||||
// the named clip directly.
|
||||
let entry = match movie {
|
||||
Some((m, lang)) => resolve_movie_voice_clip(&source, &m, lang),
|
||||
None => Some(clip),
|
||||
};
|
||||
let wav = entry
|
||||
.and_then(|c| decode_sound_clip(&source, &c, f32::INFINITY).ok())
|
||||
.and_then(|p| wav_duration(&p).map(|d| (p, d)));
|
||||
// For a movie, decode its full-length cutscene voice via the continuous
|
||||
// stream (same resolution as playback), falling back to a non-`\Movie\`
|
||||
// clip (hokyu `\etc\`); for a named library clip, decode it directly.
|
||||
let wav = match movie {
|
||||
Some((m, lang)) => resolve_movie_voice_region(&source, &m, lang)
|
||||
.and_then(|(s, e)| decode_voice_region(&source, s, e, f32::INFINITY).ok())
|
||||
.or_else(|| {
|
||||
let c = resolve_movie_voice_clip(&source, &m, lang)?;
|
||||
if c.contains("\\Movie\\") {
|
||||
return None;
|
||||
}
|
||||
decode_sound_clip(&source, &c, f32::INFINITY).ok()
|
||||
}),
|
||||
None => decode_sound_clip(&source, &clip, f32::INFINITY).ok(),
|
||||
}
|
||||
.and_then(|p| wav_duration(&p).map(|d| (p, d)));
|
||||
let _ = sender.send(IsoLoaderMsg::AudioLoaded {
|
||||
name: display,
|
||||
generation,
|
||||
@@ -3400,6 +3583,7 @@ fn advance_video_playback(
|
||||
if let Some(wav) = voice.pending_wav.take() {
|
||||
if let Some(handle) = &inner.stream_handle {
|
||||
let vol = if voice.enabled { video.volume } else { 0.0 };
|
||||
info!("[voice] APPLY sink movie={:?} wav={wav:?} pos={}", voice.movie, video.position);
|
||||
inner.voice_sink =
|
||||
build_audio_sink(handle, &wav, video.position, vol, video.playing && !video.scrubbing);
|
||||
inner.voice_applied_volume = vol;
|
||||
|
||||
@@ -227,37 +227,73 @@ fn draw_viewer_ui(
|
||||
ui.label("Filter:");
|
||||
ui.text_edit_singleline(&mut voice_lib.filter);
|
||||
});
|
||||
let f = voice_lib.filter.to_lowercase();
|
||||
// Group the thousands of entries as directory → speaker so the
|
||||
// list is navigable (e.g. browse `Voice` by character to find a
|
||||
// cutscene's radio line). `name` is `<lang>\<dir>\<file>.slb`.
|
||||
use std::collections::BTreeMap;
|
||||
let mut groups: BTreeMap<&str, BTreeMap<&str, Vec<&sylpheed_formats::slb::VoiceClip>>> =
|
||||
BTreeMap::new();
|
||||
for c in &voice_lib.clips {
|
||||
if !f.is_empty() && !c.name.to_lowercase().contains(&f) {
|
||||
continue;
|
||||
}
|
||||
let dir = c.name.rsplit('\\').nth(1).unwrap_or("?");
|
||||
groups
|
||||
.entry(dir)
|
||||
.or_default()
|
||||
.entry(c.speaker.as_str())
|
||||
.or_default()
|
||||
.push(c);
|
||||
}
|
||||
let shown: usize = groups.values().flat_map(|s| s.values()).map(Vec::len).sum();
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{} clips", voice_lib.clips.len()))
|
||||
egui::RichText::new(format!("{shown} / {} clips", voice_lib.clips.len()))
|
||||
.weak()
|
||||
.small(),
|
||||
);
|
||||
ui.separator();
|
||||
let f = voice_lib.filter.to_lowercase();
|
||||
let clips: Vec<_> = voice_lib
|
||||
.clips
|
||||
.iter()
|
||||
.filter(|c| f.is_empty() || c.name.to_lowercase().contains(&f))
|
||||
.take(2000)
|
||||
.cloned()
|
||||
.collect();
|
||||
let filtering = !f.is_empty();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
for c in &clips {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("▶").on_hover_text(&c.name).clicked() {
|
||||
audio.generation = audio.generation.wrapping_add(1);
|
||||
audio.loading = true;
|
||||
audio.active = true;
|
||||
audio.name = c.display.clone();
|
||||
events.audio.send(RequestAudio {
|
||||
clip: c.name.clone(),
|
||||
display: c.display.clone(),
|
||||
movie: None,
|
||||
generation: audio.generation,
|
||||
});
|
||||
}
|
||||
ui.label(&c.display);
|
||||
});
|
||||
for (dir, speakers) in &groups {
|
||||
let dtotal: usize = speakers.values().map(Vec::len).sum();
|
||||
egui::CollapsingHeader::new(format!("📁 {dir} ({dtotal})"))
|
||||
.id_salt(("vdir", *dir))
|
||||
.default_open(filtering)
|
||||
.show(ui, |ui| {
|
||||
for (speaker, clips) in speakers {
|
||||
egui::CollapsingHeader::new(format!(
|
||||
"{speaker} ({})",
|
||||
clips.len()
|
||||
))
|
||||
.id_salt(("vspk", *dir, *speaker))
|
||||
.default_open(filtering || clips.len() <= 6)
|
||||
.show(ui, |ui| {
|
||||
for c in clips {
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.button("▶")
|
||||
.on_hover_text(&c.name)
|
||||
.clicked()
|
||||
{
|
||||
audio.generation =
|
||||
audio.generation.wrapping_add(1);
|
||||
audio.loading = true;
|
||||
audio.active = true;
|
||||
audio.name = c.display.clone();
|
||||
events.audio.send(RequestAudio {
|
||||
clip: c.name.clone(),
|
||||
display: c.display.clone(),
|
||||
movie: None,
|
||||
generation: audio.generation,
|
||||
});
|
||||
}
|
||||
ui.label(&c.display);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user