Files
Sylpheed/docs/re/structures/ui-paint-order-key.md
Sylpheed RE agent e0394647b2 re: census the T8aD paint-order key -- it is pak-local, not a global vocabulary
The page rested on twelve values from two screens.  This walks all 4525 sprites
on the disc.

  * The field is a u16 at +0x0A.  The upper half of the 32-bit word the page
    reads is zero in 4525/4525.  Nothing above changes -- 0x00008100 sorts the
    same as 0x8100 -- but a future value with the high half set would mean
    something had been misread rather than that the layer got deeper.
  * It is an enumeration: 45 values for 4525 sprites, one of which (0x8100)
    covers 1188 of them.
  * The reading worth trying -- a global layer vocabulary shared across the UI
    -- is refuted.  Only 4 of 45 keys cross a pak family and 33 of 45 live only
    in GP_MAIN_GAME_2D; every other pak owns a narrow high-byte band (0x90-0x94
    for the in-game overlays, 0xa4 mission log, 0xb1-0xb2 save/load).  A screen
    that owns one or two keys is not ordering itself with them.

That supports "group id in the high bits, order in the low bits", which is what
the page already suspected, but it does NOT test it: paint order has been
measured on two screens and both are inside GP_MAIN_GAME_2D, so there is no
ground truth to check the split against.  Left amber.

The first number I got was 37/45 shared, which would have supported precisely
the wrong conclusion.  It came from counting paks instead of pak families: the
six GP_MAIN_GAME_*2D paks are the same screens in six languages and their key
sets are byte-for-byte identical.  Recorded on the page, because the shape
recurs -- a corpus with near-duplicate members manufactures agreement.
2026-08-26 09:50:29 +00:00

18 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 compositor never sees it. Its measured order is therefore inert in practice, and the splash cannot be rendered by screen render at all. That is a separate gap in what counts as a "build", not a paint-order question.

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)

Everything above rests on twelve values from two screens. This walks all 4 525 T8aD sprites on the disc, so the open question can be asked of the whole corpus. tools/re-capture/paint_key_census.py, output in ../data/paint-key-census.txt.

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 4 525 / 4 525 sprites. Nothing above changes — 0x00008100 sorts identically to 0x8100 — but the port should read two bytes, and a future value with the high half set would mean something has been misread rather than that the layer got deeper.

It is an enumeration: 45 values for 4 525 sprites

Not a per-sprite depth. The commonest key alone (0x8100) covers 1 188 sprites, and the whole range is 0x0000, 0x7d000x9820, then 0xa410, 0xb1000xb210.

🔴 The keys are pak-local — so this is not a global layer vocabulary

That was the reading worth trying, and it fails. Collapsing the six language variants of GP_MAIN_GAME_*2D.pak into one family:

keys appearing in more than one pak family 4 / 45 (0x0000, 0x9092, 0x9110, 0x9200)
keys confined to GP_MAIN_GAME_2D alone 33 / 45

and each auxiliary pak occupies its own narrow high-byte band:

pak its keys
GP_MAIN_GAME_2D 0x7d000x9820 (34 keys)
GP_DEBRIEFING_PILOTLOG 0x9091, 0x9092
GP_LEADERBOARD 0x9108, 0x9110, 0x9200
GP_CHALLENGE 0x9110, 0x9200
GP_HANGAR_ARSENAL 0x9200, 0x9300, 0x9400
GP_STAGE_CLEAR 0x9092
GP_MISSION_SELECT 0x9110
GP_MISSION_LOG 0xa410
GP_SAVE_LOAD 0xb100, 0xb110, 0xb210

A screen that owns one or two keys is not using them to order itself internally — it has nothing to order against. So the high bits look like a group or owner id and the low bits like the ordering within it, which is the shape this page already suspected. 🟡 That remains a description of 45 numbers: paint order has been measured on two screens only, both inside GP_MAIN_GAME_2D, so there is no ground truth anywhere else and the split between "group" and "order" bits is still untested.

⚠️ The first count I got was 37 / 45, and it was about localisation

Counting paks rather than pak families makes every GP_MAIN_GAME_2D key look like it appears in six paks. The six are the same screens in six languages — and their key sets are byte-for-byte identical (verified: one distinct set across all six). The inflated number would have supported exactly the wrong conclusion, that the keys form a vocabulary shared across the whole UI. Recorded because the shape recurs: a corpus with near-duplicate members will manufacture agreement.