This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Syplheed-Reborn/docs/re/structures/ui-paint-order-key.md
Sylpheed RE agent 5d02d5cd60 re: additive blending refuted, the swoosh is not displaced, and the
residual is smaller than I said

Testing the candidate I raised last iteration rather than carrying it.
Blending bit-0x02 sprites additively moves every measure the wrong way --
whole-frame mean diff +0.55 to +1.04, swoosh-band mean +1.83 to +3.98,
band edge-correlation 0.6971 down to 0.5578. So the bit is real and
independent but does not select an additive blend. I reverted the
experiment and kept the word as T8adImage::flags, documented and not
acted on; the render is byte-identical to before.

Second refutation: the swoosh is not displaced. Shifting the band over
plus or minus 80 by 8 pixels peaks sharply at zero, 0.7342, falling to
0.22 at 24 px. So the pivot story is dead twice over -- inert at scale
100, and no displacement to explain anyway.

And I have restated the residual, because earlier sections overstated it.
The +16 to +34 band tiles I quoted were measured WITHOUT --primitives.
With the dim drawn the band's average is nearly right at +1.83; what is
wrong is its structure, tiles running -38.6 then +33.8 and cancelling.
Six candidates eliminated now and none confirmed.

One caveat I owe the port agent about the capture I gave them: it is at
t=4.0s, roughly 174 keyframe units into a screen whose elements have
keyframes out to t=600. I judged "settled" from mean luminance, which
cannot see a thin sprite still moving. It is settled for the bulk of the
screen and not proven settled for every element -- which is a live
alternative explanation for a structural difference in exactly the band
the sweeps cross.

METHOD: cargo build passing does not mean cargo test compiles. Adding the
field built the library in 1.48s and broke two test-only struct literals;
cargo test failed with exit 101.
2026-08-28 22:00:55 +00:00

23 KiB
Raw Blame History

The paint order comes from a layer key in the T8aD sprite header

Status: CONFIRMED on both screens whose paint order has been measured — the key is non-decreasing in paint order on every element that has a sprite, 20 of 24 on one and 6 of 7 on the other. 🟡 ties are not explained. the field's full meaning (it looks like flags, not a plain depth).

The field

Every T8aD sprite begins with a 44-byte header. The word at +0x08 — untouched by this project's decoder, which reads width/height/tile-count at +0x14/+0x18/+0x1c — sorts the screen.

Read in the order the game paints them (ui-screen-runtime.md has how that order was measured — off the live child list, checked against a draw capture):

GP_TITLE build 4, the title screen

paint slot element sprite +0x08
0 9 ptbase2 0x8000
3 10 pteff04 0x8020
5 6 pteff01 0x8040
6 20 ptlogo_back2eff 0x8081
7 19 ptlogo_back2 0x8082
812 14,15,18,16,17 back2eff1…5 0x8083 ×5
1319 0,2,4,7,1,3,5 ptlogo1/tm/ptlogo2 0x80a0 ×7
20 22 ptlogoall_eff 0x80a8
21 23 ptlogoall_eff2 0x80a9
22 21 ptcopyright 0x8100

GP_TITLE entries 11/14, the developer-logo splash

paint slot sprite +0x08
13 the three _eff glows 0x a100 ×3
46 the three base logos 0x a110 ×3

Both are ascending, with no inversion anywhere. On the splash it explains the whole permutation — the glows sort before their logos because 0xa100 < 0xa110, which is why the declaration table's interleaving (base, glow, base, glow) is not what you see.

Why this matters

It is the first file-derivable account of the paint order. Everything checked before it failed: declaration order and its reverse, the placement region, the RATC child order, keyframe start and rest times, resting Y, the runtime element record's fields, and every other build's table. This is a per-sprite value the port can read directly.

What is not settled

  • Ties. Two groups share a key (0x8083 ×5 and 0x80a0 ×7) and the game paints them in an order that is not the declaration order — 14,15,18,16,17 and 0,2,4,7,1,3,5. Something breaks those ties and it is not known. For the port it may not matter (tied elements are same-layer, and the three ptlogo1/ptlogo2 instances are the ghosts that are not drawn at rest), but it is unmeasured, not proven harmless.
  • The field's meaning. 0x8000, 0x8020, 0x8040, 0x80810x8100 on one screen and 0xa100/0xa110 on another look like flag words with a layer in some of the bits rather than a plain integer depth. Sorting on the whole word works on both screens; which bits actually carry the layer is unknown.
  • Two screens is two screens. A third measured permutation would either promote this to a rule or break it. The cheapest one available is any screen whose object is resident at the same time as the title's.

Landed in the compositor (2026-08-19)

ui_layout::compose now paints in the derived order — a stable sort of the elements by sprite_layer_key — for every build except the two whose measured order is hard-coded, which stay as the ground truth they are. Elements with no sprite (the .prm primitives) have no key; they keep their declaration position among themselves and compose skips them anyway.

Verified three ways rather than by a green build:

  • a disc-gated test asserts the measured orders are non-decreasing in the key and that the composite's key sequence comes out sorted — and it was checked both ways: reading the word from +0x0c instead of +0x08 makes it fail;
  • the title still composites identically (its measured order is used);
  • a screen nobody has captured — GP_MISSION_SELECT — now composites cleanly (capture).

Found on the way, and worth its own line: the developer-logo splash bundle has no .rat child, so ui_layout::is_build rejects it and the default screen render never sees it.

⚠️ Corrected 2026-08-28: the sentence that used to end this paragraph — "the splash cannot be rendered by screen render at all" — is wrong. It can: screen render --all widens the enumeration past is_build and the splash composites fine, both halves. ../ui-title-build-map.md has the renders. What is true is narrower: it is invisible to the default listing, so anyone who does not pass --all will conclude it is missing.

What the change did to the screens that were already verified (2026-08-19)

The derived order is applied to every build on the disc on the strength of two measured screens, so the first thing owed to it is a check of what it did to the screens the corpus had already validated against the running game. Two exist: the tutorial PAUSE menu and the title main menu (../captures/ui-layout/pause-tutorial-real-vs-rebuilt.png).

Rendered both ways — compose as committed, then with compose temporarily reverted to declaration order — and diffed:

screen pixels differing RMSE max per-channel delta
tutorial PAUSE 35 162 / 921 600 (3.8 %) 0.52 % 45 / 255
title main menu 9 911 / 921 600 (1.1 %) 0.36 % 34 / 255

No layout regression. Side by side the two renders are indistinguishable: every panel, label and glyph is in the same place at the same size. What moved is confined to pixels where translucent sprites overlap — the glows around PAUSE, the OBJECTIVE / DEFEAT CONDITION / HINT header bars, the menu underlines — i.e. the order the blends compose in, which is exactly what a paint-order change is supposed to touch and nothing else.

🟡 Which of the two is more faithful on these two screens is NOT settled. A difference of ≤45/255 on a few per cent of pixels is not decidable against the committed side-by-side oracle, and there is no fresh framebuffer capture of either screen to diff at that magnitude. The derived order is kept because it is the rule measured off the game on the two screens where the order is known, not because it was shown to be better here. If a capture of the PAUSE menu is ever taken, this is the first thing to check it against.

Corpus-wide, and not a no-op

A disc-gated test (every_composite_paints_in_layer_key_order) composes every build on the disc and asserts the draw list is strictly increasing in (layer key, declaration index). It also counts how far the rule reaches:

341 of 965 builds (35 %) are reordered by it.

That matters for how much credit the rule gets. Had it been a near-no-op, the two measured screens would be the entire evidence base; instead a third of the disc's screens now composite in an order no capture has checked. The test asserts the share stays above a quarter, so a future change that quietly collapses the rule back to declaration order fails here instead of passing silently.

A third measured permutation — the main menu (2026-08-19)

Read off the running game with tools/re-capture/screen_children.py, and identified in the file by its pivot signature: GP_TITLE.pak ratc-index 8, the NEW GAME / LOAD GAME / TUTORIAL / OPTIONS / EXTRAS screen, 16 elements.

paint order (child slots): 1 3 4 2 5 8 9 6 7 15 10 11 12 13 14 0
slot element slot element
0 1 ptbase.t32 8 7 ptframe2
1 3 ptloop01 9 15 ptmsg
2 4 ptloop02 1014 1014 ptbtn01ptbtn05
3 2 pteff05 15 0 pteff00.prm
4 5 pteff02.prm
57 8 pteff10, 9 pteff12, 6 ptframe1

This is the first measured screen with TWO primitives, and they land in different places — which is the point. pteff02.prm (the 25 % black dim) paints 4th, beneath the whole UI; pteff00.prm (the screen-transition fade, resting transparent) paints last. Both match their positions on the title screen exactly, where pteff02.prm is also slot 4 and pteff00.prm is also last.

So a primitive's place is per-element and stable by role across screens:

primitive role measured position
palogo_eff0.prm opaque black backdrop first (splash)
pteff02.prm 25 % dim under the UI slot 4 (title and menu)
pteff00.prm screen-transition fade last (title and menu)

It is still not derived — a primitive has no T8aD header and so no layer key — but there are now three permutations to test a candidate against instead of two, and the candidate has to live in the 60-byte declaration entry.

Verified against the live capture

The order is wired into measured_paint_order (keyed by element names, so both language builds get it). Composited with --primitives and edge-correlated against a framebuffer capture taken in the same session (capture, composite):

0.9591 at shift (0, 0)

That is a third screen confirming the whole stack at once — paint order, resting pose, fade alpha and primitives — on a capture this project had not seen before.

Not settled

  • ptframe1/ptframe2 rest at 0x00ffffff (alpha 0) and are therefore not drawn, but the capture shows the menu frame plainly. Either the resting rule picks the wrong plateau for them or the frame is drawn by something else.
  • The derivation for primitives. Three permutations now, still no rule.

The tie-break: still unsolved, and now measured down to the pixel (2026-08-19)

With the layer key and the primitives' implied keys in place, the tie-break — how the game orders elements that share a key — is the only thing left between the derived order and ground truth. Three measured screens now constrain it.

On the menu and the splash it does not bite. Every tied group there comes out in declaration order, which is what the stable sort already gives:

screen tied group measured
menu 0x8010 ×2 3, 4
menu 0x8050 ×2 6, 7
menu 0x8110 ×5 10, 11, 12, 13, 14
splash 0xa100 ×3 2, 4, 6
splash 0xa110 ×3 1, 3, 5

The title is the one screen that discriminates, and nothing predicts it:

0x8083 ×5   measured 14, 15, 18, 16, 17     (eff1, eff2, eff5, eff3, eff4)
0x80a0 ×7   measured  0,  2,  4,  7,  1, 3, 5  (logo1×3, tm, logo2×3)

Refuted

candidate result
declaration order interleaves the logos (0,1,2,3,4,5,7); wrong
RATC child order groups the logos correctly but puts tm last, and leaves 0x8083 in eff1..eff5; exact on the menu and splash, wrong on the title
first keyframe time 0x8083 times are 52, 56, 62, 58, 60 — the measured order is not sorted by them
resting keyframe time same shape, same failure
resting X or Y 0x8083 rests at x = 938, 938, 64, 788, 447 — unsorted either way
T8aD header +0x00, +0x04, +0x0c, +0x10 identical within a group, or unsorted

Child order deserves a note: it is a strict improvement over declaration order (7 misplaced positions on the title instead of 9, and it recovers the logo1×3 / logo2×3 grouping), and it is exactly right on the two other screens. It was not adopted, because on the only screen that can tell the two apart it is still wrong, and a rule that is wrong there buys nothing a stable sort does not already give.

What it costs, exactly

order_disagreements_that_change_pixels_are_pinned measures the damage rather than assuming it. Across all three measured screens:

  • 3 disagreeing pairs of drawn elements (repeat instances and faded-out elements are skipped, so they cannot count);
  • all 3 have overlapping bounding boxes;
  • 2 actually share opaque pixels — ptlogo_back2eff5 against eff3 (22 568 px) and against eff4 (32 395 px). The game paints eff5 third in the group, the sort paints it fifth, and those blended glow pixels differ.

The third pair is the instructive one. ptlogo2 and ptlogo_tm overlap by two columns — the wordmark decodes 992 px wide and reaches x=1129, the trademark starts at x=1127 — but the wordmark is fully transparent there, so the order cannot matter. A bounding-box test called this a defect; reading the alpha says it is not. That is why the test reads pixels.

So the residual error in the derived order is confined to one element's blend on one screen, and it is pinned: a change that makes it worse fails the test.

Two more measured orders — and the first independent confirmation (2026-08-19)

The three orders the derived rule was built from all live in GP_TITLE.pak, so they cannot confirm it: the rule was fitted to them. These two are from GP_SAVE_LOAD.pak, read off the running game after the Canary threading fix made the main menu reachable (capture).

The slot-list header — exact, without being told anything

GP_SAVE_LOAD ratc-indices 1/4/5/21/27, 9 elements:

measured  7 8 0 1 2 3 4 5 6
derived   7 8 0 1 2 3 4 5 6      EXACT, on all 6 instances
element key
7 pffocus1.rat, 8 pfslot1f_scr.rat 0xb102 tied ×2
0 pftitlebase 0xb200
15 pfeff11pftitle1 0xb210 tied ×5
6 pfeff00.prm none → last

Both tied groups come out in declaration order, which is what the stable sort gives, and the unkeyed fade quad lands last. Nothing about this screen was fed into the rule. It is the first evidence that the layer key is a real ordering mechanism rather than a description of three screens in one pak.

🟡 The save/load frame — differs in exactly the two known ways

GP_SAVE_LOAD ratc-index 18/24, 13 elements (is_build rejects it, so the compositor never sees it — but it is just as good as evidence):

measured  0 1 2 3 4 5 6 7 10 11 8 12 9
derived   2 3 4 5 6 7 8 10 11 12 9 0 1
  1. Unkeyed elements paint first, not last. Elements 0 and 1 are pfbase.tbm — a .tbm, not a .prm, with no sprite and no key — and the game paints them before everything else. Exactly the splash's palogo_eff0.prm behaviour in a different file type, so implied_layer_key now covers it and the layer-key sequences agree.
  2. A tied group in a non-declaration order. 0xb100 holds 8 pfwin, 10 pfbtn0, 11 pfbtn1, 12 pfmsg, and the game paints 10, 11, 8, 12.

That second point is the tie-break question again — and it is now on a second screen, which is what it needed.

🔴 Refuted: the tie-break is not kind

The new group is suggestive: 10 and 11 are kind = 0x2002 and 8 and 12 are 0x0000, so "descending kind, then declaration index" reproduces 10, 11, 8, 12 exactly. It does not survive the title:

  • 0x8083 ×5 — every element is kind = 0x0000, so the rule predicts declaration order 14,15,16,17,18; the game paints 14,15,18,16,17.
  • 0x80a0 ×7 — kinds are 0x0 and 0x4, so the rule predicts 2,3,4,5 then 0,1,7; the game paints 0,2,4,7,1,3,5.

So kind is not it either, and the count of refuted candidates is now seven: declaration order, RATC child order, first keyframe time, resting time, resting X/Y, the T8aD header words, and kind.

The corpus-wide census (2026-08-26)

tools/re-capture/paint_key_census.py, output in ../data/paint-key-census.txt. All 21 184 T8aD sprites on the disc — pak entries and RATC children.

⚠️ The first version of this section counted 4 525 sprites, and was retracted

It filtered pak entries whose own first four bytes are T8aD. But a sprite is usually a child of a RATC bundle, and a bundle entry's magic is RATC, so a top-level magic filter cannot see one:

sprites distinct keys
top-level T8aD entries (what was counted) 4 525 45
T8aD inside RATC bundles (what was missed) 16 659 204
both 21 184 216

171 of the 216 keys exist only inside bundles. The sharpest way to put it: that census never saw GP_TITLE.pak at all — the pak holding both screens this page's evidence comes from. Every number it produced described a fifth of the corpus, chosen by an accident of container nesting.

Retracted from it: "45 values", "the keys are pak-local", and "each auxiliary pak occupies its own narrow high-byte band". What survives is below.

The field is a u16 at +0x0A, not a word at +0x08

The upper half of the 32-bit word this page reads is zero in 21 184 / 21 184 sprites. Nothing above changes — 0x00008100 sorts identically to 0x8100 — but the port should read two bytes, and a value with the high half set would mean something has been misread rather than that the layer got deeper.

It is an enumeration: 216 values for 21 184 sprites

Not a per-sprite depth. The commonest key (0x8000) covers 1 768 sprites, 0x8100 another 1 524.

🔴 Refuted: the keys are not partitioned by pak

This was the previous section's headline and the fuller population kills it.

4 525-sprite version all 21 184
keys crossing a pak family 4 / 45 = 9 % 68 / 216 = 31 %
keys confined to GP_MAIN_GAME_2D 33 / 45 52 / 216

And the per-pak ranges overlap heavily rather than banding: GP_BUNK 0x80000xa110, GP_CHALLENGE 0x80000x9200, GP_HANGAR_ARSENAL 0x80000x9412, GP_LEADERBOARD 0x80000xf100, GP_TITLE 0x80000xc150. The tidy "each pak owns a high-byte band" picture was an artifact of seeing one or two keys per pak. Only three paks are actually narrow (GP_GAMEOVER 0xd8400xd862, GP_MISSION_LOG 0xa4000xa420, GP_SYSTEM 0xa0100xa112).

So the key is closer to a shared vocabulary than to a per-screen depth — the opposite of what the under-sampled census said. Which bits carry the layer remains unknown, and is now a question about 216 values rather than 45.

The language paks are one screen set six times

The six GP_MAIN_GAME_*2D.pak have byte-for-byte identical key sets. This is why counting paks rather than pak families first reported "37 of 45 keys appear in more than one pak" — a statement about localisation, not about the format.

The recurring failure, stated once

Three published numbers on this page were wrong the same way: each was computed over a population I had not checked was the population in question — the six language copies, then the top-level-only sprites, then the size of that gap. The analysis was never the problem. Check the sampling frame before the statistic.

EXTRAS — the derived order against a capture nobody had taken (2026-08-28)

The five orders above are all measured: the derived rule is checked against a live child list. This one is the other direction — a screen with no measured order at all, composited purely from the derived rule and scored against a framebuffer capture taken the same day (capture, composite).

GP_TITLE.pak build 6, 18 elements, two .prm primitives:

sylpheed-cli screen render --build 6 --black --primitives GP_TITLE.pak extras.png
align_to_capture.py live-extras.png extras.png --region 150 100 1150 600
composite best shift score
build 6, --black --primitives (0, 0) 0.9620
build 6, no primitives (0, 0) 0.9576
control: main menu vs main-menu-oracle.png, same tooling (0, 0) 0.9657

The control was run first and reproduces the screen this rule was fitted to, so the harness is known-good before the new screen is scored. EXTRAS lands within 0.004 of it, at zero shift, with the primitives helping rather than hurting.

⚠️ What this does and does not show

It does show that every other part of the stack transfers to an unfitted screen: element enumeration, resting pose, pivots, fade alpha, and that the two primitives land somewhere harmless under the derived implied_layer_key.

It does not show that the paint order is right. align_to_capture.py correlates gradient magnitude — edges — and a paint-order change moves blend values, not edges. The earlier derived-vs-declaration A/B on the pause menu and main menu measured exactly that: 1.13.8 % of pixels differing by ≤45/255, in translucent overlaps. A score computed this way cannot see a difference that size, so 0.9620 is evidence for placement and only consistent with the order.

The sharp test, not run. Composite build 6 twice — the committed derived order, and compose temporarily reverted to declaration order — and diff both against the capture over the pixels where they disagree. That needs a rebuild of sylpheed-formats, which is why it is named here rather than done; it is the same A/B this page already ran on the two screens that had oracles, and EXTRAS would be the first screen where the two orders can be judged against ground truth rather than declared indistinguishable.

🟡 A second flag in the same header: +0x04, bit 0x02 (2026-08-28)

This page reads the layer key at +0x0A. The word at +0x04 is a different flag word, and on the title screen it splits the sprites exactly along effect versus normal:

+0x04 title sprites
0x8832 pteff01, pteff03a, ptlogo_back2eff1…5, ptlogoall_eff, ptlogoall_eff2
0x8830 ptlogo1, ptlogo2, ptlogo_tm, ptbase2, ptlogo_back2, ptcopyright, ptlogo_back2eff

One bit apart: 0x02.

Disc-wide (19 216 T8aD sprites, top-level and RATC-nested): 18 distinct values, and bit 0x02 is set in 27.1 %. It varies independently of the rest of the word — 0x8830/0x8832, 0x0830/0x0832, 0x0810/0x0812 and 0x0030/0x0032 all occur — so it is a genuine flag, not part of a larger enumeration.

Why it matters: the title's swoosh renders opaque white where the game draws a thin coloured stroke, and every other candidate is eliminated — position and size come from the texture and match, fade is white-with-alpha, tint is white, and the texture itself is blue-leaning (../ui-title-build-map.md). A per-sprite blend mode is what is left, and this is the only per-sprite field found that distinguishes the elements involved.

🔴 Tested — it does NOT select an additive blend

Blending bit-0x02 sprites additively and re-correlating against the title capture moved every measure the wrong way:

alpha-over additive
whole-frame mean diff +0.55 +1.04
swoosh-band mean diff +1.83 +3.98
swoosh-band edge-corr 0.6971 0.5578

So the bit is real and independent, but additive is refuted. T8adImage now carries the word as flags, documented and not acted on.

⚠️ What remains is a correlation, not a decode. The original test was against ../captures/title-builds/live-title-build4-no-plate.png. Worth noting ptlogo_back2eff carries 0x8830 despite having eff in its name — so the split is the field's, not a naming pattern's. What the bit does mean is still unknown.