diff --git a/authored/audio.json b/authored/audio.json index af4f3644..4f4cbe9e 100644 --- a/authored/audio.json +++ b/authored/audio.json @@ -200,6 +200,46 @@ "clean audible dialogue, so nothing in the output reveals that two streams", "are missing. That is why the manifest says it in words on every voice entry", "rather than leaving it to this file." - ] + ], + "stream_weights": { + "_": [ + "Declared XMA `byte_size` -> the coefficient that stream's position takes in a", + "stereo downmix. MEASURED by the RE agent 2026-08-30", + "(docs/re/structures/intro-audio-decomposed.md): decomposing the game's own", + "6-channel output as capture = 0.600 x movie + residual puts ctx0 at FL/FR,", + "ctx1 at FC with LFE silent, and ctx2 at BL/BR.", + "", + "🔴 KEYED BY BYTE SIZE ON PURPOSE. The assignment is indexed by the decoder's", + "own declared size, so the exporter can CHECK that the stream in front of it is", + "the one the measurement describes rather than assume it. A region whose chunks", + "do not match falls back to the count divisor and says so. That is not defensive", + "programming: on 2026-08-30 this table's sizes did NOT fit the region the", + "resolver returned, which is what exposed `resolve_movie_voice_region` starting", + "238 packets late. Had the weights been applied positionally they would have", + "been applied to the wrong streams silently.", + "", + "⚠️ ONE BOOT, ONE MOVIE. Only `ADV`'s three streams were measured. `S00A`'s", + "sizes match nothing here and it keeps the divisor -- extending this by", + "POSITION would be assuming the ordering generalises, which is exactly the", + "inference the byte-size key exists to avoid.", + "", + "⚠️ The weights are a stereo downmix's, folded to mono. They sum to 1.0, so the", + "total is the movie's own; what they distribute is the balance between three", + "positions. Whether the game's 0.600 mixer gain is a constant or a volume", + "setting is unknown and the port applies no gain of its own." + ], + "1294336": { + "position": "FL/FR", + "weight": 0.4142 + }, + "1118208": { + "position": "FC (LFE silent)", + "weight": 0.2929 + }, + "1171456": { + "position": "BL/BR", + "weight": 0.2929 + } + } } } diff --git a/crates/sylpheed-export/src/audio.rs b/crates/sylpheed-export/src/audio.rs index 8239d2c6..3a8f2273 100644 --- a/crates/sylpheed-export/src/audio.rs +++ b/crates/sylpheed-export/src/audio.rs @@ -124,6 +124,9 @@ pub struct Config { pub se: Vec<(String, CueSpec)>, pub bgm: Vec<(String, BgmSpec)>, pub voice: Presentation, + /// Declared XMA `byte_size` -> stereo-downmix coefficient, from + /// `authored/audio.json`. Empty means no region is weighted. + pub stream_weights: std::collections::BTreeMap, } /// Read `authored/audio.json`, or `None` when there is no such file. @@ -175,10 +178,28 @@ pub fn load(authored: &Path) -> Result> { .with_context(|| format!("authored/audio.json: voice.presentation {v}"))?, None => Presentation::default(), }; + // Declared `byte_size` -> stereo-downmix coefficient. Keyed by size so the + // exporter can CHECK the stream is the one the measurement describes. + let mut stream_weights: std::collections::BTreeMap = Default::default(); + if let Some(serde_json::Value::Object(m)) = file.voice.get("stream_weights") { + for (k, v) in m { + if k == "_" { + continue; + } + let size: usize = k + .parse() + .with_context(|| format!("authored/audio.json: voice.stream_weights key {k}"))?; + let w = v.get("weight").and_then(serde_json::Value::as_f64).with_context(|| { + format!("authored/audio.json: voice.stream_weights.{k} has no numeric weight") + })?; + stream_weights.insert(size, w); + } + } Ok(Some(Config { se: entries(file.se, "se")?, bgm: entries(file.bgm, "bgm")?, voice, + stream_weights, })) } @@ -188,6 +209,10 @@ pub fn load(authored: &Path) -> Result> { /// be a codec artefact. const VORBIS_Q: &str = "5"; +/// Bytes of RIFF header `to_xma_riffs` prepends to a chunk. The decoder's +/// `byte_size` is the payload, so a size comparison must subtract it. +const RIFF_HEADER: usize = 60; + pub struct Exported { pub name: String, pub file: String, @@ -649,6 +674,7 @@ pub fn export_voice( movie: &str, video_duration_s: Option, presentation: Presentation, + stream_weights: &std::collections::BTreeMap, ) -> Result> { use sylpheed_formats::slb::VoiceLang; @@ -839,11 +865,36 @@ pub fn export_voice( for (i, p) in staged.iter().enumerate() { parts.push(format!("[{i}:a]anull{}[m{i}]", fold_of(p))); } - let ins: String = (0..staged.len()).map(|i| format!("[m{i}]")).collect(); - parts.push(format!( - "{ins}amix=inputs={}:normalize=1[a]", - staged.len() - )); + // 🔴 MEASURED POSITIONAL WEIGHTS where every kept stream's declared size + // is in the authored table, and the count divisor otherwise. + // + // The table is keyed by the decoder's own `byte_size`, so this is a + // CHECK and not an assumption: if the streams in front of us are not the + // ones the measurement describes, the sizes do not match and the mix + // falls back. That mattered once already -- on 2026-08-30 these sizes + // did NOT fit the region the resolver returned, which is how a + // 238-packet late start was found. Applied positionally instead, the + // weights would have gone onto the wrong streams in silence. + let sizes: Vec = keep.iter().map(|&i| riffs[i].len() - RIFF_HEADER).collect(); + let ws: Option> = sizes.iter().map(|s| stream_weights.get(s).copied()).collect(); + match ws { + Some(w) if w.len() == staged.len() => { + // Weights sum to one, so the total is the movie's own and what + // they distribute is the balance between three positions. + let terms: Vec = w + .iter() + .enumerate() + .map(|(i, g)| format!("[m{i}]volume={g:.4}[w{i}]")) + .collect(); + parts.extend(terms); + let ins: String = (0..staged.len()).map(|i| format!("[w{i}]")).collect(); + parts.push(format!("{ins}amix=inputs={}:normalize=0[a]", staged.len())); + } + _ => { + let ins: String = (0..staged.len()).map(|i| format!("[m{i}]")).collect(); + parts.push(format!("{ins}amix=inputs={}:normalize=1[a]", staged.len())); + } + } parts.join(";") }; argv.push("-filter_complex".into()); @@ -895,8 +946,8 @@ pub fn export_voice( downmix weights sum to one whatever the assignment, so the total is right and \ the distribution is the only thing unclaimed. ⚠️ The movie's OWN track is WMA \ Pro 5.1 and carries the bed; these streams are additional. {} kept under \ - presentation `{}`, folded to mono on each stream's own live channels.\ - {}{against}", + presentation `{}`, folded to mono on each stream's own live channels. \ + Chunks found, in region order: {}.{}{against}", riffs.len(), staged.len(), match presentation { @@ -904,6 +955,21 @@ pub fn export_voice( Presentation::Loudest => "loudest", Presentation::HighestRate => "highest_rate", }, + // The INVENTORY, not just what was dropped. A reader mapping these + // onto the decoder's own `byte_size` values -- which is how the + // stream-to-speaker assignment is indexed -- needs every chunk's + // size, and the dropped list only ever showed the ones that lost. + (0..all.len()) + .map(|i| { + format!( + "chunk {i} {} B ({:.3} s{})", + riffs[i].len(), + lengths[i], + if silent.contains(&i) { ", SILENT" } else { "" } + ) + }) + .collect::>() + .join("; "), if dropped.is_empty() { String::new() } else { diff --git a/crates/sylpheed-export/src/main.rs b/crates/sylpheed-export/src/main.rs index 21557efb..dd887126 100644 --- a/crates/sylpheed-export/src/main.rs +++ b/crates/sylpheed-export/src/main.rs @@ -451,7 +451,8 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> { // there is no `authored/audio.json` -- the voice binding is decoded, // so the dialogue exports either way and only the choice defaults. let want = audio_cfg.as_ref().map(|c| c.voice).unwrap_or_default(); - match audio::export_voice(&source, out, stem, *len, want)? { + let weights = audio_cfg.as_ref().map(|c| c.stream_weights.clone()).unwrap_or_default(); + match audio::export_voice(&source, out, stem, *len, want, &weights)? { Some(a) => { // 🔴 A TOP-LEVEL WARNING, not just a `why` on the entry. The // export is known to be missing audio the game plays, and diff --git a/docs/port/BLOCKED.md b/docs/port/BLOCKED.md index aab24495..83d3b14d 100644 --- a/docs/port/BLOCKED.md +++ b/docs/port/BLOCKED.md @@ -674,3 +674,25 @@ What would settle it: **any capture of a loading screen with the ring mid-expans The Decoder records these screens as unreachable from the title path, so this may never become answerable, and that is an acceptable outcome — the entry now says what is being withheld rather than implying the question is empty. + +--- + +## `verify-menu-audio`'s dead-press check fails, cause unidentified + +*Derived from HANDOFF `9ca1eb5`. Raised 2026-08-30 by the port. **Mine, not +anybody else's** — filed here so it is not lost, not because it needs an answer +from the Decoder.* + +The check asserts that five presses bound to nothing (`left`, a measured no-op) +produce a Master bus bit-identical to five waits. It now reports DIFFER across +three consecutive runs: divergence at 0.085 s, 92 % of samples differing, and +recording durations of 1.300 s against 1.207 s where they were previously equal. + +🔴 Not diagnosed. Candidates not separated: `formats-pin-2026-08-30`, the +plate-pulse draw path, the static-overlay clock. It is **not** the voice export +change — neither control run plays a voice. + +⚠️ The check's premise is **cross-run bit-determinism**, which is what made it a +strong assertion with no threshold to tune, and also what makes it brittle: +nothing in it verifies that startup is still deterministic. Left failing rather +than silenced. diff --git a/docs/port/DECISIONS.md b/docs/port/DECISIONS.md index b6d53e4d..472699f7 100644 --- a/docs/port/DECISIONS.md +++ b/docs/port/DECISIONS.md @@ -9,7 +9,7 @@ dies, which is what this file is for. -130 sections. Search this before re-deriving anything. +132 sections. Search this before re-deriving anything. * [P0 — the exporter, 2026-08-28](#p0--the-exporter-2026-08-28) * [P1 — Godot draws the screen, 2026-08-28](#p1--godot-draws-the-screen-2026-08-28) @@ -141,6 +141,8 @@ dies, which is what this file is for. * [The resolver starts late, and my "duplicate tail" was a real stream all along](#the-resolver-starts-late-and-my-duplicate-tail-was-a-real-stream-all-along) * [The export knew the voice was incomplete; the runtime did not say so](#the-export-knew-the-voice-was-incomplete-the-runtime-did-not-say-so) * [The voice export is complete — new pin, and the cause was a "within one bank" cap](#the-voice-export-is-complete--new-pin-and-the-cause-was-a-within-one-bank-cap) +* [The positional weights are applied — keyed by byte size, so the key is a check](#the-positional-weights-are-applied--keyed-by-byte-size-so-the-key-is-a-check) +* [🔴 Unexplained: `verify-menu-audio`'s dead-press check has started failing](#unexplained-verify-menu-audios-dead-press-check-has-started-failing) ## P0 — the exporter, 2026-08-28 @@ -7595,3 +7597,62 @@ crate — but it is a different asset than the one the fix was derived on, and t outcome was predicted before it was observed. Recorded as that and nothing more. Oracle rows unmoved; MODDING rules pass. + +## The positional weights are applied — keyed by byte size, so the key is a check + +With the span fixed, `ADV`'s three chunks map onto the Decoder's contexts +**exactly** — each is a declared `byte_size` plus the 60-byte RIFF header +`to_xma_riffs` prepends: + +| chunk | bytes | − 60 | context | position | weight | +|---|---|---|---|---|---| +| 0 | 1 294 396 | 1 294 336 | ctx0 | FL/FR | **0.4142** | +| 1 | 1 118 268 | 1 118 208 | ctx1 | FC, LFE silent | **0.2929** | +| 2 | 1 171 516 | 1 171 456 | ctx2 | BL/BR | **0.2929** | + +`authored/audio.json` gains `voice.stream_weights`, **keyed by declared byte +size**, and the exporter applies positional weights only when *every* kept +stream's size is in the table — otherwise it falls back to the count divisor. + +🔴 **The key is the check.** Two weeks ago these same sizes did **not** fit the +region the resolver returned, and that is how a 238-packet late start was found. +Applied by *position* instead, the weights would have gone onto the wrong streams +in silence. `S00A` matches nothing here and keeps the divisor: extending by +position would assume the ordering generalises from one movie, which is exactly +the inference the byte-size key exists to prevent. + +`ADV` now mixes at 0.4142 / 0.2929 / 0.2929 and lands at **−2.87 dBFS**. + +### ✅ An unlooked-for structural confirmation + +The generated filter folds chunks 0 and 2 from **two** live channels +(`0.5*c0+0.5*c1`) and chunk 1 from **one** (`c0`). `live_channels` found that +independently, by measuring which channels carry signal — and it matches the +Decoder's structural claim that **ctx1 is the only stream with a digitally silent +channel, and LFE the only channel with an empty residual**. Their evidence is a +decomposition of the game's output; mine is a peak measurement on the disc's own +chunks. Different sides, same structure. + +## 🔴 Unexplained: `verify-menu-audio`'s dead-press check has started failing + +Its first assertion — five presses bound to nothing produce a Master bus +**bit-identical** to five waits — now reports DIFFER, reproducibly across three +runs. The two recordings diverge at **0.085 s**, differ on 92 % of samples, and +have different durations (1.300 s against 1.207 s) where they were previously +identical. + +⚠️ **I have not identified the cause and am not guessing at one.** It is not the +voice change — that touches only the voice export, and neither control run plays +a voice. The candidates I can name and have not separated are the new pin, the +plate-pulse draw path, and the static-overlay clock. + +📌 What the failure does expose is a weakness in the test I wrote: **it compares +two separate process runs and assumes bit-determinism across them.** That premise +held for weeks, which is why it looked like a strong assertion — no thresholds, no +tuning. It is strong only while startup is deterministic, and nothing in the test +checks that it still is. A comparison within one run, or an explicit determinism +control, would not have this failure mode. + +Filed rather than patched: silencing it would remove the only check that a dead +press stays silent, and I would rather have a failing check than a passing one +whose premise I have stopped believing.