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", "format": "sylpheed.flow/1",
"_": [ "_": [
"The boot sequence. AUTHORED, and it has to be: HANDOFF Q6 closed this with a", "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", "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", "agent watched the game do, not what any file on the disc says it does. Nothing",
"here may be presented as decoded." "here may be presented as decoded."
], ],
"boot": [ "boot": [
{ {
"screen": "publisher_logo", "screen": "publisher_logo",
@@ -23,12 +21,17 @@
"screen": "developer_logos", "screen": "developer_logos",
"why": "GAME ARTS / SETA / studio anima, after the publisher wordmark. HANDOFF Q2." "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", "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": { "dwell": {
"_": [ "_": [
"DELIBERATELY EMPTY. Each screen's dwell is its own keyframe group -- the", "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." "When a capture times the real boot, the extra hold per screen goes here."
] ]
}, },
"screens": { "screens": {
"_": [ "_": [
"What each button does. NOT FILLED IN -- that is P5. HANDOFF Q4 measured the", "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. //! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope.
mod check; mod check;
mod video;
mod screen; mod screen;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@@ -67,6 +68,16 @@ struct ManifestScreen {
missing_sprites: Vec<String>, 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)] #[derive(Serialize)]
struct Manifest { struct Manifest {
format: &'static str, format: &'static str,
@@ -75,6 +86,8 @@ struct Manifest {
formats_rev: &'static str, formats_rev: &'static str,
disc: String, disc: String,
screens: Vec<ManifestScreen>, screens: Vec<ManifestScreen>,
#[serde(skip_serializing_if = "Vec::is_empty")]
videos: Vec<ManifestVideo>,
warnings: Vec<String>, 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 { let manifest = Manifest {
format: "sylpheed.manifest/1", format: "sylpheed.manifest/1",
exporter: EXPORTER, exporter: EXPORTER,
formats_rev: FORMATS_REV, formats_rev: FORMATS_REV,
disc: disc.display().to_string(), disc: disc.display().to_string(),
screens, screens,
videos,
warnings: vec![ warnings: vec![
"P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive." "P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive."
.into(), .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 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 renderer ignores beats dropping it, but it will not draw it until the
divergence question is settled. 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) get_tree().quit(2)
return return
for step: Dictionary in _flow["boot"]: for step: Dictionary in _flow["boot"]:
_sequence.append(String(step["screen"])) _sequence.append(step)
_film = args.get("film", "") _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) var screen: Dictionary = export_tree.screen(name)
if screen.is_empty(): if screen.is_empty():
push_error(export_tree.error) push_error(export_tree.error)
@@ -117,7 +120,8 @@ func _ready() -> void:
var _frozen := false var _frozen := false
var _flow: Variant = null var _flow: Variant = null
var _sequence: Array[String] = [] var _sequence: Array[Dictionary] = []
var _player: VideoStreamPlayer = null
var _step := 0 var _step := 0
var _film := "" var _film := ""
var _film_frame := 0 var _film_frame := 0
@@ -133,7 +137,7 @@ func _process(delta: float) -> void:
_elapsed += delta _elapsed += delta
view.queue_redraw() view.queue_redraw()
if _sequence.is_empty(): if _sequence.is_empty() or _player != null:
return return
# A screen holds at `rest` until it has arrived, then plays itself out and # 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: func _advance() -> void:
_step += 1 _step += 1
var next := _sequence[_step] var next: Dictionary = _sequence[_step]
print(" -> %s at %.2f s" % [next, _elapsed]) 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.holding = true
view.time_units = 0.0 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) push_error(view.tree.error)
get_tree().quit(2) 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: func _capture(path: String) -> void:
# Two frames: the first is the one this callback is still inside of. # Two frames: the first is the one this callback is still inside of.
await RenderingServer.frame_post_draw await RenderingServer.frame_post_draw

View File

@@ -83,6 +83,20 @@ func screen(name: String) -> Dictionary:
return {} 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: func screen_names() -> PackedStringArray:
var names := PackedStringArray() var names := PackedStringArray()
for entry: Dictionary in manifest().get("screens", []): for entry: Dictionary in manifest().get("screens", []):