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.
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.
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.
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.
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.
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.
The port is deliberately separate from the reverse-engineering project: its own
repository, its own clone, its own container. Two writers in one working tree
means files change under whoever is mid-edit and a `git add -A` by one sweeps up
the other's work -- which happened today in the Reborn tree, so this is set up
not to repeat it.
The wall: Godot never reads a disc format. An offline Rust exporter converts the
user's disc into JSON + PNG + Ogg, and the Godot project reads only that. No
GDExtension, no Rust in port/. Beyond the practical reason -- Godot cannot read
IPFB, RATC, T8aD, XMA or WMV -- there is the design one: modding is a goal, and
if the runtime reads the original formats then modding means reverse
engineering, whereas if it reads JSON it means opening a file.
The decoders come from sylpheed-formats PINNED BY REVISION (8b6dbcf), not
vendored and not reimplemented. `sylpheed_formats::media` in particular already
owns every case where one playable thing is not one archive entry: entries that
span segment files, banks with several sub-waves, and the cutscene voices, which
are one continuous XMA stream chunked into VOICE_*.slb entries whose boundaries
do NOT match the cues. That last one is the easiest thing in this project to get
subtly wrong, so the mission says outright not to re-derive it.
docs/MISSION.md is the objective (P0-P7, each gated by an artifact rather than
by compiling). docs/BLOCKED.md lists what cannot proceed until the RE agent
answers Q1-Q10, and says plainly that none of it may be guessed -- this agent
has no emulator and no oracle, so a value it invents is indistinguishable from a
decoded one a month later.
The container is deliberately small: 3 cpus / 4 GB against the RE container's
6 / 7, and an image with no C++ toolchain, no Vulkan stack and no emulator. Two
full-size containers do not fit on this box beside a desktop.
Its launcher sets the git identity through GIT_AUTHOR_*/GIT_COMMITTER_* rather
than writing [user] into .git/config -- the config route captures every commit
made in that tree, including a human's, which is how six of today's commits
ended up attributed to the RE agent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>