port: land the play-tested work, and only that

Takes the port branch up to 77320d5e -- the state the human play-tested on
2026-09-02 -- for SOURCE paths only. Not a branch merge: `auto/port-p6-audio`
is 366 commits and 938 files, and most of that must not land.

WHAT COMES IN (76 files, all human-confirmed working):
  * the logo splash animation. 08ed3dd1 found it: `pose_at` ASSIGNED the settle
    instant instead of clamping to it, so the splash never animated at all --
    and the same bug manufactured a passing harness result, because the harness
    photographed t past the settle. Confirmed by play-test: "cannot notice any
    obvious difference from the actual game."
  * gamepad input -- (A)/(B) bound additively (`ui_accept` ships with NO joypad
    binding), stick latched with hysteresis at the game's own 61% digitise
    threshold. This is what made (A), video-skip and Extras work at all.
  * menu navigation and flow, menu audio, the exporter, the authored
    declarations, and 23 verification tools under tools/port/.

WHAT IS DELIBERATELY LEFT ON THE BRANCH:
  * everything after c0ae460a -- the F5/F6 title-timing investigation, whose own
    tip commit calls itself a "hand-off for one-minute human checks". Unchecked
    by definition; it goes through the new review gate like anything else.
  * the OPTIONS menu work of 2026-09-03. Real, probably good, NOT play-tested.
  * the F1 repeat mechanism, which its own commit calls "deliberately inert".

WHAT MUST NOT LAND, AND WHY THE .gitignore CHANGED:
  545 MB of extracted game content was committed on that branch -- 850 sprite,
  audio and transcoded video files under `export-probe/` and `export-probe2/`,
  plus 246 MB of loose .wav and .tsv at the repo root. This repository's own
  rule, in this file, is "never game content".

  The rule was not missing. It was written, and it was tightened on that very
  branch, with a careful comment explaining why BOTH `export/` and `data/base/`
  had to be listed -- while the exporter was writing to a third name that
  nobody had thought to list. Enumerating names is the thing that failed. So
  the ignore rules now describe the SHAPE: any top-level `export*/`, game media
  by extension, and loose capture output at the root. Verified both ways -- it
  catches all four offenders and ignores nothing currently tracked.

Verified: `cargo check --workspace` clean; all nine GDScript files parse in
project context, with a positive control (an injected syntax error is detected,
3 lines) so the clean result means something. `tools/port/check-all` was NOT
run -- it needs the container, the export tree and a display.
This commit is contained in:
MechaCat02
2026-09-04 16:17:14 +02:00
parent ad96fe97b8
commit a23c321831
76 changed files with 32972 additions and 346 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,9 @@
//! * a `buttons` entry naming an element that is not a button, or out of
//! resting-Y order;
//! * a sprite path that does not exist, or a PNG that does not decode;
//! * a name presented as recovered when it was authored.
//! * a name presented as recovered when it was authored;
//! * an audio file that is silent or clips -- the two audio failures that pass
//! every check that is not looking for them.
//!
//! It deliberately does **not** check that the export matches the disc. That is
//! what `sylpheed-cli screen render` is for.
@@ -184,10 +186,26 @@ fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()>
for (k, kf) in kfs.iter().enumerate() {
check_pose(&mut c, &format!("{at} keyframe {k}"), kf);
}
// The last keyframe of a group carries no time slot on the disc, and
// an invented one is exactly the kind of value this format refuses.
if kfs.len() > 1 && kfs.last().is_some_and(|k| k.get("t").is_some()) {
c.err(format!("{at}: the final keyframe has a `t`; the disc has no time slot there"));
// 🔴 INVERTED 2026-08-29, and the old rule is the more interesting
// half. It read: "the last keyframe of a group carries no time slot
// on the disc, and an invented one is exactly the kind of value this
// format refuses." That was true of the OLD keyframe association,
// where a group's data stopped four bytes short of its final block's
// time slot.
//
// Under the corrected layout (`formats-pin-2026-08-29c` onward) a
// group is an 8-byte header then `frames` x {u32 time; 36-byte
// pose}, so **pose 0's time is the group's lead-in word and EVERY
// POSE IS TIMED, including the last.** The rule now says the
// opposite, and an untimed keyframe is the thing to refuse.
//
// ⚠️ This fired 150 times on a re-export and I had not run `check`
// between pinning the tag and measuring against the oracle -- the
// pixel harness was green while the format validator was failing on
// every screen with a multi-keyframe group. A correctness harness
// does not replace a format one; they fail at different layers.
if kfs.len() > 1 && kfs.iter().any(|k| k.get("t").is_none()) {
c.err(format!("{at}: a keyframe has no `t`; every pose is timed under the corrected record layout"));
}
}
}
@@ -267,6 +285,8 @@ pub fn run(root: &Path) -> Result<usize> {
check_screen(root, file, &mut errors)?;
}
check_audio(root, &m, &mut errors);
if !errors.is_empty() {
for e in &errors {
eprintln!(" ✗ {e}");
@@ -275,3 +295,88 @@ pub fn run(root: &Path) -> Result<usize> {
}
Ok(screens.len())
}
/// The `audio` array, checked the way a consumer would have to.
///
/// Two of these are content checks rather than schema checks, and they are here
/// on purpose. `docs/port/AUDIO-VERIFICATION.md` names silence as "the failure
/// that looks like success": a file of exactly the right duration, the right
/// channel count and the right size, full of zeroes, because something opened
/// the wrong thing. Every structural check passes it. So does clipping, which
/// the BGM can produce because it is a **sum of two stems** at unity gain.
///
/// The exporter measures both at export time and writes them here; this refuses
/// the tree if what it wrote is a file nobody would want to play. Neither is a
/// judgement about whether the audio is the RIGHT audio — nothing in this
/// binary can know that, and `docs/port/BLOCKED.md` says which parts are still
/// authored guesses.
fn check_audio(root: &Path, m: &Value, errors: &mut Vec<String>) {
let Some(audio) = m.get("audio").and_then(Value::as_array) else {
// Absent is correct for every export taken before P6.
return;
};
for a in audio {
let name = a.get("name").and_then(Value::as_str).unwrap_or("?");
let kind = a.get("kind").and_then(Value::as_str).unwrap_or("");
if !matches!(kind, "se" | "bgm" | "voice") {
errors.push(format!(
"manifest.json: audio `{name}` has kind {kind:?}, which a consumer cannot dispatch on"
));
}
for key in ["file", "command", "why"] {
if a.get(key).and_then(Value::as_str).is_none_or(str::is_empty) {
errors.push(format!("manifest.json: audio `{name}` has no `{key}`"));
}
}
let Some(file) = a.get("file").and_then(Value::as_str) else { continue };
if !root.join(file).exists() {
errors.push(format!("manifest.json: lists audio {file}, which does not exist"));
continue;
}
match a.get("peak_dbfs").and_then(Value::as_f64) {
None => errors.push(format!(
"manifest.json: audio `{name}` carries no `peak_dbfs` -- it was not measured, \
and silence is the audio failure that passes every check that is not looking \
for it"
)),
Some(p) if p <= -90.0 => errors.push(format!(
"{file}: peak is {p:.1} dBFS -- this file is silent"
)),
// The bound differs by kind, and the difference is the point. A
// `bgm` is something WE combined -- a sum of stems -- so a peak at
// or above full scale is our arithmetic and is refused outright. An
// `se` is a single wave off the disc: it is mastered near full
// scale, and a lossy decode of a near-full-scale signal overshoots
// by a fraction of a dB (`confirm` lands at +0.18). Refusing that
// would be refusing the disc's own mastering, and "fixing" it would
// mean attenuating a game asset to make a number smaller.
//
// 🟡 +1.0 dB is a JUDGEMENT, not a measurement: a few tenths is
// reconstruction overshoot, a whole dB is not. Nobody has measured
// the overshoot distribution across a corpus of cues, and if a cue
// ever trips this the right response is that measurement, not a
// looser bound.
// `voice` was on the strict side of this bound while it was a SUM of a
// region's chunks. It no longer is: a region carries three
// presentations of one take, so the exporter keeps ONE stream and
// performs no arithmetic on it. That puts `voice` with `se` -- a
// single wave off the disc, mastered near full scale, whose lossy
// decode overshoots by a fraction of a dB. `ADV`'s louder
// presentation measures +0.0003 dBFS at source; refusing that would
// be refusing the disc's own mastering.
Some(p) if kind == "bgm" && p >= 0.0 => errors.push(format!(
"{file}: peak is {p:.1} dBFS -- a SUM we produced clips"
)),
Some(p) if kind != "bgm" && p > 1.0 => errors.push(format!(
"{file}: peak is {p:.1} dBFS -- too far over full scale to be decode overshoot"
)),
Some(_) => {}
}
match a.get("duration_s").and_then(Value::as_f64) {
Some(d) if d > 0.0 => {}
_ => errors.push(format!(
"{file}: no positive `duration_s` -- a zero-length asset plays as silence"
)),
}
}
}

View File

@@ -12,6 +12,7 @@
//!
//! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope.
mod audio;
mod check;
mod video;
mod screen;
@@ -20,7 +21,7 @@ use anyhow::{Context, Result};
use clap::Parser;
use serde::Serialize;
use std::path::{Path, PathBuf};
use sylpheed_formats::{pak::PakArchive, ui_layout};
use sylpheed_formats::{media, pak::PakArchive, ui_layout};
/// The revision of `sylpheed-formats` this exporter is pinned to, recorded in
/// every file it writes. Keep in step with `Cargo.toml` — it is what makes an
@@ -76,6 +77,48 @@ struct ManifestVideo {
/// dislikes the quality re-runs one line rather than reverse-engineering it.
command: String,
why: &'static str,
/// What the runtime should have played, so it can report what it did.
/// See `video::Transcoded::duration_s` — the port measured its player
/// presenting 28–47 % of a stream's frames, and seconds alone hide that.
duration_s: f64,
fps: f64,
}
/// One exported audio file. Carries the same provenance a video does, plus the
/// measured peak and duration: silence and clipping are the two audio failures
/// that pass every check that is not looking for them.
#[derive(Serialize)]
struct ManifestAudio {
/// `se` or `bgm`. The runtime dispatches on it, so it is a field rather
/// than a prefix on `name` that a consumer would have to parse.
kind: &'static str,
name: String,
file: String,
command: String,
why: String,
#[serde(skip_serializing_if = "Option::is_none")]
peak_dbfs: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
duration_s: Option<f32>,
/// 🔴 One line saying what this asset is KNOWN to be missing, for the
/// runtime to announce. Absent means nothing is known to be missing --
/// never that the asset was checked and is complete.
///
/// It exists because the export could already say this and the RUNTIME
/// could not. `why` carries the full account, but it is a paragraph aimed
/// at a reader of the manifest; a player hears clean dialogue and has no
/// way to learn that a stream is absent from it. This port already
/// announces the two measured screens NEW GAME jumps over, on the principle
/// that a gap is announced before it is opened. Audio had no equivalent.
#[serde(skip_serializing_if = "Option::is_none")]
incomplete: Option<String>,
/// The game's own cue identifier where one is a NAME MATCH. Absent means
/// nobody has claimed one -- never that the binding is unknown.
#[serde(skip_serializing_if = "Option::is_none")]
name_match: Option<String>,
/// What the runtime does at the end of the file, where that was authored.
#[serde(skip_serializing_if = "Option::is_none")]
loop_mode: Option<String>,
}
#[derive(Serialize)]
@@ -88,6 +131,8 @@ struct Manifest {
screens: Vec<ManifestScreen>,
#[serde(skip_serializing_if = "Vec::is_empty")]
videos: Vec<ManifestVideo>,
#[serde(skip_serializing_if = "Vec::is_empty")]
audio: Vec<ManifestAudio>,
warnings: Vec<String>,
}
@@ -195,11 +240,53 @@ fn main() -> Result<()> {
fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
let names = load_names(authored_dir)?;
// Built up as the export runs. A warning is a thing a CONSUMER of the tree
// has to know about; it is not an error, and it is not a log line, because
// the person who needs it reads `manifest.json` and never sees stdout.
let mut warnings: Vec<String> = vec![
"GP_TITLE screen builds only. No other archive, and only the two movies \
MISSION section 6 puts in scope."
.into(),
"The four splash bundles (entries 10/13 publisher, 11/14 developer) have no .rat \
layout child, so `is_build` cannot see them and no content rule can: element \
count and design size both overlap with two-element fragments in other archives. \
They are located by ENTRY INDEX from authored/screen_names.json `also_export`, \
which is a locator and not a claim -- see each one's name_why."
.into(),
];
// 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)?;
@@ -261,20 +348,153 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
// MISSION §6: the boot intro and the one new-game intro only.
let mut videos = Vec::new();
let mut movie_lengths: Vec<(&'static str, Option<f32>)> = Vec::new();
// 🔴 The export deviates from a HUMAN decision, and until this warning
// existed nobody could tell. MISSION §6 pins the 5.1 fold; `video.rs` ships
// that matrix scaled by 0.4142, i.e. 7.65 dB quieter. The deviation is
// justified for one of the two movies and over-broad for the other, and
// which of the three options to take is not the exporter's call -- so it is
// reported on every run rather than left in a doc comment nobody opens.
if video::MOVIES.iter().any(|m| disc.join(m.src).exists()) {
warnings.push(
"video/*.ogv: the 5.1->stereo fold is NOT the matrix MISSION §6 pins. §6 fixes it at FL = 1.0*FL + 0.707*FC + 0.707*BL (a human decision, 2026-08-29); this export ships that matrix scaled by 0.4142 -- same weighting, 7.65 dB quieter. Measured over the whole of both movies, float-decoded so nothing is pre-clamped: under the PINNED matrix ADV peaks at +4.26 dBFS with 4406 samples at or over full scale (1874 more than 1 dB over, longest clamped run 0.333 ms), while S00A peaks at -1.34 dBFS and never clips. So the pin overloads ADV and this constant is over-broad for S00A; the smallest single scalar under which neither clamps is 1/1.6339 = 0.612. NOT changed on the exporter's own authority -- the level of a mix is what §6 reserves to a human. See docs/port/DECISIONS.md."
.to_string(),
);
}
for m in video::MOVIES {
match video::transcode(disc, out, m)? {
Some(t) => {
println!(" video {} -> {}", m.src, t.file);
movie_lengths.push((m.stem, audio::probe_duration(&out.join(&t.file))));
videos.push(ManifestVideo {
name: t.name,
file: t.file,
command: t.command,
why: t.why,
duration_s: t.duration_s,
fps: t.fps,
});
}
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();
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);
for a in audio::export_cues(&source, out, &cfg.se)? {
println!(
" se {:<8} -> {} ({})",
a.name,
a.file,
describe(&a)
);
audio.push(ManifestAudio::from(a));
}
for (role, spec) in &cfg.bgm {
match audio::export_bgm(&source, out, role, spec)? {
Some(a) => {
println!(
" bgm {:<8} -> {} ({}, bank {}, {} sub-wave(s))",
a.name,
a.file,
describe(&a),
spec.bank,
a.sub_waves
);
// HANDOFF Q10's census is "exactly two waves of
// identical duration, 32/32 banks on the disc". When
// `media` hands back a different number, SAY SO -- the
// port does not get to decide that one of them is not a
// stem, and silently summing an extra region into the
// music is precisely the media-assembly mistake MISSION
// section 2 names. The decoder's answer is what ships;
// the disagreement is what gets reported.
if a.sub_waves != 2 {
warnings.push(format!(
"audio/bgm/{role}.ogg: sylpheed_formats::media::sound_bank_riffs \
returned {} sub-wave(s) for `{}`, but HANDOFF Q10's bank census \
says a music bank is EXACTLY TWO waves of identical duration \
(32/32 banks). All {} are summed, because choosing which to drop \
is a decoding question and this exporter does not answer those. \
See docs/port/BLOCKED.md.",
a.sub_waves, spec.bank, a.sub_waves
));
}
audio.push(ManifestAudio::from(a));
}
// Not an error: the authored bank may simply not be on this
// disc, and the export of everything else is still good.
None => warnings.push(format!(
"authored/audio.json bgm.{role} names bank `{}`, which is not in \
this disc's sound.pak -- no BGM exported for that role.",
spec.bank
)),
}
}
}
}
// The cutscene voices are DERIVED, not authored, so this runs outside the
// `authored/audio.json` block above: the binding comes off the disc (the
// movie manifest in `tables.pak`), and an export with no authored audio
// should still carry the dialogue for the movies it ships.
//
// A movie that resolves to no region is genuinely unvoiced and gets a
// warning rather than a substitute -- for both movies in scope this port
// expects a region, so a warning here is a real signal and not noise.
{
let source = media::DirectorySource::new(disc);
for (stem, len) in &movie_lengths {
// 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();
let weights = audio_cfg.as_ref().map(|c| c.stream_weights.clone()).unwrap_or_default();
match audio::export_voice(&source, out, stem, *len, want, &weights)? {
Some(a) => {
// 🔴 A TOP-LEVEL WARNING, not just a `why` on the entry. The
// export is known to be missing audio the game plays, and
// the failure sounds like success: one stream decodes to
// clean dialogue, so nobody listening finds out.
if a.kept_waves < a.content_waves {
warnings.push(format!(
"{}: KNOWN INCOMPLETE. This region holds {} streams and the RUNNING \
GAME DECODES ALL OF THEM CONCURRENTLY (Canary --xma_param_probe: \
three XMA contexts, byte sizes matching the disc payloads exactly). \
The export carries ONE. Nothing in the audio reveals this -- a \
single stream is clean audible dialogue. Held rather than summed \
because an equal-gain sum of channel pairs is not a downmix and \
would be a second guess, not a fix. See authored/audio.json voice \
and docs/port/BLOCKED.md.",
a.file, a.sub_waves
));
}
println!(
" voice {:<8} -> {} ({}, {} of {} stream(s){})",
a.name,
a.file,
describe(&a),
a.kept_waves,
a.sub_waves,
if a.kept_waves < a.content_waves { " -- KNOWN INCOMPLETE, see warnings" } else { "" }
);
audio.push(ManifestAudio::from(a));
}
None => warnings.push(format!(
"movie `{stem}`: the movie manifest binds it to no voice region, so no dialogue was exported. That is a real answer for an unvoiced cutscene -- nothing is substituted, because resolving an unbound movie through a shared demo line was measured to play the WRONG recording."
)),
}
}
}
let manifest = Manifest {
format: "sylpheed.manifest/1",
@@ -283,16 +503,8 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
disc: disc.display().to_string(),
screens,
videos,
warnings: vec![
"P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive."
.into(),
"The four splash bundles (entries 10/13 publisher, 11/14 developer) have no .rat \
layout child, so `is_build` cannot see them and no content rule can: element \
count and design size both overlap with two-element fragments in other archives. \
They are located by ENTRY INDEX from authored/screen_names.json `also_export`, \
which is a locator and not a claim -- see each one's name_why."
.into(),
],
audio,
warnings,
};
std::fs::write(
out.join("manifest.json"),
@@ -301,3 +513,81 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
println!("wrote {}/manifest.json", out.display());
Ok(())
}
impl From<audio::Exported> for ManifestAudio {
fn from(a: audio::Exported) -> Self {
ManifestAudio {
kind: a.kind,
name: a.name,
file: a.file,
command: a.command,
why: a.why,
peak_dbfs: a.peak_dbfs,
duration_s: a.duration_s,
incomplete: (a.kept_waves < a.content_waves).then(|| {
format!(
"{} of {} streams. The running game decodes all {} concurrently. \
Nothing in the audio reveals the gap -- what plays is clean dialogue. \
WHICH streams are dropped and why differs per asset; the manifest \
entry's `why` says, and it is not the same story twice.",
a.kept_waves, a.sub_waves, a.sub_waves
)
}),
name_match: a.name_match,
loop_mode: a.loop_mode,
}
}
}
/// The two numbers worth reading on an audio line, in the console.
///
/// Printed rather than left to the manifest because the failure this catches is
/// a SILENT file: the right duration, the right channel count, the right size,
/// and nothing in it. `-inf dB` on stdout is the one form of that failure a
/// person notices without being told to look.
fn describe(a: &audio::Exported) -> String {
let peak = match a.peak_dbfs {
Some(p) => format!("peak {p:.1} dBFS"),
None => "peak unmeasured".into(),
};
match a.duration_s {
Some(d) => format!("{d:.3} s, {peak}"),
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(())
}

View File

@@ -103,6 +103,14 @@ pub struct FocusElement {
pub id: String,
pub declared: String,
pub sprite: Option<String>,
/// `true` when the game draws this sprite ADDITIVE — `T8aD +0x04` bit
/// `0x02`, decoded. Absent when the sprite resolves to no `T8aD` header.
///
/// A leaf's sprite may live in the leaf's own table or in the parent
/// bundle's, so the bit is looked up in the same two places, in the same
/// order, that the PNG is written from.
#[serde(skip_serializing_if = "Option::is_none")]
pub blend_additive: Option<bool>,
pub pivot: [u32; 2],
pub rest: Rest,
pub keyframes: Vec<Keyframe>,
@@ -112,6 +120,34 @@ pub struct FocusElement {
pub struct Focus {
/// The `.rat` leaf this came from, e.g. `ptbtn01f.rat`.
pub record: String,
/// The record header's `+0x08`: **where the cycle restarts**, in keyframe
/// units — which is not the same thing as the last keyframe's time.
///
/// `ptbtn00f`, the `PRESS Ⓐ` plate's glow, ramps 0→80→0 over **105** units
/// inside a **120**-unit cycle and rests dark for the remaining 15. Deriving
/// the period from the largest keyframe time — what the port did until now —
/// runs it 14 % fast and deletes the dark rest entirely.
///
/// Decoded by the Decoder (`07e93ce`, `docs/re/structures/ui-record-loop-length.md`,
/// delivered in HANDOFF `27938aa`) and **re-run here before adoption**, with
/// their falsifier and their non-triviality control (⚠️ the 92.3 % below is
/// "of records where the question is meaningful" -- 1 643 of the 1 781 with a
/// timed keyframe. 3 311 nested records exist; the other 1 530 have no
/// keyframe time at all, so `+0x08 == max t` is not a question there. Quoted
/// bare until 2026-09-01, which is a population-scoped statistic reported
/// without its population):
/// `cargo run -p sylpheed-export --example record_loop_control`. Disc-wide
/// 1 781 timed records, 92.3 % exact, 7.7 % hold, **0 declaring less than
/// their own last pose**; on the eight records this port animates, seven
/// exact and `ptbtn00f` the one hold.
///
/// ✅ **The port no longer owns this reading.** For one iteration `screen.rs`
/// held its own guard and byte read, because the field was decoded in an
/// example and a test and exposed in no public API on any ref. It is now
/// `ui_layout::loop_length_units`, taken at `formats-pin-2026-08-30b`, and
/// the local copy is deleted — the doc comment that promised that deletion
/// is the only reason it did not quietly become permanent.
pub loop_length_units: Option<u32>,
/// Back-to-front, in the leaf's own declaration order.
pub elements: Vec<FocusElement>,
}
@@ -141,6 +177,59 @@ pub struct Element {
/// convention and a consumer may still want the bare highlight texture.
#[serde(skip_serializing_if = "Option::is_none")]
pub focus: Option<Focus>,
/// This element's own `.rat` leaf, when its declared name is itself a
/// record in the bundle.
///
/// 🔴 **DECODED DATA THE EXPORTER USED TO DROP.** `ptloop01`/`ptloop02` on
/// the title declare scale 100 % and rotation 0 at the parent, and their
/// leaves declare **(100, 600) at +30°** and **(100, 800) at −45°** — and
/// the leaves *move*, x from −639 → 1521 and 1721 → −839. `ui_layout`'s own
/// note says so: *"the rotated quads come from its two nested `.rat` leaf
/// records, which the census never opened."* Neither did this exporter: it
/// opened a leaf only for a FOCUS record, via `highlight_name`.
///
/// That omission is measurable. It is the whole of the title's 1.82 %
/// disagreement with the oracle — the port draws two 400 px sprites upright
/// and static at (441, 270) where the game sweeps two ~1080 and ~1440 px
/// quads across the frame at opposite leans.
///
/// ⚠️ **Emitted, not yet drawn.** Parent and leaf each carry their own alpha
/// ramp on a different span — parent 0→255 over t=70…238, leaf
/// 255→0x80→255 over t=150…600 — so how the two compose is a *decoding*
/// question and not the port's to answer. The data is exported so it stops
/// being invisible; `ScreenView` ignores it until the composition rule is
/// known.
#[serde(skip_serializing_if = "Option::is_none")]
pub leaf: Option<Focus>,
/// True when the leaf's geometry DIFFERS from the parent's, so the leaf is
/// what the game draws.
///
/// Decided here rather than in the runtime because it is disc knowledge.
/// The Decoder's rule: *"the discriminator is which record carries the
/// geometry, not a fixed order"* — and the census over this export splits
/// cleanly, with no ambiguous middle:
///
/// * **30 of 46** leaf elements duplicate the parent's scale and rotation
/// exactly. That is the BASE-record case `screen.rs` already handled: the
/// leaf may differ by a unit of position (`ptbtn04`: parent y=401, leaf
/// y=402) and the parent wins. Flag is false; nothing changes.
/// * **16 differ**, and all of them differ in scale or rotation, not by a
/// rounding unit: the ten `ptloop01`/`ptloop02` sweeps ((100,600) at +30°
/// and (100,800) at −45° against an identity parent), two
/// `pgloading_ring` (leaf scale **(0,0)**), and `title_jp`'s
/// `ptlogo_eff2` (**parent 125 %, leaf 100 %**).
///
/// ⚠️ **Only the `ptloop` case is decoded.** The Decoder fitted the game's
/// own composed alpha — vertex colours `C3FFFFFF`/`B6FFFFFF`, i.e. 195 and
/// 182 — against the two leaf ramps and got one consistent time, t=355, then
/// *predicted* the quad centres at 981 and 478 against 992.0 and 467.2
/// measured. The other two are the same shape and are **not** separately
/// confirmed; they are flagged so the harness can adjudicate them rather
/// than being asserted.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub leaf_carries_geometry: bool,
/// The raw `opt ` link inside this element's `.rat` record.
///
/// ⚠️ **This is not a focus link.** It was read as one, and that was
@@ -161,6 +250,23 @@ pub struct Element {
/// Paint-order key. `"sprite"` = read from the `T8aD` header at `+0x0A`.
/// `"implied"` = **measured off the running game**, for elements that carry
/// no header. `"none"` = neither; sorts last.
/// `true` when the game draws this element ADDITIVE — `T8aD +0x04` bit
/// `0x02`.
///
/// 🔴 **DECODED, and it replaces an authored map.** The port carried an
/// `additive_elements` table in `authored/rendering.json`, keyed by SCREEN
/// NAME and transcribed from the Decoder's per-draw `RB_BLENDCONTROL0` log.
/// A name-keyed map cannot answer for a screen nobody drove the game to,
/// which is why the port was drawing the English menus additive and the
/// Japanese ones alpha-over — asserting by omission that the JP build
/// blends differently. The bit is on the disc for every screen at once.
///
/// ⚠️ `kind_raw` is NOT this field. `kind` is `+40` of the RATC declaration
/// entry; this is `+0x04` of the sprite's own `T8aD` header. Tested over
/// four screens: `kind & 0x2` is *anti*-correlated with the measured map —
/// 0 of 14 additive elements set it and 9 non-additive ones do.
#[serde(skip_serializing_if = "Option::is_none")]
pub blend_additive: Option<bool>,
pub layer_source: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub layer: Option<String>,
@@ -201,6 +307,35 @@ pub struct Screen {
/// **Geometric, not a decoded neighbour graph** — right for a vertical menu
/// and not to be trusted for anything else.
pub buttons: Vec<String>,
/// The instant every element of this screen is settled at, and the width of
/// the interval it was taken from — `[start, end, midpoint]` in keyframe
/// units, absent when the screen has fewer than two keyframe times.
///
/// 🔴 **A SETTLED SCREEN IS ONE INSTANT, AND THE DISC SAYS WHICH.** Posing
/// each element at its own `rest()` is right for anything that ends the
/// screen settled and **exactly wrong for a transient**: the title's
/// `ptlogo_back2eff1` is a two-frame flash — 0 until t52, 255 at t54–56, 0
/// again by t58 — so its last *hold* is the flash peak and `rest()` leaves
/// it burning forever. There are five of these, and `rest()` draws all five
/// at once, saturating the light arc.
///
/// The window is the **longest interval containing no keyframe time**, over
/// this bundle's TOP-LEVEL elements only. Nested leaves are excluded, and
/// that exclusion is what reproduces the Decoder's independently computed
/// `[160, 236]` for the title: including the `ptloop` leaves gives
/// `[269, 540]` instead.
///
/// ⚠️ **Emitted for every screen; USABLE only where it is wide.** Across this
/// export the widths split with nothing in between — `press_start` 214,
/// `publisher_logo` 190, `developer_logos` 145, `title` 76, then
/// `main_menu` 12, `extras` 12, the loading screens 8 and 4. A 12-unit
/// "settle" on a menu that builds in until t=70 is not a settled pose, it is
/// a gap between staggered ramps. The Decoder's disc-wide census agrees on
/// the shape: only 30 % of bundles have a window ≥ 30 units and 42 % have
/// one under 10, the latter mostly `loop*` fragments meant to be in motion.
#[serde(skip_serializing_if = "Option::is_none")]
pub settle_window: Option<[i64; 3]>,
/// What this file does not answer. A consumer needing one of these must get
/// it from `authored/`.
pub unresolved: Vec<&'static str>,
@@ -316,6 +451,76 @@ pub fn export_build(
// Contrast with a BASE record, where the leaf duplicates the parent's
// placement and the two can differ by a unit (ptbtn04: parent y=401,
// leaf y=402). There the parent wins. Here there is no parent.
// Reads one record in the bundle as a nested build and returns its
// elements. Used twice: for a FOCUS record (`ptbtn0Nf.rat`) and for an
// element whose OWN declared name is a record (`ptloop01.rat`). One
// implementation, because the second case was missing for eight
// milestones and a second copy is how it would go missing again.
let read_leaf = |rec: &str,
written: &mut std::collections::BTreeMap<String, ()>,
missing: &mut Vec<String>|
-> Result<Option<Focus>> {
let Some(&(off, size)) = b.records.get(rec) else { return Ok(None) };
let Some(leaf) = ui_layout::parse_build(&bundle[off..off + size]) else {
return Ok(None);
};
let mut fes = Vec::new();
for fe in &leaf.elements {
let sp: &str = fe.sprite.as_deref().unwrap_or(&fe.name);
let mut fsprite = None;
if write_from(&sprite_dir, written, sp, &bundle[off..off + size], &leaf.sprites)?
|| write_from(&sprite_dir, written, sp, bundle, &b.sprites)?
{
fsprite = Some(sprite_rel(sp));
} else if sp.ends_with(".t32") {
missing.push(sp.to_string());
}
let Some(r) = fe.rest() else { continue };
fes.push(FocusElement {
id: id_of(&fe.name),
declared: fe.name.clone(),
sprite: fsprite,
blend_additive: ui_layout::blend_additive_by_name(
&leaf, &bundle[off..off + size], sp)
.or_else(|| ui_layout::blend_additive_by_name(&b, bundle, sp)),
pivot: [fe.pivot_x, fe.pivot_y],
rest: Rest {
pos: [r.x, r.y],
scale: [r.scale_x, r.scale_y],
tint_rgba: hex32(r.tint),
fade_argb: hex32(r.fade),
rotation_deg: r.rotation_deg,
t: r.time,
},
keyframes: fe
.keyframes
.iter()
.map(|k| Keyframe {
t: k.time,
pos: [k.x, k.y],
scale: [k.scale_x, k.scale_y],
tint_rgba: hex32(k.tint),
fade_argb: hex32(k.fade),
rotation_deg: k.rotation_deg,
})
.collect(),
});
}
Ok(if fes.is_empty() {
None
} else {
Some(Focus {
record: rec.to_string(),
loop_length_units: ui_layout::loop_length_units(&bundle[off..off + size]),
elements: fes,
})
})
};
// An element whose own declared name is a record in this bundle carries
// its geometry THERE, not in its parent entry. See `Element::leaf`.
let leaf = read_leaf(&el.name, &mut written, &mut missing)?;
let mut focus = None;
if let Some(rec) = highlight_name(&el.name) {
if let Some(&(off, size)) = b.records.get(&rec) {
@@ -341,6 +546,9 @@ pub fn export_build(
id: id_of(&fe.name),
declared: fe.name.clone(),
sprite: fsprite,
blend_additive: ui_layout::blend_additive_by_name(
&leaf, &bundle[off..off + size], sp)
.or_else(|| ui_layout::blend_additive_by_name(&b, bundle, sp)),
pivot: [fe.pivot_x, fe.pivot_y],
rest: Rest {
pos: [r.x, r.y],
@@ -365,7 +573,11 @@ pub fn export_build(
});
}
if !fes.is_empty() {
focus = Some(Focus { record: rec, elements: fes });
focus = Some(Focus {
record: rec,
loop_length_units: ui_layout::loop_length_units(&bundle[off..off + size]),
elements: fes,
});
}
}
}
@@ -397,10 +609,21 @@ pub fn export_build(
sprite: sprite_out,
focus_sprite,
focus,
leaf_carries_geometry: leaf.as_ref().is_some_and(|l| {
let p = el.rest();
l.elements.iter().any(|le| {
p.is_none_or(|p| {
le.rest.scale != [p.scale_x, p.scale_y]
|| le.rest.rotation_deg != p.rotation_deg
})
})
}),
leaf,
opt_link: el.focus_link.clone(),
pivot: [el.pivot_x, el.pivot_y],
size: (role == "primitive").then(|| [el.pivot_x * 2, el.pivot_y * 2]),
parent: el.parent,
blend_additive: ui_layout::sprite_blend_additive(&b, bundle, el),
layer_source,
layer,
focused: el.focused,
@@ -427,6 +650,12 @@ pub fn export_build(
.collect();
buttons.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
let window = settle_window(&elements);
let order = forced_backdrop_first(
ui_layout::derived_paint_order(&b, bundle),
&elements,
[b.design_w, b.design_h],
);
let screen = Screen {
format: "sylpheed.screen/3",
exporter: exporter.to_string(),
@@ -441,8 +670,9 @@ pub fn export_build(
name_why,
design: [b.design_w, b.design_h],
elements,
paint_order: ui_layout::derived_paint_order(&b, bundle),
paint_order: order,
buttons: buttons.into_iter().map(|(_, n)| n).collect(),
settle_window: window,
unresolved: vec![
// The time unit is measured off the running game, not on the disc.
"keyframe_time_unit",
@@ -473,3 +703,234 @@ pub fn export_build(
missing,
})
}
/// The longest interval containing no keyframe time, over TOP-LEVEL elements.
///
/// See [`Screen::settle_window`] for why this is the settled instant and why
/// nested leaves are excluded. Returns `[start, end, midpoint]`.
fn settle_window(elements: &[Element]) -> Option<[i64; 3]> {
let mut times: Vec<i64> = elements
.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.t.map(i64::from)))
.collect();
times.sort_unstable();
times.dedup();
if times.len() < 2 {
return None;
}
// 🔴 A GAP IN WHICH NOTHING IS VISIBLE IS NOT A SETTLE WINDOW.
//
// The widest keyframe-free interval is only a settled state if the screen is
// actually PRESENTING something across it. `press_start` is the case that
// proves it: its keyframes are 0, 214, 236, 238, 244, so the widest gap is
// 0..214 -- the dead stretch BEFORE the plate appears, where `ptbtn00` is
// alpha 0 throughout. Taking its midpoint gave a settle instant of t=107,
// and the runtime then answered every question about that screen at t=107.
// The result was that the PRESS (A) plate could not be drawn at any instant
// at all, including the boot's own end state, whose entire purpose is to
// show it.
//
// The fix is not a tuned threshold: it is that the heuristic was reading an
// interval where the screen is BLANK as the interval where it has arrived.
// Rejecting those leaves `press_start` with 214..236 (22 units), which is
// under the runtime's 30-unit bar, so it falls back to each element's own
// hold -- which is the plate, opaque, exactly as the disc declares it.
//
// ⚠️ This does not disturb the windows the settle instant was measured on.
// `title` keeps [160, 236]: elements are visible across it, and the
// Decoder's draw stream independently found the game's clock freezing in
// that same interval.
let visible_at = |t: i64| elements.iter().any(|e| alpha_at(e, t) > 0);
let (a, b) = times
.windows(2)
.map(|w| (w[0], w[1]))
.filter(|(a, b)| visible_at((a + b) / 2))
.max_by_key(|(a, b)| b - a)?;
Some([a, b, (a + b) / 2])
}
/// Alpha of one element at instant `t`, under the linear ramp the port uses.
fn alpha_at(e: &Element, t: i64) -> u8 {
let ks = &e.keyframes;
let a = |k: &Keyframe| (u32::from_str_radix(k.fade_argb.trim_start_matches("0x"), 16)
.unwrap_or(0) >> 24) as i64;
let timed: Vec<&Keyframe> = ks.iter().filter(|k| k.t.is_some()).collect();
if timed.is_empty() {
return 0;
}
if t <= timed[0].t.unwrap() as i64 {
return a(timed[0]) as u8;
}
for w in timed.windows(2) {
let (t0, t1) = (w[0].t.unwrap() as i64, w[1].t.unwrap() as i64);
if t < t1 {
if t1 <= t0 {
return a(w[0]) as u8;
}
let f = (t - t0) as f64 / (t1 - t0) as f64;
return (a(w[0]) as f64 + (a(w[1]) - a(w[0])) as f64 * f).round() as u8;
}
}
a(timed[timed.len() - 1]) as u8
}
/// Scale of one element at instant `t`, in percent per axis, under the same
/// linear ramp as the fade. Interpolated rather than stepped, because a scale
/// that animates passes through every value between its keyframes.
fn scale_at(e: &Element, t: i64) -> [f64; 2] {
let timed: Vec<&Keyframe> = e.keyframes.iter().filter(|k| k.t.is_some()).collect();
if timed.is_empty() {
return [100.0, 100.0];
}
let g = |k: &Keyframe, i: usize| k.scale[i] as f64;
if t <= timed[0].t.unwrap() as i64 {
return [g(timed[0], 0), g(timed[0], 1)];
}
for w in timed.windows(2) {
let (t0, t1) = (w[0].t.unwrap() as i64, w[1].t.unwrap() as i64);
if t < t1 {
if t1 <= t0 {
return [g(w[0], 0), g(w[0], 1)];
}
let f = (t - t0) as f64 / (t1 - t0) as f64;
return [
g(w[0], 0) + (g(w[1], 0) - g(w[0], 0)) * f,
g(w[0], 1) + (g(w[1], 1) - g(w[0], 1)) * f,
];
}
}
let l = timed[timed.len() - 1];
[g(l, 0), g(l, 1)]
}
/// Move a full-screen opaque primitive to the FRONT of the paint order when the
/// file forces it there.
///
/// 🔴 **The rule is a constraint, not a preference**, and it is the Decoder's:
/// *an element that covers the screen and is fully opaque at some instant cannot
/// paint above anything visible at that instant; where the elements visible
/// during its opaque span are ALL of them, its position is forced to first.*
///
/// It was found because `build_12`/`build_15` are **black at every instant** of
/// their declared timeline under the old rule — `pgloading_eff00` is opaque for
/// 39 instants while all 9 other elements live and die inside that span. A
/// screen that is black for its whole life is impossible on its face, which is
/// the only kind of check that survives two renderers sharing an assumption:
/// `sylpheed-cli` agreed with the port here because it agreed about
/// `implied_layer_key`.
///
/// Two measured controls, both prior orders off the running game:
///
/// | primitive | measured | opaque instants | forced below | |
/// |---|---|---|---|---|
/// | `palogo_eff0.prm` | **first** | 211 | 6 of 6 | ✅ forced |
/// | `pteff00.prm` | **last** | 2 | 3 of 23 | ✅ permitted on top |
///
/// ⚠️ **Do NOT reduce this to a name heuristic.** `*base*` first / `*eff*` last
/// matches 77 of 80 and fails on exactly the three families that cross it —
/// `palogo_eff0`, `pgloading_eff00`, `pzeff00`. `palogo_eff0.prm` is *named like
/// an overlay* and is measured painting first. The name is not the rule.
///
/// 🔴 **And it is restricted to elements with NO SPRITE**, which is the limit
/// that the rule's own disc-wide test caught: applied to sprites it claimed 22
/// `.t32` textures must sort first *against their own layer keys*. **An
/// element's alpha says nothing about whether its texture covers the screen** —
/// most of a sprite may be transparent.
///
/// ⚠️ Reach: assumes straight alpha-over. Blend mode is undecoded, and an
/// additive quad at alpha 255 would not occlude. It is a lower bound, not an
/// ordering — it says nothing about elements that are constrained but not
/// forced. Delete this when a pinned `sylpheed-formats` does it.
fn forced_backdrop_first(order: Vec<usize>, elements: &[Element], design: [u32; 2]) -> Vec<usize> {
let screen_end: i64 = elements
.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.t))
.map(i64::from)
.max()
.unwrap_or(0);
let forced: Vec<usize> = elements
.iter()
.enumerate()
.filter(|(_, e)| {
// 🔴 UNTEXTURED SOLID QUAD, tested positively -- NOT merely "has no
// sprite". Those coincide in GP_TITLE and the distinction is still
// the whole point, because the negative test guards a SYMPTOM.
//
// The rule needs the element's alpha to BE its pixels' alpha. That
// is true of a `.prm` solid quad and of nothing else. The Decoder
// found this the expensive way twice: first `.t32` sprites (an
// element's alpha says nothing about a texture that is mostly
// transparent), guarded with "no sprite" -- and then `.tbm`, which
// is 38 of their 80 forced-first verdicts and declares fade
// `ffffffff`. A solid WHITE quad painted first at alpha 255 would
// make the screen white; no screen is white, so a `.tbm`'s white is
// a modulation ON a texture and its element alpha proves nothing
// about coverage either.
//
// "No sprite" would keep admitting a `.tbm` that this exporter
// happens not to emit a sprite for. `role == "primitive"` cannot.
// GP_TITLE has no full-screen `.tbm` at all -- every layerless
// full-screen element here is `.prm` and pure black, checked -- so
// this changes no verdict today and is a guard against a corpus
// that grows.
// Cheap prefilter only -- the binding coverage test is per-instant,
// in `covers` below. An element scaled ABOVE 100 could cover the
// screen from a smaller declared size, so this deliberately does
// not reject on size.
e.role == "primitive" && e.sprite.is_none() && e.size.is_some()
})
.filter(|(i, e)| {
let span: Vec<i64> = e
.keyframes
.iter()
.filter_map(|k| k.t)
.map(i64::from)
.collect();
let Some(&lo) = span.first() else { return false };
// 🔴 COVERAGE IS TESTED AT EACH INSTANT, NOT ONCE FROM `size`.
// Declared size alone is not what the element draws: scale is a
// percent per axis and it animates. `pbafc.prm` is the disc's own
// counterexample -- declared 844x600, scaled 2 % x 3 %, so it draws
// about 17x18 px, a moving glint rather than a wash. A rule that
// read its declared size would call it screen-covering.
//
// Nothing in GP_TITLE needs this: every layerless full-screen
// element here is at scale 100 on every keyframe, so no verdict
// moves. It is in because the data that would break it exists on
// this disc, which is a better reason than a failure would have been.
let covers = |t: i64| {
let sc = scale_at(e, t);
e.size.is_some_and(|s| {
s[0] as f64 * sc[0] / 100.0 >= design[0] as f64
&& s[1] as f64 * sc[1] / 100.0 >= design[1] as f64
})
};
// An element HOLDS ITS FINAL POSE to the end of the screen -- it does
// not vanish at its own last keyframe. `palogo_eff0.prm` is the case
// that shows why: it declares ONE keyframe, opaque black full-screen
// at t=0, and reading its span as `0..=0` makes the splash's backdrop
// a single-instant event instead of the thing that is on screen for
// the whole splash. So the span runs to the SCREEN's last keyframe.
let hi = screen_end.max(*span.last().unwrap());
let opaque: Vec<i64> = (lo..=hi)
.filter(|&t| alpha_at(e, t) == 255 && covers(t))
.collect();
if opaque.is_empty() {
return false;
}
// Every OTHER element must be visible somewhere inside that span.
elements.iter().enumerate().all(|(j, o)| {
j == *i || opaque.iter().any(|&t| alpha_at(o, t) > 0)
})
})
.map(|(i, _)| i)
.collect();
if forced.is_empty() {
return order;
}
let mut out = forced.clone();
out.extend(order.into_iter().filter(|i| !forced.contains(i)));
out
}

View File

@@ -63,8 +63,34 @@ pub const MOVIES: &[Movie] = &[
/// coefficient rounding — and peak and mean levels agree to 0.1 dB. ffmpeg's
/// default *is* this matrix; the point is that the manifest now says so.
///
/// The unnormalised form was measured too and **clips**: peak 0.0 dBFS. That is
/// why the normalisation is here rather than the textbook coefficients.
/// # 🔴 This is NOT the matrix MISSION §6 pins, and that was never said out loud
///
/// MISSION §6 records a **human decision of 2026-08-29** fixing the fold at
/// `FL = 1.0·FL + 0.707·FC + 0.707·BL` (plus 7.1 terms a 5.1 source does not
/// have). This constant is that matrix scaled by 0.4142 — the same relative
/// weighting, **7.65 dB quieter** — and until now nothing in the code, the
/// manifest or the docs said so. Recording the command you ran does not disclose
/// that it is not the command you were given.
///
/// The original justification for the deviation was *"the unnormalised form
/// clips: peak 0.0 dBFS"*, and that is a peak reading — the instrument
/// `docs/port/BLOCKED.md` records this port declaring unfit for the clipping
/// question, because one sample at full scale and two seconds of square wave
/// give the same number. Re-measured properly (float decode, whole file, count
/// the samples that would clamp):
///
/// | | peak | ≥ full scale | > +1 dB over | longest run |
/// |---|---|---|---|---|
/// | `ADV`, MISSION §6 | **+4.26 dBFS** | 4 406 / 13 187 900 | 1 874 | 0.333 ms |
/// | `S00A`, MISSION §6 | −1.34 dBFS | **0** | 0 | — |
///
/// So the pin really does overload `ADV` — and this constant is over-broad,
/// because `S00A` never needed it. The smallest single scalar under which
/// neither clamps is `1/1.6339 = 0.612`, +3.39 dB on today.
///
/// **Not changed here.** The level of a mix is what §6 reserves to a human
/// (*"adjust it deliberately, as a commit"*), so the export carries a warning
/// with these numbers instead. See `docs/port/DECISIONS.md`.
const DOWNMIX_51: &str = "pan=stereo|FL=0.4142*FL+0.2929*FC+0.2929*BL |FR=0.4142*FR+0.2929*FC+0.2929*BR";
/// How many audio channels the source declares.
@@ -80,6 +106,34 @@ fn channels(src: &Path) -> Result<u32> {
Ok(String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or(2))
}
/// Duration and frame rate of a finished transcode, straight from the file.
///
/// Probed from the OUTPUT, not the source: what the runtime will play is this
/// file, and the two differ — `ADV` is 137.44 s against a 137.71 s source.
/// Returns zeros rather than failing, because a missing number should make the
/// runtime say "unknown", not stop an export that otherwise succeeded.
fn probe_timebase(out: &Path) -> (f64, f64) {
let probe = |entries: &str, stream: bool| -> String {
let mut c = Command::new("ffprobe");
c.args(["-v", "error"]);
if stream {
c.args(["-select_streams", "v:0"]);
}
c.args(["-show_entries", entries, "-of", "csv=p=0"]).arg(out);
c.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default()
};
let secs = probe("format=duration", false).parse().unwrap_or(0.0);
// `r_frame_rate` is a rational, "30/1".
let rate = probe("stream=r_frame_rate", true);
let fps = match rate.split_once('/') {
Some((n, d)) => n.parse::<f64>().unwrap_or(0.0) / d.parse::<f64>().unwrap_or(1.0),
None => rate.parse().unwrap_or(0.0),
};
(secs, fps)
}
fn args(src: &Path, out: &Path, channels: u32) -> Vec<String> {
let mut v: Vec<String> = [
"-hide_banner", "-loglevel", "error", "-y",
@@ -108,6 +162,30 @@ pub struct Transcoded {
pub file: String,
pub command: String,
pub why: &'static str,
/// The transcode's own duration and frame rate, probed from the file that
/// was just written.
///
/// Recorded so the RUNTIME can say what it actually presented.
///
/// 🔴 CORRECTED 2026-09-01. This read: *"Godot's video player drops frames to
/// hold its schedule, and it drops a lot of them here — measured at 28 % of [refuted]
/// `S00A`'s frames presented and 47 % of `ADV`'s"*. **Both numbers are
/// retracted.** They came from CONTENDED runs, and the counter is an upper
/// bound on ENGINE frames that is vacuous once the engine outruns the stream
/// — quiet, `ADV` draws 6 480 frames across a 4 123-frame video. On a quiet
/// box the bound is 88–90 % for `S00A`, and playback runs **+6.7 %…+6.9 %**
/// long for both films. What survives is that elapsed seconds hide whatever
/// the player does, which is why the count is in the manifest. Without a frame count in the manifest a run can only
/// report elapsed seconds, and elapsed seconds are exactly what stays
/// plausible while three frames in four go missing.
///
/// 🔴 This field exists because the port asserted the opposite. The claim was
/// *"a player that runs long decoded everything"*, argued from the absence of
/// an overrun rather than measured; the measurement was four lines and
/// refuted it. **The instrument is now permanent so the argument cannot be
/// made again from a run that never counted.**
pub duration_s: f64,
pub fps: f64,
}
/// Transcode one movie, skipping the encode when the output already exists and
@@ -131,10 +209,35 @@ pub fn transcode(disc: &Path, out: &Path, m: &Movie) -> Result<Option<Transcoded
let argv = args(&src, &ogv, ch);
let command = format!("ffmpeg {}", argv.join(" "));
let size = std::fs::metadata(&src)?.len();
let want = format!("{command}\nsource-bytes: {size}\nsource-channels: {ch}\n");
// The sidecar SAYS WHAT IT IS. It sits in the modder-facing asset tree next
// to the `.ogv`, and MODDING rule 2's principle is that a generated file
// should be tellable from a hand-made one by reading it -- a bare ffmpeg
// line beside a video looks like something a modder should edit or delete.
//
// The header is NOT part of the cache key: `fresh` compares only the lines
// that describe the encode. Otherwise rewording this comment would re-encode
// four minutes of video to no purpose, which is a cache that punishes
// documentation.
let key = format!("{command}\nsource-bytes: {size}\nsource-channels: {ch}\n");
let want = format!(
"# Generated by sylpheed-export. NOT an asset and not hand-editable: this\n\
# records how {}.ogv beside it was encoded, so a re-export can skip the\n\
# encode when the source and the command are both unchanged. Deleting it\n\
# only forces one re-encode. To change the video, override the .ogv under\n\
# data/mods/ (MODDING rule 4) -- editing this file changes nothing.\n{key}",
m.stem
);
let cache_key = |s: &str| -> String {
s.lines()
.filter(|l| !l.starts_with('#'))
.collect::<Vec<_>>()
.join("\n")
};
let fresh = ogv.exists()
&& std::fs::read_to_string(&stamp).map(|s| s == want).unwrap_or(false);
&& std::fs::read_to_string(&stamp)
.map(|s| cache_key(&s) == cache_key(&want))
.unwrap_or(false);
if !fresh {
// Encode to a temp name and rename on success. A reader that catches
// this mid-write sees no file at all rather than a valid-looking one
@@ -155,12 +258,26 @@ pub fn transcode(disc: &Path, out: &Path, m: &Movie) -> Result<Option<Transcoded
bail!("ffmpeg failed on {}", m.src);
}
std::fs::rename(&partial, &ogv)?;
}
// Refresh the sidecar whenever its TEXT differs, encode or no encode.
//
// It used to be written only inside the `!fresh` branch, which is right for
// the cache and wrong for the file: a change to the header alone -- the part
// deliberately excluded from the key -- would then never reach an existing
// export, because nothing that reads the header can trigger the write that
// updates it. The explanation would be correct in the source and absent on
// disc, which is the same shape as every other documented-but-unexercised
// thing this port has had to find the hard way.
if std::fs::read_to_string(&stamp).map(|s| s != want).unwrap_or(true) {
std::fs::write(&stamp, &want)?;
}
let (duration_s, fps) = probe_timebase(&ogv);
Ok(Some(Transcoded {
name: m.stem.to_string(),
file: format!("video/{}.ogv", m.stem),
command,
why: m.why,
duration_s,
fps,
}))
}