formats/cli: draw the .prm primitives, opt-in, because their paint order is unsolved

fill_quad composites an untextured primitive as a solid rectangle of the
keyframes fade colour, pivot x 2 in size, placed and scaled exactly as a sprite
is. Behind ComposeOptions::include_primitives and screen render --primitives.

On the title screen, whose paint order is ground truth, it is measurably right:
mean luminance 76.30 -> 63.72 against the captures 64.58, i.e. from +18% to
-1.3%, and mean absolute difference 16.07 -> 13.08. The background was ~40% too
bright; pteff02.prm, a 25% black dim, was what was missing. The wordmark is not
dimmed by it because the measured order paints that quad at slot 4, beneath the
logo. Edge correlation moves 0.9538 -> 0.9480, which is not informative here: a
uniform dim scales gradients uniformly so a normalised edge score barely sees it.

OFF BY DEFAULT, and that is the finding. A primitive has no T8aD header, so no
layer key, and derived_paint_order sorts the keyless to the end. GP_DIALOGs
pzeff00.prm is a single keyframe of opaque black at full screen; painted last it
wipes the build. Of the 125 builds that draw a visible primitive, 36 come out
>99% one colour with the derived order.

No constant default works, because the two screens read off the running game
disagree: the splash paints its primitive FIRST (the black backdrop) while the
title paints one at slot 4 and another LAST (the fade-out). Declaration order
fails the title too. A disc test measures the damage rather than asserting the
feature works, so the number moves when the ordering is solved.

Also records a false alarm worth keeping: a first pass reported 36 GP_DIALOG
builds at "100% black", which was a crude near-black pixel threshold and not a
black screen - those dialogs are dimmed 50% and perfectly legible. The genuinely
wiped builds are a different set.
This commit is contained in:
Sylpheed RE agent
2026-08-19 07:21:32 +00:00
parent 4baa981c8e
commit 451e328ba1
4 changed files with 243 additions and 32 deletions

View File

@@ -49,45 +49,81 @@ dim-behind-a-menu and flash layers. That matches the standing note on
`ComposeOptions::backdrop`, which describes the compositor's dim slate as a
stand-in for "the PRMD dim-quad" — this is that quad, and it is in the file.
## Refuted: you cannot just draw them at rest
## Drawing them (2026-08-19)
The obvious next step — treat a `.prm` as a quad and blit it at `rest()` — is
wrong, and would have been a visible disaster rather than a subtle one.
`fill_quad` composites a primitive as a solid rectangle of the keyframe's `fade`
colour, `pivot × 2` in size, placed and scaled exactly as a sprite is. It is
behind `ComposeOptions::include_primitives` / `screen render --primitives`, and
**off by default** — for a reason that is itself the result of this iteration.
The title screen's `pteff00.prm`:
On the title screen, whose paint order is ground truth, it is measurably right:
```
kf0 fade=0xff000000 t=12 opaque black
kf1 fade=0x00000000 t=64 transparent
kf2 fade=0x00000000 t=74 transparent
kf3 fade=0xff000000 t=None opaque black
```
| composite | mean luminance | vs capture | mean abs diff |
|---|---|---|---|
| primitives off | 76.30 | **+18 %** | 16.07 |
| primitives on | **63.72** | **1.3 %** | **13.08** |
That is a **transition**: the screen fades up out of black, sits clear, and fades
back down on the way out. What the title screen *shows* is the transparent
plateau, kf1kf2.
(capture mean 64.58). The composite was ~40 % too bright in the background
regions; `pteff02.prm`, a 25 % black dim, is what was missing. The wordmark is
*not* dimmed by it — the measured order paints that quad at slot 4, beneath the
logo — and the fraction of wordmark pixels above 200 moves 29.9 % → 28.2 %
against the capture's 26.8 %.
[The composite](../captures/ui-layout/title-composited-with-primitives.png).
`Element::rest()` picks the keyframe with the largest gap to the next keyframe's
time — 12→64 is the biggest gap, so it picks **kf0**, opaque black. And in the
measured paint order `pteff00.prm` is painted **last** on the title screen. Drawn
at `rest()`, it is a full-screen opaque black quad over everything.
Edge correlation goes 0.9538 → 0.9480, which sounds like a loss and is not
informative: a uniform dim scales gradients uniformly, so a normalised edge
correlation barely sees it. Brightness is the metric that discriminates here, and
it moves 20 percentage points toward the capture.
The rule's assumption is the problem: it treats a keyframe as a pose that is
*held* until the next one. For a fade it is the *start of a ramp*. The pose a
screen actually rests at is the **plateau** — a run of consecutive keyframes with
equal values — which for this group is kf1kf2, transparent.
## Refuted twice, and the second one is the blocker
This is pinned by `the_title_fade_quad_is_a_transition_and_rest_picks_the_wrong_end`,
which asserts the current (wrong) answer on purpose, so that fixing the resting
rule fails the test and leads whoever does it here.
**Refuted: you cannot draw them at `Element::rest()`** — as the old longest-dwell
rule computed it. The title's `pteff00.prm` runs opaque → transparent →
transparent → opaque, a screen transition whose resting pose is the transparent
plateau, and it paints **last**; the old rule picked the opaque endpoint, i.e.
the whole screen. That is fixed — [`ui-resting-pose.md`](ui-resting-pose.md).
**Refuted: the derived paint order does not place them.** This is what keeps the
flag off. A primitive has no `T8aD` header, so it has **no layer key**, and
`derived_paint_order` sorts the keyless to the very end. `GP_DIALOG`'s
`pzeff00.prm` is a *single* keyframe of opaque black at full screen; painted last
it wipes the build. Measured: of the 125 builds that draw a visible primitive,
**36 come out more than 99 % one colour** with the derived order.
And no simple default fixes it, because the two screens read off the running game
disagree with each other:
* the developer-logo splash paints `palogo_eff0.prm` **first** — it is the black
backdrop the logos sit on;
* the title paints `pteff02.prm` at slot **4**, beneath the wordmark, and
`pteff00.prm` **last**, as the fade-out.
So a primitive's position is real, per-element, and not derivable from anything
decoded so far. "Primitives first" would break the title's fade-out; "primitives
last" wipes 36 builds; "keep declaration position" was checked against the title
and fails there too — `pteff00.prm` is element 8, declared among the wordmarks,
and the game paints it 23rd.
A disc test measures the damage (36 / 125 / 0-by-default) rather than asserting
the feature works, so the number stays honest and changes when the ordering is
solved.
Worth seeing anyway: the DIFFICULTY dialog
([capture](../captures/ui-layout/dialog-difficulty-with-primitives.png)) renders
legibly with its 50 % dim — an early false alarm said 36 `GP_DIALOG` builds went
"100 % black", which was a crude near-black pixel threshold, not a black screen.
The genuinely wiped ones are a different set, wiped by an *opaque* quad.
## What is not settled
***The resting rule.** "Longest plateau of equal consecutive keyframes"
is the candidate, and it is *not* `.prm`-specific — it would change `rest()`
for every element on the disc, including the ones checked against the title
framebuffer capture. It has to be A/B'd against those before it can land. That
is the next step and the reason `.prm` compositing is not in this change.
## What is not settled
***Where a primitive paints.** The blocker, described above. It has no layer
key and the two measured screens rule out every constant default. The cheapest
next step is a third measured order from a screen that carries a primitive —
the `GP_DIALOG` DIFFICULTY box is reachable from the main menu and has exactly
one, so a runtime child-list read there would say whether its dim is first or
last.
***Blend mode.** A dim quad at `0x7f000000` is presumably straight alpha over
what is beneath, but the flash (`0xf0ffffff`) and the coloured ones
(`0x60ff0000`) may well be additive. Nothing measured.