Files
Sylpheed/docs/re/ui-title-build-map.md
sylph-decoder e94e203a71 re: the A-press fault is SOLVED -- Xenia swallows input, the guest pump is unbounded
The 326 MB log from the failing run was still on disk, so this needed no
emulator time at all.

Mechanism: Xenia's XamInputGetKeystrokeEx returns X_ERROR_SUCCESS with a zeroed
keystroke on every call while a XAM dialog is up (xam_input.cc:197, upstream
Canary). The game's keystroke pump -- sub_82457038, read out of the image -- is
an unbounded 'while (GetKeystrokeEx() == SUCCESS) queue.push_back()'. It queued
8 388 608 empty keystrokes, grew its vector to 64 MB, asked for 128 MB, got a
failed allocation back unchecked, and copied off the top of the guest stack.

Two independent instruments agree to within 7: the Canary counter's last report
before the crash says 8 388 601 swallowed calls; the crash dump's r29 says the
vector held 8 388 608. The reporting granularity is 600.

Retracts this page's own 'r9 is a wild pointer above 4 GB'. Xenia prints
si_addr, a host address; the guest is mapped at 0x100000000, so the fault
address is guest 0x701D0000 -- which is exactly r9 in the register dump.

Also refutes nothing of the port's, but answers its ask #3: the two press-a
captures are different frames (40.84 % of the band's pixels differ at the
best alignment, which has a sharp minimum), so its 0.301 % is not an
instrument floor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 06:44:49 +00:00

905 lines
44 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.
# Which `GP_TITLE` build is which screen — measured against the running game
**Status:**`CONFIRMED` for the four screens the boot path actually shows
(title art, `PRESS Ⓐ BUTTON`, main menu, `EXTRAS`); 🟡 `PROBABLE` for their
Japanese twins.
✅ **2026-08-29 — the two "unidentified `DELTASABER` plates" are the LOADING
SCREEN**, and the ❔ on them is withdrawn. See
[below](#-the-deltasaber-plates-are-the-loading-screen-and-there-are-two-of-them).
Answers [MISSION Q2](../port/MISSION.md). The previous statement — *"build 4
title, 5 main menu, 6/8/9 submenus"* — is **partly wrong** and is withdrawn:
build 8 is the **Japanese main menu**, not a submenu, and `GP_TITLE` holds
exactly **one** submenu (`EXTRAS`), in two languages.
## The archive is eight screens, each shipped twice
`GP_TITLE.pak` has 16 entries; `sylpheed-cli screen list` calls 12 of them
composable builds. The 16 fall into **eight pairs**, and each pair's two members
have near-identical sizes and name hashes that differ by a constant — one
character of the name apart:
| entry | hash | bytes | build idx | pair |
|---|---|---|---|---|
| 0 | `01a2db9c` | 483 958 | 0 | A |
| 1 | `0ff0b8a8` | 483 958 | 1 | A |
| 2 | `285d8849` | 267 014 | 2 | B |
| 3 | `369773cb` | 267 014 | 3 | B |
| 4 | `a60fcb85` | 12 278 666 | 4 | C |
| 7 | `b483e6e6` | 13 363 328 | 7 | C |
| 5 | `a715f485` | 6 977 437 | 5 | D |
| 8 | `b58a0fe6` | 6 931 653 | 8 | D |
| 6 | `a81c1d85` | 6 549 126 | 6 | E |
| 9 | `b69038e6` | 6 548 438 | 9 | E |
| 10 | `cdba806e` | 426 473 | — | F |
| 13 | `db2ea1f8` | 423 333 | — | F |
| 11 | `cec0a96e` | 999 643 | — | G |
| 14 | `dc34caf8` | 999 643 | — | G |
| 12 | `cf2a8ccd` | 1 774 639 | 10 | H |
| 15 | `dd56d99b` | 1 774 639 | 11 | H |
For the three pairs whose members differ visibly — C, D, E — **the difference is
English versus Japanese**: build 7 is the title art with the katakana subtitle
プロジェクト シルフィード, build 8 the main menu reading 新規 / ロード /
チュートリアル / オプション / エクストラ, build 9 the `EXTRAS` submenu as
エクストラ. That is the whole of the language split we can see. In pairs A, B and
H the two members render **byte-identical PNGs** — the archive still carries two
copies, but the artwork does not change with the language.
Pairs F and G are the four entries `screen list` does *not* classify as builds by
default. **They are the developer splash**, and they render — see below.
## The map
Contact sheet of every render:
[`captures/title-builds/title-build-contact-sheet.png`](captures/title-builds/title-build-contact-sheet.png).
| build | what it is | confirmed how |
|---|---|---|
| 0, 1 | ✅ **the LOADING screen**, plain — a `DELTASABER / SYLPHEED A.I.` plate low-left on black, no background. 7 elements, all named `pgloading_*` | ✅ **decoded from the declaration table**, not observed running |
| **2**, 3 | the `PRESS Ⓐ BUTTON` plate — **an overlay build of its own**, not a state of build 4 | ✅ seen composited over build 4 on the live title, at the same rect our render puts it |
| **4** | title art, English (`PROJECT SYLPHEED`, ™, `(C)2006,2007 SQUARE ENIX`) | ✅ [`live-title-press-a.png`](captures/title-builds/live-title-press-a.png) |
| 7 | the same, Japanese | 🟡 renders as the JP twin of build 4; the container runs an English locale, so it was not seen |
| **5** | main menu, English: NEW GAME / LOAD GAME / TUTORIAL / OPTIONS / EXTRAS, footer `Ⓐ : OK` | ✅ [`live-main-menu.png`](captures/title-builds/live-main-menu.png) — element for element |
| 8 | the same, Japanese | 🟡 as above |
| **6** | the `EXTRAS` submenu, English: MISSION SELECT / MOVIE THEATER / BACK, footer `Ⓐ : OK Ⓑ : Back` | ✅ [`live-extras.png`](captures/title-builds/live-extras.png) |
| 9 | the same, Japanese | 🟡 as above |
| 10, 11 | ✅ **the LOADING screen**, dressed — the same plate over a dark circuit-line background. 10 elements, the same seven plus `pgloading_eff00.prm`, `pgloading_loop5.rat` and `pgloading_baseeff.t32` | ✅ **decoded from the declaration table**, not observed running |
## ✅ The `DELTASABER` plates are the LOADING screen, and there are two of them
**2026-08-29. Decoded — the authors' own element names, out of the declaration
table.** No renderer, no capture, no inference. Every element of all four
bundles is prefixed `pgloading_`:
| build | entry | elements |
|---|---|---|
| 0, 1 | 0, 1 | `pgloading_loop1.rat` `pgloading_loop3.rat` `pgloading_str.t32` `pgloading_line.t32` `pgloading_loop4.rat` `pgloading_eff01.t32` `pgloading_eff02.t32` |
| 10, 11 | 12, 15 | the same seven, plus `pgloading_eff00.prm` `pgloading_loop5.rat` `pgloading_baseeff.t32` |
Reproduce:
```bash
sylpheed-cli screen info --build 0 "$SYLPHEED_DISC/dat/GP_TITLE.pak"
sylpheed-cli screen info --build 10 "$SYLPHEED_DISC/dat/GP_TITLE.pak"
```
`DELTASABER / SYLPHEED A.I.` is the artwork on `pgloading_str.t32` — the loading
screen's caption, not the screen's identity. Reading the picture named the plate;
reading the file names the screen.
⚠️ This also removes the reason the pair was open. The previous row said *"never
seen in the boot path, the main menu, `EXTRAS` or `MISSION SELECT`… a mission
load is the remaining candidate"*. It is a **loading** screen: it is not supposed
to appear on any of those, and the remaining candidate was right.
### 🟡 Which is `LOADING` and which is `LOADING2` — the executable names five
`sub_821C4EB0` builds `GamePart_Title` and asks its table for five sub-entries in
this order, one `bl sub_821CEDF8` each, setting an error flag on any failure:
| call site | string | VA |
|---|---|---|
| `0x821C503C` | `TITLE_SCREEN` | `0x820A3D3C` |
| `0x821C5068` | `BUTTON` | `0x820A339C` |
| `0x821C5090` | `TITLE_MENU` | `0x820A3D30` |
| `0x821C50BC` | `LOADING` | `0x820A214C` |
| `0x821C50E4` | `LOADING2` | `0x820A3D24` |
**Checked against the image, not just the database**`/image/sylpheed.pe` at
`0x821C503C`, `0x821C5090`, `0x821C50BC`, `0x821C50E4` reads `38aa3d3c`,
`3baa3d30`, `38aa214c`, `38aa3d24`, exactly the `addi rX, r10, <lo16>` the
database shows, with `r10 = 0x820A0000` set two instructions earlier.
That is five named title-side screens, and `BUTTON` is what the `PRESS Ⓐ BUTTON`
overlay would be called — which the corpus had already isolated as a build of its
own (pair B) on capture evidence alone.
🟡 **Two loading screens named, two loading bundles found — but nothing observed
maps one to the other.** `LOADING` is the 7-element plain plate and `LOADING2`
the 10-element dressed one *if* the suffix means "the second, richer variant",
and that is a guess about a name. The port should treat the pairing as
**undecided** and not carry either name into an asset path.
Also note the lookup is **not** by pak TOC hash: at `0x821C5118``0x821C512C` the
game hashes `BASE_INFO` and `TITLE_MENU` with `sub_82455C78` (the same name-hash
[`hash.rs`](../../crates/sylpheed-formats/src/hash.rs) implements) and splices
the two 32-bit results into one 64-bit key. So these strings key a **sub-table
inside the GamePart's own record**, not an archive entry — which is why
`pak list` resolves none of `GP_TITLE`'s 16 names.
## 🟡 Which member of each pair is English — the archive is packed in two halves
The port asked which member of pairs A, B and H is which locale, since those
three render byte-identically and no capture can tell them apart. The **data
segment** can:
| | entries | data offset |
|---|---|---|
| first half | 0, 2, 4, 5, 6, 10, 11, 12 | 0 … 507 904 |
| second half | 1, 3, 7, 8, 9, 13, 14, 15 | 6 078 464 … 10 868 736 |
Every one of the eight pairs has **exactly one member in each half**, and in all
three pairs whose language is visible — C (4/7), D (5/8), E (6/9) — the English
build is the one in the **first** half. `GP_TITLE.p00` is one locale's eight
bundles followed by the other's; the TOC interleaves them only because it is
sorted by name hash.
So: **first half = English**, i.e. builds 0, 2, 4, 5, 6, 10 are English and 1, 3,
7, 8, 9, 11 are Japanese.
⚠️ 🟡 not ✅. The rule is 8/8 structurally consistent and 3/3 where it can be
checked, but the three pairs it is *used* for are exactly the three it cannot be
checked on. Reach of the negative: nothing in the bundle bytes themselves — the
header, the declaration table, the element names — differs between the twins of
pairs A, B and H at all; they render byte-identical PNGs. If a locale marker
exists it is not in the bundle.
## ✅ The splash — the four "non-build" entries, rendered
`screen list --all` widens the enumeration to every composable bundle and shows
all **16** entries. The four that the default listing drops are the two halves of
the developer splash, each shipped twice:
| entry | elements / sprites | what it is |
|---|---|---|
| **10, 13** | 3 / 2 | the white **`SQUARE ENIX`** publisher logo |
| **11, 14** | 7 / 6 | **`GAME ARTS` / `SETA` / `studio anima`** — the developer logos |
[`splash-entries-rendered.png`](captures/title-builds/splash-entries-rendered.png).
Rendered with `screen render --all --primitives --black`. Entry 11's seven
elements are the three logos, their three `_eff` glows and the
`palogo_eff0.prm` black backdrop — exactly the composition
[`structures/ui-paint-order-key.md`](structures/ui-paint-order-key.md) measured
for the splash.
### ✅ Confirmed against the running game — 2026-08-28
The 🟡 above ("no framebuffer capture to diff against") is closed. Recording the
boot from the moment the window appears, at 2 fps, catches the splash before the
movie:
[`live-splash-publisher.png`](captures/title-builds/live-splash-publisher.png),
[`live-splash-developer.png`](captures/title-builds/live-splash-developer.png).
Edge-correlated against the renders, **with the other half as a negative
control**:
| capture | vs entry 10 | vs entry 11 |
|---|---|---|
| publisher frame (t ≈ 2.0 s) | **0.9146** @ (0,0) | 0.027 ✗ |
| developer frame (t ≈ 6.0 s) | 0.033 ✗ | **0.9792** @ (0,0) |
Both halves match their own render at zero shift and are firmly rejected by the
other. The `13`/`14` twins score 0.876 / 0.966 — near-identical artwork, so this
test cannot tell a pair apart, only a screen from a different screen.
### ✅ The splash's timing is DECODED, and the capture confirms it
Re-recorded at **10 fps** (the 2 fps pass below was ±0.5 s and could not see
ramps at all). The splash **fades, both ways** — it does not cut — and the
bundle's own keyframes say so:
```
$ sylpheed-cli screen info --all --build 10 GP_TITLE.pak
1 palogo_sqex.t32 7 kf [15 30 235 239 251 255 -]
2 palogo_sqex_eff.t32 4 kf [15 30 45 -] (the glow, child of 1)
$ ... --build 11
1 palogo_gamearts.t32 7 kf [15 30 190 194 206 210 -] (seta, anima identical)
2 palogo_gamearts_eff 4 kf [15 30 45 -]
```
Under Q1's `1 unit = 1/60 s`:
| | declared | measured at 10 fps |
|---|---|---|
| `SQUARE ENIX` ramp in | `15 → 30` = 0.25 s | rise 0.4 → 0.8 s |
| `SQUARE ENIX` hold | `30 → 235` = **3.42 s** | ≈ 3.5 s (0.8 → 4.3 s) |
| `SQUARE ENIX` fade out | `235 → 255` = **0.33 s** | **≈ 0.3 s** (4.4 → 4.7 s) |
| developer hold | `30 → 190` = **2.67 s** | ≈ 2.4 s (5.6 → 8.0 s) |
| developer fade out | `190 → 210` = **0.33 s** | **≈ 0.3 s** (8.1 → 8.4 s) |
The offset between declared and measured start is ≈ 0.35 s, which is simply that
the recording's `t = 0` is when the *window* appears, not when the guest starts
drawing. Everything downstream of that lines up.
**The overshoot is the glow.** The measured rise peaks (6.21) at 0.8 s and settles
back (5.39) by 1.1 s, which looks like a bloom. It is the `_eff` child element:
its keyframes are `15 → 30 → 45`, so it ramps in *after* the logo and then back
down, while the logo itself holds. Decoded, not a rendering artifact.
So the port can **read** the splash's timing off the disc rather than author it —
the first screen's animation is not a measurement it has to trust.
### The wall-clock sequence, for orientation
| | |
|---|---|
| `SQUARE ENIX` on screen | **≈ 0.5 → 4.0 s** after the window appears |
| black | ≈ 4.5 s |
| `GAME ARTS` / `SETA` / `studio anima` | **≈ 5.0 → 7.5 s** |
| black | ≈ 9.0 s |
| `ADV.wmv` begins | **≈ 9.5 s** |
⚠️ The frames at ≈ 9.512.5 s show `SQUARE ENIX` again in **cyan**. That is not a
third splash — it is the intro movie's own opening, which the milestone-2 notes
describe as "white SQUARE ENIX + cyan glow + red diamonds". A capture-only reading
would have recorded a third logo screen that does not exist.
**So the first of the five screens now has a reference composite, a framebuffer
capture, and its on-screen durations.**
**Reach of the two negatives.** Builds 0/1 and 10/11 were looked for in: the whole
boot sequence (a 5 s-cadence filmstrip from launch to the title, ~190 s), the
title, the main menu, the `EXTRAS` submenu, the `LOAD GAME` slot list, the
transition into `MISSION SELECT` (a 40-frame burst), and the attract cycle. They
appear in none of them. The obvious remaining candidate is a *long* load — a
mission launch — which is out of this objective's scope; they are most likely a
loading/AI-chatter plate. That is a hypothesis, not a result.
## Three things the captures settle beyond the map
**The live title is two builds composited.** Build 4 draws the art; build 2 draws
`PRESS Ⓐ BUTTON` on top, and it **fades in a beat later** — a screenshot taken
2.5 s after arriving at the title has the art and no plate, one taken ~1 s later
has both. The port must treat the plate as its own timed element.
**The attract-loop title carries the plate too.** After ~810 s idle the title
fades to black, a full-motion video plays for ~85 s, and the title comes back —
*with* `PRESS Ⓐ BUTTON`
([`live-attract-title-press-a-band.png`](captures/title-builds/live-attract-title-press-a-band.png)).
This is consistent with, and adds nothing to, the draw-quad comparison in
[`canary-scripted-input-traps.md`](canary-scripted-input-traps.md): the plate is
not the tell that distinguishes the boot title from the attract title.
### ✅ Refutation attempt (2026-08-30) — the two `press-a` captures are DIFFERENT FRAMES
The port asked whether `live-title-press-a.png` and
`live-attract-title-press-a-band.png` were captured the same way, because it
measures **0.301 %** between them and — if they were the same frame — that number
would be a **floor under every full-frame comparison in this corpus**. That is the
expensive reading, so it is the one worth attacking.
**The attempt to confirm it failed; they are two different moments.** The band is
1279×120 and the full capture 1279×675, so the band was slid down every row of the
full frame and scored by mean |Δ|. The alignment is unambiguous — a sharp minimum,
which is the instrument's own control:
| y offset | mean abs Δ |
|---|---|
| 519 | 7.887 |
| **520** | **4.016** |
| 521 | 7.872 |
At that best alignment the two disagree on **40.84 %** of the band's pixels
(32.56 % by more than 1), mean |Δ| **4.02**, max **169**. A crop of the same frame
would be zero. So the band is a different instant of a moving screen — the movie
still running behind the plate — and the port's own preferred reading is right.
**So 0.301 % is not an instrument floor**, and no full-frame figure in this corpus
needs to be discounted by it. ⚠️ Reach: this says the two *captures* differ; it
says nothing about whether the two *configurations* differ, because a moving
background makes that unanswerable from these two images. A configuration
comparison needs two captures of a static screen taken deliberately.
**`EXTRAS` is the only main-menu destination inside `GP_TITLE`.** Ⓐ on `EXTRAS`
opens build 6 — measured. The other four destinations leave the archive: Ⓐ on
`LOAD GAME` opened a `LOAD GAME` slot list, and Ⓐ on `MISSION SELECT` inside
`EXTRAS` opened a `MISSION SELECT` screen, neither of which is a `GP_TITLE`
build. `dat/` carries `GP_SAVE_LOAD.pak`, `GP_TUTORIAL.pak`, `GP_OPTIONS.pak`,
`GP_MISSION_SELECT.pak` and `GP_MOVIE_THEATER.pak`; **that those are the archives
behind the other four buttons is an inference from the names, not a measurement.**
## How to reproduce
```bash
sylpheed-cli screen list "$SYLPHEED_DISC/dat/GP_TITLE.pak"
sylpheed-cli screen render --build 6 "$SYLPHEED_DISC/dat/GP_TITLE.pak" /tmp/b6.png
tools/re-capture/boot_menu.sh q2 # boots to the main menu, cursor on NEW GAME
tools/re-capture/pad.py dpad down 0.35 # x4 -> EXTRAS
tools/re-capture/pad.py tap A 0.30
```
Mind the d-pad hold — see [`METHOD.md`](METHOD.md).
## ✅ The title is not a still image — it loops, ≈ 2.2 s
Recording the title at 10 fps for 22 s shows the screen **never settles**. After
the build-in it oscillates by about ±0.5 in mean luminance, continuously:
```
peaks at 5.6 7.8 10.0 12.7 15.2 17.1 19.2 21.3 s
intervals 2.2 2.2 2.7 2.5 1.9 2.1 2.1
mean 2.24 s
```
**measured**, n = 7 intervals, spread 1.92.7 s — peak-picking a low-amplitude
signal is coarse, so read it as **≈ 2.2 s ± 0.4**, not a precise period.
The mechanism is already decoded: build 4 declares **`ptloop01.rat` /
`ptloop02.rat`**, and `loop*.rat` is a **looping sprite animation** rather than a
composition ([`INDEX.md`](INDEX.md), UI screen layout row). So the title carries a
looping element by construction; what is new is that it runs at ≈ 2.2 s and never
stops.
### ✅ The loop records ARE decoded — and they are not what I measured
`ptloop01.rat` is an **`opt `-linked leaf record**: in build 4 the chunk `opt `
(size `0x0c`) carries the name, immediately followed by a `RATC` blob at
**`0xbb5966`** (`ptloop02.rat` the same at `0xbb5a82`). Its pivot fields read
`200`/`90`, matching `screen info`'s `pivot (200,90)` — the right blob.
**The keyframes use the ordinary 40-byte block layout**, starting at `+0x68`:
| | `ptloop01``pteff03.t32` | `ptloop02``pteff03a.t32` |
|---|---|---|
| kf0 | `t=150` x=**639** α=`ff` | `t=150` x=**1721** α=`00` |
| kf1 | `t=540` x=39 α=`80` | `t=630` x=1111 α=`80` |
| kf2 | `t=600` x=**1521** α=`ff` | `t=720` x=**839** α=`ff` |
| scale | 100 × 600 | 100 × 800 |
| span | 150 → 600 = **450 units = 7.5 s** | 150 → 720 = **570 units = 9.5 s** |
Y is constant at 270 and X runs off one edge to the other, so these are
**horizontal light sweeps**`loop01` left → right, `loop02` right → left.
### 🔴 Two things I wrote last iteration are wrong
**1. "A leaf record's keyframes are not in the build's 40-byte layout."**
Withdrawn — they are, exactly. The scan that "found nothing" demanded **29**
strictly-increasing times because I read the word at `+0x004` (`0x001e0000`) as a
keyframe count. These records hold **three** keyframes. A filter that hard-codes
the expected count rejects the right structure; whatever the `30` is, it is not
the number of keyframes here.
**2. "The ≈ 2.2 s oscillation is the `ptloop` elements."** Withdrawn — I asserted
the link because build 4 declares those elements, not because anything showed it.
The decoded sweeps run **7.5 s and 9.5 s**. A 22 s capture would show ~3 peaks
from a 7.5 s cycle; it showed **8**. So the loops are *not* what the oscillation
measured.
### ✅ Identified: it is the `PRESS Ⓐ BUTTON` plate pulsing
A per-tile amplitude map over the capture (8 × 6 grid, 18 s after build-in)
localises the 2.3 s period precisely:
| region | sd | dominant lag |
|---|---|---|
| band x ≈ 318954, y ≈ 560672 | **7.65** | **2.3 s** |
| wordmark centre | 0.06 | — |
| bottom-right corner | 0.003 | — |
That band is the `PRESS Ⓐ BUTTON` plate's rest position (`ptbtn00.rat`, rest
`(383,550)`, pivot `(256,25)`). It is **build 2**, composited over the title — not
build 4.
Decoding `ptbtn00f.rat`, the plate's highlight variant, gives the pulse directly:
| kf | t | alpha |
|---|---|---|
| 0 | 6 | `0x00` |
| 1 | 29 | `0x06` |
| 2 | 35 | `0x4a` |
| 3 | 50 | `0x50` |
| 4 | 58 | `0x50` (hold) |
| 5 | 97 | `0x4a` |
| 6 | 105 | `0x06` |
A glow that fades in to `0x50` and back out — exactly a "press start" pulse.
There is an **eighth** keyframe: fade `0x00ffffff` at the same position — the glow
returns to **fully transparent**, so this is a closed cycle, not a one-shot ramp.
🟡 **The cycle length is still not readable — and now that is an observation.**
The eighth block's time slot contains the four bytes `end `, the record's ASCII
terminator: the record simply stops there and the value does not exist. So the
corpus's "a group's last block has no time of its own" rule holds here in a second
form — not the next group's index, but the chunk terminator.
Declared span is **≥ 105 units = 1.75 s**; measured **≈ 2.3 s** (≈ 138 units),
which would need a final step of ≈ 33 units. **That 33 is fitted to the
measurement, not read from the file**, and is recorded only so nobody re-derives
it as if it were a decode.
**The word at `+0x004` is not a keyframe count.** It reads `0x003c0000` (60)
here with 8 keyframes, and `0x001e0000` (30) in the loop records with 3. Whatever
it is, it is not the count, and it is not decoded.
### ❔ The declared 4.08 s build-in was not tested
That was the intent of this recording and it did not work. The title was reached
by **skipping the movie with Ⓐ**, which cuts to black and brings the title up on a
path that may not be the normal one; and the visible rise (≈ 2.7 s, from t ≈ 0.8
to 3.5) is a *luminance* curve, which
[`screen-transitions.md`](screen-transitions.md) already establishes is **not**
the fade quad's ramp. So ≈ 2.7 s neither confirms nor contradicts build 4's
declared `16 → 261` (4.08 s); the two are not measuring the same thing.
Testing it properly needs the title reached **without** a skip, and a way to
separate the quad from the elements — neither of which this recording had.
## ✅ For the port: build 4 alone, and groups HOLD rather than loop
Both answers the port agent asked for, in one recording.
### The capture — the title with no `PRESS Ⓐ` plate over it
Ⓑ from the main menu returns to the title and the plate fades in **a beat later**,
which opens a clean window. Recorded at 20 fps from the press:
| | |
|---|---|
| title art appears | t ≈ 1.10 s |
| builds in | 1.10 → 3.70 s (mean 41.8 → 63.2) |
| **settled, still no plate** | **3.70 → 5.00 s** |
| plate arrives | t ≈ 5.10 s (band jumps 282 → 3 755 bright px) |
[`live-title-build4-no-plate.png`](captures/title-builds/live-title-build4-no-plate.png)
is t = 4.0 s — build 4, settled, unobstructed. That is the reference for the
washed-out cyan glow slab the port reports drawing and the game not having.
### ✅ A keyframe group HOLDS at its last keyframe — it does not loop
This follows from the decoded sweeps plus a measurement, and the two agree:
* `ptloop01.rat`'s final keyframe puts `pteff03.t32` at **x = 1521** and
`ptloop02.rat`'s puts `pteff03a.t32` at **x = 839** — both **off-screen** on a
1280-wide design. A group that holds therefore parks both sweep sprites out of
view and nothing moves after the build-in.
* Measured: over 18 s of settled title the centre tiles sit at **sd ≤ 0.01**
(per-tile map above). A looping group would recross the screen every **7.5 s**
and be unmissable.
So the name `loop*.rat` is misleading — in this build the records animate **once**
during the build-in and then rest off-screen. 🟡 This is about *these* groups on
*this* screen; nothing here says no group anywhere loops.
## ✅ The title's settled pose is `rest`, and the "washed-out slab" is a MISSING dim
The port agent could not decide whether `rest` or its played-out timeline is the
title's arrived pose (they disagree by 142247/255), and separately reported a
*"washed-out cyan glow slab over the title logo that the running game doesn't
have"*. The plate-free capture settles both.
`sylpheed-cli screen render --build 4 --black`, edge-correlated against
[`live-title-build4-no-plate.png`](captures/title-builds/live-title-build4-no-plate.png)
— a capture of the real screen, so this is an independent oracle and not one
renderer checking another:
> **0.9163 at shift (0,0)**, and per band 0.92 / 0.78 / 0.93.
**Geometry is right**, so `rest` is the arrived pose for the title.
### The slab is the 25 % dim, absent — not a glow, present
`rest` alone is **uniformly too bright**, and the excess leans cyan:
| render | mean (render capture) | R | G | B |
|---|---|---|---|---|
| `--black` | **+13.14** | +12.35 | +13.58 | +13.48 |
| `--black --primitives` | **+0.55** | +1.30 | +0.89 | 0.54 |
Drawing the `.prm` primitives collapses the excess to nothing. The element is
**`pteff02.prm`**, the 25 % dim quad (build 4, rest at `t=46`, fade `0x40` = 64 =
25 %) — and `--primitives` is **off by default**.
So the "washed-out cyan slab" is not something being drawn that shouldn't be. It
is the **dim that should be drawn and isn't**: without it every pixel sits ~13
high, and because the title art is blue-dominant the shortfall reads as a cyan
wash. ⚠️ Any consumer of `screen render` that omits `--primitives` on this screen
gets it.
### 🟡 The residual: right on average, not right per pixel
With primitives the *mean* is essentially exact (+0.55) but pixel agreement is
slightly **worse** — edge-correlation 0.9163 → **0.9066**, and pixels differing by
> 20 rise 108 051 → 162 636. So the dim's average contribution is right while its
application is not exactly the game's (blend mode or per-region alpha). Not
diagnosed.
### 🔴 The real defect: the logo swoosh is drawn white and thick
With the dim in place, the residual is **not uniform** — it is a dark patch beside
a bright one in one band:
```
row1 (y 112-225): -2.0 +3.0 -8.8 -38.6 -17.6 +16.2 +33.8 +24.4
```
Cropping that band from capture and render
([`title-swoosh-capture-vs-render.png`](captures/title-builds/title-swoosh-capture-vs-render.png))
shows it plainly: the game draws the logo's `Z` **swoosh thin, with a
pink/magenta edge**; our render draws it **thick and solid white**. Too bright to
its right, too dark where the game's thin stroke actually falls.
**This — not the missing dim — is the port agent's "washed-out slab over the title
logo".** The dim explains a *uniform* +13; the slab is this.
The elements are `ptlogo_back2.t32` (rest `(71,126)`, pivot `(500,117)` — a
1000 × 234 diagonal), its glow `ptlogo_back2eff.t32`, and the five
`ptlogo_back2eff1…5` segments at y ≈ 117194 — exactly the band that disagrees.
🟡 **A connection worth chasing, not a diagnosis.** Those five segments are the
group whose **paint-order tie-break is the known unsolved residual**
([`structures/ui-paint-order-key.md`](structures/ui-paint-order-key.md)): they
share key `0x8083`, the game paints them `14,15,18,16,17`, the stable sort paints
`14,15,16,17,18`, and the measured cost is "`ptlogo_back2eff5` against `eff3`
(22 568 px) and against `eff4` (32 395 px)". **Same screen, same elements.**
❔ But a blend-order swap is a poor explanation for *white instead of pink* — that
looks like a tint or blend-mode problem, so I would expect a second cause.
### 🟡 A quantified cause for the GEOMETRY: the pivot belongs to the other language
The fade and tint fields are **not** the culprit — every keyframe of every swoosh
element carries `0x??ffffff`, white RGB with only alpha varying, and no tint is
anything but `0xffffffff`. Nor is the texture pink: `ptlogo_back2.t32` decodes
**blue**-leaning (175, 174, 198) and its glow warm (255, 253, 234).
What *is* wrong is the **pivot**. Checking every `GP_TITLE` element whose texture
we decode (109 of 178) against the rule *pivot = texture ÷ 2*:
| | |
|---|---|
| exact | 17 |
| off by ≤ 1 px (rounding) | 54 |
| off by ≤ 8 px | 14 |
| **off by > 8 px** | **24** |
and the gross ones are **concentrated on the title's logo elements**:
```
a60fcb85 ptlogo_back2.t32 pivot (500,117) texture 1118x262 -> implies (559,131) off 59.0
a60fcb85 ptlogo_back2eff.t32 pivot (507,126) texture 1133x280 -> implies (566,140) off 59.5
a60fcb85 ptlogo2.t32 pivot (449, 46) texture 992x104 -> implies (496, 52) off 47.0
b483e6e6 ptlogo1.t32 pivot (451, 50) texture 822x100 -> implies (411, 50) off 40.0
```
**Why:** build 4's `ptlogo_back2` pivot `(500,117)` is *exactly* half of the
**Japanese** texture (1000 × 234), not its own English one (1118 × 262). The
layout record is authored once and shared while the `.t32` sprites are swapped per
language — the effect
[`structures/ui-rat-layout.md`](structures/ui-rat-layout.md) warns about in
general, here measured on the screen where the render disagrees with the game.
Note it cuts **both ways**: the Japanese build's `ptlogo1` is off by 40 px too.
A 59 px offset on a 1118 px sprite is the right order to produce "too thick and
extending too far right", which is what the crop shows.
### 🔴 …and that candidate is refuted
Checked, as promised. `ui_layout`'s `blit` takes the drawn size from the
**texture** (`img.width`/`img.height`), and uses the pivot only for the
scale anchor:
```rust
let ox = kf.x - (pivot_x as i32 * (sx_pct as i32 - 100)) / 100;
```
At `sx_pct == 100` that term is **zero**. And **every one of the seven swoosh
elements is scale `(100,100)` at every keyframe** — `ptlogo_back2`,
`ptlogo_back2eff` and `ptlogo_back2eff1…5` all report a single scale. So the
pivot mismatch, real as it is in the data, **cannot** move or resize the swoosh in
our render.
⚠️ It is not harmless everywhere: `ptlogo1`/`ptlogo2` run scales
`100 → 101 → 103 → 112 → 150` during the build-in, so there the wrong pivot *does*
displace them — during the animation, not at rest.
### 🟡 So the swoosh defect is a BLEND problem, by elimination
Position and size are the texture's own and are right; fade is white-with-alpha;
tint is white; the texture is blue-leaning, not pink. What remains is how the
seven overlapping sprites are combined — `ptlogo_back2` is only **5.4 % opaque**,
its glow 10.3 %, the five `eff` segments 1023 %, all white or warm. Stacked with
plain alpha-over they saturate toward opaque white, which is what we draw and
would read as "thicker" beside the game's thin coloured stroke. ❔ Not diagnosed —
no blend mode has been identified in the data.
### 🔴 Refuted on the way
The obvious guess — *our dim is applied over the whole frame instead of beneath
the UI, where its layer key puts it* — is **wrong**. If it were, the logo would
render too dark; it reads **+2.36** against a background of **0.74**. The
compositor honours the paint order here.
## 🔴 The swoosh is not displaced either — and the residual is restated
Two more candidates eliminated, and the residual is smaller than earlier sections
implied.
**Not a displacement.** Shifting the render's swoosh band over ±80 px × ±8 px and
re-correlating peaks **sharply at (0, 0)** — 0.7342, falling to 0.22 at ±24 px and
0.10 at ±48. The swoosh is where it should be.
**Not additive blending.** See
[`structures/ui-paint-order-key.md`](structures/ui-paint-order-key.md): every
measure worsens.
**And the residual, restated with the current best render** (`--black
--primitives`, `rest()` fixed):
| | |
|---|---|
| whole-frame mean diff | **+0.55** |
| swoosh-band mean diff | **+1.83** |
| swoosh-band edge-correlation | **0.6971** (vs ≈ 0.92 frame-wide) |
⚠️ Earlier sections quoted band tiles at **+16 … +34**. Those were measured on a
render **without** `--primitives`. With the dim drawn the band's *average* is
nearly right; what is wrong is its **structure** — the tiles run 38.6 then +33.8
across the band and cancel. So the defect is neither brightness, nor position, nor
additive blending: it is a shape difference in one band, and it is **not
diagnosed**. Six candidates eliminated: pivot (twice — inert at scale 100, and no
measured displacement), `fade`, `tint`, texture colour, additive blend.
### 🔴 The capture IS settled — my own caveat, tested and withdrawn
The previous version of this section worried that the plate-free capture, at
t ≈ 4.0 s, might be too early: elements have keyframes to t = 600 (10 s), and
"settled" had been judged from mean luminance, which cannot see a thin sprite
still moving.
The plate sits at y ≈ 550600, **disjoint** from the swoosh band at y 112225, so
a *late* capture works even with the plate present. Correlating the render's band
against the same band at several ages of the screen:
| capture | band edge-corr | band mean |
|---|---|---|
| t = 4.0 s (plate-free) | **0.7342** | 126.63 |
| t = 6.0 s | 0.7351 | 126.97 |
| t = 12.0 s | 0.7352 | 126.98 |
| t = 18.0 s | 0.7353 | 126.98 |
| t = 21.5 s | 0.7353 | 126.98 |
Identical to within 0.001 over 17.5 s. **The band is settled by t = 4.0 s**, the
capture handed to the port agent is sound, and the caveat is withdrawn. It also
corroborates that the groups **hold**: nothing crosses that band in 22 s.
## The swoosh — SOLVED by draw capture (see below); the elimination trail kept
Seven candidates eliminated, none confirmed:
| candidate | verdict |
|---|---|
| element **pivot** (off by 59 px, authored for the other language) | inert — `blit` sizes from the texture and applies the pivot only when scale ≠ 100; all seven elements are scale `(100,100)` |
| **displacement** | none — shifting ±80 × ±8 px peaks sharply at (0,0), 0.734 → 0.22 at ±24 px |
| **`fade`** | `0x??ffffff` on every keyframe: white RGB, alpha only |
| **`tint`** | `0xffffffff` throughout |
| **texture colour** | blue-leaning (175,174,198); the glow warm — neither pink |
| **additive blend** via `T8aD +0x04` bit `0x02` | refuted: every measure worsens |
| **capture not settled** | refuted above |
The residual is stable and modest: band mean **+1.83**, band edge-correlation
**0.69710.735** against ≈ 0.92 frame-wide. Real, persistent, and **not located in
any field this project can read from the disc**.
**Where a next attempt should start, and it is not another field** — but check
what the tool actually records first, because the obvious phrasing of this is
wrong.
⚠️ **The per-draw capture does NOT record blend state.** Reading
`command_processor.cc`, each captured draw carries: primitive type, index count,
index-buffer address, vertex- and pixel-shader `ucode_data_hash`, the pixel
shader's **texture bindings** (base, dimensions, format), and **vertex attribute
0 of binding 0**. There is no `RB_BLENDCONTROL` / `RB_COLORCONTROL` dump. An
earlier version of this section claimed the capture "reads the actual blend
state"; it does not.
So the route splits:
***Testable today, no code change** — whether the game passes a **vertex
colour** for those draws. The capture dumps vertex attributes, and a pink vertex
colour would explain white-versus-pink directly.
***Needs a Canary change** — the blend mode itself, which means adding an
`RB_BLENDCONTROL` dump to the same capture path.
Either way it is instrumentation of the running guest, not another field in the
file.
## ✅ SOLVED — the game draws the swoosh as ROTATED QUADS, which our blit cannot
The route named in the previous section was run: `--ui_draw_capture_frames=3`,
armed with F10 on the settled title.
[`title-draw-capture-vertex-colours.log`](captures/title-builds/title-draw-capture-vertex-colours.log)
is the capture, 22 draws over 3 frames.
### 🔴 First, the vertex-colour hypothesis dies
Every vertex colour in the entire capture is `<alpha>FFFFFF`**white RGB**, only
the alpha varying: `FFFFFFFF`, `C5FFFFFF`, `C3FFFFFF`, `B8FFFFFF`, `B6FFFFFF`,
`31FFFFFF`, `1EFFFFFF`. The game passes no colour. That was the eighth candidate.
### ✅ And the ninth is the answer — it is the GEOMETRY
Draw 2 submits **two parallelograms, neither axis-aligned**:
| quad | corners (NDC) | edge `v0→v1` | axis-aligned? |
|---|---|---|---|
| A | `(0.70,1.58) (1.24,1.02) (0.40,1.57) (0.14,1.02)` | `(0.54, 0.56)` | **no** |
| B | `(1.29,1.02) (0.85,1.81) (0.75,1.02) (0.31,1.81)` | `(0.44, 0.79)` | **no** |
Both verified parallelograms (opposite edges equal to 0.01), both **rotated**
roughly 45° and 61° — and both extending to `y = ±1.81`, well beyond the screen.
That is the diagonal `Z` stroke.
**Our compositor cannot draw that.** `ui_layout::blit` walks destination rows and
columns of an **axis-aligned rectangle** (`for row in 0..dh { for col in 0..dw`),
sampling the source by a straight ratio. It has no rotation. So the swoosh is
blitted upright where the game draws it skewed — which is exactly the observed
signature: **right on average (+1.83), right in position (peak at (0,0)), wrong in
structure (edge-corr 0.70)**, dark on one side of the true stroke and bright on
the other.
### ❔ And a real gap in the decoded format
The keyframe carries `fade`, `scale_x`, `scale_y`, `tint`, `x`, `y`, `time`
([`ui_layout.rs`](../../crates/sylpheed-formats/src/ui_layout.rs)) — **no
rotation**. So where the game's rotation comes from is **not decoded**: either a
field not yet identified, or the element is positioned by code rather than by its
keyframes. That is the open question this leaves.
⚠️ **The "pink versus white" reading is now suspect.** It was a visual comparison
of two differently-*shaped* renderings. Whether any colour difference survives
correct geometry is **untested**, and should be re-checked rather than carried
forward as a separate defect.
## 🟡 Three angle fields found — and they are NOT the title's rotation
The obvious place to look for the rotation was the keyframe block's three words
at `+4`, `+8`, `+12`, which `ui_layout.rs` documented as `0`.
**They are not zero.** Over **72 287** keyframe blocks disc-wide:
| word | non-zero | commonest values (signed) |
|---|---|---|
| `+4` | **4.81 %** | `180`, `180`, `22`, `90` |
| `+8` | **4.56 %** | `180`, `90`, `180`, `178` |
| `+12` | **15.82 %** | `90`, `90`, `120`, `58` |
Values clustering on ±180, ±90, 120 read as **degrees**, and three of them suggests
rotation about three axes. 🟡 That reading is **not tied to an observed rotation**
it is the shape of the numbers, nothing more. The doc comment is corrected either
way: "0 on every frame seen" was a sample artefact.
### ✅ They *do* explain this screen — the earlier negative was wrong about reach
~~**Every element of `GP_TITLE` build 4 has all three at zero** — checked element
by element. So the title's rotation comes from outside the keyframe data.~~
**Withdrawn (2026-08-28).** That check walked build 4's **top-level declaration
table**. The rotated quads belong to its two **nested leaf records**, and there
`+12` reads **30** and **45** — against a measured **+30.26°** and **45.28°**.
The bytes were read correctly; the *region* was too small. See
[`structures/ui-keyframe-rotation.md`](structures/ui-keyframe-rotation.md).
⚠️ **One thing I should not have stated flatly:** that the skewed draw *is* the
swoosh. It is the only skewed geometry in the capture and the swoosh is the only
diagonal element on the screen, so the inference is reasonable — but it was not
confirmed by matching the draw's texture or screen position to that element, and
should be.
## 🔴 The skewed draw is NOT the swoosh — my identification was wrong
Converting draw 2's quads from NDC to screen space settles it:
| quad | screen corners | bbox |
|---|---|---|
| A | (1088,209) (1434,7) (896,925) (550,727) | x 550…1434, **y 209…925** |
| B | (186,7) (96,292) (1120,727) (838,1012) | x 186…1120, **y 292…1012** |
These span the **full screen height and well beyond it**. The swoosh
(`ptlogo_back2`, rest `(71,126)`, 1118 × 262) is a **band** at y 126…360. Draw 2
is not it.
## ✅ It is the two `ptloop` sweeps — confirmed
The bounding box was the wrong measurement; the quads are rotated, so what
identifies them is their **edge lengths**:
| quad | size | rotation | centre |
|---|---|---|---|
| A | 400.1 × 1076.3 | **+30.26°** | (992.0, 359.1) |
| B | 400.2 × 1444.5 | **45.28°** | (467.2, 360.0) |
| quad | element | sprite × declared scale |
|---|---|---|
| A | `ptloop01.rat` | `pteff03.t32` 399×180 @ 100 %,**600 %** = 399 × **1080** |
| B | `ptloop02.rat` | `pteff03a.t32` 399×180 @ 100 %,**800 %** = 399 × **1440** |
Five independent agreements, listed in
[`structures/ui-keyframe-rotation.md`](structures/ui-keyframe-rotation.md): the
count (two quads, two `ptloop` elements), both widths (400 vs 399), **both
heights, which are different numbers that both land**, the direction each quad
moves between the capture's two frames (matching each record's own sweep
direction), and the vertex alphas falling inside the declared ramps.
The known-positives in the same capture pass the same test: draw 5 measures
915 × 115 (`ptlogo1.t32` 919×113), draw 7 measures 691 × 18 (`ptcopyright.t32`
694×20) and 512 × 50 (`ptbtn00.t32` 513×50).
### ✅ And "a keyframe group holds" survives
The consequence I flagged conditionally last iteration resolves the *other* way.
Our renderer parks these two sweeps at their final keyframe (`x = 1521` and
`839`, both off-screen); the game draws them across the screen. That is not a
contradiction — the capture caught them **mid-sweep**: the quad centres, x = 992
and x = 467, both fall inside the decoded `t = 150…600` / `150…720` travel, the
alphas are mid-ramp, and the sprites move in the decoded direction between
frames. The capture is of the **build-in**, not of the settled screen, and the
18 s stillness measurement (sd ≤ 0.01) still says the groups stop. No change to
[the holds-not-loops finding](#-a-keyframe-group-holds-at-its-last-keyframe--it-does-not-loop).
### What this costs
Five iterations of swoosh work — the additive-blend test, the pivot analysis, the
vertex-colour capture — were built on "the skewed draw is the swoosh", an
identification made by *elimination on one screen* and never checked against the
draw's own coordinates. The eliminations themselves stand (they were measured
against the capture, not against the identification), but the chain of reasoning
that pointed at `ptlogo_back2*` did not.
---
## 🟢 Refutation attempt, 2026-08-29 — "entries 6/9 are a three-button submenu". It SURVIVED.
The port reported `GP_TITLE` entries 6 and 9 as a **three-button** submenu
(`ptbtn11/12/13` at x = 532, y = 282 / 362 / 442). This page and HANDOFF call
6/9 `EXTRAS` and say nothing about a button count, and 18 elements looked like
too many for three buttons, so the claim was challenged.
**The challenge was wrong and the claim stands.** `screen info --all --build 6`:
```
ptframe3.t32 ptframe4.t32 ptbtn11.rat ptbtn12.rat ptbtn13.rat ptmsg2.t32
pteff20.t32 pteff21.t32 pteff22.t32 pteff23.t32 pttitle.t32 ptbase.t32
pteff05.t32 pteff02.prm ptloop01.rat ptloop02.rat pteff10.t32 pteff00.prm
```
Entry 9 is identical. Three `ptbtn*` elements, and the other fifteen are frame,
title, background and effect layers. Both statements hold at once: 6/9 **are**
`EXTRAS` — our composite of entry 6 correlates **+0.944** whole-frame with the
committed [`live-extras.png`](captures/title-builds/live-extras.png) — **and**
`EXTRAS` is a three-button screen. The corpus had never recorded its button
count; the port established it, and this page now does too.
⚠️ The challenge was raised from an element *count* without listing the elements,
when a one-line `screen info` was available. Recorded because the protocol's
adversarial duty is worth nothing if only the successful challenges get written
down.
## ✅ Entries 5 and 8 cannot be told apart by layout — only by pixels
Checked in the same pass, because it bears on which build is which screen state.
Entries 5 and 8 have **identical element lists** (`pteff00.prm ptbase pteff05
ptloop01 ptloop02 pteff02.prm ptframe1 ptframe2 pteff10 pteff12 ptbtn01…05
ptmsg`) and identical button placements. The EN/JP difference between the two
main-menu bundles lives in the **baked sprite pixels**, not in any layout field.
So the 🟡 rule that "the English member of a pair is the one in the first half of
the data segment" is not going to be replaced by a layout field for this pair —
separating 5 from 8 needs either the sprite images or a capture. The same is true
of 6 vs 9, whose element lists are also identical.