Compare commits

21 Commits

Author SHA1 Message Date
MechaCat02
ff0937fa8a port: pin the 5.1 downmix matrix explicitly -- human decision
The RE agent escalated this rather than picking, correctly: folding centre
into L/R changes how dialogue sits against the music, which is an aesthetic
judgement about the game and not a container detail.

Decided: explicit ITU fold, centre at -3 dB, recorded in the manifest with the
rest of the transcode command. Pinned rather than defaulted because a default is
a decision nobody made -- invisible in the output and free to change between
ffmpeg versions.
2026-08-29 11:12:38 +02:00
Sylpheed port agent
8d7373b16c video: state the 5.1 downmix instead of inheriting it, and write atomically
A real defect in the P4 output, found by the human on ADV.wmv and widened by the
RE agent to the whole disc: the disc ships 28 movies in 5.1 WMA Pro (every
cutscene, INCLUDING both movies this port needs) and 69 already in stereo. A
bare `-ac 2` therefore does two different things and records neither -- stereo
passes through, and 5.1 is folded by ffmpeg's DEFAULT matrix. How loudly
centre-channel dialogue sits against the music is a content decision, and it was
being made by accident and could move under an ffmpeg upgrade.

Now stated: ITU-R BS.775, LFE dropped, normalised by 1/(1+2*sqrt(1/2)) = 0.4142.
It appears in the recorded command, so the manifest determines the output.

MEASURED rather than chosen by taste, and the measurement is the interesting
part: the explicit matrix and ffmpeg's inherited default differ by a residual of
-91 dB -- about one LSB at 16-bit -- with peak and mean agreeing to 0.1 dB. So
ffmpeg's default IS this matrix, and the audio does not change; what changes is
that the manifest now says which matrix. The UNnormalised textbook form was
measured too and clips at 0.0 dBFS, which is why the scaling is there.

The filter is applied only to 6-channel sources, probed per file with ffprobe,
so a stereo source is never run through a matrix referencing channels it lacks.

Also: encode to a temp name and rename on success. ffprobe read a mid-write
.ogv as 33 s against a 137 s source -- no error, no warning, the exact shape of
catastrophic truncation. The filesystem is shared with the RE agent, so that is
a race, not an edge case, and a half-written file must never be visible under
its final name.

tools/verify-video-audio answers the second of the three questions
docs/AUDIO-VERIFICATION.md separates: does GODOT route the audio. An
AudioEffectRecord on the Master bus writes Godot's own mixed output to a WAV
from a headless run, so "no sound card" was never the obstacle I claimed. It
deliberately checks non-silence and level only -- a difference-signal RMS
against the source is inconclusive without cross-correlation alignment and an
agreed downmix, and would produce a confident wrong number.
2026-08-29 09:04:47 +00:00
Sylpheed port agent
700094bef1 FORMAT v3: rotation, and the focus record -- the ring the port could not reach
Pin bumped to the TAG formats-pin-2026-08-29 (7eeae30), applying the policy the
previous commit wrote. What I wanted from it: `UiBuild` gained a public
`records` map. Without it a leaf was unreachable through the public API --
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, could not be located at all. My exporter
was writing 19 of build 5's 21 sprites and I could not see why.

v3 carries two new things.

ROTATION. `rotation_deg`, decoded at keyframe +12, and the game DRAWS it --
confirmed twice by the RE agent on different screens with different elements:
the title's ptloop sweeps declare +30/-45 and a GPU capture submits them at
+30.26/-45.28, and the focus ring ramps 0 -> 360 with everything else constant,
caught mid-spin in a capture. Rotation is about the DECLARED PIVOT, measured.
The comparison renderer does not draw it yet, so a rotation disagreement means
sylpheed-cli is behind, not that the port is wrong. Sign is still an assumption.

THE FOCUS RECORD. A focused button is not a sprite swap: ptbtn0Nf.rat declares
the spinning ring AND the bright label, and since the parent declares no element
for the record, the leaf is the only source of placement for both. v2's single
focus_sprite could not carry the ring at all and drew the highlight 7 px
off-centre by inheriting the base position. That -7,-7 is load-bearing: the f
label is 13 px larger per axis and -7 keeps the two concentric.

Checked against the game, not against the other renderer: rendering main_menu
with OPTIONS focused changes the region x 504..703, y 399..448. The RE agent
measured the same difference in the live capture at x 505..703, y 397..446 --
independently, from the other side. Ring, label and underline all land; the only
visible residual is the ring's spin PHASE, which is exactly the one thing
neither of us has resolved (its second keyframe is untimed, and the screen-level
rule for that is not established to apply inside a leaf). Listed in `unresolved`
rather than invented.

verify-screen is unchanged at 16/16 -- rotation has no effect at rest on these
screens, as predicted.
2026-08-29 09:04:47 +00:00
MechaCat02
c0a5725934 port: pin by tag, and how to verify audio with no sound card
**The pin.** 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, silently, at their build. formats-pin-2026-08-29 exists for the
current state.

Also says plainly why NOT to float to a branch, which was the tempting fix:
Cargo resolves a git dependency once and writes the sha into Cargo.lock, so
floating gives staleness you cannot see instead of staleness you can read.
push-work now pushes --follow-tags so annotated tags travel with the branch.

**Audio.** docs/AUDIO-VERIFICATION.md separates three questions that were being
asked as one: is the transcode faithful (no engine, no device -- a file-vs-file
difference measurement), does Godot route it (AudioEffectRecord on the Master
bus writes a WAV from a headless run), and what does the GAME play (a PulseAudio
null sink, which needs a rebuild).

It leads with the three ways the fidelity measurement lies, because all three
were hit on the first attempt and each produces a confident wrong number rather
than an error: unaligned subtraction, mismatched channel layouts, and probing a
file another process is still writing.

The 5.1 disc fact deliberately is NOT copied here -- it lives in the RE corpus
at docs/re/structures/movie-audio-channels.md and is linked, so there is one
copy to keep true rather than two that drift. Same reason HANDOFF is a summary
with links.

The downmix itself stays flagged as an unmade decision, not quietly resolved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:00:54 +02:00
Sylpheed port agent
2fbcd59920 docs: retract "reference renderer" -- sylpheed-cli is not the oracle
A framing correction from the human, and it runs through everything I have
written, so it is a retraction rather than a silent edit.

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 verify-screen is a CONSISTENCY check between two decoders that share their
assumptions, plus a regression detector -- not a correctness check, and
agreement in it is not evidence of correctness. Its header now says so, it calls
the CLI the COMPARISON renderer, and DIFFERS means "we moved apart, find out
which of us moved".

The uncomfortable part, recorded because it is the actual failure mode: this
file already contained the sentence "two renderers reading one field through one
decoder agreeing is not evidence that the field is right", written after the
ptframe1 case -- and I then went on quoting 3/255 against sylpheed-cli as though
it meant the port was right. Having the principle written down did not stop me
leaning on the agreement.

Three times both renderers agreed and both were wrong, each caught only by a
capture: pteff05 (menu screens had no background), scale-0 (drawn full size
instead of collapsed), rest() (the menu bracket missing).

Correctness moves to the captures -- nine of them, indexed at
docs/re/captures/ORACLE-CAPTURES.md, covering all five screens in scope. Three
cautions travel with them: not gamma-neutral (there is a floor, don't chase it),
geometry IS sound (a positional disagreement is real), and each is one moment of
a still-animating screen.

verify-screen keeps running over all 16 screens every iteration. It is still
worth having -- total, cheap, and it catches a divergence introduced on the RE
side. It is just not a grade.
2026-08-29 08:45:48 +00:00
Sylpheed port agent
579c8096c1 port: P4 -- the intro video plays inside the boot sequence
The exporter transcodes ADV.wmv and S00A.wmv to Ogg Theora and records the exact
ffmpeg command in the manifest, per MISSION §6, so a modder who dislikes the
quality re-runs one line rather than reverse-engineering what was done.

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

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

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

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

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

NOT VERIFIED, and stated as such: audible playback. This container has no audio
device and Godot falls back to the dummy driver. The Vorbis stream exists, is
2-channel and decodes; whether Godot emits it is unconfirmed.
2026-08-29 08:44:25 +00:00
Sylpheed port agent
b632602517 docs: P3 -- the boot runs unattended, and four answers taken as given
Gate: `godot --path port -- --boot --film=/tmp/boot` runs publisher_logo ->
developer_logos (4.65 s) -> title (8.57 s), holding on the title after 13.05 s,
each screen fading in, holding, and fading through black into the next.

verify-screen now covers all 16 screens and passes `--all` to the reference
renderer: the exporter addresses by pak entry index, which is the numbering
`--all` uses, and without it `--build 10` would land on entry 12. The four new
splash bundles come in at max 1-2/255. The three known differences are unchanged.

Also recorded, taken from the RE agent rather than re-derived:

* Focus stays "replace" -- over-vs-instead is unobservable (the focused sprite
  covers the base at 100 % of base-visible pixels; the two compositions differ by
  RMSE 1.1, under the gamma floor). The port guessed right for the wrong reason.
  The real gap is that ptbtn0Nf.rat declares TWO sprites -- a glowing ring, then
  the label -- where ptbtn0N.rat declares one. P5's, and the ring's placement is
  not decoded, so it will be authored from the capture and marked as such.
* RMSE against captures has a FLOOR: capture ~ 255*(render/255)^g, g ~ 1.49 menu
  and EXTRAS, 1.34 title, and it is a ramp the GAME installed, not a capture-path
  artefact to subtract. Narrow reach (fitted on mostly-dark patches), so the port
  will not extrapolate it and will not apply it to rendered output. 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 -- about the declared pivot, measured -- and it has zero effect on the
  five screens at rest.
2026-08-29 08:27:41 +00:00
Sylpheed port agent
0a026074d3 port: play the exit ramp, and run the boot sequence unattended
P3. The exit is the group playing ITSELF out, not a black rect over a frozen
screen -- and that was settled by a test that discriminates rather than by
plausibility. Under the black-rect model every region is scaled by the same
1-alpha, so the button/background brightness RATIO would hold constant through
the fade; measured, it falls 6.495 -> 5.574 -> 3.105 -> 2.125 -> 1.935.

Implemented by giving the final untimed keyframe a SYNTHETIC time,
exit_ramp_units after the last timed one, then interpolating it like any other
frame. One code path: arriving and leaving differ only in how far `t` is allowed
to run, not in kind. `holding` is what the sequencer clears to send a screen away.

The sequencer waits on nothing the disc does not carry. A screen holds until its
own group has arrived, then plays out; `dwell` in flow.json is deliberately empty
because each screen's dwell IS its keyframe group (publisher wordmark 3.92 s,
developer logos 3.17 s, both read from the disc). Any extra hold would be a
number nobody measured.

The last screen keeps holding -- nothing is taking the title's place, and a boot
that ends by fading to black looks like a boot that crashed.

flow.json reproduces an OBSERVATION and says so in its header: Q6 closed with a
negative, the order is on the disc nowhere, a transition is a call with a name
argument chosen by code. The intro video's place in the real boot is named as a
gap rather than the order being quietly rewritten to hide it.
2026-08-29 08:27:41 +00:00
Sylpheed port agent
0d2bf1f241 export: reach the splash by authored entry index, and key names by entry
There were TWO splash screens and this port had neither. Entries 11/14 are the
developer logos; entries 10/13 are the SQUARE ENIX publisher wordmark, the first
thing the boot shows, which nothing in this project had noticed.

They have no .rat layout child so `is_build` cannot see them, and the RE agent
established that no CONTENT rule can either: design size fails (every extra
composable bundle sampled is 1280x720, same as every screen) and element count
fails (fragments run 2..15 elements in GP_OPTIONS/GP_SAVE_LOAD, the splash
halves are 3 and 7 -- the ranges overlap).

So `screen_builds` is `is_build` plus an authored allow-list of ENTRY INDICES,
each carrying a `why` that says it is a locator and not a claim. An allow-list
rather than a loosened predicate because this is safe in GP_TITLE and would not
be in general: there, widening adds exactly four bundles and all four are real
screens, zero fragments.

screen_names.json is rekeyed from enumeration ordinal to pak entry. Widening
renumbers ordinals, and a name that moves when the enumeration rule changes is
not a name -- the file always called the entry the stronger locator, and it is
now the only stable one. The two unnamed plates therefore renamed build_10/11 ->
build_12/15; they were always locators, and now they locate the right thing.

All 16 export and validate.
2026-08-29 08:27:41 +00:00
Sylpheed port agent
3588a0270d docs: record what the port needs from the RE agent, ranked by cost to the port
Five items, written down here rather than only in a message, because a request
that lives in one conversation dies with the container. Two are blocking P3 (how
to recognise the splash bundle, and what the ~0.4 s fade-out actually measures),
one is cheap and prevents P5 rework (focus drawn over vs instead of the base --
the port picked replace without evidence, and the oracle says it picked wrong),
one needs a joint decision rather than a unilateral one (whether the port should
draw the newly-decoded rotation and thereby become deliberately more correct than
the renderer it verifies against), and one calibrates whether RMSE against
captures has a floor at all.
2026-08-29 08:04:38 +00:00
Sylpheed port agent
f6fccee11c deps: bump the pin to f817dd5 -- the menu screens get their background
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 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.

Every menu render this port has produced has been missing its background, and
P1 wrote that up as a fact about the disc -- "the bundle carries zero RATC
children for either, so there is no texture on the disc to export". That is
retracted in docs/DECISIONS.md rather than edited away.

The re-export is contained: six new sprites and nothing else. pteff05 on
main_menu/extras and their JP twins, pteff04 on both titles. Each gains a
`sprite` line and moves `layer_source` from "implied" to "sprite" -- the layer
key is now read from the file instead of the decoders' table of keys measured
off the running game, which is the ratchet turning the right way.

pteff05.png is 1280x720; ptbase.png, which had been carrying the background
alone, is 640x360 drawn at 200 %.

Measured against the live capture rather than the other renderer: settled
main_menu RMSE 8.05 % -> 5.92 %. The reference renderer was missing the same
element for the same reason, so no renderer-vs-renderer diff could have found
this -- the third time a capture has caught something both renderers agreed on.
2026-08-29 08:04:06 +00:00
Sylpheed port agent
4a5fa7f1cb merge main: container and push-work fixes
Bringing the infrastructure fixes from main onto this branch so the two do not
drift. main touched docker/{entrypoint.sh,sylph-port,bin/push-work} and
docs/loop-task.md; this branch's only docker/ change is bin/build-reference-cli,
so there is no overlap and nothing to reconcile by hand.
2026-08-29 06:14:43 +00:00
Sylpheed port agent
40aee1da48 docs: answer the RE agent's question, and record the stale-reference failure
Their question was which screens the other four of "six" elements are on. The
six span the whole 12-screen export: ptframe1/ptframe2 on main_menu and
main_menu_jp (trailing run alpha 0xff, VISIBLE -- their rule accepts these, and
they are the four it revealed disc-wide), and pteff02 on title and title_jp
(trailing run alpha 0x00, TRANSPARENT -- their rule excludes it).

That exclusion is correct, and their own measurement is what proves it: pteff02
is the 25 % dim quad, and they measured the title render going from +13.14 to
+0.55 against the plate-free capture once it is drawn. So `rest` must stay at
0x40 and must not move to the transparent trailing run -- which is what their
alpha rule does. Two investigations converging from opposite directions, and no
third discriminator is needed.

Also recorded: the reference renderer was stale for three diff runs and reported
success throughout, and the shape worth naming is that a build system reporting
success is not evidence the artifact you are about to trust is the code you
pinned. The RE agent hit the same class of trap this session from the other side.

BLOCKED.md: the loop/hold question is answered (groups hold -- ptloop01/ptloop02
park off-screen at x=1521 and x=-839), and the rest_plateau entry is closed as
fixed, noting the adopted rule is theirs and not the looser one this port
proposed.
2026-08-28 21:44:08 +00:00
Sylpheed port agent
b36dddaeb5 build: key the reference CLI's target dir by the pinned revision
The reference renderer was a revision behind for three consecutive diff runs and
nothing said so.

After the pin bump, `build-reference-cli` reported success at rev 5414db3 and
`verify-screen` showed main_menu jumping 3/255 -> 72/255. The natural reading was
that the port had regressed. It had not: the port was right and the REFERENCE was
stale. The shared CARGO_TARGET_DIR still held a `sylpheed-cli` built from
8b6dbcf, cargo said "Finished in 0.13s" and left it there. A clean target dir
built a binary resolving 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. A stale binary runs
perfectly.

So: build into `$CARGO_TARGET_DIR/reference-cli/$rev`, keyed by the pin, where a
new revision has no artifacts to reuse. Then verify 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 needs an image rebuild to reach
PATH; until then invoke the repo copy by path.
2026-08-28 21:44:08 +00:00
Sylpheed port agent
22d7c88cf5 port: settle at the hold, not at the last timed keyframe
P2 shipped the wrong rule and the title is the counter-example. I had reasoned
that the exit is the final untimed keyframe, so anything timed was still the
entry. It is not: `pteff02` on the title holds at t=46 with the 25 % dim quad at
alpha 0x40 and then ramps to 0x00 by t=236. The exit can be a long run of TIMED
keyframes, and running to the end drops the dim and leaves the whole screen
~13/255 too bright -- exactly the luminance excess P2 recorded (oracle 64.1,
rest 62.8, timeline 80.0) and filed as an unexplained "glow slab".

A group is pre-roll -> ramp in -> hold -> ramp out -> post-roll, and a screen
that has arrived sits on the hold. `settle_units()` is now `rest.t`, the
decoders' own identification of that hold.

The check is that a disagreement vanishes: on ALL TWELVE screens the settled
timeline is now byte-identical to the `--pose=rest` render, where before this
four differed by up to 247/255. The timeline's endpoint should BE `rest` -- the
animation is what it adds, not a different destination -- so this is the
property to want, and it holds with no special case.

Credit where due: this came out of the RE agent measuring the title's dim quad
against a plate-free capture, in pursuit of a different question.
2026-08-28 21:44:08 +00:00
Sylpheed port agent
9ecef5208e deps: bump the sylpheed-formats pin 8b6dbcf -> 5414db3 for the rest() fix
What I wanted from it: `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.

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 rule shipped is the RE agent's, not the
condition this port proposed -- mine was too loose and would have erased the
word PAUSE on `pgptitle.rat`, whose trailing run is two identical TRANSPARENT
frames. A trailing run is the hold exactly when it is VISIBLE.

The re-export shows the change was contained: two files differ, and within them
exactly four `rest` blocks -- ptframe1/ptframe2 on both main menus, t=16 alpha
0x00 at the pre-roll position becoming t=62 alpha 0xff at the arrived one.
Every diff line pairs. The other ten screens are byte-identical, no sprite
changed, and `pteff02` correctly did not move.
2026-08-28 21:44:08 +00:00
Sylpheed port agent
89de93b18f docs: the timeline lands on rest -- except on six elements, where the game
agrees with the timeline

P2's gate is the buttons sliding in, and `tools/screen-strip` renders the strip
that shows it. But the useful result came out of checking where the animation
settles.

On 8 of 12 screens the settled timeline is BYTE-IDENTICAL to the declared `rest`
pose -- the port walks the keyframes with an authored time unit and arrives, to
the pixel, where the pinned decoders independently say the screen rests. On
`main_menu` the two differ in exactly one region, 400x470 at (440,108): the
bounding box of `ptframe1` and `ptframe2` and nothing else.

`rest` puts both at their first keyframe, off-position and transparent. The
capture of the running game shows them -- the bright circuit bracket around the
menu. Cropping the same region from the capture and from both renders puts the
ring and its elbow trace in the timeline render pixel-aligned with the game's,
and absent from the rest render. Geometry, so it does not depend on the
capture's gamma or on its having been taken with NEW GAME focused.

`ui_layout::rest_plateau` excludes a trailing run of identical keyframes because
it is normally the exit. On an element with NO exit animation the trailing run IS
the hold. The condition that identifies these exactly, with no false positives
here, is "the final untimed keyframe has the same pose as the last timed one" --
six elements, and `rest()` misses all six. Filed in BLOCKED.md for the RE agent:
the decoders are pinned and are not this port's to fix, and `sylpheed-cli screen
render` is missing the bracket too.

Worth saying plainly what this does to P1: the port and the reference agreed on
`main_menu` to 3/255 and BOTH were missing two elements the game draws. Two
renderers reading one field through one decoder agreeing is not evidence the
field is right. BLOCKED.md had already said that about the pivot; here it bit.

The title is NOT settled and P2 does not claim it. `rest` and the timeline
disagree there by 142-247/255, the only live title capture composites the PRESS A
plate over build 4 so it cannot be diffed against the title alone, and both of
the port's modes draw a cyan glow slab the game does not have -- a third problem,
P3's. Recorded as an open question rather than resolved by tuning.

Also reconciled against the RE agent's new work: Q8 is answered -- the SE waves
are located in `Static.slb` (move/confirm/back), which unblocks P6's audio; and
the title's transitions are a lookup by NAME, giving P3/P5 the game's own screen
vocabulary as candidate `goto` targets, marked as the name match it is.
2026-08-28 19:51:57 +00:00
Sylpheed port agent
0db45b4c66 port: play the keyframe timeline, with the time unit authored in one place
P2. A keyframe is the start of a linear ramp toward the next; `ScreenView` walks
them at `time_units` and `boot.gd` advances that in real time, or freezes it with
`--time=<seconds>`.

`authored/timing.json` holds the ONE constant this needs. HANDOFF Q1 is answered
-- linear, 2 units per rendered frame, 1 unit = 1/60 s -- but that conversion was
MEASURED off the running game, not read from a file, so it is authored rather
than exported and it says so at length. Expressed as units-per-second, because
60 is exact and 0.01666... is a decimal a reader has to recognise.

The timeline stops at the last TIMED keyframe and never plays the exit. Every
group's final keyframe carries no `t` -- across this export it is a fade-out for
116 of 134 elements, a scale-and-slide exit for 12, and identical for 6 -- so
playing into it would mean inventing how long the ramp takes. That duration is
the screen transition, it is measured at ~0.4 s, and it is P3's to author with
its own evidence. `exit_ramp_seconds` is therefore null on purpose, not missing.

`--pose=rest` keeps the P1 behaviour available: since the port's default is now
the timeline and the two DISAGREE, renderer-vs-renderer diffing has to be able to
ask for the same assumption the reference renderer makes.

The interpolation is checked by where it lands: on 8 of the 12 screens the
settled timeline is byte-identical to the rest render.
2026-08-28 19:51:57 +00:00
Sylpheed port agent
146a604500 docs: the P1 diff, and the one question it raised
`main_menu` -- the milestone's gate -- agrees with the reference renderer to
within 3/255 on every channel of every pixel, RMSE 0.38 %, no pixel above 4 %.
Nine of the twelve screens are at or under that; two are byte-identical.

Three exceed it, and each gets a cause rather than a wider tolerance:

* `title` (max 6): every disagreement is INSIDE A TIE -- the derived order and
  the CLI's measured-off-the-game order differ only among elements with
  identical layer keys. That is exactly the residual HANDOFF Q3 documents and
  the export already declares in `unresolved: paint_order_ties`. Cost: 904 px at
  4-6/255 in one glow band. The port keeps the stable sort; fitting it to one
  screen's capture would be tuning.

* `title_jp` (max 154): `ptlogo_eff2` at 125 % is the only drawn element in the
  whole export at a scale that is not a whole multiple of 100 %, and `title_jp`
  is the only screen over 6/255. Same fact twice. `ui_layout::blit` samples the
  source at the destination pixel's top-left corner; a GPU samples at its
  centre, and at 125 % those disagree on one column in five.

  I think the CLI is the one that is wrong -- corner-sampled nearest is a
  half-pixel bias toward the top-left that no rasteriser produces. But that is a
  reading, not a measurement: it needs a framebuffer capture of the Japanese
  title screen, so it is filed in BLOCKED.md as a question. The port is NOT
  changing to match, because matching would mean reproducing a half-pixel offset
  on purpose to make a number smaller.

* `extras` (max 4): two pixels.

Also reconciled: the pivot question predicted a P1 diff could not distinguish
the declared pivot from half the texture, because both renderers use the
declared one. That held. Recorded so the agreement is not later mistaken for
evidence -- and P2 will not settle it either.

BLOCKED.md's `/reborn` HEAD updated to bc6354d, with a note that the mount is
read-only by design and `git -C /reborn pull` fails rather than being skipped.
2026-08-28 19:31:21 +00:00
Sylpheed port agent
a79acdd29a tools: verify-screen -- diff every exported screen against the reference renderer
The P1 gate is a pixel comparison, so it needs to be a command rather than a
paragraph. `tools/verify-screen` renders each 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 on purpose:

* the reference CLI is the one `build-reference-cli` builds from the revision
  the exporter is PINNED to -- not `/reborn/target/`, which is a live mount of
  the other agent's checkout and moves mid-iteration. A pixel disagreement
  against a moving decoder has a free variable in it and proves nothing.
* `--black`, because the screen carries its own background and the CLI's default
  dim slate stands in for a 3D scene that is not on this screen.
* `--primitives --animated`, because the port draws every element at rest and
  those two flags are what make the CLI draw the same set. NOT `--focus`:
  nothing is focused at rest.

The threshold is 3/255 -- what integer-truncating compositing in the CLI and
float rounding on a GPU differ by. Above that is a placement, order or colour
disagreement that needs a reason, and the script says DIFFERS rather than
pretending a wider tolerance is a result.
2026-08-28 19:31:21 +00:00
Sylpheed port agent
3d12498550 port: Godot draws an exported screen at its resting pose
P1. The project reads only `export/` -- the manifest, a screen's JSON and its
PNGs -- and draws every element at `rest`, in the export's own `paint_order`.

Three choices worth the words:

* `ExportTree` addresses screens by manifest NAME, never by path, and checks
  `format` on the manifest and on each screen before drawing. Textures are
  decoded from bytes at runtime rather than Godot-imported: `export/` is
  gitignored and regenerated wholesale, and a `.import` per sprite would be
  derived state next to derived state, invalidated on every re-export.

* One CanvasItem draws the whole screen. `paint_order` is already back-to-front,
  so honouring it is a loop; spreading it across sixteen nodes' z-indices would
  hide the one unresolved thing about that order -- the ties -- behind Godot's
  sibling rules.

* The screen renders into a SubViewport sized to the export's `design` rect.
  Capturing the window instead gave 1280x720 of screen minus a window manager's
  title bar: 1235x695. A gate that rescales that to compare against a 1280x720
  composite is measuring the compositor.

Nearest-neighbour filtering, because the export is a 1:1 copy of the disc's
texels, elements draw at up to 500 %, and it is what `ui_layout::blit` does --
so a filter difference cannot masquerade as a placement difference in the diff.

No keyframe interpolation and no focus state: both depend on constants that are
MEASURED rather than decoded (HANDOFF Q1, Q5), and a pixel-diff gate must not
have one of those inside it. P2 and P5.
2026-08-28 19:31:03 +00:00
28 changed files with 2828 additions and 59 deletions

2
Cargo.lock generated
View File

@@ -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",

View File

@@ -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
View 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."
]
}
}

View File

@@ -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
View 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."
]
}

View File

@@ -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"

View File

@@ -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 {

View File

@@ -13,6 +13,7 @@
//! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope. //! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope.
mod check; mod check;
mod video;
mod screen; mod screen;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@@ -67,6 +68,16 @@ struct ManifestScreen {
missing_sprites: Vec<String>, missing_sprites: Vec<String>,
} }
#[derive(Serialize)]
struct ManifestVideo {
name: String,
file: String,
/// The exact command that produced this file. MISSION §6: a modder who
/// dislikes the quality re-runs one line rather than reverse-engineering it.
command: String,
why: &'static str,
}
#[derive(Serialize)] #[derive(Serialize)]
struct Manifest { struct Manifest {
format: &'static str, format: &'static str,
@@ -75,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(),
], ],
}; };

View File

@@ -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 {

View 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,
}))
}

View File

@@ -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)"

View File

@@ -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
View 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.

View File

@@ -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.170.23 s black hold | Q7 | not yet written — P3 | | the ~0.4 s fade-out and the 0.170.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.170.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.

View File

@@ -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 46/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 34/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 12/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.

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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
View 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
View File

@@ -0,0 +1 @@
uid://cskqmpkw2q6k2

120
port/scripts/export_tree.gd Normal file
View 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)

View File

@@ -0,0 +1 @@
uid://kyd3xrt1lpnj

387
port/scripts/screen_view.gd Normal file
View 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)

View File

@@ -0,0 +1 @@
uid://cf6hspvq602s3

52
tools/screen-strip Executable file
View 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
View 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
View 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 } }'