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 0a64728fae re: retract the paint-key census and redo it over all 21 184 sprites
The census filtered pak entries whose own first four bytes are T8aD.  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:

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

171 of the 216 keys exist only inside bundles.  The sharpest statement of the
error: that census never saw GP_TITLE.pak at all -- the pak holding both of the
screens this page's entire evidence comes from.

Retracted: "45 values", "the keys are pak-local", "each auxiliary pak occupies
its own narrow high-byte band".  On the full population 68/216 keys (31%, not
9%) cross a pak family and the per-pak ranges overlap heavily -- GP_BUNK
0x8000-0xa110, GP_TITLE 0x8000-0xc150, GP_LEADERBOARD 0x8000-0xf100.  The tidy
banding was an artifact of seeing one or two keys per pak.  So the key looks
like a shared vocabulary, which is the opposite of what I published.

Survives, now on the full population: the field is a u16 at +0x0A (upper half
zero 21 184/21 184), and it is an enumeration (216 values for 21 184 sprites).

Three wrong numbers on this page now, all the same shape -- a statistic computed
over a population I had not checked was the population in question.  Stated once
at the end of the section rather than three times: check the sampling frame
before the statistic.
2026-08-26 10:06:23 +00:00

398 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`, `0x8081``0x8100` 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](../captures/mission-select-derived-order.png)).
**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 `ptbtn01``ptbtn05` |
| 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](../captures/main-menu-oracle.png),
[composite](../captures/ui-layout/main-menu-composited.png)):
> **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](../captures/load-game-oracle.png)).
### ✅ 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 `pfeff11``pftitle1` | `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`](../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`
`0x8000``0xa110`, `GP_CHALLENGE` `0x8000``0x9200`, `GP_HANGAR_ARSENAL`
`0x8000``0x9412`, `GP_LEADERBOARD` `0x8000``0xf100`, `GP_TITLE`
`0x8000``0xc150`. 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` `0xd840``0xd862`, `GP_MISSION_LOG` `0xa400``0xa420`,
`GP_SYSTEM` `0xa010``0xa112`).
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.**