From 579c8096c14d1b17a419e1482836e5df74838e2a Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Sat, 29 Aug 2026 08:44:25 +0000 Subject: [PATCH] port: P4 -- the intro video plays inside the boot sequence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- authored/flow.json | 12 +-- crates/sylpheed-export/src/main.rs | 31 ++++++++ crates/sylpheed-export/src/video.rs | 111 ++++++++++++++++++++++++++++ docs/DECISIONS.md | 68 +++++++++++++++++ port/scripts/boot.gd | 78 +++++++++++++++++-- port/scripts/export_tree.gd | 14 ++++ 6 files changed, 302 insertions(+), 12 deletions(-) create mode 100644 crates/sylpheed-export/src/video.rs diff --git a/authored/flow.json b/authored/flow.json index 3575f4e..5025fa5 100644 --- a/authored/flow.json +++ b/authored/flow.json @@ -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", diff --git a/crates/sylpheed-export/src/main.rs b/crates/sylpheed-export/src/main.rs index 5e5778b..7817ace 100644 --- a/crates/sylpheed-export/src/main.rs +++ b/crates/sylpheed-export/src/main.rs @@ -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, } +#[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, + #[serde(skip_serializing_if = "Vec::is_empty")] + videos: Vec, warnings: Vec, } @@ -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(), diff --git a/crates/sylpheed-export/src/video.rs b/crates/sylpheed-export/src/video.rs new file mode 100644 index 0000000..fa58052 --- /dev/null +++ b/crates/sylpheed-export/src/video.rs @@ -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 { + [ + "-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> { + 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, + })) +} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 16d7a2b..720114c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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. diff --git a/port/scripts/boot.gd b/port/scripts/boot.gd index 61c3b19..8020b78 100644 --- a/port/scripts/boot.gd +++ b/port/scripts/boot.gd @@ -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 diff --git a/port/scripts/export_tree.gd b/port/scripts/export_tree.gd index 1a6c615..1929053 100644 --- a/port/scripts/export_tree.gd +++ b/port/scripts/export_tree.gd @@ -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", []):