Files
Sylpheed/docs/DECISIONS.md
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

26 KiB
Raw Blame History

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:

  1. the exporter emits build_05.json and the runtime renames it from authored/screen_names.json;
  2. the exporter reads that map and writes main_menu.json directly.

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.t32ptbtn01f.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 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 cornersxi = col * sw / dw.
  • A GPU samples at the destination pixel's centrefloor((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.


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 0xffvisible
pteff02 title, title_jp alpha 0x00transparent

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 8b6dbcf5414db3

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 blocksptframe1/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.