Takes the port branch up to77320d5e-- 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.08ed3dd1found 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 afterc0ae460a-- 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.
284 lines
13 KiB
Rust
284 lines
13 KiB
Rust
//! Movies: disc WMV → Ogg Theora, because Godot 4 plays Theora natively and
|
||
//! will never be taught to read WMV.
|
||
//!
|
||
//! The transcode command is **recorded in the manifest verbatim**. A modder who
|
||
//! dislikes the quality re-runs one line rather than reverse-engineering what
|
||
//! was done to their video, which is the whole reason this project converts the
|
||
//! disc instead of reading it at runtime.
|
||
|
||
use anyhow::{bail, Context, Result};
|
||
use std::path::Path;
|
||
use std::process::Command;
|
||
|
||
/// A movie in scope for this port.
|
||
pub struct Movie {
|
||
/// Path under the disc root.
|
||
pub src: &'static str,
|
||
/// Output stem under `export/video/`.
|
||
pub stem: &'static str,
|
||
pub why: &'static str,
|
||
}
|
||
|
||
/// MISSION §6: the boot intro and the one new-game intro. The disc holds 3.3 GB
|
||
/// of video and transcoding all of it is not this milestone.
|
||
pub const MOVIES: &[Movie] = &[
|
||
Movie {
|
||
src: "dat/movie/ADV.wmv",
|
||
stem: "ADV",
|
||
why: "HANDOFF Q9: ADVERTISE_MOVIE -> ADV.wmv, and the boot intro and the \
|
||
attract movie are the SAME asset -- there is no separate boot slot.",
|
||
},
|
||
Movie {
|
||
src: "dat/movie/S00A.wmv",
|
||
stem: "S00A",
|
||
why: "HANDOFF Q9: MS00A -> S00A.wmv is the new-game intro. P7.",
|
||
},
|
||
];
|
||
|
||
/// The encode.
|
||
///
|
||
/// `-q:v 8` was chosen by measurement, not taste: against the decoded source,
|
||
/// SSIM over a 10 s sample is 0.9863 at q6, **0.9896 at q8** and 0.9924 at q10,
|
||
/// and q8 is visually indistinguishable at 200 % zoom on the reel's hardest
|
||
/// case — fine serif text and soft gradients over near-black, which is where
|
||
/// Theora usually breaks first. MISSION §6 anticipated that 720p Theora might
|
||
/// be too poor and asked for the FFmpeg-GDExtension fallback to be *proposed*
|
||
/// if so. It is not: **no runtime dependency is needed, and none is requested.**
|
||
///
|
||
/// The stereo downmix, **stated explicitly rather than inherited**.
|
||
///
|
||
/// The disc ships movies in two audio profiles: 28 files are 5.1 WMA Pro (every
|
||
/// cutscene, including both movies this port needs) and 69 are already stereo.
|
||
/// A bare `-ac 2` therefore does two different things and records neither — the
|
||
/// stereo files pass through, and the 5.1 files are folded by **ffmpeg's default
|
||
/// matrix**. How loudly centre-channel dialogue sits against the music is a
|
||
/// CONTENT decision, and leaving it to a default means it is made by accident
|
||
/// and can move under an ffmpeg upgrade.
|
||
///
|
||
/// So the matrix is written out: **ITU-R BS.775, LFE dropped**, normalised by
|
||
/// `1/(1 + √½ + √½) = 0.4142` so the sum of coefficients cannot clip.
|
||
///
|
||
/// This does not change the audio. Measured against the inherited default over a
|
||
/// 25 s stretch, the residual is **−91 dB** — roughly one LSB at 16-bit, i.e.
|
||
/// 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.
|
||
///
|
||
/// # 🔴 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.
|
||
fn channels(src: &Path) -> Result<u32> {
|
||
let out = Command::new("ffprobe")
|
||
.args([
|
||
"-v", "error", "-select_streams", "a:0",
|
||
"-show_entries", "stream=channels", "-of", "csv=p=0",
|
||
])
|
||
.arg(src)
|
||
.output()
|
||
.context("run ffprobe -- is it on PATH?")?;
|
||
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",
|
||
"-i", &src.display().to_string(),
|
||
"-c:v", "libtheora", "-q:v", "8",
|
||
"-c:a", "libvorbis", "-q:a", "5",
|
||
]
|
||
.iter()
|
||
.map(|s| s.to_string())
|
||
.collect();
|
||
// Only 5.1 sources are folded. A source that is already stereo is passed
|
||
// through untouched rather than run through a matrix that would silently
|
||
// reference channels it does not have.
|
||
if channels == 6 {
|
||
v.push("-af".into());
|
||
v.push(DOWNMIX_51.into());
|
||
}
|
||
v.push("-ac".into());
|
||
v.push("2".into());
|
||
v.push(out.display().to_string());
|
||
v
|
||
}
|
||
|
||
pub struct Transcoded {
|
||
pub name: String,
|
||
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
|
||
/// was produced by exactly this command against exactly this source.
|
||
///
|
||
/// `export/` is still regenerated wholesale — this is a cache, not a hand-edit.
|
||
/// The sidecar records the command and the source size, so any change to either
|
||
/// re-encodes. Without it every re-export pays ~4 minutes to produce a
|
||
/// byte-identical file, and an exporter nobody re-runs is worse than a cache.
|
||
pub fn transcode(disc: &Path, out: &Path, m: &Movie) -> Result<Option<Transcoded>> {
|
||
let src = disc.join(m.src);
|
||
if !src.exists() {
|
||
return Ok(None);
|
||
}
|
||
let dir = out.join("video");
|
||
std::fs::create_dir_all(&dir)?;
|
||
let ogv = dir.join(format!("{}.ogv", m.stem));
|
||
let stamp = dir.join(format!("{}.cmd", m.stem));
|
||
|
||
let ch = channels(&src)?;
|
||
let argv = args(&src, &ogv, ch);
|
||
let command = format!("ffmpeg {}", argv.join(" "));
|
||
let size = std::fs::metadata(&src)?.len();
|
||
// 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| 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
|
||
// with a wrong duration -- ffprobe reported 33 s against a 137 s source
|
||
// during one such race, with no error, and it looked exactly like
|
||
// catastrophic truncation. The filesystem is shared with another agent,
|
||
// so this is a race and not an edge case.
|
||
let partial = dir.join(format!(".{}.partial.ogv", m.stem));
|
||
let mut argv = argv.clone();
|
||
let last = argv.len() - 1;
|
||
argv[last] = partial.display().to_string();
|
||
let status = Command::new("ffmpeg")
|
||
.args(&argv)
|
||
.status()
|
||
.context("run ffmpeg -- is it on PATH?")?;
|
||
if !status.success() {
|
||
let _ = std::fs::remove_file(&partial);
|
||
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,
|
||
}))
|
||
}
|