diff --git a/authored/audio.json b/authored/audio.json index cea0c917..6e25f564 100644 --- a/authored/audio.json +++ b/authored/audio.json @@ -1,113 +1,156 @@ { - "format": "sylpheed.audio/1", + "format": "sylpheed.audio/1", + "_": [ + "Menu audio. EVERY VALUE IN THIS FILE IS MEASURED OR CHOSEN -- none of it is", + "in a data file the exporter can read, which is why it is here and not in the", + "exporter. `measured` and `chosen` are NOT the same thing and this file keeps", + "them apart: a measurement is deleted when the disc states it, a choice is", + "deleted when somebody measures it.", + "", + "Two different kinds of not-on-the-disc live in this file and they are not", + "interchangeable:", + "", + " * `se` -- MEASURED. `Static.slb` is a delimiter-less run of whole 2048-byte", + " XMA1 packets: no RIFF, no seek chunk, no XACT container. A wave is defined", + " ONLY by (offset, packet_count), and both numbers come from the running", + " game, not from the file. HANDOFF Q8. Delete a row the day a table on the", + " disc states the same thing.", + "", + " * `bgm` -- MEASURED, and only the LOOP POLICY beside it is chosen. HANDOFF", + " Q10's negative is about the TABLES: `SOUNDS`, `FILES` and the bank headers", + " name no screen. The executable does -- cue 1103 = `BGM_103`, corroborated", + " by a byte-for-byte match against what the XMA probe saw at the main menu.", + " An earlier draft read the negative as unbounded, picked a track at random", + " and called it authored. See the `bgm._` block for what that cost.", + "", + "The exporter reads this file and emits `export/audio/**` from it. It holds no", + "cue table of its own: a measured offset compiled into a Rust `const` is a", + "measurement wearing the costume of a decoded field, and MISSION section 3 is", + "explicit that measured values live here." + ], + "se": { "_": [ - "Menu audio. EVERY VALUE IN THIS FILE IS MEASURED OR CHOSEN -- none of it is", - "in a data file the exporter can read, which is why it is here and not in the", - "exporter. `measured` and `chosen` are NOT the same thing and this file keeps", - "them apart: a measurement is deleted when the disc states it, a choice is", - "deleted when somebody measures it.", - "", - "Two different kinds of not-on-the-disc live in this file and they are not", - "interchangeable:", - "", - " * `se` -- MEASURED. `Static.slb` is a delimiter-less run of whole 2048-byte", - " XMA1 packets: no RIFF, no seek chunk, no XACT container. A wave is defined", - " ONLY by (offset, packet_count), and both numbers come from the running", - " game, not from the file. HANDOFF Q8. Delete a row the day a table on the", - " disc states the same thing.", - "", - " * `bgm` -- MEASURED, and only the LOOP POLICY beside it is chosen. HANDOFF", - " Q10's negative is about the TABLES: `SOUNDS`, `FILES` and the bank headers", - " name no screen. The executable does -- cue 1103 = `BGM_103`, corroborated", - " by a byte-for-byte match against what the XMA probe saw at the main menu.", - " An earlier draft read the negative as unbounded, picked a track at random", - " and called it authored. See the `bgm._` block for what that cost.", - "", - "The exporter reads this file and emits `export/audio/**` from it. It holds no", - "cue table of its own: a measured offset compiled into a Rust `const` is a", - "measurement wearing the costume of a decoded field, and MISSION section 3 is", - "explicit that measured values live here." + "MEASURED, HANDOFF Q8, and the RE agent retracted an earlier 'cannot be", + "extracted' to publish these. The waves were located BY PLAYING THEM: Canary", + "with `--xma_param_probe=true` prints a stream's packet count and first 32", + "bytes when it is played, and searching those bytes in the bank gives the", + "offset.", + "", + "WARNING, from the same finding: the file order is NOT cue-id order. These", + "cannot be counted out, and an index here would be a fabrication.", + "", + "`name_match` is the authors' own identifier GUESSED BY NAME. It is carried", + "so the guess is not lost and is never presented as the measurement. Where", + "the RE agent did not separate two candidates, there is no name at all --", + "an absent `name_match` means nobody has claimed one, never that the", + "BINDING is unknown. The binding is the measured part.", + "", + "All three are mono 48 kHz; that is the RE agent's statement in", + "`sylpheed_formats::media::se_wave_riff`, not something re-derived here." ], - - "se": { - "_": [ - "MEASURED, HANDOFF Q8, and the RE agent retracted an earlier 'cannot be", - "extracted' to publish these. The waves were located BY PLAYING THEM: Canary", - "with `--xma_param_probe=true` prints a stream's packet count and first 32", - "bytes when it is played, and searching those bytes in the bank gives the", - "offset.", - "", - "WARNING, from the same finding: the file order is NOT cue-id order. These", - "cannot be counted out, and an index here would be a fabrication.", - "", - "`name_match` is the authors' own identifier GUESSED BY NAME. It is carried", - "so the guess is not lost and is never presented as the measurement. Where", - "the RE agent did not separate two candidates, there is no name at all --", - "an absent `name_match` means nobody has claimed one, never that the", - "BINDING is unknown. The binding is the measured part.", - "", - "All three are mono 48 kHz; that is the RE agent's statement in", - "`sylpheed_formats::media::se_wave_riff`, not something re-derived here." - ], - "move": { - "bank": "Static.slb", - "offset": "0x1ec0", - "packets": 4, - "channels": 1, - "rate": 48000, - "name_match": "SE_UI_CURSOR", - "why": "HANDOFF Q8, measured: the d-pad move cue, 8 192 B / 0.533 s, reproduced across two boots. Left/right play nothing at all, which is a measurement too and is why there is no `left`/`right` row here rather than a silent file." - }, - "confirm": { - "bank": "Static.slb", - "offset": "0x5d6c0", - "packets": 6, - "channels": 1, - "rate": 48000, - "why": "HANDOFF Q8, measured: the (A) confirm cue, 12 288 B / 1.016 s. NO `name_match`: Q8 is explicit that (A)'s wave was not separated between `SE_UI_DECIDE` and `SE_UI_SUB_WIN_OPN`, so naming it would invent the one thing the measurement did not settle." - }, - "back": { - "bank": "Static.slb", - "offset": "0x0ec0", - "packets": 2, - "channels": 1, - "rate": 48000, - "why": "HANDOFF Q8, measured: the (B) back cue, 4 096 B / 0.344 s, reproduced across two boots. No `name_match` for the same reason as `confirm` -- Q8 names no identifier for it." - } + "move": { + "bank": "Static.slb", + "offset": "0x1ec0", + "packets": 4, + "channels": 1, + "rate": 48000, + "name_match": "SE_UI_CURSOR", + "why": "HANDOFF Q8, measured: the d-pad move cue, 8 192 B / 0.533 s, reproduced across two boots. Left/right play nothing at all, which is a measurement too and is why there is no `left`/`right` row here rather than a silent file." }, - - "bgm": { - "_": [ - "MEASURED, NOT CHOSEN -- and the port got this wrong for one iteration.", - "", - "`docs/port/BLOCKED.md` carried a row reading 'not on the disc ... the port", - "is choosing a track, and that choice is authored', and the first draft of", - "this file duly picked BGM_001 and labelled it arbitrary. That row was not", - "stale: `BGM_103` is in HANDOFF at `9ca1eb5`, which is the exact commit the", - "row says it was reconciled against. It was WRONG WHEN WRITTEN.", - "", - "What HANDOFF actually says is a negative with a stated reach, and the reach", - "is what the port dropped: the *tables* cannot say which BGM a screen plays", - "-- `SOUNDS`, `FILES` and the bank headers name no screen. The EXECUTABLE", - "can. `GamePart_Title`'s phase handler `sub_821C5580` carries `li r5, 1103`", - "into a sound call, cue 1103 is `BGM_103`, and `BGM_103.slb`'s two declared", - "waves (3 876 864 / 3 930 112 B) are byte-for-byte the two streams the XMA", - "probe saw decoding at the main menu. Static code, disc census and runtime", - "agree. HANDOFF's own words: 'The port does not have to choose a track.'", - "", - "So this section is a CITATION, not a decision. It lives in `authored/`", - "only because the binding is in the .xex and the exporter reads data files,", - "not code -- and it must be deleted the day something the exporter can read", - "states it. The loop policy below IS still a decision." - ], - "main_menu": { - "bank": "BGM_103.slb", - "loop": "restart", - "kind": "measured", - "why": "MEASURED, HANDOFF Q10 -- NOT a port choice. `GamePart_Title`'s phase handler `sub_821C5580` plays cue 1103 = `BGM_103`, and `BGM_103.slb`'s two declared waves (3 876 864 / 3 930 112 B) are byte-for-byte the two streams the XMA probe saw decoding at the main menu. Static code, disc census and runtime all agree; see docs/re/menu-audio-cues.md and docs/re/structures/bgm-two-stems.md. The name carries its `.slb` extension because that is what `sound.pak` hashes -- `BGM_103` alone resolves to nothing, which is how the first draft of this file failed.", - "loop_why": "AUTHORED, and it will be AUDIBLY WRONG AT THE SEAM. HANDOFF Q10: no loop-point field has been identified anywhere, so the file carries no marked restart. `restart` replays from sample 0, which means the listener hears the track's own fade-out and the silence after it before the music comes back. Trimming to the fade would sound better and would be worse: it would INVENT a loop point, and an invented one is indistinguishable from a decoded one a month later. The seam stays ugly until a loop point is measured or a capture of the real menu looping settles it.", - "stems": "sum", - "stems_why": "MEASURED, HANDOFF Q10: a bank is exactly TWO waves of identical duration (32/32 banks on the disc), sample-synchronous -- transient correlation peaks at lag 0.00 s over +/-5 s and both stop at the same millisecond. Concatenating them plays the piece twice, the second time as a bass-less stem; that was the previous reading and it is refuted. Emitting two files would be wrong for a second reason: MODDING rule 1 is one logical asset, one file, and handing a modder two stems to line up by hand is the reassembly the exporter exists to have already done. WHAT IS SUMMED IS SETTLED; WHAT WAVE 1 IS, IS NOT -- HANDOFF calls it quieter, far more L/R-decorrelated and almost bass-free, so it reads as a surround-rear pair OR a second intensity layer, and `ChannelMask` is 0x0002 on both so the file will not say. A unity sum is right under either reading; a weighting would only be justified once that is settled." - } + "confirm": { + "bank": "Static.slb", + "offset": "0x5d6c0", + "packets": 6, + "channels": 1, + "rate": 48000, + "why": "HANDOFF Q8, measured: the (A) confirm cue, 12 288 B / 1.016 s. NO `name_match`: Q8 is explicit that (A)'s wave was not separated between `SE_UI_DECIDE` and `SE_UI_SUB_WIN_OPN`, so naming it would invent the one thing the measurement did not settle." + }, + "back": { + "bank": "Static.slb", + "offset": "0x0ec0", + "packets": 2, + "channels": 1, + "rate": 48000, + "why": "HANDOFF Q8, measured: the (B) back cue, 4 096 B / 0.344 s, reproduced across two boots. No `name_match` for the same reason as `confirm` -- Q8 names no identifier for it." } + }, + "bgm": { + "_": [ + "MEASURED, NOT CHOSEN -- and the port got this wrong for one iteration.", + "", + "`docs/port/BLOCKED.md` carried a row reading 'not on the disc ... the port", + "is choosing a track, and that choice is authored', and the first draft of", + "this file duly picked BGM_001 and labelled it arbitrary. That row was not", + "stale: `BGM_103` is in HANDOFF at `9ca1eb5`, which is the exact commit the", + "row says it was reconciled against. It was WRONG WHEN WRITTEN.", + "", + "What HANDOFF actually says is a negative with a stated reach, and the reach", + "is what the port dropped: the *tables* cannot say which BGM a screen plays", + "-- `SOUNDS`, `FILES` and the bank headers name no screen. The EXECUTABLE", + "can. `GamePart_Title`'s phase handler `sub_821C5580` carries `li r5, 1103`", + "into a sound call, cue 1103 is `BGM_103`, and `BGM_103.slb`'s two declared", + "waves (3 876 864 / 3 930 112 B) are byte-for-byte the two streams the XMA", + "probe saw decoding at the main menu. Static code, disc census and runtime", + "agree. HANDOFF's own words: 'The port does not have to choose a track.'", + "", + "So this section is a CITATION, not a decision. It lives in `authored/`", + "only because the binding is in the .xex and the exporter reads data files,", + "not code -- and it must be deleted the day something the exporter can read", + "states it. The loop policy below IS still a decision." + ], + "main_menu": { + "bank": "BGM_103.slb", + "loop": "restart", + "kind": "measured", + "why": "MEASURED, HANDOFF Q10 -- NOT a port choice. `GamePart_Title`'s phase handler `sub_821C5580` plays cue 1103 = `BGM_103`, and `BGM_103.slb`'s two declared waves (3 876 864 / 3 930 112 B) are byte-for-byte the two streams the XMA probe saw decoding at the main menu. Static code, disc census and runtime all agree; see docs/re/menu-audio-cues.md and docs/re/structures/bgm-two-stems.md. The name carries its `.slb` extension because that is what `sound.pak` hashes -- `BGM_103` alone resolves to nothing, which is how the first draft of this file failed.", + "loop_why": "AUTHORED, and it will be AUDIBLY WRONG AT THE SEAM. HANDOFF Q10: no loop-point field has been identified anywhere, so the file carries no marked restart. `restart` replays from sample 0, which means the listener hears the track's own fade-out and the silence after it before the music comes back. Trimming to the fade would sound better and would be worse: it would INVENT a loop point, and an invented one is indistinguishable from a decoded one a month later. The seam stays ugly until a loop point is measured or a capture of the real menu looping settles it.", + "stems": "sum", + "stems_why": "MEASURED, HANDOFF Q10: a bank is exactly TWO waves of identical duration (32/32 banks on the disc), sample-synchronous -- transient correlation peaks at lag 0.00 s over +/-5 s and both stop at the same millisecond. Concatenating them plays the piece twice, the second time as a bass-less stem; that was the previous reading and it is refuted. Emitting two files would be wrong for a second reason: MODDING rule 1 is one logical asset, one file, and handing a modder two stems to line up by hand is the reassembly the exporter exists to have already done. WHAT IS SUMMED IS SETTLED; WHAT WAVE 1 IS, IS NOT -- HANDOFF calls it quieter, far more L/R-decorrelated and almost bass-free, so it reads as a surround-rear pair OR a second intensity layer, and `ChannelMask` is 0x0002 on both so the file will not say. A unity sum is right under either reading; a weighting would only be justified once that is settled." + } + }, + "voice": { + "_": [ + "WHICH PRESENTATION of a cutscene's voice to export. CHOSEN, not measured and", + "not decoded -- delete this the day a capture says which one the game plays.", + "", + "A voice region carries THREE PRESENTATIONS OF ONE TAKE (decoded disc-wide by", + "the Decoder: 258 inter-descriptor spans hold one stream, 28 hold three, none", + "holds two). The exporter drops the leading one, which this port measured to", + "be the tail of the others, and must then pick between the two full-length", + "ones. Nothing on the disc ranks them: wEncodeOptions, channel count and", + "channel mask are byte-identical across them.", + "", + "This lives here rather than in the exporter because MISSION section 3 puts a", + "value we chose in authored/ with a why. A selection rule compiled into Rust", + "reads like something the exporter derived, and nobody deletes what nobody", + "can see." + ], + "presentation": "loudest", + "presentation_why": [ + "`loudest` = the full-length stream whose peak is nearest full scale.", + "", + "IT USED TO BE `highest_rate`, on the Decoder's recommendation, and that", + "recommendation was WITHDRAWN as self-contradictory -- it read 'the", + "highest-rate, highest-gain one is chunk 1', and those two criteria select", + "different streams: ADV chunk 1 is 1118268 B at 0.0 dBFS, chunk 2 is", + "1171516 B at -8.3 dBFS.", + "", + "The reason for switching is a measurement of this port's, not a preference.", + "The case for `highest_rate` was that more bytes per second means a better", + "encode. It does not, here: ADV chunk 1 is MONO-IN-STEREO (channel 2", + "digitally silent) and chunk 2 is DUAL-MONO (both channels identical at", + "-8.318574). So chunk 2's extra bytes go into encoding a duplicate of its own", + "channel, not into fidelity, and the byte-rate difference is explained without", + "any appeal to quality. That removes the only argument for it.", + "", + "What is left points the other way, and both parts are weak on their own:", + "the disc masters its other audio near full scale (the SE cues decode to", + "+0.18 dBFS), and S00A's only surviving full-length stream is its louder one", + "at -4.2 dBFS -- so `loudest` makes the two cutscenes' dialogue sit at", + "comparable levels instead of 4.4 dB apart.", + "", + "STILL A CHOICE. One capture of the intro with dialogue audible settles it,", + "and it is the last unforced decision in the voice pipeline." + ] + } } diff --git a/crates/sylpheed-export/src/audio.rs b/crates/sylpheed-export/src/audio.rs index e6b17673..b7a69bf1 100644 --- a/crates/sylpheed-export/src/audio.rs +++ b/crates/sylpheed-export/src/audio.rs @@ -83,10 +83,28 @@ pub struct BgmSpec { pub stems_why: Option, } +/// Which of a voice region's full-length presentations to export. +/// +/// A region carries three presentations of one take and **nothing on the disc +/// ranks them** β€” `wEncodeOptions`, channel count and channel mask are +/// byte-identical across them. So this is a CHOICE, it lives in +/// `authored/audio.json` with its `why`, and it is deleted the day a capture +/// says which one the game plays. +#[derive(Deserialize, Default, Clone, Copy, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Presentation { + /// Peak nearest full scale. + #[default] + Loudest, + /// Most bytes per second. + HighestRate, +} + /// `authored/audio.json`, with the documentation keys dropped. pub struct Config { pub se: Vec<(String, CueSpec)>, pub bgm: Vec<(String, BgmSpec)>, + pub voice: Presentation, } /// Read `authored/audio.json`, or `None` when there is no such file. @@ -104,6 +122,8 @@ pub fn load(authored: &Path) -> Result> { se: BTreeMap, #[serde(default)] bgm: BTreeMap, + #[serde(default)] + voice: BTreeMap, } let raw = std::fs::read_to_string(&path) .with_context(|| format!("read {}", path.display()))?; @@ -128,9 +148,18 @@ pub fn load(authored: &Path) -> Result> { .collect() } + // Absent means `loudest`, which is what the file says today. A default here + // is safe in a way a default matrix is not: the manifest records which + // presentation was taken and why, on every entry. + let voice = match file.voice.get("presentation") { + Some(v) => serde_json::from_value(v.clone()) + .with_context(|| format!("authored/audio.json: voice.presentation {v}"))?, + None => Presentation::default(), + }; Ok(Some(Config { se: entries(file.se, "se")?, bgm: entries(file.bgm, "bgm")?, + voice, })) } @@ -535,6 +564,7 @@ pub fn export_voice( out: &Path, movie: &str, video_duration_s: Option, + presentation: Presentation, ) -> Result> { use sylpheed_formats::slb::VoiceLang; @@ -590,20 +620,25 @@ pub fn export_voice( // 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. + // WHICH of the equal-duration survivors is a CHOICE, and it lives in + // `authored/audio.json` rather than here -- see [`Presentation`]. It was + // `highest_rate` on the Decoder's recommendation until that was withdrawn as + // self-contradictory, and the reason it is now `loudest` is a measurement: + // `ADV`'s louder presentation is mono-in-stereo while its higher-rate one is + // DUAL-MONO, so the extra bytes encode a duplicate channel rather than + // fidelity, and the rate difference is explained without appealing to + // quality at all. 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 chosen = match presentation { + Presentation::HighestRate => tied.iter().copied().max_by_key(|&i| riffs[i].len()), + Presentation::Loudest => tied + .iter() + .copied() + .max_by(|&a, &b| probed[a].1.total_cmp(&probed[b].1)), + }; + let keep: Vec = chosen.into_iter().collect(); let dropped: Vec = (0..all.len()) .filter(|i| !keep.contains(i)) .map(|i| { @@ -708,14 +743,19 @@ pub fn export_voice( 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 \ + and colours it. 🟑 WHICH of the equal-duration survivors is kept is a CHOICE, \ + not a decoded field -- authored/audio.json voice.presentation = {:?}, with its \ + why. Nothing on the disc ranks the presentations: wEncodeOptions, channel count \ + and channel mask are byte-identical across them. One capture of the movie with \ + dialogue audible deletes that entry.{} 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(), + match presentation { + Presentation::Loudest => "loudest", + Presentation::HighestRate => "highest_rate", + }, if dropped.is_empty() { String::new() } else { diff --git a/crates/sylpheed-export/src/main.rs b/crates/sylpheed-export/src/main.rs index 563048ee..746a0d08 100644 --- a/crates/sylpheed-export/src/main.rs +++ b/crates/sylpheed-export/src/main.rs @@ -241,8 +241,35 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> { // Derived output is regenerated wholesale: clear it, so a screen that stops // being exported stops existing rather than lingering as a stale file that // still validates. + // + // πŸ”΄ EXCEPT `video/`, and leaving it out was a bug that hid in plain sight. + // `video::transcode` has always carried a cache -- it writes a `.cmd` + // sidecar with the exact command, the source size and the channel count, and + // skips the encode when all three still match. Its own doc comment says + // "without it every re-export pays ~4 minutes to produce a byte-identical + // file". **This wipe deleted the sidecar and the output immediately before + // the check, so the cache had never hit once.** Six exports in one session + // paid ~48 minutes of Theora to produce five byte-identical files, and + // nothing reported it: the cache is silent when it works and silent when it + // does not. + // + // The wholesale guarantee is kept rather than weakened -- everything else is + // still cleared outright, and `prune_videos` below deletes any file in + // `video/` that this run did not claim, so a movie that stops being exported + // still stops existing. if out.exists() { - std::fs::remove_dir_all(&out).context("clear the output tree")?; + for entry in std::fs::read_dir(&out).context("clear the output tree")? { + let entry = entry?; + if entry.file_name() == "video" { + continue; + } + if entry.file_type()?.is_dir() { + std::fs::remove_dir_all(entry.path()) + } else { + std::fs::remove_file(entry.path()) + } + .with_context(|| format!("clear {}", entry.path().display()))?; + } } std::fs::create_dir_all(&out)?; @@ -332,13 +359,15 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> { None => println!(" video {} not on this disc -- skipped", m.src), } } + prune_videos(out, &videos)?; // P6. Both tables are AUTHORED, for two different reasons -- the cue offsets // because they were measured off the running game and are on the disc in no // findable form, the BGM choice because HANDOFF Q10 is a negative and // nothing states which track a menu plays. See `authored/audio.json`. let mut audio = Vec::new(); - match audio::load(authored_dir)? { + let audio_cfg = audio::load(authored_dir)?; + match &audio_cfg { None => println!(" no authored/audio.json -- no audio exported"), Some(cfg) => { let source = media::DirectorySource::new(disc); @@ -406,7 +435,11 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> { { let source = media::DirectorySource::new(disc); for (stem, len) in &movie_lengths { - match audio::export_voice(&source, out, stem, *len)? { + // The presentation choice is AUTHORED and this block runs even when + // 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)? { Some(a) => { println!( " voice {:<8} -> {} ({}, {} region chunk(s))", @@ -475,3 +508,38 @@ fn describe(a: &audio::Exported) -> String { None => peak, } } + + +/// Delete anything in `video/` this run did not produce. +/// +/// `video/` is the one directory the wholesale wipe spares, so that the +/// transcode cache survives to be consulted. This restores the guarantee the +/// wipe exists for: a movie that stops being exported stops existing, rather +/// than lingering as a file the manifest no longer lists. +fn prune_videos(out: &Path, kept: &[ManifestVideo]) -> Result<()> { + let dir = out.join("video"); + if !dir.exists() { + return Ok(()); + } + let mut keep: Vec = Vec::new(); + for v in kept { + if let Some(name) = Path::new(&v.file).file_name() { + let name = name.to_string_lossy().into_owned(); + keep.push(name.clone()); + // The cache sidecar goes with the file it stamps. + if let Some(stem) = Path::new(&name).file_stem() { + keep.push(format!("{}.cmd", stem.to_string_lossy())); + } + } + } + for entry in std::fs::read_dir(&dir)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().into_owned(); + if keep.contains(&name) { + continue; + } + println!(" video {name} is no longer exported -- removed"); + let _ = std::fs::remove_file(entry.path()); + } + Ok(()) +} diff --git a/docs/port/BLOCKED.md b/docs/port/BLOCKED.md index 8be5027b..79efb592 100644 --- a/docs/port/BLOCKED.md +++ b/docs/port/BLOCKED.md @@ -113,7 +113,8 @@ HANDOFF. | P6 BGM β€” the sub-wave count | **is a music bank's LEADING REGION a stem, or a decoder artefact?** | Q10 | πŸ”΄ **HANDOFF and the decoders disagree, and P6 ships the disagreement.** `media::sound_bank_riffs("BGM_103.slb")` returns **three** sub-waves; HANDOFF Q10's census says a music bank is *"exactly two waves of identical duration (32/32 banks on the disc)"*. The third comes from `slb.rs:380` `to_xma_riffs`, whose hybrid branch emits a leading headerless packet region ahead of the `RIFF` waves β€” and `docs/re/REFUTED.md` already records that region as what makes `BGM_106`–`BGM_109` *"break the two-wave rule"*. Derived at HANDOFF `9ca1eb5`. **The exporter sums all three and writes a manifest warning**, because choosing which sub-wave to drop is a decoding question and MISSION Β§2 forbids this exporter answering one. So the menu currently plays a sum of three things where the census predicts two. What settles it: whether that leading region carries music. Raised with the Decoder 2026-08-29. | | ~~P3 β€” the plate's ONSET~~ | ~~visible 2.13 s after settle, or group starts then?~~ | Q2 | βœ… **resolved 2026-08-29, and the answer is AUTHOR NOTHING.** The port's refutation held and produced a better answer than either option it offered. Correction at `5b0a6e6` on `auto/no-disc-and-menu-captures`: **both builds run on one clock, started together**, and the plate arrives at its own declared `t=238`. Checked against this export rather than taken on trust β€” build 4's visible build-in ends at `t=118` (`pteff01`, `pteff02`, `ptlogoall_eff` finish together), `ptbtn00` reaches alpha 255 at `t=238`, difference **120 units = 2.000 s**, against a measured 2.138 / 2.132 s at an emulator presenting 28.1 fps rather than 30. The 2.13 s constant is **deleted**. | -| P3/P5 β€” `settle_time()` | **`rest.t` is not when a screen settles, and the port's sequencer uses it** | β€” | πŸ”΄ **new, mine, and it is the premise that produced the wrong plate arithmetic.** `5b0a6e6` states it plainly: `rest.t` is the last *hold* keyframe before the exit. `title`'s `ptlogo1` has `rest.t=251` and stops **moving** at `t=42`, then creeps 5 px while its alpha crawls `0xe0`β†’`0xff` over 209 units. `ScreenView.settle_time()` returns the max `rest.t` β€” **4.350 s** for the title where the visible build-in is over at `t=118` = **1.967 s**. Everything the boot sequencer paces off that landmark is therefore late: `publisher_logo` is held to t=235, `developer_logos` to t=190, and `_script_settled` waits on it too. ⚠️ **Not changed this iteration**, because "visible arrival" is a heuristic and getting it wrong re-paces every screen; it needs its own gate. **What settles it outright:** the oracle is live β€” a timed boot with screen-to-screen transitions would give the real dwells directly. Asked 2026-08-29. | +| ~~P3/P5 β€” `settle_time()`~~ | ~~`rest.t` is not when a screen settles, and the port's sequencer uses it~~ | β€” | βœ… **MEASURED 2026-08-29 and the row was HALF WRONG β€” mine.** The Decoder took it on a cold profile with no shader cache (`auto/no-disc-and-menu-captures` at `4bd4779`, `docs/re/boot-settle-times-measured.md`). The principle holds: the title's `rest.t` is 251 units = **4.183 s** where its art finishes at ~2 s. **But "everything the sequencer paces off that landmark is therefore late" does not.** Measured the port the way the game was measured β€” visible span, `--film` at 4 fps β€” the publisher wordmark runs **4.25 s** against the game's 4.297/4.604/4.370 and the developer logos **3.50 s** against 3.508/3.503/3.366. Dead on. My earlier reading compared the port's *arrival-to-arrival* timestamps against the game's *visible spans*, which differ by the exit ramp plus the black hold β€” the whole of the discrepancy I was about to chase. `rest.t` is still the wrong landmark; its blast radius is `_script_settled` waiting longer than it needs to, which is a slow test and not a wrong frame. `dwell_seconds` stays `null`, now for a measured reason. πŸ”΄ **Do not author an β’Άβ†’menu dwell**: it measures 3.763 s and contains a 1.53 s guest load stall, third independent reproduction. 🟑 Menu build-in 0.531 s and β’·β†’title 0.482 s rest on one run and are not authored; the port is within ~0.1 s of both from the disc. | +| P4/P7 β€” which voice presentation | **which of a region's two full-length streams does the game play?** | β€” | 🟑 **the last unforced decision in the voice pipeline, and it is now unambiguously the port's.** The Decoder's "highest byte rate" was **withdrawn as self-contradictory** β€” its sentence read *"the highest-rate, highest-gain one is chunk 1"*, and those select different streams (`ADV` chunk 1: 1 118 268 B at 0.0 dBFS; chunk 2: 1 171 516 B at βˆ’8.3). Nothing on the disc ranks them: `wEncodeOptions`, channel count and channel mask are byte-identical. Moved to `authored/audio.json` `voice.presentation` per MISSION Β§3, set to **`loudest`**, and the reason is a measurement of mine rather than a preference: chunk 1 is **mono-in-stereo** and chunk 2 is **dual-mono**, so chunk 2's extra bytes encode a duplicate channel rather than fidelity β€” which explains the rate difference and removes the only argument for it. What settles it: **one capture of the intro with dialogue audible.** That did not ride the settle-time boot, which drove the title path and never played the movie with audio. | | ~~P3/P5 β€” the title plate~~ | ~~does the idle title show `PRESS β’Ά`~~ | Q2 | βœ… **answered and TAKEN at this iteration.** `auto/no-disc-and-menu-captures` at `fb536df`, `docs/re/title-plate-delay-measured.md`, traces in `docs/re/data/plate-timing-run{1,2}.tsv`. It is the third case: build 4 alone, then the plate composited over it. ⚠️ The delay is timed from where build 4 **stops animating**, not from where it first appears β€” measured the other way the two runs differ by 0.48 s against 6 ms. `ScreenView` now draws two builds at once, as a second `ScreenView` in the same `SubViewport` rather than a subordinate screen inside one. The onset question above is what is left. | | P3 β€” the plate's PULSE | **does the plate's focus record loop, and with what period?** | Q2 | ❔ **open, and the port's earlier reading of it was wrong.** The port had looked for the pulse in `ptbtn00`'s own group; `5b0a6e6` identifies it as the plate's **focus record** `ptbtn00f` β€” a glow ramping alpha `0x00`β†’`0x50` and back, t=6…105. Measured on the running game at 2.12 / 2.19 / 2.34 / 2.31 s, mean **2.24 s**. 🟑 **The port has not taken it.** Looping that record needs a period, and its group is 105 timed units plus the **authored** 24-unit exit ramp = 129 units = 2.15 s β€” composing an authored constant with a loop assumption to land on a measured number is tuning, not measuring. Separately: the port draws no focus record on `press_start` at all, because the screen has no `buttons` and nothing is focused, so *whether the game always draws it* is its own question. | diff --git a/docs/port/DECISIONS.md b/docs/port/DECISIONS.md index 2616912c..74e646e1 100644 --- a/docs/port/DECISIONS.md +++ b/docs/port/DECISIONS.md @@ -2790,3 +2790,135 @@ on the first movie anybody checked. ❔ **Why the disc stores three presentations at all is unanswered**, by either of us. + +## The transcode cache had never hit, because the wipe ran first + +`video::transcode` has carried a cache since P4. It writes a `.cmd` sidecar with +the exact ffmpeg command, the source's byte count and its channel count, and +skips the encode when all three still match. Its doc comment says why: *"without +it every re-export pays ~4 minutes to produce a byte-identical file, and an +exporter nobody re-runs is worse than a cache."* + +**It had never hit once.** `main.rs` clears the output tree wholesale β€” and the +`remove_dir_all` runs immediately before the check, deleting the sidecar and the +output it stamps. The cache tested a file it had just erased. + +This session ran the exporter **six times** and paid the full Theora encode every +one of them, producing five byte-identical files. Roughly 48 minutes. Nothing +reported it, and nothing could have: a cache is silent when it works and silent +when it does not, and the only symptom is a wall-clock cost that looks like the +job simply being slow. + +⚠️ **It is worth being specific about how this hid**, because the ingredients are +ordinary. The cache is correct. The wipe is correct. Each carries a doc comment +explaining itself, and neither mentions the other. The defect exists only in +their ordering, which is stated in neither, and the cost is invisible in every +artefact the export produces β€” the tree is byte-identical either way. + +### The fix keeps the wholesale guarantee rather than trading it away + +The obvious repair β€” stop wiping β€” would break what the wipe is for: *a screen +that stops being exported stops existing, rather than lingering as a stale file +that still validates.* So the wipe now spares exactly `video/`, and +`prune_videos` deletes anything in it this run did not claim. Everything else is +still cleared outright. + +That is a **cache, not a hand-edit**, and the distinction matters against +MISSION Β§3: nothing in `export/` is authored, the sidecar is derived from the +command the exporter itself computed, and any change to the command, the source +size or the channel count re-encodes. A modder who edits an `.ogv` by hand gets +it overwritten on the next export, exactly as before. + +🟑 **Not measured yet:** that a cached run reproduces the same tree. The claim is +structural β€” the skip is keyed on the whole command string β€” but "the second run +produces the same bytes" is checkable and has not been checked. + +## `settle_time()` β€” the answer arrived, and it refutes my own πŸ”΄ more than it confirms it + +The Decoder took the port's top ask and measured the boot on a cold profile with +no shader cache (`auto/no-disc-and-menu-captures` at `4bd4779`, +`docs/re/boot-settle-times-measured.md`). It confirms the *principle* I filed: +the title's `rest.t` is 251 units = **4.183 s** where its art is finished at about +2 s, so `rest.t` is not when a screen arrives. + +**But my row said more than that**, and the extra part is wrong. It said +*"everything the boot sequencer paces off that landmark is therefore late"*, and +named `publisher_logo` and `developer_logos`. So I measured the port the way the +game was measured β€” **visible span, not arrival-to-arrival** β€” with `--film` at +4 fps and a per-frame greyscale mean: + +| | port, visible span | game, three cold boots | | +|---|---|---|---| +| publisher wordmark | **4.25 s** | 4.297 / 4.604 / 4.370 | 0.05 s under the lowest | +| developer logos | **3.50 s** | 3.508 / 3.503 / 3.366 | **dead on** | +| black hold between | β‰ˆ0.25 s | 0.2 – 0.3 s | inside | +| title settled β†’ plate | 2.000 s (declared 120 units) | 2.247 s | inside, at ~28 fps presentation | + +**The splashes are not late. They match.** ⚠️ And the reason my earlier reading +said otherwise is worth keeping: I had compared the port's *transition +timestamps* β€” 4.68 s and 3.94 s, arrival to arrival β€” against the game's +*visible spans*. Those differ by the exit ramp plus the black hold, about 0.6 s, +which is the whole of the discrepancy I was about to chase. This corpus has been +bitten by exactly this before, in the plate delay: *"timed from where build 4 +stops animating, not from where it first appears β€” measured the other way the two +runs differ by 0.48 s against 6 ms."* + +So the port paces the boot correctly, and **`rest.t` is a wrong landmark whose +blast radius is much smaller than I claimed**: on the screens the sequencer +actually advances off, `rest.t` plus the 24-unit exit ramp lands where the game +lands. What it still affects is `_script_settled`, which waits longer than it +needs to before photographing β€” a slow test, not a wrong frame. + +### `dwell_seconds` stays `null`, and the question is now closed rather than open + +`authored/timing.json` says of it: *"If a capture ever times the real boot, this +is where that number goes."* A capture has now timed the real boot, and the +answer is that **nothing goes there** β€” the disc's own keyframe groups reproduce +the game's dwells to 0.05 s and 0.01 s. The field stays `null` for a measured +reason instead of an absence of one. + +### Taken from the same page, and not taken + +* βœ… **The 120-unit plate delay stands.** The Decoder ran a refutation of it that + failed instructively: its probe's `title_static` mark gave 3.203 s, which on a + cold boot looks like a real effect. It was the instrument β€” the mark fires + during the crossfade out of the attract movie, with the glyph count still 0. + Re-measured from content: 2.247 s. **The port changes nothing**, and the + declared 120 units is what it keeps. +* πŸ”΄ **No β’Άβ†’menu dwell is authored.** It measured 3.763 s and contains a 1.53 s + guest load stall β€” the third independent reproduction of that stall, this one + on a cold cache, so it is not a warm-cache artefact. It is emulator time, not a + game constant. +* 🟑 **Menu build-in 0.531 s and β’·β†’title 0.482 s are not authored either**, and + that is the Decoder's own caveat rather than my caution: they rest on one run, + where the plate delay and the load stall are each cross-checked against + independent prior evidence. The port is within ~0.1 s of both with its existing + 24-unit exit ramp, so authoring them would replace a disc-derived number with a + provisional measured one and gain nothing measurable. + +## The voice presentation is now unambiguously the port's choice, and the recommendation behind it was withdrawn + +The Decoder has withdrawn "highest byte rate": its sentence read *"the +highest-rate, highest-gain one is chunk 1"*, and those two criteria select +**different streams** β€” `ADV` chunk 1 is 1 118 268 B at 0.0 dBFS, chunk 2 is +1 171 516 B at βˆ’8.3. The rule named one and the parenthetical named the other. I +implemented the rule faithfully and got the quieter presentation. + +What the file can still say is decoded and does not adjudicate it: the `fmt ` +chunk is a 32-byte `XMAWAVEFORMAT` whose `+0x20` is a declared +`PsuedoBytesPerSec` β€” 8 142 and 8 530 on `ADV`'s two, matching the computed rates +to 0.02 % β€” but `wEncodeOptions` (`0x10d6`), channel count and channel mask are +**byte-identical across the presentations**. Nothing in the header ranks them. + +⚠️ One more observable, measured here and not in that page: **the two +presentations differ in channel layout.** `ADV` chunk 1 is mono-in-stereo β€” +channel 2 digitally silent β€” while chunk 2 is **dual-mono**, both channels +identical at βˆ’8.318574. So they are not two encodes of one file differing only in +rate. + +Also recorded, because it cost the Decoder time: **`sylpheed-cli audio info` is +not to be trusted on these.** Its "16 channels / 4310 Hz / 2-bit" is +`wBitsPerSample`, `wEncodeOptions` and the channel fields read at the wrong +offsets β€” its XMA1 reader is misaligned. That is a tool in this repository +reporting confident nonsense, and it is the second time a renderer or reader of +ours has been believed before it was checked.