P5's gate is "a human clicks through it". The artifact is a scripted walk that
proves the wiring rather than the intent -- up (wraps 01->05), five down, (A)
into EXTRAS, down, (B) back, landing on the main menu with focus RESTORED to
EXTRAS, ten PNGs one per settled step:
xvfb-run -a godot --path port -- --menu \
--script=up,down,down,down,down,down,accept,down,cancel --shots=/tmp/p5
--script posts InputEventAction through Input.parse_input_event so the presses
arrive at _unhandled_input exactly as a d-pad's would. Calling MenuFlow directly
would have been shorter and would have proved nothing: the wiring between a
press and the cursor is the part most likely to be broken, and a direct call is
exactly the part that skips it.
Derived vs authored, which P5 is the easiest place to blur:
* DERIVED -- the ORDER of the items, from each screen file's `buttons`, which
the exporter already fills from button-role elements sorted by resting Y.
* AUTHORED -- destinations, initial focus, what (B) does, and left/right being
a no-op. All measured off the running game (HANDOFF Q4/Q5) or chosen, none
on the disc, all in authored/flow.json with a why.
Four of five main-menu destinations are `goto: null` with a `blocked` note. That
is a MILESTONE BOUNDARY, not an unknown -- DIFFICULTY, the save list, the lesson
list and OPTIONS were all measured and live in archives this export does not
carry. `blocked` and `none` are kept apart so nobody later "discovers" the gap.
--headless CANNOT DRAW, and the port hung instead of saying so.
Measured, not assumed: under --headless Godot's dummy renderer never emits
RenderingServer.frame_post_draw, so every capture path awaited it forever --
--capture since P1, --film since P3, --shots as of now. With stdout block-
buffered the observable behaviour was SILENCE, FOREVER, which in a loop reads as
a job still working. Isolated by `--quit` (prints, exits 0) vs `--capture` (zero
bytes, killed at 40 s). Now those three flags refuse at STARTUP naming the
xvfb-run line that works, and --script no longer waits for a frame it is not
going to photograph -- so headless walks the menus in 4.5 s as a cheap
regression check needing no X server.
REFUTATION ATTEMPT, against the Decoder's 76653ca point 2 ("the oracle confirms
the game renders the ring's rotation"). Aimed there because PROTOCOL says to aim
at a claim the port is about to build on that rests on an estimator whose own
control the Decoder reported as +/-19.8 deg. IT SURVIVES, more strongly than
claimed.
Both captures draw the SAME sprite (ptbtneff01) 240 px apart, so "is it drawn
rotated" becomes "are these two crops one image at a different angle" -- no crop
offset needed and no reference to our own renderer. 360-bin angular luminance
profile over the annulus, circularly cross-correlated. Two controls first: known
rotations 0/30/90/150/210/270/330 recovered with 0 deg error, and a ring-free
patch of the same capture peaks at 0.369, so the estimator does not manufacture
matches. Then: A vs B 134 deg (corr 0.968), sprite vs A 76 deg, sprite vs B
210 deg -- and 210-76 = 134, which nothing in the method forced.
So 0 deg is NOT A POSE THE GAME SHOWS, and screen_view.gd draws the ring at
0 deg. That is now stated in the code as known-wrong rather than suspected. The
port did NOT start spinning it: the period has two unknowns and both are the
Decoder's -- the second keyframe is untimed, and "groups hold" predicts a stop
at 360 = 0 which contradicts both captures. Two frames of one focused button a
known time apart settle it. Filed in BLOCKED.md and asked over the channel.
BLOCKED.md's staleness check was half a check. It tested whether that page is
stale relative to HANDOFF; it cannot see the other direction, and the other
direction is what happened -- 76653ca lands 27 minutes AFTER HANDOFF was last
written and answers a question HANDOFF still lists as open. Added the missing
half: `git log --oneline 0fd8e69..HEAD -- docs/re/`.
Also recorded, since the two were nearly confused: the ring's annulus centroid
lands within ~0.4 px of its design position under a ZERO crop offset, which
corroborates ORACLE-CAPTURES' "1279x675, top-left aligned" on a feature nobody
chose for the purpose. The earlier "text bands at design y + 23" is an offset
WITHIN the button sprite, not a crop offset.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtmUw5N5LJaMW1Njb8Ziey
58 KiB
Decisions
One entry per decision that outlives the container it was made in. Newest last. A decision that lives only in an agent's context is lost when that container dies, which is what this file is for.
P0 — the exporter, 2026-08-28
The exporter reads one authored file, and stamps its provenance into the output
export/ is derived and authored/ is hand-written, and the natural reading of
that is that the exporter never touches authored/. But a screen has to be
called something, and the disc does not name its builds — the identification of
build 5 as the main menu is HANDOFF Q2, measured against a live capture, not
a field.
Two ways to handle that:
- the exporter emits
build_05.jsonand the runtime renames it fromauthored/screen_names.json; - the exporter reads that map and writes
main_menu.jsondirectly.
Chose 2, with a condition: every name it applies carries name_source: "authored" and a name_why quoting the evidence, and check rejects an
authored name with no why. The file that lands in export/ is therefore still
honest about which of its fields is a measurement — which is the property the
derived/authored split exists to protect — while a human opening the tree sees
main_menu.json rather than having to resolve a rename in their head. A build
nobody has identified exports as build_NN with name_source: "index", which is
a locator and not a claim.
This is the only authored input the exporter takes. Everything else in
authored/ is applied by the runtime over export/.
Sprites are per screen, not a flat pool
main_menu and extras both ship a ptbase.t32 and they are different
pictures. A flat sprites/ directory would have silently collided; whichever
screen exported second would have won, and the loser would have drawn the wrong
background with no error anywhere. sprites/<subdir>/<screen>/<name>.png.
The format is executable
sylpheed-export check --out export validates a tree against docs/FORMAT.md
with no disc in hand. It exists because "the export is correct" is otherwise an
assertion, and because the P0 gate is "validates against FORMAT.md" — which is
not a thing anyone can confirm by reading.
It reads the tree the way Godot will: as a stranger, with no access to the disc,
the decoders, or the exporter's internals. It deliberately does not check the
export against the disc — that is what sylpheed-cli screen render is for, at P1.
Checked that it bites, rather than assuming: five mutations of a valid
main_menu.json — a broken paint_order permutation, a dangling
focus_sprite, a reversed buttons list, a #rrggbbaa colour, an invented
name_source — are each caught with a specific message.
The highlight sprite pairs by name; opt is exported but not believed
FORMAT v1 said focus_sprite came from the element's opt link. That reading
was measured and refuted by the RE agent, and this export shows why plainly:
on the main menu, opt chains ptloop01 → ptloop02 → ptbtn01 — two decorations
and then a button. It is a linked list of something, and it is not focus.
The highlight is paired by sprite name instead (ptbtn01.t32 ↔
ptbtn01f.t32), which is HANDOFF's convention and holds for all 54 real pairs on
the disc. It resolves all five main-menu buttons. The raw link is still exported
as opt_link, renamed so that nothing downstream mistakes it for navigation, and
so that whoever eventually decodes it has the data.
Note this is 🟡 a naming convention, not a decoded field. It is authored in effect, and lives in the exporter only because it is a rule over disc data rather than a value we chose.
The paint order is exported, not authored
Q3 decoded it — a u16 layer key at +0x0A of each T8aD sprite header,
stable-sorted with declaration index. So it is read in the exporter, per the
contract's own rule for a decoded answer, and paint_order in export/ is a
derived field. "paint_order" is gone from unresolved; paint_order_ties
replaces it, because the tie-break is still unknown and costs one element's
blend on one screen.
Where an element has no T8aD header the key comes from the decoders' table of
keys measured off the running game. That is a different kind of fact, so it
is labelled: layer_source is "sprite", "implied" or "none", and a
consumer that needs to know whether a layer is read or measured can tell.
Colours are exported as two fields with the byte order in the name
There are two modulate colours and they multiply: tint is RGBA, fade is
ARGB and its high byte is the alpha that ramps. v1's single "#ffffffff"
could not carry both and silently discarded the ramping alpha. They are exported
as tint_rgba and fade_argb, raw hex, byte order in the key — because getting
it backwards is silent and looks like an art bug rather than a parse bug.
t stays raw
HANDOFF Q1 is answered — linear ramp, 2 units per rendered frame, working conversion 1 unit = 1/60 s — but that conversion is measured off the running game, not read from the file, and the finding itself flags the 27.6 present- frames/second measurement as the part worth re-testing. If the game turns out to present at 60 Hz, every duration halves.
So t is exported exactly as the disc spells it, keyframe_time_unit stays in
unresolved, and the conversion will live in one authored place at P2. One
constant to change, in a file that says it is a decision.
The final keyframe has no t, and check enforces that
The disc has no time slot on the last keyframe of a group. A file that carries
one there has invented it. check rejects it — this is the one place where the
temptation to emit a plausible number is strongest and the resulting error is
completely invisible.
P1 — Godot draws the screen, 2026-08-28
The Godot side reads the manifest, not a path
ExportTree is the only class that knows where export/ is: SYLPHEED_EXPORT
if set, otherwise <project>/../export. Screens are addressed by their manifest
name (main_menu), never by a file path, so the runtime never encodes the
archive's subdirectory and a re-export that moves a file does not break it. It
also checks format on both the manifest and each screen, and refuses a tree it
was not built to read rather than half-drawing one.
Textures are read as bytes and decoded with load_png_from_buffer at runtime.
They are deliberately not Godot-imported resources: export/ is gitignored
and regenerated wholesale, and a .import sidecar per sprite would be derived
state living next to derived state, invalidated on every re-export.
One CanvasItem draws the whole screen
ScreenView._draw walks paint_order and draws each element itself, rather
than making a node per element and leaning on z_index. The export's
paint_order is already back-to-front, so honouring it is a loop; expressing
the same order through sixteen nodes' z-indices would hide the one thing that is
still unresolved about it — the ties — behind Godot's own sibling rules,
where a change in the export would silently become a change in Godot's tree
order instead of a visible change in the draw sequence.
P1 draws rest and nothing else
Every element is drawn at its resting pose. No keyframe interpolation: that is P2, and it depends on the keyframe time unit, which is measured rather than decoded. A milestone whose gate is a pixel diff must not have a measured constant inside it, or the diff stops being evidence about the port.
For the same reason focused_id is empty at P1. Initial focus was measured as
unstable boot to boot (HANDOFF Q5), so choosing one is an authored decision and
it belongs to P5, where a human is pressing keys.
Nearest-neighbour, and why that is not a preference
TEXTURE_FILTER_NEAREST. The export is a 1:1 copy of the disc's own texels and
elements draw at up to 500 %; a bilinear filter invents detail the disc does not
have. It is also what the reference renderer does — ui_layout::blit maps
destination to source by integer division — so a filter difference cannot
masquerade as a placement difference in the diff.
The capture is the SubViewport, not the window
The screen is drawn into a SubViewport sized to the export's own design
rectangle and shown through a container that scales it to the window. The first
attempt captured get_viewport() and got 1235×695: there is a window manager
on the Xvfb display and its title bar had eaten 45×25 px of a screen the export
declares as 1280×720. A gate that compares a rescaled 1235×695 capture against a
1280×720 composite measures the compositor.
So --capture grabs the SubViewport texture: exactly the design rectangle,
independent of the window, directly comparable with screen render with no crop
and no resample. The windowed run is still worth doing — it is what proves a
human sees the screen — but it is not what the numbers come from.
P1 gate — the diff, and what it found
tools/verify-screen renders every screen in the manifest both ways and reports
the largest per-channel difference anywhere in the frame. Both renderers are held
to the same inputs: the reference CLI built by build-reference-cli from the
revision the exporter is pinned to (not /reborn/target/, which is a live
mount that moves mid-iteration), --black because the screen carries its own
background, and --primitives --animated because those are what make the CLI
draw the same element set the port draws at rest.
| screen | build | max per-channel Δ | |
|---|---|---|---|
main_menu |
5 | 3 | the P0/P1 gate screen |
main_menu_jp |
8 | 3 | |
extras / extras_jp |
6 / 9 | 4 / 3 | |
press_start / press_start_jp |
2 / 3 | 1 | |
build_00 / build_01 |
0 / 1 | 3 | |
build_10 / build_11 |
10 / 11 | 0 | byte-identical |
title |
4 | 6 | paint-order tie, below |
title_jp |
7 | 154 | sampling phase, below |
main_menu — the milestone's own gate — agrees to ≤3/255 on every channel of
every pixel, RMSE 0.38 %, with no pixel differing by more than 4 %. 3/255
is what integer-truncating compositing in the CLI and float rounding on a GPU
differ by; there is no structural disagreement anywhere in the frame.
Three screens exceed that, and each has a named cause rather than a threshold.
title: a tie in the paint order — neither renderer is wrong
Build 4 is the one screen where the CLI uses a paint order measured off the
running game instead of deriving it. Compared against the order this port
exports, every single disagreement is inside a tie — the two orders differ
only among elements carrying identical layer keys (0x8083, the back2 glow
group, and 0x80a0):
derived : … 15, 16, 17, 18, 0, 1, 2, 3, 4, 5, 7, …
measured: … 15, 18, 16, 17, 0, 2, 4, 7, 1, 3, 5, …
That is exactly the residual HANDOFF Q3 documents and this export already
declares in unresolved: ["paint_order_ties"]. It is worth stating what it
costs: 904 px in the glow band at (445,117)–(1195,313), all of them 4–6/255.
The port keeps the stable sort, per HANDOFF's own recommendation. Nothing to fix,
and nothing to tune — a "fix" here would be fitting the port to one screen's
capture.
Two of the reordered indices (0x80a0) are kind & 0x4 template instances that
both renderers skip, so the only real reorder outside the glow group is
ptlogo2 against ptlogo_tm, which do not overlap.
title_jp: nearest-neighbour sampling phase — the CLI is the one I would call wrong
title_jp is the only screen in the export with a drawn element at a scale
that is not a whole multiple of 100 %: ptlogo_eff2 at 125 %. It is also the
only screen with a difference above 6/255. The two facts are the same fact.
At a non-integer ratio the two renderers pick different source texels:
ui_layout::blitsamples 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
pteff05.t32 and pteff04.t32 have no sprite, and that is correctRETRACTED 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,rest62.8, timeline 80.0 — which looks like it favoursrest, except that the plate adds brightness andrestis 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
restmode 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-clibuilds 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 resolvesptframe1's rest differently from what the exporter wrote, the two halves of the verification are not the same revision and it fails loudly. It compares the two rather than asserting a literal, so it stays true when the pin moves again.
docker/bin/ is baked into the image, so this takes effect on the next image
build; until then the repo copy has to be invoked by path. The RE agent hit the
same class of trap this session from the other side (./target/debug stale
against a redirected CARGO_TARGET_DIR). It is worth naming the general shape:
a build system reporting success is not evidence that the artifact you are
about to trust is the code you pinned.
What this did not change
title (6/255), extras (4/255) and title_jp (154/255) are unchanged, and
their diagnoses stand — a paint-order tie, two pixels, and nearest-neighbour
sampling phase at 125 % scale. The title's swoosh defect the RE agent localised
(drawn thick and white where the game draws it thin and pink) is untouched by
any of this and remains P3's.
The menu had no background, and P1 called that correct, 2026-08-29
The pin moved 5414db3 → f817dd5 for 56cc7ac, "a RATC child's name is stated,
not inferred". ratc::parse had named each child by scanning backwards for the
last printable run of bytes before its magic. For pteff05.t32 the three
trailing payload bytes are 38 41 58 — 8AX — which beat the real name, so the
child registered under a name no element declares and resolved to no sprite.
pteff05.t32 is the full-resolution background of all five menu screens.
So every render this port has produced of a menu screen has been missing its background, and P1 wrote that up as a property of the disc: "the bundle declares them and carries zero RATC children for either, so there is no texture on the disc to export." That sentence was false. The bundle carries the child; the decoder was handing back the wrong name for it. Retracted above rather than edited away.
What the re-export shows
Six new sprites and nothing else: pteff05.png on main_menu, extras and
their Japanese twins, pteff04.png on both titles. Per screen the JSON gains a
sprite line and layer_source moves "implied" → "sprite" — the layer key is
now read from the file instead of taken from the decoders' table of keys
measured off the running game. That is the derived/authored ratchet turning the
right way, in the exporter rather than in authored/.
pteff05.png is 1280×720; ptbase.png, which had been carrying the
background alone, is 640×360 drawn at 200 %. The screen was being shown its own
art at half resolution.
Measured against the live capture, not against the other renderer
Whole-frame RMSE of the settled main_menu against
captures/main-menu-oracle.png:
| RMSE | |
|---|---|
| before this pin | 8.05 % |
| with the real background | 5.92 % |
A 26 % reduction, and it is the right kind of evidence: the reference renderer was missing the same element for the same reason, so a renderer-vs-renderer diff could not have found this. It is the third time on this project that the capture caught something both renderers agreed on — the bracket, the title dim quad, and now the background.
verify-screen after the bump is unchanged in character: everything at 3–4/255
except title (6, the paint-order tie) and title_jp (155, the sampling phase).
Both renderers gained the background together.
One thing the comparison says that I did not expect
Rendering with --focus=ptbtn01, which is how the capture was taken, makes the
RMSE worse — 5.92 % → 7.00 %. The port replaces an element's sprite with
its *f twin; sylpheed-cli's own --focus is documented as drawing the
focused record over the base element. Those are different operations, and
the capture shows a ring marker beside NEW GAME that the port does not draw.
This is P5's, not P2's, and it is not being guessed at here. Raised in
docs/BLOCKED.md.
P3 — splash → title, unattended, 2026-08-29
The splash is located by entry index, because no rule can find it
The RE agent looked for a content predicate and there is none: design size fails
(every extra composable bundle sampled is 1280×720, the same as every screen) and
element count fails (fragments run 2…15 elements in GP_OPTIONS/GP_SAVE_LOAD
while the splash halves are 3 and 7 — the ranges overlap).
So screen_builds is now is_build plus an authored allow-list of entry
indices, in authored/screen_names.json under also_export, each with a why
that says it is a locator and not a claim. This is safe in GP_TITLE and would
not be in general: there, widening adds exactly four bundles and all four are
real screens with zero fragments. That is why it is an allow-list rather than a
loosened predicate.
There were two splash screens and the port had neither. Entries 11/14 are the developer logos (GAME ARTS / SETA / studio anima); entries 10/13 are the SQUARE ENIX publisher wordmark, the first thing the boot shows, and nothing in this project had noticed them. Both pairs are region twins — ™ on 10, ® on 13 — and the port shows one of each, not both.
authored/screen_names.json is now keyed by pak entry, not by ordinal
Widening the enumeration renumbers the ordinals, and a name that moves when the
enumeration rule changes is not a name. The file had always called the entry
"the stronger locator"; it is now the only stable one. In GP_TITLE the two
coincide across all 16 entries, which is also the numbering sylpheed-cli screen --build N --all takes — so verify-screen now passes --all, and without it
--build 10 would have landed on entry 12.
The two previously-unnamed plates therefore renamed build_10/build_11 →
build_12/build_15. Their names were always locators; now they locate the
right thing.
The exit is the group playing itself out, not a black rect over a freeze
HANDOFF's answer to ask 2 was (a), and it came with a test that discriminates
rather than a plausibility argument. Under "a black quad over a frozen screen"
every region is scaled by the same 1−α, so the button-region / background-region
brightness ratio stays constant through the fade. Measured, it falls
6.495 → 5.574 → 3.105 → 2.125 → 1.935 — a 3.4× monotonic drop. The screen plays
out: pteff00.prm ramps to opaque black while the labels, ptmsg, pteff10
and pteff12 ramp to transparent, and ptframe1/ptframe2 hold.
Implemented by giving the final untimed keyframe a synthetic time,
exit_ramp_units after the last timed one, and then interpolating it like any
other. One code path: the difference between arriving and leaving is only how far
t is allowed to run, not a second kind of animation.
exit_ramp_units = 24 (~0.4 s) is authored, and authored/timing.json carries
the RE agent's own reach caveat rather than smoothing it: the filmstrip is
downsampled and the button region contains some background, so this pins the
direction, not 0.4 s to ±0.05 s, and it is one transition pair.
Nothing waits on a timer the disc does not carry
dwell in authored/flow.json is deliberately empty. Each screen's dwell is its
own keyframe group — the publisher wordmark reaches its hold at t=235 (3.92 s),
the developer logos at t=190 (3.17 s), both read from the disc. Adding a hold on
top would be inventing a number nobody measured. The pacing you see is the
disc's own, and the file says where a measured number would go.
The last screen holds
A screen plays itself out because something is taking its place. Nothing takes the title's place yet, so the sequencer holds there. A boot that ends by fading to black is a boot that looks like it crashed. P4 puts the intro video in front of the title and P5 gives the title somewhere to go.
flow.json reproduces an observation and says so
Q6 closed with a negative: the order is in none of the four places it could have been, and a transition is a call with a name argument chosen by code. So this file is authored and its header says plainly that it reproduces what was watched, not what any file states. The intro video's place in the real boot is named as a gap rather than the order being quietly rewritten to hide it.
P3 gate
godot --path port -- --boot --film=/tmp/boot runs unattended:
publisher_logo → developer_logos at 4.65 s → title at 8.57 s
boot sequence complete after 13.05 s, holding on title
The filmstrip shows each screen fading in, holding, and fading through black into
the next, and the title staying up. verify-screen covers all 16 screens
now; the four new splash bundles come in at max 1–2/255 against the reference
renderer. The three known differences are unchanged: title 6 (paint-order tie),
main_menu 4, title_jp 155 (sampling phase at 125 % scale).
Answers taken from the RE agent without re-deriving them
- Focus stays "replace". Over-vs-instead is unobservable: the focused sprite
covers the base at 100 % of base-visible pixels, and the two compositions
differ by RMSE 1.1 inside the button rect — under the gamma floor. The port's
guess was right for the wrong reason, and the actual gap is that
ptbtn0Nf.ratdeclares two sprites —ptbtneff01.t32, a glowing ring, and then the bright label — whereptbtn0N.ratdeclares 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 andEXTRAS, 1.34 on the title, and it is a ramp the game installed (VdGetCurrentDisplayGammaat 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_degin 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-screensays all of this in its own header, calls the CLI the comparison renderer, and aDIFFERSrow 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 withOPTIONSfocused, 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.
P5 groundwork — the focus record, checked against a capture, 2026-08-29
P5 is the lowest unfinished milestone (P0–P4 are gated above). This iteration did not implement navigation; it did the measurement P5 needs first, because the one thing P5 is built on — how a focused button is drawn — had three claims attached to it and none of them had been checked from this side.
The exporter already emits the focus record's second element
HANDOFF ask 3 answers with a 🔴: "what you are actually missing is the focus
record's SECOND element" — ptbtneff01.t32, a 42×46 glowing ring, declared
before the bright label in ptbtn0Nf.rat.
That gap is in the renderer, not the exporter. export/screens/title/main_menu.json
already carries both, in declaration order, under focus.elements, for all five
buttons — ptbtneff01 then ptbtn0Nf, each with its own pivot, rest pose and
keyframes. Nothing needs to change in crates/sylpheed-export for the ring. What
is missing is that screen_view.gd draws only one sprite per focused button.
Recording this so P5 does not re-open the exporter looking for it.
The ring's size checks out exactly: ptbtneff01.png is 42×46, as stated.
The (7,7) focus offset survives a refutation attempt, uniquely
Ask 3 states the focused sprite covers the base at 100.0 % of base-visible pixels "once aligned properly (the true offset is (7,7), and at the centre alignment it reads a misleading 78–84 %)". P5 builds directly on this, so it was worth attacking.
Re-ran the RE agent's own metric on the exported PNGs — over every pixel where the base sprite is visible, the fraction where the focus sprite's alpha ≥ the base's — scanning the whole offset space, not just the stated answer:
| alignment | ptbtn01 | ptbtn02 | ptbtn03 | ptbtn04 | ptbtn05 |
|---|---|---|---|---|---|
| (7,7) | 100.00 % | 100.00 % | 100.00 % | 100.00 % | 100.00 % |
| geometric centre | 80.58 % | 79.20 % | 79.58 % | 79.45 % | 79.45 % |
| pivot-to-pivot | 80.58 % | 86.59 % | 87.40 % | 79.45 % | 84.58 % |
The refutation fails, and more strongly than the original claim. Over a
15×14 offset scan, (7,7) is a unique isolated cell at 100 % on every one of
the five buttons — every neighbouring offset, including (6,6) and (7,6), falls
below 90 %. The centre and pivot alignments reproduce the 78–84 % band the RE
agent reported as misleading. A claim that survives a search of its whole
parameter space is worth more than one checked at a single point, so this is
recorded as strengthened, not merely unrefuted.
(7,7) is not a constant to apply — it is what the declared positions already say
An earlier version of this analysis had the port disagreeing with the capture by
6 px. That was my arithmetic error and it is worth writing down, because it is
the mistake this format invites: I computed each element's top-left as
pos - pivot, which gives base→focus deltas of (13,13) and a 29 % coverage — a
confident wrong number.
pos is the top-left. screen_view.gd:121 is
Rect2(pos - pivot*(s - 1), natural*s): the pivot is the anchor scale grows
about and it cancels at 100 %, which is exactly the "can be got wrong
invisibly" the comment there warns about. Getting it wrong invisibly is what
happened.
With pos as the top-left, base − focus is (542,162) - (535,155) = (7,7)
directly, on four of the five buttons. So P5 draws each focus element at its own
declared pos and needs no offset constant at all. Nothing to author.
The one real find: ptbtn04 is 1 px off the grid on the disc
The focus records sit on a clean 80 px pitch — 155, 235, 315, 395, 475. The
base records do not: 162, 242, 322, 401, 482, i.e. spacings 80, 80, 79,
81. So ptbtn04's declared base→focus delta is (7,6), while the art
itself aligns at (7,7) — the coverage scan puts ptbtn04 at 100 % on (7,7) and
below 90 % on (7,6), the same as every other button.
This is 1 px of authoring jitter on the disc, not a decode error, and it has one
consequence worth stating: do not derive the focus placement from the base by a
constant. Draw the focus record at its own declared pos. A port that
"simplified" this to base + (7,7) would put ptbtn04's focus art 1 px off, and
would look right on the other four.
Verified against a capture, not against our other renderer
Diffing two oracle frames isolates what focus changes without any instrument in
the path: live-main-menu.png vs live-main-menu-options-focused.png differ in
one tight cluster of 6 338 px at x 506..702, y 398..445. ptbtn04 is the
OPTIONS button, and the union of its focus record under the pos-as-top-left
reading — ring ptbtneff01 at (500,396) 42×46 plus label ptbtn04f at (535,395)
172×56 — is x 500..706, y 395..451. Those agree on all four edges to within a
few px of near-transparent sprite border.
Under the pos - pivot reading the same record predicts x 433..604, y 367..422,
which matches nothing in the capture — and no button matches that cluster. The
capture, not our renderer, is what settles it.
An instrument that failed its own control, and was therefore discarded
To locate the buttons independently I wrote a masked normalised-cross-correlation template matcher and ran it as PROTOCOL requires — through a control first: match each base sprite against the plain capture, where the declared position is known and the answer must be a (0,0) delta.
It returned deltas of (13,5), (6,−19), (12,21), (−16,22), (6,8) at NCC 0.096–0.206 — noise, with three of five pinned to the ±22 search boundary. The control fails, so the instrument is dead rather than tuneable, and none of its output is used above. The button art is dark, low-contrast and further crushed by the capture's γ ≈ 1.49 ramp, which is the likely cause; a matcher for this corpus would have to work on gradients rather than luminance. Filed so the next iteration does not rebuild the same broken tool.
What P5 still needs, and has not got
- Initial focus is not stable across boots (Q5: 2×
TUTORIAL, 2×NEW GAME). That is a value to author, with awhynaming Q5 — it is not written yet. - The ring's own animation is unread.
ptbtneff01's two keyframes gorotation_deg0 → 360 at t=120 with no second timed keyframe, i.e. a full turn. Whether it spins continuously while focused, or turns once and holds, is the group-loop question again — answered "groups hold" for build-in animations, but a 360° hold and a 0° hold are the same pose, so this group cannot be told apart by its rest pose. Not guessed; raised below.
P5 — navigation, 2026-08-29
The gate is "a human clicks through it", and the artifact is a walk that
proves the wiring rather than the intent: up (which wraps 01→05), five down,
Ⓐ into EXTRAS, down, Ⓑ back — landing on the main menu with focus restored
to EXTRAS.
xvfb-run -a godot --path port -- --menu \
--script=up,down,down,down,down,down,accept,down,cancel --shots=/tmp/p5
Ten PNGs, one per step, each taken after the screen it produced had settled.
Contact sheet handed over as share id 1788002507-ef4468a0a33a.
The scripted walk goes through the input system, not around it
--script posts InputEventAction through Input.parse_input_event and lets
it arrive at _unhandled_input exactly as a d-pad's press would. Calling
MenuFlow.move()/accept()/cancel() directly would have been shorter and
would have proved nothing: the thing most likely to be broken is the wiring
between a press and the cursor, and a direct call is precisely the part that
skips it. The same reasoning says the settle wait must be real — a shot taken
before the screen stops moving photographs a fade and calls it a menu.
What is authored here, and what is derived
Split deliberately, because P5 is where the two are easiest to blur:
| where | why | |
|---|---|---|
| the ORDER of the items | derived — each screen file's buttons, filled by the exporter from the button-role elements sorted by resting Y |
it is on the disc |
| where an item goes | authored — authored/flow.json |
HANDOFF Q4 measured the destinations; they are not in the file |
| which item opens focused | authored | Q5 measured that it is not stable boot to boot |
| what Ⓑ does | authored | Q5, measured — except on the main menu, see below |
| ⬅➡ do nothing | authored, written as an explicit no-op | so that "the game ignores it" and "we never wired it" are different lines of code |
Four of the five main-menu destinations are goto: null with a blocked note.
That is not an unknown: DIFFICULTY, the save-slot list, the lesson list and
the settings menu were all measured, and they live in archives this export does
not carry. blocked and none are kept apart so a later reader does not
"discover" a gap that was a milestone boundary.
EXTRAS is the only main-menu destination inside GP_TITLE, and therefore the
only Ⓐ-into-a-submenu this gate can actually walk.
The one navigation rule with nothing behind it
Ⓑ on the main menu → title. HANDOFF Q5 states it, and flow.json marks it
authored — likely but UNPROVEN, because the title also self-returns after
~8–10 s idle and a single unrecorded observation cannot separate the two. The
port implements it anyway — a menu with no way out is worse than a menu with a
plausible one — and says in the file that it did. Asked of the Decoder this
iteration; see BLOCKED.md.
Independent corroboration that the main menu is different from its submenu:
the main menu's footer advertises only Ⓐ : OK, while EXTRAS' footer
advertises Ⓑ : Back. That is on the disc, in ptmsg.png vs ptmsg2.png, and
it is visible in both the port's render and the captures.
A press during a fade is dropped
Authored, and not measured. Nobody has watched what the game does with a
button pressed mid-transition. Dropping invents less than queueing does: it
cannot manufacture a press the game might have discarded. flow.json says so
under navigation.input_during_transition.
--headless cannot draw, and the port hung instead of saying so, 2026-08-29
docs/port/PORT-MISSION.md and the loop prompt both name godot-headless as
how this project runs unattended. It does not work, and the way it failed was
the worst available shape.
Measured, not assumed. Under --headless Godot's dummy renderer never emits
RenderingServer.frame_post_draw. Every capture path in boot.gd awaits it —
--capture since P1, --film since P3, --shots as of this milestone — so all
three blocked forever. Isolated by the difference between two runs:
godot --headless --path port --quit # prints, exits 0
godot --headless --path port -- --screen=… --capture=… # no output at all, killed at 40 s
The second produces zero bytes of output before it is killed, because Godot's stdout is block-buffered and never flushes. So the observable behaviour of an unattended headless capture was: silence, forever. In a loop, a job that waits reads as a job still working — this is the failure mode that costs a whole iteration and leaves nothing behind to say what happened.
Two changes, and deliberately not one:
--capture,--filmand--shotsrefuse at startup under--headless, naming the flag and printing thexvfb-runline that does work. Refusing early rather than at the first frame means the run does not die halfway through a filmstrip with some frames written.--scriptno longer waits for a drawn frame when it is not going to photograph one. Navigation is checkable where nothing draws, and that is worth keeping:godot --headless --path port -- --menu --script=…now walks the menus and exits 0 in about four seconds, which is a cheap regression check that needs no X server at all.
The Xvfb path is unchanged and is what produced the P5 artifact.
Refutation — the focus ring IS drawn rotated, and it is not at 0° in either capture
Attempted against the Decoder's 7eeae30 ("re(ui): the focus ring SPINS, the
game draws it, and the leaf owns the f record"), point 2: that in the
OPTIONS-focused capture the ring's bright head sits in a different angular
position from the sprite's own, caught mid-spin. It survives, and the
evidence is stronger than what was claimed.
Chosen for refutation because it is exactly what PROTOCOL says to aim at: a claim the port is about to build on, resting on an estimator (a brightest-region centroid) whose own control the Decoder reported as ±19.8°.
The test, and why it needs no absolute registration
live-main-menu.png has ptbtn01 focused; live-main-menu-options-focused.png
has ptbtn04 focused. Both draw the same sprite, ptbtneff01.png — the
export confirms the two focus records name the same file. So the two captures
contain two instances of one 42×46 image, 240 px apart in design space, and the
question "is it drawn rotated" becomes "are these two crops the same image
at a different angle" — which needs no crop offset and no reference to our own
renderer.
Method: sample each ring into a 360-bin angular luminance profile over the annulus band (r = 9…15 px, bilinear, 0.5 px radial step) and circularly cross-correlate. A rotation about the centre shifts that vector and changes nothing else.
The instrument was run through two controls before it was believed
| control | result |
|---|---|
| rotate a capture's own ring by a known 0/30/90/150/210/270/330° and recover it | 0° error on all seven, peak corr 1.000 |
| the same estimator on a ring-free 60×64 patch of the same capture | peak corr 0.369 — it does not manufacture a match |
The measurement
On one shared centre for all three images, so a centroid difference cannot masquerade as a rotation:
| pair | best shift | peak corr | corr at 0° |
|---|---|---|---|
| capture A vs capture B | 134° | 0.968 | −0.064 |
| sprite (unrotated) vs capture A | 76° | 0.969 | −0.295 |
| sprite (unrotated) vs capture B | 210° | 0.948 | −0.181 |
210 − 76 = 134: the three measurements are internally consistent, which nothing in the method forced them to be. Sweeping the centre by ±2 px moves the A-vs-B answer over 117…161° while the peak correlation stays 0.9+ across the middle of that range, so the magnitude is ~134° ± ~15° and the precision claim stops there.
Evidence sheet — sprite, capture A, capture B, each cropped at the declared
42×46+500+156 / +500+396 — handed over as share id
1788002507-afe1ad843789. The phase difference is obvious by eye; the numbers
are here so it is not only obvious by eye.
The two things this settles for the port
-
The game draws
rotation_degon an element the English boot path shows. This is a second, independent confirmation on a different screen and a different element from theptloopsweeps, and it moves HANDOFF ask 4 (should the port draw rotation) off "changes nothing at rest" — it changes the main menu's focus marker, in every frame. -
0° is not a pose the running game shows.
screen_view.gdcurrently draws the ring at itsrestpose, which isrotation_deg 0, and both captures put it at 76° and 210°. So the port's focus marker is known to be wrong, not suspected — and the comment inscreen_view.gdnow says which two numbers it is wrong against.
Registration, as a by-product
The ring's annulus centroid lands at (32.94, 36.63) and (33.30, 38.90) in
windows whose design-space prediction under a zero crop offset is
(33.0, 37.0). Within ~0.4 px on the better-thresholded of the two. That
corroborates ORACLE-CAPTURES.md's "1279×675, top-left aligned" directly, on
a feature nobody chose for the purpose.
⚠️ Do not read the earlier P5-groundwork note "button text bands land at design y + 23" as a crop offset — it is an offset within the button sprite, and the two were nearly confused here.
What the port did NOT do about it
It did not start spinning the ring. The period is a guess with two unknowns and both belong to the Decoder:
- the keyframes are
t=120, rot 0then an untimedrot 360. Under HANDOFF Q1's replicated reading ("+36is the time the NEXT pose is reached") that is one revolution in 120 units = 2.0 s — but this port'spose_atimplements the other reading, and switching it is a change to every screen's animation timing, not a P5 change; - "groups hold" (settled 2026-08-28) predicts the ring stops at 360° = 0°. Both captures show it elsewhere. That is either a spin that loops, or two captures both taken inside the first two seconds of focus. The port cannot tell those apart, and a wrong answer here is a visible continuous rotation on whichever button the player is sitting on.
Filed in BLOCKED.md and asked over the message channel. What settles it is two
frames of one focused button a known time apart.