`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.
14 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
Both renderers skip them. The bundle declares them and carries zero RATC
children for either, so there is no texture on the disc to export — this is a
property of the disc, not a gap in the exporter, and ScreenView reports it as
no sprite in the export rather than dropping it silently.