Nothing here changes what a tool computes; it changes where tools look. - tools/re-capture: 33 censuses globbed /work/sylph_extract, a path that has existed nowhere since /work became a clone, so they matched nothing and printed empty results. They now resolve the disc through a new disc.py from $SYLPHEED_DISC and exit loudly without it (the #44 fix, generalised). Nine scripts that imported siblings from the retired Reborn checkout or an old session scratchpad now import from their own directory. unitgroup.py only needs the variable when --pak is not given. - sylpheed-xex: the loader only ever uses the XEX2 retail key. The dead devkit key and a doc comment claiming a devkit fallback that does not exist are gone; Project Sylpheed is a retail XEX2, so no XEX1 key either. - sylpheed-viewer: real_font_rasterizes looked for /tmp/sylph_extract and so always skipped. It reads $SYLPHEED_DISC now, and passes against the disc. - Comments and docs that named xenia-rs, the Reborn repository or /work/*.pe as places to look now name sylpheed.db, Canary's ppc_context.h and the flat .pe; docs/re/README.md no longer says the native Canary build does not run. Historical records keep their original paths: findings that were measured against /work/xenia-rs/sylpheed.db still say so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 `sylpheed.db` is in the repository root (`tools/zq.py`).
|
||
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.
|