diff --git a/crates/sylpheed-export/src/audio.rs b/crates/sylpheed-export/src/audio.rs index bd2b2f11..e6b17673 100644 --- a/crates/sylpheed-export/src/audio.rs +++ b/crates/sylpheed-export/src/audio.rs @@ -463,16 +463,15 @@ pub fn export_bgm( /// /// ## Three choices made here, and the reason each is not a guess /// -/// * **One file per movie** (MODDING rule 1), and the region's `RIFF`s are -/// **summed, not concatenated** — the reverse of what this function did when -/// it was first written an hour earlier. Concatenating produced a 359 s voice -/// track for a 137 s movie. +/// * **One file per movie** (MODDING rule 1), and **exactly one region chunk is +/// kept**. Not concatenated, not summed. This function got that wrong twice +/// before it got it right, and the history is kept below because each wrong +/// reading was ended by a measurement, not by an argument. /// -/// ## The chunks are stems, and only a measurement showed it +/// ## A region holds THREE PRESENTATIONS OF ONE TAKE — decoded, and not by me /// -/// A resolved region decodes to several chunks, and the two readings — segments -/// to join end to end, or stems to mix — look identical in the bytes. Decoded -/// and timed against the movies' own lengths: +/// A resolved region decodes to several chunks. Decoded and timed against the +/// movies' own lengths: /// /// | movie | movie | chunk 0 | chunk 1 | chunk 2 | /// |---|---|---|---|---| @@ -480,29 +479,50 @@ pub fn export_bgm( /// | `S00A` | 93.779 s | 68.072 | **93.694** | **93.694** | /// | `RT01A` | — | 0.009 | **34.034** | — | /// -/// Chunks 1 and 2 are **equal to six decimals and each span the whole movie**. -/// That is exactly HANDOFF Q10's decoded shape — *a bank is two stems of one -/// performance, played together; do not concatenate* — arriving on a different -/// asset kind, which is why they are summed at `1/n` like [`export_bgm`]'s. +/// **Reading 1, concatenate:** 359 s of dialogue for a 137 s movie. Dead. /// -/// ⚠️ **Chunk 0 is dropped, and dropping it may be a TRUNCATION.** This comment -/// first guessed it was the same thing as `BGM_103`'s third sub-wave — a bank -/// header — and a disc-wide census over all 95 English movie-voice regions -/// showed that is a *different structure*: 78 regions open with a 10 240 B bank -/// header, 17 with a leading headerless stream at the disc's own `1392 mod 2048` -/// data offset, and the chunk count discriminates neither. Then the byte-span -/// test settled what it holds: **the leading chunk is this movie's own dialogue, -/// 17 of 17** — not an in-mission line, which was the standing hypothesis. +/// **Reading 2, sum them as HANDOFF Q10's two stems** — equal duration, each +/// spanning the movie, which is exactly Q10's *music* shape. ❌ **Refuted here, +/// and the claim had already been adopted into the RE corpus before I tested +/// it**: `S00A`'s second full-length chunk is **digital silence** (peak −inf) +/// and `ADV`'s is **0.60 × the first** with 26.8 dB of residual. Equal duration +/// was a shape match and carrying a music census across on it was my error. /// -/// It is dropped anyway, and only for this reason: **the region over-covers.** -/// Taking everything measures 2.6× the movie's length, which nothing explains -/// yet. So the omission is a *bounded* choice, not junk removal, and the -/// manifest says so in those words — a consumer must not read a dropped chunk -/// here as a defect the exporter cleaned up. +/// **Reading 3, one stream. ✅ Decoded disc-wide by the Decoder**, by counting +/// stream starts inside every inter-descriptor span: **258 spans hold one +/// stream, 28 hold three, and nothing holds two or any other number.** The 95 +/// movie-voice regions decompose 70 + 8 + 17. So a region is three presentations +/// of one take, and `359 = 84.55 + 137.32 + 137.32`. Summing a take with a +/// scaled copy of itself adds ~4 dB and colours it. /// -/// The selection rule is therefore stated in terms of what was measured — *keep -/// the longest duration and everything tying with it, minus anything digitally -/// silent* — and every dropped chunk is named with its length and peak. +/// 🟡 **Which presentation to keep is a recommendation, not a field.** The +/// selector is the **highest byte rate** among the equal-duration survivors, on +/// the Decoder's advice. Nothing on the disc says which one the game plays, and +/// on `ADV` this picks the **quieter** of the two — −8.3 dBFS against 0.0. It is +/// stated in the manifest with that consequence so the choice is reversible; a +/// capture of the intro with dialogue audible settles it. +/// +/// ## Chunk 0 is dropped, and it is a DUPLICATE rather than a truncation +/// +/// This comment first guessed it was `BGM_103`'s third sub-wave — a bank header +/// — and a census over all 95 regions showed that is a *different structure*: +/// 78 open with a 10 240 B bank header, 17 with a leading headerless stream at +/// the disc's own `1392 mod 2048` data offset, and **the chunk count +/// discriminates neither**. The byte-span test then found it is **this movie's +/// own dialogue, 17 of 17** — not an in-mission line, which was the standing +/// hypothesis. +/// +/// Which raised the real question: is dropping it a truncation? **No.** Measured +/// here with a decoder the RE container does not have — sliding envelope +/// correlation, overhang allowed, normalised over the overlap: **r = 0.998** +/// (`ADV`) and **0.932** (`S00A`), against controls of 1.000 (self) and 0.289 (a +/// different movie), with both lags placing chunk 0 **flush against the end** of +/// the kept stream. Confirmed in the sample domain at 16.7 / 23.2 dB of +/// residual. It is the tail of the take, presented again. +/// +/// So the selection rule is stated in terms of what was measured — *the longest +/// duration, ties broken by byte rate, minus anything digitally silent* — and +/// every dropped chunk is named in the manifest with its length and peak. /// * **Mono**, with the fold chosen from the stream's own declared channel /// count rather than by passing `-ac 1` and hoping. A voice track that is /// already mono is passed through untouched. @@ -559,9 +579,31 @@ pub fn export_voice( // A tie at 1 ms. The two stems agree to six decimals and the chunk that is // not one of them misses by tens of seconds, so nothing sits near this // bound: it separates the measured cases without being a tuned threshold. - let keep: Vec = (0..all.len()) + // ONE STREAM, NOT A SUM -- and this is the third reading of these chunks, each + // one refuted by a measurement rather than by an argument. + // + // They were concatenated (359 s for a 137 s movie), then summed as HANDOFF + // Q10's two stems (refuted here: `S00A`'s second is silence, `ADV`'s is + // 0.60x the first). The Decoder then decoded the shape disc-wide -- counting + // stream starts inside every inter-descriptor span gives 258 spans with ONE + // stream and 28 with THREE, and nothing with two or any other number, so a + // region carries **three presentations of one take**, not a mix. Summing a + // take with a scaled copy of itself adds ~4 dB and colours it. + // + // The selector is the **highest byte rate** among the equal-duration + // survivors, on the Decoder's recommendation. 🟡 That is a recommendation and + // not a decoded field: no flag on the disc says which presentation the game + // plays, and on `ADV` it picks the quieter of the two (-8.3 dBFS against + // 0.0). Recorded in the manifest so the choice is visible and reversible. + let tied: Vec = (0..all.len()) .filter(|&i| !silent.contains(&i) && (longest - lengths[i]).abs() < 0.001) .collect(); + let keep: Vec = tied + .iter() + .copied() + .max_by_key(|&i| riffs[i].len()) + .into_iter() + .collect(); let dropped: Vec = (0..all.len()) .filter(|i| !keep.contains(i)) .map(|i| { @@ -619,16 +661,9 @@ pub fn export_voice( argv.push("-i".into()); argv.push(s.display().to_string()); } - // SUMMED at 1/n, with the coefficient written out rather than left to - // `amix`'s `normalize=1` default, so it appears in the manifest's command - // line. Same reasoning as `export_bgm`: a default is a decision nobody made - // and it can move under an ffmpeg upgrade. - let filter = format!( - "{}amix=inputs={n}:normalize=0,volume={:.6}{fold}[a]", - (0..staged.len()).map(|i| format!("[{i}:a]")).collect::(), - 1.0 / staged.len() as f64, - n = staged.len(), - ); + // One input, so no mix and no normalising coefficient: the stream reaches the + // Ogg at the level the disc has it, and the only filter is the mono fold. + let filter = format!("[0:a]anull{fold}[a]"); argv.push("-filter_complex".into()); argv.push(filter); argv.push("-map".into()); @@ -665,31 +700,39 @@ pub fn export_voice( movie -> token -> sound id -> byte region [{start}, {end}) of the continuous \ voice stream. NOT matched by filename: RT01A's voice lives inside \ VOICE_ADV.slb, so the name is right for this movie by luck and wrong for \ - others. Of {} region chunk(s), {} were SUMMED at 1/{} -- they are \ - equal-duration and each spans the whole movie, which is HANDOFF Q10's decoded \ - two-stem shape, so joining them end to end would play the dialogue twice.{} \ - Folded to mono from the {} of {channels} declared channel(s) that carry \ - signal -- averaging a silent channel in would cost 6.02 dB, and channel 2 of \ - both voice streams IS silent.{against}", - live.len(), + others. Of {} region chunk(s), exactly ONE is kept -- not summed. A region \ + carries THREE PRESENTATIONS OF ONE TAKE, decoded disc-wide by counting stream \ + starts inside every inter-descriptor span: 258 spans hold one stream and 28 hold \ + three, and nothing holds two. This exporter read these chunks wrongly twice \ + before that landed -- first concatenating them (359 s for a 137 s movie), then \ + summing them as HANDOFF Q10's two stems, which its own measurements refuted: \ + S00A's second full-length chunk is DIGITAL SILENCE and ADV's is 0.60x the first \ + with 26.8 dB of residual. Summing a take with a scaled copy of itself adds ~4 dB \ + and colours it. 🟡 The kept stream is the HIGHEST BYTE RATE among the \ + equal-duration survivors, which is a recommendation and NOT a decoded field: \ + nothing on the disc says which presentation the game plays, and on ADV this \ + picks the quieter of two.{} Folded to mono from the {} of {channels} declared \ + channel(s) that carry signal -- channel 2 of both voice streams is digitally \ + silent, and averaging it in cost 5.94 dB until this was measured rather than \ + read off the declared count.{against}", riffs.len(), - staged.len(), - staged.len(), if dropped.is_empty() { String::new() } else { format!( - " DROPPED, and NOT because it is spurious -- the leading chunk is DECODED \ - to be this movie's OWN dialogue, 17 of 17 regions (the Decoder's \ + " DROPPED, and NOT as junk -- the leading chunk is DECODED to be this \ + movie's OWN dialogue, 17 of 17 regions (docs/re/structures/\ voice-region-leading-chunk.md; an earlier note here wrongly equated it \ with BGM_103's third sub-wave, which a disc-wide census showed is a \ - different structure). It is dropped because the region OVER-COVERS: \ - including everything measures 2.6x the movie's length. So this may be a \ - TRUNCATION, it is an open decoding question, and a consumer must not read \ - the omission as junk removal. See docs/port/BLOCKED.md: {}.", + different structure). This port then measured it to be the TAIL of the \ + kept stream -- sliding envelope correlation r=0.998 (ADV) and 0.932 \ + (S00A), the lag placing it flush against that stream's end, against \ + controls of 1.000 self and 0.289 for a different movie -- so dropping it \ + removes a DUPLICATE, not dialogue. Dropped: {}.", dropped.join(", ") ) - } + }, + live.len() ), peak_dbfs: peak, duration_s: dur, diff --git a/crates/sylpheed-export/src/check.rs b/crates/sylpheed-export/src/check.rs index 9be3a9ab..c416024b 100644 --- a/crates/sylpheed-export/src/check.rs +++ b/crates/sylpheed-export/src/check.rs @@ -340,13 +340,18 @@ fn check_audio(root: &Path, m: &Value, errors: &mut Vec) { // the overshoot distribution across a corpus of cues, and if a cue // ever trips this the right response is that measurement, not a // looser bound. - // `voice` joins `bgm` on the strict side of this bound for the same - // reason: it is a sum of stems this exporter produced, not a single - // wave taken off the disc, so a peak at full scale is our arithmetic. - Some(p) if matches!(kind, "bgm" | "voice") && p >= 0.0 => errors.push(format!( + // `voice` was on the strict side of this bound while it was a SUM of a + // region's chunks. It no longer is: a region carries three + // presentations of one take, so the exporter keeps ONE stream and + // performs no arithmetic on it. That puts `voice` with `se` -- a + // single wave off the disc, mastered near full scale, whose lossy + // decode overshoots by a fraction of a dB. `ADV`'s louder + // presentation measures +0.0003 dBFS at source; refusing that would + // be refusing the disc's own mastering. + Some(p) if kind == "bgm" && p >= 0.0 => errors.push(format!( "{file}: peak is {p:.1} dBFS -- a SUM we produced clips" )), - Some(p) if !matches!(kind, "bgm" | "voice") && p > 1.0 => errors.push(format!( + Some(p) if kind != "bgm" && p > 1.0 => errors.push(format!( "{file}: peak is {p:.1} dBFS -- too far over full scale to be decode overshoot" )), Some(_) => {} diff --git a/docs/port/BLOCKED.md b/docs/port/BLOCKED.md index 32fbfbeb..8be5027b 100644 --- a/docs/port/BLOCKED.md +++ b/docs/port/BLOCKED.md @@ -98,7 +98,7 @@ HANDOFF. |---|---|---|---| | P4/P7 — the intro's dialogue | ~~why the intro has no voices~~ | Q9 | ✅ **answered and TAKEN 2026-08-29, and the obvious diagnosis was wrong.** Not a transcode fault: `ADV.wmv` carries music and effects only, and a cutscene's voice is a *separate* continuous XMA stream in `sound.pak` bound by the movie manifest. `audio::export_voice` now resolves it with `media::resolve_movie_voice_region` — never by filename, because `RT01A`'s voice lives inside `VOICE_ADV.slb` and a name match is right on exactly the two movies this port would have spot-checked. Region chunks are **concatenated** (one continuous stream), not summed. This is **decoded, nothing authored**. | | P4/P7 — the movie downmix | **is the exporter allowed to ship a matrix MISSION §6 did not pin?** | — | 🔴 **with the HUMAN, not the Decoder, and now visible for the first time.** §6 pins the 5.1 fold as a human decision of 2026-08-29; `video.rs` has shipped that matrix scaled by **0.4142** since P4 — same weighting, **7.65 dB quieter** — and said so nowhere. Re-measured this iteration with the right instrument (float decode, whole file, count the samples that would clamp, not a peak reading): under the **pinned** matrix `ADV` peaks at **+4.26 dBFS** with **4 406** samples at or over full scale and 1 874 more than 1 dB over, while `S00A` peaks at −1.34 dBFS and **never clips**. So the pin overloads one movie and the exporter's constant is over-broad for the other. Smallest single scalar under which neither clamps: **0.612**, +3.39 dB on today. **Not changed** — the level of a mix is what §6 reserves. The export now carries a manifest warning with these numbers. | -| P4/P7 — a voice region's chunks | **what is the leading chunk, and is `ADV`'s near-duplicate second chunk played?** | — | 🟡 **half answered by the Decoder, half by my decoder run, and one of my own claims withdrawn.** Structure is decoded disc-wide, 95/95 regions (`auto/no-disc-and-menu-captures` at `7e12a3b`, `docs/re/structures/voice-region-leading-chunk.md`): 78 regions open with a bank header, 17 with a leading headerless stream at the disc's own `1392 mod 2048` data offset, 0 at a `RIFF` — and **the chunk count discriminates nothing**, 8 header regions also yield three. What the leading chunk *contains* is 🟡, and the Decoder cannot settle it: no XMA1 decoder in that container. I decoded it — `ADV`, 84.553 s, speech-structured, 54 % silence — which says it is dialogue-shaped and says **nothing about whose dialogue**. 🔴 **And it refuted my own "two stems" reading**: `S00A`'s second chunk is digital silence and `ADV`'s is 0.60 × the first with 26.8 dB of residual. The silent one is now dropped (arithmetic — it cost `S00A` 6.02 dB); `ADV`'s near-duplicate is **still summed and still open**. What settles the leading chunk: enumerating the `VOICE_D_*` regions and re-running the Decoder's own byte-span coverage test. | +| ~~P4/P7 — a voice region's chunks~~ | ~~what is the leading chunk, and is the second one played?~~ | — | ✅ **CLOSED 2026-08-29, decoded disc-wide, and it cost this exporter three wrong readings in one session.** A region carries **three presentations of one take** — the Decoder counted stream starts inside every inter-descriptor span: 258 spans hold one, 28 hold three, nothing holds two (`auto/no-disc-and-menu-captures` at `801062c`). So `359 = 84.55 + 137.32 + 137.32`. My concatenation was wrong, my "two stems" reading was wrong (and had already been adopted into the Decoder's page before I tested it — withdrawn in both), and summing was wrong a third time because a take plus a 0.60× copy of itself is ~4 dB louder and coloured. The exporter now keeps **one stream** and does no arithmetic on it. The leading chunk is this movie's own dialogue, and I measured it to be the **tail** of the kept stream (r=0.998 / 0.932, controls 1.000 and 0.289), so dropping it removes a duplicate. 🟡 **What is left open is which presentation to keep**: the selector is highest byte rate on the Decoder's recommendation, nothing on the disc says which the game plays, and on `ADV` it picks the quieter of two. Settled by a capture of the movie's dialogue level. ❔ Why the disc stores three at all is unanswered by either agent. | | P4 — is an attract movie skippable at all? | **does the real game let Ⓐ end `ADV`, or does it play through?** | Q9 | 🔴 **a human play-test reports Ⓐ does not skip the port's intro, and the port could not tell which bug that is.** It is *implemented*, not assumed: `authored/flow.json` carries `skippable: true` with a `why` citing Q9 as measured (title at 57 s against a 193 s baseline), and `boot.gd` `_unhandled_input` acts on it. What did not exist was any way to **test** it: `--script` structurally cannot press during a movie, because `_script_settled` waits while `_player != null`. `--skip-at=SECONDS` was added this iteration to close that hole. ⚠️ Two different questions sit behind the one symptom, and only the first is mine: (a) does the synthetic press reach `_unhandled_input` — measurable here; (b) does the **game** permit skipping an attract movie — `INDEX.md` still marks skippability 🟡 and only a capture settles it. If (b) is no, the port's skip path is deleted rather than debugged. Asked 2026-08-29. | | ~~P5 — a real submenu cycle~~ | ~~is any submenu reachable without a new archive?~~ | Q2/Q4 | ✅ **already shipped at P5, and one premise of the ask is refuted by this repo.** `ptbtn05` (EXTRAS) → screen `extras` (entries 6/9), and `extras`' `on_cancel` returns to `main_menu` with focus restored — a full main-menu → submenu → back cycle, in `GP_TITLE`, live since P5. ⚠️ **Build 8 is not a submenu.** It is `main_menu_jp`, the Japanese five-button main menu; `authored/screen_names.json` records that an earlier reading called 8 a submenu and that HANDOFF Q2 **withdrew it** against a capture. That coordinates identical to build 5 mean a language twin rather than a second menu is exactly the inference the port is not allowed to make on layout similarity — in either direction. The other four main-menu items really are blocked: `GP_SAVE_LOAD`, `GP_OPTIONS`, `GP_MISSION_SELECT` and the `DIFFICULTY`/`TUTORIAL_MENU` builds are not in this archive. | diff --git a/docs/port/DECISIONS.md b/docs/port/DECISIONS.md index 1b979e3b..2616912c 100644 --- a/docs/port/DECISIONS.md +++ b/docs/port/DECISIONS.md @@ -2734,3 +2734,59 @@ byte↔time mapping, and the numbers above are the reason there isn't one: chunk is 1 118 268 B and chunk 2 is 1 171 516 B for **the same 137.324 s**, so bytes per second is not constant even inside a single region. Any figure in seconds off that constant would be invented. + +## Third reading of a voice region, and this one is decoded: three presentations of one take + +`export_voice` has now read the same bytes three ways in one session, and each +reading was ended by a measurement rather than by an argument: + +1. **Concatenate the chunks** — 359 s of dialogue for a 137 s movie. +2. **Sum them as HANDOFF Q10's two stems** — refuted here: `S00A`'s second + full-length chunk is digital silence, `ADV`'s is 0.60 × the first with 26.8 dB + of residual. +3. **Keep one stream.** ✅ This one is decoded, and not by me. + +The Decoder settled the shape disc-wide without a decoder, by counting stream +starts inside every inter-descriptor span: **258 spans hold one stream, 28 hold +three, and nothing holds two or any other number.** The 95 movie-voice regions +decompose 70 + 8 + 17, and the 8 are independently the same 8 its first census +flagged. So a region carries **three presentations of one take** — which is +exactly `359 = 84.55 + 137.32 + 137.32`, the first clipped by its crate's own +1.5 MB predecessor guard. + +It also cross-checked my correlation by a route needing no decoder: if the +leading chunk is the tail of a full stream, the whole leading stream should be +one complete take, and `ADV`'s 504 464 + 808 304 = 1 312 768 B at chunk 0's byte +rate is **137.323 s against my measured 137.324**. Two instruments, no shared +assumption. + +**So summing was wrong for a third reason:** a take plus a 0.60 × copy of itself +is ~4 dB louder and coloured, not a mix of parts. The exporter keeps one stream +and performs no arithmetic on it. + +🟡 **Which stream is a recommendation, not a decoded field.** The selector is the +**highest byte rate** among the equal-duration survivors, on the Decoder's +advice. Nothing on the disc says which presentation the game plays, and on `ADV` +this picks the **quieter** of the two — −8.3 dBFS against 0.0. That is in the +manifest in those words so the choice is visible and reversible; it is the one +part of this that a capture could still overturn. + +`check` moves `voice` off the strict peak bound as a consequence. It sat with +`bgm` because it was a sum this exporter produced; it is now a single wave off +the disc, mastered near full scale — `ADV`'s louder presentation measures +**+0.0003 dBFS at source** — and refusing that would be refusing the disc's own +mastering. + +### The 504 464 B constant: I refused the conversion, and refusing was right + +The Decoder asked whether I would spend a decode converting its anchor offset to +seconds, and I declined because bytes per second is not constant even inside one +region. It has since found the stronger reason and withdrawn the ask: **the +constant is structural, not proportional** — identical on all 17 regions despite +their differing durations. A proportional prediction lands within 8 bytes on +`ADV`, which is a coincidence, and is **4 305 B out on `S00A`**. A seconds figure +off that constant would have been invented, and it would have looked corroborated +on the first movie anybody checked. + +❔ **Why the disc stores three presentations at all is unanswered**, by either of +us.