Compare commits
21 Commits
main
...
auto/p0-ex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff0937fa8a | ||
|
|
8d7373b16c | ||
|
|
700094bef1 | ||
|
|
c0a5725934 | ||
|
|
2fbcd59920 | ||
|
|
579c8096c1 | ||
|
|
b632602517 | ||
|
|
0a026074d3 | ||
|
|
0d2bf1f241 | ||
|
|
3588a0270d | ||
|
|
f6fccee11c | ||
|
|
4a5fa7f1cb | ||
|
|
40aee1da48 | ||
|
|
b36dddaeb5 | ||
|
|
22d7c88cf5 | ||
|
|
9ecef5208e | ||
|
|
89de93b18f | ||
|
|
0db45b4c66 | ||
|
|
146a604500 | ||
|
|
a79acdd29a | ||
|
|
3d12498550 |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -959,7 +959,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "sylpheed-formats"
|
name = "sylpheed-formats"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://git.mc02.dev/fabi/Syplheed-Reborn.git?rev=8b6dbcf#8b6dbcfead4168a674015f0e4fd2c8d83f9ffe31"
|
source = "git+https://git.mc02.dev/fabi/Syplheed-Reborn.git?tag=formats-pin-2026-08-29#7eeae3006a7bb92ae31ba8d2b402891f3a31f070"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"binrw",
|
"binrw",
|
||||||
|
|||||||
28
README.md
28
README.md
@@ -42,8 +42,34 @@ assuming a value is on the disc.
|
|||||||
| `port/` | the Godot 4 project |
|
| `port/` | the Godot 4 project |
|
||||||
| `authored/` | decisions that are **not** on the disc, each with its reason |
|
| `authored/` | decisions that are **not** on the disc, each with its reason |
|
||||||
| `export/` | generated, gitignored, never hand-edited |
|
| `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 |
|
| `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
|
## Status
|
||||||
|
|
||||||
Pre-P0. Nothing runs yet.
|
**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.
|
||||||
|
|||||||
55
authored/flow.json
Normal file
55
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."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
{
|
{
|
||||||
"format": "sylpheed.screen_names/1",
|
"format": "sylpheed.screen_names/1",
|
||||||
"_": [
|
"_": [
|
||||||
"Which GP_TITLE build is which screen. AUTHORED: the disc does not name its",
|
"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",
|
"builds, so every name here is a decision. The identifications come from",
|
||||||
"HANDOFF Q2 (ui-title-build-map.md), which measured four of them against",
|
"HANDOFF Q2 (ui-title-build-map.md), which measured them against framebuffer",
|
||||||
"framebuffer captures of the running game; the exporter stamps the name into",
|
"captures of the running game; the exporter stamps the name into the screen",
|
||||||
"the screen file with name_source: \"authored\" so a reader can tell a",
|
"file with name_source: \"authored\" so a reader can tell a recovered name from",
|
||||||
"recovered name from an invented one.",
|
"an invented one.",
|
||||||
"",
|
"",
|
||||||
"`build` is the index into the pak's list of screen builds -- what",
|
"KEYED BY PAK ENTRY INDEX, not by the enumeration ordinal. It used to be the",
|
||||||
"`sylpheed-cli screen --build N` takes -- and is stable as long as the",
|
"ordinal; widening the enumeration to reach the splash renumbers ordinals, and",
|
||||||
"enumeration rule is. The screen file also records the pak entry index,",
|
"a name that moves when the enumeration rule changes is not a name. The entry",
|
||||||
"which is the stronger locator.",
|
"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."
|
"Delete an entry here the day the RE agent decodes a name field."
|
||||||
],
|
],
|
||||||
@@ -21,7 +22,10 @@
|
|||||||
"name": "press_start",
|
"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."
|
"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." },
|
"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": {
|
"4": {
|
||||||
"name": "title",
|
"name": "title",
|
||||||
"why": "HANDOFF Q2: build 4 is the English title art. Measured against a live capture."
|
"why": "HANDOFF Q2: build 4 is the English title art. Measured against a live capture."
|
||||||
@@ -34,12 +38,57 @@
|
|||||||
"name": "extras",
|
"name": "extras",
|
||||||
"why": "HANDOFF Q2: builds 6/9 are the EXTRAS submenu, the only submenu inside this archive. Measured against a fresh EXTRAS capture."
|
"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." },
|
"7": {
|
||||||
"8": { "name": "main_menu_jp", "why": "HANDOFF Q2: the Japanese twin of build 5." },
|
"name": "title_jp",
|
||||||
"9": { "name": "extras_jp", "why": "HANDOFF Q2: the Japanese twin of build 6." }
|
"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": {
|
"unnamed": {
|
||||||
"dat/GP_TITLE.pak": "Builds 0/1 and 10/11 are a DELTASABER / SYLPHEED A.I. plate that was 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 build index rather than a name we would be inventing."
|
"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
authored/timing.json
Normal file
59
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."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -13,7 +13,43 @@ license.workspace = true
|
|||||||
# `sylpheed_formats::media` in particular owns the cases where one playable thing
|
# `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
|
# is not one archive entry (segment-spanning reads, multi-sub-wave banks, and the
|
||||||
# continuous cutscene-voice stream). Do not re-derive those here.
|
# continuous cutscene-voice stream). Do not re-derive those here.
|
||||||
sylpheed-formats = { git = "https://git.mc02.dev/fabi/Syplheed-Reborn.git", rev = "8b6dbcf" }
|
#
|
||||||
|
# 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 = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use anyhow::{bail, Result};
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
const SCREEN_FORMAT: &str = "sylpheed.screen/2";
|
const SCREEN_FORMAT: &str = "sylpheed.screen/3";
|
||||||
const MANIFEST_FORMAT: &str = "sylpheed.manifest/1";
|
const MANIFEST_FORMAT: &str = "sylpheed.manifest/1";
|
||||||
|
|
||||||
struct Ctx {
|
struct Ctx {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
//! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope.
|
//! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope.
|
||||||
|
|
||||||
mod check;
|
mod check;
|
||||||
|
mod video;
|
||||||
mod screen;
|
mod screen;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -67,6 +68,16 @@ struct ManifestScreen {
|
|||||||
missing_sprites: Vec<String>,
|
missing_sprites: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ManifestVideo {
|
||||||
|
name: String,
|
||||||
|
file: String,
|
||||||
|
/// The exact command that produced this file. MISSION §6: a modder who
|
||||||
|
/// dislikes the quality re-runs one line rather than reverse-engineering it.
|
||||||
|
command: String,
|
||||||
|
why: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct Manifest {
|
struct Manifest {
|
||||||
format: &'static str,
|
format: &'static str,
|
||||||
@@ -75,10 +86,16 @@ struct Manifest {
|
|||||||
formats_rev: &'static str,
|
formats_rev: &'static str,
|
||||||
disc: String,
|
disc: String,
|
||||||
screens: Vec<ManifestScreen>,
|
screens: Vec<ManifestScreen>,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
videos: Vec<ManifestVideo>,
|
||||||
warnings: Vec<String>,
|
warnings: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The authored `build index → name` map, keyed by archive path.
|
/// 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>>;
|
type NameMap = std::collections::BTreeMap<String, std::collections::BTreeMap<String, NameEntry>>;
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
@@ -96,6 +113,8 @@ fn load_names(authored: &Path) -> Result<NameMap> {
|
|||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct File {
|
struct File {
|
||||||
archives: NameMap,
|
archives: NameMap,
|
||||||
|
#[serde(default)]
|
||||||
|
also_export: AlsoExport,
|
||||||
}
|
}
|
||||||
let raw = std::fs::read_to_string(&path)
|
let raw = std::fs::read_to_string(&path)
|
||||||
.with_context(|| format!("read {}", path.display()))?;
|
.with_context(|| format!("read {}", path.display()))?;
|
||||||
@@ -104,17 +123,54 @@ fn load_names(authored: &Path) -> Result<NameMap> {
|
|||||||
.archives)
|
.archives)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every RATC entry of a UI pak that parses as a screen build.
|
/// 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 filter is `is_build` — a bundle with a `.rat` layout child. The developer
|
/// The rule is `is_build` — a bundle with a `.rat` layout child — **plus an
|
||||||
/// splash declares its sprites directly and has none, so it is invisible here;
|
/// authored allow-list of entry indices**.
|
||||||
/// that is P3's problem and is recorded as a manifest warning rather than
|
///
|
||||||
/// silently widened.
|
/// The allow-list exists because the splash screens declare their sprites
|
||||||
fn screen_builds(ar: &PakArchive) -> Vec<(usize, Vec<u8>)> {
|
/// 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();
|
let mut out = Vec::new();
|
||||||
for (i, e) in ar.entries().iter().enumerate() {
|
for (i, e) in ar.entries().iter().enumerate() {
|
||||||
let Ok(bytes) = ar.read(e) else { continue };
|
let Ok(bytes) = ar.read(e) else { continue };
|
||||||
if ui_layout::is_build(&bytes) {
|
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.push((i, bytes));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,7 +186,7 @@ fn main() -> Result<()> {
|
|||||||
} => run_export(&disc, &out, &authored),
|
} => run_export(&disc, &out, &authored),
|
||||||
Cmd::Check { out } => {
|
Cmd::Check { out } => {
|
||||||
let n = check::run(&out)?;
|
let n = check::run(&out)?;
|
||||||
println!("{} screen(s) in {} validate against sylpheed.screen/2", n, out.display());
|
println!("{} screen(s) in {} validate against sylpheed.screen/3", n, out.display());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,18 +206,26 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
|||||||
let archive = "dat/GP_TITLE.pak";
|
let archive = "dat/GP_TITLE.pak";
|
||||||
let pak = disc.join(archive);
|
let pak = disc.join(archive);
|
||||||
let ar = PakArchive::open(&pak).with_context(|| format!("open {}", pak.display()))?;
|
let ar = PakArchive::open(&pak).with_context(|| format!("open {}", pak.display()))?;
|
||||||
let builds = screen_builds(&ar);
|
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());
|
println!("{archive}: {} screen build(s)", builds.len());
|
||||||
|
|
||||||
let archive_names = names.get(archive);
|
let archive_names = names.get(archive);
|
||||||
let mut screens = Vec::new();
|
let mut screens = Vec::new();
|
||||||
for (build_idx, (entry, bytes)) in builds.iter().enumerate() {
|
for (build_idx, (entry, bytes)) in builds.iter().enumerate() {
|
||||||
let authored = archive_names.and_then(|m| m.get(&build_idx.to_string()));
|
// Keyed by ENTRY, not by the ordinal: widening the enumeration to reach
|
||||||
let (name, name_source, why) = match authored {
|
// 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()),
|
Some(e) => (e.name.clone(), "authored", e.why.clone()),
|
||||||
// Nobody has identified this build. Emit a stable synthetic id and
|
// Nobody has identified this build. Emit a stable synthetic id and
|
||||||
// say in the file that the name is not a recovered one.
|
// say in the file that the name is not a recovered one.
|
||||||
None => (format!("build_{build_idx:02}"), "index", None),
|
None => (format!("build_{entry:02}"), "index", None),
|
||||||
};
|
};
|
||||||
let ex = screen::export_build(
|
let ex = screen::export_build(
|
||||||
&out,
|
&out,
|
||||||
@@ -195,17 +259,38 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MISSION §6: the boot intro and the one new-game intro only.
|
||||||
|
let mut videos = Vec::new();
|
||||||
|
for m in video::MOVIES {
|
||||||
|
match video::transcode(disc, out, m)? {
|
||||||
|
Some(t) => {
|
||||||
|
println!(" video {} -> {}", m.src, t.file);
|
||||||
|
videos.push(ManifestVideo {
|
||||||
|
name: t.name,
|
||||||
|
file: t.file,
|
||||||
|
command: t.command,
|
||||||
|
why: t.why,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None => println!(" video {} not on this disc -- skipped", m.src),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let manifest = Manifest {
|
let manifest = Manifest {
|
||||||
format: "sylpheed.manifest/1",
|
format: "sylpheed.manifest/1",
|
||||||
exporter: EXPORTER,
|
exporter: EXPORTER,
|
||||||
formats_rev: FORMATS_REV,
|
formats_rev: FORMATS_REV,
|
||||||
disc: disc.display().to_string(),
|
disc: disc.display().to_string(),
|
||||||
screens,
|
screens,
|
||||||
|
videos,
|
||||||
warnings: vec![
|
warnings: vec![
|
||||||
"P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive."
|
"P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive."
|
||||||
.into(),
|
.into(),
|
||||||
"The developer-logo splash is not here: it declares its sprites directly and has \
|
"The four splash bundles (entries 10/13 publisher, 11/14 developer) have no .rat \
|
||||||
no .rat layout child, so `is_build` does not see it. P3."
|
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(),
|
.into(),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! One UI build → one `sylpheed.screen/2` JSON document plus its sprite PNGs.
|
//! 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
|
//! 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
|
//! format Godot can read. The two places a value is not read off the disc are
|
||||||
@@ -61,6 +61,14 @@ pub struct Keyframe {
|
|||||||
/// The second modulate colour, **ARGB** byte order — the high byte is the
|
/// The second modulate colour, **ARGB** byte order — the high byte is the
|
||||||
/// alpha that ramps during a fade. Multiplies with `tint_rgba`.
|
/// alpha that ramps during a fade. Multiplies with `tint_rgba`.
|
||||||
pub fade_argb: 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -69,10 +77,45 @@ pub struct Rest {
|
|||||||
pub scale: [u32; 2],
|
pub scale: [u32; 2],
|
||||||
pub tint_rgba: String,
|
pub tint_rgba: String,
|
||||||
pub fade_argb: 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")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub t: Option<u32>,
|
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)]
|
#[derive(Serialize)]
|
||||||
pub struct Element {
|
pub struct Element {
|
||||||
/// Declaration index — the key the placement region and `paint_order` use.
|
/// Declaration index — the key the placement region and `paint_order` use.
|
||||||
@@ -93,6 +136,11 @@ pub struct Element {
|
|||||||
/// between a button and its highlight that has survived checking.
|
/// between a button and its highlight that has survived checking.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub focus_sprite: Option<String>,
|
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.
|
/// The raw `opt ` link inside this element's `.rat` record.
|
||||||
///
|
///
|
||||||
/// ⚠️ **This is not a focus link.** It was read as one, and that was
|
/// ⚠️ **This is not a focus link.** It was read as one, and that was
|
||||||
@@ -207,22 +255,32 @@ pub fn export_build(
|
|||||||
std::fs::create_dir_all(&sprite_dir)?;
|
std::fs::create_dir_all(&sprite_dir)?;
|
||||||
let mut written: BTreeMap<String, ()> = BTreeMap::new();
|
let mut written: BTreeMap<String, ()> = BTreeMap::new();
|
||||||
let mut missing = Vec::new();
|
let mut missing = Vec::new();
|
||||||
let mut write_sprite = |sprite: &str| -> Result<bool> {
|
// 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) {
|
if written.contains_key(sprite) {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
let Some(&(off, size)) = b.sprites.get(sprite) else {
|
let Some(&(off, size)) = map.get(sprite) else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
let Some(img) = t8ad::parse(&bundle[off..off + size]) else {
|
let Some(img) = t8ad::parse(&bytes[off..off + size]) else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
let buf = image::RgbaImage::from_raw(img.width, img.height, img.rgba)
|
let buf = image::RgbaImage::from_raw(img.width, img.height, img.rgba)
|
||||||
.context("T8aD dimensions disagree with its pixel count")?;
|
.context("T8aD dimensions disagree with its pixel count")?;
|
||||||
buf.save(sprite_dir.join(format!("{}.png", id_of(sprite))))?;
|
buf.save(dir.join(format!("{}.png", id_of(sprite))))?;
|
||||||
written.insert(sprite.to_string(), ());
|
written.insert(sprite.to_string(), ());
|
||||||
Ok(true)
|
Ok(true)
|
||||||
};
|
}
|
||||||
|
|
||||||
|
|
||||||
/// The highlighted twin of a sprite name: `ptbtn01.t32` → `ptbtn01f.t32`.
|
/// The highlighted twin of a sprite name: `ptbtn01.t32` → `ptbtn01f.t32`.
|
||||||
fn highlight_name(sprite: &str) -> Option<String> {
|
fn highlight_name(sprite: &str) -> Option<String> {
|
||||||
@@ -234,7 +292,7 @@ pub fn export_build(
|
|||||||
for el in &b.elements {
|
for el in &b.elements {
|
||||||
let mut sprite_out = None;
|
let mut sprite_out = None;
|
||||||
if let Some(s) = &el.sprite {
|
if let Some(s) = &el.sprite {
|
||||||
if write_sprite(s)? {
|
if write_from(&sprite_dir, &mut written, s, bundle, &b.sprites)? {
|
||||||
sprite_out = Some(sprite_rel(s));
|
sprite_out = Some(sprite_rel(s));
|
||||||
} else {
|
} else {
|
||||||
missing.push(s.clone());
|
missing.push(s.clone());
|
||||||
@@ -245,11 +303,74 @@ pub fn export_build(
|
|||||||
// entirely on half these elements.
|
// entirely on half these elements.
|
||||||
let mut focus_sprite = None;
|
let mut focus_sprite = None;
|
||||||
if let Some(h) = el.sprite.as_deref().and_then(highlight_name) {
|
if let Some(h) = el.sprite.as_deref().and_then(highlight_name) {
|
||||||
if b.sprites.contains_key(&h) && write_sprite(&h)? {
|
if write_from(&sprite_dir, &mut written, &h, bundle, &b.sprites)? {
|
||||||
focus_sprite = Some(sprite_rel(&h));
|
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) {
|
let (layer, layer_source) = match ui_layout::sprite_layer_key(&b, bundle, el) {
|
||||||
Some(k) => (Some(hex32(k)), "sprite"),
|
Some(k) => (Some(hex32(k)), "sprite"),
|
||||||
None => match ui_layout::implied_layer_key(&el.name) {
|
None => match ui_layout::implied_layer_key(&el.name) {
|
||||||
@@ -264,6 +385,7 @@ pub fn export_build(
|
|||||||
scale: [k.scale_x, k.scale_y],
|
scale: [k.scale_x, k.scale_y],
|
||||||
tint_rgba: hex32(k.tint),
|
tint_rgba: hex32(k.tint),
|
||||||
fade_argb: hex32(k.fade),
|
fade_argb: hex32(k.fade),
|
||||||
|
rotation_deg: k.rotation_deg,
|
||||||
};
|
};
|
||||||
let role = role_of(el.kind, el.sprite.is_some());
|
let role = role_of(el.kind, el.sprite.is_some());
|
||||||
elements.push(Element {
|
elements.push(Element {
|
||||||
@@ -274,6 +396,7 @@ pub fn export_build(
|
|||||||
kind_raw: format!("{:#x}", el.kind),
|
kind_raw: format!("{:#x}", el.kind),
|
||||||
sprite: sprite_out,
|
sprite: sprite_out,
|
||||||
focus_sprite,
|
focus_sprite,
|
||||||
|
focus,
|
||||||
opt_link: el.focus_link.clone(),
|
opt_link: el.focus_link.clone(),
|
||||||
pivot: [el.pivot_x, el.pivot_y],
|
pivot: [el.pivot_x, el.pivot_y],
|
||||||
size: (role == "primitive").then(|| [el.pivot_x * 2, el.pivot_y * 2]),
|
size: (role == "primitive").then(|| [el.pivot_x * 2, el.pivot_y * 2]),
|
||||||
@@ -287,6 +410,7 @@ pub fn export_build(
|
|||||||
scale: [k.scale_x, k.scale_y],
|
scale: [k.scale_x, k.scale_y],
|
||||||
tint_rgba: hex32(k.tint),
|
tint_rgba: hex32(k.tint),
|
||||||
fade_argb: hex32(k.fade),
|
fade_argb: hex32(k.fade),
|
||||||
|
rotation_deg: k.rotation_deg,
|
||||||
t: k.time,
|
t: k.time,
|
||||||
}),
|
}),
|
||||||
keyframes: el.keyframes.iter().map(kf).collect(),
|
keyframes: el.keyframes.iter().map(kf).collect(),
|
||||||
@@ -304,7 +428,7 @@ pub fn export_build(
|
|||||||
buttons.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
|
buttons.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
|
||||||
|
|
||||||
let screen = Screen {
|
let screen = Screen {
|
||||||
format: "sylpheed.screen/2",
|
format: "sylpheed.screen/3",
|
||||||
exporter: exporter.to_string(),
|
exporter: exporter.to_string(),
|
||||||
formats_rev,
|
formats_rev,
|
||||||
source: Source {
|
source: Source {
|
||||||
|
|||||||
166
crates/sylpheed-export/src/video.rs
Normal file
166
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -36,10 +36,45 @@ if [ -z "$src" ]; then
|
|||||||
fi
|
fi
|
||||||
[ -n "$src" ] || { echo "build-reference-cli: no checkout for rev $rev" >&2; exit 1; }
|
[ -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"
|
echo "build-reference-cli: building sylpheed-cli from $rev"
|
||||||
cargo build --release --manifest-path "$src/Cargo.toml" -p sylpheed-cli
|
tree="$CARGO_TARGET_DIR/reference-cli/$rev"
|
||||||
|
CARGO_TARGET_DIR="$tree" cargo build --release --manifest-path "$src/Cargo.toml" -p sylpheed-cli
|
||||||
|
|
||||||
out="$CARGO_TARGET_DIR/release/sylpheed-cli"
|
stable="$CARGO_TARGET_DIR/reference-cli/sylpheed-cli"
|
||||||
"$out" screen list "${SYLPHEED_DISC:-/disc}/dat/GP_TITLE.pak" >/dev/null \
|
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; }
|
|| { echo "build-reference-cli: built, but 'screen list' failed" >&2; exit 1; }
|
||||||
echo "build-reference-cli: $out (rev $rev, 'screen' subcommand present)"
|
|
||||||
|
# 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)"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
# `sylph-agent`). They are never printed, never logged, and never passed on a
|
# `sylph-agent`). They are never printed, never logged, and never passed on a
|
||||||
# command line.
|
# command line.
|
||||||
#
|
#
|
||||||
# push-work push the current branch
|
# push-work push the current branch, and any annotated tags on it
|
||||||
# push-work --dry-run say what it would do
|
# push-work --dry-run say what it would do
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -72,5 +72,5 @@ fi
|
|||||||
# --force-with-lease is deliberately NOT offered. If this is rejected as
|
# --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
|
# non-fast-forward, someone else moved the branch: fetch and merge, do not
|
||||||
# overwrite.
|
# overwrite.
|
||||||
git -c "credential.helper=$CRED_HELPER" push --set-upstream origin "$branch"
|
git -c "credential.helper=$CRED_HELPER" push --follow-tags --set-upstream origin "$branch"
|
||||||
echo "push-work: pushed $branch"
|
echo "push-work: pushed $branch"
|
||||||
|
|||||||
110
docs/AUDIO-VERIFICATION.md
Normal file
110
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.
|
||||||
173
docs/BLOCKED.md
173
docs/BLOCKED.md
@@ -9,13 +9,15 @@ 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
|
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.
|
for, so it is deleted rather than forgotten when the answer arrives.
|
||||||
|
|
||||||
Last reconciled against HANDOFF.md on **2026-08-28**, at `/reborn` HEAD `e81dcad`.
|
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
|
## Still open — these block work
|
||||||
|
|
||||||
| Milestone | Needs | HANDOFF | State |
|
| Milestone | Needs | HANDOFF | State |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| P6 audio | which cue fires on move / confirm / back | Q8 | ❔ open. The cue table is complete; the **event binding is not**. P6 cannot bind a sound to a keypress without inventing it. |
|
| ~~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 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. |
|
| 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. |
|
| 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. |
|
||||||
@@ -32,6 +34,18 @@ measured), Q9 (`ADVERTISE_MOVIE` → `ADV.wmv` is boot intro *and* attract; `MS0
|
|||||||
`S00A.wmv` is the new-game intro), Q10 (a bank is two stems played **together** —
|
`S00A.wmv` is the new-game intro), Q10 (a bank is two stems played **together** —
|
||||||
do not concatenate), S1 (Ready Room: no-go).
|
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
|
Three of those are **measured**, not decoded, and so are authored here rather
|
||||||
than exported:
|
than exported:
|
||||||
|
|
||||||
@@ -41,11 +55,161 @@ than exported:
|
|||||||
| initial menu focus (not stable across boots; pick one and say so) | Q5 | not yet written — P5 |
|
| 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 |
|
| 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
|
## 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
|
Not blocking anything today; raised because the port found them and a guess here
|
||||||
would be believed later.
|
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`
|
### The pivot is not half the texture on `GP_TITLE`
|
||||||
|
|
||||||
`sylpheed-formats`'s `ui_layout::Element::pivot_x` is documented as "for a `.t32`
|
`sylpheed-formats`'s `ui_layout::Element::pivot_x` is documented as "for a `.t32`
|
||||||
@@ -68,3 +232,8 @@ 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
|
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
|
`sylpheed-cli screen render` does — so a P1 diff cannot distinguish them, and
|
||||||
agreement between the two is not evidence.
|
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.
|
||||||
|
|||||||
@@ -114,3 +114,698 @@ 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
|
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
|
temptation to emit a plausible number is strongest and the resulting error is
|
||||||
completely invisible.
|
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.
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# The open export format — v2
|
# The open export format — v3
|
||||||
|
|
||||||
The format the disc is converted *into*, and the one the Godot project and any
|
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
|
modding tool read. **It is versioned, so a change is a deliberate act with a
|
||||||
version bump**, not a silent edit. [Changes from v1](#changes-from-v1) is at the
|
version bump**, not a silent edit. [Changes from v2](#changes-from-v2) and
|
||||||
bottom, with a reason for each.
|
[Changes from v1](#changes-from-v1) are at the bottom, with a reason for each.
|
||||||
|
|
||||||
Design rules, in priority order:
|
Design rules, in priority order:
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ rest are applied by the runtime over `export/`.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"format": "sylpheed.screen/2",
|
"format": "sylpheed.screen/3",
|
||||||
"exporter": "sylpheed-export 0.1.0",
|
"exporter": "sylpheed-export 0.1.0",
|
||||||
"formats_rev": "8b6dbcf",
|
"formats_rev": "8b6dbcf",
|
||||||
"source": { "archive": "dat/GP_TITLE.pak", "entry": 5, "build": 5 }
|
"source": { "archive": "dat/GP_TITLE.pak", "entry": 5, "build": 5 }
|
||||||
@@ -69,7 +69,7 @@ the index into that pak's list of screen builds (what `sylpheed-cli screen
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"format": "sylpheed.screen/2",
|
"format": "sylpheed.screen/3",
|
||||||
"exporter": "sylpheed-export 0.1.0",
|
"exporter": "sylpheed-export 0.1.0",
|
||||||
"formats_rev": "8b6dbcf",
|
"formats_rev": "8b6dbcf",
|
||||||
"source": { "archive": "dat/GP_TITLE.pak", "entry": 5, "build": 5 },
|
"source": { "archive": "dat/GP_TITLE.pak", "entry": 5, "build": 5 },
|
||||||
@@ -154,6 +154,46 @@ pairs **by name** on the sprite — `ptbtn01.t32` ↔ `ptbtn01f.t32` — which i
|
|||||||
naming convention that holds for all 54 real pairs on the disc, not a decoded
|
naming convention that holds for all 54 real pairs on the disc, not a decoded
|
||||||
field.
|
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
|
**`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
|
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
|
refuted: on the main menu it chains `ptloop01 → ptloop02 → ptbtn01`, across two
|
||||||
@@ -177,6 +217,23 @@ there — and a file that puts one on it is wrong, not merely odd. The unit of `
|
|||||||
is measured, not on the disc, and so lives in `authored/` and is applied in
|
is measured, not on the disc, and so lives in `authored/` and is applied in
|
||||||
exactly one place.
|
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
|
**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
|
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
|
during a fade. The byte order is in the key name because getting it backwards is
|
||||||
@@ -187,6 +244,22 @@ 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
|
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`.
|
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
|
**`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`.
|
of them. It is the stable sort by `layer`. See `unresolved: paint_order_ties`.
|
||||||
|
|
||||||
@@ -244,6 +317,17 @@ reaches which entry is Q4 and is not).
|
|||||||
`video_transcode` will record the exact command so a modder can re-run it rather
|
`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.
|
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
|
## Changes from v1
|
||||||
|
|
||||||
v1 was written before HANDOFF answered Q1 and Q3, and before the two-colour
|
v1 was written before HANDOFF answered Q1 and Q3, and before the two-colour
|
||||||
|
|||||||
@@ -43,10 +43,21 @@ XMA, no WMV. If Godot cannot read something, the exporter's job is to emit it
|
|||||||
differently — not to bridge the gap at runtime.
|
differently — not to bridge the gap at runtime.
|
||||||
|
|
||||||
* **No GDExtension. No Rust in `port/`.**
|
* **No GDExtension. No Rust in `port/`.**
|
||||||
* The decoders come from `sylpheed-formats`, **pinned by revision**. Do not vendor
|
* The decoders come from `sylpheed-formats`, **pinned by TAG**:
|
||||||
them, do not reimplement them, and do not float the pin — a decoder change
|
`sylpheed-formats = { git = "...", tag = "formats-pin-2026-08-29" }`.
|
||||||
landing mid-milestone is exactly the confusion this pin prevents.
|
|
||||||
* Bump the pin deliberately, as its own commit, saying what you wanted from it.
|
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`
|
**In particular, do not reimplement media assembly.** `sylpheed_formats::media`
|
||||||
already handles the cases where one playable thing is not one archive entry: a
|
already handles the cases where one playable thing is not one archive entry: a
|
||||||
@@ -110,6 +121,28 @@ your own authority.
|
|||||||
Only the boot intro and the one new-game intro are in scope. The disc holds
|
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.
|
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
|
## 7. Out of scope
|
||||||
|
|
||||||
3D, gameplay, HUD, missions, save/load, localisation beyond English, the Ready
|
3D, gameplay, HUD, missions, save/load, localisation beyond English, the Ready
|
||||||
|
|||||||
@@ -18,3 +18,10 @@ window/size/viewport_width=1280
|
|||||||
window/size/viewport_height=720
|
window/size/viewport_height=720
|
||||||
window/stretch/mode="canvas_items"
|
window/stretch/mode="canvas_items"
|
||||||
window/stretch/aspect="keep"
|
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
port/scenes/boot.tscn
Normal file
6
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
port/scripts/boot.gd
Normal file
273
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
port/scripts/boot.gd.uid
Normal file
1
port/scripts/boot.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cskqmpkw2q6k2
|
||||||
120
port/scripts/export_tree.gd
Normal file
120
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
port/scripts/export_tree.gd.uid
Normal file
1
port/scripts/export_tree.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://kyd3xrt1lpnj
|
||||||
387
port/scripts/screen_view.gd
Normal file
387
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
port/scripts/screen_view.gd.uid
Normal file
1
port/scripts/screen_view.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cf6hspvq602s3
|
||||||
52
tools/screen-strip
Executable file
52
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
tools/verify-screen
Executable file
109
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
tools/verify-video-audio
Executable file
86
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