Files
Sylpheed/docs/re/structures/ui-paint-order-key.md
Sylpheed RE agent 83d159ea16 formats: a third measured paint order — the main menu, with two primitives
Read off the running game with screen_children.py and identified by pivot
signature as GP_TITLE.pak ratc-index 8, the NEW GAME / LOAD GAME / TUTORIAL /
OPTIONS / EXTRAS screen:

  paint order: 1 3 4 2 5 8 9 6 7 15 10 11 12 13 14 0

It is the first measured screen carrying TWO primitives, and they land in
different places, which is the point. pteff02.prm (the 25% dim) paints 4th,
beneath the whole UI; pteff00.prm (the transition fade, resting transparent)
paints last. Both match their positions on the title screen exactly. So a
primitive's place is per-element and stable by role - backdrop first, dim at
slot 4, fade last - and there are now three permutations to test a derivation
against rather than two.

Wired into measured_paint_order, keyed by element names so both language builds
get it. The English build composited with --primitives edge-correlates at 0.9591
at shift (0,0) against a framebuffer capture taken in the same session - a third
screen confirming paint order, resting pose, fade alpha and primitives at once,
against a capture this project had not seen before.

13 disc tests green.
2026-08-19 08:56:21 +00:00

9.2 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.