Files
Sylpheed/docs/re/ui-renderer-additive-path.md
sylph-decoder 728683d111 formats: teach the reference renderer the additive blend, and re-open 8 claims
The blend bit has been decoded against RB_BLENDCONTROL0 since 2026-08-31,
but ui_layout::blit could not draw it, and said so in a comment citing a
refutation that is <render-vs-capture> -- this renderer disagreeing with
itself while it had a stale keyframe association, no leaf geometry and no
rotation.

The consequence the port raised: verify-screen compares two renderers, so a
renderer that structurally cannot express a declared field makes the check
incapable on every screen that uses it -- 12 of 16 -- and the tolerance
silently excuses all of them. A quiet check is worse than a failing one.

Both equations come off the game's own pixel shader, which premultiplies
(oC0 = rgb*A, A), so only the blend register differs: 0x07010701 gives
rgb*A + dst*(1-A), 0x01010101 gives rgb*A + dst. Additive therefore
saturates rather than wrapping, and a transparent or black source is the
identity -- neither is a choice.

No plumbing needed: t8ad::parse already stores +0x04 as T8adImage::flags.

Four controls, pinned against arithmetic per the rotation precedent. The
fourth is the only one that can fail for the right reason: the first three
pass just as well if blit ignores the flag and draws everything additive,
so the discriminator flips only the blend on one sprite and requires two
different answers, each equal to its own equation. That is the same failure
class as the port's non-inverting latch check and my own backward scan that
resolved every guard to "internal".

120 passed, 0 failed on the full lib suite.

67 sprites over 14 screens were being drawn with the wrong blend, including
10 of 18 on the title and ptbtn00f, the PRESS (A) plate's highlight.

R1: tools/stale-instrument render-vs-capture lists 8 claims that died to
this instrument, including both legs of the rest() pair and "the plate-free
title capture may be too early to be settled", which sits on play-test
finding 3. None is re-derived here; this only records that the instrument
no longer exists in that form.

Also corroborates the port's H5: pgloading_loop5 is an ELEMENT resolving to
sprite pgloading_ring.t32, which is additive. I could not find loop5 as a
sprite in any pak and nearly reported a false contradiction from the
element/sprite name split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
2026-09-01 19:03:53 +00:00

6.6 KiB
Raw Blame History

The reference renderer can now express the blend the disc declares — and 8 claims re-open

Status: done, with controls. Instrument: ⟨our-renderer⟩, deliberately — this page is about a change to our instrument, not a claim about the game. 2026-09-01.


Why

T8aD +0x04 bit 0x02 has been decoded since 2026-08-31 — 35 elements over three screens against RB_BLENDCONTROL0 read out of the guest command stream, zero errors, plus an out-of-sample hit on GP_OPTIONS (structures/ui-blend-mode-decoded.md).

ui_layout::blit could not draw it. The code said so in a comment that had gone stale:

"Straight alpha-over. T8aD +0x04 bit 0x02 was tested as an ADDITIVE selector and REFUTED — it moved every metric against the title capture the wrong way … so the bit is carried but not acted on."

That refutation is ⟨render-vs-capture⟩: it was this renderer disagreeing with itself, recorded while the same renderer had a stale keyframe association, no leaf geometry and no rotation. The corpus's own rule — a claim resting on our renderer is a claim about our renderer — applies to it, and R1 is exactly the machinery for noticing.

The port raised the consequence: verify-screen compares two renderers, and if one of them structurally cannot express a field the disc declares, the check is incapable on every screen that uses it — 12 of 16 — and its tolerance silently excuses all of them. A quiet check is worse than a failing one.

What changed

One function. Both the rotated and unrotated paths now route their per-pixel combine through:

fn combine(additive: bool, sc: u32, sa: u32, dc: u32) -> u8 {
    if additive { (dc + sc * sa / 255).min(255) as u8 }
    else        { ((sc * sa + dc * (255 - sa)) / 255) as u8 }
}

Both equations are read off the game's own pixel shader, dumped from the running guest and disassembled in ui-splash-draw-pass.md. The shader premultiplies — oC0 = (rgb·A, A) — so only the blend register differs:

RB_BLENDCONTROL0 is gives
0x07010701 ONE / ONE_MINUS_SRC_ALPHA dst' = rgb·A + dst·(1 A) — source-over
0x01010101 ONE / ONE dst' = rgb·A + dst — additive

So the additive case saturates rather than wrapping, and a transparent or black source is the identity in it. Neither is a choice; both fall out of the equation.

The flag needs no plumbing: t8ad::parse already stores the +0x04 word as T8adImage::flags, so both call sites read img.flags & 0x02 != 0.

The controls

Following the precedent of rotation_control_known_angles — pin against answers that are arithmetic, not opinions.

test pins
additive_control_black_source_is_identity adding zero changes nothing
additive_control_alpha_zero_is_identity a transparent source is the identity
additive_control_known_sums 40+100=140, 200+100=255, 250+10=255 — it saturates
🔴 additive_control_bit_actually_selects the discriminator

⚠️ The fourth one is the only one that can fail for the right reason. The first three pass just as well if blit ignores the flag and draws everything additive. The discriminator takes one sprite, one pose, one canvas, flips only the blend, and requires two different answers, each equal to its own equation: alpha-over (100·128 + 80·127)/255 = 90, additive 80 + 100·128/255 = 130.

This is the failure class the port hit the same day — "removing the latch does not remove the threshold, so the row could never invert" — and one I hit in the pad decode, where a backward scan silently resolved every guard to "internal". A control that removes the mechanism but not the observable tests nothing.

120 passed; 0 failed on the full library suite, so no unrotated screen regresses.

What it changes, per screen

data/additive-elements-per-screen.txt67 sprites over 14 screens that our renderer was drawing with the wrong blend, including 10 of 18 on the title (pteff01, pteff03, pteff03a, the five ptlogo_back2eff*, ptlogoall_eff) and ptbtn00f, the PRESS Ⓐ plate's own highlight.

🔴 R1: what this re-opens

tools/stale-instrument render-vs-capture8 claims, and they are not peripheral. Three bear on the current focus:

  • "rest() for a plateau-less element should be the last keyframe"🟡, and its sibling, both legs of the pair the R1 pass re-opened in both directions;
  • "an element with no held pose should be drawn as NOTHING rather than at a guessed endpoint"🟡;
  • "the plate-free title capture (t ≈ 4.0 s) may be too early to be settled"🟡, which sits directly on play-test finding 3;
  • "the shifted time reading implies rest = the last keyframe"🟡.

Every one of them died to a renderer that drew ten of the title's eighteen sprites with the wrong blend. None is re-derived here — this page only records that the instrument that killed them no longer exists in that form.

Corroboration of another agent's claim, recorded per the adversarial duty

Target: the port's "H5 closes on your bit — pgloading_loop5 is additive."

I could not find loop5 as a sprite anywhere in any pak, which looked like a contradiction. It is not: pgloading_loop5.rat is an element, and it resolves to the sprite pgloading_ring.t32, which the bit marks additive.

GP_TITLE.pak     entry 12  element 'pgloading_loop5.rat'  sprite pgloading_ring.t32  additive=true
GP_TITLE.pak     entry 15  ditto
GP_SAVE_LOAD.pak entry 46  ditto
GP_SAVE_LOAD.pak entry 69  ditto

Their claim stands. Recorded because the element/sprite name split is a real trap — a census keyed by sprite name will not find an element by its own name, and I nearly reported a false contradiction from exactly that.

Reach, and what this is NOT

⟨our-renderer⟩. This makes our renderer able to express a decoded field; it does not make it right, and it is not evidence about the game. The evidence for the field is ui-blend-mode-decoded.md's GPU measurement, which predates this and does not depend on it.

⚠️ .prm primitives carry no T8aD header, so the bit cannot speak for them and this path never fires for one.

⚠️ Not re-run: verify-screen across the 16 screens. The numbers there will move, and the port's own generalisation applies to reading them — raw-rmse is area-weighted, so ask "broad or deep?" of any row that moves before calling it a regression.