The rotated quads on the title screen come from the keyframe block after all. The earlier negative -- "every GP_TITLE build 4 element has all three angle words at zero" -- read the right bytes over too small a region: it walked the top-level declaration table, and the rotated elements are the nested leaf records ptloop01.rat / ptloop02.rat. Confirmed against the framebuffer rather than against our own renderer. The two records declare +12 = 30 and -45; the GPU capture submits their quads at +30.26 and -45.28 degrees -- magnitude and sign, two different values. Corroborated by shape in GP_BUNK 117ca14f, where +12 ramps 0 -> 360 with position, scale and alpha constant: a spin in place. Identifying which draw it was needed edge lengths, not bounding boxes: 400x1076 and 400x1444 against pteff03/pteff03a 399x180 at the elements' two different declared scales, 600% (1080) and 800% (1440). The same test names three known-positives in the capture (ptlogo1, ptcopyright, ptbtn00), so it passes its own control. Keyframe gains rotation_deg plus unknown_4/unknown_8, carried rather than dropped. NOT rendered -- ui_layout::blit is axis-aligned only, so the reference renderer and the port will both draw these upright until a rotating blit exists. The census tool ships with the trap that broke its first version: nested RATC blobs are not 4-byte aligned, so an aligned scan found 0/3 of its own control blocks and missed 16 341 blocks. Disc-wide +12 is non-zero in 14.50 % of 83 862 blocks. sylpheed-formats tests, SYLPHEED_DISC set: 131 passed, 0 failed across the 6 suites finished at commit time; the run had not yet completed.
739 lines
36 KiB
Markdown
739 lines
36 KiB
Markdown
# 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; ❔ open for the two `DELTASABER` plates.
|
||
|
||
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 | a `DELTASABER / SYLPHEED A.I.` plate low-left on black, no background | ❔ **not observed running.** Never seen in the boot path, the main menu, `EXTRAS` or `MISSION SELECT` |
|
||
| **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 same `DELTASABER` plate as build 0, over a dark circuit-line background | ❔ **not observed running** |
|
||
|
||
## ✅ 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.5–12.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 ~8–10 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.
|
||
|
||
**`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.9–2.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 ≈ 318–954, y ≈ 560–672 | **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 142–247/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 ≈ 117–194 — 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 10–23 %, 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 ≈ 550–600, **disjoint** from the swoosh band at y 112–225, 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.6971–0.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. |