The plan was to separate ptlogo_back2eff from ptlogo_back2eff5 (same 1133x280, declaration 20 vs 18, either side of ptlogo_back2 at 19) by their resting fade alphas. Carried out, and refuted: both quads come back at alpha FF, and reading the bundle again, both elements REST at 255 under this project's own max-dwell rule — back2eff5's longest hold is t=74 at 255, and the 192 I had quoted is a later, shorter keyframe, not its resting value. The ambiguity stands. What the colour did buy is worth more than the tie-break: the first check of the fade/keyframe decode against the RUNNING GAME rather than against another parse. Every static element draws at exactly the resting alpha the bundle predicts, and the only two quads whose alpha moves between consecutive frames (88->88/86->87 and 3C->38) are the rotating effect pair and the PRESS (A) glow — the two things visibly animating on screen. Nothing had confirmed before that the alpha channel of a keyframe is what drives that throb.
384 lines
20 KiB
Markdown
384 lines
20 KiB
Markdown
# The title screen's paint order, measured from the guest's draw submissions
|
||
|
||
**Status:** ✅ `CONFIRMED` — the order in which the running game paints the title
|
||
screen's sprites, read off the guest's own draw stream. 🟡 the *rule* that
|
||
produces that order is still unknown; four candidate rules are refuted below.
|
||
❔ two full-screen layers and one rotated effect pair are unidentified.
|
||
|
||
This closes the measurement gap named in `BACKLOG.md` — *"the next step is no
|
||
longer static: it is a per-draw capture of the title screen showing the order the
|
||
game submits"* — and it does so without the guest-code read that entry expected.
|
||
|
||
## What it took: a third RE hook in Canary
|
||
|
||
Neither existing hook can answer this (`BACKLOG.md` has the detail): `log_draws`
|
||
de-dups by vertex declaration, so a screen's sprites collapse into one record,
|
||
and the F10 ship capture drops any draw without an `f32x3` position stream —
|
||
which is every UI quad. So `log_ui_draws` was added on the Canary branch
|
||
`auto/re-ui-draw-order`:
|
||
|
||
* F10 arms it (shared with the ship capture; `log_ui_draws` is what separates
|
||
them), and it writes `xenia_re_ui_draws_NN.log` — **every** draw of the next
|
||
*N* frames, in submission order, undeduplicated;
|
||
* `--ui_draw_capture_frames` / `--ui_draw_capture_max` bound it;
|
||
* each record carries the primitive type, index count, VS/PS hashes, every bound
|
||
texture's base and dimensions, and **the quad's vertex positions**.
|
||
|
||
Two traps were paid for on the way, both worth stating because both produced
|
||
confident nonsense rather than an error:
|
||
|
||
* **`counter_` is not a frame number.** It looks like one — the VdSwap packet
|
||
increments it — but `GraphicsSystem::MarkVblank()` increments it too, from the
|
||
vblank thread. A capture bounded by it ended **after one draw**, having
|
||
"covered 6 frames", because six vblanks landed between two draws of the same
|
||
frame. The hook now counts VdSwap packets itself.
|
||
* **`screenshot` was grabbing a 10-pixel sliver.** The app owns two windows of
|
||
class `xenia_canary` — the 1280×745 game window and a 10×10 helper — and the
|
||
wrapper took the first match from `xwininfo -root -children`, which lists only
|
||
*direct* children of the root and so never contained the real, reparented game
|
||
window at all. Every screen classification for a whole session was noise from a
|
||
10×10 image; the movie-skip heuristic fired on it repeatedly and drove the
|
||
guest into a save-data probe that crashed it. Fixed to take the largest
|
||
`xenia_canary` window from the full tree.
|
||
|
||
## The measurement
|
||
|
||
Boot to the title screen, arm the capture, decode the quads. The title screen's
|
||
steady state is **11 draws per frame**, every frame, identically:
|
||
|
||
| draw | what |
|
||
|---|---|
|
||
| 0 | untextured full-screen triangle (VS `0A6D1DD7`, PS `2E372EA2`), positions already in pixels |
|
||
| 1–8 | the UI sprite shader (PS **`E59B2B3D`**), quad lists, positions in NDC |
|
||
| 9–10 | two full-screen triangles sampling a 1×1 texture — a fade/tint pass |
|
||
|
||
Sprite draws sample **1280×768** texture pages (`fmt=6`, i.e. 720 rounded up to
|
||
64), several pages alive at once — so the bound texture does *not* name the
|
||
sprite. The **quad's size does**: the disc's decoded sprites have near-unique
|
||
dimensions. `tools/re-capture/ui_draw_order.py` converts NDC → pixels and matches
|
||
each quad against `sylpheed-cli pak textures` output.
|
||
|
||
Frame 2 of [`captures/title-screen-draw-order.log`](captures/title-screen-draw-order.log),
|
||
in submission order ([full table](captures/title-screen-paint-order.txt)):
|
||
|
||
```
|
||
draw 1 ( 0, 0) 1280x720 full-screen layer
|
||
draw 2 (-147,-209) 883x1134 ROT rotated effect ┐ two rotated quads,
|
||
draw 2 ( 525,-292) 1299x1303 ROT rotated effect ┘ off-screen extents
|
||
draw 3 ( 0, 0) 1280x720 full-screen layer
|
||
draw 4 ( 64, 115) 1133x281 ptlogo_back2eff / ptlogo_back2eff5 [22 or 24]
|
||
draw 4 ( 70, 126) 1120x263 ptlogo_back2 [23]
|
||
draw 5 ( 186, 191) 915x115 ptlogo1 [0]
|
||
draw 5 (1126, 378) 38x18 ptlogo_tm [11]
|
||
draw 6 ( 134, 306) 992x104 ptlogo2 [1]
|
||
draw 7 ( 294, 655) 691x18 ptcopyright [28]
|
||
draw 7 ( 384, 551) 512x50 ptbtn00 ← build 2, not build 7
|
||
draw 8 ( 371, 536) 538x76 ptbtn00f ← build 2, not build 7
|
||
```
|
||
|
||
Bracketed numbers are `GP_TITLE.pak` build 7's declaration indices
|
||
(`sylpheed-cli screen info … --build 7`). Sizes match to ≤4 px; the quads run a
|
||
few pixels under their sprite (919×113 → 915×115), whose cause is **unmeasured**
|
||
— a scale just under 1, or a UV inset.
|
||
|
||
### Two things fall out of it immediately
|
||
|
||
**1. Declaration order is refuted per-draw, not just by a framebuffer.** The
|
||
background paints **first** and `ptlogo1`/`ptlogo2` (elements 0 and 1) paint
|
||
*after* elements 22/23 — an order no reading of the table produces. Reverse
|
||
order dies with it: `ptcopyright` (28) is second-to-last, not first.
|
||
|
||
**2. The visible title screen is more than one bundle.** `ptbtn00` (513×50, the
|
||
`PRESS Ⓐ BUTTON` plate) and `ptbtn00f` (537×76) are **not among build 7's 30
|
||
elements** — they are the whole of `GP_TITLE.pak` **build 2** (and its language
|
||
twin build 3), a one-element bundle: `ptbtn00.rat`, kind `0x73002`, focus-linked
|
||
to `ptbtn00f.rat`, resting at **(383,550)**. The capture puts that quad at
|
||
**(384,551)**, which is a one-pixel agreement between a static placement decode
|
||
and the running game, on a bundle nothing had checked before. So the
|
||
screen the player sees is a composite of at least two builds, and **no single
|
||
build's element table can be the paint order**, whatever its order. Any rule has
|
||
to sequence *bundles* as well as elements inside one.
|
||
|
||
This also independently corroborates the pivot fix landed earlier the same day:
|
||
the full-screen layers cover exactly (0,0)–(1280,720), which is where
|
||
pivot-anchored scaling puts `ptbase2` at 200 % and not where corner-anchored
|
||
scaling puts it.
|
||
|
||
## Refuted, with this capture as the ground truth
|
||
|
||
* **declaration order** and **reverse declaration order** — above.
|
||
* **keyframe start time** — `ptlogo1` (first keyframe t=26) paints *after*
|
||
`ptlogo_back2` (t=112).
|
||
* **resting-keyframe time** — same pair: rest t=42 after rest t=126.
|
||
* the placement-region order and the RATC child order were already refuted
|
||
statically (`BACKLOG.md`); nothing here rehabilitates them.
|
||
|
||
## Not settled
|
||
|
||
* **The rule.** Nothing yet found in the bundle orders these draws. What has
|
||
*not* been checked against this new ground truth: a depth/priority field inside
|
||
the per-element `.rat` record (as opposed to the 60-byte declaration entry,
|
||
which was dumped and carries no such field), and whatever sequences the two
|
||
bundles.
|
||
* **Three of the twelve quads are unidentified**: the two full-screen layers
|
||
(one is `ptbase2` at 200 %, the other is `pteff04` or a `PRMD`; the capture
|
||
cannot tell them apart because both are full-screen and both sample a page)
|
||
and the rotated pair, whose 883×1134 and 1299×1303 bounding boxes match no
|
||
sprite because the quads are rotated.
|
||
* **One screen only.** This is `GP_TITLE`. A rule needs a second screen with a
|
||
background and overlapping foreground; the same hook now makes that a
|
||
ten-minute run rather than a static argument.
|
||
|
||
---
|
||
|
||
## Correction (same day, next iteration): the running screen is build **4**, not build 7
|
||
|
||
Everything above was matched against `GP_TITLE.pak` **build 7**, because that is
|
||
the largest build and what `screen info` defaults to. The game does not run it.
|
||
|
||
`screen info --geometry` prints each element's *decoded sprite size*, and the two
|
||
builds disagree:
|
||
|
||
| sprite | build 7 | build 4 (`a60fcb85`) | captured quad |
|
||
|---|---|---|---|
|
||
| `ptlogo1.t32` | 822×100 | **919×113** | 915×115 |
|
||
| `ptlogo2.t32` | 888×93 | **992×104** | 992×104 |
|
||
| `ptlogo_back2.t32` | 1000×234 | **1118×262** | 1120×263 |
|
||
|
||
So the quads come from build 4 — a 24-element build — and the earlier index
|
||
mapping was against the wrong table. **The conclusion does not change**, only the
|
||
numbers: the paint order in build 4's declaration indices is
|
||
|
||
```
|
||
draw 1,2,4 full-screen layers 9 ptbase2 / 10 pteff04 (not separable — see below)
|
||
draw 3 two rotated effect quads ?
|
||
draw 5 ptlogo_back2eff(5) 18|20, then ptlogo_back2 19
|
||
draw 6 ptlogo1 0, then ptlogo_tm 7
|
||
draw 7 ptlogo2 1
|
||
draw 8 ptcopyright 21, then ptbtn00 ← ptbtn00 is build 2
|
||
draw 9 ptbtn00f ← build 2
|
||
```
|
||
|
||
Declaration order is still refuted (`9` first; `0`/`1` after `18`/`19`), reverse
|
||
order still refuted (`21` second-to-last), and the two-bundle finding is
|
||
unchanged — build 2 is a one-element bundle holding `ptbtn00.rat` at rest
|
||
(383,550), against a captured (384,551).
|
||
|
||
**Reproduced in an independent run** (fresh boot, a signed-in profile, different
|
||
frame numbers): byte-for-byte the same order and the same rects. That is the
|
||
second observation the `CONFIRMED` bar asks for.
|
||
|
||
### The batching rule, which is what makes the order readable
|
||
|
||
Draws carry one or two quads, and a two-quad draw always holds two elements that
|
||
are **adjacent in paint order and share a texture page**. Pages alternate
|
||
(`0B2C, 11C3, 0BE3, 11C3, 0B2C`), so this is not a sort by page: the engine
|
||
accumulates quads while the bound page stays the same and flushes when it
|
||
changes. The paint order is therefore the concatenation of the draws' quads, in
|
||
vertex order within each draw.
|
||
|
||
## Two more orderings refuted, and one non-place to look
|
||
|
||
**There is no depth in the geometry.** Attribute 0 of a UI quad is
|
||
`k_32_32_32_FLOAT` — a float3 — so the stream carries a Z. Every Z in the capture
|
||
is **0.00000**, on every vertex of every quad of every frame. The submission
|
||
order is the whole of the ordering; nothing is left to a depth test.
|
||
|
||
**There is no ordering field in the bundle either.** Dumping build 4's whole
|
||
declaration table word by word (`docs/re/captures/title-build4-decl-words.txt`):
|
||
`+28` is 0 on all 24 entries, `+32` is `0xffffffff` (no parent) on all 24, `+36`
|
||
is `0xffffffff` except on the three `kind = 0x4` repeat instances where it is
|
||
0/1, `+44` is `0xffffffff` on all 24, `+56` is 0 on all 24. The placement
|
||
region's per-group lead word — the one word of that header this project had never
|
||
read — is **0 for every group**. And the region is followed immediately by the
|
||
RATC child stream (`opt ` + `ptlogo1.t32` + `T8aD`), so there is no table after
|
||
it to hold a draw order.
|
||
|
||
So the paint order is not carried by the file. It is produced by the guest's
|
||
screen code, and settling it means reading that code (the splash path noted in
|
||
the emulator-era work — `sub_821CC7A0`, item vtable `0x820b30b4` — draws with
|
||
exactly the PS hash `E59B2B3D` this capture sees).
|
||
|
||
## A correction to this entry's own premise
|
||
|
||
It says the F10 ship capture "explicitly drops UI draws … it requires an `f32x3`
|
||
position attribute, which a 2D quad stream does not have". **These quads have
|
||
one.** Armed on the title screen, `xenia_ship_capture_01.log` holds 1 150 draw
|
||
records of which **965 are the UI sprite shader** (`stride=24 prim=13`,
|
||
VS `E0BAFB4F`).
|
||
|
||
The new hook is still the right tool, for reasons that are about the record and
|
||
not the filter: the ship capture writes no frame boundaries, no submission
|
||
sequence across frames, no bound-texture identity, and de-dups on a key that
|
||
happens not to collide here only because the game rebuilds its UI vertex buffer
|
||
every frame. But "it cannot see UI draws" was wrong, and the cost of the new hook
|
||
was justified with a wrong reason.
|
||
|
||
---
|
||
|
||
## The refutations, re-checked against the build the game actually runs
|
||
|
||
The two time-based orderings were first refuted with build 7's numbers, and build
|
||
7 is not what runs. Re-checked on **build 4**, both still fail, and by the same
|
||
element:
|
||
|
||
| ordering | build 4 values, in the observed paint order | verdict |
|
||
|---|---|---|
|
||
| first keyframe time | bg 0, `back2eff5` 62, `back2` 66, **`logo1` 26**, `tm` 98, **`logo2` 26**, `copyright` 138 | not monotonic — refuted |
|
||
| resting keyframe time | `back2eff5` 74 / `back2eff` 66, `back2` 80, **`logo1` 42**, `tm` 116, **`logo2` 42**, `copyright` 160 | not monotonic — refuted |
|
||
|
||
**A new candidate, and its counterexamples.** Painter's order by *screen depth*
|
||
would be the obvious runtime rule, and sorting the identified sprites by resting
|
||
**Y** very nearly reproduces the capture:
|
||
|
||
```
|
||
sorted by rest Y : back2eff5 117, back2 126, logo1 186, logo2 315, tm 378, copyright 655
|
||
captured order : back2eff5, back2, logo1, tm, logo2, copyright
|
||
```
|
||
|
||
— one transposition away. It is still **refuted**, twice over: `ptlogo_tm`
|
||
(y 378) is painted before `ptlogo2` (y 315), and the full-screen background is
|
||
painted *first* although `ptbase2`'s resting Y (180) would sort it after both
|
||
`back2` elements. Recorded because "nearly sorted by Y" is the kind of result
|
||
that gets adopted as a rule if only the agreeing five are counted.
|
||
|
||
## A foothold in the guest code, and what it is not
|
||
|
||
The remaining avenue is the code, and `xenia-rs/sylpheed.db` is in the container.
|
||
What is established so far is small, and stated so it is not mistaken for more:
|
||
|
||
* **The item class from the splash-era work is real.** Vtable `0x820b30b4` is
|
||
class `ANON_Class_03A0925F` with **25 slots**, constructed at three sites
|
||
(`sub_823CB2A0`, `sub_823CB558`, `sub_823CBB90`, each with a single caller).
|
||
* **RTTI carries no names.** All 851 classes in the database are
|
||
`ANON_Class_########`; the retail binary has the RTTI structure but not the
|
||
type names, so nothing can be found by searching for "screen" or "sprite".
|
||
* **The format tags are immediates, not strings.** `RATC` appears as
|
||
`addis 0x5241` / `ori 0x5443` in ~10 functions in the `0x82188`–`0x821B1`
|
||
range, always loaded into `r4` before a **virtual** call — e.g. `sub_821881D8`
|
||
does `obj->slot1('RATC', &out)`, then passes the result to `sub_82187B78`. So
|
||
the container walk is behind an interface, and a plain string search for
|
||
`.rat` / `.t32` finds nothing (2 hits disc-wide, both unrelated).
|
||
* **A dead end worth recording:** searching for the declaration table's 60-byte
|
||
stride (`mulli rX, rY, 60`) finds 49 sites, and the most promising,
|
||
`sub_821B4498`, turns out to be a **time-string parser** (`MM:SS.mmm` — it
|
||
splits on `:` and `.` and multiplies by 60, then 100). The guest does not
|
||
necessarily index that table with a multiply at all.
|
||
|
||
---
|
||
|
||
## A second screen, and a second title state (2026-08-18, later still)
|
||
|
||
### The main menu's paint order
|
||
|
||
Reached and captured. Its sprites are `GP_TITLE.pak` **build 5**'s (the 16-element
|
||
menu build), and every identified quad lands within 4 px of its declared resting
|
||
placement — another independent check on the placement decode, on a bundle
|
||
nothing had touched:
|
||
|
||
| draw | quad | sprite | declared rest |
|
||
|---|---|---|---|
|
||
| 7 | (442,108) 243×281 | `ptframe1.t32` [6] | (445,108) |
|
||
| 7 | (582,266) 256×310 | `ptframe2.t32` [7] | (578,267) |
|
||
| 8 | (525,594) 224×40 | `ptmsg.t32` | — |
|
||
| 9 | (538,155) 211×54 | `ptbtn01f.t32` (focused NEW GAME) [10→focus] | (542,157) |
|
||
|
||
preceded by five full-screen layers and the rotated `ptloop` pair.
|
||
|
||
**And it does not discriminate**, which is the honest headline. The observed order
|
||
is *consistent* with build 5's declaration order — but build 5 lists its
|
||
background at indices 1–2, so "declaration order" and "background first" predict
|
||
the same thing here. It is exactly the failure mode `BACKLOG.md` recorded for
|
||
`GP_READY_ROOM` and `GP_OPTIONS`. What it does establish is that the title's
|
||
disagreement is not a decode artefact: same pak, same engine, two builds, and one
|
||
of them paints in an order its table does not describe.
|
||
|
||
### The two title states: identical to draw, different to press
|
||
|
||
The previous entry's hypothesis — that the attract-loop title is a
|
||
non-interactive *presentation* that omits the `PRESS Ⓐ BUTTON` plate — is
|
||
**half confirmed and half refuted**, and the halves matter:
|
||
|
||
* ✅ **The state distinction is real.** A single Ⓐ on the title that ends the boot
|
||
sequence opens the main menu — **twice out of two**, in independent runs, one of
|
||
which never pressed F10 at all. On a title that the attract loop has come back
|
||
to, Ⓐ does nothing — as do START, B, BACK, X and Y — across dozens of presses,
|
||
every one delivered and none swallowed.
|
||
* ❌ **The tell is not in the drawing.** Capturing `log_ui_draws` in both states
|
||
in one run gives **13 quads each, at identical rects**, `ptbtn00` and
|
||
`ptbtn00f` included. The two titles are pixel-identical apart from the phase of
|
||
the rotating effect. Whatever gates the button is invisible to the renderer.
|
||
|
||
So there is now a **reliable route to the main menu** — wait for the first title
|
||
after boot, tap Ⓐ once — which is what the second screen's capture needed, and it
|
||
is written into `tools/re-capture/`. Why the attract title stops accepting input
|
||
is still unexplained.
|
||
|
||
## Two more routes closed (2026-08-19)
|
||
|
||
### The loading screen does not appear during the boot
|
||
|
||
`GP_TITLE`'s 10-element loading build is the best *unnavigated* discriminator on
|
||
the disc: its full-screen element `pgloading_baseeff` (640×360 at 200 %) is
|
||
declared **7th of 10**, behind the spinner and the "NOW LOADING" text, so which
|
||
of the two orders the game uses would be plain to see.
|
||
|
||
It never shows up. Arming a capture every ~6 s across an entire boot — 23
|
||
captures, `tools/re-capture/loading_draw_capture.sh` — yields **zero** loading
|
||
sprites: every capture is the intro movie compositing two full-screen quads per
|
||
frame. The loading screen belongs to a *content* load (a mission, the hangar),
|
||
which is behind the main menu, which is behind the Ⓐ that works half the time.
|
||
|
||
### No other build's table produces the observed order either
|
||
|
||
A loophole this document left open: the title's sprites were matched to build 4
|
||
by size, so perhaps the game runs a *different* build whose table happens to
|
||
list them in the captured order. Checked against every RATC bundle in
|
||
`GP_TITLE.pak`:
|
||
|
||
```
|
||
entry 4 (a60fcb85) n=24 all 7 sprites present, ascending=False
|
||
ptbase2:9 ptlogo_back2eff:20 ptlogo_back2:19 ptlogo1:0
|
||
ptlogo_tm:7 ptlogo2:1 ptcopyright:21
|
||
entry 7 (b483e6e6) n=30 all 7 sprites present, ascending=False
|
||
```
|
||
|
||
Only two bundles contain all seven, and **neither lists them in the captured
|
||
order**. So the disagreement is not an artefact of picking the wrong build.
|
||
|
||
### One sub-order is genuinely ambiguous, and how to fix it
|
||
|
||
`ptlogo_back2eff` and `ptlogo_back2eff5` decode to the **same 1133×280**, so the
|
||
capture cannot say which of them the first quad of that draw is — and they sit at
|
||
declaration 20 and 18, on opposite sides of `ptlogo_back2` (19). The pair is the
|
||
one place the observed order might still be locally ascending.
|
||
|
||
**That plan was carried out, and it does not work.** The colour attribute is now
|
||
logged (`col=AARRGGBB` per vertex) and both quads of that draw come back at
|
||
**α = FF**. Reading the bundle again more carefully, both elements *rest* at 255
|
||
under this project's own max-dwell rule — `back2eff5`'s longest hold is t=74
|
||
(α=255, 36 ticks) and `back2eff`'s is t=66 (α=255, **172** ticks). The α=192 in
|
||
`back2eff5`'s table is a later, shorter keyframe, not its resting value. So the
|
||
alpha cannot separate them and **the ambiguity stands**.
|
||
|
||
### What the colour did buy: the fade decode, checked against the running game
|
||
|
||
Every quad's alpha, from
|
||
[`captures/title-draw-order-with-alpha.log`](captures/title-draw-order-with-alpha.log):
|
||
|
||
| quad | sprite | α (two consecutive frames) |
|
||
|---|---|---|
|
||
| 1280×720 ×2 | full-screen layers | FF, FF |
|
||
| 883×1134 / 1299×1303 ROT | the rotating effect pair | **88 → 88**, **86 → 87** |
|
||
| 1133×281, 1120×263 | `back2eff?` + `back2` | FF, FF |
|
||
| 915×115, 38×18, 992×104, 691×18, 512×50 | `ptlogo1`, `tm`, `ptlogo2`, `ptcopyright`, `ptbtn00` | FF |
|
||
| 538×76 | `ptbtn00f` (the PRESS Ⓐ glow) | **3C → 38** |
|
||
|
||
Two independent things fall out. Every *static* element draws at exactly the
|
||
resting α=255 the bundle predicts — the first check of the fade/keyframe decode
|
||
against the running game rather than against another parse. And the two elements
|
||
whose α **moves between consecutive frames** are exactly the two that should be
|
||
animating: the rotating effect pair, and the `PRESS Ⓐ BUTTON` glow, whose throb
|
||
is visible on screen. Nothing had confirmed before that the α channel in a
|
||
keyframe is what drives that throb.
|