diff --git a/crates/sylpheed-formats/examples/se_wave_dump.rs b/crates/sylpheed-formats/examples/se_wave_dump.rs new file mode 100644 index 00000000..00fc64e2 --- /dev/null +++ b/crates/sylpheed-formats/examples/se_wave_dump.rs @@ -0,0 +1,27 @@ +//! Dump the menu's three SE cues as decodable `RIFF`s, to prove `se_wave_riff` +//! produces something ffmpeg actually accepts. +//! +//! cargo run -p sylpheed-formats --example se_wave_dump -- +//! ffmpeg -i /move.riff move.wav +use sylpheed_formats::media::{self, DirectorySource}; + +fn main() { + let out = std::env::args().nth(1).unwrap_or_else(|| "/tmp".into()); + let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"); + let src = DirectorySource::new(&disc); + for (name, off, pkts) in [("move", 0x1ec0usize, 4usize), ("back", 0x0ec0, 2), ("confirm", 0x5d6c0, 6)] { + match media::se_wave_riff(&src, "Static.slb", off, pkts, 1, 48000) { + Ok(riff) => { + let p = format!("{out}/{name}.riff"); + std::fs::write(&p, &riff).unwrap(); + println!("{p}: {} bytes ({pkts} packets at {off:#x})", riff.len()); + } + Err(e) => println!("{name}: ERROR {e}"), + } + } + // The refusal path: a packet count the bank cannot satisfy. + match media::se_wave_riff(&src, "Static.slb", 0x1ec0, 1 << 20, 1, 48000) { + Ok(_) => println!("REFUSAL PATH FAILED — returned a short stream"), + Err(e) => println!("refusal path ok: {e}"), + } +} diff --git a/crates/sylpheed-formats/src/media.rs b/crates/sylpheed-formats/src/media.rs index 99465260..14cccb43 100644 --- a/crates/sylpheed-formats/src/media.rs +++ b/crates/sylpheed-formats/src/media.rs @@ -87,6 +87,62 @@ pub fn sound_bank_riffs( Ok(riffs_of(&bytes)) } +/// One sound-effect wave out of a **delimiter-less** bank, as a decodable `RIFF`. +/// +/// `Static.slb` — where the menu's cues live — has no `RIFF`, no `seek` chunk and +/// no XACT container: it is a packed run of whole 2048-byte XMA1 packets. So +/// [`sound_bank_riffs`] finds nothing to split on, and a wave is defined *only* +/// by `(offset, packet_count)`. Both come from the running game, not from the +/// file: launch Canary with `--xma_param_probe=true`, trigger the sound, and the +/// log prints the stream's packet count and first 32 bytes; searching those bytes +/// in the bank gives the offset. ⚠️ The file order is **not** cue-id order, so the +/// index cannot be counted out — see `docs/re/menu-audio-cues.md`. +/// +/// The three cues a menu needs, all mono 48 kHz: +/// +/// | event | offset | packets | +/// |---|---|---| +/// | d-pad move | `0x1ec0` | 4 | +/// | Ⓑ back | `0x0ec0` | 2 | +/// | Ⓐ confirm | `0x5d6c0` | 6 | +/// +/// Returns an error rather than a short stream if the bank does not actually +/// hold `packet_count` whole packets at `offset` — a truncated XMA stream decodes +/// to plausible-sounding garbage, which is the failure worth refusing. +pub fn se_wave_riff( + source: &S, + bank: &str, + offset: usize, + packet_count: usize, + channels: u8, + rate: u32, +) -> Result, String> { + let len = packet_count * crate::slb::XMA1_PACKET; + // Read only the packets asked for, not the whole bank. That is not just an + // efficiency point: `Static.slb` is 8.97 MB and is the ONE entry in + // `sound.pak` whose declared extent runs past the end of the extracted + // segments (by 616 768 B), so reading it whole fails outright on a disc + // extraction that is short at the tail. Every cue we need sits in the first + // few hundred KB. See `docs/re/menu-audio-cues.md`. + let toc = source.read_file("dat/sound.pak")?; + let entries = PakArchive::parse_toc(&toc).map_err(|e| e.to_string())?; + let hash = crate::hash::name_hash(bank); + let idx = entries + .binary_search_by_key(&hash, |e| e.name_hash) + .map_err(|_| format!("{bank}: not present in sound.pak"))?; + let e = &entries[idx]; + if offset + len > e.comp_size as usize { + return Err(format!( + "{bank}: {packet_count} packets at {offset:#x} need {len} bytes, \ + but the bank declares only {} bytes", + e.comp_size + )); + } + let packets = + source.read_segment_range("dat/sound", e.offset as u64 + offset as u64, len)?; + Ok(crate::slb::xma1_wave_riff(&packets, channels, rate)) +} + /// The XMA `RIFF`s of a continuous byte region of the voice stream, as returned /// by [`resolve_movie_voice_region`]. pub fn voice_region_riffs( diff --git a/crates/sylpheed-formats/src/slb.rs b/crates/sylpheed-formats/src/slb.rs index 1c6bf399..9b198b22 100644 --- a/crates/sylpheed-formats/src/slb.rs +++ b/crates/sylpheed-formats/src/slb.rs @@ -535,6 +535,28 @@ pub fn to_xma_riff_best(slb: &[u8]) -> Option> { (!data.is_empty()).then(|| build_riff(&synth_xma1_fmt(2, 2, 48000), data)) } +/// Wrap a run of **raw XMA1 packets** as a standalone, decodable `RIFF/WAVE`. +/// +/// For a bank with no internal delimiters — `Static.slb` is a packed run of whole +/// 2048-byte packets with no `RIFF`, no `seek` and no `WAVE` — a wave is defined +/// *only* by `(offset, packet count)`, both of which come from the running game +/// (`--xma_param_probe`). There is nothing in the file to parse, so the header +/// has to be synthesized, and that is the step worth doing exactly once, here, +/// rather than in each consumer. +/// +/// `packets` must be a whole number of [`XMA1_PACKET`] bytes; anything else is a +/// short read and produces a stream the decoder will run off the end of. +/// The `channel_mask` follows the same convention as the rest of this module: +/// `1` for mono, `2` for stereo. +/// +/// The three menu cues in `docs/re/menu-audio-cues.md` are +/// `(0x1ec0, 4)` d-pad move, `(0x0ec0, 2)` Ⓑ back and `(0x5d6c0, 6)` Ⓐ confirm, +/// all mono 48 kHz. +pub fn xma1_wave_riff(packets: &[u8], channels: u8, rate: u32) -> Vec { + let mask = if channels == 1 { 1 } else { 2 }; + build_riff(&synth_xma1_fmt(channels, mask, rate), packets) +} + /// A minimal `fmt ` chunk carrying an XMA1 `XMAWAVEFORMAT` (one stream). fn synth_xma1_fmt(channels: u8, channel_mask: u16, rate: u32) -> Vec { let mut fmt = Vec::with_capacity(40); diff --git a/docs/re/captures/ORACLE-CAPTURES.md b/docs/re/captures/ORACLE-CAPTURES.md new file mode 100644 index 00000000..1eeb164f --- /dev/null +++ b/docs/re/captures/ORACLE-CAPTURES.md @@ -0,0 +1,53 @@ +# The oracle frames — what to verify a render against + +**These are framebuffer captures of the real game running under Xenia Canary.** +They are the reference. `sylpheed-cli screen render` is **not** — Reborn is a GUI +explorer and extraction CLI built to check that our *decoding* is right, and it +may very well be wrong. Where a render and a capture disagree, the capture wins, +and the render is the thing to go and fix. + +⚠️ Two renderers agreeing proves nothing: they share our assumptions. This corpus +has been bitten by exactly that three times — the dropped `pteff05` background, +the scale-0 rect, and `rest()`. Each was invisible to any render-vs-render diff +and visible immediately against a capture. + +## The frames + +All are **1279×675**, top-left aligned, cropped to the game surface by the +screenshot tool (the guest renders 1280×720; the missing row/column is the crop, +not a scale). + +| screen | capture | +|---|---| +| publisher splash (SQUARE ENIX) | [`title-builds/live-splash-publisher.png`](title-builds/live-splash-publisher.png) | +| developer splash (GAME ARTS / SETA / anima) | [`title-builds/live-splash-developer.png`](title-builds/live-splash-developer.png) | +| title, **without** the `PRESS Ⓐ` plate | [`title-builds/live-title-build4-no-plate.png`](title-builds/live-title-build4-no-plate.png) | +| title, **with** the plate | [`title-builds/live-title-press-a.png`](title-builds/live-title-press-a.png) | +| main menu | [`title-builds/live-main-menu.png`](title-builds/live-main-menu.png) · [`main-menu-oracle.png`](main-menu-oracle.png) | +| main menu, **`OPTIONS` focused** | [`title-builds/live-main-menu-options-focused.png`](title-builds/live-main-menu-options-focused.png) | +| `EXTRAS` | [`title-builds/live-extras.png`](title-builds/live-extras.png) | +| title (alternate) | [`title-screen-oracle.png`](title-screen-oracle.png) | +| a screen transition, 13 frames | [`transitions/transition-filmstrip.png`](transitions/transition-filmstrip.png) + [`transition-luminance.csv`](transitions/transition-luminance.csv) | + +The **focused** pair is the useful one for button states: the same screen with a +different button highlighted, so the difference isolates what focus changes. + +## ⚠️ Before you compute an RMSE against one + +* **They are not gamma-neutral.** `capture ≈ 255·(render/255)^γ` with γ ≈ 1.34–1.49, + and that is a ramp **the game installed**, not a capture-path artefact. So RMSE + against these has a floor and chasing it below that floor is chasing the ramp. + [`../structures/ui-render-tone-curve.md`](../structures/ui-render-tone-curve.md) +* **Geometry is sound**: cross-correlating a render against `live-main-menu.png` + over ±6 px puts the best alignment at exactly (0,0), correlation 0.9466. So a + positional disagreement is real, not a crop artefact. +* **A capture is one moment.** Several of these screens are still animating; the + title's two `ptloop` sweeps move continuously. Compare settled poses, or + compare regions you know are at rest. + +## What is NOT here + +No capture of the interactive title reached mid-run without a pad press — three +runs across two locales and two launch paths never reached it in ~35 minutes. +See [`../capture-harness-status.md`](../capture-harness-status.md). And no +`GP_READY_ROOM` capture; S1 ruled it out of scope. diff --git a/docs/re/data/se-wave-riff-decode.txt b/docs/re/data/se-wave-riff-decode.txt new file mode 100644 index 00000000..dda47b06 --- /dev/null +++ b/docs/re/data/se-wave-riff-decode.txt @@ -0,0 +1,17 @@ +# se_wave_riff — the three menu cues, decoded end to end + + $ cargo run -p sylpheed-formats --example se_wave_dump -- /tmp/se + + /tmp/se/move.riff: 8252 bytes (4 packets at 0x1ec0) + /tmp/se/back.riff: 4156 bytes (2 packets at 0xec0) + /tmp/se/confirm.riff: 12348 bytes (6 packets at 0x5d6c0) + refusal path ok: Static.slb: 1048576 packets at 0x1ec0 need 2147483648 bytes, but the bank declares only 8970240 bytes + + $ ffmpeg -i .riff .wav # then measure the PCM + + move 48000 Hz mono 0.533 s rms 2084.7 peak 29813 non-quiet 47.5% + back 48000 Hz mono 0.344 s rms 2984.7 peak 16973 non-quiet 95.1% + confirm 48000 Hz mono 1.016 s rms 4327.0 peak 32767 non-quiet 92.5% +Non-silent, plausible envelopes, durations consistent with a UI blip. +The refusal path is exercised in the same run: an impossible packet count is +rejected rather than returning a short stream.