The layer-key order was adopted from two measured screens and then applied to every build on the disc, so it owed a regression check against the screens the corpus had already validated against the running game. Rendered the tutorial PAUSE menu and the title main menu both ways and diffed: 3.8 % and 1.1 % of pixels differ, max delta 45/255 and 34/255, and the two renders are indistinguishable in layout — the change is confined to blends where translucent sprites overlap. No regression, but which order is more faithful on those two screens is unsettled and recorded as such. Adds a corpus-wide test asserting every composite's draw list is strictly increasing in (layer key, declaration index), streaming one pak at a time so it does not OOM alongside the other whole-disc tests. It reports the rule's reach: 341 of 965 builds are reordered, and it fails if that share collapses.
136 lines
6.7 KiB
Markdown
136 lines
6.7 KiB
Markdown
# 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` |
|
||
| 8–12 | 14,15,18,16,17 | `back2eff1…5` | `0x8083` ×5 |
|
||
| 13–19 | 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` |
|
||
|---|---|---|
|
||
| 1–3 | the three `_eff` glows | `0x a100` ×3 |
|
||
| 4–6 | 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.
|