media: expose se_wave_riff -- the menu's SE cues, assembled where the format lives
The port is forbidden from reimplementing media assembly and Static.slb is exactly that case: no RIFF, no seek chunk, no XACT container, just a packed run of whole 2048-byte XMA1 packets, so a wave is defined only by (offset, packet count) and the header has to be synthesized. That step now happens once, in the crate that owns the format, instead of in each consumer. `slb::xma1_wave_riff` wraps raw packets; `media::se_wave_riff` looks the bank up and reads just the packets asked for. Both reuse the existing synth_xma1_fmt / build_riff, which are already byte-identical to what tools/re-capture/ slb_extract_wave.py writes -- so this is exposure, not a second implementation. It reads a TARGETED range rather than the whole bank, and that is load-bearing: Static.slb is the ONE entry of sound.pak's 9 519 whose declared extent runs past the end of the extracted segments -- by exactly 616 768 B -- so reading it whole fails outright on this extraction. Every cue we need is in the first few hundred KB. Recorded rather than worked around silently. Verified as an artifact, not a compile: all three cues decode through ffmpeg to mono 48 kHz PCM at 0.533 / 0.344 / 1.016 s, non-silent (rms 2085 / 2985 / 4327, peaks 29813 / 16973 / 32767). The refusal path is exercised in the same run -- an impossible packet count is rejected rather than returning a short stream, because a truncated XMA decodes to plausible-sounding garbage. Also adds docs/re/captures/ORACLE-CAPTURES.md: an index of the nine canary framebuffer captures already in this repo, and a plain statement that THEY are the reference and `screen render` is not.
This commit is contained in:
27
crates/sylpheed-formats/examples/se_wave_dump.rs
Normal file
27
crates/sylpheed-formats/examples/se_wave_dump.rs
Normal file
@@ -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 -- <outdir>
|
||||
//! ffmpeg -i <outdir>/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}"),
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,62 @@ pub fn sound_bank_riffs<S: DiscSource + ?Sized>(
|
||||
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<S: DiscSource + ?Sized>(
|
||||
source: &S,
|
||||
bank: &str,
|
||||
offset: usize,
|
||||
packet_count: usize,
|
||||
channels: u8,
|
||||
rate: u32,
|
||||
) -> Result<Vec<u8>, 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<S: DiscSource + ?Sized>(
|
||||
|
||||
@@ -535,6 +535,28 @@ pub fn to_xma_riff_best(slb: &[u8]) -> Option<Vec<u8>> {
|
||||
(!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<u8> {
|
||||
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<u8> {
|
||||
let mut fmt = Vec::with_capacity(40);
|
||||
|
||||
Reference in New Issue
Block a user