Run 204 gave this repository its first clippy measurement — 48 errors, 43 of them in `sylpheed-formats`. This clears that 43 to zero under the exact invocation CI runs, `cargo clippy -p sylpheed-formats -- -D warnings`. Why this crate first, and why it is safe to touch: Every one of the 43 sites was checked against the line ranges that `auto/frame-blend-draw-path` (495 commits) and `auto/port-p6-audio` (366) actually modify. None of them overlap. Eleven of the fifteen affected files are byte-identical on both branches, including `mesh.rs` and `texture.rs`, which carry 28 of the hits between them. The three sites in `audio.rs`, `ui_layout.rs` and `slb.rs` that live in files those branches do change fall outside every modified hunk. The collision argument that defers #12 does not transfer here; it was tested rather than assumed. It also unblocks a measurement. `-D warnings` turns a lint in this crate into a hard compile error, so its dependents never build — `sylpheed-cli` and `sylpheed-viewer` have never been linted at all, and viewer is the largest crate in the workspace. Both depend only on `sylpheed-formats` (`sylpheed-export` pins it from a git tag instead), so this commit is what makes their real counts knowable. 38 applied by `cargo clippy --fix` — chunks_exact_to_as_chunks, manual_div_ceil / is_multiple_of / range_contains, unnecessary_map_or, needless_borrow, let_and_return, dead_code, unused_mut/variables. Purely local expression rewrites: 38 insertions, 39 deletions. 2 by hand: a doc continuation that markdown was parsing as a list, and `d / frame` behind a `frame > 0` guard becoming `checked_div`. 3 `#[allow(clippy::too_many_arguments)]` with a stated reason. On those three allows: 8 parameters against a threshold of 7, in the mesh anchor path. The real fix is a shared params struct across `anchor_pool_mesh`, `validate_block` and `validate_block_report` — the latter two take the same eight arguments and one delegates to the other — which is a change to the decoder's signatures and belongs to whoever owns that path, not to a CI-lint pass. This is not the shape PROTOCOL.md forbids. `continue-on-error` suppresses everything, present and future, at the job level, and cannot tell "not yet" from "no longer". A site-local `#[allow]` with a reason is a decision recorded where it applies: one lint, one function, and any new violation anywhere else still fails the build. `sylpheed-export`'s remaining 5 are deliberately untouched — three of them sit inside hunks both long-lived branches modify, and that crate blocks nothing. Left for #13. Refs #13 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
746 lines
31 KiB
Rust
746 lines
31 KiB
Rust
//! `.slb` XACT sound banks → a decodable XMA1 `RIFF`.
|
||
//!
|
||
//! `dat/sound.pak` (9519 entries across `sound.p00..p04`) is the game's audio
|
||
//! bank. Each entry is an XACT `.slb` wrapping **XMA1** (`fmt ` tag `0x0165`,
|
||
//! 48 kHz). Files are named `<lang>\Voice\…`, `<lang>\Movie\VOICE_<movie>.slb`,
|
||
//! `BGM_###.slb`, etc. (see `docs/re/structures/sound-slb.md`); look them up by
|
||
//! [`crate::hash::name_hash`]. Movie cutscene voice = one continuous
|
||
//! `<eng|jpn>\Movie\VOICE_<movie>.slb` track meant to play from the video start.
|
||
//!
|
||
//! Two on-disc layouts, both reversed statically:
|
||
//! - **RIFF present** — a standard `RIFF/WAVE` sits inside the bank; its 32-byte
|
||
//! `fmt ` is the real `XMAWAVEFORMAT` and the XMA packets are everything after
|
||
//! that RIFF's `data` chunk header (the declared `data` size is unreliable, so
|
||
//! we take to end and let the caller clamp to the known media length).
|
||
//! - **Headerless** — no RIFF at all; a fixed **1392-byte** header precedes raw
|
||
//! XMA1 packets (48 kHz, 2 channels).
|
||
//!
|
||
//! [`to_xma_riff`] rebuilds a standalone `RIFF/WAVE` (XMA1) for either layout,
|
||
//! ready to hand to an XMA decoder (e.g. FFmpeg's `xma1`). The content is always
|
||
//! mono (some clips put it in the left channel only, others duplicate L=R), so
|
||
//! the decode step should downmix to mono (take the left channel).
|
||
|
||
/// Fixed offset of the raw XMA1 stream in a headerless `.slb` (no `RIFF`).
|
||
pub const HEADERLESS_DATA_OFFSET: usize = 1392;
|
||
|
||
/// XMA1 packet size. A headerless stream is always a whole number of these, which
|
||
/// is how a leading stream is told apart from arbitrary bytes before a `RIFF`.
|
||
pub const XMA1_PACKET: usize = 2048;
|
||
|
||
/// Voice language for cutscene audio. Only English and Japanese voice exist on
|
||
/// the disc (subtitles cover more languages, voice does not).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum VoiceLang {
|
||
/// The default only because the disc's own default audio track is English;
|
||
/// nothing else about the code should assume it.
|
||
#[default]
|
||
English,
|
||
Japanese,
|
||
}
|
||
|
||
impl VoiceLang {
|
||
pub const ALL: [VoiceLang; 2] = [VoiceLang::English, VoiceLang::Japanese];
|
||
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
VoiceLang::English => "English",
|
||
VoiceLang::Japanese => "Japanese",
|
||
}
|
||
}
|
||
|
||
fn code(self) -> &'static str {
|
||
match self {
|
||
VoiceLang::English => "eng",
|
||
VoiceLang::Japanese => "jpn",
|
||
}
|
||
}
|
||
|
||
/// `eng` / `jpn` — the `sound.pak` path prefix for this voice language.
|
||
pub fn code_pub(self) -> &'static str {
|
||
self.code()
|
||
}
|
||
}
|
||
|
||
/// The `sound.pak` entry name for a movie's continuous voice track, e.g.
|
||
/// `eng\Movie\VOICE_RT07A.slb`. Hash it with [`crate::hash::name_hash`] to get
|
||
/// the `sound.pak` TOC key.
|
||
pub fn movie_voice_name(movie_basename: &str, lang: VoiceLang) -> String {
|
||
format!("{}\\Movie\\VOICE_{}.slb", lang.code(), movie_basename)
|
||
}
|
||
|
||
/// One playable voice clip discovered in `sounds.tbl`.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct VoiceClip {
|
||
/// Full `sound.pak` entry name, e.g. `eng\Voice\VOICE_ADAN_010.slb`.
|
||
pub name: String,
|
||
/// Speaker/category code, e.g. `ADAN`, `TCAF`, `A`, `RT07A`.
|
||
pub speaker: String,
|
||
/// Short UI label, e.g. `ADAN 010`.
|
||
pub display: String,
|
||
}
|
||
|
||
/// What kind of audio a `sounds.tbl` entry names.
|
||
///
|
||
/// The split is the on-disc path shape, not a guess: the 36 language-independent
|
||
/// banks sit at the table root (`BGM_###.slb`, `JNGL_00#.slb`, `Static.slb`),
|
||
/// while everything else is under `<lang>\<dir>\`.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||
pub enum AudioCategory {
|
||
/// `BGM_###.slb` — 32 music tracks, language-independent.
|
||
Music,
|
||
/// `JNGL_00#.slb` — 3 short jingles (mission clear / fail stings).
|
||
Jingle,
|
||
/// `Static.slb` — the sound-effect bank, one 9 MB multi-wave bank.
|
||
Sfx,
|
||
/// `<lang>\Voice\` — in-mission radio chatter, by speaker.
|
||
Radio,
|
||
/// `<lang>\etc\` — the other spoken lines (cutscene dialogue, system).
|
||
Dialogue,
|
||
/// `<lang>\Movie\VOICE_<movie>.slb` — a cutscene's continuous voice track.
|
||
MovieVoice,
|
||
/// `<lang>\Briefing\BR<NN>_<MM>.slb` — mission briefing lines.
|
||
Briefing,
|
||
/// A `.slb` whose path matched no known shape.
|
||
Other,
|
||
}
|
||
|
||
impl AudioCategory {
|
||
pub const ALL: [AudioCategory; 8] = [
|
||
AudioCategory::Music,
|
||
AudioCategory::Jingle,
|
||
AudioCategory::Sfx,
|
||
AudioCategory::Radio,
|
||
AudioCategory::Dialogue,
|
||
AudioCategory::MovieVoice,
|
||
AudioCategory::Briefing,
|
||
AudioCategory::Other,
|
||
];
|
||
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
AudioCategory::Music => "Music",
|
||
AudioCategory::Jingle => "Jingles",
|
||
AudioCategory::Sfx => "Sound effects",
|
||
AudioCategory::Radio => "Radio",
|
||
AudioCategory::Dialogue => "Dialogue",
|
||
AudioCategory::MovieVoice => "Movie voice",
|
||
AudioCategory::Briefing => "Briefing",
|
||
AudioCategory::Other => "Other",
|
||
}
|
||
}
|
||
|
||
/// True for the categories that are spoken lines — the set
|
||
/// [`list_voice_clips`] returns.
|
||
pub fn is_voice(self) -> bool {
|
||
matches!(
|
||
self,
|
||
AudioCategory::Radio
|
||
| AudioCategory::Dialogue
|
||
| AudioCategory::MovieVoice
|
||
| AudioCategory::Briefing
|
||
)
|
||
}
|
||
|
||
/// True when the bank is language-independent, so it appears whichever
|
||
/// `<lang>\sounds.tbl` is read.
|
||
pub fn is_shared(self) -> bool {
|
||
matches!(
|
||
self,
|
||
AudioCategory::Music | AudioCategory::Jingle | AudioCategory::Sfx
|
||
)
|
||
}
|
||
|
||
fn classify(name: &str) -> AudioCategory {
|
||
let leaf = name.rsplit('\\').next().unwrap_or(name);
|
||
if !name.contains('\\') {
|
||
return if leaf.starts_with("BGM_") {
|
||
AudioCategory::Music
|
||
} else if leaf.starts_with("JNGL_") {
|
||
AudioCategory::Jingle
|
||
} else if leaf.eq_ignore_ascii_case("Static.slb") {
|
||
AudioCategory::Sfx
|
||
} else {
|
||
AudioCategory::Other
|
||
};
|
||
}
|
||
match name.rsplit('\\').nth(1) {
|
||
Some("Voice") => AudioCategory::Radio,
|
||
Some("etc") => AudioCategory::Dialogue,
|
||
Some("Movie") => AudioCategory::MovieVoice,
|
||
Some("Briefing") => AudioCategory::Briefing,
|
||
_ => AudioCategory::Other,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One playable bank named in `sounds.tbl`, with the category its path implies.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct AudioEntry {
|
||
pub clip: VoiceClip,
|
||
pub category: AudioCategory,
|
||
}
|
||
|
||
/// Enumerate **every** `.slb` bank named in a decompressed `sounds.tbl` (the
|
||
/// IDXD in `tables.pak`): the language-independent music/jingle/SFX banks at
|
||
/// the table root, plus every `<lang>\…` spoken line.
|
||
///
|
||
/// Measured on the retail disc: `eng\sounds.tbl` names 4 418 banks (36 shared +
|
||
/// 2 382 Radio + 1 821 Dialogue + 101 Briefing + 78 Movie voice) and
|
||
/// `jpn\sounds.tbl` names 5 136 (the same 36 shared + 5 100 Japanese lines).
|
||
/// Every one of the 36 shared names resolves to a `sound.pak` TOC entry under
|
||
/// [`crate::hash::name_hash`], which is the check that they are real banks and
|
||
/// not stale table text.
|
||
pub fn list_audio_entries(sounds_tbl: &[u8], lang: VoiceLang) -> Vec<AudioEntry> {
|
||
let prefix = format!("{}\\", lang.code());
|
||
let mut seen = std::collections::BTreeSet::new();
|
||
let mut out = Vec::new();
|
||
// Scan for printable-ASCII runs; keep those that name a `.slb`.
|
||
let mut i = 0;
|
||
while i < sounds_tbl.len() {
|
||
let start = i;
|
||
while i < sounds_tbl.len() && (0x20..=0x7e).contains(&sounds_tbl[i]) {
|
||
i += 1;
|
||
}
|
||
if i - start >= 6 {
|
||
if let Ok(s) = std::str::from_utf8(&sounds_tbl[start..i]) {
|
||
// Take this language's entries plus the root (shared) banks; a
|
||
// path under the OTHER language would be a table artefact.
|
||
let mine = s.starts_with(&prefix) || !s.contains('\\');
|
||
if mine && s.ends_with(".slb") && seen.insert(s.to_string()) {
|
||
out.push(AudioEntry {
|
||
category: AudioCategory::classify(s),
|
||
clip: parse_voice_clip(s),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
i += 1;
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Enumerate just the spoken-line clips — [`list_audio_entries`] restricted to
|
||
/// [`AudioCategory::is_voice`].
|
||
///
|
||
/// In-mission radio (`\Voice\`, `\etc\`) and bound movie voices (`\Movie\`) all
|
||
/// carry `VOICE_`; mission-briefing lines live in `\Briefing\` as
|
||
/// `BR<NN>_<MM>.slb` and carry no `VOICE` at all, which is why the category —
|
||
/// i.e. the directory — decides this and not the filename.
|
||
pub fn list_voice_clips(sounds_tbl: &[u8], lang: VoiceLang) -> Vec<VoiceClip> {
|
||
list_audio_entries(sounds_tbl, lang)
|
||
.into_iter()
|
||
.filter(|e| e.category.is_voice())
|
||
.map(|e| e.clip)
|
||
.collect()
|
||
}
|
||
|
||
fn parse_voice_clip(name: &str) -> VoiceClip {
|
||
// `<lang>\<cat>\VOICE_<SPK>_<NNN>.slb` or `..\VOICE_<movie>.slb`.
|
||
let stem = name
|
||
.rsplit('\\')
|
||
.next()
|
||
.unwrap_or(name)
|
||
.strip_suffix(".slb")
|
||
.unwrap_or(name);
|
||
let body = stem.strip_prefix("VOICE_").unwrap_or(stem);
|
||
let (speaker, display) = match body.rsplit_once('_') {
|
||
Some((spk, num)) if num.chars().all(|c| c.is_ascii_digit()) => {
|
||
(spk.to_string(), format!("{spk} {num}"))
|
||
}
|
||
_ => (body.to_string(), body.to_string()),
|
||
};
|
||
VoiceClip {
|
||
name: name.to_string(),
|
||
speaker,
|
||
display,
|
||
}
|
||
}
|
||
|
||
/// Build one standalone XMA1 `RIFF/WAVE` **per sub-wave** in a `.slb` bank, in
|
||
/// on-disc order. A `.slb` is an XACT bank of one or more sub-waves; the sub-waves
|
||
/// are EITHER alternate takes (the first is the whole track — e.g. `VOICE_S01A`,
|
||
/// `VOICE_ADV`: sub0 ≈ the movie length) OR sequential segments that must be
|
||
/// concatenated (e.g. `VOICE_RT07A`: 24s + 14s + 11s ≈ the 50s movie). The caller
|
||
/// decodes each to PCM, concatenates in order, and clamps to the movie length —
|
||
/// that yields the full track for segment banks while the clamp drops the
|
||
/// duplicate takes for alternate-take banks. (Dynamic RE via Canary file-I/O
|
||
/// tracing confirmed the movie→voice binding; this fixes the *decode* of `RT*`.)
|
||
/// A bank's channel count, read from its first `RIFF` sub-wave.
|
||
///
|
||
/// `XMASTREAMFORMAT.Channels` sits at `RIFF + 49`. **2.12 % of banks are stereo**
|
||
/// (170 of 8 021), and decoding one of those as mono yields a single frame and
|
||
/// stops — the same signature already recorded for the leading segment. So the
|
||
/// channel count has to be read, not assumed. Returns `None` when there is no
|
||
/// `RIFF` to read it from.
|
||
fn riff_channels(slb: &[u8]) -> Option<u8> {
|
||
let ri = find(slb, b"RIFF", 0)?;
|
||
slb.get(ri + 49).copied().filter(|c| *c == 1 || *c == 2)
|
||
}
|
||
|
||
/// The four data offsets that occur on the disc, in ascending order.
|
||
///
|
||
/// Measured over all 7 358 banks whose offset is *known* (they carry a `RIFF`,
|
||
/// so the offset is forced to `first_riff % XMA1_PACKET`): no other value
|
||
/// occurs. They are all of the form `1392 + 4k`.
|
||
pub const DATA_OFFSET_CANDIDATES: [usize; 4] = [1392, 1468, 1600, 1728];
|
||
|
||
/// How plausible a candidate offset is, judged by XMA1 packet headers alone.
|
||
///
|
||
/// Each 2048-byte packet opens with a big-endian header: 6 bits frame count,
|
||
/// 15 bits frame-offset-in-bits, 3 bits metadata, 8 bits packet-skip. At the
|
||
/// true offset those fields stay in range packet after packet; one byte off and
|
||
/// they do not. Returns the fraction of the first `LIMIT` packets that look
|
||
/// sane, so 1.0 is a clean stream.
|
||
fn packet_plausibility(slb: &[u8], start: usize) -> f32 {
|
||
const LIMIT: usize = 24;
|
||
let (mut seen, mut ok, mut pos) = (0usize, 0usize, start);
|
||
while pos + XMA1_PACKET <= slb.len() && seen < LIMIT {
|
||
let h = u32::from_be_bytes([slb[pos], slb[pos + 1], slb[pos + 2], slb[pos + 3]]);
|
||
let frame_offset_bits = (h >> 11) & 0x7FFF;
|
||
let metadata = (h >> 8) & 0x7;
|
||
let packet_skip = h & 0xFF;
|
||
if frame_offset_bits as usize <= XMA1_PACKET * 8 && metadata <= 1 && packet_skip <= 8 {
|
||
ok += 1;
|
||
}
|
||
seen += 1;
|
||
pos += XMA1_PACKET;
|
||
}
|
||
if seen == 0 {
|
||
0.0
|
||
} else {
|
||
ok as f32 / seen as f32
|
||
}
|
||
}
|
||
|
||
/// The data offset implied by the bank's `seek` chunk, if it has one.
|
||
///
|
||
/// A bank's `seek` chunk lands on a packet boundary, so `seek_pos % XMA1_PACKET`
|
||
/// *is* the data offset. Measured on the 6 033 labelled banks that have a
|
||
/// `seek` before their first `RIFF`: **6 031 agree (99.97 %)**, 2 disagree.
|
||
/// This is structural rather than statistical, which is why it is tried first.
|
||
fn seek_chunk_offset(slb: &[u8]) -> Option<usize> {
|
||
let pos = find(slb, b"seek", 0)?;
|
||
let residue = pos % XMA1_PACKET;
|
||
DATA_OFFSET_CANDIDATES.contains(&residue).then_some(residue)
|
||
}
|
||
|
||
/// Recover a bank's data offset when there is no `RIFF` to derive it from.
|
||
///
|
||
/// Two independent signals, tried in order of how well each is evidenced:
|
||
///
|
||
/// 1. **the `seek` chunk's position** mod the packet size — 99.97 % on the
|
||
/// labelled set, and structural rather than statistical;
|
||
/// 2. **packet-header plausibility** — pick the candidate whose XMA1 headers
|
||
/// stay in range over the first 24 packets. Alone this is 99.62 %, and all
|
||
/// 28 of its misses are ties rather than wrong unique winners.
|
||
///
|
||
/// Together, on the 7 358 banks where the answer *is* known from the `RIFF`
|
||
/// position: **7 354 correct (99.95 %)**. The `seek` residue resolves 26 of the
|
||
/// scan's 28 ties correctly and none of them wrongly; the other 2 have no
|
||
/// usable `seek`. Falls back to [`HEADERLESS_DATA_OFFSET`] when neither signal
|
||
/// decides.
|
||
pub fn scan_data_offset(slb: &[u8]) -> usize {
|
||
if let Some(off) = seek_chunk_offset(slb) {
|
||
return off;
|
||
}
|
||
let mut best = (HEADERLESS_DATA_OFFSET, -1.0f32);
|
||
let mut tied = false;
|
||
for &c in &DATA_OFFSET_CANDIDATES {
|
||
if c >= slb.len() {
|
||
continue;
|
||
}
|
||
let score = packet_plausibility(slb, c);
|
||
if score > best.1 {
|
||
best = (c, score);
|
||
tied = false;
|
||
} else if (score - best.1).abs() < f32::EPSILON {
|
||
tied = true;
|
||
}
|
||
}
|
||
if tied {
|
||
HEADERLESS_DATA_OFFSET
|
||
} else {
|
||
best.0
|
||
}
|
||
}
|
||
|
||
/// Where a bank's leading headerless packet stream starts.
|
||
///
|
||
/// The stream is a whole number of 2048-byte XMA1 packets ending at the first
|
||
/// `RIFF`, so its start is simply `first_riff % XMA1_PACKET`. Disc-wide that
|
||
/// lands on 1392, 1468, 1600 or 1728 depending on language and subdirectory —
|
||
/// [`HEADERLESS_DATA_OFFSET`] is just the `<lang>\etc\` case. Measured over a
|
||
/// 140-bank sample, deriving the offset instead of assuming 1392 recovers a
|
||
/// median **70×** more decoded audio and never less except in one bank where
|
||
/// neither offset decodes (see `docs/re/structures/slb-data-offset.md`).
|
||
pub fn leading_data_offset(first_riff: usize) -> usize {
|
||
first_riff % XMA1_PACKET
|
||
}
|
||
|
||
pub fn to_xma_riffs(slb: &[u8]) -> Vec<Vec<u8>> {
|
||
let mut out = Vec::new();
|
||
let first_riff = find(slb, b"RIFF", 0);
|
||
if first_riff.is_none() {
|
||
// Headerless single-stream bank. Two things here were wrong, and the
|
||
// corrections are measured (docs/re/structures/slb-data-offset.md):
|
||
//
|
||
// * the offset is not the constant — with no `RIFF` to derive it from,
|
||
// scan the four candidates by packet plausibility;
|
||
// * the stream is **mono**. At two channels a 48-bank sample yielded
|
||
// 0..4 816 bytes; at one, 180 000..380 000. There was not one bank
|
||
// where the old stereo/1392 pair beat the scanned mono pair, and the
|
||
// median gain was 184x.
|
||
let start = scan_data_offset(slb);
|
||
if let Some(data) = slb.get(start..) {
|
||
if !data.is_empty() {
|
||
out.push(build_riff(&synth_xma1_fmt(1, 0, 48000), data));
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
// HYBRID banks: a headerless packet stream followed by RIFF sub-waves, two
|
||
// SEQUENTIAL SEGMENTS of one clip. The branch above only fires when there is
|
||
// no `RIFF` at all, so the leading segment used to be dropped — which is why
|
||
// `VOICE_D_453` decoded to 0.14 s: its line is in that segment and only the
|
||
// trailing fragment survived.
|
||
//
|
||
// The boundary is arithmetic, not a magic: XMA1 packets are 2048 bytes, so a
|
||
// leading stream is a whole number of packets ending at the first `RIFF`.
|
||
// Its START is therefore `first_riff % XMA1_PACKET` — **not** the constant
|
||
// `HEADERLESS_DATA_OFFSET`, which is only the value that offset happens to
|
||
// take in `<lang>\etc\`. Disc-wide it takes four values (1392, 1468, 1600,
|
||
// 1728), varying by language and subdirectory, and assuming 1392 starts the
|
||
// decode mid-packet everywhere else. See docs/re/structures/slb-data-offset.md.
|
||
// It decodes as **mono** — at two channels every bank yields exactly 1792
|
||
// bytes, one frame, whatever its size.
|
||
//
|
||
// An earlier version of this was withdrawn for two good reasons, both now
|
||
// answered: it recovered no audio (it used the stereo format), and it
|
||
// matched 1524 of the 8021 RIFF-bearing entries. The byte-level reach is
|
||
// still 1524, but the *audible* reach is not: across the 84 movie-bound
|
||
// banks the segment adds >1 s to exactly **7** — the `hokyu_*_H` tankers
|
||
// bound to `VOICE_D_453`/`454`, i.e. precisely the broken ones — and
|
||
// ≤0.25 s to 66 of the rest. Callers clamp to the movie length anyway.
|
||
if let Some(ri) = first_riff {
|
||
let start = leading_data_offset(ri);
|
||
if ri > start {
|
||
if let Some(data) = slb.get(start..ri) {
|
||
if data.iter().any(|b| *b != 0) {
|
||
// Channels come from the bank's own `fmt `, not a constant:
|
||
// 170 of 8 021 banks are stereo and decode to one frame if
|
||
// forced to mono.
|
||
let ch = riff_channels(slb).unwrap_or(1);
|
||
let mask = if ch == 2 { 2 } else { 0 };
|
||
out.push(build_riff(&synth_xma1_fmt(ch, mask, 48000), data));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let mut pos = 0usize;
|
||
while let Some(ri) = find(slb, b"RIFF", pos) {
|
||
// Parse this sub-wave's fmt + data. The declared `data` size is an
|
||
// UPPER bound, not an exact one: 5 296 of 7 586 banks declare more than
|
||
// the entry holds and none declares exactly what it holds, so the clamp
|
||
// below is load-bearing (docs/re/structures/slb-data-offset.md).
|
||
let Some(fi) = find(slb, b"fmt ", ri) else { break };
|
||
let Some(fsz) = le32(slb, fi + 4) else { break };
|
||
let Some(fmt_end) = fi.checked_add(8).and_then(|v| v.checked_add(fsz as usize)) else {
|
||
break;
|
||
};
|
||
if fmt_end > slb.len() {
|
||
break;
|
||
}
|
||
let Some(di) = find(slb, b"data", fi) else { break };
|
||
let Some(dsz) = le32(slb, di + 4) else { break };
|
||
let Some(ds) = di.checked_add(8) else { break };
|
||
let de = ds
|
||
.checked_add(dsz as usize)
|
||
.unwrap_or(slb.len())
|
||
.min(slb.len());
|
||
if let Some(data) = slb.get(ds..de) {
|
||
if !data.is_empty() {
|
||
out.push(build_riff(&slb[fi..fmt_end], data));
|
||
}
|
||
}
|
||
// Advance past this sub-wave's data to find the next RIFF.
|
||
pos = de.max(ri + 4);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Build a standalone XMA1 `RIFF/WAVE` from a `.slb` bank, decodable by FFmpeg's
|
||
/// `xma1`. Returns `None` if the bank is too small / malformed. This is the
|
||
/// FIRST sub-wave only; prefer [`to_xma_riffs`] for correct multi-segment banks.
|
||
pub fn to_xma_riff(slb: &[u8]) -> Option<Vec<u8>> {
|
||
if let Some(ri) = find(slb, b"RIFF", 0) {
|
||
// RIFF layout: a `.slb` is an XACT bank of one or more sub-waves, each
|
||
// `[seek][RIFF: fmt + Dmmy pad + data][declared_size XMA bytes]`. Take the
|
||
// FIRST sub-wave, bounded by its **declared `data` size** — which is an
|
||
// upper bound only (69.8 % of banks over-declare it, so the clamp
|
||
// matters), but still the right boundary to cut at. Decoding to
|
||
// end-of-file instead would append the later sub-waves — for multi-take
|
||
// story movies those are ALTERNATE takes, which is what made S10–S16
|
||
// play the wrong audio.
|
||
let fi = find(slb, b"fmt ", ri)?;
|
||
let fsz = le32(slb, fi + 4)? as usize;
|
||
let fmt_end = fi.checked_add(8)?.checked_add(fsz)?;
|
||
if fmt_end > slb.len() {
|
||
return None;
|
||
}
|
||
let fmt_chunk = &slb[fi..fmt_end];
|
||
let di = find(slb, b"data", fi)?;
|
||
let dsz = le32(slb, di + 4)? as usize;
|
||
let end = di.checked_add(8)?.checked_add(dsz)?.min(slb.len());
|
||
let data = slb.get(di + 8..end)?;
|
||
Some(build_riff(fmt_chunk, data))
|
||
} else {
|
||
// Headerless: 1392-byte header, then raw XMA1 (48 kHz, 2 channels).
|
||
let data = slb.get(HEADERLESS_DATA_OFFSET..)?;
|
||
if data.is_empty() {
|
||
return None;
|
||
}
|
||
Some(build_riff(&synth_xma1_fmt(2, 2, 48000), data))
|
||
}
|
||
}
|
||
|
||
/// Rebuild a standalone XMA1 `RIFF/WAVE` from a single-stream `.slb`, robust to
|
||
/// the layout variants seen in `<lang>\etc\` radio clips. Unlike [`to_xma_riff`]
|
||
/// (which assumes `RIFF → fmt → data` in order), this picks the **largest `data`
|
||
/// chunk anywhere** in the bank — some radio banks store the audio *before* the
|
||
/// trailing `RIFF`/`fmt` metadata (an empty post-`RIFF` `data` chunk), which the
|
||
/// ordered scan misses. Pairs it with the first `fmt ` chunk; falls back to the
|
||
/// headerless layout. Returns `None` only when no usable audio can be found.
|
||
pub fn to_xma_riff_best(slb: &[u8]) -> Option<Vec<u8>> {
|
||
// Largest usable `data` chunk (bounded by its declared size and the buffer).
|
||
let mut best: Option<(usize, usize)> = None; // (data offset, usable payload len)
|
||
let mut i = 0;
|
||
while let Some(di) = find(slb, b"data", i) {
|
||
let declared = le32(slb, di + 4).unwrap_or(0) as usize;
|
||
let usable = declared.min(slb.len().saturating_sub(di + 8));
|
||
if best.is_none_or(|(_, b)| usable > b) {
|
||
best = Some((di, usable));
|
||
}
|
||
i = di + 4;
|
||
}
|
||
if let (Some(fi), Some((di, sz))) = (find(slb, b"fmt ", 0), best) {
|
||
if sz > 512 {
|
||
let fsz = le32(slb, fi + 4)? as usize;
|
||
let fmt_end = (fi + 8 + fsz).min(slb.len());
|
||
let data = slb.get(di + 8..di + 8 + sz)?;
|
||
return Some(build_riff(slb.get(fi..fmt_end)?, data));
|
||
}
|
||
}
|
||
// Headerless fallback: fixed data offset, synthesized XMA1 stereo/48k fmt.
|
||
let data = slb.get(HEADERLESS_DATA_OFFSET..)?;
|
||
(!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);
|
||
fmt.extend_from_slice(b"fmt ");
|
||
fmt.extend_from_slice(&32u32.to_le_bytes());
|
||
// XMAWAVEFORMAT header
|
||
fmt.extend_from_slice(&0x0165u16.to_le_bytes()); // wFormatTag = XMA1
|
||
fmt.extend_from_slice(&16u16.to_le_bytes()); // BitsPerSample
|
||
fmt.extend_from_slice(&0u16.to_le_bytes()); // EncodeOptions
|
||
fmt.extend_from_slice(&0u16.to_le_bytes()); // LargestSkip
|
||
fmt.extend_from_slice(&1u16.to_le_bytes()); // NumStreams
|
||
fmt.push(0); // LoopCount
|
||
fmt.push(3); // Version
|
||
// XMASTREAMFORMAT[0]
|
||
fmt.extend_from_slice(&(rate * channels as u32 * 2).to_le_bytes()); // PsuedoBytesPerSec
|
||
fmt.extend_from_slice(&rate.to_le_bytes()); // SampleRate
|
||
fmt.extend_from_slice(&0u32.to_le_bytes()); // LoopStart
|
||
fmt.extend_from_slice(&0u32.to_le_bytes()); // LoopEnd
|
||
fmt.push(4); // SubframeData
|
||
fmt.push(channels); // Channels
|
||
fmt.extend_from_slice(&channel_mask.to_le_bytes()); // ChannelMask
|
||
fmt
|
||
}
|
||
|
||
fn build_riff(fmt_chunk: &[u8], data: &[u8]) -> Vec<u8> {
|
||
let mut body = Vec::with_capacity(4 + fmt_chunk.len() + 8 + data.len());
|
||
body.extend_from_slice(b"WAVE");
|
||
body.extend_from_slice(fmt_chunk);
|
||
body.extend_from_slice(b"data");
|
||
body.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||
body.extend_from_slice(data);
|
||
let mut out = Vec::with_capacity(8 + body.len());
|
||
out.extend_from_slice(b"RIFF");
|
||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||
out.extend_from_slice(&body);
|
||
out
|
||
}
|
||
|
||
fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
|
||
if from >= hay.len() {
|
||
return None;
|
||
}
|
||
hay[from..]
|
||
.windows(needle.len())
|
||
.position(|w| w == needle)
|
||
.map(|p| p + from)
|
||
}
|
||
|
||
fn le32(b: &[u8], o: usize) -> Option<u32> {
|
||
let s = b.get(o..o + 4)?;
|
||
Some(u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::hash::name_hash;
|
||
|
||
#[test]
|
||
fn movie_voice_names_and_hashes() {
|
||
assert_eq!(
|
||
movie_voice_name("RT07A", VoiceLang::English),
|
||
"eng\\Movie\\VOICE_RT07A.slb"
|
||
);
|
||
// Verified present in dat/sound.pak (VOICE_S00A == TOC entry 0x2A4F97D3).
|
||
assert_eq!(name_hash("eng\\Movie\\VOICE_RT07A.slb"), 0xA44A_EA1C);
|
||
assert_eq!(name_hash("eng\\Movie\\VOICE_S00A.slb"), 0x2A4F_97D3);
|
||
}
|
||
|
||
#[test]
|
||
fn takes_only_first_subwave() {
|
||
// Bank of TWO sub-waves; only the FIRST must be extracted (bounded by its
|
||
// declared `data` size), not everything to end-of-file.
|
||
let fmt = synth_xma1_fmt(2, 2, 48000);
|
||
let mut slb = vec![0xAB; 16];
|
||
slb.extend_from_slice(b"RIFF");
|
||
slb.extend_from_slice(&999u32.to_le_bytes()); // riff size (ignored)
|
||
slb.extend_from_slice(b"WAVE");
|
||
slb.extend_from_slice(&fmt);
|
||
slb.extend_from_slice(b"data");
|
||
slb.extend_from_slice(&4u32.to_le_bytes()); // declared: 4 bytes
|
||
slb.extend_from_slice(&[1, 2, 3, 4]); // sub-wave 1 XMA
|
||
slb.extend_from_slice(&[9, 9, 9, 9]); // a second sub-wave's bytes — excluded
|
||
let riff = to_xma_riff(&slb).unwrap();
|
||
assert_eq!(&riff[0..4], b"RIFF");
|
||
let dpos = find(&riff, b"data", 0).unwrap();
|
||
assert_eq!(le32(&riff, dpos + 4), Some(4));
|
||
assert_eq!(&riff[dpos + 8..], &[1, 2, 3, 4]);
|
||
}
|
||
|
||
#[test]
|
||
fn list_voice_clips_covers_movie_radio_and_briefing() {
|
||
// The standalone player must enumerate every spoken-line category: bound
|
||
// movie voices, in-mission radio (\etc\ + \Voice\), and briefing (\Briefing\,
|
||
// whose BR<NN>_<MM> names lack "VOICE"). Music (BGM_*) must stay excluded.
|
||
let mut tbl = Vec::new();
|
||
for s in [
|
||
"eng\\Movie\\VOICE_S13A.slb",
|
||
"eng\\etc\\VOICE_D_450.slb",
|
||
"eng\\Voice\\VOICE_ADAN_010.slb",
|
||
"eng\\Briefing\\BR01_01.slb",
|
||
"eng\\bgm\\BGM_001.slb",
|
||
] {
|
||
tbl.extend_from_slice(s.as_bytes());
|
||
tbl.push(0);
|
||
}
|
||
let names: Vec<String> = list_voice_clips(&tbl, VoiceLang::English)
|
||
.into_iter()
|
||
.map(|c| c.name)
|
||
.collect();
|
||
for want in [
|
||
"eng\\Movie\\VOICE_S13A.slb",
|
||
"eng\\etc\\VOICE_D_450.slb",
|
||
"eng\\Voice\\VOICE_ADAN_010.slb",
|
||
"eng\\Briefing\\BR01_01.slb",
|
||
] {
|
||
assert!(names.iter().any(|n| n == want), "missing {want}");
|
||
}
|
||
assert!(
|
||
!names.iter().any(|n| n.contains("BGM_")),
|
||
"music must not be listed as a voice clip"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn list_audio_entries_categorises_root_banks_and_keeps_them_language_shared() {
|
||
// The three root banks carry no language component, so BOTH sounds.tbl
|
||
// files name them; a language filter that only accepted `<lang>\` would
|
||
// silently drop all the music, which is what it used to do.
|
||
let mut tbl = Vec::new();
|
||
for s in [
|
||
"BGM_001.slb",
|
||
"JNGL_002.slb",
|
||
"Static.slb",
|
||
"eng\\Voice\\VOICE_ADAN_010.slb",
|
||
"eng\\etc\\VOICE_D_450.slb",
|
||
"eng\\Movie\\VOICE_S13A.slb",
|
||
"eng\\Briefing\\BR01_01.slb",
|
||
] {
|
||
tbl.extend_from_slice(s.as_bytes());
|
||
tbl.push(0);
|
||
}
|
||
let by = |lang| {
|
||
list_audio_entries(&tbl, lang)
|
||
.into_iter()
|
||
.map(|e| (e.clip.name, e.category))
|
||
.collect::<Vec<_>>()
|
||
};
|
||
let eng = by(VoiceLang::English);
|
||
let want = [
|
||
("BGM_001.slb", AudioCategory::Music),
|
||
("JNGL_002.slb", AudioCategory::Jingle),
|
||
("Static.slb", AudioCategory::Sfx),
|
||
("eng\\Voice\\VOICE_ADAN_010.slb", AudioCategory::Radio),
|
||
("eng\\etc\\VOICE_D_450.slb", AudioCategory::Dialogue),
|
||
("eng\\Movie\\VOICE_S13A.slb", AudioCategory::MovieVoice),
|
||
("eng\\Briefing\\BR01_01.slb", AudioCategory::Briefing),
|
||
];
|
||
assert_eq!(eng.len(), want.len());
|
||
for (n, c) in want {
|
||
assert!(
|
||
eng.iter().any(|(en, ec)| en == n && *ec == c),
|
||
"{n} not categorised as {c:?}"
|
||
);
|
||
}
|
||
// Reading the Japanese table yields the shared banks and none of the
|
||
// English lines.
|
||
let jpn = by(VoiceLang::Japanese);
|
||
assert_eq!(jpn.len(), 3, "only the shared banks: {jpn:?}");
|
||
assert!(jpn.iter().all(|(_, c)| c.is_shared()));
|
||
// And the voice view is exactly the non-shared half.
|
||
assert_eq!(list_voice_clips(&tbl, VoiceLang::English).len(), 4);
|
||
}
|
||
|
||
#[test]
|
||
fn rebuilds_riff_from_headerless() {
|
||
let mut slb = vec![0u8; HEADERLESS_DATA_OFFSET];
|
||
slb.extend_from_slice(&[9, 8, 7, 6]);
|
||
let riff = to_xma_riff(&slb).unwrap();
|
||
let dpos = find(&riff, b"data", 0).unwrap();
|
||
assert_eq!(&riff[dpos + 8..], &[9, 8, 7, 6]);
|
||
// synthetic fmt advertises XMA1.
|
||
let fpos = find(&riff, b"fmt ", 0).unwrap();
|
||
assert_eq!(le32(&riff, fpos + 8).map(|v| v as u16), Some(0x0165));
|
||
}
|
||
}
|