Files
Sylpheed/crates/sylpheed-export/src/audio.rs
Fabian Hamm ed54f95d54 style: rustfmt sweep -- 774 hunks across 154 files -> 0
`cargo fmt --all -- --check` has failed on every run in this repository's
history, identically on `main` and on every branch. This is #12.

Mechanical: `cargo fmt --all`, nothing else. 154 files, all `.rs`, no other
extension touched. `cargo check --workspace` exits 0 afterwards, so nothing
changed semantically.

ON THE ORDERING, WHICH WAS THE REAL QUESTION.

HANDOFF-2026-09-06 section 7 warns this is the expensive fix: a whole-tree
reformat before #7 and #8 return "would put a conflict in every file of 861
commits and make the reviews those items exist to enable unreadable".

That is measurably too pessimistic, and it had been reasoned rather than
tested. Measured here by three-way merging a rustfmt'd `main` against both
unmerged branches, file by file:

  file/branch pairs tested   32
  merges CLEAN               28
  merges CONFLICTING          4   (8 conflict hunks total)

    sylpheed-cli/src/main.rs      1 hunk
    sylpheed-export/src/check.rs  1
    sylpheed-export/src/screen.rs 4
    sylpheed-export/src/video.rs  2

All four are against `auto/frame-blend-draw-path` only;
`auto/port-p6-audio` does not conflict anywhere. The earlier framing --
154 dirty files, 133 that cannot collide, 21 that can, the collision set
carrying 147 of 774 hunks (19%) -- reproduces exactly. What it did not say
is that most of the 21 still merge cleanly, because rustfmt's edits and the
branches' edits rarely land on the same lines.

So the cost of sweeping now is 4 files and 8 hunks for one branch, against
a check that is otherwise red forever. Deliberately NOT folded into the
WASM PR: 154 reformatted files would make that one unreviewable.

Closes #12
2026-09-08 20:07:01 +02:00

1195 lines
52 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Menu audio: disc XMA → Ogg Vorbis, because Godot 4 plays Vorbis natively and
//! will never be taught to read XMA or to open `sound.pak`.
//!
//! Two kinds of thing come out of here and they are not symmetric:
//!
//! * **SE cues** — three short mono waves out of `Static.slb`, one per menu
//! event. Where each one lives was *measured off the running game* (HANDOFF
//! Q8) and is **not on the disc in any findable form**.
//! * **BGM** — one bank of `sound.pak`, which is **two stems of one
//! performance** (HANDOFF Q10). They are summed here into a single file.
//!
//! ## Neither table lives in this file
//!
//! Both come from `authored/audio.json`, and that is the point of the module
//! rather than an accident of configuration. MISSION §3: a value that is
//! *measured* rather than *decoded* lives in `authored/`, carries a `why`, and
//! is deleted the day the disc states it. A measured offset compiled into a Rust
//! `const` is a measurement wearing the costume of a decoded field — it reads
//! like the exporter derived it, and nobody deletes it, because nobody can see
//! it. Contrast [`crate::video::MOVIES`], which *is* a `const` here: Q9 decoded
//! that mapping from the movie manifest on the disc.
//!
//! ## The assembly is not reimplemented here
//!
//! `sylpheed_formats::media` owns every question of the form *"which bytes
//! belong together"* — segment-spanning reads, multi-sub-wave banks, and the
//! delimiter-less `Static.slb` where a wave is only `(offset, packet_count)`.
//! This module asks it for `RIFF`s and converts them. The seam is deliberate:
//! everything before it is disc knowledge, everything after it is a codec
//! choice, and re-deriving the first half here is exactly the mistake the
//! mission names.
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use sylpheed_formats::media::{self, DiscSource};
/// One menu sound effect, and where its wave sits in a delimiter-less bank.
///
/// `offset` is a string so the file can carry `"0x1ec0"` — the form the RE
/// finding is written in. A reader comparing the two should not have to convert
/// 125 632 in their head to believe they match.
#[derive(Deserialize)]
pub struct CueSpec {
pub bank: String,
pub offset: String,
pub packets: usize,
pub channels: u8,
pub rate: u32,
/// The game's own cue identifier, where one has been *guessed by name*.
/// Absent means nobody claimed one — never that the binding is unknown.
#[serde(default)]
pub name_match: Option<String>,
pub why: String,
}
impl CueSpec {
fn offset(&self, event: &str) -> Result<usize> {
let s = self.offset.trim();
let parsed = match s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
Some(hex) => usize::from_str_radix(&hex.replace('_', ""), 16),
None => s.replace('_', "").parse(),
};
parsed.with_context(|| format!("cue `{event}`: `{s}` is not an offset"))
}
}
/// One music bank and what to do with it.
#[derive(Deserialize)]
pub struct BgmSpec {
pub bank: String,
/// What happens at the end of the file. Carried through to the manifest so
/// the runtime does not have to reach into `authored/` to find out, and so
/// that the *why* travels with the decision.
#[serde(default)]
pub r#loop: Option<String>,
pub why: String,
#[serde(default)]
pub loop_why: Option<String>,
/// How a bank's two sub-waves become one file. **Only `"sum"` is
/// implemented**, and this field exists to say so when it is not.
///
/// 🔴 It was `stems_why` alone until 2026-08-30 — the *reason* was
/// deserialised and the *value* was not, so `stems` sat in
/// `authored/audio.json` being ignored by serde. Changing it to anything at
/// all did nothing and warned nobody, which is the seventh instance in this
/// port of an authored value with no reader.
///
/// It is ASSERTED rather than implemented: a weighted mix is not written,
/// and inventing one would be a level decision nobody measured (HANDOFF Q10
/// settles that the two waves are summed, not what wave 1 *is*).
/// Seconds of the summed bank the game actually plays before wrapping.
/// `None` = the whole wave.
///
/// 🔴 Godot loops a WHOLE FILE, so a loop region has to BE the file. The
/// exporter therefore trims to this length rather than carrying a loop
/// point the runtime could not honour, and the trimmed tail is content the
/// game never reaches.
/// Seconds into the summed bank where the loop window BEGINS.
///
/// 🔴 Split out from `loop_end_s` on 2026-08-30 because carrying only an end
/// silently asserted a start of zero, and that start is now known to be
/// WRONG — the measured window begins about ten seconds in. An assumption
/// that has to be inferred from the absence of a field is not one a reader
/// can weigh.
#[serde(default)]
pub loop_start_s: Option<f64>,
#[serde(default)]
pub loop_end_s: Option<f64>,
// Deserialised to model the sidecar schema, not read in Rust.
#[allow(dead_code)]
#[serde(default)]
pub loop_end_why: Option<serde_json::Value>,
#[serde(default)]
pub stems: Option<String>,
#[serde(default)]
pub stems_why: Option<String>,
}
/// 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 {
/// Every equal-length survivor, summed at unity. **The authored value.**
///
/// Measured from the game's own output: all three play. See `export_voice`.
All,
/// Peak nearest full scale. Kept so an older `authored/audio.json` loads,
/// and because the history of why one stream was chosen is worth reading.
#[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,
/// Declared XMA `byte_size` -> stereo-downmix coefficient, from
/// `authored/audio.json`. Empty means no region is weighted.
pub stream_weights: std::collections::BTreeMap<usize, f64>,
}
/// Read `authored/audio.json`, or `None` when there is no such file.
///
/// Absent is not an error: an export with no audio is what every milestone
/// before P6 produced, and it should stay possible to take one.
pub fn load(authored: &Path) -> Result<Option<Config>> {
let path = authored.join("audio.json");
if !path.exists() {
return Ok(None);
}
#[derive(Deserialize)]
struct File {
#[serde(default)]
se: BTreeMap<String, serde_json::Value>,
#[serde(default)]
bgm: BTreeMap<String, serde_json::Value>,
#[serde(default)]
voice: BTreeMap<String, serde_json::Value>,
}
let raw = std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
let file: File =
serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))?;
// `_` is the house convention for a prose block explaining the section it
// sits in -- see `authored/flow.json` and `authored/timing.json`. It is
// documentation, not an entry, and the schema must skip it rather than
// force the reasoning out of the file that holds the decision.
fn entries<T: serde::de::DeserializeOwned>(
m: BTreeMap<String, serde_json::Value>,
what: &str,
) -> Result<Vec<(String, T)>> {
m.into_iter()
.filter(|(k, _)| k != "_")
.map(|(k, v)| {
let parsed = serde_json::from_value(v)
.with_context(|| format!("authored/audio.json: {what}.{k}"))?;
Ok((k, parsed))
})
.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(),
};
// Declared `byte_size` -> stereo-downmix coefficient. Keyed by size so the
// exporter can CHECK the stream is the one the measurement describes.
let mut stream_weights: std::collections::BTreeMap<usize, f64> = Default::default();
if let Some(serde_json::Value::Object(m)) = file.voice.get("stream_weights") {
for (k, v) in m {
if k == "_" {
continue;
}
let size: usize = k
.parse()
.with_context(|| format!("authored/audio.json: voice.stream_weights key {k}"))?;
let w = v
.get("weight")
.and_then(serde_json::Value::as_f64)
.with_context(|| {
format!("authored/audio.json: voice.stream_weights.{k} has no numeric weight")
})?;
stream_weights.insert(size, w);
}
}
Ok(Some(Config {
se: entries(file.se, "se")?,
bgm: entries(file.bgm, "bgm")?,
voice,
stream_weights,
}))
}
/// Vorbis quality. `-q:a 5` is ffmpeg's usual transparent-ish setting and is
/// what [`crate::video`] already uses for the movies' audio; using one value
/// across the export means a level difference between a cue and a movie cannot
/// be a codec artefact.
const VORBIS_Q: &str = "5";
/// Bytes of RIFF header `to_xma_riffs` prepends to a chunk. The decoder's
/// `byte_size` is the payload, so a size comparison must subtract it.
const RIFF_HEADER: usize = 60;
pub struct Exported {
pub name: String,
pub file: String,
pub command: String,
pub why: String,
/// dBFS peak of the decoded result. Recorded because the BGM sum can clip
/// and a clipped file is not visibly different from a correct one.
pub peak_dbfs: Option<f32>,
/// Seconds, as ffprobe reads them back off the finished file. Recorded
/// because the cue durations are the one thing about the SE export that an
/// outside finding predicts, so they are the one thing that can be checked.
pub duration_s: Option<f32>,
pub kind: &'static str,
/// The game's own identifier where it is a name match, never a measurement.
pub name_match: Option<String>,
/// How many of `sub_waves` this export actually carries. 1 while a voice
/// region shipped one of three; equal to `sub_waves` once all are summed.
/// Exists so a "known incomplete" warning fires on the gap and not on the
/// mere presence of more than one stream.
pub kept_waves: usize,
/// How many of `sub_waves` carry SIGNAL. A dropped stream that is digitally
/// silent is not missing content, and a warning that fires on it is crying
/// wolf: `S00A`'s third chunk is 93.694 s of exact zeroes, so dropping it
/// costs nothing and saying "KNOWN INCOMPLETE" over it would train a reader
/// to ignore the one case that means something.
pub content_waves: usize,
/// What the runtime should do at the end of the file, where that was
/// authored. `None` on a cue: a cue ends.
pub loop_mode: Option<String>,
/// How many sub-waves `media` returned for a bank. Carried out of here so
/// the caller can warn when it contradicts HANDOFF's census -- this module
/// does not get to decide that one of them is not a stem.
pub sub_waves: usize,
}
/// Run ffmpeg, writing to a temp name and renaming on success.
///
/// The rename is not tidiness: the filesystem is shared with another agent, and
/// a reader that catches a half-written Ogg gets a confident wrong duration
/// rather than an error. `docs/port/AUDIO-VERIFICATION.md` records that this
/// already happened once on a video.
fn run_ffmpeg(argv: &[String], out: &Path) -> Result<()> {
// The extension goes LAST, not the `.partial`. ffmpeg picks its muxer from
// the output filename, so `.back.ogg.partial` is not a slightly uglier
// temp name -- it is a hard failure before a byte is written: "Unable to
// choose an output format". `video.rs` already had this shape; this
// function was written from scratch and did not.
let stem = out
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let ext = out
.extension()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let partial = out.with_file_name(format!(".{stem}.partial.{ext}"));
let mut argv = argv.to_vec();
let last = argv.len() - 1;
argv[last] = partial.display().to_string();
let res = Command::new("ffmpeg")
.args(&argv)
.output()
.context("run ffmpeg -- is it on PATH?")?;
if !res.status.success() {
let _ = std::fs::remove_file(&partial);
bail!(
"ffmpeg failed writing {}:\n{}",
out.display(),
String::from_utf8_lossy(&res.stderr)
);
}
std::fs::rename(&partial, out)?;
Ok(())
}
/// Peak level and duration of a finished file.
///
/// Measured rather than assumed because the BGM is a **sum of two stems** and a
/// sum can clip, and because silence is the audio failure that looks like
/// success: a file of exactly the right duration, full of zeroes. Both numbers
/// pass every check that is not looking for them, so the export looks for them.
fn measure(path: &Path) -> (Option<f32>, Option<f32>) {
let out = Command::new("ffmpeg")
.args(["-hide_banner", "-v", "info", "-i"])
.arg(path)
.args(["-af", "astats=measure_perchannel=none", "-f", "null", "-"])
.output();
let Ok(out) = out else { return (None, None) };
let text = String::from_utf8_lossy(&out.stderr).into_owned();
// `astats` writes through the filter log, so every line carries a
// `[Parsed_astats_0 @ 0x…] ` prefix. Matching on the line START silently
// finds nothing and reports "peak unmeasured", which is the failure this
// measurement exists to catch -- so it is found as a SUBSTRING.
const KEY: &str = "Peak level dB:";
let peak = text
.lines()
.find_map(|l| l.split_once(KEY)?.1.trim().parse().ok());
let dur = Command::new("ffprobe")
.args([
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
])
.arg(path)
.output()
.ok()
.and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok());
(peak, dur)
}
/// Write one XMA `RIFF` beside the output so ffmpeg has something to open.
///
/// Kept next to the result rather than in `/tmp` so a failed export leaves the
/// intermediate where the person debugging it will look, and removed on success
/// so the tree holds only formats a modder can open (MODDING rule 3).
fn stage_riff(dir: &Path, stem: &str, riff: &[u8]) -> Result<PathBuf> {
let path = dir.join(format!(".{stem}.xma.wav"));
std::fs::write(&path, riff).with_context(|| format!("write {}", path.display()))?;
Ok(path)
}
/// The menu cues, as `audio/se/<event>.ogg`.
pub fn export_cues<S: DiscSource + ?Sized>(
source: &S,
out: &Path,
cues: &[(String, CueSpec)],
) -> Result<Vec<Exported>> {
if cues.is_empty() {
return Ok(Vec::new());
}
let dir = out.join("audio/se");
std::fs::create_dir_all(&dir)?;
let mut done = Vec::new();
for (event, cue) in cues {
let offset = cue.offset(event)?;
// `media` owns the assembly. Asking it for the RIFF rather than reading
// `Static.slb` here is the whole point of the seam -- and it REFUSES a
// short read rather than returning a truncated stream, because a
// truncated XMA decodes to plausible-sounding garbage.
let riff = media::se_wave_riff(
source,
&cue.bank,
offset,
cue.packets,
cue.channels,
cue.rate,
)
.map_err(anyhow::Error::msg)
.with_context(|| format!("assemble the {event} cue"))?;
let staged = stage_riff(&dir, event, &riff)?;
let ogg = dir.join(format!("{event}.ogg"));
let argv: Vec<String> = [
"-hide_banner",
"-loglevel",
"error",
"-y",
"-i",
&staged.display().to_string(),
"-c:a",
"libvorbis",
"-q:a",
VORBIS_Q,
&ogg.display().to_string(),
]
.iter()
.map(|s| s.to_string())
.collect();
let command = format!("ffmpeg {}", argv.join(" "));
run_ffmpeg(&argv, &ogg)?;
let (peak, dur) = measure(&ogg);
std::fs::remove_file(&staged).ok();
done.push(Exported {
name: event.clone(),
file: format!("audio/se/{event}.ogg"),
command,
why: format!(
"{} AUTHORED because it is measured, not decoded: authored/audio.json \
se.{event}. Located in {} at {:#x} for {} packet(s); the ASSEMBLY is \
sylpheed_formats::media::se_wave_riff, which refuses a short read.",
cue.why, cue.bank, offset, cue.packets
),
peak_dbfs: peak,
duration_s: dur,
kind: "se",
name_match: cue.name_match.clone(),
loop_mode: None,
sub_waves: 1,
kept_waves: 1,
content_waves: 1,
});
}
Ok(done)
}
/// One music bank, as a single `audio/bgm/<name>.ogg`.
///
/// **The two stems are summed, not concatenated and not split into two files.**
///
/// HANDOFF Q10: a bank's sub-waves are two stems of one performance, played
/// together — sample-synchronous, equal duration, on all 32 banks.
/// Concatenating them is explicitly wrong. Emitting two files would be wrong
/// here for a different reason: MODDING rule 1 is *one logical asset, one
/// file*, and a modder who had to line two stems up by hand would be
/// reassembling exactly what the exporter exists to resolve.
///
/// **The sum is scaled by 1/n, and an earlier version of this comment argued the
/// opposite.** It said `normalize=0` sums at unity "because halving is a mix
/// decision nobody made". That was wrong twice over. Unity summing IS a decision
/// — and it is the one that can clip, which it duly did: `BGM_103` came out at
/// **+1.8 dBFS**. And 1/n is not a taste call but the smallest constant that
/// makes an n-input sum of unity-scale signals provably clip-free, which is the
/// same reasoning `video.rs` already uses for its 0.4142-normalised 5.1 downmix.
/// It is written out as an explicit `volume=` rather than left to `amix`'s
/// `normalize=1` default so the coefficient appears in the manifest's command
/// line: a default is a decision nobody made, and it can move under an ffmpeg
/// upgrade.
///
/// It preserves the stems' relative balance exactly, which is the only thing
/// about the sum that HANDOFF Q10 actually settles. The peak is still measured
/// and reported.
///
/// The file is named for the **role** (`main_menu`), not for the bank
/// (`BGM_001`). Which bank plays here is authored and expected to change; the
/// role is what the runtime asks for, and a rename of the disc asset should not
/// be a change to the Godot project.
pub fn export_bgm<S: DiscSource + ?Sized>(
source: &S,
out: &Path,
role: &str,
spec: &BgmSpec,
) -> Result<Option<Exported>> {
// Assert the authored value this function was built for, rather than
// silently doing something else. Only `sum` is implemented.
if let Some(mode) = spec.stems.as_deref() {
if mode != "sum" {
bail!(
"authored/audio.json bgm.{role}.stems is {mode:?}; \
export_bgm implements only \"sum\" (HANDOFF Q10 settles that a \
bank's two waves are summed; a weighting would be an unmeasured \
level decision)"
);
}
}
let riffs = match media::sound_bank_riffs(source, &spec.bank) {
Ok(r) if !r.is_empty() => r,
Ok(_) => return Ok(None),
// "This disc does not have that bank" is a MISSING ASSET, not a broken
// exporter: the manifest carries a warning and everything else still
// exports. Any other failure -- a short read, a malformed bank -- is a
// real error and stops the run, because a partly-read bank produces a
// file that plays.
Err(e) if e.contains("not present in sound.pak") => return Ok(None),
Err(e) => bail!("{}: {e}", spec.bank),
};
let dir = out.join("audio/bgm");
std::fs::create_dir_all(&dir)?;
let mut all = Vec::new();
for (i, riff) in riffs.iter().enumerate() {
all.push(stage_riff(&dir, &format!("{role}.{i}"), riff)?);
}
// DROP DIGITALLY SILENT SUB-WAVES BEFORE SUMMING -- arithmetic, not a
// decoding decision, and the same rule `export_voice` already applies.
//
// Every music bank returns THREE sub-waves where HANDOFF Q10's census says
// two, and the extra one is identical in all three banks measured:
//
// BGM_103 / BGM_102 / BGM_001 sub-wave 0: 10 300 B -> 0.009 s, peak -inf
//
// 10 300 B is 10 240 + a 60-byte RIFF wrapper, and 10 240 B is exactly what
// the Decoder's disc-wide census identifies as the BANK HEADER. So it is not
// a stem, it is silence, and counting it in the divisor attenuated every
// real stem by 1/3 instead of 1/2 -- **3.52 dB, on all the menu music this
// port has shipped since P6**. A silent input contributes nothing to a sum;
// including it in the normalisation is my error, not a judgement about
// content.
let quiet: Vec<usize> = (0..all.len())
.filter(|&i| decoded_chunk(&all[i]).1 <= -90.0)
.collect();
let staged: Vec<PathBuf> = (0..all.len())
.filter(|i| !quiet.contains(i))
.map(|i| all[i].clone())
.collect();
if staged.is_empty() {
return Ok(None);
}
let ogg = dir.join(format!("{role}.ogg"));
let mut argv: Vec<String> = ["-hide_banner", "-loglevel", "error", "-y"]
.iter()
.map(|s| s.to_string())
.collect();
for s in &staged {
argv.push("-i".into());
argv.push(s.display().to_string());
}
if staged.len() > 1 {
argv.push("-filter_complex".into());
argv.push(format!(
"amix=inputs={n}:normalize=0,volume={:.6}",
1.0 / staged.len() as f64,
n = staged.len()
));
}
// 🔴 TRIM TO THE MEASURED LOOP REGION. Godot loops a whole file, so the
// region has to be the file; carrying a loop point the runtime cannot
// honour would leave the fade-out playing every cycle.
if let Some(start) = spec.loop_start_s.filter(|v| *v > 0.0) {
argv.push("-ss".into());
argv.push(format!("{start}"));
}
if let Some(end) = spec.loop_end_s {
// A LENGTH, applied after any `-ss`, so the pair is (start, duration)
// and moving the start does not silently change how much is kept.
argv.push("-t".into());
argv.push(format!("{end}"));
}
argv.extend(
[
"-c:a",
"libvorbis",
"-q:a",
VORBIS_Q,
&ogg.display().to_string(),
]
.iter()
.map(|s| s.to_string()),
);
let command = format!("ffmpeg {}", argv.join(" "));
run_ffmpeg(&argv, &ogg)?;
let (peak, dur) = measure(&ogg);
for s in &all {
std::fs::remove_file(s).ok();
}
let mut why = format!(
"{} authored/audio.json bgm.{role} names bank {}. Of its {} \
sub-wave(s), {} are SUMMED into one file and the sum is scaled by 1/{}, \
which is the smallest constant that cannot clip.{}",
spec.why,
spec.bank,
riffs.len(),
staged.len(),
staged.len(),
if quiet.is_empty() {
String::new()
} else {
format!(
" DROPPED {} DIGITALLY SILENT sub-wave(s) before summing -- each 10 300 B \
decoding to 0.009 s at peak -inf, which is the 10 240-byte BANK HEADER plus \
a RIFF wrapper, not a stem. Counting them in the divisor attenuated every \
real stem by 3.52 dB. This is arithmetic, not a decoding decision.",
quiet.len()
)
}
);
if let Some(s) = &spec.stems_why {
why.push(' ');
why.push_str(s);
}
if let Some(s) = &spec.loop_why {
why.push(' ');
why.push_str(s);
}
Ok(Some(Exported {
name: role.to_string(),
file: format!("audio/bgm/{role}.ogg"),
command,
why,
peak_dbfs: peak,
duration_s: dur,
kind: "bgm",
name_match: None,
loop_mode: spec.r#loop.clone(),
sub_waves: staged.len(),
kept_waves: 1,
content_waves: 1,
}))
}
/// One cutscene's voice-over, as `audio/voice/<movie>.ogg`.
///
/// ## Why this is a separate file from the movie at all
///
/// A human play-test heard music under the intro and no dialogue, and the
/// obvious reading — "the transcode dropped a channel" — is wrong. `ADV.wmv`
/// genuinely carries **music and effects only**. On this disc a cutscene's voice
/// is a *different asset*: one continuous XMA stream in `sound.pak`, bound to
/// the movie by the movie manifest in `tables.pak` (`ADV` → `VOICE_ADV`). It was
/// not dropped by [`crate::video`]; it was never exported, because nothing here
/// asked for it.
///
/// ## The binding is resolved, never matched by name
///
/// `sylpheed_formats::media::resolve_movie_voice_region` walks
/// movie → cue token → sound id → byte region. It is the only route taken here,
/// and the reason is that the cheap route looks correct on exactly the movies a
/// person would check first. Measured on the retail disc:
///
/// | movie | region | inside the bank named after it? |
/// |---|---|---|
/// | `ADV` | 433 930 240…437 044 592 | yes |
/// | `S00A` | 452 798 464…455 499 120 | yes |
/// | `RT01A` | 437 044 592…437 345 648 | **no — it is inside `VOICE_ADV.slb`** |
///
/// So `VOICE_<movie>.slb` is a name that happens to hold the right audio twice
/// out of three, and the two it gets right are the two in this port's scope.
/// Reading the bank by name would have shipped, verified clean, and been wrong
/// for the radio cutscenes the moment anybody extended the export.
///
/// ## What a `None` means, and what it must not become
///
/// A movie whose region does not resolve is **genuinely unvoiced** — that is a
/// real answer for most `hokyu_*` resupply cutscenes, and the corpus already
/// paid for the alternative: resolving unbound movies through a shared demo line
/// played the *wrong recording*. Nothing is substituted. A `\Movie\` token that
/// resolved a clip but not a region stays silent for the same reason
/// `sylpheed-viewer` keeps it silent: its raw `.slb` is off by one chunk, so it
/// is not this movie's dialogue.
///
/// ## Three choices made here, and the reason each is not a guess
///
/// * **One file per movie** (MODDING rule 1), and **exactly one region chunk is
/// kept**. Not concatenated, not summed. This function got that wrong twice
/// before it got it right, and the history is kept below because each wrong
/// reading was ended by a measurement, not by an argument.
///
/// ## A region holds THREE PRESENTATIONS OF ONE TAKE — decoded, and not by me
///
/// A resolved region decodes to several chunks. Decoded and timed against the
/// movies' own lengths:
///
/// | movie | movie | chunk 0 | chunk 1 | chunk 2 |
/// |---|---|---|---|---|
/// | `ADV` | 137.437 s | 84.553 | **137.324** | **137.324** |
/// | `S00A` | 93.779 s | 68.072 | **93.694** | **93.694** |
/// | `RT01A` | — | 0.009 | **34.034** | — |
///
/// **Reading 1, concatenate:** 359 s of dialogue for a 137 s movie. Dead.
///
/// **Reading 2, sum them as HANDOFF Q10's two stems** — equal duration, each
/// spanning the movie, which is exactly Q10's *music* shape. ❌ **Refuted here,
/// and the claim had already been adopted into the RE corpus before I tested
/// it**: `S00A`'s second full-length chunk is **digital silence** (peak −inf)
/// and `ADV`'s is **0.60 × the first** with 26.8 dB of residual. Equal duration
/// was a shape match and carrying a music census across on it was my error.
///
/// **Reading 3, one stream. ✅ Decoded disc-wide by the Decoder**, by counting
/// stream starts inside every inter-descriptor span: **258 spans hold one
/// stream, 28 hold three, and nothing holds two or any other number.** The 95
/// movie-voice regions decompose 70 + 8 + 17. So a region is three presentations
/// of one take, and `359 = 84.55 + 137.32 + 137.32`. Summing a take with a
/// scaled copy of itself adds ~4 dB and colours it.
///
/// 🟡 **Which presentation to keep is a recommendation, not a field.** The
/// selector is the **highest byte rate** among the equal-duration survivors, on
/// the Decoder's advice. Nothing on the disc says which one the game plays, and
/// on `ADV` this picks the **quieter** of the two — −8.3 dBFS against 0.0. It is
/// stated in the manifest with that consequence so the choice is reversible; a
/// capture of the intro with dialogue audible settles it.
///
/// ## Chunk 0 is dropped, and it is a DUPLICATE rather than a truncation
///
/// This comment first guessed it was `BGM_103`'s third sub-wave — a bank header
/// — and a census over all 95 regions showed that is a *different structure*:
/// 78 open with a 10 240 B bank header, 17 with a leading headerless stream at
/// the disc's own `1392 mod 2048` data offset, and **the chunk count
/// discriminates neither**. The byte-span test then found it is **this movie's
/// own dialogue, 17 of 17** — not an in-mission line, which was the standing
/// hypothesis.
///
/// Which raised the real question: is dropping it a truncation? **No.** Measured
/// here with a decoder the RE container does not have — sliding envelope
/// correlation, overhang allowed, normalised over the overlap: **r = 0.998**
/// (`ADV`) and **0.932** (`S00A`), against controls of 1.000 (self) and 0.289 (a
/// different movie), with both lags placing chunk 0 **flush against the end** of
/// the kept stream. Confirmed in the sample domain at 16.7 / 23.2 dB of
/// residual. It is the tail of the take, presented again.
///
/// So the selection rule is stated in terms of what was measured — *the longest
/// duration, ties broken by byte rate, minus anything digitally silent* — and
/// every dropped chunk is named in the manifest with its length and peak.
/// * **Mono**, with the fold chosen from the stream's own declared channel
/// count rather than by passing `-ac 1` and hoping. A voice track that is
/// already mono is passed through untouched.
/// * **No sync offset.** The voice plays from the video's first frame, so the
/// runtime needs no delay and none is authored. The decoded length is
/// recorded beside the movie's own length in the manifest so a disagreement
/// is visible rather than absorbed.
pub fn export_voice<S: DiscSource + ?Sized>(
source: &S,
out: &Path,
movie: &str,
video_duration_s: Option<f32>,
presentation: Presentation,
stream_weights: &std::collections::BTreeMap<usize, f64>,
) -> Result<Option<Exported>> {
use sylpheed_formats::slb::VoiceLang;
// English only: MISSION §7 puts localisation beyond English out of scope.
// The language is a parameter of the resolution, not of the file layout, so
// adding Japanese later is a second call and a second file, not a re-think.
let lang = VoiceLang::English;
let region = media::resolve_movie_voice_region(source, movie, lang);
let Some((start, end)) = region else {
return Ok(None);
};
let riffs = media::voice_region_riffs(source, start, end)
.map_err(anyhow::Error::msg)
.with_context(|| format!("decode the {movie} voice region"))?;
if riffs.is_empty() {
return Ok(None);
}
let dir = out.join("audio/voice");
std::fs::create_dir_all(&dir)?;
let mut all = Vec::new();
for (i, riff) in riffs.iter().enumerate() {
all.push(stage_riff(&dir, &format!("{movie}.{i}"), riff)?);
}
// Classify before mixing. XMA declares no duration, so each chunk is decoded
// and timed -- the only way to tell a stem from the leading region, and the
// measurement that showed concatenation to be wrong here.
let probed: Vec<(f32, f32)> = all.iter().map(|p| decoded_chunk(p)).collect();
let lengths: Vec<f32> = probed.iter().map(|&(d, _)| d).collect();
// A DIGITALLY SILENT chunk is dropped before anything else, and that is
// arithmetic rather than a judgement about content: it contributes nothing
// to a mix, and counting it in the 1/n normalisation costs 6.02 dB for
// nothing. `S00A`'s second full-length chunk is exactly this -- 4 497 300
// samples of zeroes, peak -inf -- and summing it is why that movie's voice
// came out at -16.2 dBFS against a source peaking at -4.2.
let silent: Vec<usize> = (0..all.len()).filter(|&i| probed[i].1 <= -90.0).collect();
let longest = (0..all.len())
.filter(|i| !silent.contains(i))
.map(|i| lengths[i])
.fold(0.0f32, f32::max);
// A tie at 1 ms. The two stems agree to six decimals and the chunk that is
// not one of them misses by tens of seconds, so nothing sits near this
// bound: it separates the measured cases without being a tuned threshold.
// ONE STREAM, NOT A SUM -- and this is the third reading of these chunks, each
// one refuted by a measurement rather than by an argument.
//
// They were concatenated (359 s for a 137 s movie), then summed as HANDOFF
// Q10's two stems (refuted here: `S00A`'s second is silence, `ADV`'s is
// 0.60x the first). The Decoder then decoded the shape disc-wide -- counting
// stream starts inside every inter-descriptor span gives 258 spans with ONE
// stream and 28 with THREE, and nothing with two or any other number, so a
// 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.
//
// 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. `loudest` is a PER-ASSET CONTENT choice and nothing
// more: the disc masters its other audio near full scale, and it puts the
// two cutscenes' dialogue at comparable levels.
//
// ⚠️ A structural argument for it — `ADV`'s higher-rate stream is dual-mono,
// so its extra bytes encode a duplicated channel rather than fidelity — was
// offered here and **does not generalise**. The channel measurement is
// `ADV`'s and stands; the inference was tested disc-wide over the 28
// three-stream cues and the size ratio runs 0.0778 to 2.9163. Neither rule
// has a structural argument behind it, which is exactly why the choice is
// authored rather than derived.
let tied: Vec<usize> = (0..all.len())
.filter(|&i| !silent.contains(&i) && (longest - lengths[i]).abs() < 0.001)
.collect();
// 🔴 `All` KEEPS EVERY SURVIVOR, and it is the authored value since
// 2026-08-30. Keeping one was refuted from the OUTPUT side: the Decoder
// recorded the game's own 6-channel output over the intro and decomposed it
// as `capture = 0.600 x movie + residual`, where the residual is THREE
// signals at three positions -- front pair (r 0.918), rear pair (r 0.929),
// and a centre whose partner LFE is empty to -115 dB. All three play. This
// exporter was shipping one and discarding two.
//
// ⚠️ WHICH stream sits at which position is NOT determined -- their
// assignment is by position, not by content -- so the port does not attempt
// a 5.1 build and a positional downmix. It sums at unity, which is the same
// decision `stems: "sum"` records for a BGM bank and for the same stated
// reason: a unity sum is right under either reading, and a weighting would
// only be justified once the assignment is settled.
let keep: Vec<usize> = match presentation {
Presentation::All => tied.clone(),
Presentation::HighestRate => tied
.iter()
.copied()
.max_by_key(|&i| riffs[i].len())
.into_iter()
.collect(),
Presentation::Loudest => tied
.iter()
.copied()
.max_by(|&a, &b| probed[a].1.total_cmp(&probed[b].1))
.into_iter()
.collect(),
};
let dropped: Vec<String> = (0..all.len())
.filter(|i| !keep.contains(i))
.map(|i| {
format!(
"chunk {i} ({:.3} s, {} B, peak {})",
lengths[i],
riffs[i].len(),
if silent.contains(&i) {
"SILENT".to_string()
} else {
format!("{:.1} dBFS", probed[i].1)
}
)
})
.collect();
if keep.is_empty() {
return Ok(None);
}
let staged: Vec<PathBuf> = keep.iter().map(|&i| all[i].clone()).collect();
// The fold averages the channels that CARRY SIGNAL, not the channels the
// stream declares.
//
// This function's first version averaged all declared channels, and the doc
// comment above it warned in as many words that "a stereo matrix applied to
// a mono voice track is not an error, it is a -6 dB attenuation that nothing
// reports". It then did exactly that: **channel 2 of both voice streams is
// digitally silent** -- peak -inf over the whole file, on `ADV` and on
// `S00A` -- so this is a mono recording carried in a nominally stereo
// stream, and averaging it with silence cost 5.94 dB. Checking the declared
// count is not checking the content, and only the content is the fold.
//
// Same principle as the silent-chunk drop above, one level down: a silent
// input contributes nothing to an average and counting it in the divisor is
// arithmetic, not a mixing decision. `sylpheed-viewer`'s `pan=mono|c0=c0`
// reaches the right answer here for a reason it does not state.
// Each kept stream is folded to mono on ITS OWN live channels -- the fold is
// per input, because "which channels carry signal" is a property of the
// stream and not of the set.
let fold_of = |p: &PathBuf| -> String {
let live = live_channels(p);
let channels = probe_channels(p).unwrap_or(1);
if live.len() <= 1 && channels <= 1 {
String::new()
} else if live.len() == 1 {
format!(",pan=mono|c0=c{}", live[0])
} else {
let g = 1.0 / live.len() as f64;
let terms: Vec<String> = live.iter().map(|c| format!("{g:.6}*c{c}")).collect();
format!(",pan=mono|c0={}", terms.join("+"))
}
};
let ogg = dir.join(format!("{movie}.ogg"));
let mut argv: Vec<String> = ["-hide_banner", "-loglevel", "error", "-y"]
.iter()
.map(|s| s.to_string())
.collect();
for s in &staged {
argv.push("-i".into());
argv.push(s.display().to_string());
}
// One input, so no mix and no normalising coefficient: the stream reaches the
// Ogg at the level the disc has it, and the only filter is the mono fold.
// 🔴 `normalize=1` -- the mix DIVIDES by the input count, and that is right
// here for a reason the two earlier divisor bugs in this file are not.
//
// Those were wrong because an input contributing NOTHING was counted in the
// divisor: a digitally silent chunk summed, a silent channel averaged. Both
// attenuated a signal by counting silence as a voice.
//
// This is the opposite case. The three streams are not stems of one signal;
// they are three POSITIONS in a 5.1 field (front pair, centre, rear pair --
// measured). A stereo downmix of that field weights them 0.4142, 0.2929 and
// 0.2929, which SUM TO ONE whatever the assignment. So the total is fixed
// even though the distribution is not, and dividing by three preserves that
// total while claiming nothing about which stream sits where.
//
// ⚠️ Unity summing was tried first and `check` refused it: `ADV` reached
// **+2.62 dBFS**, over the +1.0 bound. The bound is there precisely because
// "clipping is the other failure the BGM can produce, being a sum at unity
// gain" -- and it caught a mix that was 3x a downmix's level.
let filter = if staged.len() == 1 {
format!("[0:a]anull{}[a]", fold_of(&staged[0]))
} else {
let mut parts: Vec<String> = Vec::new();
for (i, p) in staged.iter().enumerate() {
parts.push(format!("[{i}:a]anull{}[m{i}]", fold_of(p)));
}
// 🔴 MEASURED POSITIONAL WEIGHTS where every kept stream's declared size
// is in the authored table, and the count divisor otherwise.
//
// The table is keyed by the decoder's own `byte_size`, so this is a
// CHECK and not an assumption: if the streams in front of us are not the
// ones the measurement describes, the sizes do not match and the mix
// falls back. That mattered once already -- on 2026-08-30 these sizes
// did NOT fit the region the resolver returned, which is how a
// 238-packet late start was found. Applied positionally instead, the
// weights would have gone onto the wrong streams in silence.
let sizes: Vec<usize> = keep.iter().map(|&i| riffs[i].len() - RIFF_HEADER).collect();
let ws: Option<Vec<f64>> = sizes
.iter()
.map(|s| stream_weights.get(s).copied())
.collect();
match ws {
Some(w) if w.len() == staged.len() => {
// Weights sum to one, so the total is the movie's own and what
// they distribute is the balance between three positions.
let terms: Vec<String> = w
.iter()
.enumerate()
.map(|(i, g)| format!("[m{i}]volume={g:.4}[w{i}]"))
.collect();
parts.extend(terms);
let ins: String = (0..staged.len()).map(|i| format!("[w{i}]")).collect();
parts.push(format!("{ins}amix=inputs={}:normalize=0[a]", staged.len()));
}
_ => {
let ins: String = (0..staged.len()).map(|i| format!("[m{i}]")).collect();
parts.push(format!("{ins}amix=inputs={}:normalize=1[a]", staged.len()));
}
}
parts.join(";")
};
argv.push("-filter_complex".into());
argv.push(filter);
argv.push("-map".into());
argv.push("[a]".into());
argv.extend(
[
"-c:a",
"libvorbis",
"-q:a",
VORBIS_Q,
&ogg.display().to_string(),
]
.iter()
.map(|s| s.to_string()),
);
let command = format!("ffmpeg {}", argv.join(" "));
run_ffmpeg(&argv, &ogg)?;
let (peak, dur) = measure(&ogg);
for s in &all {
std::fs::remove_file(s).ok();
}
let against = match (dur, video_duration_s) {
(Some(d), Some(v)) => format!(
" Decoded length {d:.3} s against the movie's {v:.3} s (delta {:+.3} s); \
NOT trimmed to fit -- a clamp would hide a resolution error, and the \
runtime stops the voice when the video ends.",
d - v
),
_ => String::new(),
};
Ok(Some(Exported {
name: movie.to_string(),
file: format!("audio/voice/{movie}.ogg"),
command,
why: format!(
"DECODED, not authored: the movie manifest in tables.pak binds {movie} to a \
voice cue, and sylpheed_formats::media::resolve_movie_voice_region walks \
movie -> token -> sound id -> byte region [{start}, {end}) of the continuous \
voice stream. NOT matched by filename: RT01A's voice lives inside \
VOICE_ADV.slb, so the name is right for this movie by luck and wrong for \
others. ✅ ALL {} region chunk(s) are exported, summed. Keeping ONE was \
REFUTED FROM THE OUTPUT SIDE 2026-08-30: a recording of the game's own \
6-channel output over the intro decomposes as capture = 0.600 x movie + \
residual, and the residual is THREE signals at three positions -- front pair \
(r 0.918), rear pair (r 0.929), and a centre whose partner LFE is empty to \
-115 dB. The load-bearing number is LFE reproducing to -115.73 dBFS: where \
nothing is added the two decoders agree exactly, so the other residuals are \
ADDED CONTENT and not codec mismatch. ⚠️ WHICH stream sits at which position \
is NOT determined -- the assignment is by position, not content -- so this is \
a mono sum divided by the count, never a positional downmix. The three \
downmix weights sum to one whatever the assignment, so the total is right and \
the distribution is the only thing unclaimed. ⚠️ The movie's OWN track is WMA \
Pro 5.1 and carries the bed; these streams are additional. {} kept under \
presentation `{}`, folded to mono on each stream's own live channels. \
Chunks found, in region order: {}.{}{against}",
riffs.len(),
staged.len(),
match presentation {
Presentation::All => "all",
Presentation::Loudest => "loudest",
Presentation::HighestRate => "highest_rate",
},
// The INVENTORY, not just what was dropped. A reader mapping these
// onto the decoder's own `byte_size` values -- which is how the
// stream-to-speaker assignment is indexed -- needs every chunk's
// size, and the dropped list only ever showed the ones that lost.
(0..all.len())
.map(|i| {
format!(
"chunk {i} {} B ({:.3} s{})",
riffs[i].len(),
lengths[i],
if silent.contains(&i) { ", SILENT" } else { "" }
)
})
.collect::<Vec<_>>()
.join("; "),
if dropped.is_empty() {
String::new()
} else {
format!(
" Also dropped: {}. A chunk marked SILENT carries no signal and \
contributes nothing; one of a different duration is not a \
concurrent stream of this take. \u{1f534} THIS SENTENCE USED TO SAY \
the leading chunk was `the TAIL of the kept stream [refuted]`, on a sliding \
envelope correlation of r=0.998. The CORRELATION was sound and the \
INTERPRETATION was refuted: `resolve_movie_voice_region` was \
starting 238 packets inside the first stream, so what matched \
end-flush was a START-TRUNCATED SIMULTANEOUS stream, not a \
duplicate tail -- which is why it aligned at the end. Fixed in \
formats-pin-2026-08-30; that chunk is now kept at full length and \
nothing is dropped for that reason any more.",
dropped.join(", ")
)
},
),
peak_dbfs: peak,
duration_s: dur,
kind: "voice",
name_match: None,
loop_mode: None,
sub_waves: riffs.len(),
kept_waves: staged.len(),
content_waves: riffs.len() - silent.len(),
}))
}
/// How many channels a staged `RIFF` declares, per ffprobe.
fn probe_channels(path: &Path) -> Option<u8> {
let out = Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"a:0",
"-show_entries",
"stream=channels",
"-of",
"csv=p=0",
])
.arg(path)
.output()
.ok()?;
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
}
/// Seconds a finished media file runs, per ffprobe.
///
/// Exposed so the caller can hand [`export_voice`] the movie's own length: the
/// voice is a separate asset with no shared container to agree with, so the only
/// way a resolution error shows up is a length that does not match the picture.
pub fn probe_duration(path: &Path) -> Option<f32> {
let out = Command::new("ffprobe")
.args([
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
])
.arg(path)
.output()
.ok()?;
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
}
/// Seconds and peak dBFS that one staged XMA `RIFF` decodes to.
///
/// XMA carries no duration in its header, so the chunk is decoded to PCM and the
/// result measured. That is expensive and it is the only instrument that can
/// separate these chunks at all: `ffprobe` on the `RIFF` itself returns `N/A`
/// for duration, which a caller that trusted it would read as zero, and the
/// corpus records `sylpheed-cli audio info` mis-reading the same headers as
/// 16 channels at 4 310 Hz.
///
/// A silent chunk returns `-inf`, which the caller drops.
fn decoded_chunk(riff: &Path) -> (f32, f32) {
let wav = riff.with_extension("probe.wav");
let ok = Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
.arg(riff)
.arg(&wav)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
let out = if ok {
let (peak, dur) = measure(&wav);
(dur.unwrap_or(0.0), peak.unwrap_or(f32::NEG_INFINITY))
} else {
(0.0, f32::NEG_INFINITY)
};
let _ = std::fs::remove_file(&wav);
out
}
/// Which channel indices of a decoded stream are not digitally silent.
///
/// `astats` reports per-channel blocks: a `Channel: N` line followed by that
/// channel's own `Peak level dB`. A channel whose peak is `-inf` carries
/// nothing, and folding it into an average is a pure loss.
///
/// Falls back to "every declared channel is live" if the parse finds nothing,
/// because the failure to prefer is the one that changes no level.
fn live_channels(riff: &Path) -> Vec<usize> {
let wav = riff.with_extension("chan.wav");
let ok = Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
.arg(riff)
.arg(&wav)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
let mut live = Vec::new();
if ok {
if let Ok(out) = Command::new("ffmpeg")
.args(["-hide_banner", "-v", "info", "-i"])
.arg(&wav)
.args(["-af", "astats", "-f", "null", "-"])
.output()
{
let text = String::from_utf8_lossy(&out.stderr).into_owned();
let mut current: Option<usize> = None;
for line in text.lines() {
if let Some((_, n)) = line.split_once("Channel: ") {
// astats numbers channels from 1; `pan` addresses c0 upward.
current = n.trim().parse::<usize>().ok().map(|n| n.saturating_sub(1));
} else if let Some((_, v)) = line.split_once("Peak level dB: ") {
if let Some(c) = current.take() {
if v.trim().parse::<f32>().map(|p| p > -90.0).unwrap_or(false) {
live.push(c);
}
}
}
}
}
}
let _ = std::fs::remove_file(&wav);
if live.is_empty() {
live = (0..probe_channels(riff).unwrap_or(1) as usize).collect();
}
live
}