merge the Godot port's history into the monorepo
Brought in with a subtree merge rather than a copy, so the port's 31 commits survive as history rather than arriving as one anonymous import. Landed under godot-import/ and moved into the final layout in the next commit, which keeps git's rename detection able to follow each file across the move.
This commit is contained in:
12
godot-import/.gitignore
vendored
Normal file
12
godot-import/.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# Generated from the user's own disc. This repo stays clean-room: code,
|
||||
# schemas, authored mappings and docs only -- never game assets.
|
||||
/export/
|
||||
|
||||
# Rust
|
||||
/target/
|
||||
**/*.rs.bk
|
||||
|
||||
# Godot
|
||||
.godot/
|
||||
/port/.godot/
|
||||
*.import
|
||||
1217
godot-import/Cargo.lock
generated
Normal file
1217
godot-import/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
godot-import/Cargo.toml
Normal file
8
godot-import/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/sylpheed-export"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
75
godot-import/README.md
Normal file
75
godot-import/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Sylpheed Godot
|
||||
|
||||
A clean-room Godot 4 port of *Project Sylpheed: Arc of Deception*, starting with
|
||||
the menu shell: developer splash → intro video → title → main menu → submenus.
|
||||
|
||||
**You need your own copy of the game.** Nothing in this repository is game
|
||||
content. An offline exporter reads the disc you supply and writes an open,
|
||||
moddable asset tree; the Godot project reads only that tree and never touches a
|
||||
disc format.
|
||||
|
||||
```
|
||||
your disc ──▶ crates/sylpheed-export ──▶ export/ ──▶ port/ (Godot 4)
|
||||
(Rust; decoders come from JSON + PNG reads ONLY
|
||||
sylpheed-formats) + OGG + OGV open formats
|
||||
```
|
||||
|
||||
## Why the wall
|
||||
|
||||
Two reasons, and the second is the interesting one:
|
||||
|
||||
1. Godot cannot read IPFB archives, RATC bundles, T8aD textures, XMA banks or
|
||||
WMV video, and it should not learn to.
|
||||
2. **Modding is a goal of this port.** If the runtime reads the original formats,
|
||||
modding means reverse engineering. If it reads JSON and PNG, modding means
|
||||
opening a file.
|
||||
|
||||
## Where the knowledge comes from
|
||||
|
||||
The decoders live in [`sylpheed-formats`][formats], pinned by revision — a
|
||||
separate project, where the reverse engineering happens. Its
|
||||
`docs/port/HANDOFF.md` is the contract: what has been decoded, what was measured
|
||||
off the running game, and what is known to be undecodable. Read it before
|
||||
assuming a value is on the disc.
|
||||
|
||||
[formats]: https://git.mc02.dev/fabi/Syplheed-Reborn
|
||||
|
||||
## Layout
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `crates/sylpheed-export/` | disc → open formats. Regenerates `export/` wholesale |
|
||||
| `port/` | the Godot 4 project |
|
||||
| `authored/` | decisions that are **not** on the disc, each with its reason |
|
||||
| `export/` | generated, gitignored, never hand-edited |
|
||||
| `tools/` | verification harnesses that hold the port to the reference renderer |
|
||||
| `docs/` | the mission, the format spec, the agent's loop prompt |
|
||||
|
||||
## Verifying
|
||||
|
||||
**The oracle is the Xenia Canary capture and the game**, not either renderer.
|
||||
`sylpheed-cli screen render` is an explorer and extraction CLI for verifying
|
||||
decodes, and it can be wrong -- three times both it and the port agreed and both
|
||||
were wrong, each caught only by a capture.
|
||||
|
||||
So `tools/verify-screen` is a **consistency check and a regression detector**,
|
||||
not a grade. It draws every exported screen both ways -- built from the same
|
||||
`sylpheed-formats` revision the exporter is pinned to -- and reports the largest
|
||||
per-channel difference in the frame:
|
||||
|
||||
```
|
||||
tools/verify-screen # every screen in the manifest
|
||||
tools/verify-screen main_menu # one of them
|
||||
```
|
||||
|
||||
A difference means the two moved apart; `docs/DECISIONS.md` says which one moved
|
||||
and why, rather than tuning the port until the number goes down. Correctness is
|
||||
checked against the captures indexed at `docs/re/captures/ORACLE-CAPTURES.md` --
|
||||
mind that they are not gamma-neutral, so RMSE against them has a floor.
|
||||
|
||||
## Status
|
||||
|
||||
**P1.** The exporter writes `GP_TITLE`'s twelve screen builds and their sprites,
|
||||
and the Godot project draws any of them statically at 1280x720 from that tree
|
||||
alone. `main_menu` matches the reference renderer to within 3/255 on every
|
||||
channel of every pixel. Next: P2, keyframe animation.
|
||||
12
godot-import/authored/README.md
Normal file
12
godot-import/authored/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Authored decisions
|
||||
|
||||
Everything here is a decision **we** made, not something the disc said. It is
|
||||
hand-written, committed, and survives a re-export — unlike `export/`, which is
|
||||
regenerated wholesale and must never be hand-edited.
|
||||
|
||||
Every entry carries a `why`. When the RE agent decodes the real answer, **delete
|
||||
the entry** and let the exporter emit it; that deletion is the measure of
|
||||
progress.
|
||||
|
||||
See `docs/FORMAT.md` for the schemas and `docs/BLOCKED.md` for which HANDOFF
|
||||
question each placeholder is standing in for.
|
||||
55
godot-import/authored/flow.json
Normal file
55
godot-import/authored/flow.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"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",
|
||||
"not in config.ini's empty [SYSTEM], not in the movie manifest (which carries",
|
||||
"assets, not transitions), not in a persistent GamePart field (the requested id",
|
||||
"lives only as a stack argument in flight), and `GP_ADVERTISE_DEMO` has zero",
|
||||
"xrefs of any kind. A transition is a call with a name argument, chosen by code.",
|
||||
"",
|
||||
"So this file REPRODUCES AN OBSERVATION. The sequence below is what the RE",
|
||||
"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",
|
||||
"why": "The SQUARE ENIX wordmark is the first thing the boot shows -- RE agent, 2026-08-29. Entry 10 of the pair; 13 is its region twin and the port shows one, not both."
|
||||
},
|
||||
{
|
||||
"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 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",
|
||||
"publisher wordmark reaches its hold at t=235 (3.92 s) and the developer",
|
||||
"logos at t=190 (3.17 s), both read from the disc. Holding beyond that would",
|
||||
"be a number nobody has measured, so the sequencer holds for zero extra time",
|
||||
"and the pacing is the disc's own.",
|
||||
"",
|
||||
"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",
|
||||
"destination screens and the RE agent later decoded that a transition is a",
|
||||
"lookup by NAME, giving a candidate vocabulary (TITLE_SCREEN, TITLE_MENU,",
|
||||
"LOADING, DIFFICULTY, EXTRA_MENU, TUTORIAL_MENU). Those are the right `goto`",
|
||||
"targets when this is written, marked as the name match they are."
|
||||
]
|
||||
}
|
||||
}
|
||||
94
godot-import/authored/screen_names.json
Normal file
94
godot-import/authored/screen_names.json
Normal file
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"format": "sylpheed.screen_names/1",
|
||||
"_": [
|
||||
"Which GP_TITLE pak entry is which screen. AUTHORED: the disc does not name its",
|
||||
"builds, so every name here is a decision. The identifications come from",
|
||||
"HANDOFF Q2 (ui-title-build-map.md), which measured them against framebuffer",
|
||||
"captures of the running game; the exporter stamps the name into the screen",
|
||||
"file with name_source: \"authored\" so a reader can tell a recovered name from",
|
||||
"an invented one.",
|
||||
"",
|
||||
"KEYED BY PAK ENTRY INDEX, not by the enumeration ordinal. It used to be the",
|
||||
"ordinal; widening the enumeration to reach the splash renumbers ordinals, and",
|
||||
"a name that moves when the enumeration rule changes is not a name. The entry",
|
||||
"was always described here as the stronger locator -- now it is the only",
|
||||
"stable one.",
|
||||
"",
|
||||
"Delete an entry here the day the RE agent decodes a name field."
|
||||
],
|
||||
"archives": {
|
||||
"dat/GP_TITLE.pak": {
|
||||
"2": {
|
||||
"name": "press_start",
|
||||
"why": "HANDOFF Q2: builds 2/3 are the PRESS (A) BUTTON plate -- a build of its own, composited over the title and faded in a beat later. English of the EN/JP pair. Measured against a live capture."
|
||||
},
|
||||
"3": {
|
||||
"name": "press_start_jp",
|
||||
"why": "HANDOFF Q2: the Japanese twin of build 2. Out of scope for this milestone; named so it is not mistaken for a screen we need."
|
||||
},
|
||||
"4": {
|
||||
"name": "title",
|
||||
"why": "HANDOFF Q2: build 4 is the English title art. Measured against a live capture."
|
||||
},
|
||||
"5": {
|
||||
"name": "main_menu",
|
||||
"why": "HANDOFF Q2: builds 5/8 are the five-button main menu; 5 is English. Measured against a live capture. (An earlier reading called 8 a submenu and was withdrawn -- 8 is the Japanese main menu.)"
|
||||
},
|
||||
"6": {
|
||||
"name": "extras",
|
||||
"why": "HANDOFF Q2: builds 6/9 are the EXTRAS submenu, the only submenu inside this archive. Measured against a fresh EXTRAS capture."
|
||||
},
|
||||
"7": {
|
||||
"name": "title_jp",
|
||||
"why": "HANDOFF Q2: the Japanese twin of build 4."
|
||||
},
|
||||
"8": {
|
||||
"name": "main_menu_jp",
|
||||
"why": "HANDOFF Q2: the Japanese twin of build 5."
|
||||
},
|
||||
"9": {
|
||||
"name": "extras_jp",
|
||||
"why": "HANDOFF Q2: the Japanese twin of build 6."
|
||||
},
|
||||
"10": {
|
||||
"name": "publisher_logo",
|
||||
"why": "The SQUARE ENIX PUBLISHER wordmark -- the FIRST thing the boot sequence shows, before the developer logos. Measured by the RE agent 2026-08-29, render grid at docs/re/captures/title-builds/splash-both-halves-rendered.png. Entries 10/13 are region twins distinguished by the trademark glyph; 10 carries the (TM)."
|
||||
},
|
||||
"13": {
|
||||
"name": "publisher_logo_r",
|
||||
"why": "The region twin of entry 10, carrying (R) where 10 carries (TM). Named so it is not mistaken for a second screen the boot path needs."
|
||||
},
|
||||
"11": {
|
||||
"name": "developer_logos",
|
||||
"why": "The GAME ARTS / SETA / studio anima logos -- the developer splash, shown after the publisher wordmark. HANDOFF Q2 and the RE agent's 2026-08-29 render grid; draws 7/7 elements."
|
||||
},
|
||||
"14": {
|
||||
"name": "developer_logos_r",
|
||||
"why": "The region twin of entry 11, as 13 is to 10."
|
||||
}
|
||||
}
|
||||
},
|
||||
"unnamed": {
|
||||
"dat/GP_TITLE.pak": "Entries 0/1 and 12/15 are plates never seen running -- not in the boot path, not on any title-side screen, not in the attract loop (HANDOFF Q2). They export under their entry index rather than a name we would be inventing."
|
||||
},
|
||||
"also_export": {
|
||||
"dat/GP_TITLE.pak": {
|
||||
"10": {
|
||||
"name": "publisher_logo",
|
||||
"why": "LOCATED BY ENTRY INDEX, not by a rule. These four bundles declare their sprites directly and have no .rat layout child, so `is_build` cannot see them -- and the RE agent established that NO content rule can: design size fails (every extra composable bundle sampled is 1280x720, the same as every screen) and element count fails (fragments run 2..15 elements in GP_OPTIONS/GP_SAVE_LOAD while these are 3 and 7 -- the ranges overlap). Safe here and not in general: in GP_TITLE the widened set adds exactly these four and all four are real screens, zero fragments."
|
||||
},
|
||||
"11": {
|
||||
"name": "developer_logos",
|
||||
"why": "As entry 10: located by index because no content rule distinguishes a splash from a fragment. 7 elements, all drawn."
|
||||
},
|
||||
"13": {
|
||||
"name": "publisher_logo_r",
|
||||
"why": "As entry 10, region twin."
|
||||
},
|
||||
"14": {
|
||||
"name": "developer_logos_r",
|
||||
"why": "As entry 11, region twin."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
godot-import/authored/timing.json
Normal file
59
godot-import/authored/timing.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"format": "sylpheed.timing/1",
|
||||
"keyframe_units_per_second": 60,
|
||||
"why": [
|
||||
"HANDOFF Q1. The disc says a keyframe is at `t=30`; it does not say what a",
|
||||
"`t` is. The unit was MEASURED off the running game, not decoded: a declared",
|
||||
"15-unit fade lands on round(255*k/15) for all seven of its samples with k",
|
||||
"stepping 2,4,6,8,10,12,14 on seven consecutive submitted frames -- so 2",
|
||||
"units per rendered frame -- and the idle title presents at 28.3-28.8 fps,",
|
||||
"a 30 Hz game, giving 60 units per second. A second line agrees: the",
|
||||
"transition quad is declared black for 12 units, and a capture measured the",
|
||||
"pure-black plateau at 0.17-0.23 s, where 12/60 = 0.20 s.",
|
||||
"",
|
||||
"Expressed as units-per-second rather than seconds-per-unit so the value is",
|
||||
"exact rather than a repeating decimal a reader has to recognise.",
|
||||
"",
|
||||
"DELETE THIS FILE when a field on the disc is found that states the unit.",
|
||||
"Nothing here is on the disc."
|
||||
],
|
||||
"kind": "measured",
|
||||
"source": "/reborn docs/port/HANDOFF.md Q1, docs/re/ui-keyframe-time-unit.md",
|
||||
"ramp": "linear",
|
||||
"ramp_why": [
|
||||
"Also HANDOFF Q1, and part of the same measurement: the fade lands on the",
|
||||
"linear value at every one of the seven sampled frames, so there is no ease."
|
||||
],
|
||||
"exit_ramp_seconds": 0.4,
|
||||
"exit_ramp_why": [
|
||||
"HANDOFF Q7 + the RE agent's 2026-08-29 answer. MEASURED, not on the disc.",
|
||||
"",
|
||||
"Every element of a screen ends on exactly ONE untimed keyframe, so there is",
|
||||
"exactly one unknown duration per screen -- the ramp INTO that final keyframe.",
|
||||
"This is that duration. ~0.4 s, which is 24 units at 60 units/s.",
|
||||
"",
|
||||
"The alternative readings were tested and refuted. It is not a black quad laid",
|
||||
"over a frozen screen: under that model a black rect scales every region by the",
|
||||
"same 1-alpha, so the button-region / background-region brightness RATIO would",
|
||||
"be constant through the fade. Measured on the RE agent's filmstrip it falls",
|
||||
"6.495 -> 5.574 -> 3.105 -> 2.125 -> 1.935, a 3.4x monotonic drop. The screen",
|
||||
"itself plays out: pteff00.prm ramps to opaque black while the button labels,",
|
||||
"ptmsg, pteff10 and pteff12 all ramp to transparent, and ptframe1/2 hold.",
|
||||
"",
|
||||
"REACH, quoted from the RE agent rather than smoothed over: the filmstrip is",
|
||||
"downsampled and the button region contains some background, so this pins the",
|
||||
"DIRECTION, not 0.4 s to +/-0.05 s, and it is one transition pair. Treat the",
|
||||
"number as approximate and the model as established."
|
||||
],
|
||||
"exit_ramp_units": 24,
|
||||
"dwell_seconds": null,
|
||||
"dwell_why": [
|
||||
"NOT SET, and not needed. A screen's dwell is its OWN keyframe group: the",
|
||||
"publisher wordmark reaches its hold at t=235 (3.92 s) and the developer logos",
|
||||
"at t=190 (3.17 s), both read from the disc. Adding a hold on top of that would",
|
||||
"be inventing a number nobody measured, so the sequencer holds for zero extra",
|
||||
"time and the pacing you see is the disc's own.",
|
||||
"",
|
||||
"If a capture ever times the real boot, this is where that number goes."
|
||||
]
|
||||
}
|
||||
58
godot-import/crates/sylpheed-export/Cargo.toml
Normal file
58
godot-import/crates/sylpheed-export/Cargo.toml
Normal file
@@ -0,0 +1,58 @@
|
||||
[package]
|
||||
name = "sylpheed-export"
|
||||
description = "Convert a Project Sylpheed disc into the open asset tree the Godot port reads"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
# The decoders, PINNED BY REVISION. Not vendored and not reimplemented: they are
|
||||
# disc-wide verified in their own repository, and floating the pin would let a
|
||||
# decoder change land mid-milestone -- exactly the confusion this prevents.
|
||||
#
|
||||
# `sylpheed_formats::media` in particular owns the cases where one playable thing
|
||||
# is not one archive entry (segment-spanning reads, multi-sub-wave banks, and the
|
||||
# continuous cutscene-voice stream). Do not re-derive those here.
|
||||
#
|
||||
# Pin moved f817dd5 -> 7eeae30 on 2026-08-29. WHAT I WANTED FROM IT: `UiBuild`
|
||||
# gained a public `records` map (record name -> the nested `.rat` leaf's byte
|
||||
# range). Without it a consumer could not locate a leaf at all: `parse_build`
|
||||
# sorted T8aD children into `sprites` and `.rat` children into a PRIVATE map, so
|
||||
# the focus ring -- which lives inside `ptbtn0Nf.rat`, a record the parent
|
||||
# bundle declares no element for -- was unreachable through the public API.
|
||||
# Also brings `Keyframe::rotation_deg`, which the game does render.
|
||||
#
|
||||
# PINNED BY TAG, not by sha, and MISSION §2 now requires it. The reachability
|
||||
# risk this comment used to warn about is closed: a sha reachable only from an
|
||||
# `auto/*` branch is orphaned when that branch is deleted or -- worse --
|
||||
# SQUASH-MERGED, because squash creates new commits, so `main` would look like
|
||||
# it contained the work while this pin became unreachable. A tag is a permanent
|
||||
# ref, it says what it is here in the file, and it fails loudly at FETCH rather
|
||||
# than silently at build. `formats-pin-2026-08-29` is 7eeae30.
|
||||
#
|
||||
# Previous pin note, kept because the reasoning still holds:
|
||||
# Pin moved 5414db3 -> f817dd5 on 2026-08-29. WHAT I WANTED FROM IT: `56cc7ac`,
|
||||
# "a RATC child's name is stated, not inferred". A child was named by scanning
|
||||
# backwards for the last printable run before its magic; for `pteff05.t32` the
|
||||
# three trailing payload bytes are `38 41 58` = `8AX` and beat the real name, so
|
||||
# the FULL-RESOLUTION BACKGROUND OF ALL FIVE MENU SCREENS registered under a
|
||||
# name no element declares and resolved to no sprite. This port exported those
|
||||
# screens without their background and said so in every render as "pteff05 (no
|
||||
# sprite in the export)" -- which docs/DECISIONS.md then wrote up as correct.
|
||||
# It was not. f817dd5 is the last commit touching `crates/` on that branch.
|
||||
#
|
||||
# Previous pin note, kept because the reasoning still holds:
|
||||
# Pin moved 8b6dbcf -> 5414db3 on 2026-08-28. WHAT I WANTED FROM IT: the fix to
|
||||
# `ui_layout::rest()`. At 8b6dbcf a trailing run of identical keyframes was
|
||||
# always treated as the exit, so an element with no exit animation rested at its
|
||||
# invisible pre-roll -- `ptframe1`/`ptframe2`, the main menu's circuit bracket,
|
||||
# which a capture of the running game plainly shows. 5414db3 is the revision at
|
||||
# which that fix carries its disc-wide check (30 of 13 991 elements move, 4
|
||||
# become visible, 0 become invisible), not merely the one where it was written.
|
||||
sylpheed-formats = { git = "https://git.mc02.dev/fabi/Syplheed-Reborn.git", tag = "formats-pin-2026-08-29" }
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
277
godot-import/crates/sylpheed-export/src/check.rs
Normal file
277
godot-import/crates/sylpheed-export/src/check.rs
Normal file
@@ -0,0 +1,277 @@
|
||||
//! `sylpheed-export check` — validate an export tree against `docs/FORMAT.md`.
|
||||
//!
|
||||
//! This is the executable form of FORMAT.md, and the reason it exists is that
|
||||
//! "the export is correct" is otherwise an assertion. It reads `export/` the way
|
||||
//! the Godot project will — as a stranger, with no access to the disc, the
|
||||
//! decoders or this exporter's internals — and fails on anything a consumer
|
||||
//! could not act on:
|
||||
//!
|
||||
//! * a document whose `format` is not the version this build writes;
|
||||
//! * a required field missing, or a colour that is not `0x` + 8 hex digits;
|
||||
//! * a `paint_order` that is not a permutation of the element indices;
|
||||
//! * 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.
|
||||
//!
|
||||
//! It deliberately does **not** check that the export matches the disc. That is
|
||||
//! what `sylpheed-cli screen render` is for.
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
|
||||
const SCREEN_FORMAT: &str = "sylpheed.screen/3";
|
||||
const MANIFEST_FORMAT: &str = "sylpheed.manifest/1";
|
||||
|
||||
struct Ctx {
|
||||
file: String,
|
||||
errors: Vec<String>,
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
fn err(&mut self, msg: impl Into<String>) {
|
||||
self.errors.push(format!("{}: {}", self.file, msg.into()));
|
||||
}
|
||||
fn require<'a>(&mut self, v: &'a Value, key: &str) -> Option<&'a Value> {
|
||||
match v.get(key) {
|
||||
Some(Value::Null) | None => {
|
||||
self.err(format!("missing required field `{key}`"));
|
||||
None
|
||||
}
|
||||
Some(x) => Some(x),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour is exported as `0x` + 8 hex digits, with its byte order in the key
|
||||
/// name. Anything else means a consumer has to guess, which is the whole thing
|
||||
/// the format exists to prevent.
|
||||
fn is_hex32(v: Option<&Value>) -> bool {
|
||||
v.and_then(Value::as_str)
|
||||
.is_some_and(|s| s.len() == 10 && s.starts_with("0x") && s[2..].chars().all(|c| c.is_ascii_hexdigit()))
|
||||
}
|
||||
|
||||
fn check_pose(c: &mut Ctx, where_: &str, p: &Value) {
|
||||
for (key, want_len) in [("pos", 2usize), ("scale", 2)] {
|
||||
match p.get(key).and_then(Value::as_array) {
|
||||
Some(a) if a.len() == want_len && a.iter().all(Value::is_i64) => {}
|
||||
_ => c.err(format!("{where_}: `{key}` must be {want_len} integers")),
|
||||
}
|
||||
}
|
||||
for key in ["tint_rgba", "fade_argb"] {
|
||||
if !is_hex32(p.get(key)) {
|
||||
c.err(format!("{where_}: `{key}` must be 0x + 8 hex digits"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()> {
|
||||
let raw = std::fs::read_to_string(root.join(rel))?;
|
||||
let v: Value = serde_json::from_str(&raw)?;
|
||||
let mut c = Ctx {
|
||||
file: rel.to_string(),
|
||||
errors: Vec::new(),
|
||||
};
|
||||
|
||||
if v.get("format").and_then(Value::as_str) != Some(SCREEN_FORMAT) {
|
||||
c.err(format!(
|
||||
"format is {:?}, expected {SCREEN_FORMAT:?}",
|
||||
v.get("format")
|
||||
));
|
||||
}
|
||||
for key in ["exporter", "formats_rev", "name", "name_source"] {
|
||||
c.require(&v, key);
|
||||
}
|
||||
// Rule 2 of the format: a modder must be able to tell a recovered name from
|
||||
// an invented one, so the provenance is mandatory and closed.
|
||||
match v.get("name_source").and_then(Value::as_str) {
|
||||
Some("authored") => {
|
||||
if v.get("name_why").and_then(Value::as_str).is_none_or(str::is_empty) {
|
||||
c.err("name_source is `authored` but there is no `name_why`");
|
||||
}
|
||||
}
|
||||
Some("index") => {}
|
||||
other => c.err(format!("name_source must be `authored` or `index`, got {other:?}")),
|
||||
}
|
||||
if let Some(s) = v.get("source") {
|
||||
for key in ["archive", "entry", "build"] {
|
||||
if s.get(key).is_none() {
|
||||
c.err(format!("source is missing `{key}`"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
c.err("missing required field `source`");
|
||||
}
|
||||
match v.get("design").and_then(Value::as_array) {
|
||||
Some(d) if d.len() == 2 && d.iter().all(Value::is_u64) => {}
|
||||
_ => c.err("`design` must be two positive integers"),
|
||||
}
|
||||
|
||||
let Some(elements) = v.get("elements").and_then(Value::as_array) else {
|
||||
c.err("missing required field `elements`");
|
||||
errors.append(&mut c.errors);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut indices = Vec::new();
|
||||
let mut buttons_by_y: Vec<(i64, String)> = Vec::new();
|
||||
for (i, el) in elements.iter().enumerate() {
|
||||
let id = el.get("id").and_then(Value::as_str).unwrap_or("<no id>").to_string();
|
||||
let at = format!("element {i} ({id})");
|
||||
for key in ["index", "id", "declared", "role", "kind_raw", "pivot", "layer_source", "keyframes"] {
|
||||
if el.get(key).is_none() {
|
||||
c.err(format!("{at}: missing `{key}`"));
|
||||
}
|
||||
}
|
||||
let Some(idx) = el.get("index").and_then(Value::as_u64) else {
|
||||
c.err(format!("{at}: `index` is not an integer"));
|
||||
continue;
|
||||
};
|
||||
if idx as usize != i {
|
||||
c.err(format!("{at}: `index` {idx} does not match its position {i}"));
|
||||
}
|
||||
indices.push(idx as usize);
|
||||
|
||||
let role = el.get("role").and_then(Value::as_str).unwrap_or("");
|
||||
if !matches!(role, "button" | "decoration" | "primitive" | "unknown") {
|
||||
c.err(format!("{at}: role {role:?} is not one FORMAT.md defines"));
|
||||
}
|
||||
// A role of `unknown` must still carry the raw kind, or the information
|
||||
// is simply lost.
|
||||
if role == "unknown" && el.get("kind_raw").is_none() {
|
||||
c.err(format!("{at}: role `unknown` without `kind_raw`"));
|
||||
}
|
||||
// A primitive has no texture, so its quad size has to come from the file.
|
||||
if role == "primitive" && el.get("size").is_none() {
|
||||
c.err(format!("{at}: primitive without a `size`"));
|
||||
}
|
||||
|
||||
match el.get("layer_source").and_then(Value::as_str) {
|
||||
Some("sprite") | Some("implied") => {
|
||||
if !is_hex32(el.get("layer")) {
|
||||
c.err(format!("{at}: layer_source claims a key but `layer` is not one"));
|
||||
}
|
||||
}
|
||||
Some("none") => {
|
||||
if el.get("layer").is_some() {
|
||||
c.err(format!("{at}: layer_source `none` but a `layer` is present"));
|
||||
}
|
||||
}
|
||||
other => c.err(format!("{at}: layer_source must be sprite/implied/none, got {other:?}")),
|
||||
}
|
||||
|
||||
for key in ["sprite", "focus_sprite"] {
|
||||
if let Some(p) = el.get(key).and_then(Value::as_str) {
|
||||
let path = root.join(p);
|
||||
if !path.exists() {
|
||||
c.err(format!("{at}: `{key}` points at {p}, which does not exist"));
|
||||
} else if let Err(e) = image::open(&path) {
|
||||
c.err(format!("{at}: `{key}` {p} does not decode as an image: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(r) = el.get("rest") {
|
||||
check_pose(&mut c, &at, r);
|
||||
if role == "button" {
|
||||
if let Some(y) = r.get("pos").and_then(Value::as_array).and_then(|a| a[1].as_i64()) {
|
||||
buttons_by_y.push((y, id.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(kfs) = el.get("keyframes").and_then(Value::as_array) {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Paint order must be a permutation of the element indices, or the runtime
|
||||
// either drops an element or draws one twice.
|
||||
match v.get("paint_order").and_then(Value::as_array) {
|
||||
Some(po) => {
|
||||
let mut got: Vec<usize> = po.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect();
|
||||
if got.len() != po.len() {
|
||||
c.err("`paint_order` holds a non-integer");
|
||||
}
|
||||
let mut want = indices.clone();
|
||||
got.sort_unstable();
|
||||
want.sort_unstable();
|
||||
if got != want {
|
||||
c.err("`paint_order` is not a permutation of the element indices");
|
||||
}
|
||||
}
|
||||
None => c.err("missing required field `paint_order`"),
|
||||
}
|
||||
|
||||
// `buttons` is navigation order and is defined as resting Y, ascending. If
|
||||
// it is not sorted, it is not the thing FORMAT.md says it is.
|
||||
match v.get("buttons").and_then(Value::as_array) {
|
||||
Some(b) => {
|
||||
let listed: Vec<&str> = b.iter().filter_map(Value::as_str).collect();
|
||||
buttons_by_y.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
|
||||
let want: Vec<&str> = buttons_by_y.iter().map(|(_, n)| n.as_str()).collect();
|
||||
if listed != want {
|
||||
c.err(format!(
|
||||
"`buttons` is {listed:?} but resting-Y order is {want:?}"
|
||||
));
|
||||
}
|
||||
}
|
||||
None => c.err("missing required field `buttons`"),
|
||||
}
|
||||
|
||||
if v.get("unresolved").and_then(Value::as_array).is_none() {
|
||||
c.err("missing required field `unresolved` (an empty list is a claim; absence is a gap)");
|
||||
}
|
||||
|
||||
errors.append(&mut c.errors);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a whole export tree. Returns the number of screens checked.
|
||||
pub fn run(root: &Path) -> Result<usize> {
|
||||
let manifest_path = root.join("manifest.json");
|
||||
if !manifest_path.exists() {
|
||||
bail!("{} has no manifest.json — is that an export tree?", root.display());
|
||||
}
|
||||
let m: Value = serde_json::from_str(&std::fs::read_to_string(&manifest_path)?)?;
|
||||
let mut errors = Vec::new();
|
||||
if m.get("format").and_then(Value::as_str) != Some(MANIFEST_FORMAT) {
|
||||
errors.push(format!("manifest.json: format is not {MANIFEST_FORMAT:?}"));
|
||||
}
|
||||
for key in ["exporter", "formats_rev", "screens", "warnings"] {
|
||||
if m.get(key).is_none() {
|
||||
errors.push(format!("manifest.json: missing `{key}`"));
|
||||
}
|
||||
}
|
||||
let screens = m
|
||||
.get("screens")
|
||||
.and_then(Value::as_array)
|
||||
.map(|s| s.to_vec())
|
||||
.unwrap_or_default();
|
||||
for s in &screens {
|
||||
let Some(file) = s.get("file").and_then(Value::as_str) else {
|
||||
errors.push("manifest.json: a screen entry has no `file`".into());
|
||||
continue;
|
||||
};
|
||||
if !root.join(file).exists() {
|
||||
errors.push(format!("manifest.json: lists {file}, which does not exist"));
|
||||
continue;
|
||||
}
|
||||
check_screen(root, file, &mut errors)?;
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
for e in &errors {
|
||||
eprintln!(" ✗ {e}");
|
||||
}
|
||||
bail!("{} problem(s) in {}", errors.len(), root.display());
|
||||
}
|
||||
Ok(screens.len())
|
||||
}
|
||||
303
godot-import/crates/sylpheed-export/src/main.rs
Normal file
303
godot-import/crates/sylpheed-export/src/main.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
//! Convert a Project Sylpheed disc into the open asset tree the Godot port reads.
|
||||
//!
|
||||
//! The one rule this binary exists to enforce: **Godot never sees a disc format.**
|
||||
//! Everything proprietary is decoded here and written out as JSON, PNG, Ogg
|
||||
//! Vorbis and Ogg Theora, so the runtime — and anyone modding it — reads formats
|
||||
//! a person can open.
|
||||
//!
|
||||
//! The output tree is **derived**: regenerated wholesale, never hand-edited. The
|
||||
//! only thing this program takes from `authored/` is the screen-name map, and
|
||||
//! every name it applies is stamped `name_source: "authored"` in the file it
|
||||
//! lands in, so the export stays auditable against the disc.
|
||||
//!
|
||||
//! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope.
|
||||
|
||||
mod check;
|
||||
mod video;
|
||||
mod screen;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use sylpheed_formats::{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
|
||||
/// export auditable a month later.
|
||||
const FORMATS_REV: &str = "8b6dbcf";
|
||||
const EXPORTER: &str = concat!("sylpheed-export ", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(about, version)]
|
||||
struct Args {
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand)]
|
||||
enum Cmd {
|
||||
/// Convert the disc into `export/`. Rewrites the tree wholesale.
|
||||
Export {
|
||||
/// Extracted disc root (the directory holding `dat/` and `hidden/`).
|
||||
#[arg(long, env = "SYLPHEED_DISC")]
|
||||
disc: PathBuf,
|
||||
/// Output tree. Rewritten wholesale — never hand-edit it.
|
||||
#[arg(long, default_value = "export")]
|
||||
out: PathBuf,
|
||||
/// Authored decisions applied during export (currently the screen names).
|
||||
#[arg(long, default_value = "authored")]
|
||||
authored: PathBuf,
|
||||
},
|
||||
/// Validate an export tree against `docs/FORMAT.md`, with no disc in hand.
|
||||
///
|
||||
/// Reads the tree the way the Godot project will: as a stranger, with no
|
||||
/// access to the disc, the decoders or this exporter's internals.
|
||||
Check {
|
||||
#[arg(long, default_value = "export")]
|
||||
out: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ManifestScreen {
|
||||
name: String,
|
||||
file: String,
|
||||
sprites: usize,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
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,
|
||||
exporter: &'static str,
|
||||
/// Which decoders produced this export. Pinned by revision, not floated.
|
||||
formats_rev: &'static str,
|
||||
disc: String,
|
||||
screens: Vec<ManifestScreen>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
videos: Vec<ManifestVideo>,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The authored `pak entry index → name` map, keyed by archive path.
|
||||
///
|
||||
/// Keyed by **entry**, not by the enumeration ordinal. The file itself always
|
||||
/// called the entry "the stronger locator"; it is now also the only stable one,
|
||||
/// because widening the enumeration to reach the splash renumbers the ordinals.
|
||||
type NameMap = std::collections::BTreeMap<String, std::collections::BTreeMap<String, NameEntry>>;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct NameEntry {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
why: Option<String>,
|
||||
}
|
||||
|
||||
fn load_names(authored: &Path) -> Result<NameMap> {
|
||||
let path = authored.join("screen_names.json");
|
||||
if !path.exists() {
|
||||
return Ok(NameMap::new());
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct File {
|
||||
archives: NameMap,
|
||||
#[serde(default)]
|
||||
also_export: AlsoExport,
|
||||
}
|
||||
let raw = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("read {}", path.display()))?;
|
||||
Ok(serde_json::from_str::<File>(&raw)
|
||||
.with_context(|| format!("parse {}", path.display()))?
|
||||
.archives)
|
||||
}
|
||||
|
||||
/// Extra pak entries to export that `is_build` does not accept, keyed by
|
||||
/// archive. AUTHORED, and each carries its own `why`.
|
||||
type AlsoExport =
|
||||
std::collections::BTreeMap<String, std::collections::BTreeMap<String, NameEntry>>;
|
||||
|
||||
fn load_also_export(authored: &Path) -> Result<AlsoExport> {
|
||||
let path = authored.join("screen_names.json");
|
||||
if !path.exists() {
|
||||
return Ok(AlsoExport::new());
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct File {
|
||||
#[serde(default)]
|
||||
also_export: AlsoExport,
|
||||
}
|
||||
let raw = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("read {}", path.display()))?;
|
||||
Ok(serde_json::from_str::<File>(&raw)
|
||||
.with_context(|| format!("parse {}", path.display()))?
|
||||
.also_export)
|
||||
}
|
||||
|
||||
/// Every RATC entry of a UI pak this exporter treats as a screen.
|
||||
///
|
||||
/// The rule is `is_build` — a bundle with a `.rat` layout child — **plus an
|
||||
/// authored allow-list of entry indices**.
|
||||
///
|
||||
/// The allow-list exists because the splash screens declare their sprites
|
||||
/// directly and have no `.rat` child, so `is_build` cannot see them, and **there
|
||||
/// is no content rule that would**. The RE agent looked: design size fails
|
||||
/// (every extra composable bundle sampled is 1280x720, the same as every
|
||||
/// screen) and element count fails (fragments run 2..15 elements in
|
||||
/// `GP_OPTIONS`/`GP_SAVE_LOAD` while the splash halves are 3 and 7 — the ranges
|
||||
/// overlap). So the splashes are located **by entry index**, which is a locator
|
||||
/// and not a claim, and each one says so in its own `why`.
|
||||
///
|
||||
/// This is safe here rather than in general: in `GP_TITLE` the widened set adds
|
||||
/// exactly four bundles and all four are real screens, with zero fragments. In
|
||||
/// another archive it would not be, which is why this is an allow-list and not
|
||||
/// a widened predicate.
|
||||
fn screen_builds(ar: &PakArchive, also: Option<&std::collections::BTreeMap<String, NameEntry>>)
|
||||
-> Vec<(usize, Vec<u8>)>
|
||||
{
|
||||
let mut out = Vec::new();
|
||||
for (i, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(bytes) = ar.read(e) else { continue };
|
||||
let allowed = also.is_some_and(|m| m.contains_key(&i.to_string()));
|
||||
if ui_layout::is_build(&bytes) || allowed {
|
||||
out.push((i, bytes));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
match Args::parse().cmd {
|
||||
Cmd::Export {
|
||||
disc,
|
||||
out,
|
||||
authored,
|
||||
} => run_export(&disc, &out, &authored),
|
||||
Cmd::Check { out } => {
|
||||
let n = check::run(&out)?;
|
||||
println!("{} screen(s) in {} validate against sylpheed.screen/3", n, out.display());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
let names = load_names(authored_dir)?;
|
||||
|
||||
// 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.
|
||||
if out.exists() {
|
||||
std::fs::remove_dir_all(&out).context("clear the output tree")?;
|
||||
}
|
||||
std::fs::create_dir_all(&out)?;
|
||||
|
||||
let archive = "dat/GP_TITLE.pak";
|
||||
let pak = disc.join(archive);
|
||||
let ar = PakArchive::open(&pak).with_context(|| format!("open {}", pak.display()))?;
|
||||
let also = load_also_export(authored_dir)?;
|
||||
let archive_also = also.get(archive);
|
||||
let builds = screen_builds(&ar, archive_also);
|
||||
println!("{archive}: {} screen build(s)", builds.len());
|
||||
|
||||
let archive_names = names.get(archive);
|
||||
let mut screens = Vec::new();
|
||||
for (build_idx, (entry, bytes)) in builds.iter().enumerate() {
|
||||
// Keyed by ENTRY, not by the ordinal: widening the enumeration to reach
|
||||
// the splash renumbers ordinals, and a name that moves when the rule
|
||||
// changes is not a name.
|
||||
let key = entry.to_string();
|
||||
let named = archive_names
|
||||
.and_then(|m| m.get(&key))
|
||||
.or_else(|| archive_also.and_then(|m| m.get(&key)));
|
||||
let (name, name_source, why) = match named {
|
||||
Some(e) => (e.name.clone(), "authored", e.why.clone()),
|
||||
// Nobody has identified this build. Emit a stable synthetic id and
|
||||
// say in the file that the name is not a recovered one.
|
||||
None => (format!("build_{entry:02}"), "index", None),
|
||||
};
|
||||
let ex = screen::export_build(
|
||||
&out,
|
||||
archive,
|
||||
*entry,
|
||||
build_idx,
|
||||
bytes,
|
||||
&name,
|
||||
name_source,
|
||||
why,
|
||||
"title",
|
||||
EXPORTER,
|
||||
FORMATS_REV,
|
||||
)
|
||||
.with_context(|| format!("export build {build_idx} of {archive}"))?;
|
||||
println!(
|
||||
" [{build_idx}] entry {entry:<3} -> {} ({} sprites{})",
|
||||
ex.json_path,
|
||||
ex.sprites,
|
||||
if ex.missing.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(", {} missing", ex.missing.len())
|
||||
}
|
||||
);
|
||||
screens.push(ManifestScreen {
|
||||
name: ex.name,
|
||||
file: ex.json_path,
|
||||
sprites: ex.sprites,
|
||||
missing_sprites: ex.missing,
|
||||
});
|
||||
}
|
||||
|
||||
// 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(),
|
||||
"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(),
|
||||
],
|
||||
};
|
||||
std::fs::write(
|
||||
out.join("manifest.json"),
|
||||
format!("{}\n", serde_json::to_string_pretty(&manifest)?),
|
||||
)?;
|
||||
println!("wrote {}/manifest.json", out.display());
|
||||
Ok(())
|
||||
}
|
||||
475
godot-import/crates/sylpheed-export/src/screen.rs
Normal file
475
godot-import/crates/sylpheed-export/src/screen.rs
Normal file
@@ -0,0 +1,475 @@
|
||||
//! One UI build → one `sylpheed.screen/3` JSON document plus its sprite PNGs.
|
||||
//!
|
||||
//! Everything here is **derived**: it is what the bundle says, restated in a
|
||||
//! format Godot can read. The two places a value is not read off the disc are
|
||||
//! marked in the output itself — `name_source` when a screen's name came from
|
||||
//! `authored/`, and `layer_source: "implied"` when the paint-order key came from
|
||||
//! the decoders' measured table rather than from a `T8aD` header. A consumer can
|
||||
//! tell the difference without reading this file.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use sylpheed_formats::{t8ad, ui_layout};
|
||||
|
||||
/// `elements[].role`, from the decoded element kind.
|
||||
///
|
||||
/// ⚠️ `0x3002` is one member of a `0x3000` family and is **not** a general
|
||||
/// button test — `GP_READY_ROOM` uses `0x3000`/`0x3004`/`0x300c`/`0x3008` and
|
||||
/// has zero `0x3002`. Every screen in this milestone is `GP_TITLE`, where the
|
||||
/// mapping is decoded; anything else exports as `unknown` with its raw kind.
|
||||
fn role_of(kind: u32, has_sprite: bool) -> &'static str {
|
||||
match kind {
|
||||
0x3002 => "button",
|
||||
0x10 if !has_sprite => "primitive",
|
||||
0x0 => "decoration",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Source {
|
||||
/// Path of the archive within the disc root.
|
||||
pub archive: String,
|
||||
/// Pak **entry index** — the stable locator, not the display ordinal.
|
||||
pub entry: usize,
|
||||
/// Index into this pak's list of screen builds (what `screen --build` takes).
|
||||
pub build: usize,
|
||||
}
|
||||
|
||||
/// A placement keyframe, carrying the on-disc time verbatim.
|
||||
///
|
||||
/// `t` is in the disc's own units and is deliberately **not** converted here:
|
||||
/// the seconds conversion is measured off the running game, not read from the
|
||||
/// file, so it lives in `authored/timing.json` and is applied in exactly one
|
||||
/// place. See HANDOFF Q1.
|
||||
#[derive(Serialize)]
|
||||
pub struct Keyframe {
|
||||
/// On-disc time, absent on the final keyframe of a group — which carries no
|
||||
/// time slot at all. Absent, never invented.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub t: Option<u32>,
|
||||
/// Top-left of the element at 1:1. Signed: elements animate in from off-screen.
|
||||
pub pos: [i32; 2],
|
||||
/// Percent, per axis. Scale grows the element **about its pivot**, not about
|
||||
/// `pos` — at 100 % the two are identical, which is why it went unnoticed.
|
||||
pub scale: [u32; 2],
|
||||
/// Modulate colour, **RGBA** byte order. `0xffffffff` on essentially every
|
||||
/// keyframe on the disc.
|
||||
pub tint_rgba: String,
|
||||
/// The second modulate colour, **ARGB** byte order — the high byte is the
|
||||
/// alpha that ramps during a fade. Multiplies with `tint_rgba`.
|
||||
pub fade_argb: String,
|
||||
/// Screen-plane rotation in **degrees**, clockwise-positive, decoded from
|
||||
/// the keyframe's `+12`. **The game renders this** — confirmed twice, on
|
||||
/// different screens and different elements: the title's `ptloop` sweeps
|
||||
/// declare +30 / −45 and a GPU capture submits their quads at +30.26 /
|
||||
/// −45.28, and the focus ring ramps 0 → 360 with everything else constant,
|
||||
/// which a capture caught mid-spin. Rotation is about the **declared
|
||||
/// pivot**, also measured.
|
||||
pub rotation_deg: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Rest {
|
||||
pub pos: [i32; 2],
|
||||
pub scale: [u32; 2],
|
||||
pub tint_rgba: String,
|
||||
pub fade_argb: String,
|
||||
/// Screen-plane rotation in **degrees**, clockwise-positive, decoded from
|
||||
/// the keyframe's `+12`. **The game renders this** — confirmed twice, on
|
||||
/// different screens and different elements: the title's `ptloop` sweeps
|
||||
/// declare +30 / −45 and a GPU capture submits their quads at +30.26 /
|
||||
/// −45.28, and the focus ring ramps 0 → 360 with everything else constant,
|
||||
/// which a capture caught mid-spin. Rotation is about the **declared
|
||||
/// pivot**, also measured.
|
||||
pub rotation_deg: i32,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub t: Option<u32>,
|
||||
}
|
||||
|
||||
/// One element of a button's focused-state record.
|
||||
///
|
||||
/// A focus record is **not** a single sprite. `ptbtn0Nf.rat` declares the
|
||||
/// spinning ring `ptbtneff01.t32` *and* the bright label, and the parent bundle
|
||||
/// declares **no element for the record at all** — so the leaf is the only
|
||||
/// source of placement for both, and the parent has nothing to inherit from.
|
||||
/// That is why these carry their own `pos`, and why they are not simply a
|
||||
/// second sprite path on the base element.
|
||||
#[derive(Serialize)]
|
||||
pub struct FocusElement {
|
||||
pub id: String,
|
||||
pub declared: String,
|
||||
pub sprite: Option<String>,
|
||||
pub pivot: [u32; 2],
|
||||
pub rest: Rest,
|
||||
pub keyframes: Vec<Keyframe>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Focus {
|
||||
/// The `.rat` leaf this came from, e.g. `ptbtn01f.rat`.
|
||||
pub record: String,
|
||||
/// Back-to-front, in the leaf's own declaration order.
|
||||
pub elements: Vec<FocusElement>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Element {
|
||||
/// Declaration index — the key the placement region and `paint_order` use.
|
||||
pub index: usize,
|
||||
/// The declared name with its extension stripped; stable within a screen.
|
||||
pub id: String,
|
||||
/// The name exactly as the declaration table spells it.
|
||||
pub declared: String,
|
||||
pub role: &'static str,
|
||||
pub kind_raw: String,
|
||||
/// Sprite PNG, relative to `export/`. Absent for an untextured primitive.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sprite: Option<String>,
|
||||
/// The highlighted-state sprite: this element's sprite with an `f` before
|
||||
/// the extension, when the bundle carries one — `ptbtn01.t32` ↔
|
||||
/// `ptbtn01f.t32`. 🟡 **A naming convention, not a decoded field.** It holds
|
||||
/// for all 54 real pairs on the disc (HANDOFF), and it is the only link
|
||||
/// between a button and its highlight that has survived checking.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub focus_sprite: Option<String>,
|
||||
/// The focused state, read from the element's `.rat` leaf. Supersedes
|
||||
/// `focus_sprite`, which is kept because it is the 54-pair naming
|
||||
/// convention and a consumer may still want the bare highlight texture.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub focus: Option<Focus>,
|
||||
/// The raw `opt ` link inside this element's `.rat` record.
|
||||
///
|
||||
/// ⚠️ **This is not a focus link.** It was read as one, and that was
|
||||
/// measured and refuted (HANDOFF, `ui-focus-and-effect-elements.md`) — on the
|
||||
/// main menu it chains `ptloop01 → ptloop02 → ptbtn01`, across two
|
||||
/// decorations and into a button. It is carried through unresolved and
|
||||
/// unnamed so that whoever decodes it has it, and so that nothing downstream
|
||||
/// mistakes it for navigation.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub opt_link: Option<String>,
|
||||
pub pivot: [u32; 2],
|
||||
/// Untextured primitives have no texture to take a size from; the quad is
|
||||
/// `pivot × 2`, which is 1280×720 for 361 of the disc's 369 primitives.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<[u32; 2]>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent: Option<usize>,
|
||||
/// 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.
|
||||
pub layer_source: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub layer: Option<String>,
|
||||
/// This element is another element's focused state, not a screen element.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub focused: bool,
|
||||
/// A `loopN` sprite animation rather than a placed element.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub animated: bool,
|
||||
/// The resting pose: the **hold**, the longest run of consecutive keyframes
|
||||
/// with an identical pose that does not end the group. Neither the first nor
|
||||
/// the last keyframe.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rest: Option<Rest>,
|
||||
pub keyframes: Vec<Keyframe>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Screen {
|
||||
pub format: &'static str,
|
||||
pub exporter: String,
|
||||
/// Revision of `sylpheed-formats` whose decoders produced this file.
|
||||
pub formats_rev: &'static str,
|
||||
pub source: Source,
|
||||
pub name: String,
|
||||
/// `"authored"` when the name came from `authored/screen_names.json`,
|
||||
/// `"index"` when nobody has named this build yet.
|
||||
pub name_source: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name_why: Option<String>,
|
||||
pub design: [u32; 2],
|
||||
pub elements: Vec<Element>,
|
||||
/// Back-to-front paint order as declaration indices, from the decoded `u16`
|
||||
/// layer key at `+0x0A` of each sprite header, stable-sorted so equal keys
|
||||
/// keep declaration order. See `unresolved: paint_order_ties`.
|
||||
pub paint_order: Vec<usize>,
|
||||
/// Navigation order: `button`-role elements sorted by resting Y.
|
||||
/// **Geometric, not a decoded neighbour graph** — right for a vertical menu
|
||||
/// and not to be trusted for anything else.
|
||||
pub buttons: Vec<String>,
|
||||
/// What this file does not answer. A consumer needing one of these must get
|
||||
/// it from `authored/`.
|
||||
pub unresolved: Vec<&'static str>,
|
||||
}
|
||||
|
||||
fn hex32(v: u32) -> String {
|
||||
format!("0x{v:08x}")
|
||||
}
|
||||
|
||||
/// Strip the extension the declaration table spells, giving a stable id.
|
||||
pub fn id_of(declared: &str) -> String {
|
||||
declared
|
||||
.rsplit_once('.')
|
||||
.map(|(stem, _)| stem)
|
||||
.unwrap_or(declared)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// What one screen's export produced, for the manifest.
|
||||
pub struct Exported {
|
||||
pub name: String,
|
||||
pub json_path: String,
|
||||
pub sprites: usize,
|
||||
/// Sprites an element named that did not resolve or decode.
|
||||
pub missing: Vec<String>,
|
||||
}
|
||||
|
||||
/// Convert one build to JSON on disk, writing its sprite PNGs beside it.
|
||||
///
|
||||
/// `sprite_dir` is per-screen: a sprite name is unique within a bundle but not
|
||||
/// across builds, and two screens' `ptbase.t32` are different pictures.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn export_build(
|
||||
out: &Path,
|
||||
archive: &str,
|
||||
entry: usize,
|
||||
build_idx: usize,
|
||||
bundle: &[u8],
|
||||
name: &str,
|
||||
name_source: &'static str,
|
||||
name_why: Option<String>,
|
||||
subdir: &str,
|
||||
exporter: &str,
|
||||
formats_rev: &'static str,
|
||||
) -> Result<Exported> {
|
||||
let b = ui_layout::parse_build(bundle).context("build did not parse")?;
|
||||
|
||||
// Every sprite an element actually references, decoded once and written as a
|
||||
// PNG under this screen's own directory.
|
||||
let sprite_rel = |sprite: &str| format!("sprites/{subdir}/{name}/{}.png", id_of(sprite));
|
||||
let sprite_dir = out.join("sprites").join(subdir).join(name);
|
||||
std::fs::create_dir_all(&sprite_dir)?;
|
||||
let mut written: BTreeMap<String, ()> = BTreeMap::new();
|
||||
let mut missing = Vec::new();
|
||||
// Decode one T8aD and write it, from whichever bundle slice and sprite map
|
||||
// owns it. A leaf's sprites may be indexed in the leaf's own map (offsets
|
||||
// relative to the leaf slice) or in the parent's; the caller says which.
|
||||
fn write_from(
|
||||
dir: &Path,
|
||||
written: &mut BTreeMap<String, ()>,
|
||||
sprite: &str,
|
||||
bytes: &[u8],
|
||||
map: &std::collections::HashMap<String, (usize, usize)>,
|
||||
) -> Result<bool> {
|
||||
if written.contains_key(sprite) {
|
||||
return Ok(true);
|
||||
}
|
||||
let Some(&(off, size)) = map.get(sprite) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(img) = t8ad::parse(&bytes[off..off + size]) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let buf = image::RgbaImage::from_raw(img.width, img.height, img.rgba)
|
||||
.context("T8aD dimensions disagree with its pixel count")?;
|
||||
buf.save(dir.join(format!("{}.png", id_of(sprite))))?;
|
||||
written.insert(sprite.to_string(), ());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
|
||||
/// The highlighted twin of a sprite name: `ptbtn01.t32` → `ptbtn01f.t32`.
|
||||
fn highlight_name(sprite: &str) -> Option<String> {
|
||||
let (stem, ext) = sprite.rsplit_once('.')?;
|
||||
Some(format!("{stem}f.{ext}"))
|
||||
}
|
||||
|
||||
let mut elements = Vec::new();
|
||||
for el in &b.elements {
|
||||
let mut sprite_out = None;
|
||||
if let Some(s) = &el.sprite {
|
||||
if write_from(&sprite_dir, &mut written, s, bundle, &b.sprites)? {
|
||||
sprite_out = Some(sprite_rel(s));
|
||||
} else {
|
||||
missing.push(s.clone());
|
||||
}
|
||||
}
|
||||
// The highlight pairs by NAME on the sprite, not through the `opt `
|
||||
// link: `opt ` is refuted as a focus link and points somewhere else
|
||||
// entirely on half these elements.
|
||||
let mut focus_sprite = None;
|
||||
if let Some(h) = el.sprite.as_deref().and_then(highlight_name) {
|
||||
if write_from(&sprite_dir, &mut written, &h, bundle, &b.sprites)? {
|
||||
focus_sprite = Some(sprite_rel(&h));
|
||||
}
|
||||
}
|
||||
|
||||
// The focused state is a RECORD, not a sprite. `ptbtn0Nf.rat` declares
|
||||
// the spinning ring AND the bright label, and the parent bundle
|
||||
// declares no element for it at all -- so the leaf is the only source
|
||||
// of placement for both, and there is nothing for it to inherit.
|
||||
//
|
||||
// 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.
|
||||
let mut focus = None;
|
||||
if let Some(rec) = highlight_name(&el.name) {
|
||||
if let Some(&(off, size)) = b.records.get(&rec) {
|
||||
if let Some(leaf) = ui_layout::parse_build(&bundle[off..off + size]) {
|
||||
let mut fes = Vec::new();
|
||||
for fe in &leaf.elements {
|
||||
// A leaf element's own NAME is its sprite -- `el.sprite`
|
||||
// is only populated for a T8aD child of the same bundle,
|
||||
// and these are indexed either in the leaf's map (offsets
|
||||
// into the leaf slice) or in the parent's.
|
||||
let sp: &str = fe.sprite.as_deref().unwrap_or(&fe.name);
|
||||
let mut fsprite = None;
|
||||
if write_from(&sprite_dir, &mut written, sp,
|
||||
&bundle[off..off + size], &leaf.sprites)?
|
||||
|| write_from(&sprite_dir, &mut 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,
|
||||
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(),
|
||||
});
|
||||
}
|
||||
if !fes.is_empty() {
|
||||
focus = Some(Focus { record: rec, elements: fes });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (layer, layer_source) = match ui_layout::sprite_layer_key(&b, bundle, el) {
|
||||
Some(k) => (Some(hex32(k)), "sprite"),
|
||||
None => match ui_layout::implied_layer_key(&el.name) {
|
||||
Some(k) => (Some(hex32(k)), "implied"),
|
||||
None => (None, "none"),
|
||||
},
|
||||
};
|
||||
|
||||
let kf = |k: &ui_layout::Keyframe| 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,
|
||||
};
|
||||
let role = role_of(el.kind, el.sprite.is_some());
|
||||
elements.push(Element {
|
||||
index: el.index,
|
||||
id: id_of(&el.name),
|
||||
declared: el.name.clone(),
|
||||
role,
|
||||
kind_raw: format!("{:#x}", el.kind),
|
||||
sprite: sprite_out,
|
||||
focus_sprite,
|
||||
focus,
|
||||
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,
|
||||
layer_source,
|
||||
layer,
|
||||
focused: el.focused,
|
||||
animated: el.animated,
|
||||
rest: el.rest().map(|k| Rest {
|
||||
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,
|
||||
t: k.time,
|
||||
}),
|
||||
keyframes: el.keyframes.iter().map(kf).collect(),
|
||||
});
|
||||
}
|
||||
|
||||
// Navigation order is geometric: buttons top-to-bottom by resting Y. A
|
||||
// focused-state record is not itself a menu item.
|
||||
let mut buttons: Vec<(i32, String)> = b
|
||||
.elements
|
||||
.iter()
|
||||
.filter(|e| e.kind == 0x3002 && !e.focused)
|
||||
.filter_map(|e| e.rest().map(|k| (k.y, id_of(&e.name))))
|
||||
.collect();
|
||||
buttons.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
|
||||
|
||||
let screen = Screen {
|
||||
format: "sylpheed.screen/3",
|
||||
exporter: exporter.to_string(),
|
||||
formats_rev,
|
||||
source: Source {
|
||||
archive: archive.to_string(),
|
||||
entry,
|
||||
build: build_idx,
|
||||
},
|
||||
name: name.to_string(),
|
||||
name_source,
|
||||
name_why,
|
||||
design: [b.design_w, b.design_h],
|
||||
elements,
|
||||
paint_order: ui_layout::derived_paint_order(&b, bundle),
|
||||
buttons: buttons.into_iter().map(|(_, n)| n).collect(),
|
||||
unresolved: vec![
|
||||
// The time unit is measured off the running game, not on the disc.
|
||||
"keyframe_time_unit",
|
||||
// Where two elements share a layer key the game's order is
|
||||
// unexplained; eight candidates refuted. Costs one element's blend
|
||||
// on one screen.
|
||||
"paint_order_ties",
|
||||
// The last keyframe of a group carries no time slot, so the
|
||||
// fade-OUT length is not in the file.
|
||||
"fade_out_duration",
|
||||
],
|
||||
};
|
||||
|
||||
let dir = out.join("screens").join(subdir);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let json_path = format!("screens/{subdir}/{name}.json");
|
||||
std::fs::write(
|
||||
out.join(&json_path),
|
||||
format!("{}\n", serde_json::to_string_pretty(&screen)?),
|
||||
)?;
|
||||
|
||||
missing.sort();
|
||||
missing.dedup();
|
||||
Ok(Exported {
|
||||
name: name.to_string(),
|
||||
json_path,
|
||||
sprites: written.len(),
|
||||
missing,
|
||||
})
|
||||
}
|
||||
166
godot-import/crates/sylpheed-export/src/video.rs
Normal file
166
godot-import/crates/sylpheed-export/src/video.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
//! 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.
|
||||
///
|
||||
/// The unnormalised form was measured too and **clips**: peak 0.0 dBFS. That is
|
||||
/// why the normalisation is here rather than the textbook coefficients.
|
||||
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))
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let want = format!("{command}\nsource-bytes: {size}\nsource-channels: {ch}\n");
|
||||
|
||||
let fresh = ogv.exists()
|
||||
&& std::fs::read_to_string(&stamp).map(|s| s == 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)?;
|
||||
std::fs::write(&stamp, &want)?;
|
||||
}
|
||||
Ok(Some(Transcoded {
|
||||
name: m.stem.to_string(),
|
||||
file: format!("video/{}.ogv", m.stem),
|
||||
command,
|
||||
why: m.why,
|
||||
}))
|
||||
}
|
||||
100
godot-import/docker/Dockerfile
Normal file
100
godot-import/docker/Dockerfile
Normal file
@@ -0,0 +1,100 @@
|
||||
# Autonomous port agent for the Sylpheed Godot menu shell.
|
||||
#
|
||||
# DELIBERATELY SMALL. The reverse-engineering container next door is 4.36 GB
|
||||
# because it builds Xenia Canary and drives it under a software Vulkan stack.
|
||||
# This agent has no emulator, no oracle and no C++ build: it converts already-
|
||||
# decoded assets and drives Godot. Keeping it light is what lets both containers
|
||||
# run on one 12-core / 15 GB box without the memory pressure that has crashed it.
|
||||
#
|
||||
# What it needs, and nothing else: Rust (the exporter), Godot 4 (the runtime),
|
||||
# ffmpeg (the transcode), and a headless display to screenshot Godot for
|
||||
# comparison against the reference renderer.
|
||||
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
LANG=C.UTF-8 \
|
||||
TZ=Etc/UTC
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# toolchain for the exporter and for building sylpheed-cli from /reborn
|
||||
build-essential pkg-config git curl ca-certificates \
|
||||
libssl-dev \
|
||||
# Godot 4 needs these even headless; the windowed run needs the X libs
|
||||
libx11-6 libxcursor1 libxinerama1 libxrandr2 libxi6 libgl1 \
|
||||
libasound2t64 libpulse0 libfontconfig1 \
|
||||
# the transcode target (libtheora + libvorbis ship in Ubuntu's ffmpeg)
|
||||
ffmpeg \
|
||||
# headless display + the screenshot path, for diffing Godot's output
|
||||
# against `sylpheed-cli screen render`
|
||||
xvfb x11-utils openbox imagemagick \
|
||||
# everyday
|
||||
python3 jq ripgrep unzip file less nano tini sudo procps \
|
||||
# expect drives Claude Code's one-time interactive gates
|
||||
expect \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Godot 4 ──────────────────────────────────────────────────────────────────
|
||||
# Pinned. An engine version bump changes rendering, and this project compares
|
||||
# screenshots against a reference renderer — so an upgrade must be a deliberate,
|
||||
# stated act rather than a silent drift.
|
||||
ARG GODOT_VERSION=4.7.2
|
||||
RUN cd /tmp \
|
||||
&& curl -fsSLO "https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}-stable/Godot_v${GODOT_VERSION}-stable_linux.x86_64.zip" \
|
||||
&& unzip -q "Godot_v${GODOT_VERSION}-stable_linux.x86_64.zip" \
|
||||
&& mv "Godot_v${GODOT_VERSION}-stable_linux.x86_64" /usr/local/bin/godot \
|
||||
&& chmod +x /usr/local/bin/godot \
|
||||
&& printf '#!/bin/sh\nexec /usr/local/bin/godot --headless "$@"\n' > /usr/local/bin/godot-headless \
|
||||
&& chmod +x /usr/local/bin/godot-headless \
|
||||
&& rm -f "Godot_v${GODOT_VERSION}-stable_linux.x86_64.zip"
|
||||
|
||||
# ── Node + Claude Code ───────────────────────────────────────────────────────
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& npm install -g @anthropic-ai/claude-code \
|
||||
&& npm cache clean --force \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── The agent user ───────────────────────────────────────────────────────────
|
||||
# NOT root: Claude Code refuses --dangerously-skip-permissions with root
|
||||
# privileges. Ubuntu 24.04 ships its own `ubuntu` account at uid 1000, so the
|
||||
# common case — matching a host user who is also 1000 — collides with it.
|
||||
ARG AGENT_UID=1000
|
||||
ARG AGENT_GID=1000
|
||||
RUN if getent passwd "${AGENT_UID}" >/dev/null; then \
|
||||
userdel -r "$(getent passwd "${AGENT_UID}" | cut -d: -f1)" 2>/dev/null || true; \
|
||||
fi; \
|
||||
if getent group "${AGENT_GID}" >/dev/null; then \
|
||||
groupdel "$(getent group "${AGENT_GID}" | cut -d: -f1)" 2>/dev/null || true; \
|
||||
fi; \
|
||||
groupadd -g "${AGENT_GID}" agent \
|
||||
&& useradd -m -u "${AGENT_UID}" -g "${AGENT_GID}" -s /bin/bash -d /sylph-home/port agent \
|
||||
&& mkdir -p /sylph-home/port /work /reborn \
|
||||
&& chown -R "${AGENT_UID}:${AGENT_GID}" /sylph-home \
|
||||
&& echo 'agent ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/agent
|
||||
|
||||
COPY bin/ /usr/local/bin/
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/* /usr/local/bin/entrypoint.sh
|
||||
|
||||
USER agent
|
||||
WORKDIR /work
|
||||
|
||||
# CARGO_TARGET_DIR points OUTSIDE the bind-mounted repo so the host and the
|
||||
# container do not invalidate each other's incremental state on every switch.
|
||||
ENV RUSTUP_HOME=/sylph-home/port/.rustup \
|
||||
CARGO_HOME=/sylph-home/port/.cargo \
|
||||
CARGO_TARGET_DIR=/sylph-home/port/target-container \
|
||||
PATH=/sylph-home/port/.cargo/bin:/usr/local/bin:/usr/bin:/bin
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --default-toolchain stable --profile minimal --component clippy --component rustfmt
|
||||
|
||||
RUN mkdir -p /sylph-home/port/target-container /sylph-home/port/.claude
|
||||
|
||||
ENV HOME=/sylph-home/port \
|
||||
DISPLAY=:97 \
|
||||
SCREEN_GEOMETRY=1280x720x24 \
|
||||
PROJECT_DIR=/work
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"]
|
||||
CMD ["bash"]
|
||||
23
godot-import/docker/bin/build-export
Executable file
23
godot-import/docker/bin/build-export
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build and run the exporter against the disc.
|
||||
#
|
||||
# build-export build only
|
||||
# build-export --run build, export to ./export, then validate it
|
||||
#
|
||||
# The validate step is not optional politeness: `export` writes a tree and
|
||||
# `check` is the only thing that says the tree is readable by anything other
|
||||
# than the program that wrote it. A build that exports and does not check has
|
||||
# not shown anything.
|
||||
#
|
||||
# Jobs are capped: this box runs two agent containers and a desktop, and an
|
||||
# unbounded parallel build has crashed it. Do not raise this to "use all cores".
|
||||
set -euo pipefail
|
||||
cd "${PROJECT_DIR:-/work}"
|
||||
export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-3}"
|
||||
cargo build --release -p sylpheed-export
|
||||
if [ "${1:-}" = "--run" ]; then
|
||||
shift
|
||||
disc="${SYLPHEED_DISC:?set SYLPHEED_DISC to the extracted disc root}"
|
||||
"$CARGO_TARGET_DIR/release/sylpheed-export" export --disc "$disc" --out export "$@"
|
||||
exec "$CARGO_TARGET_DIR/release/sylpheed-export" check --out export
|
||||
fi
|
||||
80
godot-import/docker/bin/build-reference-cli
Executable file
80
godot-import/docker/bin/build-reference-cli
Executable file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build `sylpheed-cli` from the SAME revision of sylpheed-formats the exporter
|
||||
# is pinned to, and put it on the persistent target volume.
|
||||
#
|
||||
# build-reference-cli -> $CARGO_TARGET_DIR/release/sylpheed-cli
|
||||
#
|
||||
# Why not just use /reborn/target/release/sylpheed-cli: that binary is built
|
||||
# from whatever /reborn's working tree is at, which is a LIVE mount of the other
|
||||
# agent's checkout and moves under you mid-iteration. `sylpheed-cli screen
|
||||
# render` is the reference the Godot port is diffed against, so if it runs
|
||||
# different decoders than the exporter, a pixel disagreement has a free variable
|
||||
# in it and proves nothing about the port.
|
||||
#
|
||||
# The pinned source lives in CARGO_HOME, which is on the container overlay and
|
||||
# does not survive a fresh container -- cargo re-fetches it. The BINARY goes to
|
||||
# CARGO_TARGET_DIR, which is a volume, so this is a one-off per image.
|
||||
#
|
||||
# Jobs are capped for the same reason as build-export.
|
||||
set -euo pipefail
|
||||
cd "${PROJECT_DIR:-/work}"
|
||||
export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-3}"
|
||||
|
||||
rev=$(sed -n 's/.*Syplheed-Reborn\.git", rev = "\([0-9a-f]*\)".*/\1/p' \
|
||||
crates/sylpheed-export/Cargo.toml | head -1)
|
||||
[ -n "$rev" ] || { echo "build-reference-cli: no rev pin found in Cargo.toml" >&2; exit 1; }
|
||||
|
||||
# The checkout only exists once cargo has fetched it; a fresh container has not.
|
||||
find_checkout() {
|
||||
find "${CARGO_HOME:?}/git/checkouts" -maxdepth 2 -type d -name "${rev}*" 2>/dev/null | head -1
|
||||
}
|
||||
src=$(find_checkout)
|
||||
if [ -z "$src" ]; then
|
||||
echo "build-reference-cli: fetching the pinned decoders ($rev)"
|
||||
cargo fetch
|
||||
src=$(find_checkout)
|
||||
fi
|
||||
[ -n "$src" ] || { echo "build-reference-cli: no checkout for rev $rev" >&2; exit 1; }
|
||||
|
||||
# Build into a target directory KEYED BY THE REVISION.
|
||||
#
|
||||
# This is not tidiness. Sharing one target dir across pins silently served a
|
||||
# stale binary: after the pin moved 8b6dbcf -> 5414db3, cargo reported
|
||||
# "Finished in 0.13s" and left in place a `sylpheed-cli` built from the OLD
|
||||
# decoders. `screen list` still worked, so the old check passed, and the
|
||||
# reference renderer this whole project verifies against was a revision behind
|
||||
# for three consecutive diff runs. A per-rev tree cannot do that: a new pin has
|
||||
# no artifacts to reuse.
|
||||
echo "build-reference-cli: building sylpheed-cli from $rev"
|
||||
tree="$CARGO_TARGET_DIR/reference-cli/$rev"
|
||||
CARGO_TARGET_DIR="$tree" cargo build --release --manifest-path "$src/Cargo.toml" -p sylpheed-cli
|
||||
|
||||
stable="$CARGO_TARGET_DIR/reference-cli/sylpheed-cli"
|
||||
mkdir -p "$(dirname "$stable")"
|
||||
cp -f "$tree/release/sylpheed-cli" "$stable"
|
||||
|
||||
"$stable" screen list "${SYLPHEED_DISC:-/disc}/dat/GP_TITLE.pak" >/dev/null \
|
||||
|| { echo "build-reference-cli: built, but 'screen list' failed" >&2; exit 1; }
|
||||
|
||||
# And check the binary is actually the pinned code, not merely a working one.
|
||||
# `screen info` prints each element's resting placement, which is decoder
|
||||
# output; if this disagrees with what the exporter wrote from the same pin, the
|
||||
# two halves of the verification are not the same revision and every diff below
|
||||
# is meaningless. Compare rather than assert a value, so this stays true when
|
||||
# the pin moves again.
|
||||
if [ -f "${PROJECT_DIR:-/work}/export/screens/title/main_menu.json" ]; then
|
||||
cli_rest=$("$stable" screen info "${SYLPHEED_DISC:-/disc}/dat/GP_TITLE.pak" --build 5 \
|
||||
| sed -n 's/.*ptframe1\.t32.*rest (\([0-9]*\),\([0-9]*\)).*/\1,\2/p')
|
||||
exp_rest=$(python3 -c '
|
||||
import json,sys
|
||||
d=json.load(open(sys.argv[1]))
|
||||
e=next(e for e in d["elements"] if e["id"]=="ptframe1")
|
||||
print("%d,%d" % tuple(e["rest"]["pos"]))' "${PROJECT_DIR:-/work}/export/screens/title/main_menu.json")
|
||||
if [ "$cli_rest" != "$exp_rest" ]; then
|
||||
echo "build-reference-cli: STALE OR MISMATCHED BINARY" >&2
|
||||
echo " the CLI resolves ptframe1 rest to ($cli_rest) but export/ says ($exp_rest)." >&2
|
||||
echo " Both should come from rev $rev. Delete $tree and rebuild." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "build-reference-cli: $stable (rev $rev, agrees with export/ on ptframe1)"
|
||||
72
godot-import/docker/bin/claude-autonomous
Executable file
72
godot-import/docker/bin/claude-autonomous
Executable file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/expect -f
|
||||
# Start Claude Code for an unattended run, answering the first-run gates.
|
||||
#
|
||||
# Claude Code has three one-time interactive prompts, and every one of them is a
|
||||
# silent, permanent hang for an agent with nobody at the keyboard — no error, no
|
||||
# log line, just a container that looks healthy and does nothing:
|
||||
#
|
||||
# 1. the theme picker (first run, or whenever the installed version is
|
||||
# newer than lastOnboardingVersion)
|
||||
# 2. "do you trust this folder?" (per workspace)
|
||||
# 3. the Bypass Permissions disclaimer (for --dangerously-skip-permissions)
|
||||
#
|
||||
# `seed-claude-config.py` pre-sets the config keys for 1 and 2. The disclaimer
|
||||
# has no such key — it is meant to be accepted by a person once — so it is
|
||||
# answered here instead. That is the honest reading of `sylph-agent loose`: the
|
||||
# operator accepted it by choosing to run this, and the container is exactly the
|
||||
# sandbox the warning asks for.
|
||||
#
|
||||
# ── Why the patterns are single words ──
|
||||
# Claude Code draws its UI with ABSOLUTE COLUMN escapes between words, so the
|
||||
# prompt arrives on the wire as
|
||||
#
|
||||
# 2.\x1b[8GYes,\x1b[13GI\x1b[15Gaccept
|
||||
#
|
||||
# A multi-word pattern like {Yes, I accept} therefore never matches, and the
|
||||
# wrapper sits there looking like it is not running at all. Match one word.
|
||||
|
||||
set timeout 90
|
||||
log_user 1
|
||||
|
||||
# Give the pty a wide, tall geometry. A detached `docker run -t` defaults to
|
||||
# 80x24, and Claude Code hard-wraps to the terminal width — which truncates the
|
||||
# Remote Control URL to "https://claude.ai/code/session_01…" in the one place
|
||||
# you need to read it, and makes `docker logs` nearly unusable generally.
|
||||
set stty_init "rows 50 cols 200"
|
||||
|
||||
set answered_theme 0
|
||||
set answered_trust 0
|
||||
set answered_bypass 0
|
||||
|
||||
spawn -noecho claude --dangerously-skip-permissions {*}$argv
|
||||
|
||||
expect {
|
||||
-re {Choose} {
|
||||
if {!$answered_theme} { set answered_theme 1; send "\r" }
|
||||
exp_continue
|
||||
}
|
||||
-re {trust} {
|
||||
if {!$answered_trust} {
|
||||
set answered_trust 1
|
||||
send_user "\n\[claude-autonomous] accepting the workspace trust prompt\n"
|
||||
send "1\r"
|
||||
}
|
||||
exp_continue
|
||||
}
|
||||
-re {accept} {
|
||||
if {!$answered_bypass} {
|
||||
set answered_bypass 1
|
||||
send_user "\n\[claude-autonomous] accepting the Bypass Permissions disclaimer\n"
|
||||
send "2\r"
|
||||
}
|
||||
exp_continue
|
||||
}
|
||||
timeout {
|
||||
# No new gate for a while: the session is up (or never had one). Stop
|
||||
# matching so nothing later in the run can be answered by accident.
|
||||
}
|
||||
eof { exit }
|
||||
}
|
||||
|
||||
# Hand the terminal over for the rest of the run.
|
||||
interact
|
||||
76
godot-import/docker/bin/push-work
Executable file
76
godot-import/docker/bin/push-work
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
# Push the current topic branch to origin — the ONLY sanctioned way out of the
|
||||
# container.
|
||||
#
|
||||
# Why a wrapper instead of plain `git push`:
|
||||
#
|
||||
# * **`main` and shared branches are refused.** The agent commits to
|
||||
# `auto/<topic>`; a human merges. A token that can push anywhere is one
|
||||
# confused iteration away from rewriting the consolidated line.
|
||||
# * **Force-push is refused**, always. Nothing here needs it, and history
|
||||
# rewriting is the one mistake that cannot be undone by merging.
|
||||
# * It pushes the CURRENT branch only, by name, so a stray `--all` cannot
|
||||
# publish another agent's worktree branch mid-experiment.
|
||||
#
|
||||
# Credentials come from a file mounted read-only at ~/.git-credentials (see
|
||||
# `sylph-agent`). They are never printed, never logged, and never passed on a
|
||||
# command line.
|
||||
#
|
||||
# push-work push the current branch, and any annotated tags on it
|
||||
# push-work --dry-run say what it would do
|
||||
set -euo pipefail
|
||||
|
||||
DRY=0
|
||||
[ "${1:-}" = "--dry-run" ] && DRY=1
|
||||
|
||||
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
|
||||
echo "push-work: not inside a git repository" >&2; exit 1; }
|
||||
cd "$repo_root"
|
||||
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [ "$branch" = "HEAD" ]; then
|
||||
echo "push-work: detached HEAD — check out a branch first" >&2; exit 1
|
||||
fi
|
||||
|
||||
case "$branch" in
|
||||
auto/*) ;;
|
||||
*)
|
||||
echo "push-work: refusing to push '$branch'." >&2
|
||||
echo " Only auto/* topic branches may leave the container; a human merges" >&2
|
||||
echo " them into main. Move your work: git switch -c auto/<topic>" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
|
||||
if [ ! -s "$HOME/.git-credentials" ]; then
|
||||
echo "push-work: no credentials mounted at ~/.git-credentials." >&2
|
||||
echo " The host must start the container with SYLPH_GIT_CREDENTIALS pointing" >&2
|
||||
echo " at a file containing one line:" >&2
|
||||
echo " https://<user>:<token>@git.mc02.dev" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Applied to THIS COMMAND ONLY, via `-c`, never `git config --local`.
|
||||
#
|
||||
# Writing it to --local config persists it in the repository, and this repo is a
|
||||
# bind mount the host also uses -- so the host's git inherited
|
||||
# `store --file=/sylph-home/port/.git-credentials`, a path that exists only
|
||||
# inside the container, and every host push then failed with
|
||||
# `unable to get credential storage lock: No such file or directory`.
|
||||
#
|
||||
# A tool that configures a shared repository to suit itself breaks every other
|
||||
# user of that repository. Keep it to the invocation.
|
||||
CRED_HELPER="store --file=$HOME/.git-credentials"
|
||||
|
||||
ahead=$(git rev-list --count "origin/$branch..$branch" 2>/dev/null || git rev-list --count HEAD)
|
||||
echo "push-work: $branch — $ahead commit(s) to publish"
|
||||
|
||||
if [ "$DRY" = 1 ]; then
|
||||
echo "push-work: --dry-run, stopping here"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --force-with-lease is deliberately NOT offered. If this is rejected as
|
||||
# non-fast-forward, someone else moved the branch: fetch and merge, do not
|
||||
# overwrite.
|
||||
git -c "credential.helper=$CRED_HELPER" push --follow-tags --set-upstream origin "$branch"
|
||||
echo "push-work: pushed $branch"
|
||||
13
godot-import/docker/bin/screenshot
Executable file
13
godot-import/docker/bin/screenshot
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Capture the current display to a PNG.
|
||||
#
|
||||
# screenshot out.png
|
||||
#
|
||||
# Used to diff Godot's rendering against `sylpheed-cli screen render`. Captures
|
||||
# the whole 1280x720 root window, which is exactly the design space the screens
|
||||
# are authored in, so a capture and a composite are directly comparable without
|
||||
# cropping or scaling.
|
||||
set -euo pipefail
|
||||
out="${1:?usage: screenshot OUT.png}"
|
||||
import -display "${DISPLAY:-:97}" -window root "$out"
|
||||
identify -format 'captured %wx%h -> %f\n' "$out"
|
||||
85
godot-import/docker/bin/seed-claude-config.py
Executable file
85
godot-import/docker/bin/seed-claude-config.py
Executable file
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mark Claude Code's onboarding as complete in ~/.claude.json.
|
||||
|
||||
Claude Code re-runs its first-run wizard whenever `lastOnboardingVersion` does
|
||||
not match the installed version. In a terminal that is a one-key prompt; for an
|
||||
unattended agent it is a silent, permanent hang on the theme picker — no error,
|
||||
no log line, and the container looks like it started fine.
|
||||
|
||||
It also pre-accepts the workspace's trust prompt. That is a SECOND, separate
|
||||
first-run gate: even past onboarding, Claude Code asks "is this a project you
|
||||
trust?" per directory, and this repo's settings pre-approve 442 tool permissions
|
||||
so the prompt is emphatic about it. Unattended, it is another silent hang.
|
||||
|
||||
Pre-accepting is safe here precisely because the trust question is being
|
||||
answered by the person who built and launched the container, for their own
|
||||
repository — it is not a judgement being made on their behalf about someone
|
||||
else's code.
|
||||
|
||||
Usage: seed-claude-config.py <path to .claude.json> <installed version> [workspace...]
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 3:
|
||||
print(f"usage: {sys.argv[0]} <config.json> <version> [workspace...]",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
path, version = sys.argv[1], sys.argv[2]
|
||||
workspaces = sys.argv[3:]
|
||||
|
||||
cfg = {}
|
||||
if os.path.exists(path) and os.path.getsize(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
cfg = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
# A corrupt or partial config is not worth failing the container
|
||||
# over — start from empty rather than block the run.
|
||||
cfg = {}
|
||||
if not isinstance(cfg, dict):
|
||||
cfg = {}
|
||||
|
||||
cfg["hasCompletedOnboarding"] = True
|
||||
cfg["lastOnboardingVersion"] = version
|
||||
cfg.setdefault("theme", "dark")
|
||||
# The THIRD interactive gate: --dangerously-skip-permissions shows a
|
||||
# "Bypass Permissions mode / you accept all responsibility" confirmation on
|
||||
# first use. Key name taken from the shipped binary's own strings, not
|
||||
# guessed. Accepting it here is the whole point of `sylph-agent loose` —
|
||||
# the container is the sandbox that warning asks you to provide.
|
||||
cfg["bypassPermissionsModeAccepted"] = True
|
||||
# A FOURTH gate, and this one fires mid-session rather than at startup, so
|
||||
# the pty wrapper has already handed over by then: an upsell asking whether
|
||||
# to try the fullscreen renderer. It is shown while
|
||||
# `fullscreenUpsellSeenCount` is below an internal threshold, so park it far
|
||||
# above. Found by reading the shipped binary's strings, same as the others.
|
||||
cfg["fullscreenUpsellSeenCount"] = 9999
|
||||
# An auto-update mid-run would restart the process and lose the loop's
|
||||
# scheduled wake-up, so pin the version the container was built with.
|
||||
cfg["autoUpdates"] = False
|
||||
|
||||
projects = cfg.setdefault("projects", {})
|
||||
if not isinstance(projects, dict):
|
||||
projects = cfg["projects"] = {}
|
||||
for ws in workspaces:
|
||||
entry = projects.setdefault(ws, {})
|
||||
if not isinstance(entry, dict):
|
||||
entry = projects[ws] = {}
|
||||
entry["hasTrustDialogAccepted"] = True
|
||||
entry.setdefault("projectOnboardingSeenCount", 1)
|
||||
entry["hasClaudeMdExternalIncludesApproved"] = True
|
||||
entry["hasClaudeMdExternalIncludesWarningShown"] = True
|
||||
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
os.replace(tmp, path)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
72
godot-import/docker/entrypoint.sh
Executable file
72
godot-import/docker/entrypoint.sh
Executable file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bring up the headless display, then hand over.
|
||||
#
|
||||
# Xvfb and openbox are started as children of PID 1 (tini), NOT of the agent's
|
||||
# shell, so they outlive any single command. The RE container learned this the
|
||||
# hard way: a display owned by a shell gets reaped when that shell exits, which
|
||||
# reads as "Xvfb dies on its own every few minutes".
|
||||
set -euo pipefail
|
||||
|
||||
: "${DISPLAY:=:97}"
|
||||
: "${SCREEN_GEOMETRY:=1280x720x24}"
|
||||
|
||||
if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then
|
||||
Xvfb "$DISPLAY" -screen 0 "$SCREEN_GEOMETRY" -nolisten tcp &
|
||||
for _ in $(seq 50); do
|
||||
xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 && break
|
||||
sleep 0.1
|
||||
done
|
||||
openbox >/dev/null 2>&1 &
|
||||
fi
|
||||
echo "[entrypoint] display $DISPLAY ready ($SCREEN_GEOMETRY)"
|
||||
|
||||
if [ -d /reborn ]; then
|
||||
echo "[entrypoint] /reborn mounted read-only — HANDOFF.md is the contract"
|
||||
fi
|
||||
|
||||
# Seed ~/.claude.json from the host's read-only copy, then stamp onboarding as
|
||||
# complete. Claude Code re-runs its first-run wizard whenever
|
||||
# lastOnboardingVersion differs from the installed version, so a container with a
|
||||
# newer Claude than the host stops on the theme picker -- no error, no log line,
|
||||
# and an unattended agent sits there forever.
|
||||
if [ -f "$HOME/.claude.host.json" ] && [ ! -s "$HOME/.claude.json" ]; then
|
||||
cp "$HOME/.claude.host.json" "$HOME/.claude.json" 2>/dev/null || true
|
||||
fi
|
||||
# Same reason as .claude.json above: `credential.helper=store` rewrites this
|
||||
# file by rename-over-target, which fails with EBUSY on a bind mount. Copy it to
|
||||
# a writable path; nothing is ever written back to the host's file.
|
||||
if [ -f "$HOME/.git-credentials.host" ]; then
|
||||
cp "$HOME/.git-credentials.host" "$HOME/.git-credentials" 2>/dev/null || true
|
||||
chmod 600 "$HOME/.git-credentials" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
CLAUDE_VER=$(claude --version 2>/dev/null | grep -oE '^[0-9][0-9.]*' || echo 0.0.0)
|
||||
python3 /usr/local/bin/seed-claude-config.py "$HOME/.claude.json" "$CLAUDE_VER" \
|
||||
"$PWD" "${PROJECT_DIR:-/work}" "$HOME" || true
|
||||
chmod 600 "$HOME/.claude.json" 2>/dev/null || true
|
||||
|
||||
# ── Claude Code ──────────────────────────────────────────────────────────────
|
||||
# Without this the loop prompt is handed to `exec` as a command, and the whole
|
||||
# markdown file is tried as a filename: exit 126, "File name too long".
|
||||
if [ "${SYLPH_AUTONOMOUS:-0}" = "1" ]; then
|
||||
# Drop the image's default CMD first, or `claude` is handed the literal string
|
||||
# "bash" as its prompt and answers a question nobody asked.
|
||||
if [ "$#" -eq 1 ] && [ "$1" = "bash" ]; then
|
||||
set --
|
||||
fi
|
||||
# Remote Control registers the session with the account so the agent can be
|
||||
# reached from claude.ai -- the point of a detached run being that nobody is
|
||||
# sitting in front of it. The name is passed EXPLICITLY: the flag's value is
|
||||
# optional, so a bare --remote-control swallows the /loop prompt after it.
|
||||
if [ "${SYLPH_REMOTE:-1}" != "0" ]; then
|
||||
set -- --remote-control "${SYLPH_REMOTE_NAME:-sylpheed-port}" "$@"
|
||||
echo "[entrypoint] Remote Control as '${SYLPH_REMOTE_NAME:-sylpheed-port}'"
|
||||
fi
|
||||
# claude-autonomous wraps `claude --dangerously-skip-permissions` in a pty and
|
||||
# answers the one-time first-run gates. The Bypass Permissions disclaimer has
|
||||
# no config key that skips it, so unattended it hangs forever.
|
||||
set -- claude-autonomous "$@"
|
||||
echo "[entrypoint] starting Claude Code in $(pwd)"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
180
godot-import/docker/sylph-port
Executable file
180
godot-import/docker/sylph-port
Executable file
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env bash
|
||||
# Launcher for the Godot port agent.
|
||||
#
|
||||
# ./sylph-port build build the image
|
||||
# ./sylph-port shell interactive shell
|
||||
# ./sylph-port loose [task] detached, self-running on a fixed interval
|
||||
# ./sylph-port logs -f follow it
|
||||
# ./sylph-port attach chat with it (Ctrl-P Ctrl-Q to leave it running)
|
||||
# ./sylph-port remote a link to chat with it from anywhere
|
||||
# ./sylph-port stop stop it
|
||||
#
|
||||
# Env:
|
||||
# SYLPH_PORT_CPUS / SYLPH_PORT_MEM_GB override the cap (default 3 / 4)
|
||||
# SYLPH_PORT_REPO repo to mount at /work (default: this script's parent)
|
||||
# SYLPH_REBORN path to the Syplheed-Reborn checkout (read-only mount)
|
||||
# SYLPH_DISC extracted disc root
|
||||
# SYLPH_GIT_CREDENTIALS file with `https://<user>:<token>@host` for push-work
|
||||
# SYLPH_LOOP_INTERVAL fixed loop cadence (default 45m)
|
||||
#
|
||||
# ── Two hard-won constraints ────────────────────────────────────────────────
|
||||
#
|
||||
# 1. THIS REPO IS ITS OWN CLONE. It is deliberately NOT the tree the RE agent
|
||||
# or a human is working in. Sharing a working tree between two writers means
|
||||
# files change under whoever is mid-edit, and a `git add -A` by one sweeps up
|
||||
# the other's work. That happened; do not re-create it.
|
||||
#
|
||||
# 2. IDENTITY GOES IN THE ENVIRONMENT, NOT `.git/config`. Writing `[user]` into
|
||||
# a repo's config captures every commit made in that tree, including a
|
||||
# human's. GIT_AUTHOR_*/GIT_COMMITTER_* apply to this container's commits and
|
||||
# nobody else's.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# The repo to mount at /work. Overridable so this script can be run from a
|
||||
# worktree -- a human editing on `main` must not repoint the agent's checkout.
|
||||
REPO="${SYLPH_PORT_REPO:-$(cd "$HERE/.." && pwd)}"
|
||||
IMAGE="${SYLPH_PORT_IMAGE:-sylpheed-port:latest}"
|
||||
NAME="${SYLPH_PORT_NAME:-sylpheed-port}"
|
||||
|
||||
# Half of what the RE container takes. That container builds a C++ emulator and
|
||||
# drives it; this one converts assets and runs Godot. Two full-size containers
|
||||
# do not fit on a 12-core / 15 GB box beside a desktop -- memory is the binding
|
||||
# constraint, and an over-committed build has crashed this machine before.
|
||||
CPUS="${SYLPH_PORT_CPUS:-3}"
|
||||
MEM_GB="${SYLPH_PORT_MEM_GB:-4}"
|
||||
|
||||
REBORN="${SYLPH_REBORN:-$(cd "$REPO/../Syplheed-Reborn" 2>/dev/null && pwd || true)}"
|
||||
DISC="${SYLPH_DISC:-$(cd "$REPO/../sylph_extract" 2>/dev/null && pwd || true)}"
|
||||
|
||||
docker_args() {
|
||||
local _out=(
|
||||
--name "$NAME"
|
||||
--hostname sylph-port
|
||||
--cpus "$CPUS"
|
||||
--memory "${MEM_GB}g"
|
||||
--memory-swap "${MEM_GB}g" # no swap escape hatch: a swapping build
|
||||
# thrashes the whole host
|
||||
--pids-limit 2048
|
||||
-v "$REPO:/work"
|
||||
-v "sylpheed-port-target:/sylph-home/port/target-container"
|
||||
# CARGO_HOME on a volume, not the container overlay: without it the pinned
|
||||
# decoder source is re-fetched from the network on every fresh container.
|
||||
-v "sylpheed-port-cargo:/sylph-home/port/.cargo"
|
||||
-v "${SYLPH_CLAUDE_HOME:-$HOME/.claude}:/sylph-home/port/.claude"
|
||||
-v "${SYLPH_CLAUDE_JSON:-$HOME/.claude.json}:/sylph-home/port/.claude.host.json:ro"
|
||||
-e "PROJECT_DIR=/work"
|
||||
)
|
||||
|
||||
# The RE corpus, READ-ONLY. `docs/port/HANDOFF.md` is the contract, and the
|
||||
# agent also builds sylpheed-cli from here for the reference renderer. Mounted
|
||||
# ro so a port iteration cannot edit the other agent's repository.
|
||||
if [ -n "$REBORN" ] && [ -d "$REBORN" ]; then
|
||||
_out+=(-v "$REBORN:/reborn:ro")
|
||||
else
|
||||
echo "==> NOTE: no Syplheed-Reborn checkout found; the agent cannot read" >&2
|
||||
echo " HANDOFF.md or build the reference renderer. Set SYLPH_REBORN." >&2
|
||||
fi
|
||||
|
||||
if [ -n "$DISC" ] && [ -d "$DISC" ]; then
|
||||
_out+=(-v "$DISC:/disc:ro" -e "SYLPHEED_DISC=/disc")
|
||||
else
|
||||
echo "==> NOTE: no extracted disc found; the exporter has nothing to read." >&2
|
||||
echo " Set SYLPH_DISC to the directory holding dat/ and hidden/." >&2
|
||||
fi
|
||||
|
||||
# Commits are attributed to the port agent, via the environment so that
|
||||
# nothing is written into the repository's config. See constraint 2 above.
|
||||
_out+=(
|
||||
-e "GIT_AUTHOR_NAME=Sylpheed port agent"
|
||||
-e "GIT_AUTHOR_EMAIL=port-agent@localhost"
|
||||
-e "GIT_COMMITTER_NAME=Sylpheed port agent"
|
||||
-e "GIT_COMMITTER_EMAIL=port-agent@localhost"
|
||||
)
|
||||
|
||||
# Mounted as `.host` and copied to a writable file by the entrypoint, exactly
|
||||
# like .claude.json. `credential.helper=store` REWRITES its file after a
|
||||
# successful auth -- it writes a temp file and renames over the target, and
|
||||
# renaming onto a bind-mount point gives EBUSY, which surfaces as
|
||||
# `fatal: unable to write credential store: Device or resource busy`.
|
||||
#
|
||||
# The push still succeeds, which is the actual danger: a `fatal:` line that is
|
||||
# routinely wrong teaches the reader to ignore the one that is real. Mounting
|
||||
# rw would also silence it, but then the container can clobber the host's
|
||||
# credential file; copying cannot.
|
||||
local gitcred="${SYLPH_GIT_CREDENTIALS:-$HOME/.sylph-git-credentials}"
|
||||
if [ -f "$gitcred" ]; then
|
||||
_out+=(-v "$gitcred:/sylph-home/port/.git-credentials.host:ro")
|
||||
else
|
||||
echo "==> NOTE: no git credentials at $gitcred — the agent cannot push," >&2
|
||||
echo " so its work dies with the container." >&2
|
||||
fi
|
||||
|
||||
[ -n "${ANTHROPIC_API_KEY:-}" ] && _out+=(-e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY")
|
||||
printf '%s\n' "${_out[@]}"
|
||||
}
|
||||
|
||||
mapfile -t ARGS < <(docker_args)
|
||||
|
||||
case "${1:-}" in
|
||||
build)
|
||||
exec docker build -t "$IMAGE" \
|
||||
--build-arg "AGENT_UID=$(id -u)" --build-arg "AGENT_GID=$(id -g)" "$HERE"
|
||||
;;
|
||||
|
||||
shell)
|
||||
TTY=(-i); [ -t 0 ] && TTY=(-it)
|
||||
exec docker run --rm "${TTY[@]}" "${ARGS[@]}" "$IMAGE" bash
|
||||
;;
|
||||
|
||||
loose)
|
||||
shift
|
||||
TASK="${1:-}"
|
||||
if [ -z "$TASK" ]; then
|
||||
if [ -f "$REPO/docs/loop-task.md" ]; then
|
||||
TASK="$(cat "$REPO/docs/loop-task.md")"
|
||||
else
|
||||
TASK="Work the milestones in docs/MISSION.md."
|
||||
fi
|
||||
fi
|
||||
# A FIXED interval, not self-pacing: the one thing an agent deep in a
|
||||
# milestone reliably forgets is the bookkeeping after it, and a forgotten
|
||||
# wake-up silently ends the loop.
|
||||
INTERVAL="${SYLPH_LOOP_INTERVAL-45m}"
|
||||
echo "==> loose | cpus=$CPUS mem=${MEM_GB}g pacing=${INTERVAL:-self}"
|
||||
echo "==> repo: $REPO"
|
||||
echo "==> reborn: ${REBORN:-<none>} (read-only)"
|
||||
docker run -d -i -t "${ARGS[@]}" -e SYLPH_AUTONOMOUS=1 -w /work "$IMAGE" \
|
||||
"/loop ${INTERVAL:+$INTERVAL }$TASK" >/dev/null
|
||||
echo
|
||||
echo " running detached as '$NAME'."
|
||||
echo " ./sylph-port remote link to chat with it from anywhere"
|
||||
echo " ./sylph-port logs -f follow it"
|
||||
echo " ./sylph-port attach chat with it locally"
|
||||
echo " ./sylph-port stop stop it"
|
||||
;;
|
||||
|
||||
logs) shift; exec docker logs "$@" "$NAME" ;;
|
||||
attach) exec docker attach "$NAME" ;;
|
||||
stop) exec docker rm -f "$NAME" ;;
|
||||
|
||||
remote)
|
||||
echo "waiting for the session to register" >&2
|
||||
for _ in $(seq 60); do
|
||||
# Read the container LOG, not the session transcript. The transcript
|
||||
# records every command run inside the container -- including this
|
||||
# lookup -- so grepping it matched our own pattern string back.
|
||||
url=$(docker logs "$NAME" 2>&1 \
|
||||
| grep -aoE 'https://claude\.ai/code/session_[A-Za-z0-9]+' \
|
||||
| tail -1 || true)
|
||||
[ -n "$url" ] && { echo "$url"; exit 0; }
|
||||
sleep 2
|
||||
done
|
||||
echo "no session link yet — try ./sylph-port logs -f" >&2
|
||||
exit 1
|
||||
;;
|
||||
|
||||
*)
|
||||
sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'
|
||||
;;
|
||||
esac
|
||||
110
godot-import/docs/AUDIO-VERIFICATION.md
Normal file
110
godot-import/docs/AUDIO-VERIFICATION.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Verifying audio without an audio device
|
||||
|
||||
Neither container has a sound card, so "does it actually play?" cannot be
|
||||
answered by listening. It can be answered by measurement, and the two things
|
||||
usually meant by that question need different measurements.
|
||||
|
||||
**Separate them before reaching for a tool:**
|
||||
|
||||
| question | needs Godot? | needs a device? |
|
||||
|---|---|---|
|
||||
| Is the transcoded file faithful to the source? | no | no |
|
||||
| Does Godot actually route it to an output? | yes | no |
|
||||
| What does the *game* play on a menu move? | no (Canary) | a virtual one |
|
||||
|
||||
## 1. Transcode fidelity — file against file
|
||||
|
||||
This is the question P4 actually raised, and it needs neither an engine nor a
|
||||
device. Decode both, subtract, and measure what is left.
|
||||
|
||||
```bash
|
||||
# Source, for a reference level
|
||||
ffmpeg -hide_banner -t 25 -i ADV.wmv \
|
||||
-af "aformat=channel_layouts=stereo,astats=measure_perchannel=none" -f null - 2>&1 \
|
||||
| grep "RMS level"
|
||||
|
||||
# The difference signal: source minus transcode
|
||||
ffmpeg -hide_banner -t 25 -i ADV.wmv -t 25 -i ADV.ogv -filter_complex \
|
||||
"[0:a]aformat=channel_layouts=stereo[a];\
|
||||
[1:a]aformat=channel_layouts=stereo,volume=-1[b];\
|
||||
[a][b]amix=inputs=2:normalize=0,astats=measure_perchannel=none" -f null - 2>&1 \
|
||||
| grep "RMS level"
|
||||
```
|
||||
|
||||
A faithful transcode puts the difference **40 dB or more below** the source.
|
||||
|
||||
### Three ways this measurement lies
|
||||
|
||||
Run it wrong and it reports a disaster that is not there. All three of these
|
||||
were hit on the first attempt:
|
||||
|
||||
* **Alignment.** A one-sample offset makes the difference nearly as loud as the
|
||||
source. Cross-correlate and compensate *before* subtracting, or the number is
|
||||
meaningless. A first run gave source −25.3 dB against difference −34.2 dB —
|
||||
only 9 dB down, which looks catastrophic and proves nothing.
|
||||
* **Channel layout.** The source and the transcode do not have the same channel
|
||||
count. You are not comparing like with like unless both sides are downmixed
|
||||
the same way, and `astats` will give you a confident number regardless. See
|
||||
[`movie-audio-channels`][mac] for which profile a given movie is in — that is
|
||||
a disc fact and lives in the RE corpus, not here.
|
||||
* **A file still being written.** `ffprobe` reported the `.ogv` as 33 s against
|
||||
the source's 137 s — apparent catastrophic truncation, actually a transcode in
|
||||
progress. Check `mtime` and packet count before believing a duration, and
|
||||
write to a temp name and rename on completion so a reader cannot see a partial
|
||||
file at all.
|
||||
|
||||
⚠️ **The downmix is an unrecorded decision, and it is not ours to make quietly.**
|
||||
Nothing in the manifest says a fold happened or on what weighting; it is whatever
|
||||
ffmpeg defaulted to, and that default can change between versions. Centre-channel
|
||||
dialogue folds into L/R, so this changes how speech sits against music — an
|
||||
aesthetic judgement, not a container detail. Pin it explicitly and record it,
|
||||
exactly as MISSION §6 requires of the transcode command itself.
|
||||
|
||||
[mac]: https://git.mc02.dev/fabi/Syplheed-Reborn/src/branch/main/docs/re/structures/movie-audio-channels.md
|
||||
|
||||
## 2. Engine routing — Godot writes a WAV instead of a device
|
||||
|
||||
Godot does not need a sound card to produce audio you can inspect. Put an
|
||||
`AudioEffectRecord` on the **Master** bus and it captures the mixed output from
|
||||
inside a headless run:
|
||||
|
||||
```gdscript
|
||||
var bus := AudioServer.get_bus_index("Master")
|
||||
var rec := AudioEffectRecord.new()
|
||||
AudioServer.add_bus_effect(bus, rec)
|
||||
rec.set_recording_active(true)
|
||||
# ... play the scene ...
|
||||
rec.set_recording_active(false)
|
||||
rec.get_recording().save_to_wav("user://master.wav")
|
||||
```
|
||||
|
||||
Then feed that WAV through §1 against the source. That closes the loop: it
|
||||
proves the asset is right **and** that the engine reached it, which no amount of
|
||||
file comparison can show on its own.
|
||||
|
||||
Confirm the dummy driver is what is actually in use rather than assuming it —
|
||||
`AudioServer.get_driver_name()` — and say so in the write-up, because "recorded
|
||||
under a dummy driver" is a weaker claim than "heard", and the difference matters.
|
||||
|
||||
## 3. A virtual device, when something insists on a real one
|
||||
|
||||
For anything that opens a device rather than a bus — the emulator, most
|
||||
obviously — a PulseAudio **null sink** is a real device that records to a file:
|
||||
|
||||
```bash
|
||||
pactl load-module module-null-sink sink_name=cap sink_properties=device.description=cap
|
||||
PULSE_SINK=cap <the application>
|
||||
parec -d cap.monitor --file-format=wav /tmp/captured.wav
|
||||
```
|
||||
|
||||
This is the route to capturing what the *game* plays — the menu move and confirm
|
||||
cues behind HANDOFF Q8 — rather than what we think it should play. It needs
|
||||
`pulseaudio-utils` in the image, so it is a rebuild, not something to reach for
|
||||
mid-iteration.
|
||||
|
||||
## What none of this establishes
|
||||
|
||||
That it *sounds right*. Every method here shows correspondence to a source, not
|
||||
that the source is the audio the game plays at that moment, and not that levels
|
||||
are sane in a mix. A ten-second human listen still answers something no
|
||||
measurement above does — so when a result rests on one of these, say which one.
|
||||
239
godot-import/docs/BLOCKED.md
Normal file
239
godot-import/docs/BLOCKED.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# Waiting on the RE agent
|
||||
|
||||
What this port cannot do until an answer lands in
|
||||
[`/reborn/docs/port/HANDOFF.md`](https://git.mc02.dev/fabi/Syplheed-Reborn).
|
||||
Recorded so it is not re-discovered every iteration.
|
||||
|
||||
**None of these may be guessed.** A value invented here is indistinguishable from
|
||||
a decoded one a month from now. Where a milestone can proceed with a placeholder,
|
||||
the placeholder goes in `authored/` with a `why` naming the question it stands in
|
||||
for, so it is deleted rather than forgotten when the answer arrives.
|
||||
|
||||
Last reconciled against HANDOFF.md on **2026-08-29**, at `/reborn` HEAD `9a0ca0d`.
|
||||
(`/reborn` is mounted read-only, so `git -C /reborn pull` fails by design; the
|
||||
mount is refreshed outside this container and HEAD is read, not fetched.)
|
||||
|
||||
## Still open — these block work
|
||||
|
||||
| Milestone | Needs | HANDOFF | State |
|
||||
|---|---|---|---|
|
||||
| ~~P6 audio~~ | ~~which cue fires on move / confirm / back~~ | Q8 | ✅ **answered 2026-08-28** — the RE agent retracted "cannot be extracted". The waves are located in `Static.slb` by playing them: **move `0x1ec0`** (8 192 B, 0.533 s), **confirm `0x5d6c0`** (12 288 B, 1.016 s), **back `0x0ec0`** (4 096 B, 0.344 s), and ⬅➡ play nothing. Move and back reproduce across two boots. 🟡 that the cursor's wave is the cue *named* `SE_UI_CURSOR` is still a name match, and Ⓐ's wave is not separated between `SE_UI_DECIDE` and `SE_UI_SUB_WIN_OPN`. P6 can now export real audio; the exporter has to grow an SE path. |
|
||||
| P6 audio | which BGM the menu plays | Q10 | ❔ **not on the disc.** All 32 banks are named `BGM_001`…`BGM_109` with no semantic name anywhere. The port is choosing a track, and that choice is authored. |
|
||||
| P6 looping | where a menu loop restarts | Q10 | ❔ `BGM_001` fades out at 167.663 s into 6.15 s of silence, and no loop-point field has been identified. A menu loop is authored. |
|
||||
| P4/P7 video | whether Ⓐ skips a movie | Q9 | 🟡 unsettled — the corpus says Ⓐ skips every time, the boot harness never taps during a movie because it breaks the title. P4 can play the movie; it cannot yet say what a button press does during one. |
|
||||
| P5 `NEW GAME` | what Ⓐ on `NEW GAME` opens | Q4 | ❔ untested: Ⓐ on it **hangs the emulator**. The other four destinations are measured. |
|
||||
| P3 sequencing | what code decides to advance the boot sequence | Q6 | 🟡 the order is observed and the attract cycle timed (~8–10 s idle → fade → `ADV.wmv` in full → title). The *driver* is not decoded. P3 can reproduce the observed behaviour and must say it is reproducing an observation. |
|
||||
|
||||
## Answered since this file was last written — no longer blocking
|
||||
|
||||
Q1 (keyframe time unit — linear ramp, 2 units per rendered frame, 1 unit = 1/60 s
|
||||
*measured*), Q2 (which build is which screen), Q3 (paint order — a `u16` layer key
|
||||
at `+0x0A`, **decoded**), Q5 (navigation: ⬆⬇ wrap, ⬅➡ nothing, Ⓑ up with focus
|
||||
restored), Q7 (transitions: a fade through black, fade-in decoded, ~0.4 s fade-out
|
||||
measured), Q9 (`ADVERTISE_MOVIE` → `ADV.wmv` is boot intro *and* attract; `MS00A` →
|
||||
`S00A.wmv` is the new-game intro), Q10 (a bank is two stems played **together** —
|
||||
do not concatenate), S1 (Ready Room: no-go).
|
||||
|
||||
Also newly available, and useful to P3/P5 when they author the flow: the title
|
||||
part's transitions are a **lookup by name**, and the game's own screen
|
||||
vocabulary includes `TITLE_SCREEN`, `TITLE_MENU`, `LOADING`, `DIFFICULTY`,
|
||||
`EXTRA_MENU`, `TUTORIAL_MENU`. Three of those are corroborated by measurements
|
||||
taken before the function was opened (`DIFFICULTY` is what `NEW GAME` opens,
|
||||
`EXTRA_MENU` is `EXTRAS`, `TUTORIAL_MENU` the lesson list). 🟡 **Candidate, not
|
||||
decoded** — the RE agent is explicit that the strings are what the call sites
|
||||
*reference*, not proven arguments, and the same list mixes in `TEXT_FONT` and
|
||||
`GAMMA_RGB`. So `authored/flow.json` may use these as `goto` names — which is
|
||||
better than inventing names — but must mark them as a name match, not a
|
||||
measurement.
|
||||
|
||||
Three of those are **measured**, not decoded, and so are authored here rather
|
||||
than exported:
|
||||
|
||||
| Authored because it is not on the disc | HANDOFF | Where it lives |
|
||||
|---|---|---|
|
||||
| `1 keyframe unit = 1/60 s` | Q1 | not yet written — P2 |
|
||||
| initial menu focus (not stable across boots; pick one and say so) | Q5 | not yet written — P5 |
|
||||
| the ~0.4 s fade-out and the 0.17–0.23 s black hold | Q7 | not yet written — P3 |
|
||||
|
||||
## What the port needs next — sent to the RE agent 2026-08-29
|
||||
|
||||
Ordered by what it costs the port, not by what it costs to answer.
|
||||
|
||||
### 1. How should the exporter recognise the developer-logo splash? (P3, blocking)
|
||||
|
||||
The splash is the **first thing P3 draws** and it is not in `export/`. It
|
||||
declares its sprites directly and has no `.rat` layout child, so `is_build`
|
||||
rejects it; `sylpheed-cli` reaches it only via `--all`, which the CLI's own help
|
||||
says **renumbers `--build`**. So the port cannot address it by build index
|
||||
without the index meaning something different from everywhere else in this
|
||||
format.
|
||||
|
||||
What I need is a **predicate**, not an index: something the exporter can apply to
|
||||
say "this bundle is a composable screen" that admits the splash and does not
|
||||
admit the 1 894 two-element fragments `--all` also lets in. If the honest answer
|
||||
is "there is no such rule, take `GP_TITLE` entries 11/14", that is a usable
|
||||
answer — I will export it under a synthetic name with `name_source` saying it was
|
||||
located by entry index and not by a rule.
|
||||
|
||||
### 2. Is the ~0.4 s fade-out the whole ramp, or a segment of it? (P3, blocking)
|
||||
|
||||
Q7 measures the screen fade-out at ~0.4 s and the black hold at 0.17–0.23 s.
|
||||
The port needs to know **which quantity that 0.4 s is**, because the last
|
||||
keyframe of a group carries no `t` and the port refuses to invent one:
|
||||
|
||||
* the ramp from the hold to the exit pose — i.e. the missing duration of that
|
||||
final untimed keyframe; or
|
||||
* hold → exit → fully black, the 0.4 s covering several keyframes; or
|
||||
* something the game does independently of the group.
|
||||
|
||||
Under the first reading the port writes one authored constant and plays the
|
||||
group to its end. Under the third it must not.
|
||||
|
||||
### 3. Focus: drawn OVER the base element, or INSTEAD of it? (P5, cheap, avoid rework)
|
||||
|
||||
`sylpheed-cli --focus` is documented as drawing the focused record **over** its
|
||||
base. The port **replaces** the sprite. Those are different operations and the
|
||||
port picked its one without evidence.
|
||||
|
||||
Evidence that the port is wrong: rendering `main_menu` with `ptbtn01` focused —
|
||||
which is how `main-menu-oracle.png` was taken — makes the RMSE against that
|
||||
capture **worse**, 5.92 % → 7.00 %. The capture also shows a **ring marker**
|
||||
beside `NEW GAME` that the port draws nowhere. Cheap to answer from a capture
|
||||
that already exists, and it decides how P5 is built.
|
||||
|
||||
### 4. Rotation — should the port draw it, and about what? (P2/P3, needs a joint decision)
|
||||
|
||||
`67fa1a1` decodes `rotation_deg` at keyframe `+12` and explicitly does **not**
|
||||
render it: `ui_layout::blit` is axis-aligned. `ptloop01`/`ptloop02` on the title
|
||||
declare +30° and −45°, and the framebuffer submits them at +30.26 and −45.28.
|
||||
|
||||
A canvas rotation is a few lines in Godot, so the port *can* draw these. But
|
||||
then the port is deliberately more correct than the reference renderer, and
|
||||
`verify-screen` — the port's whole verification method — starts reporting a large
|
||||
diff on the title that means "the port is right". That is a bad state to be in
|
||||
silently, so I would rather agree it than do it.
|
||||
|
||||
Two sub-questions: **is the rotation about the declared pivot** or about the
|
||||
element's centre or corner? And would you rather `blit` grow a rotating path so
|
||||
the diff stays meaningful? The format would go to **v3** to carry
|
||||
`rotation_deg`; that is my side and I will do it either way, since carrying a
|
||||
decoded field the renderer ignores is better than dropping it.
|
||||
|
||||
### 5. Is `main-menu-oracle.png` gamma-correct? (not blocking, but it calibrates everything)
|
||||
|
||||
With the background in, the port sits at 5.92 % RMSE against that capture and is
|
||||
visibly **darker and less saturated** than it across the whole frame. If the
|
||||
capture path applies a gamma or a colour transform the game does not, then RMSE
|
||||
against captures has a floor and the port should stop chasing it. If it does
|
||||
not, something is still missing. The port cannot tell these apart from inside.
|
||||
|
||||
## Questions this port has raised
|
||||
|
||||
### ~~Does a keyframe group loop, or hold its last pose?~~ — answered
|
||||
|
||||
**Answered 2026-08-28 by the RE agent: groups hold.** `ptloop01`/`ptloop02` park
|
||||
their sprites at x=1521 and x=−839, both off a 1280-wide design, and 18 s of
|
||||
settled title sits at sd ≤ 0.01. `loop*.rat` is a misleading name — these
|
||||
animate once during build-in and then rest off-screen.
|
||||
|
||||
The port's own error here was different and is fixed: it settled at the last
|
||||
*timed* keyframe rather than at the hold. See `docs/DECISIONS.md`.
|
||||
|
||||
Kept for the record:
|
||||
|
||||
Raised at P2 and **unsettled**. The port holds the last timed keyframe, which is
|
||||
right for an entry animation (the main menu settles at t=80, 1.33 s) and is
|
||||
proven on the screen P2 gates. The **title** runs to t=269 — 4.48 s — and there
|
||||
the port's settled pose and the decoders' `rest` disagree badly (max 142/255).
|
||||
|
||||
What is known: no element's alpha reverses direction anywhere in this export, so
|
||||
nothing pulses, which removes the obvious reason to expect a loop without
|
||||
disproving one. What would settle it: **a capture of build 4 alone**. The one
|
||||
live title capture composites the `PRESS Ⓐ` plate (build 2) over it, so it
|
||||
cannot be diffed against the title by itself.
|
||||
|
||||
⚠️ Independently, **both** of the port's modes draw a washed-out cyan glow over
|
||||
the title logo that the running game does not have. That is a third problem and
|
||||
it is P3's; it is noted here so nobody reads the loop question as its cause.
|
||||
|
||||
Not blocking anything today; raised because the port found them and a guess here
|
||||
would be believed later.
|
||||
|
||||
### ~~`rest_plateau` misfires on elements with no exit animation~~ — fixed
|
||||
|
||||
**Fixed 2026-08-28** in `sylpheed-formats`, and this port's pin moved
|
||||
`8b6dbcf → 5414db3` to take it. The rule adopted is **not** the condition this
|
||||
port proposed, which was too loose: a trailing run is the hold exactly when it
|
||||
is **visible**. The port's condition would have erased the word PAUSE on
|
||||
`pgptitle.rat`, whose trailing run is two identical *transparent* frames.
|
||||
|
||||
Kept for the record, since the reasoning is still what found it:
|
||||
|
||||
**This one is a decoder bug, not a question**, and it was the highest-value item
|
||||
on this page for the RE agent. `ui_layout::rest_plateau` excludes a run of
|
||||
identical keyframes that ends the group, on the grounds that it is the exit. For
|
||||
an element that **has no exit animation** the trailing run *is* the hold, and the
|
||||
rule falls back to an earlier run — for a slide-in, the invisible pre-roll.
|
||||
|
||||
The condition that identifies the affected elements exactly, with no false
|
||||
positives across this export, is: **the final untimed keyframe has the same pose
|
||||
as the last timed one.** Six elements match; `rest()` misses all six.
|
||||
|
||||
`ptframe1` and `ptframe2` on the main menu are the visible case, and
|
||||
`docs/re/captures/main-menu-oracle.png` settles it — the game draws the circuit
|
||||
bracket that `rest` calls invisible. `sylpheed-cli screen render` is missing it
|
||||
too, so this is not only a port concern.
|
||||
|
||||
The port needs nothing here: it derives the arrived pose from the keyframes and
|
||||
does not use `rest`. Filed because `rest()` is used elsewhere and because a
|
||||
capture already proves it.
|
||||
|
||||
### Does the game sample a scaled sprite at the pixel corner or the pixel centre?
|
||||
|
||||
Found at P1, by the only screen it could have been found on. `title_jp`'s
|
||||
`ptlogo_eff2` is the **single drawn element in the whole export** at a scale that
|
||||
is not a whole multiple of 100 % (125 %), and `title_jp` is the only one of the
|
||||
twelve screens whose Godot-vs-CLI diff exceeds 6/255.
|
||||
|
||||
The two renderers pick different source texels at a non-integer ratio.
|
||||
`sylpheed_formats::ui_layout::blit` samples at the destination pixel's **top-left
|
||||
corner** (`sxi = col * sw / dw`); a GPU samples at its **centre**
|
||||
(`floor((col+0.5)*sw/dw)`). At 125 % they disagree on one column in five — ~30
|
||||
pixels above 100/255, strung along thin diagonal edges. At every whole multiple
|
||||
of 100 % they agree exactly, which is why the other eleven screens are clean.
|
||||
|
||||
The port has **not** changed to match: matching would mean reproducing a half-
|
||||
pixel bias on purpose to make a number smaller. The question for the RE agent,
|
||||
when it is cheap: **a framebuffer capture of the Japanese title screen** would
|
||||
settle it outright, and it is the kind of thing a capture answers in one look.
|
||||
|
||||
Cost of being wrong either way: a one-texel edge on one glow, on a screen the
|
||||
English boot path never shows. This is filed, not urgent.
|
||||
|
||||
### The pivot is not half the texture on `GP_TITLE`
|
||||
|
||||
`sylpheed-formats`'s `ui_layout::Element::pivot_x` is documented as "for a `.t32`
|
||||
element this is exactly half the decoded texture's dimensions (verified 7/7 on
|
||||
the tutorial bundle)". Counting it over the whole of `GP_TITLE` as exported:
|
||||
|
||||
* **55 of 93** sprite-bearing `.t32` elements match within ±1 px.
|
||||
* **38 do not**, and several are not close: `ptlogo_back2` is 1118×262 with pivot
|
||||
(500, 117) where half is (559, 131); `ptmsg` is 223×38 with pivot (123, 19)
|
||||
where half is (111.5, 19) — the Y matches and the X does not.
|
||||
|
||||
This changes nothing today: the exporter emits the **declared** pivot and never
|
||||
derives one, and the pivot only affects drawing when scale ≠ 100 %. But it does
|
||||
matter, because scale is genuinely animated here — **177 keyframes** across
|
||||
`GP_TITLE` are not 100 %, including on the title screen the port must draw at P1.
|
||||
|
||||
The question for the RE agent, when it is cheap to answer: **does the running
|
||||
game anchor a scale to the declared pivot, or to half the texture?** The two
|
||||
differ by up to 59 px on `ptlogo_back2`, which is visible. Until then the port
|
||||
follows the decoders and uses the declared pivot, which is also what
|
||||
`sylpheed-cli screen render` does — so a P1 diff cannot distinguish them, and
|
||||
agreement between the two is not evidence.
|
||||
|
||||
**P1 has now been run and that prediction held.** The port and the CLI agree on
|
||||
every scaled element across all twelve screens; the question is untouched by it.
|
||||
It will stay untouched by P2 as well, since P2 animates the same two renderers'
|
||||
shared assumption. Only a capture answers this.
|
||||
811
godot-import/docs/DECISIONS.md
Normal file
811
godot-import/docs/DECISIONS.md
Normal file
@@ -0,0 +1,811 @@
|
||||
# Decisions
|
||||
|
||||
One entry per decision that outlives the container it was made in. Newest last.
|
||||
A decision that lives only in an agent's context is lost when that container
|
||||
dies, which is what this file is for.
|
||||
|
||||
---
|
||||
|
||||
## P0 — the exporter, 2026-08-28
|
||||
|
||||
### The exporter reads one authored file, and stamps its provenance into the output
|
||||
|
||||
`export/` is derived and `authored/` is hand-written, and the natural reading of
|
||||
that is that the exporter never touches `authored/`. But a screen has to be
|
||||
*called* something, and the disc does not name its builds — the identification of
|
||||
build 5 as the main menu is HANDOFF Q2, **measured against a live capture**, not
|
||||
a field.
|
||||
|
||||
Two ways to handle that:
|
||||
|
||||
1. the exporter emits `build_05.json` and the runtime renames it from
|
||||
`authored/screen_names.json`;
|
||||
2. the exporter reads that map and writes `main_menu.json` directly.
|
||||
|
||||
Chose **2**, with a condition: every name it applies carries `name_source:
|
||||
"authored"` and a `name_why` quoting the evidence, and `check` **rejects** an
|
||||
authored name with no `why`. The file that lands in `export/` is therefore still
|
||||
honest about which of its fields is a measurement — which is the property the
|
||||
derived/authored split exists to protect — while a human opening the tree sees
|
||||
`main_menu.json` rather than having to resolve a rename in their head. A build
|
||||
nobody has identified exports as `build_NN` with `name_source: "index"`, which is
|
||||
a locator and not a claim.
|
||||
|
||||
This is the **only** authored input the exporter takes. Everything else in
|
||||
`authored/` is applied by the runtime over `export/`.
|
||||
|
||||
### Sprites are per screen, not a flat pool
|
||||
|
||||
`main_menu` and `extras` both ship a `ptbase.t32` and they are different
|
||||
pictures. A flat `sprites/` directory would have silently collided; whichever
|
||||
screen exported second would have won, and the loser would have drawn the wrong
|
||||
background with no error anywhere. `sprites/<subdir>/<screen>/<name>.png`.
|
||||
|
||||
### The format is executable
|
||||
|
||||
`sylpheed-export check --out export` validates a tree against `docs/FORMAT.md`
|
||||
with no disc in hand. It exists because "the export is correct" is otherwise an
|
||||
assertion, and because the P0 gate is *"validates against FORMAT.md"* — which is
|
||||
not a thing anyone can confirm by reading.
|
||||
|
||||
It reads the tree the way Godot will: as a stranger, with no access to the disc,
|
||||
the decoders, or the exporter's internals. It deliberately does **not** check the
|
||||
export against the disc — that is what `sylpheed-cli screen render` is for, at P1.
|
||||
|
||||
Checked that it bites, rather than assuming: five mutations of a valid
|
||||
`main_menu.json` — a broken `paint_order` permutation, a dangling
|
||||
`focus_sprite`, a reversed `buttons` list, a `#rrggbbaa` colour, an invented
|
||||
`name_source` — are each caught with a specific message.
|
||||
|
||||
### The highlight sprite pairs by name; `opt ` is exported but not believed
|
||||
|
||||
FORMAT v1 said `focus_sprite` came from the element's `opt ` link. That reading
|
||||
was **measured and refuted** by the RE agent, and this export shows why plainly:
|
||||
on the main menu, `opt ` chains `ptloop01 → ptloop02 → ptbtn01` — two decorations
|
||||
and then a button. It is a linked list of something, and it is not focus.
|
||||
|
||||
The highlight is paired by **sprite name** instead (`ptbtn01.t32` ↔
|
||||
`ptbtn01f.t32`), which is HANDOFF's convention and holds for all 54 real pairs on
|
||||
the disc. It resolves all five main-menu buttons. The raw link is still exported
|
||||
as `opt_link`, renamed so that nothing downstream mistakes it for navigation, and
|
||||
so that whoever eventually decodes it has the data.
|
||||
|
||||
Note this is 🟡 a naming convention, not a decoded field. It is authored in
|
||||
effect, and lives in the exporter only because it is a rule over disc data rather
|
||||
than a value we chose.
|
||||
|
||||
### The paint order is exported, not authored
|
||||
|
||||
Q3 decoded it — a `u16` layer key at `+0x0A` of each `T8aD` sprite header,
|
||||
stable-sorted with declaration index. So it is read in the exporter, per the
|
||||
contract's own rule for a decoded answer, and `paint_order` in `export/` is a
|
||||
derived field. `"paint_order"` is gone from `unresolved`; **`paint_order_ties`
|
||||
replaces it**, because the tie-break is still unknown and costs one element's
|
||||
blend on one screen.
|
||||
|
||||
Where an element has no `T8aD` header the key comes from the decoders' table of
|
||||
keys **measured off the running game**. That is a different kind of fact, so it
|
||||
is labelled: `layer_source` is `"sprite"`, `"implied"` or `"none"`, and a
|
||||
consumer that needs to know whether a layer is read or measured can tell.
|
||||
|
||||
### Colours are exported as two fields with the byte order in the name
|
||||
|
||||
There are two modulate colours and they multiply: `tint` is RGBA, `fade` is
|
||||
**ARGB** and its high byte is the alpha that ramps. v1's single `"#ffffffff"`
|
||||
could not carry both and silently discarded the ramping alpha. They are exported
|
||||
as `tint_rgba` and `fade_argb`, raw hex, byte order in the key — because getting
|
||||
it backwards is silent and looks like an art bug rather than a parse bug.
|
||||
|
||||
### `t` stays raw
|
||||
|
||||
HANDOFF Q1 is answered — linear ramp, 2 units per rendered frame, working
|
||||
conversion 1 unit = 1/60 s — but that conversion is **measured off the running
|
||||
game, not read from the file**, and the finding itself flags the 27.6 present-
|
||||
frames/second measurement as the part worth re-testing. If the game turns out to
|
||||
present at 60 Hz, every duration halves.
|
||||
|
||||
So `t` is exported exactly as the disc spells it, `keyframe_time_unit` stays in
|
||||
`unresolved`, and the conversion will live in one authored place at P2. One
|
||||
constant to change, in a file that says it is a decision.
|
||||
|
||||
### The final keyframe has no `t`, and `check` enforces that
|
||||
|
||||
The disc has no time slot on the last keyframe of a group. A file that carries
|
||||
one there has invented it. `check` rejects it — this is the one place where the
|
||||
temptation to emit a plausible number is strongest and the resulting error is
|
||||
completely invisible.
|
||||
|
||||
---
|
||||
|
||||
## P1 — Godot draws the screen, 2026-08-28
|
||||
|
||||
### The Godot side reads the manifest, not a path
|
||||
|
||||
`ExportTree` is the only class that knows where `export/` is: `SYLPHEED_EXPORT`
|
||||
if set, otherwise `<project>/../export`. Screens are addressed by their manifest
|
||||
**name** (`main_menu`), never by a file path, so the runtime never encodes the
|
||||
archive's subdirectory and a re-export that moves a file does not break it. It
|
||||
also checks `format` on both the manifest and each screen, and refuses a tree it
|
||||
was not built to read rather than half-drawing one.
|
||||
|
||||
Textures are read as bytes and decoded with `load_png_from_buffer` at runtime.
|
||||
They are deliberately **not** Godot-imported resources: `export/` is gitignored
|
||||
and regenerated wholesale, and a `.import` sidecar per sprite would be derived
|
||||
state living next to derived state, invalidated on every re-export.
|
||||
|
||||
### One CanvasItem draws the whole screen
|
||||
|
||||
`ScreenView._draw` walks `paint_order` and draws each element itself, rather
|
||||
than making a node per element and leaning on `z_index`. The export's
|
||||
`paint_order` is already back-to-front, so honouring it is a loop; expressing
|
||||
the same order through sixteen nodes' z-indices would hide the one thing that is
|
||||
still unresolved about it — the **ties** — behind Godot's own sibling rules,
|
||||
where a change in the export would silently become a change in Godot's tree
|
||||
order instead of a visible change in the draw sequence.
|
||||
|
||||
### P1 draws `rest` and nothing else
|
||||
|
||||
Every element is drawn at its resting pose. No keyframe interpolation: that is
|
||||
P2, and it depends on the keyframe time unit, which is **measured** rather than
|
||||
decoded. A milestone whose gate is a pixel diff must not have a measured
|
||||
constant inside it, or the diff stops being evidence about the port.
|
||||
|
||||
For the same reason `focused_id` is empty at P1. Initial focus was measured as
|
||||
unstable boot to boot (HANDOFF Q5), so choosing one is an authored decision and
|
||||
it belongs to P5, where a human is pressing keys.
|
||||
|
||||
### Nearest-neighbour, and why that is not a preference
|
||||
|
||||
`TEXTURE_FILTER_NEAREST`. The export is a 1:1 copy of the disc's own texels and
|
||||
elements draw at up to 500 %; a bilinear filter invents detail the disc does not
|
||||
have. It is also what the reference renderer does — `ui_layout::blit` maps
|
||||
destination to source by integer division — so a filter difference cannot
|
||||
masquerade as a placement difference in the diff.
|
||||
|
||||
### The capture is the SubViewport, not the window
|
||||
|
||||
The screen is drawn into a `SubViewport` sized to the export's own `design`
|
||||
rectangle and shown through a container that scales it to the window. The first
|
||||
attempt captured `get_viewport()` and got **1235×695**: there is a window manager
|
||||
on the Xvfb display and its title bar had eaten 45×25 px of a screen the export
|
||||
declares as 1280×720. A gate that compares a rescaled 1235×695 capture against a
|
||||
1280×720 composite measures the compositor.
|
||||
|
||||
So `--capture` grabs the SubViewport texture: exactly the design rectangle,
|
||||
independent of the window, directly comparable with `screen render` with no crop
|
||||
and no resample. The windowed run is still worth doing — it is what proves a
|
||||
human sees the screen — but it is not what the numbers come from.
|
||||
|
||||
## P1 gate — the diff, and what it found
|
||||
|
||||
`tools/verify-screen` renders every screen in the manifest both ways and reports
|
||||
the largest per-channel difference anywhere in the frame. Both renderers are held
|
||||
to the same inputs: the reference CLI built by `build-reference-cli` from the
|
||||
revision the exporter is **pinned** to (not `/reborn/target/`, which is a live
|
||||
mount that moves mid-iteration), `--black` because the screen carries its own
|
||||
background, and `--primitives --animated` because those are what make the CLI
|
||||
draw the same element set the port draws at rest.
|
||||
|
||||
| screen | build | max per-channel Δ | |
|
||||
|---|---|---|---|
|
||||
| `main_menu` | 5 | **3** | the P0/P1 gate screen |
|
||||
| `main_menu_jp` | 8 | 3 | |
|
||||
| `extras` / `extras_jp` | 6 / 9 | 4 / 3 | |
|
||||
| `press_start` / `press_start_jp` | 2 / 3 | 1 | |
|
||||
| `build_00` / `build_01` | 0 / 1 | 3 | |
|
||||
| `build_10` / `build_11` | 10 / 11 | **0** | byte-identical |
|
||||
| `title` | 4 | 6 | paint-order tie, below |
|
||||
| `title_jp` | 7 | 154 | sampling phase, below |
|
||||
|
||||
`main_menu` — the milestone's own gate — agrees to **≤3/255 on every channel of
|
||||
every pixel**, RMSE 0.38 %, with **no** pixel differing by more than 4 %. 3/255
|
||||
is what integer-truncating compositing in the CLI and float rounding on a GPU
|
||||
differ by; there is no structural disagreement anywhere in the frame.
|
||||
|
||||
Three screens exceed that, and each has a named cause rather than a threshold.
|
||||
|
||||
### `title`: a tie in the paint order — neither renderer is wrong
|
||||
|
||||
Build 4 is the one screen where the CLI uses a paint order **measured off the
|
||||
running game** instead of deriving it. Compared against the order this port
|
||||
exports, every single disagreement is **inside a tie** — the two orders differ
|
||||
only among elements carrying *identical* layer keys (`0x8083`, the `back2` glow
|
||||
group, and `0x80a0`):
|
||||
|
||||
```
|
||||
derived : … 15, 16, 17, 18, 0, 1, 2, 3, 4, 5, 7, …
|
||||
measured: … 15, 18, 16, 17, 0, 2, 4, 7, 1, 3, 5, …
|
||||
```
|
||||
|
||||
That is exactly the residual HANDOFF Q3 documents and this export already
|
||||
declares in `unresolved: ["paint_order_ties"]`. It is worth stating what it
|
||||
costs: **904 px** in the glow band at (445,117)–(1195,313), all of them 4–6/255.
|
||||
The port keeps the stable sort, per HANDOFF's own recommendation. Nothing to fix,
|
||||
and nothing to tune — a "fix" here would be fitting the port to one screen's
|
||||
capture.
|
||||
|
||||
Two of the reordered indices (`0x80a0`) are `kind & 0x4` template instances that
|
||||
both renderers skip, so the only real reorder outside the glow group is
|
||||
`ptlogo2` against `ptlogo_tm`, which do not overlap.
|
||||
|
||||
### `title_jp`: nearest-neighbour sampling phase — the CLI is the one I would call wrong
|
||||
|
||||
`title_jp` is the **only** screen in the export with a drawn element at a scale
|
||||
that is not a whole multiple of 100 %: `ptlogo_eff2` at 125 %. It is also the
|
||||
only screen with a difference above 6/255. The two facts are the same fact.
|
||||
|
||||
At a non-integer ratio the two renderers pick different source texels:
|
||||
|
||||
* `ui_layout::blit` samples the source at the destination pixel's **top-left
|
||||
corner** — `sxi = col * sw / dw`.
|
||||
* A GPU samples at the destination pixel's **centre** — `floor((col+0.5)·sw/dw)`.
|
||||
|
||||
At 125 % those disagree on one column in five, which is why the differing pixels
|
||||
are ~30 above 100/255 strung along thin diagonal edges rather than a shifted
|
||||
region. At every whole multiple of 100 % they agree exactly, which is why the
|
||||
other eleven screens are clean.
|
||||
|
||||
**Which is wrong:** the CLI, I think. Corner-sampled nearest is a half-
|
||||
destination-pixel bias toward the top-left that no rasteriser produces, and the
|
||||
Xenon GPU that drew this screen sampled at pixel centres. But I have no
|
||||
framebuffer capture of `title_jp` and the disagreement is sub-pixel on one glow,
|
||||
so this is a reading, not a measurement — recorded in `docs/BLOCKED.md` rather
|
||||
than acted on. **The port is not changing to match**, because matching the CLI
|
||||
here would mean deliberately reproducing a half-pixel offset in order to make a
|
||||
number smaller.
|
||||
|
||||
### `extras`: two pixels
|
||||
|
||||
Two pixels at 4/255. Rounding.
|
||||
|
||||
### What the diff cannot tell us
|
||||
|
||||
The pivot question in `docs/BLOCKED.md` predicted that a P1 diff could not
|
||||
distinguish "anchor scale to the declared pivot" from "anchor to half the
|
||||
texture", because both renderers use the declared pivot. That prediction held:
|
||||
the port and the CLI agree on every scaled element, and that agreement is **not
|
||||
evidence** about which anchor the game uses. It stays open.
|
||||
|
||||
### ~~`pteff05.t32` and `pteff04.t32` have no sprite, and that is correct~~
|
||||
|
||||
**RETRACTED 2026-08-29. This was wrong, and it was the most consequential thing
|
||||
on this page.** See "The menu had no background" below.
|
||||
|
||||
---
|
||||
|
||||
## P2 — keyframe animation, 2026-08-28
|
||||
|
||||
### The time unit is authored, in one file, and says loudly that it is not on the disc
|
||||
|
||||
`authored/timing.json`. HANDOFF Q1 is answered — linear ramp, 2 units per
|
||||
rendered frame, 1 unit = 1/60 s — but that conversion is **measured off the
|
||||
running game**, not read from a file, which is exactly the case the
|
||||
derived/authored split exists for. It is expressed as
|
||||
`keyframe_units_per_second: 60` rather than seconds-per-unit so the value is
|
||||
exact instead of a repeating decimal, and it carries the two independent lines
|
||||
that support it. `t` stays raw everywhere in `export/`; seconds appear only
|
||||
where this file is applied, which is one line of `boot.gd`.
|
||||
|
||||
`exit_ramp_seconds` is deliberately **null**. See below.
|
||||
|
||||
### The timeline stops at the last *timed* keyframe, and never plays the exit
|
||||
|
||||
The last keyframe of every group carries **no `t`** — the disc has no time slot
|
||||
there. Across this export that final frame is an *exit* pose: for 116 of 134
|
||||
elements it differs from the last timed keyframe **in alpha only** (a fade-out),
|
||||
for 12 it is the loading splash's scale-and-slide exit, and for 6 it is
|
||||
identical (no exit animation at all).
|
||||
|
||||
So the group is `pre-roll → ramp in → hold → [exit]`, and the port plays it up to
|
||||
the hold and stops. Playing into the exit would mean **inventing how long the
|
||||
ramp takes**, because the disc does not say. That duration is the screen
|
||||
transition — HANDOFF Q7 measured it at ~0.4 s — and it belongs to P3, with its
|
||||
own evidence. This is why `exit_ramp_seconds` is null rather than 0.4: P2 has no
|
||||
business holding it.
|
||||
|
||||
### The interpolation is checked by where it lands, not by inspection
|
||||
|
||||
For **8 of the 12** screens the settled timeline is **byte-identical** to the
|
||||
`--pose=rest` render. That is the useful assertion: the port walks the keyframes
|
||||
with an authored time unit and arrives, to the pixel, at the pose the pinned
|
||||
decoders independently identify as the resting one. `tools/screen-strip` reports
|
||||
this per screen, so a change to the interpolation that drifts by one unit shows
|
||||
up as a diff rather than as nothing.
|
||||
|
||||
The four that differ do so for two distinct reasons, below.
|
||||
|
||||
## `rest` misidentifies six elements, and the running game says so
|
||||
|
||||
On `main_menu`, the settled timeline and `rest` differ in exactly one region:
|
||||
**400×470 at (440,108)** — the bounding box of `ptframe1` and `ptframe2`, and
|
||||
nothing else on the screen.
|
||||
|
||||
`rest` puts both at their **first** keyframe: off-position and fully
|
||||
transparent. The keyframes say they slide (620,108)→(440,108) and (403,267)→
|
||||
(583,267) while fading 0x00→0xff, and then hold that pose for their last three
|
||||
keyframes including the untimed one.
|
||||
|
||||
`/reborn/docs/re/captures/main-menu-oracle.png`, a capture of the running game,
|
||||
**shows them**: the bright circuit-frame bracket around the menu, with a ring at
|
||||
the bottom right. Cropping the same 250×180 region from the capture and from
|
||||
both renders puts the ring and its elbow trace in the port's timeline render
|
||||
**pixel-aligned with the game's**, and absent from the `rest` render. That is
|
||||
geometry, not luminance, so it does not depend on the capture's gamma or on the
|
||||
fact that it was taken with `NEW GAME` focused.
|
||||
|
||||
### Why the decoders get it wrong, precisely
|
||||
|
||||
`ui_layout::rest_plateau` excludes a run of identical keyframes that **ends the
|
||||
group**, because that run is normally the exit — the comment cites the pause
|
||||
menu, where taking the trailing run erased the word PAUSE. That exclusion is
|
||||
right in general and wrong for an element with **no exit animation**, where the
|
||||
trailing run *is* the hold. The rule then falls back to an earlier run, which
|
||||
for a slide-in is the invisible pre-roll.
|
||||
|
||||
The condition that identifies the affected elements exactly, with no false
|
||||
positives in this export, is:
|
||||
|
||||
> the final untimed keyframe has the **same pose** as the last timed keyframe
|
||||
|
||||
Six elements match it and `rest` misses all six: `ptframe1`/`ptframe2` on
|
||||
`main_menu` and `main_menu_jp`, and `pteff02` on `title` and `title_jp`. This is
|
||||
a **finding for the RE agent** about `sylpheed-formats`, not something this port
|
||||
fixes: the decoders are pinned and must not be reimplemented here. The port
|
||||
simply does not use `rest` — it derives the arrived pose from the keyframes,
|
||||
which needs no heuristic — and `verify-screen` still asks for `--pose=rest` so
|
||||
that renderer-vs-renderer diffing compares like with like.
|
||||
|
||||
Note what this says about P1: the port and the reference renderer **agreed** on
|
||||
`main_menu` to 3/255, and both were missing two elements the game draws. Two
|
||||
renderers reading the same field through the same decoder agreeing is not
|
||||
evidence that the field is right. `docs/BLOCKED.md` had already said that about
|
||||
the pivot; here it bit for real.
|
||||
|
||||
## The title is not settled, and P2 does not claim it
|
||||
|
||||
`title` and `title_jp` differ between the two modes by much more (max 142 and
|
||||
247), and there the disagreement is **not** the six-element bug alone. `rest`
|
||||
picks a mid-timeline hold for several glows (`pteff01`, `ptlogoall_eff`,
|
||||
`ptlogoall_eff2`, `ptlogo_back2eff5`) where the timeline runs on to a much
|
||||
brighter pose.
|
||||
|
||||
I could not settle which is right, and did not try to make the numbers agree:
|
||||
|
||||
* No element's alpha ever reverses direction anywhere in this export, so the
|
||||
title's 4.48 s timeline is a slow one-way ramp, not a pulse — which removes the
|
||||
obvious reason to expect a loop, but does not prove there is none.
|
||||
* The only live title capture composites the **`PRESS Ⓐ` plate (build 2) over
|
||||
the title (build 4)**, so it cannot be diffed against build 4 alone. Mean
|
||||
luminance is oracle 64.1, `rest` 62.8, timeline 80.0 — which looks like it
|
||||
favours `rest`, except that the plate *adds* brightness and `rest` is carrying
|
||||
a 25 % black dim quad (`pteff02`) that is itself one of the six misidentified
|
||||
elements. The comparison is confounded in both directions and settles nothing.
|
||||
* **Both modes are visibly wrong anyway.** Side by side with the capture, the
|
||||
port draws a washed-out cyan glow slab across the logo that the running game
|
||||
does not have — in `rest` mode too. That is a third problem, independent of
|
||||
this one, and it is P3's.
|
||||
|
||||
So: the timeline is the default because it is derived from the disc's own
|
||||
keyframes with one measured constant and no heuristic, and because it is proven
|
||||
right on the screen this milestone gates. On the title it is **unverified**, and
|
||||
P3 should not assume P2 settled it.
|
||||
|
||||
---
|
||||
|
||||
## P2, corrected — the pin moved, and the settle rule was wrong, 2026-08-28
|
||||
|
||||
### Answering the RE agent's question: which six, and on what screens
|
||||
|
||||
They asked, having found only two elements on the English main menu satisfying
|
||||
the condition this port proposed. The six span the whole 12-screen export:
|
||||
|
||||
| element | screens | trailing run |
|
||||
|---|---|---|
|
||||
| `ptframe1`, `ptframe2` | `main_menu`, `main_menu_jp` | alpha `0xff` — **visible** |
|
||||
| `pteff02` | `title`, `title_jp` | alpha `0x00` — **transparent** |
|
||||
|
||||
So four of the six are the pair they already found, once per language build, and
|
||||
their alpha rule accepts exactly those. The other two are `pteff02`, whose
|
||||
trailing run is transparent, so their rule **excludes** it and leaves `rest` at
|
||||
`0x40`.
|
||||
|
||||
**That exclusion is right, and their own measurement proves it.** `pteff02` is
|
||||
the 25 % dim quad; they measured the title render going from **+13.14 to +0.55**
|
||||
against the plate-free capture once the dim is drawn. `rest` must therefore stay
|
||||
at `0x40` and must *not* move to the transparent trailing run — which is what
|
||||
their rule does. Two investigations converging from opposite directions.
|
||||
|
||||
The condition this port proposed was **too loose**; the alpha discriminator is
|
||||
the correct rule and the port has no amendment to offer.
|
||||
|
||||
### The pin moved 8b6dbcf → 5414db3
|
||||
|
||||
Its own commit, and what I wanted from it is the fixed `ui_layout::rest()`.
|
||||
Pinned at `5414db3` rather than `4bc9706` where the fix was written, because
|
||||
`5414db3` is where it carries its disc-wide check — 30 of 13 991 elements move,
|
||||
4 become visible, **0 become invisible**.
|
||||
|
||||
The re-export is the evidence the change was contained: **two files changed, and
|
||||
within them exactly four `rest` blocks** — `ptframe1`/`ptframe2` on both main
|
||||
menus moving from `(620,108)/(403,267)` at `t=16` and alpha `0x00` to
|
||||
`(440,108)/(583,267)` at `t=62` and alpha `0xff`. Every diff line pairs; the
|
||||
other ten screens are byte-identical, `pteff02` did not move, and no sprite
|
||||
changed.
|
||||
|
||||
### The settle rule was wrong, and their title finding is what showed it
|
||||
|
||||
P2 shipped "hold the last **timed** keyframe", on the reasoning that the exit is
|
||||
the final untimed frame. **That is wrong**, and the title is the counter-example:
|
||||
`pteff02` holds at `t=46` with the dim at alpha `0x40` and then ramps to `0x00`
|
||||
by `t=236`. The exit is not only the untimed frame — it can be a long run of
|
||||
timed ones. Running to the end drops the dim and makes the whole screen ~13/255
|
||||
too bright, which is exactly the luminance excess P2 recorded (oracle 64.1,
|
||||
`rest` 62.8, timeline 80.0) and could not explain.
|
||||
|
||||
A group is `pre-roll → ramp in → hold → ramp out → post-roll`, and a screen that
|
||||
has arrived sits on **the hold**. So the timeline now plays in and stops at
|
||||
`rest`, which is the decoders' identification of that hold and carries its own
|
||||
`t`. `settle_units()` is `rest.t`.
|
||||
|
||||
The check is that the disagreement vanishes: on **all twelve** screens the
|
||||
settled timeline is now byte-identical to the `--pose=rest` render, where before
|
||||
this change four of them differed by up to 247/255. The timeline's endpoint
|
||||
*should* be `rest` — the animation is what the timeline adds, not a different
|
||||
destination — so this is the property to want, and it now holds without a
|
||||
special case.
|
||||
|
||||
That also retires P2's open question about looping, from the other side: the RE
|
||||
agent measured that groups hold rather than loop (`ptloop01`/`ptloop02` park
|
||||
off-screen at x=1521 and x=−839; 18 s of settled title sits at sd ≤ 0.01).
|
||||
|
||||
## The reference renderer was stale for three diff runs
|
||||
|
||||
Worth recording as a process failure, because it defeated the project's whole
|
||||
verification method for a while and it failed *silently*.
|
||||
|
||||
After bumping the pin I rebuilt the reference CLI, and `build-reference-cli`
|
||||
reported success at rev `5414db3`. `verify-screen` then showed `main_menu`
|
||||
jumping from 3/255 to **72/255**. The natural reading — the port had regressed —
|
||||
was wrong. The port was right and **the reference was a revision behind**: the
|
||||
shared `CARGO_TARGET_DIR` still held a `sylpheed-cli` built from `8b6dbcf`, and
|
||||
cargo reported `Finished in 0.13s` and left it in place. Building into a clean
|
||||
target directory produced a binary that resolves `ptframe1` to `(440,108) t=62`;
|
||||
the shared one still said `(620,108) t=16`.
|
||||
|
||||
The old check — "does `screen list` run?" — cannot catch this, because a stale
|
||||
binary runs perfectly.
|
||||
|
||||
Two changes:
|
||||
|
||||
* `build-reference-cli` builds into `$CARGO_TARGET_DIR/reference-cli/$rev`, a
|
||||
tree **keyed by the pinned revision**, so a new pin has no artifacts to reuse.
|
||||
A stable copy is placed alongside for consumers.
|
||||
* It then checks the binary **against `export/`**: both come from the same pin,
|
||||
so if the CLI resolves `ptframe1`'s rest differently from what the exporter
|
||||
wrote, the two halves of the verification are not the same revision and it
|
||||
fails loudly. It compares the two rather than asserting a literal, so it stays
|
||||
true when the pin moves again.
|
||||
|
||||
`docker/bin/` is baked into the image, so this takes effect on the next image
|
||||
build; until then the repo copy has to be invoked by path. The RE agent hit the
|
||||
same class of trap this session from the other side (`./target/debug` stale
|
||||
against a redirected `CARGO_TARGET_DIR`). It is worth naming the general shape:
|
||||
**a build system reporting success is not evidence that the artifact you are
|
||||
about to trust is the code you pinned.**
|
||||
|
||||
### What this did not change
|
||||
|
||||
`title` (6/255), `extras` (4/255) and `title_jp` (154/255) are unchanged, and
|
||||
their diagnoses stand — a paint-order tie, two pixels, and nearest-neighbour
|
||||
sampling phase at 125 % scale. The title's swoosh defect the RE agent localised
|
||||
(drawn thick and white where the game draws it thin and pink) is untouched by
|
||||
any of this and remains P3's.
|
||||
|
||||
---
|
||||
|
||||
## The menu had no background, and P1 called that correct, 2026-08-29
|
||||
|
||||
The pin moved `5414db3 → f817dd5` for `56cc7ac`, "a RATC child's name is stated,
|
||||
not inferred". `ratc::parse` had named each child by scanning backwards for the
|
||||
last printable run of bytes before its magic. For `pteff05.t32` the three
|
||||
trailing payload bytes are `38 41 58` — `8AX` — which beat the real name, so the
|
||||
child registered under a name no element declares and resolved to no sprite.
|
||||
|
||||
`pteff05.t32` is the **full-resolution background of all five menu screens**.
|
||||
|
||||
So every render this port has produced of a menu screen has been missing its
|
||||
background, and P1 wrote that up as a property of the disc: *"the bundle declares
|
||||
them and carries zero RATC children for either, so there is no texture on the
|
||||
disc to export."* That sentence was false. The bundle carries the child; the
|
||||
decoder was handing back the wrong name for it. Retracted above rather than
|
||||
edited away.
|
||||
|
||||
### What the re-export shows
|
||||
|
||||
Six new sprites and nothing else: `pteff05.png` on `main_menu`, `extras` and
|
||||
their Japanese twins, `pteff04.png` on both titles. Per screen the JSON gains a
|
||||
`sprite` line and `layer_source` moves `"implied" → "sprite"` — the layer key is
|
||||
now **read from the file** instead of taken from the decoders' table of keys
|
||||
measured off the running game. That is the derived/authored ratchet turning the
|
||||
right way, in the exporter rather than in `authored/`.
|
||||
|
||||
`pteff05.png` is **1280×720**; `ptbase.png`, which had been carrying the
|
||||
background alone, is 640×360 drawn at 200 %. The screen was being shown its own
|
||||
art at half resolution.
|
||||
|
||||
### Measured against the live capture, not against the other renderer
|
||||
|
||||
Whole-frame RMSE of the settled `main_menu` against
|
||||
`captures/main-menu-oracle.png`:
|
||||
|
||||
| | RMSE |
|
||||
|---|---|
|
||||
| before this pin | 8.05 % |
|
||||
| with the real background | **5.92 %** |
|
||||
|
||||
A 26 % reduction, and it is the right kind of evidence: the reference renderer
|
||||
was missing the same element for the same reason, so a renderer-vs-renderer diff
|
||||
could not have found this. It is the third time on this project that the
|
||||
capture caught something both renderers agreed on — the bracket, the title dim
|
||||
quad, and now the background.
|
||||
|
||||
`verify-screen` after the bump is unchanged in character: everything at 3–4/255
|
||||
except `title` (6, the paint-order tie) and `title_jp` (155, the sampling phase).
|
||||
Both renderers gained the background together.
|
||||
|
||||
### One thing the comparison says that I did not expect
|
||||
|
||||
Rendering with `--focus=ptbtn01`, which is how the capture was taken, makes the
|
||||
RMSE **worse** — 5.92 % → 7.00 %. The port *replaces* an element's sprite with
|
||||
its `*f` twin; `sylpheed-cli`'s own `--focus` is documented as drawing the
|
||||
focused record **over** the base element. Those are different operations, and
|
||||
the capture shows a ring marker beside `NEW GAME` that the port does not draw.
|
||||
|
||||
This is P5's, not P2's, and it is not being guessed at here. Raised in
|
||||
`docs/BLOCKED.md`.
|
||||
|
||||
---
|
||||
|
||||
## P3 — splash → title, unattended, 2026-08-29
|
||||
|
||||
### The splash is located by entry index, because no rule can find it
|
||||
|
||||
The RE agent looked for a content predicate and there is none: design size fails
|
||||
(every extra composable bundle sampled is 1280×720, the same as every screen) and
|
||||
element count fails (fragments run 2…15 elements in `GP_OPTIONS`/`GP_SAVE_LOAD`
|
||||
while the splash halves are 3 and 7 — the ranges overlap).
|
||||
|
||||
So `screen_builds` is now `is_build` **plus an authored allow-list of entry
|
||||
indices**, in `authored/screen_names.json` under `also_export`, each with a `why`
|
||||
that says it is a locator and not a claim. This is safe in `GP_TITLE` and would
|
||||
not be in general: there, widening adds exactly four bundles and all four are
|
||||
real screens with zero fragments. That is why it is an allow-list rather than a
|
||||
loosened predicate.
|
||||
|
||||
**There were two splash screens and the port had neither.** Entries 11/14 are the
|
||||
developer logos (GAME ARTS / SETA / studio anima); entries **10/13 are the SQUARE
|
||||
ENIX publisher wordmark, the first thing the boot shows**, and nothing in this
|
||||
project had noticed them. Both pairs are region twins — ™ on 10, ® on 13 — and
|
||||
the port shows one of each, not both.
|
||||
|
||||
### `authored/screen_names.json` is now keyed by pak entry, not by ordinal
|
||||
|
||||
Widening the enumeration renumbers the ordinals, and a name that moves when the
|
||||
enumeration rule changes is not a name. The file had always called the entry
|
||||
"the stronger locator"; it is now the only stable one. In `GP_TITLE` the two
|
||||
coincide across all 16 entries, which is also the numbering `sylpheed-cli screen
|
||||
--build N --all` takes — so `verify-screen` now passes `--all`, and without it
|
||||
`--build 10` would have landed on entry 12.
|
||||
|
||||
The two previously-unnamed plates therefore renamed `build_10`/`build_11` →
|
||||
`build_12`/`build_15`. Their names were always locators; now they locate the
|
||||
right thing.
|
||||
|
||||
### The exit is the group playing itself out, not a black rect over a freeze
|
||||
|
||||
HANDOFF's answer to ask 2 was (a), and it came with a test that discriminates
|
||||
rather than a plausibility argument. Under "a black quad over a frozen screen"
|
||||
every region is scaled by the same 1−α, so the button-region / background-region
|
||||
brightness **ratio** stays constant through the fade. Measured, it falls
|
||||
6.495 → 5.574 → 3.105 → 2.125 → 1.935 — a 3.4× monotonic drop. The screen plays
|
||||
out: `pteff00.prm` ramps to opaque black while the labels, `ptmsg`, `pteff10`
|
||||
and `pteff12` ramp to transparent, and `ptframe1`/`ptframe2` hold.
|
||||
|
||||
Implemented by giving the final untimed keyframe a **synthetic time**,
|
||||
`exit_ramp_units` after the last timed one, and then interpolating it like any
|
||||
other. One code path: the difference between arriving and leaving is only how far
|
||||
`t` is allowed to run, not a second kind of animation.
|
||||
|
||||
`exit_ramp_units = 24` (~0.4 s) is authored, and `authored/timing.json` carries
|
||||
the RE agent's own reach caveat rather than smoothing it: the filmstrip is
|
||||
downsampled and the button region contains some background, so this pins the
|
||||
**direction**, not 0.4 s to ±0.05 s, and it is one transition pair.
|
||||
|
||||
### Nothing waits on a timer the disc does not carry
|
||||
|
||||
`dwell` in `authored/flow.json` is deliberately empty. Each screen's dwell is its
|
||||
own keyframe group — the publisher wordmark reaches its hold at t=235 (3.92 s),
|
||||
the developer logos at t=190 (3.17 s), both read from the disc. Adding a hold on
|
||||
top would be inventing a number nobody measured. The pacing you see is the
|
||||
disc's own, and the file says where a measured number would go.
|
||||
|
||||
### The last screen holds
|
||||
|
||||
A screen plays itself out because something is taking its place. Nothing takes
|
||||
the title's place yet, so the sequencer holds there. A boot that ends by fading
|
||||
to black is a boot that looks like it crashed. P4 puts the intro video in front
|
||||
of the title and P5 gives the title somewhere to go.
|
||||
|
||||
### `flow.json` reproduces an observation and says so
|
||||
|
||||
Q6 closed with a negative: the order is in none of the four places it could have
|
||||
been, and a transition is a call with a name argument chosen by code. So this
|
||||
file is authored and its header says plainly that it reproduces what was watched,
|
||||
not what any file states. The intro video's place in the real boot is **named as
|
||||
a gap** rather than the order being quietly rewritten to hide it.
|
||||
|
||||
## P3 gate
|
||||
|
||||
`godot --path port -- --boot --film=/tmp/boot` runs unattended:
|
||||
|
||||
```
|
||||
publisher_logo → developer_logos at 4.65 s → title at 8.57 s
|
||||
boot sequence complete after 13.05 s, holding on title
|
||||
```
|
||||
|
||||
The filmstrip shows each screen fading in, holding, and fading through black into
|
||||
the next, and the title staying up. `verify-screen` covers all **16** screens
|
||||
now; the four new splash bundles come in at max 1–2/255 against the reference
|
||||
renderer. The three known differences are unchanged: `title` 6 (paint-order tie),
|
||||
`main_menu` 4, `title_jp` 155 (sampling phase at 125 % scale).
|
||||
|
||||
## Answers taken from the RE agent without re-deriving them
|
||||
|
||||
* **Focus stays "replace".** Over-vs-instead is unobservable: the focused sprite
|
||||
covers the base at 100 % of base-visible pixels, and the two compositions
|
||||
differ by RMSE 1.1 inside the button rect — under the gamma floor. The port's
|
||||
guess was right for the wrong reason, and the actual gap is that
|
||||
`ptbtn0Nf.rat` declares **two** sprites — `ptbtneff01.t32`, a glowing ring, and
|
||||
then the bright label — where `ptbtn0N.rat` declares one. The ring is P5's, and
|
||||
its placement inside the record is **not decoded**, so it will be authored from
|
||||
the capture and marked as such.
|
||||
* **RMSE against captures has a floor, so stop chasing it.** The capture is
|
||||
`≈ 255·(render/255)^γ` with γ ≈ 1.49 on the menu and `EXTRAS`, 1.34 on the
|
||||
title, and it is a ramp *the game installed* (`VdGetCurrentDisplayGamma` at
|
||||
video init), not a capture-path artefact to subtract. Its reach is narrow —
|
||||
the flat patches it was fitted on are almost all dark — so the port will not
|
||||
extrapolate it across the range, and will not apply it to rendered output on
|
||||
this evidence. It is a comparison constant, not a rendering one.
|
||||
* **Rotation is escalated to a human and the port has not acted.** The RE half is
|
||||
answered — rotate about the **declared pivot**, measured against the GPU
|
||||
capture — and it has zero effect on the five screens at rest. The port will
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## RETRACTION — `sylpheed-cli` is not the oracle, 2026-08-29
|
||||
|
||||
**This corrects a framing that runs through everything above, so it is a
|
||||
retraction rather than an edit.** Every place this file called
|
||||
`sylpheed-cli screen render` *"the reference renderer"* — and it does so
|
||||
repeatedly, starting at P1 — overstated what it is.
|
||||
|
||||
The correction comes from the human, via the RE agent, in their words: Reborn
|
||||
"was/is just a GUI explorer and extraction CLI for verifying the decoding of the
|
||||
various files. It may very well be wrong." **The oracle is the Xenia Canary
|
||||
capture and the game.**
|
||||
|
||||
So `tools/verify-screen` is a **consistency check between two decoders that
|
||||
share their assumptions**, and a regression detector. It is not a correctness
|
||||
check, and agreement in it is not evidence of correctness.
|
||||
|
||||
### The embarrassing part is that this file already knew
|
||||
|
||||
After the `ptframe1` case, P2's write-up says: *"Two renderers reading one field
|
||||
through one decoder agreeing is not evidence that the field is right."* Then P1's
|
||||
numbers kept being quoted as though 3/255 against `sylpheed-cli` meant the port
|
||||
was right. Having the principle written down did not stop me leaning on the
|
||||
agreement — which is worth recording, because that is the failure mode, not
|
||||
ignorance of the principle.
|
||||
|
||||
**Three times** both renderers agreed and both were wrong, all three caught by a
|
||||
capture and catchable by nothing else:
|
||||
|
||||
| | what both got wrong | how it surfaced |
|
||||
|---|---|---|
|
||||
| `pteff05` | the menu screens had **no background** | the RE agent decoded the RATC child name |
|
||||
| scale 0 | drawn at full size instead of collapsed | RE agent's control run |
|
||||
| `rest()` | `ptframe1`/`ptframe2` invisible; the menu bracket missing | `main-menu-oracle.png` |
|
||||
|
||||
### What changes
|
||||
|
||||
* `tools/verify-screen` says all of this in its own header, calls the CLI the
|
||||
**comparison** renderer, and a `DIFFERS` row now means "we moved apart, find
|
||||
out which of us moved" rather than "the port is wrong".
|
||||
* The correctness question moves to the captures. The RE agent has committed
|
||||
nine of them with an index at `docs/re/captures/ORACLE-CAPTURES.md`, covering
|
||||
all five screens in scope — including a **main menu with `OPTIONS` focused**,
|
||||
whose difference from the unfocused menu isolates exactly what focus changes.
|
||||
* Three cautions travel with any capture comparison, and they are the RE agent's:
|
||||
the captures are **not gamma-neutral** (γ ≈ 1.49 menu, 1.34 title — there is a
|
||||
floor, do not chase it); **geometry is sound** (best alignment 0,0 at corr
|
||||
0.9466, so a positional disagreement is real); and each is **one moment of a
|
||||
still-animating screen**, so compare settled poses or regions known to be at
|
||||
rest.
|
||||
|
||||
### What does not change
|
||||
|
||||
The port keeps running `verify-screen` over all 16 screens every iteration. A
|
||||
consistency check is still worth having — it is total, it is cheap, and it is
|
||||
what catches a divergence the RE agent introduces on their side. It is simply
|
||||
not a grade, and this file will stop quoting it as one.
|
||||
347
godot-import/docs/FORMAT.md
Normal file
347
godot-import/docs/FORMAT.md
Normal file
@@ -0,0 +1,347 @@
|
||||
# The open export format — v3
|
||||
|
||||
The format the disc is converted *into*, and the one the Godot project and any
|
||||
modding tool read. **It is versioned, so a change is a deliberate act with a
|
||||
version bump**, not a silent edit. [Changes from v2](#changes-from-v2) and
|
||||
[Changes from v1](#changes-from-v1) are at the bottom, with a reason for each.
|
||||
|
||||
Design rules, in priority order:
|
||||
|
||||
1. **A human can read and edit it.** Modding is a goal of this port, which makes
|
||||
the layout part of the product rather than a temp directory.
|
||||
2. **Names, never hashes.** Where the disc's own name was never recovered — the
|
||||
six `*2D` archives and `GP_READY_ROOM` — emit a stable synthetic id **and say
|
||||
in the file that the real name is unknown**. A modder must be able to tell a
|
||||
recovered name from an invented one.
|
||||
3. **Provenance travels with the data.** Source archive, entry index, exporter
|
||||
version, decoder revision. This is what keeps the export auditable against the
|
||||
disc instead of drifting into an unverifiable fork.
|
||||
4. **Say what is unknown.** A field we could not decode is absent and listed in
|
||||
`unresolved` — never guessed, never silently defaulted.
|
||||
|
||||
**JSON, not XML.** Godot parses JSON natively with `JSON.parse_string`; its
|
||||
`XMLParser` is a SAX-style API that would need a hand-written binding per schema.
|
||||
|
||||
**The format is executable.** `sylpheed-export check --out export` validates a
|
||||
tree against this document with no disc in hand, reading it the way Godot will —
|
||||
as a stranger. Where the prose here and `crates/sylpheed-export/src/check.rs`
|
||||
disagree, that is a bug in one of them and worth saying which.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
export/ # DERIVED. Regenerable. Gitignored. Never hand-edited.
|
||||
manifest.json
|
||||
screens/title/*.json
|
||||
sprites/title/<screen>/*.png
|
||||
audio/music/*.ogg audio/sfx/*.ogg audio/cues.json
|
||||
video/*.ogv
|
||||
authored/ # AUTHORED. Hand-written. Committed. Survives re-export.
|
||||
screen_names.json # which build is which screen
|
||||
flow.json # boot sequence + what each button does
|
||||
cue_bindings.json # which cue fires on move / confirm / back
|
||||
```
|
||||
|
||||
Sprites are **per screen**, not a flat pool: a sprite name is unique within a
|
||||
bundle and not across them, and `main_menu`'s `ptbase.t32` and `extras`'
|
||||
`ptbase.t32` are different pictures.
|
||||
|
||||
`authored/screen_names.json` is the one authored file the *exporter* reads; the
|
||||
rest are applied by the runtime over `export/`.
|
||||
|
||||
## Common header
|
||||
|
||||
```json
|
||||
{
|
||||
"format": "sylpheed.screen/3",
|
||||
"exporter": "sylpheed-export 0.1.0",
|
||||
"formats_rev": "8b6dbcf",
|
||||
"source": { "archive": "dat/GP_TITLE.pak", "entry": 5, "build": 5 }
|
||||
}
|
||||
```
|
||||
|
||||
`source.entry` is the pak **entry index** — the stable locator. `source.build` is
|
||||
the index into that pak's list of screen builds (what `sylpheed-cli screen
|
||||
--build N` takes), which is stable only as long as the enumeration rule is.
|
||||
`formats_rev` pins which decoders produced the file.
|
||||
|
||||
## `screens/*.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"format": "sylpheed.screen/3",
|
||||
"exporter": "sylpheed-export 0.1.0",
|
||||
"formats_rev": "8b6dbcf",
|
||||
"source": { "archive": "dat/GP_TITLE.pak", "entry": 5, "build": 5 },
|
||||
"name": "main_menu",
|
||||
"name_source": "authored",
|
||||
"name_why": "HANDOFF Q2: builds 5/8 are the five-button main menu; 5 is English…",
|
||||
"design": [1280, 720],
|
||||
"elements": [
|
||||
{
|
||||
"index": 10,
|
||||
"id": "ptbtn01",
|
||||
"declared": "ptbtn01.rat",
|
||||
"role": "button",
|
||||
"kind_raw": "0x3002",
|
||||
"sprite": "sprites/title/main_menu/ptbtn01.png",
|
||||
"focus_sprite": "sprites/title/main_menu/ptbtn01f.png",
|
||||
"opt_link": "ptbtn01f.rat",
|
||||
"pivot": [42, 22],
|
||||
"layer_source": "sprite",
|
||||
"layer": "0x00008110",
|
||||
"rest": { "pos": [542, 162], "scale": [100, 100],
|
||||
"tint_rgba": "0xffffffff", "fade_argb": "0xffffffff", "t": 64 },
|
||||
"keyframes": [
|
||||
{ "t": 28, "pos": [542, 142], "scale": [100, 100],
|
||||
"tint_rgba": "0xffffffff", "fade_argb": "0x00ffffff" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"paint_order": [1, 3, 4, 2, 5, 8, 9, 6, 7, 15, 10, 11, 12, 13, 14, 0],
|
||||
"buttons": ["ptbtn01", "ptbtn02", "ptbtn03", "ptbtn04", "ptbtn05"],
|
||||
"unresolved": ["keyframe_time_unit", "paint_order_ties", "fade_out_duration"]
|
||||
}
|
||||
```
|
||||
|
||||
### `name` / `name_source` / `name_why`
|
||||
|
||||
`name_source` is `"authored"` or `"index"` and nothing else. `"authored"` means
|
||||
the name came from `authored/screen_names.json` and **requires** a `name_why`
|
||||
saying who decided it and on what evidence. `"index"` means nobody has
|
||||
identified this build and the name is `build_NN` — a locator, not a claim.
|
||||
|
||||
### `elements[]`
|
||||
|
||||
`index` is the declaration index and is also the key `paint_order` uses; it
|
||||
always equals the element's position in the array. `id` is `declared` with its
|
||||
extension stripped.
|
||||
|
||||
**`role`** comes from the decoded element kind: `0x3002` → `button`, `0x10`
|
||||
without a sprite → `primitive`, `0x0` → `decoration`. Anything else is
|
||||
`"unknown"` with the raw value in `kind_raw`. Do not invent a name for a kind
|
||||
nobody has decoded.
|
||||
|
||||
> ⚠️ `0x3002` is **not** a general button test. It is one member of a `0x3000`
|
||||
> family with sub-bits, and `GP_READY_ROOM` uses `0x3000` / `0x3004` / `0x300c` /
|
||||
> `0x3008` with zero `0x3002`. Every screen in this milestone is `GP_TITLE`,
|
||||
> where the mapping is decoded. A consumer meeting `role: "unknown"` should read
|
||||
> `kind_raw`, not assume.
|
||||
|
||||
> ⚠️ **`kind & 0x4` is a repeated instance of a template.** On the title screen
|
||||
> those are motion-trail ghosts and are *not* on screen at rest — the draw
|
||||
> capture shows one quad where the bundle declares three. A runtime should skip a
|
||||
> `kind & 0x4` element **when another element in the same screen has the same
|
||||
> `id` and does not have that bit**, and only then: 174 elements on the disc are
|
||||
> `0x4` with no such template, and a blanket skip erases them. Both are visible
|
||||
> in this format from `kind_raw` and `id`.
|
||||
|
||||
**`pivot`** is the declared pivot, and it is the **anchor scale grows about** —
|
||||
`pos` is the element's top-left at 1:1, and at scale `s` the drawn top-left is
|
||||
`pos − pivot·(s−1)`. At 100 % the pivot cancels, which is why it went unnoticed
|
||||
for a long time.
|
||||
|
||||
> 🟡 The decoders document the pivot as "exactly half the decoded texture's
|
||||
> dimensions (verified 7/7 on the tutorial bundle)". **That does not hold on
|
||||
> `GP_TITLE`**: 38 of its 93 sprite-bearing `.t32` elements disagree, some
|
||||
> grossly (`ptlogo_back2`, 1118×262, pivot 500,117 where half is 559,131). It is
|
||||
> not a problem for this port — the exporter emits the declared pivot and never
|
||||
> derives one — but it is a claim a consumer should not lean on. Raised in
|
||||
> `docs/BLOCKED.md`.
|
||||
|
||||
**`sprite`** / **`focus_sprite`** are paths relative to `export/`. The highlight
|
||||
pairs **by name** on the sprite — `ptbtn01.t32` ↔ `ptbtn01f.t32` — which is 🟡 a
|
||||
naming convention that holds for all 54 real pairs on the disc, not a decoded
|
||||
field.
|
||||
|
||||
**`focus`** is the focused state, and it **supersedes `focus_sprite`**. A
|
||||
focused button is not a sprite swap: `ptbtn0Nf.rat` is a nested `.rat` **leaf**
|
||||
declaring *two* elements — the spinning ring `ptbtneff01.t32` and the bright
|
||||
label — and **the parent bundle declares no element for the record at all**, so
|
||||
the leaf is the only source of placement for both and there is nothing for it to
|
||||
inherit.
|
||||
|
||||
```json
|
||||
"focus": {
|
||||
"record": "ptbtn04f.rat",
|
||||
"elements": [
|
||||
{ "id": "ptbtneff01", "sprite": "…/ptbtneff01.png", "pivot": [21, 23],
|
||||
"rest": { "pos": [500, 396], "rotation_deg": 0, "t": 120, … },
|
||||
"keyframes": [ { "t": 120, "rotation_deg": 0, … },
|
||||
{ "rotation_deg": 360, … } ] },
|
||||
{ "id": "ptbtn04f", "sprite": "…/ptbtn04f.png", "rest": { "pos": [535, 395], … } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Elements are back-to-front in the leaf's own declaration order — ring first,
|
||||
then label. Positions are **absolute design-space top-left**, not offsets from
|
||||
the button.
|
||||
|
||||
> The label's `(−7, −7)` against its base is load-bearing, not noise:
|
||||
> `ptbtn0Nf.t32` is 13 px larger per axis, and −7 keeps the two **concentric**
|
||||
> (535 + 96/2 = 583 against 542 + 83/2 = 583.5). Drawing the highlight at the
|
||||
> base position pushes it 7 px down-right and off-centre.
|
||||
|
||||
> ⚠️ **Leaf placement is authoritative for an `f` record and NOT for a base
|
||||
> record.** A base record's leaf *duplicates* its parent's placement and the two
|
||||
> can disagree by a unit (`ptbtn04`: parent y=401, leaf y=402) — there the parent
|
||||
> wins. The `f` record is the case where the parent declares nothing.
|
||||
|
||||
> ❔ **The ring spins, and its period is unresolved.** Its two keyframes differ
|
||||
> in `rotation_deg` alone, 0 → 360. But the second is untimed, and what an
|
||||
> untimed keyframe means *inside a leaf* — as opposed to at screen level, where
|
||||
> it is the exit ramp — is untested. A consumer should draw the resting angle
|
||||
> rather than invent a spin rate. This is listed in `unresolved`.
|
||||
|
||||
**`opt_link`** is the raw `opt ` link inside the element's `.rat` record, carried
|
||||
through unresolved. ⚠️ **It is not a focus link.** That reading was measured and
|
||||
refuted: on the main menu it chains `ptloop01 → ptloop02 → ptbtn01`, across two
|
||||
decorations and into a button. It is exported so whoever decodes it has it, and
|
||||
named so nothing downstream mistakes it for navigation.
|
||||
|
||||
**`layer` / `layer_source`** are the paint-order key. `"sprite"` means it was
|
||||
read from the `u16` at `+0x0A` of the element's `T8aD` header — a decoded disc
|
||||
field. `"implied"` means the element carries no header and the key came from the
|
||||
decoders' table of keys **measured off the running game**. `"none"` means neither
|
||||
is known, and the element sorts last. A consumer that needs to know whether a
|
||||
layer is a fact or a measurement reads `layer_source`.
|
||||
|
||||
**`size`** appears only on a `primitive`, which has no texture to take a size
|
||||
from: the quad is `pivot × 2`, and its colour is the keyframe's `fade_argb`.
|
||||
|
||||
**`keyframes`** carry the on-disc time verbatim in `t`. A keyframe is the
|
||||
**start of a ramp toward the next**, not a pose that is held, and the ramp is
|
||||
linear. The **last keyframe of a group has no `t`** — the disc has no time slot
|
||||
there — and a file that puts one on it is wrong, not merely odd. The unit of `t`
|
||||
is measured, not on the disc, and so lives in `authored/` and is applied in
|
||||
exactly one place.
|
||||
|
||||
**`rotation_deg`** is screen-plane rotation in degrees, clockwise-positive,
|
||||
decoded from the keyframe's `+12`. **The game renders it**, confirmed twice by
|
||||
the RE agent on different screens and different elements: the title's `ptloop`
|
||||
sweeps declare +30 / −45 and a GPU capture submits their quads at +30.26 /
|
||||
−45.28, and the main menu's focus ring ramps 0 → 360 with position, scale, alpha
|
||||
and tint all constant — a capture caught it mid-spin.
|
||||
|
||||
Rotation is **about the declared pivot**, which is measured rather than assumed:
|
||||
the `ptloop` sweeps scale 600 %/800 % vertically, where the pivot term is worth
|
||||
450 and 630 px, and the capture puts both quad centres at y 359.1/360.0 against
|
||||
the pivot formula's 360.0 (top-left predicts 810/990, centre-as-position
|
||||
predicts 270).
|
||||
|
||||
> ⚠️ `sylpheed-cli screen render` does **not** draw rotation yet — its `blit` is
|
||||
> axis-aligned. A rotation disagreement between it and a consumer that does draw
|
||||
> rotation means the CLI is behind, not that the consumer is wrong.
|
||||
|
||||
**Two colours multiply.** `tint_rgba` is RGBA and is `0xffffffff` on essentially
|
||||
every keyframe; `fade_argb` is **ARGB**, and its high byte is the alpha that ramps
|
||||
during a fade. The byte order is in the key name because getting it backwards is
|
||||
silent and looks like an art bug. The drawn modulate is their per-channel product.
|
||||
|
||||
**`rest`** is the resting pose: **the hold** — the longest run of consecutive
|
||||
keyframes with an identical pose that does not end the group. Neither the first
|
||||
nor the last keyframe, and not the longest-dwell frame either: a long gap after
|
||||
keyframe *k* means the screen spends that time *arriving at* `k+1`.
|
||||
|
||||
> ⚠️ **`rest` is a heuristic over the keyframes, and it misfires.** The rule
|
||||
> excludes a run that ends the group, because that run is usually the exit. On
|
||||
> an element with **no exit animation** the trailing run *is* the hold, and the
|
||||
> rule then falls back to an earlier run — usually the invisible pre-roll. Six
|
||||
> elements in this export are affected, and the condition that identifies them
|
||||
> exactly is *"the final untimed keyframe has the same pose as the last timed
|
||||
> one"*: `ptframe1`/`ptframe2` on both main menus, and `pteff02` on both titles.
|
||||
> A live capture of the running main menu shows `ptframe1`/`ptframe2` on screen;
|
||||
> `rest` says they are invisible.
|
||||
>
|
||||
> A consumer that wants the pose after arrival should therefore take **the last
|
||||
> timed keyframe**, not `rest`. `rest` is kept in the format because it is what
|
||||
> the pinned decoders say and removing it would hide the disagreement — see
|
||||
> `docs/DECISIONS.md`. The format is unchanged at **v2**: no field changed
|
||||
> meaning, this is a warning about one of them.
|
||||
|
||||
**`paint_order`** is back-to-front, as declaration indices, and is a permutation
|
||||
of them. It is the stable sort by `layer`. See `unresolved: paint_order_ties`.
|
||||
|
||||
**`buttons`** is navigation order: `button`-role elements sorted by resting Y.
|
||||
This is **geometric, not a decoded neighbour graph** — the disc's real navigation
|
||||
structure is unknown. It is right for a vertical menu and should not be trusted
|
||||
for anything else.
|
||||
|
||||
**`unresolved`** lists what this file does not answer; a consumer needing one of
|
||||
those must get it from `authored/`. An empty list is a claim that nothing is
|
||||
missing; an absent list is a gap, and `check` rejects it.
|
||||
|
||||
## `authored/screen_names.json`
|
||||
|
||||
Which build is which screen, keyed by archive and build index, each with a
|
||||
`why`. The exporter reads this and stamps `name` / `name_source` / `name_why`
|
||||
into the screen file. A build with no entry exports as `build_NN`.
|
||||
|
||||
## `authored/flow.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"format": "sylpheed.flow/1",
|
||||
"boot": ["splash_developer", "intro_video", "title", "main_menu"],
|
||||
"screens": {
|
||||
"main_menu": {
|
||||
"actions": {
|
||||
"ptbtn01": { "label": "NEW GAME", "goto": "new_game_intro",
|
||||
"why": "label read off the sprite; target is a placeholder for HANDOFF Q4" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`goto` may name an exported screen or a **GamePart id** from the executable's own
|
||||
table (29 entries at `.rdata 0x820A1630` — that table is a disc fact; which button
|
||||
reaches which entry is Q4 and is not).
|
||||
|
||||
## `export/manifest.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"format": "sylpheed.manifest/1",
|
||||
"exporter": "sylpheed-export 0.1.0",
|
||||
"formats_rev": "8b6dbcf",
|
||||
"disc": "/disc",
|
||||
"screens": [{ "name": "main_menu", "file": "screens/title/main_menu.json",
|
||||
"sprites": 18, "missing_sprites": [] }],
|
||||
"video_transcode": "ffmpeg -i ADV.wmv -c:v libtheora -q:v 8 -c:a libvorbis -q:a 5 ADV.ogv",
|
||||
"warnings": ["GP_READY_ROOM not exported -- out of scope"]
|
||||
}
|
||||
```
|
||||
|
||||
`video_transcode` will record the exact command so a modder can re-run it rather
|
||||
than reverse-engineer what was done. It is absent until P4 writes a video.
|
||||
|
||||
## Changes from v2
|
||||
|
||||
v2 was written before the keyframe's `+12` was decoded and before anyone could
|
||||
reach a `.rat` leaf through the public decoder API.
|
||||
|
||||
| Change | Why |
|
||||
|---|---|
|
||||
| `rotation_deg` on every keyframe and on `rest` | Decoded at keyframe `+12`, and **the game draws it** — confirmed on two different screens with two different elements against GPU captures. Dropping it would have made the focus ring's whole animation invisible. |
|
||||
| `focus` (a record with its own elements) added; `focus_sprite` kept but demoted | The focused state is two elements in a nested leaf, not one sprite. v2's single `focus_sprite` could not carry the ring at all, and drew the highlight label 7 px off-centre by inheriting the base's position. `focus_sprite` stays because it is still the 54-pair naming convention and a consumer may want the bare texture. |
|
||||
| `unresolved` gains `focus_ring_spin_period` | The ring's rotation ramps 0 → 360 across two keyframes whose second is untimed. The screen-level rule for an untimed keyframe (the exit ramp) is not established to apply inside a leaf, so the period is unknown and is not being invented. |
|
||||
|
||||
## Changes from v1
|
||||
|
||||
v1 was written before HANDOFF answered Q1 and Q3, and before the two-colour
|
||||
modulate was known. Each change below is a thing v1 could not have said.
|
||||
|
||||
| Change | Why |
|
||||
|---|---|
|
||||
| `rest.tint` (one `#rrggbbaa`) → `tint_rgba` **and** `fade_argb` | There are two modulate colours on the disc, in *different byte orders*, and they multiply. One field could not carry both, and a single `#rrggbbaa` silently discarded the alpha that every fade ramps. |
|
||||
| `scale` is percent integers, not floats | It is a percent integer on the disc. Emitting `1.0` invents a precision the file does not have. |
|
||||
| `paint_order` added, `"paint_order"` dropped from `unresolved` | Q3 decoded it: a `u16` layer key at `+0x0A`, stable-sorted. It is now derived, so it belongs in `export/` rather than `authored/`. `paint_order_ties` remains unresolved. |
|
||||
| `layer` / `layer_source` added | Some keys are read from the file and some are measured off the running game. A consumer must be able to tell which. |
|
||||
| `focus_sprite` now pairs by sprite **name**; `opt_link` exported raw | v1 implied `opt ` was the focus link. That was refuted. Pairing by name is the convention that survives. |
|
||||
| `kind_raw` on every element, not only on `unknown` | The `0x3002` button test is not general and `kind & 0x4` changes whether an element draws at all. Both need the raw value present unconditionally. |
|
||||
| `index`, `declared`, `parent`, `size`, `layer` added | Needed to reconstruct the screen: `paint_order` keys on `index`, primitives have no texture to take a size from, and `declared` keeps the disc's own spelling next to the derived `id`. |
|
||||
| `name_why` required whenever `name_source` is `authored` | Rule 2. A name presented without its evidence is indistinguishable from a recovered one. |
|
||||
| sprites moved from `sprites/*.png` to `sprites/<subdir>/<screen>/*.png` | Sprite names collide across builds. `main_menu` and `extras` both ship a `ptbase.t32`, and they are different pictures. |
|
||||
| `unresolved` is required, and may be empty | An empty list is a claim; an absent one is a gap. |
|
||||
188
godot-import/docs/MISSION.md
Normal file
188
godot-import/docs/MISSION.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# Primary objective — the menu shell, running in Godot
|
||||
|
||||
**Status:** active, set 2026-08-28.
|
||||
|
||||
Build a Godot 4 project that boots the player's own disc through the sequence the
|
||||
real game uses, and let a person move through it:
|
||||
|
||||
```
|
||||
developer logo splash → intro video → title / PRESS Ⓐ → main menu → submenus
|
||||
```
|
||||
|
||||
No gameplay. No 3D. No HUD. No emulator. Done means a human presses a d-pad and
|
||||
Ⓐ and moves through those screens with the right art, animation, music and
|
||||
transitions.
|
||||
|
||||
## 1. You are one of two agents
|
||||
|
||||
A **container agent** does the reverse engineering, in the
|
||||
[Syplheed-Reborn][reborn] repository. It runs the emulator; you do not. You build
|
||||
the port from what it publishes.
|
||||
|
||||
The contract is `docs/port/HANDOFF.md` in that repository. **Read it before
|
||||
assuming any value is on the disc.** Every answer there is one of three things,
|
||||
and the distinction decides what you do:
|
||||
|
||||
| | meaning | what you do |
|
||||
|---|---|---|
|
||||
| **decoded** | a field on the disc, with a disc-wide check | read it in the exporter |
|
||||
| **measured** | not on the disc, but the running game does *this* | put it in `authored/`, cite the finding |
|
||||
| **undecodable** | looked for, provably not there | put it in `authored/`, say it is a decision |
|
||||
|
||||
If HANDOFF.md does not answer something you need, **say so and move to another
|
||||
milestone**. Do not guess and do not reverse engineer it yourself — you have no
|
||||
emulator and no oracle, so a guess here is indistinguishable from a fact and will
|
||||
be believed later.
|
||||
|
||||
[reborn]: https://git.mc02.dev/fabi/Syplheed-Reborn
|
||||
|
||||
## 2. The wall
|
||||
|
||||
The Godot project **never reads a disc format**. No IPFB, no RATC, no T8aD, no
|
||||
XMA, no WMV. If Godot cannot read something, the exporter's job is to emit it
|
||||
differently — not to bridge the gap at runtime.
|
||||
|
||||
* **No GDExtension. No Rust in `port/`.**
|
||||
* The decoders come from `sylpheed-formats`, **pinned by TAG**:
|
||||
`sylpheed-formats = { git = "...", tag = "formats-pin-2026-08-29" }`.
|
||||
|
||||
Pin a tag, never a bare sha. A sha reachable only from an `auto/*` branch is
|
||||
orphaned when that branch is deleted or — worse — **squash-merged**, because
|
||||
squash creates *new* commits: `main` looks like it contains the work while the
|
||||
pin becomes unreachable and this project stops building for a fresh checkout.
|
||||
A tag is a permanent ref, it says what it is in `Cargo.toml`, and it fails
|
||||
loudly at *fetch* rather than silently at build.
|
||||
* **Do not float the pin** to a branch. It would not do what it sounds like:
|
||||
Cargo resolves a git dependency once and writes the sha into `Cargo.lock`, so
|
||||
floating gives you staleness you cannot see instead of staleness you can read.
|
||||
* Bump deliberately, as its own commit, saying what you wanted from the new
|
||||
state. The RE agent tags when it lands something you need and tells you over
|
||||
the message channel — that is how you stay current without floating.
|
||||
|
||||
**In particular, do not reimplement media assembly.** `sylpheed_formats::media`
|
||||
already handles the cases where one playable thing is not one archive entry: a
|
||||
`.pak` entry that spans segment files, a bank with several sub-waves, and the
|
||||
cutscene voices — which are one continuous XMA stream chunked into `VOICE_*.slb`
|
||||
entries whose boundaries do **not** match the cues, so *a `.slb` need not hold
|
||||
the track its name claims*. That last one is the single easiest thing in this
|
||||
project to get subtly wrong. Use `resolve_movie_voice_region`.
|
||||
|
||||
## 3. Derived vs authored
|
||||
|
||||
| | `export/` | `authored/` |
|
||||
|---|---|---|
|
||||
| produced by | the exporter | you, by hand |
|
||||
| contains | what the disc says | what we decided |
|
||||
| hand-edited | **never** | always |
|
||||
| in git | **no** — gitignored | yes |
|
||||
| on re-export | overwritten wholesale | untouched |
|
||||
|
||||
Tempted to hand-fix a file under `export/`? The fix belongs in the exporter or in
|
||||
`authored/`. Every `authored/` entry carries a `why`.
|
||||
|
||||
When the RE agent later decodes something you had authored, **delete the authored
|
||||
entry** and let the exporter emit it. That deletion is the measure of progress.
|
||||
|
||||
## 4. Never commit game assets
|
||||
|
||||
`export/` is generated from the user's own disc and is gitignored. Code, schemas,
|
||||
`authored/` mappings and docs only. If you are about to commit a sprite PNG or a
|
||||
transcoded video, stop.
|
||||
|
||||
## 5. Milestones
|
||||
|
||||
A milestone is done when its **artifact** exists, not when the code compiles.
|
||||
|
||||
| | Milestone | Gate |
|
||||
|---|---|---|
|
||||
| **P0** | Exporter skeleton; one screen and its sprites to `export/` | `export/screens/title/main_menu.json` validates against FORMAT.md and the PNGs open |
|
||||
| **P1** | Godot renders that screen statically at 1280×720 | A Godot screenshot beside `sylpheed-cli screen render` of the same build — they should agree, and where they do not, say which is wrong |
|
||||
| **P2** | Keyframe animation | Buttons slide in. **Blocked on HANDOFF Q1** (the time unit). Do not invent it |
|
||||
| **P3** | Splash → title, with the transition | Both screens back to back, unattended |
|
||||
| **P4** | Intro video | `ADV.wmv` plays with audio (§6) |
|
||||
| **P5** | Main menu: navigation, focus states, Ⓐ into a submenu, B back | A human clicks through it |
|
||||
| **P6** | Audio — menu BGM and move/confirm SFX | Sound on the P5 gate. **Looping is blocked on HANDOFF Q10** |
|
||||
| **P7** | New-game intro video after NEW GAME | Plays, then returns to a defined state |
|
||||
|
||||
Work the lowest unfinished milestone. When one is blocked on an RE answer, say so
|
||||
in `docs/BLOCKED.md`, and take the next milestone that is not.
|
||||
|
||||
## 6. The video problem
|
||||
|
||||
`ADV.wmv` is **WMV3 video with WMA Pro audio**, 1280×720 at 30 fps, 137 s. Godot 4
|
||||
plays only **Ogg Theora** natively.
|
||||
|
||||
Transcode with ffmpeg, and **record the exact command in the export manifest** so
|
||||
a modder who dislikes the quality can re-run it rather than reverse-engineer what
|
||||
you did. Theora at 720p is not great; if the result is visibly poor, **say so and
|
||||
propose** the FFmpeg-GDExtension fallback — do not adopt a runtime dependency on
|
||||
your own authority.
|
||||
|
||||
Only the boot intro and the one new-game intro are in scope. The disc holds
|
||||
3.3 GB of video; transcoding all of it is not this milestone.
|
||||
|
||||
### The downmix is decided: pin it explicitly
|
||||
|
||||
**Human decision, 2026-08-29.** The cinematics are 5.1 (see
|
||||
[`movie-audio-channels`][mac] for the disc-wide split — 28 surround, 69 stereo,
|
||||
and *both* movies this milestone needs are surround). Fold to stereo with an
|
||||
**explicit matrix**, not ffmpeg's default:
|
||||
|
||||
```
|
||||
-af "pan=stereo|FL=0.707*FC+1.0*FL+0.707*FLC+0.707*BL+0.707*SL|FR=0.707*FC+1.0*FR+0.707*FRC+0.707*BR+0.707*SR"
|
||||
```
|
||||
|
||||
Centre at −3 dB into both channels, which is the standard ITU fold and keeps
|
||||
dialogue sitting correctly against the music. Record the full command in the
|
||||
manifest, per the rule above.
|
||||
|
||||
Pinned rather than left to the default because a default is a decision nobody
|
||||
made: it is invisible in the output, it can change between ffmpeg versions, and
|
||||
it silently alters how speech sits in the mix. Adjust the matrix if it sounds
|
||||
wrong — but adjust it *deliberately*, as a commit.
|
||||
|
||||
[mac]: https://git.mc02.dev/fabi/Syplheed-Reborn/src/branch/main/docs/re/structures/movie-audio-channels.md
|
||||
|
||||
## 7. Out of scope
|
||||
|
||||
3D, gameplay, HUD, missions, save/load, localisation beyond English, the Ready
|
||||
Room, and any reverse engineering. If you want an answer the disc has not given
|
||||
you, that is a request to the container agent, not a task for you.
|
||||
|
||||
## 8. Tooling policy — MCP servers and third-party skills
|
||||
|
||||
Surveyed 2026-08-28. **No Godot MCP server, for now**, and the reason is not
|
||||
that they are bad:
|
||||
|
||||
* The mature ones ([godot-ai][ga], and most of the field) need a **live Godot
|
||||
editor** running with a plugin that talks WebSocket to a Python server. This
|
||||
agent is headless in a container; that is a daemon, an editor process and a
|
||||
second language runtime added to an unattended loop, all of which can fail in
|
||||
ways that look like a port bug.
|
||||
* Their headline feature is **scene-tree introspection and node manipulation** —
|
||||
built for someone hand-authoring scenes in the editor. This port *generates*
|
||||
its screens from exported JSON at runtime. The agent writes a loader, not a
|
||||
scene tree, so the feature that justifies the complexity does not apply here.
|
||||
* What the agent actually needs to verify its work already exists:
|
||||
`godot-headless` to run the project and `screenshot` to diff against
|
||||
`sylpheed-cli screen render`. The verification loop is the valuable part, and
|
||||
it is a bash job.
|
||||
|
||||
**Third-party skill packs** ([godot-claude-skills][gcs], [GodotPrompter][gp],
|
||||
[Godot-Claude-Skills][rcs]) are the opposite trade: pure context, no runtime, no
|
||||
daemon. They are worth revisiting. They are **not installed now** because a skill
|
||||
is *instructions injected into an agent running with approvals disabled*, which
|
||||
is a supply-chain decision and not one to make by default — and because P0/P1 are
|
||||
a Rust exporter and a static sprite draw, which need no advanced GDScript.
|
||||
|
||||
**If you want one, propose it**: name the pack, say which milestone it unblocks,
|
||||
and let a human vendor and review it. Do not install from a marketplace on your
|
||||
own authority.
|
||||
|
||||
Revisit this if GDScript quality becomes the bottleneck — most likely at P2,
|
||||
where keyframe ramps meet tweens.
|
||||
|
||||
[ga]: https://github.com/hi-godot/godot-ai
|
||||
[gcs]: https://github.com/alexmeckes/godot-claude-skills
|
||||
[gp]: https://github.com/jame581/GodotPrompter
|
||||
[rcs]: https://github.com/Randroids-Dojo/Godot-Claude-Skills
|
||||
93
godot-import/docs/loop-task.md
Normal file
93
godot-import/docs/loop-task.md
Normal file
@@ -0,0 +1,93 @@
|
||||
Build the Godot menu port, one milestone at a time.
|
||||
|
||||
## Your objective
|
||||
|
||||
`docs/MISSION.md` — read it every iteration. It defines the milestones P0…P7 and
|
||||
the gate each must pass, the wall between the exporter and Godot, and the
|
||||
derived/authored split.
|
||||
|
||||
**You do not reverse engineer.** A separate container agent does that, in the
|
||||
Syplheed-Reborn repository, mounted read-only at `/reborn`. You have no emulator
|
||||
and no oracle, so a guess of yours is indistinguishable from a fact and will be
|
||||
believed later. If you need an answer the disc has not given you, write it in
|
||||
`docs/BLOCKED.md` and move to another milestone.
|
||||
|
||||
## Read these first, every iteration
|
||||
|
||||
1. `docs/MISSION.md` — milestones, gates, scope.
|
||||
2. `/reborn/docs/port/HANDOFF.md` — **the contract.** What is decoded, what was
|
||||
measured off the running game, and what is known undecodable.
|
||||
|
||||
**It is a live read-only mount of the RE agent's working tree**, so it updates
|
||||
itself and there is nothing to pull — `git -C /reborn pull` cannot work (the
|
||||
mount is read-only) and should not: it would move another agent's checkout.
|
||||
`git -C /reborn log -1` shows where they are.
|
||||
|
||||
Because it is live, **it can move under you mid-iteration.** Anything you
|
||||
copied out of it earlier — `docs/BLOCKED.md` especially — may already be
|
||||
stale. Re-check it against HANDOFF before trusting it.
|
||||
3. `docs/FORMAT.md` — the open format. It is versioned and it is yours to
|
||||
revise, but a change is a deliberate act with a version bump.
|
||||
4. `docs/BLOCKED.md` — what you are waiting on, so you do not re-discover it.
|
||||
|
||||
`/reborn/docs/re/disc-atlas.html` maps how the assets reference each other.
|
||||
|
||||
## Each iteration
|
||||
|
||||
1. **Pick the lowest unfinished milestone.** If it is blocked on an RE answer,
|
||||
record that in `docs/BLOCKED.md` and take the next one that is not.
|
||||
2. **Build the smallest thing that reaches its gate.** The gate is an artifact —
|
||||
a validating JSON file, a screenshot, a clickable build — never "it compiles".
|
||||
3. **Keep derived and authored apart.** `export/` is regenerated wholesale and
|
||||
never hand-edited. A fix you are tempted to make there belongs in the exporter
|
||||
or in `authored/`, and every `authored/` entry carries a `why`.
|
||||
4. **Write down what you decided**, in `docs/`. A decision that lives only in
|
||||
your context is lost when the container dies.
|
||||
5. **Commit** to `auto/<topic>`, one logical change per commit.
|
||||
6. **Publish**: `push-work`. Every iteration that produced a commit.
|
||||
7. **Say plainly what you did not settle**, and stop.
|
||||
|
||||
## Hard rules
|
||||
|
||||
* **Never commit game assets.** `export/` is gitignored and generated from the
|
||||
user's own disc. Code, schemas, `authored/` mappings and docs only.
|
||||
* **No Rust in `port/`, no GDExtension.** If Godot cannot read something, the
|
||||
exporter emits it differently.
|
||||
* **Do not vendor or reimplement `sylpheed-formats`** — it is pinned by revision.
|
||||
In particular do not reimplement media assembly: `sylpheed_formats::media`
|
||||
already handles segment-spanning entries, multi-sub-wave banks and the
|
||||
continuous cutscene-voice stream, and that last one is the easiest thing here
|
||||
to get subtly wrong.
|
||||
* **`/reborn` is READ-ONLY.** Never commit there, never edit it. It belongs to
|
||||
the other agent and you share no working tree with it.
|
||||
* **Never commit to `main`**, never rebase a shared branch, never rewrite history.
|
||||
* **Do not adopt a runtime dependency on your own authority.** Propose it.
|
||||
|
||||
## Verifying
|
||||
|
||||
* `sylpheed-cli screen render` (built from `/reborn`) is the reference renderer.
|
||||
When Godot draws a screen, diff against the CLI's composite of the same build.
|
||||
Where they disagree, one of them is wrong — say which, and why, rather than
|
||||
tuning until they match.
|
||||
* Godot runs headless (`godot-headless`), and windowed under Xvfb with
|
||||
`screenshot` for a capture.
|
||||
* A regenerated `export/` that comes out byte-identical is strong evidence a
|
||||
change was additive. When it does change, check that every diff line pairs.
|
||||
|
||||
## Publishing
|
||||
|
||||
`push-work` pushes the current branch to origin. It refuses anything that is not
|
||||
`auto/*` and never force-pushes, so the consolidated line stays a human's
|
||||
decision. Run it **every iteration that produced a commit** — not at the end of
|
||||
some longer arc, which is exactly when a container dies.
|
||||
|
||||
If it reports no credentials, say so in your reply and continue working. Do not
|
||||
improvise another route out.
|
||||
|
||||
## Pacing
|
||||
|
||||
One milestone step plus its write-up is a good iteration; a marathon is not. Stop
|
||||
with a clean commit, a push, and an honest list of what is still open.
|
||||
|
||||
The loop runs on a fixed interval set by the harness, so you do **not** need to
|
||||
arm the next wakeup yourself. Spend that attention on the write-up instead.
|
||||
27
godot-import/port/project.godot
Normal file
27
godot-import/port/project.godot
Normal file
@@ -0,0 +1,27 @@
|
||||
; Godot 4 project for the Sylpheed menu shell.
|
||||
;
|
||||
; It reads ONLY the open asset tree produced by crates/sylpheed-export -- no
|
||||
; disc formats, no GDExtension, no Rust. See ../docs/MISSION.md.
|
||||
|
||||
config_version=5
|
||||
|
||||
[application]
|
||||
config/name="Sylpheed"
|
||||
config/features=PackedStringArray("4.3")
|
||||
run/main_scene="res://scenes/boot.tscn"
|
||||
|
||||
[display]
|
||||
; The screens are authored at 1280x720 and every coordinate in the export is in
|
||||
; that space, so the viewport matches it exactly and scaling happens once, at
|
||||
; the window edge.
|
||||
window/size/viewport_width=1280
|
||||
window/size/viewport_height=720
|
||||
window/stretch/mode="canvas_items"
|
||||
window/stretch/aspect="keep"
|
||||
|
||||
[rendering]
|
||||
|
||||
; The screens carry their own background; anything the export does not paint is
|
||||
; black, which is what `sylpheed-cli screen render --black` composites over and
|
||||
; therefore what a capture is comparable against.
|
||||
environment/defaults/default_clear_color=Color(0, 0, 0, 1)
|
||||
6
godot-import/port/scenes/boot.tscn
Normal file
6
godot-import/port/scenes/boot.tscn
Normal file
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/boot.gd" id="1"]
|
||||
|
||||
[node name="Boot" type="Node"]
|
||||
script = ExtResource("1")
|
||||
273
godot-import/port/scripts/boot.gd
Normal file
273
godot-import/port/scripts/boot.gd
Normal file
@@ -0,0 +1,273 @@
|
||||
# Entry point.
|
||||
#
|
||||
# P1 shows one exported screen, statically, so that its pixels can be diffed
|
||||
# against `sylpheed-cli screen render` of the same build. The boot sequence
|
||||
# proper (splash -> intro -> title -> menu) is P3 and is not here.
|
||||
#
|
||||
# godot --path port -- --screen=main_menu
|
||||
# godot --path port -- --screen=main_menu --capture=/tmp/godot.png
|
||||
# godot --path port -- --screen=main_menu --time=0.5 --capture=/tmp/at-half.png
|
||||
# godot --path port -- --screen=main_menu --pose=rest --capture=/tmp/rest.png
|
||||
# godot --path port -- --boot # the whole boot sequence
|
||||
# godot --path port -- --boot --film=/tmp/boot # ...and a frame every 0.25 s
|
||||
#
|
||||
# `--time` is in SECONDS and freezes the timeline there; without it the screen
|
||||
# animates in real time from t=0. `--pose=rest` draws the export's declared
|
||||
# resting pose instead of the timeline -- what the reference renderer draws, so
|
||||
# that a renderer-vs-renderer diff compares like with like.
|
||||
#
|
||||
# The screen is drawn into a SubViewport sized to the export's own `design`
|
||||
# rectangle and shown through a container that scales it to the window. That is
|
||||
# the same separation the project settings already make -- design space is
|
||||
# fixed, the window is not -- and it makes `--capture` exact: the PNG is the
|
||||
# design rectangle itself, never the window, so it is directly comparable with
|
||||
# `screen render`'s composite with no cropping or rescaling.
|
||||
extends Node
|
||||
|
||||
const DEFAULT_SCREEN := "main_menu"
|
||||
|
||||
var view: ScreenView = null
|
||||
var viewport: SubViewport = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var args := _args()
|
||||
var export_tree := ExportTree.locate()
|
||||
if export_tree.root == "":
|
||||
push_error(export_tree.error)
|
||||
get_tree().quit(2)
|
||||
return
|
||||
|
||||
_flow = export_tree.authored("flow.json")
|
||||
if args.has("boot"):
|
||||
if _flow == null:
|
||||
push_error(export_tree.error)
|
||||
get_tree().quit(2)
|
||||
return
|
||||
for step: Dictionary in _flow["boot"]:
|
||||
_sequence.append(step)
|
||||
_film = args.get("film", "")
|
||||
|
||||
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)
|
||||
print("screens in this export: ", ", ".join(export_tree.screen_names()))
|
||||
get_tree().quit(2)
|
||||
return
|
||||
var design: Array = screen.get("design", [1280, 720])
|
||||
|
||||
var container := SubViewportContainer.new()
|
||||
container.stretch = true
|
||||
container.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(container)
|
||||
|
||||
viewport = SubViewport.new()
|
||||
viewport.size = Vector2i(int(design[0]), int(design[1]))
|
||||
viewport.transparent_bg = false
|
||||
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
container.add_child(viewport)
|
||||
|
||||
view = ScreenView.new()
|
||||
# The export is a 1:1 copy of the disc's texels and elements are drawn at up
|
||||
# to 500 %. Nearest is also what the reference renderer does
|
||||
# (`ui_layout::blit` maps destination to source by integer division), so a
|
||||
# filter difference cannot masquerade as a placement difference in the diff.
|
||||
view.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
||||
view.focused_id = args.get("focus", "")
|
||||
if args.get("pose", "") == "rest":
|
||||
view.pose_mode = ScreenView.Pose.REST
|
||||
|
||||
# The keyframe unit is MEASURED, not on the disc, so it is authored and read
|
||||
# in exactly one place -- here.
|
||||
var timing: Variant = export_tree.authored("timing.json")
|
||||
if timing == null:
|
||||
push_error(export_tree.error)
|
||||
get_tree().quit(2)
|
||||
return
|
||||
view.units_per_second = float(timing["keyframe_units_per_second"])
|
||||
# The one unknown duration per screen: the ramp into the final untimed
|
||||
# keyframe. Authored, because the disc has no time slot there.
|
||||
view.exit_ramp_units = float(timing["exit_ramp_units"])
|
||||
viewport.add_child(view)
|
||||
|
||||
if not view.load_screen(export_tree, name):
|
||||
push_error(export_tree.error)
|
||||
get_tree().quit(2)
|
||||
return
|
||||
|
||||
var settle := view.settle_time()
|
||||
print("screen %s: %d elements, %d in paint order, design %dx%d, settles at t=%d (%.3f s)" % [
|
||||
name, view.screen["elements"].size(), view.screen["paint_order"].size(),
|
||||
design[0], design[1], settle, settle / view.units_per_second])
|
||||
|
||||
if args.has("time"):
|
||||
_frozen = true
|
||||
view.time_units = float(args["time"]) * view.units_per_second
|
||||
view.queue_redraw()
|
||||
|
||||
if _film != "":
|
||||
set_process(true)
|
||||
_film_capture()
|
||||
|
||||
if args.has("capture"):
|
||||
await _capture(args["capture"])
|
||||
get_tree().quit(0)
|
||||
|
||||
|
||||
var _frozen := false
|
||||
var _flow: Variant = null
|
||||
var _sequence: Array[Dictionary] = []
|
||||
var _player: VideoStreamPlayer = null
|
||||
var _step := 0
|
||||
var _film := ""
|
||||
var _film_frame := 0
|
||||
var _film_next := 0.0
|
||||
var _elapsed := 0.0
|
||||
var _boot_done := false
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _frozen or view == null:
|
||||
return
|
||||
view.time_units += delta * view.units_per_second
|
||||
_elapsed += delta
|
||||
view.queue_redraw()
|
||||
|
||||
if _sequence.is_empty() or _player != null:
|
||||
return
|
||||
|
||||
# A screen holds at `rest` until it has arrived, then plays itself out and
|
||||
# the next one begins. Nothing waits on a timer the disc does not carry: the
|
||||
# pacing is each group's own timeline (authored/flow.json, `dwell`).
|
||||
if view.holding and view.time_units >= view.settle_time():
|
||||
# The LAST screen in the sequence keeps holding. A screen plays itself
|
||||
# out because something is taking its place; nothing is taking the
|
||||
# title's place here, and a boot that ends by fading to black is a boot
|
||||
# that looks like it crashed. P4 puts the intro video in front of the
|
||||
# title, and P5 gives the title somewhere to go.
|
||||
if _step + 1 < _sequence.size():
|
||||
view.holding = false
|
||||
elif not _boot_done:
|
||||
_boot_done = true
|
||||
print("boot sequence complete after %.2f s, holding on %s" % [_elapsed, _sequence[_step]])
|
||||
if _film == "":
|
||||
get_tree().quit(0)
|
||||
elif not view.holding and view.time_units >= view.exit_time():
|
||||
_advance()
|
||||
|
||||
|
||||
func _advance() -> void:
|
||||
_step += 1
|
||||
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, 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
|
||||
await RenderingServer.frame_post_draw
|
||||
var img := viewport.get_texture().get_image()
|
||||
print("t = %.2f units (%.3f s), pose = %s" % [
|
||||
view.time_units, view.time_units / view.units_per_second,
|
||||
"rest" if view.pose_mode == ScreenView.Pose.REST else "timeline"])
|
||||
print("drew %d: %s" % [view.drawn.size(), ", ".join(view.drawn)])
|
||||
if not view.skipped.is_empty():
|
||||
print("not drawn %d: %s" % [view.skipped.size(), ", ".join(view.skipped)])
|
||||
var err := img.save_png(path)
|
||||
if err != OK:
|
||||
push_error("cannot write %s (%d)" % [path, err])
|
||||
return
|
||||
print("captured %dx%d -> %s" % [img.get_width(), img.get_height(), path])
|
||||
|
||||
|
||||
## A frame every 0.25 s for the whole run, so an unattended boot leaves a
|
||||
## filmstrip behind rather than requiring someone to be watching it.
|
||||
func _film_capture() -> void:
|
||||
while true:
|
||||
await RenderingServer.frame_post_draw
|
||||
if _elapsed >= _film_next:
|
||||
var img := viewport.get_texture().get_image()
|
||||
img.save_png("%s_%03d.png" % [_film, _film_frame])
|
||||
_film_frame += 1
|
||||
_film_next += 0.25
|
||||
|
||||
|
||||
# Godot passes everything after `--` through untouched; take `--key=value`.
|
||||
static func _args() -> Dictionary:
|
||||
var out := {}
|
||||
for arg in OS.get_cmdline_user_args():
|
||||
if arg.begins_with("--") and arg.contains("="):
|
||||
var pair := arg.substr(2).split("=", true, 1)
|
||||
out[pair[0]] = pair[1]
|
||||
elif arg.begins_with("--"):
|
||||
out[arg.substr(2)] = "1"
|
||||
return out
|
||||
1
godot-import/port/scripts/boot.gd.uid
Normal file
1
godot-import/port/scripts/boot.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cskqmpkw2q6k2
|
||||
120
godot-import/port/scripts/export_tree.gd
Normal file
120
godot-import/port/scripts/export_tree.gd
Normal file
@@ -0,0 +1,120 @@
|
||||
# Locating and reading the open export tree.
|
||||
#
|
||||
# The Godot project NEVER reads a disc format (docs/MISSION.md §2). Everything
|
||||
# it draws comes from `export/`, which is derived, gitignored and regenerated
|
||||
# wholesale by `crates/sylpheed-export`. This class is the only place that knows
|
||||
# where that tree is on disk.
|
||||
class_name ExportTree
|
||||
extends RefCounted
|
||||
|
||||
const FORMAT_SCREEN := "sylpheed.screen/3"
|
||||
const FORMAT_MANIFEST := "sylpheed.manifest/1"
|
||||
|
||||
var root: String = ""
|
||||
var error: String = ""
|
||||
|
||||
|
||||
# `SYLPHEED_EXPORT` wins, so a modder can point the game at their own tree
|
||||
# without touching the project. Otherwise `<project>/../export`, which is the
|
||||
# layout this repository has.
|
||||
static func locate() -> ExportTree:
|
||||
var t := ExportTree.new()
|
||||
var env := OS.get_environment("SYLPHEED_EXPORT")
|
||||
var candidate := env
|
||||
if candidate == "":
|
||||
candidate = ProjectSettings.globalize_path("res://").path_join("../export").simplify_path()
|
||||
if not FileAccess.file_exists(candidate.path_join("manifest.json")):
|
||||
t.error = "no manifest.json under %s -- run `sylpheed-export` first" % candidate
|
||||
return t
|
||||
t.root = candidate
|
||||
return t
|
||||
|
||||
|
||||
# `authored/` sits beside `export/`, never inside it: it is hand-written and
|
||||
# committed, and a re-export must not be able to touch it.
|
||||
func authored(name: String) -> Variant:
|
||||
var path := root.path_join("../authored").simplify_path().path_join(name)
|
||||
var text := FileAccess.get_file_as_string(path)
|
||||
if text == "":
|
||||
error = "cannot read %s" % path
|
||||
return null
|
||||
return JSON.parse_string(text)
|
||||
|
||||
|
||||
func read_json(rel: String) -> Variant:
|
||||
var path := root.path_join(rel)
|
||||
var text := FileAccess.get_file_as_string(path)
|
||||
if text == "":
|
||||
error = "cannot read %s" % path
|
||||
return null
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if parsed == null:
|
||||
error = "%s is not JSON" % path
|
||||
return null
|
||||
return parsed
|
||||
|
||||
|
||||
func manifest() -> Dictionary:
|
||||
var m: Variant = read_json("manifest.json")
|
||||
if m == null:
|
||||
return {}
|
||||
if m.get("format") != FORMAT_MANIFEST:
|
||||
error = "manifest.json is %s, this build reads %s" % [m.get("format"), FORMAT_MANIFEST]
|
||||
return {}
|
||||
return m
|
||||
|
||||
|
||||
# Screens are addressed by their manifest name, not by a path, so the caller
|
||||
# never has to know the archive's subdirectory.
|
||||
func screen(name: String) -> Dictionary:
|
||||
var m := manifest()
|
||||
if m.is_empty():
|
||||
return {}
|
||||
for entry: Dictionary in m.get("screens", []):
|
||||
if entry.get("name") == name:
|
||||
var s: Variant = read_json(entry["file"])
|
||||
if s == null:
|
||||
return {}
|
||||
if s.get("format") != FORMAT_SCREEN:
|
||||
error = "%s is %s, this build reads %s" % [name, s.get("format"), FORMAT_SCREEN]
|
||||
return {}
|
||||
return s
|
||||
error = "no screen named %s in manifest.json" % name
|
||||
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", []):
|
||||
names.append(entry["name"])
|
||||
return names
|
||||
|
||||
|
||||
# Textures live outside res://, so they are read as bytes and decoded at
|
||||
# runtime rather than imported. Nearest-neighbour: the export is a 1:1 copy of
|
||||
# the disc's own texels and several elements are drawn at 200 %, where a
|
||||
# bilinear filter would invent detail the disc does not have.
|
||||
func texture(rel: String) -> Texture2D:
|
||||
var bytes := FileAccess.get_file_as_bytes(root.path_join(rel))
|
||||
if bytes.is_empty():
|
||||
error = "cannot read sprite %s" % rel
|
||||
return null
|
||||
var img := Image.new()
|
||||
if img.load_png_from_buffer(bytes) != OK:
|
||||
error = "%s is not a PNG" % rel
|
||||
return null
|
||||
return ImageTexture.create_from_image(img)
|
||||
1
godot-import/port/scripts/export_tree.gd.uid
Normal file
1
godot-import/port/scripts/export_tree.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://kyd3xrt1lpnj
|
||||
387
godot-import/port/scripts/screen_view.gd
Normal file
387
godot-import/port/scripts/screen_view.gd
Normal file
@@ -0,0 +1,387 @@
|
||||
# Draws one exported screen, either at a moment on its timeline or at the
|
||||
# `rest` pose the export declares.
|
||||
#
|
||||
# TIMELINE is the real behaviour and the default. A keyframe is the start of a
|
||||
# LINEAR ramp toward the next, and the unit of `t` comes from
|
||||
# `authored/timing.json` -- it is measured, not on the disc, which is why it is
|
||||
# authored and applied in exactly one place.
|
||||
#
|
||||
# REST reproduces what the export's `rest` field says, which is what
|
||||
# `sylpheed-cli screen render` draws. It is kept so `tools/verify-screen` can
|
||||
# hold both renderers to the same assumption. The two modes DISAGREE on six
|
||||
# elements in this export, and the running game sides with the timeline -- see
|
||||
# `docs/DECISIONS.md`.
|
||||
#
|
||||
# One CanvasItem draws the whole screen in `_draw`, rather than a node per
|
||||
# element. The export's `paint_order` is already back-to-front, so honouring it
|
||||
# is a loop; z-indexing sixteen nodes to reproduce the same order would be the
|
||||
# same information expressed less directly, and would hide a tie behind Godot's
|
||||
# own sibling rules.
|
||||
class_name ScreenView
|
||||
extends Node2D
|
||||
|
||||
## Skip `kind & 0x4` template instances that duplicate a plain element.
|
||||
## docs/FORMAT.md: those are motion-trail ghosts and are not on screen at rest.
|
||||
## The narrow form of the rule matters -- 174 elements on the disc carry the bit
|
||||
## with no template to duplicate, and a blanket skip would erase them.
|
||||
const KIND_TEMPLATE_INSTANCE := 0x4
|
||||
|
||||
enum Pose { TIMELINE, REST }
|
||||
|
||||
## Which pose to draw. TIMELINE walks the keyframes at `time_units`; REST draws
|
||||
## the export's declared `rest` and is there for renderer-vs-renderer diffing.
|
||||
var pose_mode: Pose = Pose.TIMELINE
|
||||
|
||||
## Position on the timeline, in the disc's own keyframe units. `t` is left raw
|
||||
## everywhere; seconds appear only where `units_per_second` is applied.
|
||||
var time_units: float = 0.0
|
||||
var units_per_second: float = 60.0
|
||||
|
||||
## Duration of the ramp into the final, untimed keyframe -- the screen playing
|
||||
## itself out. Authored (`authored/timing.json`): the disc has no time slot on
|
||||
## that keyframe, so this is the one unknown duration per screen.
|
||||
var exit_ramp_units: float = 24.0
|
||||
|
||||
## While true the screen holds at `rest` and never plays its exit. The
|
||||
## sequencer clears it to send the screen away.
|
||||
var holding: bool = true
|
||||
|
||||
var tree: ExportTree = null
|
||||
var screen: Dictionary = {}
|
||||
var textures: Dictionary = {}
|
||||
var skipped: Array[String] = []
|
||||
var drawn: Array[String] = []
|
||||
|
||||
## Which button is highlighted, by element id. P1 leaves it empty: initial focus
|
||||
## was measured as unstable boot to boot (HANDOFF Q5) and picking one is an
|
||||
## authored decision that belongs to P5.
|
||||
var focused_id: String = ""
|
||||
|
||||
|
||||
func load_screen(t: ExportTree, name: String) -> bool:
|
||||
tree = t
|
||||
screen = t.screen(name)
|
||||
if screen.is_empty():
|
||||
push_error(t.error)
|
||||
return false
|
||||
var design: Array = screen.get("design", [1280, 720])
|
||||
# The export's coordinates are in this space and the viewport matches it, so
|
||||
# a mismatch means the export is not what this project was built to draw.
|
||||
var viewport := Vector2i(
|
||||
ProjectSettings.get_setting("display/window/size/viewport_width"),
|
||||
ProjectSettings.get_setting("display/window/size/viewport_height"))
|
||||
if Vector2i(int(design[0]), int(design[1])) != viewport:
|
||||
push_warning("screen %s is authored at %sx%s, viewport is %s" % [name, design[0], design[1], viewport])
|
||||
_load_textures()
|
||||
queue_redraw()
|
||||
return true
|
||||
|
||||
|
||||
func _load_textures() -> void:
|
||||
textures.clear()
|
||||
for element: Dictionary in screen.get("elements", []):
|
||||
var paths: Array = [element.get("sprite", ""), element.get("focus_sprite", "")]
|
||||
# The focus record's own elements carry their own sprites -- the ring is
|
||||
# only reachable this way.
|
||||
for fe: Dictionary in element.get("focus", {}).get("elements", []):
|
||||
paths.append(fe.get("sprite", ""))
|
||||
for rel: String in paths:
|
||||
if rel != "" and not textures.has(rel):
|
||||
var tex := tree.texture(rel)
|
||||
if tex == null:
|
||||
push_warning(tree.error)
|
||||
else:
|
||||
textures[rel] = tex
|
||||
|
||||
|
||||
# `tint_rgba` is RGBA and `fade_argb` is ARGB -- different byte orders, on
|
||||
# purpose, because the disc spells them differently and a silent swap looks like
|
||||
# an art bug rather than a parse bug. They multiply per channel.
|
||||
static func modulate_of(pose: Dictionary) -> Color:
|
||||
var tint := _rgba(pose.get("tint_rgba", "0xffffffff"))
|
||||
var fade := _argb(pose.get("fade_argb", "0xffffffff"))
|
||||
return Color(tint.r * fade.r, tint.g * fade.g, tint.b * fade.b, tint.a * fade.a)
|
||||
|
||||
|
||||
static func _rgba(hex: String) -> Color:
|
||||
var v := hex.hex_to_int()
|
||||
return Color8((v >> 24) & 0xff, (v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff)
|
||||
|
||||
|
||||
static func _argb(hex: String) -> Color:
|
||||
var v := hex.hex_to_int()
|
||||
return Color8((v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff, (v >> 24) & 0xff)
|
||||
|
||||
|
||||
## The drawn rectangle of an element at a pose.
|
||||
##
|
||||
## `pos` is the top-left at 1:1 and `pivot` is the anchor scale grows about, so
|
||||
## the top-left moves by `-pivot*(s-1)` and the size is the natural size times
|
||||
## `s`. At 100 % the pivot cancels, which is why it can be got wrong invisibly.
|
||||
static func placement(pose: Dictionary, pivot: Vector2, natural: Vector2) -> Rect2:
|
||||
var pos := _vec(pose.get("pos", [0, 0]))
|
||||
var s := _vec(pose.get("scale", [100, 100])) / 100.0
|
||||
return Rect2(pos - pivot * (s - Vector2.ONE), natural * s)
|
||||
|
||||
|
||||
static func _vec(a: Array) -> Vector2:
|
||||
return Vector2(float(a[0]), float(a[1]))
|
||||
|
||||
|
||||
## The pose of one element at `time_units`.
|
||||
##
|
||||
## A group is `pre-roll -> ramp in -> HOLD -> ramp out -> post-roll`, and a
|
||||
## screen that has arrived sits on the **hold**. So the timeline plays in and
|
||||
## stops at `rest`, which is the decoders' identification of that hold and
|
||||
## carries its own `t`.
|
||||
##
|
||||
## It is emphatically NOT "play to the last timed keyframe". The exit is not
|
||||
## only the final untimed frame -- it can be a long run of TIMED ones. The
|
||||
## title's `pteff02` holds at `t=46` with the 25 % dim quad at alpha 0x40 and
|
||||
## then ramps to 0x00 by `t=236`; running to the end drops the dim and makes the
|
||||
## whole screen ~13/255 too bright. That was measured against a plate-free
|
||||
## capture of the running title, and it is what corrected this rule.
|
||||
##
|
||||
## Before the first keyframe the element holds its first pose -- the pre-roll a
|
||||
## staggered menu needs, with the five buttons starting at t=28,30,32,34,36.
|
||||
func pose_at(element: Dictionary, t: float) -> Dictionary:
|
||||
var frames: Array = element.get("keyframes", [])
|
||||
var timed: Array = []
|
||||
for k: Dictionary in frames:
|
||||
if k.has("t"):
|
||||
timed.append(k)
|
||||
if timed.is_empty():
|
||||
# No timed frame at all: the group is a single static pose.
|
||||
return frames[0] if not frames.is_empty() else element.get("rest", {})
|
||||
# While holding, stop at the hold: past it the group is ramping out, and a
|
||||
# screen that has arrived and is sitting there is not leaving.
|
||||
if holding:
|
||||
t = minf(t, settle_units(element))
|
||||
# The exit. The final keyframe carries no `t` -- the disc has no slot for one
|
||||
# -- so it is given a synthetic time `exit_ramp_units` after the last timed
|
||||
# frame and then interpolated like any other. That keeps one code path: the
|
||||
# difference between arriving and leaving is only how far `t` is allowed to
|
||||
# run, not a second kind of animation.
|
||||
#
|
||||
# The whole group plays out, not just the fade quad: on the main menu
|
||||
# pteff00 ramps to opaque black while the labels ramp to transparent and
|
||||
# ptframe1/2 hold. Modelling the exit as a black rect over a frozen screen
|
||||
# was measured and refuted -- see authored/timing.json.
|
||||
var last_frame: Dictionary = frames[frames.size() - 1]
|
||||
if not last_frame.has("t"):
|
||||
var exit_frame := last_frame.duplicate()
|
||||
exit_frame["t"] = float(timed[timed.size() - 1]["t"]) + exit_ramp_units
|
||||
timed.append(exit_frame)
|
||||
|
||||
if t <= float(timed[0]["t"]):
|
||||
return timed[0]
|
||||
for i in range(timed.size() - 1):
|
||||
var a: Dictionary = timed[i]
|
||||
var b: Dictionary = timed[i + 1]
|
||||
var t0 := float(a["t"])
|
||||
var t1 := float(b["t"])
|
||||
if t < t1:
|
||||
# A keyframe is the start of a ramp toward the next, and the ramp is
|
||||
# linear -- measured, `authored/timing.json`.
|
||||
return _lerp_pose(a, b, 0.0 if t1 <= t0 else (t - t0) / (t1 - t0))
|
||||
return timed[timed.size() - 1]
|
||||
|
||||
|
||||
# Channels are integers on the disc. The running game's own fade lands on
|
||||
# `round(255*k/15)`, so rounding -- not truncation -- is what was measured.
|
||||
static func _lerp_pose(a: Dictionary, b: Dictionary, f: float) -> Dictionary:
|
||||
return {
|
||||
"pos": [_ilerp(a["pos"][0], b["pos"][0], f), _ilerp(a["pos"][1], b["pos"][1], f)],
|
||||
"scale": [_ilerp(a["scale"][0], b["scale"][0], f), _ilerp(a["scale"][1], b["scale"][1], f)],
|
||||
"tint_rgba": _hex_lerp(a["tint_rgba"], b["tint_rgba"], f),
|
||||
"fade_argb": _hex_lerp(a["fade_argb"], b["fade_argb"], f),
|
||||
"rotation_deg": _ilerp(a.get("rotation_deg", 0), b.get("rotation_deg", 0), f),
|
||||
}
|
||||
|
||||
|
||||
static func _ilerp(a: float, b: float, f: float) -> int:
|
||||
return int(round(a + (b - a) * f))
|
||||
|
||||
|
||||
# Byte-wise, so it works for both orders without knowing which one it has.
|
||||
static func _hex_lerp(a: String, b: String, f: float) -> String:
|
||||
var x := a.hex_to_int()
|
||||
var y := b.hex_to_int()
|
||||
var out := 0
|
||||
for shift in [24, 16, 8, 0]:
|
||||
out |= (_ilerp((x >> shift) & 0xff, (y >> shift) & 0xff, f) & 0xff) << shift
|
||||
return "0x%08x" % out
|
||||
|
||||
|
||||
## Where one element stops, in keyframe units: its hold.
|
||||
##
|
||||
## `rest.t` when the export gives one. An element whose `rest` carries no time is
|
||||
## a single static pose, and there the last timed keyframe is the same answer.
|
||||
static func settle_units(element: Dictionary) -> float:
|
||||
var rest: Dictionary = element.get("rest", {})
|
||||
if rest.has("t"):
|
||||
return float(rest["t"])
|
||||
var last := 0.0
|
||||
for k: Dictionary in element.get("keyframes", []):
|
||||
if k.has("t"):
|
||||
last = maxf(last, float(k["t"]))
|
||||
return last
|
||||
|
||||
|
||||
## The moment the whole screen has arrived: the last element to reach its hold.
|
||||
func settle_time() -> float:
|
||||
var last := 0.0
|
||||
for element: Dictionary in screen.get("elements", []):
|
||||
last = maxf(last, settle_units(element))
|
||||
return last
|
||||
|
||||
|
||||
## The moment the screen has finished playing itself out, in keyframe units --
|
||||
## the last element's final timed keyframe plus the authored exit ramp.
|
||||
func exit_time() -> float:
|
||||
var last := 0.0
|
||||
for element: Dictionary in screen.get("elements", []):
|
||||
var frames: Array = element.get("keyframes", [])
|
||||
if frames.is_empty():
|
||||
continue
|
||||
var timed_end := 0.0
|
||||
for k: Dictionary in frames:
|
||||
if k.has("t"):
|
||||
timed_end = maxf(timed_end, float(k["t"]))
|
||||
if not frames[frames.size() - 1].has("t"):
|
||||
timed_end += exit_ramp_units
|
||||
last = maxf(last, timed_end)
|
||||
return last
|
||||
|
||||
|
||||
# An element is a ghost only when another element on the same screen carries the
|
||||
# same id *without* the template bit -- the template it is a repeat of.
|
||||
func _template_instance_ids() -> Dictionary:
|
||||
var plain := {}
|
||||
for element: Dictionary in screen.get("elements", []):
|
||||
if int(String(element.get("kind_raw", "0x0")).hex_to_int()) & KIND_TEMPLATE_INSTANCE == 0:
|
||||
plain[element.get("id", "")] = true
|
||||
var ghosts := {}
|
||||
for element: Dictionary in screen.get("elements", []):
|
||||
var kind := int(String(element.get("kind_raw", "0x0")).hex_to_int())
|
||||
if kind & KIND_TEMPLATE_INSTANCE != 0 and plain.has(element.get("id", "")):
|
||||
ghosts[int(element.get("index", -1))] = true
|
||||
return ghosts
|
||||
|
||||
|
||||
## Draw one textured or solid quad, rotated about its pivot.
|
||||
##
|
||||
## The rotation anchor in design space is `pos + pivot`: `pos` is the top-left
|
||||
## at 1:1, so the pivot point sits `pivot` in from it, and scaling about that
|
||||
## point is exactly the `pos - pivot*(s-1)` rule the placement already uses.
|
||||
##
|
||||
## THE GAME DRAWS ROTATION. Confirmed twice by the RE agent, on different
|
||||
## screens and different elements -- the title's `ptloop` sweeps declare +30/-45
|
||||
## and a GPU capture submits them at +30.26/-45.28, and the main menu's focus
|
||||
## ring ramps 0 -> 360 with everything else held constant, caught mid-spin in a
|
||||
## capture. The comparison renderer does not draw it yet, so expect a title
|
||||
## divergence that means "sylpheed-cli is behind", not "the port is broken".
|
||||
##
|
||||
## 🟡 The SIGN is an assumption: the decoder documents `+12` as
|
||||
## clockwise-positive and Godot's 2D rotation is clockwise-positive in a y-down
|
||||
## space, so this passes the value straight through. Not yet checked against a
|
||||
## capture at a known angle.
|
||||
func _draw_quad(tex: Texture2D, rect: Rect2, colour: Color, pivot: Vector2,
|
||||
pos: Vector2, rotation_deg: float) -> void:
|
||||
if is_zero_approx(rotation_deg):
|
||||
if tex != null:
|
||||
draw_texture_rect(tex, rect, false, colour)
|
||||
else:
|
||||
draw_rect(rect, colour, true)
|
||||
return
|
||||
var anchor := pos + pivot
|
||||
draw_set_transform(anchor, deg_to_rad(rotation_deg), Vector2.ONE)
|
||||
var local := Rect2(rect.position - anchor, rect.size)
|
||||
if tex != null:
|
||||
draw_texture_rect(tex, local, false, colour)
|
||||
else:
|
||||
draw_rect(local, colour, true)
|
||||
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
|
||||
|
||||
|
||||
static func _rot_of(pose: Dictionary) -> float:
|
||||
return float(pose.get("rotation_deg", 0))
|
||||
|
||||
|
||||
## Draw a focus record's own elements -- the spinning ring and the bright label.
|
||||
##
|
||||
## The focused state is NOT a sprite swap. `ptbtn0Nf.rat` declares two elements,
|
||||
## and the parent bundle declares NO element for the record at all, so the leaf
|
||||
## is the only source of placement for both and there is nothing to inherit.
|
||||
## The label is 13 px larger per axis than the base and sits at (-7,-7), which
|
||||
## keeps the two concentric; drawing it at the base position pushes it 7 px
|
||||
## down-right and off-centre.
|
||||
func _draw_focus(element: Dictionary) -> void:
|
||||
var focus: Dictionary = element.get("focus", {})
|
||||
for fe: Dictionary in focus.get("elements", []):
|
||||
var rel: String = fe.get("sprite", "")
|
||||
if rel == "":
|
||||
continue
|
||||
var tex: Texture2D = textures.get(rel)
|
||||
if tex == null:
|
||||
skipped.append("%s (focus sprite failed to load)" % fe.get("id", ""))
|
||||
continue
|
||||
# The ring's rest pose. Its spin is real -- rotation_deg ramps 0 -> 360
|
||||
# with position, scale and alpha all constant -- but the PERIOD is not
|
||||
# established: the ramp's second keyframe is untimed, and what an untimed
|
||||
# keyframe means inside a leaf (rather than at screen level, where it is
|
||||
# the exit) is untested. So this holds the resting angle and does not
|
||||
# invent a spin rate.
|
||||
var pose: Dictionary = fe.get("rest", {})
|
||||
var pivot := _vec(fe.get("pivot", [0, 0]))
|
||||
var pos := _vec(pose.get("pos", [0, 0]))
|
||||
_draw_quad(tex, placement(pose, pivot, tex.get_size()), modulate_of(pose),
|
||||
pivot, pos, _rot_of(pose))
|
||||
drawn.append(fe.get("id", ""))
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if screen.is_empty():
|
||||
return
|
||||
var elements: Array = screen.get("elements", [])
|
||||
var ghosts := _template_instance_ids()
|
||||
skipped.clear()
|
||||
drawn.clear()
|
||||
for index: int in screen.get("paint_order", []):
|
||||
var element: Dictionary = elements[index]
|
||||
var id: String = element.get("id", "")
|
||||
if ghosts.has(index):
|
||||
skipped.append("%s (template instance)" % id)
|
||||
continue
|
||||
var pose: Dictionary = element.get("rest", {}) if pose_mode == Pose.REST \
|
||||
else pose_at(element, time_units)
|
||||
var colour := modulate_of(pose)
|
||||
if colour.a <= 0.0:
|
||||
skipped.append("%s (transparent at rest)" % id)
|
||||
continue
|
||||
var pivot := _vec(element.get("pivot", [0, 0]))
|
||||
var pos := _vec(pose.get("pos", [0, 0]))
|
||||
var rot := _rot_of(pose)
|
||||
# A focused button draws its own record instead of its base sprite.
|
||||
if focused_id == id and element.has("focus"):
|
||||
_draw_focus(element)
|
||||
continue
|
||||
var rel: String = element.get("sprite", "")
|
||||
if focused_id == id and element.get("focus_sprite", "") != "":
|
||||
rel = element["focus_sprite"]
|
||||
if rel != "":
|
||||
var tex: Texture2D = textures.get(rel)
|
||||
if tex == null:
|
||||
skipped.append("%s (sprite failed to load)" % id)
|
||||
continue
|
||||
_draw_quad(tex, placement(pose, pivot, tex.get_size()), colour, pivot, pos, rot)
|
||||
drawn.append(id)
|
||||
elif element.get("role", "") == "primitive" and element.has("size"):
|
||||
# A primitive has no texture; the quad is its declared size and its
|
||||
# colour is the pose's own modulate.
|
||||
_draw_quad(null, placement(pose, pivot, _vec(element["size"])), colour, pivot, pos, rot)
|
||||
drawn.append(id)
|
||||
else:
|
||||
# A .t32 element whose sprite the exporter could not produce. Saying
|
||||
# so is the point -- a silently missing element looks like art.
|
||||
skipped.append("%s (no sprite in the export)" % id)
|
||||
1
godot-import/port/scripts/screen_view.gd.uid
Normal file
1
godot-import/port/scripts/screen_view.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cf6hspvq602s3
|
||||
52
godot-import/tools/screen-strip
Executable file
52
godot-import/tools/screen-strip
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# Render one screen at several points on its timeline and montage them -- the
|
||||
# P2 gate artifact, and the way to eyeball any animation question later.
|
||||
#
|
||||
# tools/screen-strip main_menu # a default spread
|
||||
# tools/screen-strip main_menu 0.45 0.6 0.9 1.35 # explicit seconds
|
||||
#
|
||||
# Also writes <screen>.rest.png and <screen>.settled.png and reports where they
|
||||
# differ. That difference is the interesting number: the timeline is expected to
|
||||
# land EXACTLY on the declared resting pose for every element whose `rest` the
|
||||
# decoders identify correctly, so a clean run shows a difference confined to the
|
||||
# elements we know it misses, and nothing else. A difference anywhere else means
|
||||
# the interpolation is wrong.
|
||||
set -euo pipefail
|
||||
cd "${PROJECT_DIR:-/work}"
|
||||
|
||||
name="${1:?usage: screen-strip SCREEN [SECONDS...]}"; shift
|
||||
times=("$@")
|
||||
[ ${#times[@]} -eq 0 ] && times=(0.45 0.52 0.57 0.62 0.67 0.75 0.90 1.35)
|
||||
OUT="${OUT:-${TMPDIR:-/tmp}/screen-strip}"
|
||||
export DISPLAY="${DISPLAY:-:97}"
|
||||
mkdir -p "$OUT"
|
||||
[ -d port/.godot ] || godot --headless --path port --import >/dev/null 2>&1
|
||||
|
||||
shot() { # shot <godot args...> <outfile>
|
||||
local out="${!#}"
|
||||
godot --path port --resolution 1280x720 -- "--screen=$name" "${@:1:$#-1}" \
|
||||
"--capture=$out" >"$OUT/$name.log" 2>&1
|
||||
}
|
||||
|
||||
labelled=()
|
||||
for t in "${times[@]}"; do
|
||||
shot "--time=$t" "$OUT/$name.t$t.png"
|
||||
convert "$OUT/$name.t$t.png" -resize 320x180 -bordercolor gray30 -border 1 \
|
||||
-background black -fill white -pointsize 13 label:"t = ${t}s" \
|
||||
-gravity center -append "$OUT/$name.lab$t.png"
|
||||
labelled+=("$OUT/$name.lab$t.png")
|
||||
done
|
||||
montage "${labelled[@]}" -tile 4x -geometry +4+4 -background black "$OUT/$name.strip.png"
|
||||
|
||||
shot --pose=rest "$OUT/$name.rest.png"
|
||||
shot --time=99 "$OUT/$name.settled.png"
|
||||
convert "$OUT/$name.settled.png" "$OUT/$name.rest.png" -compose difference -composite "$OUT/$name.d.png"
|
||||
max=$(convert "$OUT/$name.d.png" -format "%[fx:maxima*255]" info:)
|
||||
convert "$OUT/$name.d.png" -colorspace Gray -threshold 0 "$OUT/$name.m.png"
|
||||
if [ "${max%.*}" = "0" ]; then
|
||||
box="(identical)"
|
||||
else
|
||||
box=$(convert "$OUT/$name.m.png" -trim -format "%wx%h%X%Y" info: 2>/dev/null)
|
||||
fi
|
||||
echo "$name: strip -> $OUT/$name.strip.png"
|
||||
echo "$name: settled timeline vs declared rest -- max ${max}/255, differing region $box"
|
||||
109
godot-import/tools/verify-screen
Executable file
109
godot-import/tools/verify-screen
Executable file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env bash
|
||||
# Diff Godot's drawing of an exported screen against `sylpheed-cli screen
|
||||
# render` of the same build.
|
||||
#
|
||||
# WHAT THIS IS, AND WHAT IT IS NOT.
|
||||
#
|
||||
# It is a CONSISTENCY check between two decoders that share their assumptions,
|
||||
# and a REGRESSION detector: "did anything move since last commit". It is NOT a
|
||||
# correctness check and agreement here is NOT evidence of correctness.
|
||||
#
|
||||
# `sylpheed-cli` is not the oracle. The oracle is the Xenia Canary capture and
|
||||
# the game. Reborn is an explorer and extraction CLI for verifying decodes, and
|
||||
# it can be wrong -- this corpus has been bitten three times by both renderers
|
||||
# agreeing and both being wrong: pteff05 (the menu background, missing from
|
||||
# both), scale-0, and rest(). Each time the capture caught it and neither
|
||||
# renderer could have.
|
||||
#
|
||||
# So: a DIFFERS row means "we moved apart, go find out which of us moved". It
|
||||
# does not mean the port is wrong. Where a capture and this tool disagree, the
|
||||
# capture wins. Use `tools/verify-capture` for the correctness question.
|
||||
#
|
||||
# tools/verify-screen # every screen in the manifest
|
||||
# tools/verify-screen main_menu title # named screens
|
||||
#
|
||||
# Writes <screen>.godot.png, <screen>.ref.png and <screen>.diff.png into
|
||||
# $OUT (default: a directory under /tmp) and prints, per screen, the largest
|
||||
# per-channel difference anywhere in the frame.
|
||||
#
|
||||
# The two renderers are held to the same inputs on purpose:
|
||||
#
|
||||
# * the COMPARISON CLI is the one built by `build-reference-cli`, from the same
|
||||
# `sylpheed-formats` revision the exporter is pinned to. /reborn's own
|
||||
# target/ is a live mount of the other agent's checkout and moves mid-run; a
|
||||
# pixel disagreement against a moving decoder proves nothing.
|
||||
# * `--black` because Godot clears to black and the screen carries its own
|
||||
# background. The CLI's default dim slate stands in for a 3D scene behind an
|
||||
# in-mission screen, which is not this screen.
|
||||
# * `--primitives --animated` because those are what make the CLI draw the same
|
||||
# element set. `--focus` is NOT passed: nothing is focused at rest (HANDOFF
|
||||
# Q5 measured initial focus as unstable boot to boot, so choosing one is
|
||||
# P5's decision).
|
||||
# * `--pose=rest` on the Godot side. Since P2 the port's DEFAULT is to play the
|
||||
# timeline, and the settled timeline is deliberately NOT what `rest` says --
|
||||
# the export's `rest` misses `ptframe1`/`ptframe2` on the main menu, and the
|
||||
# running game shows them (docs/DECISIONS.md). Both renderers read `rest`
|
||||
# through the same decoder, so asking for it here keeps this a test of the
|
||||
# PORT against the reference. It is not the test of whether `rest` is right;
|
||||
# that one is the oracle capture, and the port already departs from it.
|
||||
#
|
||||
# A difference here is not automatically the port's fault, and it is not
|
||||
# automatically a fault at all. Say which renderer moved and why -- do not tune
|
||||
# until they match.
|
||||
set -euo pipefail
|
||||
cd "${PROJECT_DIR:-/work}"
|
||||
|
||||
# `reference-cli/`, not `release/`: the reference binary is built per pinned
|
||||
# revision so a pin change cannot silently reuse the previous revision's build.
|
||||
# See docker/bin/build-reference-cli.
|
||||
CLI="${SYLPHEED_CLI:-${CARGO_TARGET_DIR:-/sylph-home/port/target-container}/reference-cli/sylpheed-cli}"
|
||||
DISC="${SYLPHEED_DISC:-/disc}"
|
||||
OUT="${OUT:-${TMPDIR:-/tmp}/verify-screen}"
|
||||
export DISPLAY="${DISPLAY:-:97}"
|
||||
|
||||
[ -x "$CLI" ] || { echo "no reference CLI at $CLI -- run build-reference-cli" >&2; exit 2; }
|
||||
[ -f export/manifest.json ] || { echo "no export/manifest.json -- run build-export --run" >&2; exit 2; }
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# Godot needs one scan to register the `class_name` globals; without it every
|
||||
# script fails to parse and the run dies with no frame drawn.
|
||||
[ -d port/.godot ] || godot --headless --path port --import >/dev/null 2>&1
|
||||
|
||||
screens=("$@")
|
||||
if [ ${#screens[@]} -eq 0 ]; then
|
||||
mapfile -t screens < <(python3 -c '
|
||||
import json; print("\n".join(s["name"] for s in json.load(open("export/manifest.json"))["screens"]))')
|
||||
fi
|
||||
|
||||
status=0
|
||||
for name in "${screens[@]}"; do
|
||||
build=$(python3 -c '
|
||||
import json,sys
|
||||
m=json.load(open("export/manifest.json"))
|
||||
f=next(s["file"] for s in m["screens"] if s["name"]==sys.argv[1])
|
||||
print(json.load(open("export/"+f))["source"]["build"])' "$name")
|
||||
|
||||
# `--all` because the exporter now addresses by PAK ENTRY INDEX, which is the
|
||||
# numbering `--all` uses; without it the CLI enumerates only the 12 bundles
|
||||
# `is_build` accepts and `--build 10` would land on entry 12. `--all` widens
|
||||
# the list, it does not change how any one bundle composites.
|
||||
"$CLI" screen render "$DISC/dat/GP_TITLE.pak" "$OUT/$name.ref.png" \
|
||||
--build "$build" --all --black --primitives --animated >/dev/null
|
||||
|
||||
godot --path port --resolution 1280x720 -- \
|
||||
"--screen=$name" --pose=rest "--capture=$OUT/$name.godot.png" >"$OUT/$name.log" 2>&1
|
||||
|
||||
convert "$OUT/$name.godot.png" "$OUT/$name.ref.png" \
|
||||
-compose difference -composite -colorspace Gray -auto-level "$OUT/$name.diff.png"
|
||||
read -r max mean <<<"$(convert "$OUT/$name.godot.png" "$OUT/$name.ref.png" \
|
||||
-compose difference -composite -format "%[fx:maxima*255] %[fx:mean*255]" info:)"
|
||||
|
||||
# 3/255 is what integer-truncating compositing in the CLI and float rounding
|
||||
# in a GPU differ by. Anything above that is a placement, order or colour
|
||||
# disagreement and needs a reason, not a threshold.
|
||||
verdict=OK
|
||||
awk "BEGIN{exit !($max > 3)}" && { verdict=DIFFERS; status=1; }
|
||||
printf '%-16s build %-3s max %-5s mean %-8s %s\n' "$name" "$build" "$max" "${mean:0:6}" "$verdict"
|
||||
done
|
||||
echo "artifacts in $OUT"
|
||||
exit $status
|
||||
86
godot-import/tools/verify-video-audio
Executable file
86
godot-import/tools/verify-video-audio
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prove Godot actually emits a transcoded movie's audio -- with no audio device.
|
||||
#
|
||||
# tools/verify-video-audio ADV
|
||||
#
|
||||
# This container has no sound card and Godot falls back to the dummy driver, so
|
||||
# "does it play" looked unanswerable from here. It is not: an AudioEffectRecord
|
||||
# on the Master bus makes Godot write its own mixed output to a WAV from inside
|
||||
# a headless run. That is Godot rendering audio to a file instead of a device --
|
||||
# no new dependency, no image rebuild, and it tests the real playback path
|
||||
# rather than the file the encoder produced.
|
||||
#
|
||||
# What this checks is that GODOT EMITS NON-SILENCE from the movie. It is
|
||||
# deliberately NOT a fidelity comparison against the source: a difference-signal
|
||||
# RMS between a transcode and its source is inconclusive without cross-
|
||||
# correlation alignment and an agreed downmix -- a one-sample offset makes the
|
||||
# residual nearly as loud as the signal. Level and non-silence are what this
|
||||
# claims.
|
||||
set -euo pipefail
|
||||
cd "${PROJECT_DIR:-/work}"
|
||||
name="${1:-ADV}"
|
||||
OUT="${OUT:-${TMPDIR:-/tmp}/verify-video-audio}"
|
||||
export DISPLAY="${DISPLAY:-:97}"
|
||||
mkdir -p "$OUT"
|
||||
[ -d port/.godot ] || godot --headless --path port --import >/dev/null 2>&1
|
||||
|
||||
wav="$OUT/$name.godot.wav"
|
||||
rm -f "$wav"
|
||||
cat > "$OUT/probe.gd" <<'GD'
|
||||
extends SceneTree
|
||||
|
||||
func _init() -> void:
|
||||
var args := {}
|
||||
for a in OS.get_cmdline_user_args():
|
||||
if a.begins_with("--") and a.contains("="):
|
||||
var p := a.substr(2).split("=", true, 1)
|
||||
args[p[0]] = p[1]
|
||||
|
||||
var tree_ := ExportTree.locate()
|
||||
if tree_.root == "":
|
||||
push_error(tree_.error); quit(2); return
|
||||
var v: Dictionary = tree_.video(args.get("video", "ADV"))
|
||||
if v.is_empty():
|
||||
push_error(tree_.error); quit(2); return
|
||||
|
||||
# Record the MASTER bus: whatever Godot mixes, including the dummy driver's
|
||||
# output. This is the real playback path, not the encoded file.
|
||||
var rec := AudioEffectRecord.new()
|
||||
AudioServer.add_bus_effect(0, rec)
|
||||
|
||||
var stream := VideoStreamTheora.new()
|
||||
stream.file = v["path"]
|
||||
var p := VideoStreamPlayer.new()
|
||||
p.stream = stream
|
||||
get_root().add_child(p)
|
||||
await process_frame
|
||||
rec.set_recording_active(true)
|
||||
p.play()
|
||||
var seconds := float(args.get("seconds", "6"))
|
||||
var t := 0.0
|
||||
while t < seconds and p.is_playing():
|
||||
await process_frame
|
||||
t += get_root().get_process_delta_time()
|
||||
rec.set_recording_active(false)
|
||||
var clip := rec.get_recording()
|
||||
if clip == null:
|
||||
push_error("no recording came back from the Master bus"); quit(3); return
|
||||
clip.save_to_wav(args.get("out", "/tmp/godot-audio.wav"))
|
||||
print("recorded %.2f s, %d Hz, stereo=%s -> %s" % [
|
||||
t, clip.mix_rate, clip.stereo, args.get("out", "")])
|
||||
quit(0)
|
||||
GD
|
||||
|
||||
godot --path port --resolution 320x180 --script "$OUT/probe.gd" -- \
|
||||
"--video=$name" "--out=$wav" "--seconds=${SECONDS_TO_RECORD:-6}" 2>&1 \
|
||||
| grep -viE "ALSA|Vulkan|V-Sync|OpenGL|audio driver|^ *at: |Condition|^$" || true
|
||||
|
||||
[ -s "$wav" ] || { echo "verify-video-audio: Godot wrote no WAV" >&2; exit 1; }
|
||||
echo "--- what Godot emitted ---"
|
||||
ffmpeg -hide_banner -i "$wav" -af volumedetect -f null - 2>&1 \
|
||||
| grep -oE "(max_volume|mean_volume): [-0-9.]+ dB" | sed 's/^/ /'
|
||||
mean=$(ffmpeg -hide_banner -i "$wav" -af volumedetect -f null - 2>&1 \
|
||||
| grep -oE "mean_volume: [-0-9.]+" | grep -oE -- "-?[0-9.]+")
|
||||
# Digital silence reports around -91 dB at 16-bit. Anything near that is nothing.
|
||||
awk -v m="$mean" 'BEGIN{ if (m < -80) { print " VERDICT: silence -- Godot is not emitting this movie\047s audio"; exit 1 }
|
||||
else { printf " VERDICT: audio present (mean %.1f dB)\n", m } }'
|
||||
Reference in New Issue
Block a user