Files
Sylpheed/docs/port/verify-screen-blend-divergence.md
MechaCat02 a23c321831 port: land the play-tested work, and only that
Takes the port branch up to 77320d5e -- the state the human play-tested on
2026-09-02 -- for SOURCE paths only. Not a branch merge: `auto/port-p6-audio`
is 366 commits and 938 files, and most of that must not land.

WHAT COMES IN (76 files, all human-confirmed working):
  * the logo splash animation. 08ed3dd1 found it: `pose_at` ASSIGNED the settle
    instant instead of clamping to it, so the splash never animated at all --
    and the same bug manufactured a passing harness result, because the harness
    photographed t past the settle. Confirmed by play-test: "cannot notice any
    obvious difference from the actual game."
  * gamepad input -- (A)/(B) bound additively (`ui_accept` ships with NO joypad
    binding), stick latched with hysteresis at the game's own 61% digitise
    threshold. This is what made (A), video-skip and Extras work at all.
  * menu navigation and flow, menu audio, the exporter, the authored
    declarations, and 23 verification tools under tools/port/.

WHAT IS DELIBERATELY LEFT ON THE BRANCH:
  * everything after c0ae460a -- the F5/F6 title-timing investigation, whose own
    tip commit calls itself a "hand-off for one-minute human checks". Unchecked
    by definition; it goes through the new review gate like anything else.
  * the OPTIONS menu work of 2026-09-03. Real, probably good, NOT play-tested.
  * the F1 repeat mechanism, which its own commit calls "deliberately inert".

WHAT MUST NOT LAND, AND WHY THE .gitignore CHANGED:
  545 MB of extracted game content was committed on that branch -- 850 sprite,
  audio and transcoded video files under `export-probe/` and `export-probe2/`,
  plus 246 MB of loose .wav and .tsv at the repo root. This repository's own
  rule, in this file, is "never game content".

  The rule was not missing. It was written, and it was tightened on that very
  branch, with a careful comment explaining why BOTH `export/` and `data/base/`
  had to be listed -- while the exporter was writing to a third name that
  nobody had thought to list. Enumerating names is the thing that failed. So
  the ignore rules now describe the SHAPE: any top-level `export*/`, game media
  by extension, and loose capture output at the root. Verified both ways -- it
  catches all four offenders and ignores nothing currently tracked.

Verified: `cargo check --workspace` clean; all nine GDScript files parse in
project context, with a positive control (an injected syntax error is detected,
3 lines) so the clean result means something. `tools/port/check-all` was NOT
run -- it needs the container, the export tree and a display.
2026-09-04 16:17:14 +02:00

19 KiB
Raw Permalink Blame History

verify-screen DIFFERS on six more screens — it is ADDITIVE, and the port is ahead of the reference

Status: RESOLVED. The reference gained an additive path (formats-pin-2026-09-01b) and the divergence this page is about collapses 6×. Everything below stands; the last section is the measurement that closes it. Port HEAD da7864e + this commit; HANDOFF at 9ca1eb5.

🔴 This page said, in its first version, that the cause was a blend-SPACE divergence — one renderer linearising and the other not. That was wrong, and the way it was wrong is the useful part. Both renderers demonstrably blend in the encoded space. The transfer curve I built to support it averaged Godot's value per reference value bucket, which collapsed a bimodal population — a large majority differing by ≤1 level and a minority differing by 40+ — into a smooth-looking curve that resembled gamma and was an artefact of the binning. A mean over a mixed population is not a transfer function.

The measurement that settles it

53 % of pixels agree to within 1 level and 69 % to within 3. The rest are not spread over a curve; they are concentrated in the middle of the frame:

|delta| 0 : 134 969   (cum 14.6 %)      > 3 : 282 166 px = 30.6 %
|delta| 1 : 355 261   (cum 53.2 %)      bbox: the whole frame, but
|delta| 2 : 103 348   (cum 64.4 %)      occupancy is ~zero in columns
|delta| 3 :  45 856   (cum 69.4 %)      0,1,6,7 of 8 and heavy in 2..5
|delta| 40+: 16 844   (cum 100 %)

The ≤1 tier is integer truncation against float rounding — the reference computes (sc*sa + dc*(255-sa)) / 255 in u32 and truncates; Godot rounds. The 30.6 % is something else, and it is in the columns where the menu's content sits.

What it is

The port draws some elements ADDITIVE. The reference has no additive path at all.

crates/sylpheed-formats/src/ui_layout.rs has exactly two blend sites, lines 1072 and 1174, and both are alpha-over:

canvas[di + k] = ((sc * sa + dc * (255 - sa)) / 255) as u8;

and line 1169 carries the reason — an "ADDITIVE selector and REFUTED — it moved every metric against the …". The reference tried additive, refuted it against its own composite metrics, and does not do it.

authored/rendering.json gives the port an additive set per screen, and its why records that this is transcribed, not authored: the port proposed additive from a two-background composite solve, kept it a proposal because nothing on the disc selects a blend mode, and adopted it only when the Decoder logged RB_BLENDCONTROL0 per draw in Canary and drove the game to both screens.

So the two renderers disagree on purpose: one implements a measurement of the game's blend register, the other implements a refutation made from composite metrics before that measurement existed.

The prediction this makes, and it holds

If the divergence is the additive set, its size should scale with how many elements are in that set. It does:

screen additive elements in authored/rendering.json mean diff
extras 9pteff10 pteff20 ptframe3 ptframe4 pteff21 pteff22 pteff23 ptloop01 ptloop02 6.7422
main_menu 5pteff12 ptframe1 ptframe2 ptloop01 ptloop02 3.9363
main_menu_jp 0 — not in the map 0.7885
extras_jp 0 — not in the map 0.6592
title 0 — present, deliberately empty 0.4431 (the known sweep residual)

Nine beats five beats zero, in order, with the two zero-rows an order of magnitude below the two non-zero ones. That ordering was not fitted; the additive map was written before this comparison existed.

And which side is right: the port, on the evidence there is

  • the additive set is a measurement off the running game — the blend control register, per draw — and the reference's alpha-over is a refutation from renderer metrics, which the protocol ranks below a capture;
  • scored against docs/re/captures/title-builds/live-main-menu.png, Godot is RMSE 3151.96 and the reference 3769.61 — the port is 16 % closer.

⚠️ That second line is an ordering and nothing more. verify-screen poses --pose=rest and its own header is emphatic that such a frame must never be scored against a capture — that mistake produced a published finding once already. Both sides carry the same pose contamination, so which is nearer is still meaningful; how near is not. Nobody should quote 0.0481 as the port's fidelity.

The blend SPACE, separately: both are encoded, and so is the game

Worth keeping even though it turned out not to be the cause, because it closes BLOCKED.md H4 and it was measured rather than recalled.

Godot, measured with a control — a white quad over black at three known modulate alphas, through the port's own texture path (load_png_from_bufferImageTexture), with the port's [rendering] settings:

declared alpha encoded-space prediction linear-then-re-encode prediction measured
64 64 138 64
128 128 188 128
192 192 225 192

Pre-registered before running; exact on all three, with the alternative excluded by 3374 levels.

The reference: integer arithmetic straight on 8-bit values, no linearisation anywhere in the file.

The game: the Decoder's blend-space-rt-format.txtRB_COLOR_INFO color_format is k_8_8_8_8 on 2402/2402 splash draws and 33779/33791 of the boot-to-title capture, k_8_8_8_8_GAMMA appears zero times, and color_exp_bias is 0 throughout. k_8_8_8_8_GAMMA is the only format around which Canary applies a gamma↔linear conversion.

All three agree. Blend space is not a difference between anybody here, and the port needs no change for it.

The other four rows — localised, and two hypotheses died getting there

check-all still fails on these four, and the allowance was not widened to cover them. What follows is where they are, not yet why.

🔴 Hypothesis 1, refuted: a blend-space divergence

Covered above. Killed by a control on my own renderer.

🔴 Hypothesis 2, refuted: the port renders rotation_deg and the reference does not

This one looked strong. ui_layout.rs:86 says so in its own words — "rotation_deg is decoded but NOT rendered" — and the port does render it. A census over all sixteen exported screens gave:

Every screen with a non-zero rotation at rest DIFFERS, and every screen with none agrees — 6 of 6 either way, including both legacy rows.

It is still wrong. Widening the rule to any rotating element — a non-zero rest rotation or a two-keyframe 360° spinner at any depth — breaks it: build_00 and build_01 carry two spinners each and agree with the reference. Asked directly, the port's own draw log says it draws both spinners on those screens. So rotation is present, rendered, and produces no difference.

A rule that holds on 14 of 16 and fails on the two cases nobody had looked at is the shape of a rule fitted to the rows it was built from. Recorded because the next person will find the ui_layout.rs:86 comment and reach for it too.

What is actually established: build_12 and build_15 are ONE element

All 951 differing pixels lie inside pgloading_loop5 (pgloading_ring.png, 333×276 at [1,444]), and the diff's own bounding box — x 69..301, y 478..710 — sits entirely within it. No other element's rectangle contains a differing pixel that this one does not.

And that closes the build_00 / build_01 question without any rule about rotation: the port's draw log shows those two screens draw 7 elements and build_12 draws 10. pgloading_loop5 is one of the three extra. The loading screens that agree are the ones that never draw the element the disagreement is in.

Why the two renderers disagree on that element is not settled. It is small — max 17, mean 0.0368 — and the element is unusual: its top-level keyframes hold a=0x7f for eight units around rest.t = 24, while its leaf record expands pgloading_ring from scale 0 to 1000 over t=30…130. The port's draw_leaf_for lists only ptloop01 and ptloop02, so the port draws the element and not that leaf. Whether the reference does the same is the open half.

🟡 main_menu_jp and extras_jp — consistent with the sweep leaves, not established

Both carry ptloop01/pteff03 (rot 30, at x=1521) and ptloop02/pteff03a (rot 45, at x=839) — the same sweep leaves whose phase residual is the already-named reason for title. Their means sit with title's and nowhere near their own EN twins':

mean
title (sweep residual, named since P1) 0.4431
extras_jp 0.6592
main_menu_jp 0.7885
main_menu (5 additive) 3.9363
extras (9 additive) 6.7422

That is consistent with the JP rows being the sweep residual alone, with no additive contribution — which is what the port's own map implies, since it lists no additive set for them. Consistent with, not established: nobody has isolated the sweep leaves on those two screens.

H6 update — the asymmetry has a better answer than a measurement

The Decoder's reply: the blend is a decoded disc field, T8aD +0x04 bit 0x02 (set ⇒ additive, clear ⇒ premultiplied alpha-over), with a disc-wide check and a surviving out-of-sample prediction. So the port can derive the blend per element on every screen instead of transcribing a table, and the JP question answers itself statically — no boot needed.

Blocked on one thing: sylpheed-formats does not expose +0x04. ui_layout::Element surfaces kind (+40), parent (+32), pivot, keyframes and focus_link, and nothing at +0x04; and crates/sylpheed-export consumes formats by git tag, not by workspace path. Asked as BLOCKED.md H6.

🔴 And a negative worth having, because it is the obvious thing to try: kind_raw in this export is NOT that field. Its bit 0x2 against the additive map over four screens is anti-correlated — 0 of 14 mapped elements have it set, and 9 unmapped ones do (0x3002 on every button, 0x0 on every element the map lists). Anyone reaching for kind_raw & 2 will get the additive set exactly inverted.

🔴 The asymmetry, until that lands

main_menu_jp contains exactly the elements main_menu marks additive — ptloop01 ptloop02 ptframe1 ptframe2 pteff10 pteff12 — and extras_jp contains all nine of extras'. The port draws them alpha-over on the JP screens and additive on the EN ones, purely because authored/rendering.json is keyed by screen name and the Decoder's RB_BLENDCONTROL0 log was taken on the EN screens.

The port is therefore asserting, by omission, that the JP build blends the same elements differently — and that is the less likely of the two possibilities. Extending the map would extrapolate a measurement onto a build nobody drove to, which is not the port's to do; leaving it silent asserts the surprising thing by default. So it is made explicit in authored/rendering.json and asked in BLOCKED.md, and the map is not extended.

⚠️ It does not affect the deliverable: MISSION §7 puts localisation beyond English out of scope, and the JP screens are not in the boot path.

Why check-all stays red

Four rows are not explained by the additive set:

screen mean over3 note
main_menu_jp 0.7885 3 248 not in the additive map, yet differs
extras_jp 0.6592 3 163 same
build_12 0.0368 462 tiny, and localised — diff bbox 464x266+60+454
build_15 0.0368 462 identical figures to build_12

build_12 and build_15 producing byte-identical statistics suggests one shared element rather than two coincidences. None of the four is diagnosed and none is excused.

What changed in check-all

The allowance is now derived rather than listed: a screen may differ if it has a non-empty additive set in authored/rendering.json, plus the two named legacy rows. That is strictly stronger than the hard-coded list — a screen that differs without additive elements now fails, which a literal list could not express, and the allowance cannot go stale against the map it is computed from.

What this does not claim

  • That the reference is wrong to have refuted additive from its own metrics. It is superseded by a capture, which is a different thing.
  • That the port's additive set is complete. It covers three screens; nobody has logged the register on the rest.
  • Anything about the four undiagnosed rows.

🔴 Re-validated on the GPU, and my prediction failed: these numbers are rasteriser-specific

The human activated a hardware GPU on 2026-09-01. Every number on this page above was measured under llvmpipe, so they needed re-deriving before anyone builds on them.

Pre-registered (R2)

Both renderers blend in encoded 8-bit space, so if the port's drawing is rasteriser-independent the diffs should be identical, or within 1 level. Anything materially different means a published conclusion here was GPU-specific.

It failed

screen llvmpipe mean GPU mean change max (llvmpipe → GPU)
title 0.4431 0.5936 +34 % 41 → 41
main_menu 3.9363 4.1449 +5.3 % 97 → 97
extras 6.7422 6.9757 +3.5 % 113 → 113
title_jp 2.7715 2.9448 +6.3 % 233 → 233
main_menu_jp 0.7885 1.0157 +29 % 26 → 27
extras_jp 0.6592 0.8906 +35 % 26 → 26
build_12 / build_15 0.0368 0.0454 +23 % 17 → 17

Every mean rose, by 335 %. So the diffs are not rasteriser-independent and the prediction was wrong.

What survives, and what does not

🔴 The maxima are unchanged — 41, 97, 113, 233, 17 identical, and 26 → 27 on one row. The large differences are exactly where they were.

That is the shape of a rounding population growing, not content moving: llvmpipe and the NVIDIA rasteriser round the last bit of a blend differently, so the ≤1-level tier grows while the elements that genuinely differ do not move at all. Consistent with both still blending in encoded space — which the control on this page established for Godot generally, not for one rasteriser.

What survives:

  • the additive diagnosis, because it rests on an ordering, and the ordering holds on the GPU: extras 6.98 > main_menu 4.14 > extras_jp 0.89 and main_menu_jp 1.02 > title 0.59 — nine additive elements, then five, then none;
  • the pgloading_loop5 localisation, which is a bounding box;
  • the build_00/build_01 agree result — still 0 pixels over the bar;
  • the derived allowance: the failing set is the same four rows.

What does not, and is now labelled:

  • the histogram (53 % within 1 level, 16 844 over 40) was llvmpipe-specific and the ≤1 tier is larger on the GPU;
  • every absolute mean quoted above this section;
  • the RMSE-vs-capture pair (3151.96 / 3769.61) was llvmpipe. The ordering claim — the port is nearer than the reference — has not been re-derived on the GPU and is not claimed here until it is.

The rule this earns

A renderer comparison carries its rasteriser as a hidden parameter. Nothing in this corpus recorded which one produced a diff, and for eight months there was only one so it never mattered. Any diff quoted from here on should say what drew it — the same discipline TEMPORAL-VERIFICATION.md already demands for capture rate, applied to the thing that rasterises rather than the thing that clocks.


Closed: the reference can draw additive now, and the divergence collapses 6×

The Decoder taught ui_layout::blit the additive blend at formats-pin-2026-09-01b, on the argument this page made — that the comparison was structurally incapable, and that the refutation which had kept additive out was ⟨render-vs-capture⟩, i.e. that renderer disagreeing with itself while it had a stale keyframe association, no leaf geometry and no rotation.

Measured without pulling their branch into mine: a detached worktree at the tag, sylpheed-cli built there, and verify-screen pointed at it through SYLPHEED_CLI. My branch is untouched and the reference on main is unchanged.

Pre-registered (R2)

With the reference finally able to draw additive, the diffs caused by that gap should collapse. main_menu/extras and their JP twins should fall sharply from ~7, and whatever remains is a different cause.

screen alpha-over reference additive reference factor
main_menu 7.2580, max 105 1.2068, max 28 6.0×
main_menu_jp 7.3440, max 108 1.2111, max 31 6.1×
extras 6.9757, max 113 1.0229, max 28 6.8×
extras_jp 7.0734, max 115 1.0255, max 30 6.9×
title 1.0335, max 88 0.5685, max 41 1.8×
title_jp 4.4944, max 233 2.8225, max 233 1.6×
build_12 / build_15 0.0772, max 60 0.0463, max 17 1.7×
build_00 / build_01 0.0676, max 60 0.0366, max 4 DIFFERS → OK

build_00 and build_01 stop differing entirelyover3 3 422 → 0.

And the twins agree to a third of a percent

main_menu 1.2068 vs main_menu_jp 1.2111 0.36 % apart
extras 1.0229 vs extras_jp 1.0255 0.25 % apart

Under the name-keyed map these pairs were 4.1× and 7.8× apart. Nothing was fitted to make that happen — the locale twins converged first when the port took the decoded field, and again now that the reference draws the same blend.

The residual is smaller and its causes are the documented ones

  • title_jp 2.82 at max 233 remains the largest, and its named reason — --pose=rest sparkle handling — is untouched by any of this.
  • title 0.57 at max 41 is the ptloop sweep-phase residual, also untouched.
  • main_menu/extras and twins now sit at ~1.01.2, max ~2831. New, small, and undiagnosed — additive was the dominant cause and not the only one.
  • build_12/build_15 do not return to their pre-change 0.0368: they land at 0.0463 with both renderers drawing pgloading_loop5 additive. So that element carries a small residual of its own beyond the blend.

🔴 What must happen to check-all, and why it has NOT happened yet

The allowance widened when the reference could not draw additive. That justification is gone, so the allowance should be narrowed back and the check should regain its teeth.

It is not narrowed in this commit, deliberately. check-all builds the reference from the workspace crates/sylpheed-formats, and the additive path is at a tag that has not landed on main. Tightening now would turn check-all red against a reference that still cannot draw additive — a wall of failures meaning one thing, which is the exact defect the display guard was added for.

The trigger is mechanical: when the additive path is on main, drop the export-derived clause and leave the two named legacy rows. The set that should then differ is measured above and is title, title_jp, main_menu, extras, main_menu_jp, extras_jp, build_12, build_15 — with build_00/build_01 expected to pass.