Files
Sylpheed/docs/re/structures/ui-forced-backdrop.md
sylph-decoder 215943e873 re: reconcile the two ink counts -- we agree at >1 and not at >0
The port agent produced a genuine second witness for the pixel-cost claim: it
re-checked GP_TITLE entry 12 in Godot, which shares no code with compose,
swapping only paint_order. 59 530 px ink with the rule, exactly 0 without it.
The strong form -- the screen ceasing to exist, not merely changing a lot --
now has two real renderers behind it on that entry.

Its figures did not match ours, so I counted the same composite every way:

    RGB > 0    ours 49 771   Godot 59 530    16 %  apart
    RGB > 1    ours 48 043   Godot 48 368    0.68 % apart

The entire disagreement lives in pixels whose value is exactly 1. That is a
1-LSB sampling artefact between two samplers, not a different set of inked
pixels. So '>0' is not a portable ink convention between renderers on a
mostly-dark frame and '>1' is; any future cross-renderer ink figure should say
which it used.

Also worth recording: our 49 771 was never a threshold figure. It is exact RGBA
inequality between the two paint orders, which over a black backdrop coincides
with ink>0 -- so it belongs against the port's 59 530, not its 48 368. Matching
it to the 48 368 would have made the two renderers look like they agreed for
the wrong reason.

The without-the-rule column is 0 at every threshold here too, matching Godot:
the strong form is not threshold-sensitive in either renderer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 07:07:21 +00:00

21 KiB
Raw Blame History

Where a keyless primitive paints, when the file forces it

Classification: decoded. Derived from the keyframes and the element's own geometry, checked disc-wide, and validated against both primitives whose position was measured in the running game — one it must reproduce, one it must not disturb.

The question

A .prm / .tbm primitive carries no layer key: the key is read from a sprite's header, and a primitive has no sprite. ui-prm-primitives.md established that the key is not in the bundle either — the declaration entry's unread words are constant, and the bundle has no RATC child for a primitive — so implied_layer_key is a measured per-name table, and anything not in it keeps u32::MAX and sorts last.

" Where an unmeasured primitive paints" has been that page's standing blocker.

The port hit it: build_12 / build_15 composited to solid black at every instant of their declared life, because pgloading_eff00.prm — a full-screen opaque quad — sorted on top.

The rule

An element that covers the screen and is fully opaque at some instant cannot paint above anything visible at that instant. Where the elements visible during its opaque span are all of them, its position is forced to first.

This is a constraint read off the file, not a preference, and it is not an analogy to a neighbouring screen.

pgloading_eff00.prm is opaque for 39 instants, and all 9 other elements on the loading screen are visible inside that span. Forced first, 4/4 instances.

The controls — the rule has to survive both, and it does

primitive measured in the game opaque instants forced below rule says
palogo_eff0.prm (entry 11, developer) paints FIRST 211 6 of 6 forced first
palogo_eff0.prm (entry 10/13, publisher) paints FIRST 256 2 of 2 forced first
pteff00.prm (title) paints LAST 2 3 of 23 permitted on top
pteff00.prm (menu) paints LAST 2 7 of 15 permitted on top

🔴 The first row is the one that matters. palogo_eff0.prm is named like an overlay, and a rule keyed on the name would sort it last — against a measured order. Occlusion gets it right. pteff00.prm is opaque only for two instants, at its screen's entry and exit, so the constraint never binds it: it is the fade cover, and it belongs on top.

The span, and what it costs to get wrong

The rule quantifies over "every instant the primitive is opaque" and "every element visible then", so it depends on where a screen's timeline ends and on what an element does after its own last keyframe. The port asked, having got 256 opaque instants for palogo_eff0.prm where this page said 211.

⚠️ That pair was a bundle mismatch, not a definitional onepalogo_eff0.prm appears on both splashes, and the publisher (entries 10, 13) runs to t=255 while the developer (11, 14) runs to t=210. 256 and 211 are both right, for their own screen. The definitions already agreed.

The definition: the span is 0 ..= max keyframe time over every element in the build, and an element holds its final pose past its own last keyframe — which is what pose_at does, and it is decoded rather than assumed: a group holds at its last keyframe rather than looping (ui-keyframe-time-unit.md), and ui-record-loop-length.md shows the declared length never falls short of the last keyframe, the slack being exactly that hold.

How much rests on it — 130 keyless full-screen primitives with an opaque interval, and how many verdicts change:

alternative convention verdicts changed
span = the bundle header's declared +0x08 0
span = the primitive's own last keyframe 72
elements counted gone after their last keyframe (no hold) 72

🔴 The hold decides 55 % of the verdicts, and dropping it is refuted by a measured order. palogo_eff0.prm is a single keyframe at t=0. Without the hold it would be opaque for one instant, no other element would be up yet, and the rule would call it free — against the order measured in the running game, which paints it first. Pinned by the_hold_after_a_final_keyframe_is_required_by_a_measured_order.

And the header length is interchangeable with the elements' maximum: zero disagreements disc-wide. Either may be used.

The verdicts that matter are convention-independent. pgloading_eff00.prm comes out first under all four conventions; pteff00.prm comes out free under all four. Only palogo_eff0.prm moves, and only under the convention its own measured order rules out.

Disc-wide

Keyless full-screen primitives with an opaque interval:

count
position forced first 80
constrained below some, not all 50
occluding nothing 0

The split falls almost exactly along the names — every *base* is forced, every *eff00* is not — with three families crossing it: palogo_eff0.prm, pgloading_eff00.prm and pzeff00.prm are named like overlays and are forced first. That is precisely why the name is not the rule.

And it explains a symptom the corpus had recorded without a cause. ui-prm-primitives.md notes 36 builds that "come out one colour ... wiped by pzeff00.prm and pceff00.prm, whose positions have never been measured". pzeff00.prm is forced first in 32 of 32 instances. Those builds were wiped by our own sort, not by the game.

🔴 The rule's real limit, found by its own test

An earlier version applied to any element. Its disc-wide test asserted that no keyed element is ever forced — and that assertion failed, on 22 of them: pneff01.t32 (key 0xd850, paints #8 of 13) and pbfriendly.t32 (key 0x9230, #17 of 49).

Both are .t32 sprites, and that is the flaw: a sprite's element alpha being 255 says nothing about whether its texture covers the screen. Most of it may be transparent. The disagreements were the rule overreaching, not the keys being wrong.

forced_backdrop is now restricted to elements with no sprite — untextured primitives, which are solid quads and do occlude what they cover. That is also the only case derived_paint_order consults it for.

🔴 Self-refutation: the argument is sound for only 42 of the 80

A disc-wide census of the colour these elements carry breaks the rule's premise for nearly half its verdicts.

the 80 forced-first instances count fade ARGB
.prm — untextured solid quads 42 pure black (ff000000, 7f000000, 40000000, …)
.tbm 38 ffffffff — white at full alpha

A solid white quad at alpha 255 painted first would make the screen white. No screen is white. So a .tbm is not a solid quad: ffffffff is a white modulation on a texture, which is exactly what a background bitmap carries.

🔴 And that means element alpha does not establish coverage for them — the same error the .t32 guard already caught, one file extension further out. I fixed that symptom (el.sprite.is_some()) rather than its cause: an element's alpha is not its texture's opacity, and only an untextured primitive makes the two the same thing.

What this does and does not change:

  • The 42 .prm verdicts stand as decoded. For a solid colour quad the fade is the pixel, so opacity and coverage are the same fact.
  • One of the 13 .tbm names is measured, not inferred. pfbase.tbm is element 0 of the save/load frame, and the order read off the running game (the_save_load_screens_match_what_the_running_game_paints) starts [0, 1, 2, …] — the game paints it first. The other twelve names rest on the rule.
  • 🟡 The 38 .tbm verdicts are not decoded. They are almost certainly still right — every one is named *base*, is full-screen, and one of them (pfbase.tbm) has its first position measured in the running game — but that is a name-and-role argument, which this page elsewhere argues is the weaker kind.
  • ⚠️ The code is deliberately unchanged. Restricting forced_backdrop to .prm would send pcbase, pnbase, pqbase, pubase, pvbase, pjbgbase2, po_menu_base and the four px_*_base back to u32::MAX — last — which is the blank-screen bug this rule was written to fix. Downgrading their status is honest; reverting their position would be wrong.

Where a .tbm's pixels live — looked, not found

If a .tbm's texture could be decoded, its alpha coverage would settle the 38 directly. It cannot be located:

  • not in its bundle — no RATC record and no sprite-table entry, for any of the 13 names;
  • not a file — there is no .tbm anywhere on the disc;
  • not a pak entry — its own archive's hashed TOC contains none of pfbase.tbm, its uppercase form, its stem, .t32/.tga/.xpr variants, or ui\/tex\ prefixes, across four archives;
  • not visible in our compositecompose skips an element with no resolvable sprite, so our renderer draws no pixels at all for a .tbm, and no committed capture covers a screen that has one (GP_SAVE_LOAD, GP_BUNK, GP_DEBRIEFING_PILOTLOG), so nothing says whether the game draws any either.

⚠️ So a second reading survives and is not excluded: that a .tbm element contributes no pixels, in which case its paint position is inert rather than wrong. That would leave the 38 verdicts harmless instead of correct — a different claim with the same consequence. Distinguishing them needs a capture of a screen that carries one, which is behind the Ⓐ fault (ui-clock-freezes-at-settle.md).

🔴 Stability is not necessity — 62 of the 80 are DECIDED by this rule (2026-08-30)

Raised by the port agent, and it is right. Every check this page has run — and every re-check after a change elsewhere — measured whether a verdict moved. That is the rule's stability. It never measured its necessity: an element whose position is already fixed by a read T8aD key or an implied_layer_key is confirmed by the rule, not decided by it, and on those screens removing the rule entirely costs nothing.

So the question was asked directly. derived_paint_order was recomputed with the forced_backdrop fallback removed and the two orders compared, over every dat/*.pak:

instances
the rule DECIDES the order — it moves without it 62
the rule merely AGREES — order unchanged 18
total forced 80

The 80 reproduces this page's own census exactly, which is the check that the probe is looking at the same set. Data: ../data/forced-backdrop-necessity.txt · instrument: crates/sylpheed-formats/examples/forced_backdrop_necessity.rs.

Every one of the 62 deciders is keyless — no keyed element is ever moved. That is the rule behaving as designed: it is a fallback, and it only ever fires where nothing else can speak.

The 18 that merely agree split two ways, and the distinction matters:

  • 14 have a key of their ownpfbase.tbm (10) and palogo_eff0.prm (4). Here the sort already had the answer and the rule reproduces it. ⚠️ This is the part that is not independent support. palogo_eff0.prm being forced first agrees with its measured order, but its implied_layer_key is 0x00000000 and would have put it first anyway — so that agreement is the rule reproducing the crate, not the game confirming the rule.
  • 4 are keyless but inertpo_menu_base.tbm, twice in each of two 2-element builds. Every element on those screens is forced, so all keys collapse to the same value and the declaration-index tie-break gives the identical order either way.

What actually holds the 62 up

Not a key — there is none — and not the palogo_eff0.prm control, which as above is only decisive under a convention its own key already satisfies. It is the impossibility argument alone: a full-screen quad that is opaque at some instant cannot paint above everything visible then, or the screen is blank at that instant. That argument is doing all the work on 62 instances, and its assumptions are exactly the ones listed under Reach below — which is now a larger exposure than this page previously implied.

By element, the 62:

count kind
pzeff00.prm 32 .prm, colour census says pure black
pgloading_eff00.prm 4 .prm — 2 in GP_TITLE (entries 12/15), 2 in GP_SAVE_LOAD
esrb_base.prm 2 .prm
ten *base*.tbm families 24 .tbm, pixels never located

⚠️ 24 of the 62 are .tbm, and this page already records that a .tbm's pixels cannot be found anywhere on the disc. For those the two readings — "the rule places it correctly" and "the element draws nothing, so its position is inert" — remain indistinguishable, and both leave the composite right. The 38 .prm deciders have no such escape: those are real quads with real colour, and the rule is load-bearing on them in the full sense.

For the port specifically

On the five menu screens the exposure is two: GP_TITLE entries 12 and 15, the dressed loading bundles, where pgloading_eff00.prm has neither a read nor an implied key. Entries 10, 11, 13 and 14 — the four splashes — are unchanged with the rule removed.

The cost in pixels: 38 of the 62 turn the screen black without it

"The order moves" is a property of the sort. The tie-break work already found overlapping reorders costing zero pixels, so it does not follow that the picture moves. Each of the 62 deciding builds was therefore rendered twice — once in derived_paint_order, once with the fallback removed — and diffed (../data/forced-backdrop-pixel-cost.txt, instrument examples/forced_backdrop_pixel_cost.rs).

The split is perfect, and it falls exactly along the element kind:

builds changed pixels
.prm deciders 38 0.69 % … 94.41 % of the frame
.tbm deciders 24 0 — but see below

On all 38 .prm builds, changed_px equals the composite's total ink exactly. Not approximately — identically, 38 times out of 38. Without the rule the primitive sorts last, paints over everything, and the screen composites to pure black. That is the port's original contradiction argument, and it is now measured on 38 builds across seven archives rather than argued on two.

For the port's two: GP_TITLE entries 12 and 15 each move 49 771 px = 5.40 % of the frame, which is their entire ink.

Second witness (2026-08-30): the strong form holds in Godot too

The port agent re-checked the 38-.prm result in Godot, which shares no code with compose, applying this crate's fallback to its own element list and swapping only paint_order on one screen file — renderer, textures and pose all held fixed, so the order is the single variable. GP_TITLE entry 12 came out at 59 530 px ink with the rule and exactly 0 without it, at both of its thresholds.

That is the claim that needed a second renderer, because it is the strong form: not "a large difference" but the screen ceasing to exist. Two renderers, and this time genuinely two — unlike the necessity census, where the port's re-run was this crate's code executed twice.

⚠️ …and the two ink figures were never counting the same pixels

The port reported 59 530 (>0) and 48 368 (>1); this page said 49 771. Counting the same composite every way (../data/forced-backdrop-ink-thresholds.txt):

threshold this crate Godot gap
RGB > 0 49 771 59 530 16 %
RGB > 1 48 043 48 368 0.68 %

The whole disagreement lives in pixels whose value is exactly 1. Above that the two renderers agree to 325 px on a 921 600-px frame. So it is a 1-LSB sampling artefact — a different sampler putting a faint non-zero where this one puts exact zero — and not a different set of inked pixels.

Two things to carry from that:

  • > 0 is not a portable ink convention between renderers on a mostly-dark frame; > 1 is. Any future cross-renderer ink comparison should say which.
  • The 49 771 in this page was never a threshold figure at all. It is exact RGBA inequality between the two paint orders, which over a black backdrop coincides with ink > 0 — so it belongs against the port's 59 530, not its 48 368. Reading it as the >1 number would have made the two renderers look like they agreed for the wrong reason.

The without-the-rule column is 0 at every threshold here as well, matching Godot. The strong form is not threshold-sensitive in either renderer.

🔴 The zero on the 24 .tbm builds is my instrument, not a finding

The control I wrote was the wrong control and it passed anyway. It asked whether the composite had ink — it always does, the rest of the screen draws — when the question is whether the element being reordered has ink. This page already records that compose draws no pixels at all for a .tbm, because it has no resolvable sprite. So a .tbm's paint position cannot change a pixel in our renderer by construction, and those 24 zeros measure that and nothing else.

tie_break_pixel_cost.rs got this right and has the per-element ink_mask this one needed. Reported rather than quietly patched, because the shape — a control that cannot fail — is the one this corpus keeps paying for.

⚠️ So the .tbm half of the necessity result stands where it stood: "correct or inert", indistinguishable, and no closer to being distinguished than before. The 38 .prm are the part this measurement moves.

🔴 "Two renderers, same answer" was true of six instances, not eighty

That sentence stood here and it overstated the evidence. The port agent caught it and it is worth stating precisely, because the failure it guards against is the one that started this whole threadverify-screen scoring two blank frames OK.

witnesses
the six GP_TITLE instances two, genuinely independent — the port removed its own post-pass in its exporter and diffed its export; different code, different language, different layer. My crate-side run agrees
the other 74 one measurement, executed twice. The port re-ran this crate's probe. A fault in the instrument reproduces identically for both of us

So the disc-wide 62 is not independently confirmed and this page will not claim it is. What the port's re-run does establish is that the probe is deterministic and that I transcribed its output correctly — worth having, and much less than agreement.

⚠️ And the instrument had a real trap. forced_backdrop_necessity.rs defaulted to GP_TITLE when given no argument, so a bare run printed 6 instances in the same format as 80. The port hit it and nearly filed the discrepancy back at me. It now walks every dat/*.pak by default and reports the archive count on stderr. "I ran your instrument" has to mean the same thing to both of us.

Reach

⚠️ Assumes straight alpha-over blending. Blend mode is on ui-prm-primitives.md: an additive quad at alpha 255 would not occlude, and the rule would then be placing it wrongly. The palogo_eff0.prm control is evidence the assumption holds at least there.

The colour census narrows this a long way. Every full-screen *eff00* primitive on the disc is pure black at its various alphas — ff000000, 7f000000, 40000000, b2000000, cc000000, d4000000, 00000000. Black at alpha a over content is exactly what an alpha-over dim or fade looks like, and an additive black quad would be a no-op, so a designer would not author one. The single non-black primitive on the disc is pbafc.prm, RGB 00e8e0 (cyan) at alphas up to ff — and it is 844×600, not full-screen, so it is outside this rule's geometry guard entirely. Whether it is additive is still open, and it is now the only candidate.

Coverage is tested per instant, against the SCALED size, and two-sidedly. An earlier version rejected on the declared size (pivot doubled), which the port pointed out replaces one error with its mirror: an element scaled above 100 % could cover the screen from a smaller declared size. Checked across 921 keyless elements: 0 do, so the mirror case does not occur on this disc — the per-instant test is in because it does not need that to stay true. Verdicts before and after: 80, split 42 .prm / 38 .tbm, unchanged.

⚠️ It gives a lower bound, not an ordering. It settles the 80 instances where occlusion forces the position, and says nothing about the 50 where the primitive is opaque only part of the time — including pteff00.prm, whose place on top is still a measured per-name entry, not a decoded one.

⚠️ No new oracle measurement. The two controls are orders measured previously; nothing here was captured from a running game. A draw capture of a loading screen would confirm it directly, and the loading screens are not reachable from the title path.

Reproducing

cargo run -p sylpheed-formats --example prm_forced_first
cargo run -p sylpheed-formats --example prm_occlusion_check
SYLPHEED_DISC=/disc cargo test -p sylpheed-formats --test ui_forced_backdrop_disc