port: settle times measured -- the pacing was already right, and my own red flag was half wrong

THE TRANSCODE CACHE HAD NEVER HIT. `video::transcode` has carried one since P4 --
a `.cmd` sidecar with the command, the source size and the channel count -- and
`main.rs` clears the output tree wholesale immediately before the check, deleting
the sidecar and the file it stamps. Six exports in this session paid the full
Theora encode and produced five byte-identical files, roughly 48 minutes. Nothing
reported it, and nothing could: a cache is silent either way and the only symptom
is a wall clock that looks like the job being slow. The wipe now spares `video/`
and `prune_videos` deletes anything in it this run did not claim, so the
wholesale guarantee is kept rather than traded. A re-export is 20 s.

SETTLE TIMES, MEASURED, and they refute more of my row than they confirm. The
principle holds -- the title's rest.t is 251 units = 4.183 s where its art
finishes at ~2 s -- but "everything the sequencer paces off that landmark is
therefore late" does not. Measured the port the way the game was measured, by
VISIBLE SPAN rather than arrival-to-arrival:

  publisher wordmark  port 4.25 s   game 4.297 / 4.604 / 4.370
  developer logos     port 3.50 s   game 3.508 / 3.503 / 3.366
  black hold          port ~0.25 s  game 0.2 - 0.3
  title -> plate      port 2.000 s (declared 120 units)   game 2.247 s

Dead on. My earlier reading compared the port's transition timestamps against the
game's visible spans, which differ by the exit ramp plus the black hold -- the
whole of the discrepancy I was about to chase, and the same definitional trap
that cost this corpus 0.48 s on the plate delay. Nothing in the sequencer is
changed. `dwell_seconds` stays null, now for a measured reason rather than an
absent one: `timing.json` said "if a capture ever times the real boot, this is
where that number goes", and the answer is that nothing goes there.

Not authored, deliberately: an (A)->menu dwell, which measures 3.763 s and
contains a 1.53 s guest load stall on a cold cache; and the menu build-in and
(B)->title, which rest on one run where the port is already within ~0.1 s.

THE VOICE PRESENTATION MOVED TO authored/, because the recommendation behind it
was withdrawn as self-contradictory and the choice is now unambiguously mine.
`voice.presentation` = `loudest`, and the reason to switch is a measurement, not
a preference: ADV chunk 1 is MONO-IN-STEREO and chunk 2 is DUAL-MONO, so chunk
2's extra bytes encode a duplicate channel rather than fidelity. That explains
the byte-rate difference and removes the only argument for `highest_rate`. ADV's
dialogue now exports at +0.3 dBFS instead of -8.7, which is the SE bound's
documented decode overshoot on a wave mastered at full scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
This commit is contained in:
Sylpheed port agent
2026-08-29 15:35:24 +00:00
parent d3b37b2a1e
commit 81ea5cb324
5 changed files with 410 additions and 126 deletions

View File

@@ -83,10 +83,28 @@ pub struct BgmSpec {
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 {
/// Peak nearest full scale.
#[default]
Loudest,
/// Most bytes per second.
HighestRate,
}
/// `authored/audio.json`, with the documentation keys dropped.
pub struct Config {
pub se: Vec<(String, CueSpec)>,
pub bgm: Vec<(String, BgmSpec)>,
pub voice: Presentation,
}
/// Read `authored/audio.json`, or `None` when there is no such file.
@@ -104,6 +122,8 @@ pub fn load(authored: &Path) -> Result<Option<Config>> {
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()))?;
@@ -128,9 +148,18 @@ pub fn load(authored: &Path) -> Result<Option<Config>> {
.collect()
}
// Absent means `loudest`, which is what the file says today. A default here
// is safe in a way a default matrix is not: the manifest records which
// presentation was taken and why, on every entry.
let voice = match file.voice.get("presentation") {
Some(v) => serde_json::from_value(v.clone())
.with_context(|| format!("authored/audio.json: voice.presentation {v}"))?,
None => Presentation::default(),
};
Ok(Some(Config {
se: entries(file.se, "se")?,
bgm: entries(file.bgm, "bgm")?,
voice,
}))
}
@@ -535,6 +564,7 @@ pub fn export_voice<S: DiscSource + ?Sized>(
out: &Path,
movie: &str,
video_duration_s: Option<f32>,
presentation: Presentation,
) -> Result<Option<Exported>> {
use sylpheed_formats::slb::VoiceLang;
@@ -590,20 +620,25 @@ pub fn export_voice<S: DiscSource + ?Sized>(
// region carries **three presentations of one take**, not a mix. Summing a
// take with a scaled copy of itself adds ~4 dB and colours it.
//
// The selector is the **highest byte rate** among the equal-duration
// survivors, on the Decoder's recommendation. 🟡 That is a recommendation and
// not a decoded field: no flag on the disc says which presentation the game
// plays, and on `ADV` it picks the quieter of the two (-8.3 dBFS against
// 0.0). Recorded in the manifest so the choice is visible and reversible.
// WHICH of the equal-duration survivors is a CHOICE, and it lives in
// `authored/audio.json` rather than here -- see [`Presentation`]. It was
// `highest_rate` on the Decoder's recommendation until that was withdrawn as
// self-contradictory, and the reason it is now `loudest` is a measurement:
// `ADV`'s louder presentation is mono-in-stereo while its higher-rate one is
// DUAL-MONO, so the extra bytes encode a duplicate channel rather than
// fidelity, and the rate difference is explained without appealing to
// quality at all.
let tied: Vec<usize> = (0..all.len())
.filter(|&i| !silent.contains(&i) && (longest - lengths[i]).abs() < 0.001)
.collect();
let keep: Vec<usize> = tied
.iter()
.copied()
.max_by_key(|&i| riffs[i].len())
.into_iter()
.collect();
let chosen = match presentation {
Presentation::HighestRate => tied.iter().copied().max_by_key(|&i| riffs[i].len()),
Presentation::Loudest => tied
.iter()
.copied()
.max_by(|&a, &b| probed[a].1.total_cmp(&probed[b].1)),
};
let keep: Vec<usize> = chosen.into_iter().collect();
let dropped: Vec<String> = (0..all.len())
.filter(|i| !keep.contains(i))
.map(|i| {
@@ -708,14 +743,19 @@ pub fn export_voice<S: DiscSource + ?Sized>(
summing them as HANDOFF Q10's two stems, which its own measurements refuted: \
S00A's second full-length chunk is DIGITAL SILENCE and ADV's is 0.60x the first \
with 26.8 dB of residual. Summing a take with a scaled copy of itself adds ~4 dB \
and colours it. 🟡 The kept stream is the HIGHEST BYTE RATE among the \
equal-duration survivors, which is a recommendation and NOT a decoded field: \
nothing on the disc says which presentation the game plays, and on ADV this \
picks the quieter of two.{} Folded to mono from the {} of {channels} declared \
and colours it. 🟡 WHICH of the equal-duration survivors is kept is a CHOICE, \
not a decoded field -- authored/audio.json voice.presentation = {:?}, with its \
why. Nothing on the disc ranks the presentations: wEncodeOptions, channel count \
and channel mask are byte-identical across them. One capture of the movie with \
dialogue audible deletes that entry.{} Folded to mono from the {} of {channels} declared \
channel(s) that carry signal -- channel 2 of both voice streams is digitally \
silent, and averaging it in cost 5.94 dB until this was measured rather than \
read off the declared count.{against}",
riffs.len(),
match presentation {
Presentation::Loudest => "loudest",
Presentation::HighestRate => "highest_rate",
},
if dropped.is_empty() {
String::new()
} else {

View File

@@ -241,8 +241,35 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
// Derived output is regenerated wholesale: clear it, so a screen that stops
// being exported stops existing rather than lingering as a stale file that
// still validates.
//
// 🔴 EXCEPT `video/`, and leaving it out was a bug that hid in plain sight.
// `video::transcode` has always carried a cache -- it writes a `.cmd`
// sidecar with the exact command, the source size and the channel count, and
// skips the encode when all three still match. Its own doc comment says
// "without it every re-export pays ~4 minutes to produce a byte-identical
// file". **This wipe deleted the sidecar and the output immediately before
// the check, so the cache had never hit once.** Six exports in one session
// paid ~48 minutes of Theora to produce five byte-identical files, and
// nothing reported it: the cache is silent when it works and silent when it
// does not.
//
// The wholesale guarantee is kept rather than weakened -- everything else is
// still cleared outright, and `prune_videos` below deletes any file in
// `video/` that this run did not claim, so a movie that stops being exported
// still stops existing.
if out.exists() {
std::fs::remove_dir_all(&out).context("clear the output tree")?;
for entry in std::fs::read_dir(&out).context("clear the output tree")? {
let entry = entry?;
if entry.file_name() == "video" {
continue;
}
if entry.file_type()?.is_dir() {
std::fs::remove_dir_all(entry.path())
} else {
std::fs::remove_file(entry.path())
}
.with_context(|| format!("clear {}", entry.path().display()))?;
}
}
std::fs::create_dir_all(&out)?;
@@ -332,13 +359,15 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
None => println!(" video {} not on this disc -- skipped", m.src),
}
}
prune_videos(out, &videos)?;
// P6. Both tables are AUTHORED, for two different reasons -- the cue offsets
// because they were measured off the running game and are on the disc in no
// findable form, the BGM choice because HANDOFF Q10 is a negative and
// nothing states which track a menu plays. See `authored/audio.json`.
let mut audio = Vec::new();
match audio::load(authored_dir)? {
let audio_cfg = audio::load(authored_dir)?;
match &audio_cfg {
None => println!(" no authored/audio.json -- no audio exported"),
Some(cfg) => {
let source = media::DirectorySource::new(disc);
@@ -406,7 +435,11 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
{
let source = media::DirectorySource::new(disc);
for (stem, len) in &movie_lengths {
match audio::export_voice(&source, out, stem, *len)? {
// The presentation choice is AUTHORED and this block runs even when
// there is no `authored/audio.json` -- the voice binding is decoded,
// so the dialogue exports either way and only the choice defaults.
let want = audio_cfg.as_ref().map(|c| c.voice).unwrap_or_default();
match audio::export_voice(&source, out, stem, *len, want)? {
Some(a) => {
println!(
" voice {:<8} -> {} ({}, {} region chunk(s))",
@@ -475,3 +508,38 @@ fn describe(a: &audio::Exported) -> String {
None => peak,
}
}
/// Delete anything in `video/` this run did not produce.
///
/// `video/` is the one directory the wholesale wipe spares, so that the
/// transcode cache survives to be consulted. This restores the guarantee the
/// wipe exists for: a movie that stops being exported stops existing, rather
/// than lingering as a file the manifest no longer lists.
fn prune_videos(out: &Path, kept: &[ManifestVideo]) -> Result<()> {
let dir = out.join("video");
if !dir.exists() {
return Ok(());
}
let mut keep: Vec<String> = Vec::new();
for v in kept {
if let Some(name) = Path::new(&v.file).file_name() {
let name = name.to_string_lossy().into_owned();
keep.push(name.clone());
// The cache sidecar goes with the file it stamps.
if let Some(stem) = Path::new(&name).file_stem() {
keep.push(format!("{}.cmd", stem.to_string_lossy()));
}
}
}
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
if keep.contains(&name) {
continue;
}
println!(" video {name} is no longer exported -- removed");
let _ = std::fs::remove_file(entry.path());
}
Ok(())
}