diff --git a/crates/sylpheed-formats/examples/voice_bank_shape.rs b/crates/sylpheed-formats/examples/voice_bank_shape.rs new file mode 100644 index 0000000..f97833b --- /dev/null +++ b/crates/sylpheed-formats/examples/voice_bank_shape.rs @@ -0,0 +1,76 @@ +//! How many sub-waves does each resupply voice bank hold? +//! +//! The corpus records `VOICE_D_452` as the binding the game rejected in-game +//! ("wrong recording"), and separately notes that `VOICE_D_453`/`454` decode to +//! 0.14 s / 0.43 s — "far too short for the spoken line". Both observations are +//! explained if these banks are multi-sub-wave and the extractor plays only the +//! first. This prints the shape so that stops being a guess. +use sylpheed_formats::{slb, PakArchive}; + +fn main() { + let disc = std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC"); + let snd = PakArchive::open(format!("{disc}/dat/sound.pak")).expect("sound.pak"); + println!( + "{:<14} {:>7} {:>6} {:>6} {:>7} sub-wave data sizes", + "bank", "bytes", "RIFFs", "waves", "cover" + ); + for n in 450..=454 { + for dir in ["etc", "Voice", "Movie"] { + let path = format!("eng\\{dir}\\VOICE_D_{n}.slb"); + let Some(entry) = snd.find_by_name(&path) else { continue }; + let bytes = snd.read(entry).expect("read"); + let riffs = slb::to_xma_riffs(&bytes); + let sizes: Vec = riffs.iter().map(|r| r.len()).collect(); + // How many RIFF magics does the bank actually contain, versus how + // many sub-waves the walker recovered? A gap means the walk stops + // early, and the missing bytes are the missing audio. + let magics = bytes.windows(4).filter(|w| *w == b"RIFF").count(); + let covered: usize = sizes.iter().sum(); + // What are the UNCOVERED bytes? If the tail past the last data + // chunk is all zero it is padding and the short duration is real; + // if it is high-entropy it is audio the parse is throwing away. + let last = bytes + .windows(4) + .rposition(|w| w == b"data") + .map(|i| { + let sz = u32::from_le_bytes(bytes[i + 4..i + 8].try_into().unwrap()) as usize; + (i + 8 + sz).min(bytes.len()) + }) + .unwrap_or(0); + // Where does the RIFF structure START? If it begins far into the + // file, the uncovered bytes are a leading region the parse skips, + // not a missed sub-wave. + let first_riff = bytes.windows(4).position(|w| w == b"RIFF").unwrap_or(0); + let datas = bytes.windows(4).filter(|w| *w == b"data").count(); + // Is the leading region padding, or content? Padding is nearly all + // zero and uses few distinct byte values. + let head = &bytes[..first_riff]; + let head_zero = head.iter().filter(|b| **b == 0).count(); + let head_distinct = { + let mut seen = [false; 256]; + for b in head { + seen[*b as usize] = true; + } + seen.iter().filter(|s| **s).count() + }; + let tail = &bytes[last..]; + let zeros = tail.iter().filter(|b| **b == 0).count(); + println!( + "{:<14} {:>7} {:>6} {:>6} {:>6.1}% 1st RIFF @{:>6} data chunks {} head {:>5.1}% zero/{:>3} distinct tail {:>5} B ({:>5.1}% zero) {:?}", + format!("VOICE_D_{n}"), + bytes.len(), + magics, + riffs.len(), + 100.0 * covered as f64 / bytes.len() as f64, + first_riff, + datas, + if head.is_empty() { 0.0 } else { 100.0 * head_zero as f64 / head.len() as f64 }, + head_distinct, + tail.len(), + if tail.is_empty() { 0.0 } else { 100.0 * zeros as f64 / tail.len() as f64 }, + sizes + ); + break; + } + } +} diff --git a/docs/re/BACKLOG.md b/docs/re/BACKLOG.md index e54a6f3..c708843 100644 --- a/docs/re/BACKLOG.md +++ b/docs/re/BACKLOG.md @@ -1033,6 +1033,24 @@ premise was wrong.** Candidates: the **7 `.embsec_` sections** (VAs 0x84D0000–0x86AC000, ~129 KB total, executable) or a hashed record in `hidden/MiscBin.pak`. **Finding it gives the actual per-phase clear condition for every stage.** +* ❌ **(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 + movie as unbound and a test asserts `None`, citing an in-game verdict that the + same value was "the wrong recording". Measured: the RIFF-magic count equals the + sub-wave count in all five hokyu banks, so nothing between or after sub-waves + is missed — the recorded "likely multi-subwave / not cleanly sliced" is wrong. + The audio is lost because a **large region precedes the first RIFF** and + `slb::to_xma_riffs` finds audio by searching for that magic: **87 % of + `VOICE_D_453` and 85 % of `VOICE_D_454`** sit in front of it, 21–27 % zero over + 256 distinct byte values — content, not padding. `VOICE_D_451` is the control, + its leading region being 100 % zero / 1 distinct value. 🟡 So the in-game + verdict tested a decode that had thrown away most of the bank and is **not** + evidence against the binding — though it does not confirm it either. + ▶️ First step: decode the leading region (it is not padding and not a RIFF — + directory? seek table? raw stream?). Then a human has to listen; audio + judgement cannot be done in this container. See + [`voice-bank-leading-region.md`](voice-bank-leading-region.md). * ❌ **(2026-08-25) My own boot-nav diagnosis, MEASURED AND WITHDRAWN.** I said the run died because `skip_intro.sh` gates the title test at `rmse <= 1500` and the run logged 1503/1549, just above the cut. Measured over a clean diff --git a/docs/re/structures/movie-subtitles.md b/docs/re/structures/movie-subtitles.md index f804c59..3106696 100644 --- a/docs/re/structures/movie-subtitles.md +++ b/docs/re/structures/movie-subtitles.md @@ -98,9 +98,13 @@ the **correct join key is not yet known**: was tried and is **WRONG** — it plays the wrong recording in-game. Do not use. - Only `VOICE_D_450..454` exist (no 44x/45x neighbours). Decoded durations are suspicious — `450`=2.8s, `451`=1.6s, `452`=2.2s, but `453`=**0.14s**, - `454`=**0.43s** — far too short for the spoken line, so these `.slb` banks are - likely **multi-subwave / not cleanly sliced** by the current extractor (same - class as the deferred B/C banks). + `454`=**0.43s** — far too short for the spoken line. ❌ The guess that follows + was **REFUTED 2026-08-25**: these banks are *not* multi-subwave-and-missed. The + RIFF-magic count equals the number of sub-waves recovered in all five banks, so + nothing between or after them is lost. The audio is missing because a large + region **precedes the first RIFF** and the decoder searches for that magic — + 87 % of `453` and 85 % of `454` sit in front of it, high-entropy and not + padding. See [voice-bank-leading-region](../voice-bank-leading-region.md). ⇒ The unbound-hokyu voice mapping is **open** (needs either the real join key from mission data, or a proper multi-subwave `.slb` decode + audio verification). diff --git a/docs/re/voice-bank-leading-region.md b/docs/re/voice-bank-leading-region.md new file mode 100644 index 0000000..e10671c --- /dev/null +++ b/docs/re/voice-bank-leading-region.md @@ -0,0 +1,95 @@ +# The resupply voice banks — the decoder discards up to 87 % of them + +Status: ❌ the recorded "multi-subwave / not cleanly sliced" explanation is +**REFUTED**. ✅ the real defect is measured. 🟡 the in-game verdict that rejected +the `hokyu_DS_s13A` voice binding is therefore **not** evidence against it. ❔ the +binding still needs audio verification, which needs a human. + +Artifact: `crates/sylpheed-formats/examples/voice_bank_shape.rs`. + +## Why this was worth chasing + +The [record-table decode](structures/idxd-container.md) gives a **direct** +binding for a movie the corpus records as unbound: + +``` +S13_SUPPLY_ACROPOLIS MOVIE = hokyu_DS_s13A.wmv VOICETRACK = VOICE_D_452 +``` + +`crates/sylpheed-formats/tests/movie_manifest_disc.rs` asserts the opposite — +`voice_token == None` — with the note that extending unbound movies *by shared +demo line* was "verified WRONG against the running game". That inference +predicted the same value the disc actually stores, so the two are in direct +conflict, and it is the only place on the disc where a runtime observation +disagrees with the record table. + +First, the shape of the data. The resupply banks are **shared**: five slots bind +`VOICE_D_452` (S04, S07, S08, S12, S13), five bind `VOICE_D_451`, four bind +`VOICE_D_450`, four `VOICE_D_453`, three `VOICE_D_454` — 21 hokyu slots over five +banks. The movies repeat too (`hokyu_DS_s07A.wmv` serves S07 and S12). These are +generic resupply cutscenes, not per-stage recordings. + +## ❌ What was recorded, and why it is wrong + +`structures/movie-subtitles.md` notes that `450`=2.8 s, `451`=1.6 s, `452`=2.2 s +but `453`=**0.14 s** and `454`=**0.43 s** — "far too short for the spoken line, so +these `.slb` banks are likely **multi-subwave / not cleanly sliced**". + +Measured, that is not it. **Every RIFF present in every bank is found and +parsed** — the count of `RIFF` magics equals the number of sub-waves recovered, +in all five banks: + +| bank | bytes | RIFF magics | sub-waves recovered | bytes covered | +|---|---|---|---|---| +| `VOICE_D_450` | 65 652 | 1 | 1 | 56.2 % | +| `VOICE_D_451` | 67 704 | 2 | 2 | 64.8 % | +| `VOICE_D_452` | 67 704 | 2 | 2 | 46.7 % | +| `VOICE_D_453` | 53 340 | 1 | 1 | **5.4 %** | +| `VOICE_D_454` | 71 808 | 1 | 1 | **9.7 %** | + +Nothing is being missed *between* sub-waves, and nothing is lost *after* them: +the last `data` chunk ends exactly at EOF in four of the five banks. + +## ✅ The real defect: a leading region before the first RIFF + +`slb::to_xma_riffs` locates audio by **searching for the `RIFF` magic**. In these +banks a large region *precedes* it, and the search skips the lot: + +| bank | first RIFF at | share of file before it | that region | +|---|---|---|---| +| `VOICE_D_451` | 3 440 | 5 % | **100.0 % zero, 1 distinct byte** — real padding | +| `VOICE_D_452` | 15 728 | 23 % | 78.3 % zero, **256 distinct** | +| `VOICE_D_450` | 17 776 | 27 % | 69.1 % zero, **256 distinct** | +| `VOICE_D_453` | 46 448 | **87 %** | 27.4 % zero, **256 distinct** | +| `VOICE_D_454` | 60 784 | **85 %** | 21.1 % zero, **256 distinct** | + +`VOICE_D_451` is the control: its leading region really is padding, and its +coverage is unremarkable. The other four have high-entropy content there — 46 KB +in `453`, 60 KB in `454` — which the decoder throws away. That is exactly the +size needed to explain a "line" that decodes to 0.14 s. + +## 🟡 What this does to the conflict + +The in-game test that rejected this binding listened to a decode that had +discarded **most of the bank**, for precisely the two-bank class involved. A +correct bank played from the wrong region sounds exactly like "the wrong line", +so the verdict does not refute the disc's binding — it is evidence about the +decoder, not about the mapping. + +Note also what was actually rejected: a value derived *by inference* from a +shared demo id. The record table supplies the same value as a **stored field**. +Those are different kinds of evidence for the same claim, and only the first was +tested. + +⚠️ This does **not** establish that the binding is right. It removes the only +recorded evidence against it. + +## What this does not settle + +* ❔ **What the leading region is.** It is not padding and not a RIFF. Whether it + is a directory, a seek table, an alternate codec stream, or the audio itself is + unestablished — I did not decode it. +* ❔ Whether `hokyu_DS_s13A` really plays `VOICE_D_452`. That needs the leading + region decoded *and* a human listening; audio judgement cannot be done here. +* ❔ Whether the same leading region exists across the other ~9 500 `sound.pak` + entries, or is peculiar to these banks. Only five were measured.