Files
Sylpheed/docs/re/ui-title-paint-order-capture.md
Sylpheed RE agent 45ee8c3a1b docs/re: the loading screen never boots, and no other build explains the order
Two routes closed, both cheaply.

The loading screen was the best discriminator reachable WITHOUT navigation — its
full-screen element is declared 7th of 10, so the two candidate orders would look
completely different. It does not appear during the boot at all: 23 captures
armed across an entire boot are all intro-movie frames, two full-screen quads
apiece. It belongs to a content load, which is behind the main menu, which is
behind the (A) that works half the time.

And a loophole this document had left open is shut: the title's sprites were
matched to build 4 BY SIZE, so the game might have been running some other build
whose table lists them in the captured order. Only two bundles in GP_TITLE.pak
contain all seven sprites, and neither is ascending in the captured order.

Recorded rather than glossed: one sub-order is genuinely ambiguous, because
ptlogo_back2eff and ptlogo_back2eff5 decode to the same 1133x280 and sit at
declaration 20 and 18, either side of ptlogo_back2 at 19. The UI quad carries a
k_8_8_8_8 colour attribute the hook does not log yet, and the two rest at
different fade alphas — logging it would name them apart.
2026-08-19 00:58:46 +00:00

19 KiB
Raw Blame History

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.logevery 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
18 the UI sprite shader (PS E59B2B3D), quad lists, positions in NDC
910 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, in submission order (full table):

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 timeptlogo1 (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 0x821880x821B1 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 12, 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.

It is resolvable: the UI quad's second vertex attribute is a k_8_8_8_8 colour (6@12 in the capture's attribute list), and the two elements rest at different fade alphas (back2eff5 ends at α=192, back2eff at α=255). Logging that colour would name them apart — a small addition to the hook, and worth doing before any further ordering hypothesis rests on that pair.