diff --git a/crates/sylpheed-formats/src/slb.rs b/crates/sylpheed-formats/src/slb.rs index 71a2552..e9fa9ab 100644 --- a/crates/sylpheed-formats/src/slb.rs +++ b/crates/sylpheed-formats/src/slb.rs @@ -133,7 +133,8 @@ fn parse_voice_clip(name: &str) -> VoiceClip { /// tracing confirmed the movie→voice binding; this fixes the *decode* of `RT*`.) pub fn to_xma_riffs(slb: &[u8]) -> Vec> { let mut out = Vec::new(); - if find(slb, b"RIFF", 0).is_none() { + let first_riff = find(slb, b"RIFF", 0); + if first_riff.is_none() { // Headerless single-stream bank. if let Some(data) = slb.get(HEADERLESS_DATA_OFFSET..) { if !data.is_empty() { @@ -142,22 +143,33 @@ pub fn to_xma_riffs(slb: &[u8]) -> Vec> { } return out; } - // ❌ A "leading headerless stream" rule was tried here and WITHDRAWN. + // HYBRID banks: a headerless packet stream followed by RIFF sub-waves, two + // SEQUENTIAL SEGMENTS of one clip. The branch above only fires when there is + // no `RIFF` at all, so the leading segment used to be dropped — which is why + // `VOICE_D_453` decoded to 0.14 s: its line is in that segment and only the + // trailing fragment survived. // - // The structure is real: in all five resupply banks the first `RIFF` sits at - // exactly `HEADERLESS_DATA_OFFSET + n*XMA1_PACKET` (n = 8, 1, 7, 22, 29), and - // 87 % of `VOICE_D_453` lies in front of it. Emitting that region as a - // sub-wave raised byte coverage from 5.4 % to 89.9 %. + // The boundary is arithmetic, not a magic: XMA1 packets are 2048 bytes, so a + // leading stream occupies exactly `HEADERLESS_DATA_OFFSET + n*XMA1_PACKET`. + // It decodes as **mono** — at two channels every bank yields exactly 1792 + // bytes, one frame, whatever its size. // - // But byte coverage was the wrong success metric. The emitted streams decode - // to **1792 PCM bytes** — silence — through the same FFmpeg path that decodes - // the RIFF sub-waves fine, so the region is not XMA1 under the synthesised - // format. And the rule is not narrow: it matches **1524 of the 8021** - // RIFF-bearing entries in `sound.pak`, including `RT*` banks that decode - // correctly today. Landing it would have risked a large regression to fix - // five banks it does not actually fix. - // - // See docs/re/voice-bank-leading-region.md. + // An earlier version of this was withdrawn for two good reasons, both now + // answered: it recovered no audio (it used the stereo format), and it + // matched 1524 of the 8021 RIFF-bearing entries. The byte-level reach is + // still 1524, but the *audible* reach is not: across the 84 movie-bound + // banks the segment adds >1 s to exactly **7** — the `hokyu_*_H` tankers + // bound to `VOICE_D_453`/`454`, i.e. precisely the broken ones — and + // ≤0.25 s to 66 of the rest. Callers clamp to the movie length anyway. + if let Some(ri) = first_riff { + if ri > HEADERLESS_DATA_OFFSET && (ri - HEADERLESS_DATA_OFFSET) % XMA1_PACKET == 0 { + if let Some(data) = slb.get(HEADERLESS_DATA_OFFSET..ri) { + if data.iter().any(|b| *b != 0) { + out.push(build_riff(&synth_xma1_fmt(1, 0, 48000), data)); + } + } + } + } 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). diff --git a/crates/sylpheed-formats/tests/slb_leading_segment_disc.rs b/crates/sylpheed-formats/tests/slb_leading_segment_disc.rs new file mode 100644 index 0000000..c7d3e11 --- /dev/null +++ b/crates/sylpheed-formats/tests/slb_leading_segment_disc.rs @@ -0,0 +1,86 @@ +//! The `.slb` leading segment — recovered, and scoped. +//! +//! `VOICE_D_453` used to decode to 0.14 s because its line lives in a headerless +//! packet stream *before* the first `RIFF`, and the decoder started at the +//! `RIFF`. The banks that looked fine were the ones whose leading segment is +//! silence. One rule, two outcomes. + +use std::path::{Path, PathBuf}; + +use sylpheed_formats::{slb, PakArchive}; + +fn disc_root() -> Option { + if let Ok(p) = std::env::var("SYLPHEED_DISC") { + let p = PathBuf::from(p); + if p.join("dat").is_dir() { + return Some(p); + } + } + let default = Path::new( + "/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)", + ); + default.join("dat").is_dir().then(|| default.to_path_buf()) +} + +macro_rules! skip_without_disc { + ($root:ident) => { + let Some($root) = disc_root() else { + eprintln!("SKIP: set SYLPHEED_DISC"); + return; + }; + }; +} + +fn bank(root: &Path, n: u32) -> Vec { + let snd = PakArchive::open(root.join("dat/sound.pak")).expect("sound.pak"); + let path = format!("eng\\etc\\VOICE_D_{n}.slb"); + let entry = snd.find_by_name(&path).expect("bank present"); + snd.read(entry).expect("read") +} + +/// The boundary is arithmetic and has no tunable: the first `RIFF` sits at +/// exactly `HEADERLESS_DATA_OFFSET + n*XMA1_PACKET` in every resupply bank. +#[test] +fn leading_segment_is_a_whole_number_of_packets() { + skip_without_disc!(root); + for (n, packets) in [(450u32, 8usize), (451, 1), (452, 7), (453, 22), (454, 29)] { + let b = bank(&root, n); + let ri = b.windows(4).position(|w| w == b"RIFF").expect("has a RIFF"); + assert!(ri > slb::HEADERLESS_DATA_OFFSET, "VOICE_D_{n}"); + let lead = ri - slb::HEADERLESS_DATA_OFFSET; + assert_eq!(lead % slb::XMA1_PACKET, 0, "VOICE_D_{n} not a whole packet count"); + assert_eq!(lead / slb::XMA1_PACKET, packets, "VOICE_D_{n} packet count"); + } +} + +/// The two banks whose line lives in the leading segment now yield it. +#[test] +fn broken_banks_recover_their_line() { + skip_without_disc!(root); + // (bank, sub-waves expected, payload of the leading one) + for (n, waves, lead_len) in [(453u32, 2usize, 45116usize), (454, 2, 59452)] { + let riffs = slb::to_xma_riffs(&bank(&root, n)); + assert_eq!(riffs.len(), waves, "VOICE_D_{n} sub-wave count"); + assert_eq!(riffs[0].len(), lead_len, "VOICE_D_{n} leading segment"); + // It must be the LARGER part: that is the whole point. + assert!( + riffs[0].len() > riffs[1].len() * 5, + "VOICE_D_{n}: leading segment should dominate" + ); + } +} + +/// `VOICE_D_451`'s leading region is all zeros — the guard must skip it, so the +/// rule cannot prepend silence to a bank that does not need it. +#[test] +fn all_zero_leading_region_is_skipped() { + skip_without_disc!(root); + let b = bank(&root, 451); + let ri = b.windows(4).position(|w| w == b"RIFF").unwrap(); + assert!( + b[slb::HEADERLESS_DATA_OFFSET..ri].iter().all(|x| *x == 0), + "expected an all-zero leading region" + ); + // Two sub-waves, both from the RIFF section — no synthesised third. + assert_eq!(slb::to_xma_riffs(&b).len(), 2); +} diff --git a/docs/re/BACKLOG.md b/docs/re/BACKLOG.md index d599a1e..55b6fe9 100644 --- a/docs/re/BACKLOG.md +++ b/docs/re/BACKLOG.md @@ -1057,9 +1057,18 @@ premise was wrong.** first `RIFF`. For the first three that discards only silence, so they looked fine (2.8 / 1.6 / 2.2 s); for the last two it discards **the line itself**, leaving 0.14 s and 0.43 s. One rule, two outcomes, depending on which segment - holds the speech. ▶️ The fix is now well-posed: emit the leading region **only - when it carries signal**, which also avoids the 1524-bank blast radius that - sank the earlier attempt. + holds the speech. ✅ **(same day) LANDED.** `to_xma_riffs` emits the leading segment + wrapped **mono** when it is a whole number of packets and carries a non-zero + byte. Both reasons the first attempt was withdrawn are answered: it used the + stereo format (mono yields up to 113× more), and while the byte-level reach is + still 1524 entries the **audible** reach is not — across the 84 movie-bound + banks it adds >1 s to exactly **7**, the `hokyu_*_H` tankers on + `VOICE_D_453`/`454`, and ≤0.25 s to 66 of the rest. ⚠️ The safety oracle is + weak and says so: **8 of the 84 already exceed their movie duration before the + change**, by hundredths of a second, so it establishes scoping rather than + correctness. Pinned by `tests/slb_leading_segment_disc.rs`, including that the + all-zero `VOICE_D_451` region stays skipped. ❔ Not verified by ear — that + needs a human. * ❌ **(2026-08-25) The `.slb` "multi-subwave" guess is REFUTED, and the voice decoder is discarding up to 87 % of a bank.** The record table gives a **direct** binding `hokyu_DS_s13A -> VOICE_D_452` where the corpus records the diff --git a/docs/re/voice-bank-leading-region.md b/docs/re/voice-bank-leading-region.md index 85c3f4e..373d77d 100644 --- a/docs/re/voice-bank-leading-region.md +++ b/docs/re/voice-bank-leading-region.md @@ -1,7 +1,8 @@ # The resupply voice banks — the decoder discards up to 87 % of them -Status: ❌ the recorded "multi-subwave / not cleanly sliced" explanation is -**REFUTED**. ✅ each shared bank is **one generic line**, which explains the +Status: ✅ **FIXED** — `to_xma_riffs` now emits the leading segment as mono, and +`VOICE_D_453`/`454` recover their line (`tests/slb_leading_segment_disc.rs`). +❌ the recorded "multi-subwave / not cleanly sliced" explanation is **REFUTED**. ✅ each shared bank is **one generic line**, which explains the in-game verdict that rejected the `hokyu_DS_s13A` binding — the line really is generic. ❌ **my own "audio is missing" conclusion is RETRACTED**: a subtitle cue is a START time, not a point inside the clip, and under the correct reading every @@ -178,6 +179,30 @@ Three of the five are decisive; the other two have their only cue at 0.0 s, whic tells us nothing in either direction. So **something is genuinely missing from these banks** — independent of anything above, and now measured rather than felt. +## ✅ Landed — and why it is safe this time + +`to_xma_riffs` now emits the leading segment, wrapped **mono**, when it sits at a +whole number of packets and carries a non-zero byte. The first attempt at this +was withdrawn for two good reasons, and both are answered: + +* *"It recovers no audio."* It used the **stereo** format. Mono yields up to 113× + more, and `VOICE_D_453`'s 45 116-byte segment is now its largest sub-wave. +* *"It matches 1524 of 8021 entries."* The byte-level reach is still 1524, but + the **audible** reach is not. Across the 84 movie-bound banks the segment adds + **more than 1 s to exactly 7** — the `hokyu_*_H` tankers on `VOICE_D_453`/`454`, + precisely the broken ones — and ≤0.25 s to 66 of the rest. The largest + non-resupply addition is `S04A` at +0.66 s on a **256 s** movie. + +The safety oracle is honest about its own limits: 8 of the 84 banks *already* +exceed their movie's duration before the change, by hundredths of a second, so it +cannot resolve differences at that scale. What it does establish is the +**scoping** — the change is material only where it is meant to be. Callers clamp +to the movie length regardless. + +`VOICE_D_451`'s all-zero leading region is skipped by the non-zero guard, so the +rule cannot prepend silence to a bank that does not need it; that is pinned by a +test. + ## ✅ The two parts are SEQUENTIAL SEGMENTS — and that closes the original mystery The last open question was whether the leading mono region duplicates the RIFF