port: P4 -- the intro video plays inside the boot sequence

The exporter transcodes ADV.wmv and S00A.wmv to Ogg Theora and records the exact
ffmpeg command in the manifest, per MISSION §6, so a modder who dislikes the
quality re-runs one line rather than reverse-engineering what was done.

Quality was MEASURED, not judged: SSIM against the decoded source over a 10 s
sample is 0.9863 / 0.9896 / 0.9924 at -q:v 6 / 8 / 10, and at 200 % zoom on the
reel's hardest case -- fine serif text and soft gradients over near-black, where
Theora breaks first -- q8 is indistinguishable. So MISSION §6's permitted
FFmpeg-GDExtension fallback is NOT needed and is NOT being proposed. No new
runtime dependency.

-ac 2 because the source is 6-channel WMA Pro; that downmix is a decision, so it
lives in the recorded command rather than in prose.

Encoding is cached on a .cmd sidecar holding the command and the source size --
any change to either re-encodes. export/ is still regenerated wholesale; this is
derived state validating derived state, not a hand-edit, and without it every
re-export pays ~4 minutes to produce a byte-identical file.

The player renders INTO the design SubViewport. Parenting it to the Boot node
played the movie to the window instead, and every captured frame came out black
-- which is worth more than a capture-bug note: a movie outside the 1280x720
design space is outside the coordinate system every screen is expressed in.

(A) skips a movie, because Q9 measured that (title at 57 s vs a 193 s baseline).

NOT VERIFIED, and stated as such: audible playback. This container has no audio
device and Godot falls back to the dummy driver. The Vorbis stream exists, is
2-channel and decodes; whether Godot emits it is unconfirmed.
This commit is contained in:
Sylpheed port agent
2026-08-29 08:44:25 +00:00
parent b632602517
commit 579c8096c1
6 changed files with 302 additions and 12 deletions

View File

@@ -1,6 +1,5 @@
{
"format": "sylpheed.flow/1",
"_": [
"The boot sequence. AUTHORED, and it has to be: HANDOFF Q6 closed this with a",
"negative -- the order is in none of the four places it could have been. It is",
@@ -13,7 +12,6 @@
"agent watched the game do, not what any file on the disc says it does. Nothing",
"here may be presented as decoded."
],
"boot": [
{
"screen": "publisher_logo",
@@ -23,12 +21,17 @@
"screen": "developer_logos",
"why": "GAME ARTS / SETA / studio anima, after the publisher wordmark. HANDOFF Q2."
},
{
"video": "ADV",
"why": "HANDOFF Q9, DECODED from the movie manifest: ADVERTISE_MOVIE -> ADV.wmv, and the boot intro and the attract movie are the SAME asset -- there is no separate boot slot. Its POSITION here (after the developer logos, before the title) is measured, not decoded: it is the order the RE agent watched the game boot in.",
"skippable": true,
"skippable_why": "HANDOFF Q9: one (A) press skips a movie -- measured, title reached at 57 s against a 193 s baseline."
},
{
"screen": "title",
"why": "HANDOFF Q2/Q6: the boot reaches the title after the splashes. The intro video (ADVERTISE_MOVIE -> ADV.wmv) plays between the splash and the title in the real boot and is SKIPPED here -- it is P4, and the sequencer names the gap rather than pretending the order is different."
"why": "HANDOFF Q2/Q6: the boot reaches the title after the intro movie. The port holds here -- nothing takes the title's place until P5 gives it somewhere to go."
}
],
"dwell": {
"_": [
"DELIBERATELY EMPTY. Each screen's dwell is its own keyframe group -- the",
@@ -40,7 +43,6 @@
"When a capture times the real boot, the extra hold per screen goes here."
]
},
"screens": {
"_": [
"What each button does. NOT FILLED IN -- that is P5. HANDOFF Q4 measured the",

View File

@@ -13,6 +13,7 @@
//! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope.
mod check;
mod video;
mod screen;
use anyhow::{Context, Result};
@@ -67,6 +68,16 @@ struct ManifestScreen {
missing_sprites: Vec<String>,
}
#[derive(Serialize)]
struct ManifestVideo {
name: String,
file: String,
/// The exact command that produced this file. MISSION §6: a modder who
/// dislikes the quality re-runs one line rather than reverse-engineering it.
command: String,
why: &'static str,
}
#[derive(Serialize)]
struct Manifest {
format: &'static str,
@@ -75,6 +86,8 @@ struct Manifest {
formats_rev: &'static str,
disc: String,
screens: Vec<ManifestScreen>,
#[serde(skip_serializing_if = "Vec::is_empty")]
videos: Vec<ManifestVideo>,
warnings: Vec<String>,
}
@@ -246,12 +259,30 @@ 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();
for m in video::MOVIES {
match video::transcode(disc, out, m)? {
Some(t) => {
println!(" video {} -> {}", m.src, t.file);
videos.push(ManifestVideo {
name: t.name,
file: t.file,
command: t.command,
why: t.why,
});
}
None => println!(" video {} not on this disc -- skipped", m.src),
}
}
let manifest = Manifest {
format: "sylpheed.manifest/1",
exporter: EXPORTER,
formats_rev: FORMATS_REV,
disc: disc.display().to_string(),
screens,
videos,
warnings: vec![
"P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive."
.into(),

View File

@@ -0,0 +1,111 @@
//! 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.**
///
/// `-ac 2` because the source is 6-channel WMA Pro and Godot's Theora playback
/// is not a surround path. Downmixing is a decision, so it is in the recorded
/// command where a modder can see and change it.
fn args(src: &Path, out: &Path) -> Vec<String> {
[
"-hide_banner", "-loglevel", "error", "-y",
"-i", &src.display().to_string(),
"-c:v", "libtheora", "-q:v", "8",
"-c:a", "libvorbis", "-q:a", "5", "-ac", "2",
&out.display().to_string(),
]
.iter()
.map(|s| s.to_string())
.collect()
}
pub struct Transcoded {
pub name: String,
pub file: String,
pub command: String,
pub why: &'static str,
}
/// 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 argv = args(&src, &ogv);
let command = format!("ffmpeg {}", argv.join(" "));
let size = std::fs::metadata(&src)?.len();
let want = format!("{command}\nsource-bytes: {size}\n");
let fresh = ogv.exists()
&& std::fs::read_to_string(&stamp).map(|s| s == want).unwrap_or(false);
if !fresh {
let status = Command::new("ffmpeg")
.args(&argv)
.status()
.context("run ffmpeg -- is it on PATH?")?;
if !status.success() {
bail!("ffmpeg failed on {}", m.src);
}
std::fs::write(&stamp, &want)?;
}
Ok(Some(Transcoded {
name: m.stem.to_string(),
file: format!("video/{}.ogv", m.stem),
command,
why: m.why,
}))
}

View File

@@ -682,3 +682,71 @@ renderer. The three known differences are unchanged: `title` 6 (paint-order tie)
carry `rotation_deg` in a future FORMAT v3 because carrying a decoded field the
renderer ignores beats dropping it, but it will not draw it until the
divergence question is settled.
---
## P4 — the intro video, 2026-08-29
### Theora at 720p is fine here, and no runtime dependency is requested
MISSION §6 anticipated that Theora might be too poor at 720p and permitted the
FFmpeg-GDExtension fallback to be **proposed**. It is not needed, and this was
measured rather than judged by eye alone. SSIM against the decoded source over a
10 s sample: **0.9863 at `-q:v 6`, 0.9896 at 8, 0.9924 at 10**. At 200 % zoom on
the reel's hardest case — fine serif text and soft gradients over near-black,
where Theora breaks first — q8 is indistinguishable from the source.
`-q:v 8`, and **no GDExtension is being proposed or adopted**.
`-ac 2` because the source is **6-channel** WMA Pro and Godot's Theora path is
not a surround one. That downmix is a decision, so it lives in the recorded
command where a modder can see and change it rather than in prose.
### The exact command is in the manifest, per MISSION §6
`export/manifest.json` gains a `videos` array, each entry carrying the verbatim
`ffmpeg` line that produced it. A modder who dislikes the quality re-runs one
line instead of reverse-engineering what was done to their video — which is the
whole reason this project converts the disc rather than reading it at runtime.
### A cache, and why that is not a hand-edit
`export/` is regenerated wholesale, but re-encoding 232 s of video on every run
costs ~4 minutes to produce a byte-identical file, and an exporter nobody re-runs
is worse than a cache. So each movie gets a `.cmd` sidecar recording the command
and the source size, and the encode is skipped only when both match exactly. Any
change to either re-encodes. This is derived state validating derived state, not
a hand-edit.
### The player renders into the design viewport, not beside it
First attempt parented the `VideoStreamPlayer` to the Boot node. It played, and
every captured frame was **black**: the capture reads the SubViewport, and the
player was rendering to the window. Worth stating as more than a capture bug —
everything this port draws composes in the export's own 1280×720 design space,
and a movie outside that space is outside the coordinate system every screen is
expressed in.
### Ⓐ skips, because Q9 measured it
The only input the port handles so far. HANDOFF Q9: one Ⓐ press skips a movie,
measured — the title was reached at 57 s against a 193 s baseline. Menu
navigation is still P5.
## P4 gate
`godot --path port -- --boot --film=…` runs
`publisher_logo → developer_logos → ADV.ogv → title`, unattended. The filmstrip
shows the SQUARE ENIX ident, then the reel's live-action-styled CG, then the
title. The movie's place in the boot is **measured, not decoded** — Q9 decodes
`ADVERTISE_MOVIE → ADV.wmv` from the movie manifest, but *where it sits in the
boot order* is what the RE agent watched, and `authored/flow.json` says so.
### What I cannot verify from here
**Audible playback.** This container has no audio device — Godot falls back to
the dummy driver. What is verified is that the Vorbis stream exists in the
transcode, is 2-channel, and decodes. Whether Godot emits it audibly is
unconfirmed and is stated as unconfirmed rather than assumed from the stream's
presence. It is a cheap check for anyone with a sound device and an impossible
one here.

View File

@@ -45,10 +45,13 @@ func _ready() -> void:
get_tree().quit(2)
return
for step: Dictionary in _flow["boot"]:
_sequence.append(String(step["screen"]))
_sequence.append(step)
_film = args.get("film", "")
var name: String = _sequence[0] if not _sequence.is_empty() else args.get("screen", DEFAULT_SCREEN)
var name: String = String(_sequence[0].get("screen", "")) if not _sequence.is_empty() \
else args.get("screen", DEFAULT_SCREEN)
if name == "":
name = DEFAULT_SCREEN # the sequence opens on a video; load something to size the viewport
var screen: Dictionary = export_tree.screen(name)
if screen.is_empty():
push_error(export_tree.error)
@@ -117,7 +120,8 @@ func _ready() -> void:
var _frozen := false
var _flow: Variant = null
var _sequence: Array[String] = []
var _sequence: Array[Dictionary] = []
var _player: VideoStreamPlayer = null
var _step := 0
var _film := ""
var _film_frame := 0
@@ -133,7 +137,7 @@ func _process(delta: float) -> void:
_elapsed += delta
view.queue_redraw()
if _sequence.is_empty():
if _sequence.is_empty() or _player != null:
return
# A screen holds at `rest` until it has arrived, then plays itself out and
@@ -158,15 +162,75 @@ func _process(delta: float) -> void:
func _advance() -> void:
_step += 1
var next := _sequence[_step]
print(" -> %s at %.2f s" % [next, _elapsed])
var next: Dictionary = _sequence[_step]
if next.has("video"):
_play_video(String(next["video"]), bool(next.get("skippable", false)))
return
var name := String(next["screen"])
print(" -> %s at %.2f s" % [name, _elapsed])
view.holding = true
view.time_units = 0.0
if not view.load_screen(view.tree, next):
if not view.load_screen(view.tree, name):
push_error(view.tree.error)
get_tree().quit(2)
## Play one transcoded movie, full-bleed over the screen.
##
## The port never reads WMV: the exporter transcoded this to Ogg Theora and
## recorded the exact ffmpeg command in the manifest (MISSION §6), so a modder
## who dislikes the quality re-runs one line.
func _play_video(name: String, skippable: bool) -> void:
var v := view.tree.video(name)
if v.is_empty():
push_error(view.tree.error)
get_tree().quit(2)
return
print(" -> video %s at %.2f s (%s)" % [name, _elapsed, v["path"]])
var stream := VideoStreamTheora.new()
stream.file = v["path"]
_player = VideoStreamPlayer.new()
_player.stream = stream
_player.expand = true
_player.set_anchors_preset(Control.PRESET_FULL_RECT)
# Into the SubViewport, not beside it. Everything this port draws composes in
# the export's own 1280x720 design space; a player parented to the Boot node
# renders to the window instead and is invisible to `--capture`, which reads
# the SubViewport. That is not only a capture artefact -- it would also put
# the movie outside the space every screen coordinate is expressed in.
viewport.add_child(_player)
_skippable = skippable
# `play()` needs the node in the tree; calling it before that is an error
# the engine reports and then ignores, which looks like a video that simply
# never starts.
await get_tree().process_frame
_player.finished.connect(_video_finished)
_player.play()
var _skippable := false
func _video_finished() -> void:
print(" video ended at %.2f s" % _elapsed)
_player.queue_free()
_player = null
_advance()
func _unhandled_input(event: InputEvent) -> void:
# HANDOFF Q9, measured: one (A) press skips a movie -- the title was reached
# at 57 s against a 193 s baseline. This is the only input the port handles
# so far; menu navigation is P5.
if _player == null or not _skippable:
return
if event.is_action_pressed("ui_accept") or event.is_action_pressed("ui_cancel"):
print(" video skipped at %.2f s" % _elapsed)
_player.stop()
_video_finished()
func _capture(path: String) -> void:
# Two frames: the first is the one this callback is still inside of.
await RenderingServer.frame_post_draw

View File

@@ -83,6 +83,20 @@ func screen(name: String) -> Dictionary:
return {}
# A transcoded movie, addressed by manifest name. The port never reads WMV --
# the exporter emits Ogg Theora, which Godot plays natively (MISSION §2, §6).
func video(name: String) -> Dictionary:
for entry: Dictionary in manifest().get("videos", []):
if entry.get("name") == name:
var path := root.path_join(entry["file"])
if not FileAccess.file_exists(path):
error = "manifest lists %s but %s is not there" % [name, path]
return {}
return {"path": path, "command": entry.get("command", "")}
error = "no video named %s in manifest.json" % name
return {}
func screen_names() -> PackedStringArray:
var names := PackedStringArray()
for entry: Dictionary in manifest().get("screens", []):