Two checks on last iteration's "sequential, not simultaneous" reading. First, the phases really are disjoint. If glows and logos ever shared a frame the claim would be wrong. Across all 235 captured frames the count of frames containing both is ZERO, and the switch is a single clean boundary -- f110-115 draw 1280x720 + 262x108 + 525x90, f116 onward 1280x720 + 243x86 + 499x72. Two sprites either side, no transition frame. Second, and larger: a third of the bundle is never drawn. Entry 11 declares three logo/glow pairs and only two appear. palogo_gamearts / _eff 95 / 22 frames palogo_seta / _eff 95 / 22 frames palogo_anima / _eff never palogo_anima declares the SAME keyframe times as palogo_gamearts. Two elements with byte-identical data, 95 frames and 0 frames in one run. Reach: the capture covers frames 1-214, so this is "never in the window". So a bundle is a palette, not a script. Its elements say what to draw and for how long; which of them run, and when each starts, is decided outside the placement data. That is the same conclusion the boot-order work reached from the other end -- the driver is code, not data -- now with a per-element measurement behind it. For the port, concretely: compositing every element of a bundle does not reproduce what the game shows over time. It is the right thing for a static screen that settles, and it is not a timeline. METHOD: two elements with identical data and different outcomes is the strongest possible evidence that the decision is elsewhere.
786 lines
60 KiB
Markdown
786 lines
60 KiB
Markdown
# Handoff — what the menu port needs, and where it stands
|
||
|
||
The single page the **port agent** reads. Everything here is produced by the
|
||
container agent's reverse engineering; nothing here is a design decision about
|
||
the port itself.
|
||
|
||
Keep it current. It is a summary with links into `docs/re/`, not a second copy of
|
||
the findings — but an answer that is not reachable from this page has not been
|
||
delivered.
|
||
|
||
## How to read an answer
|
||
|
||
Every row below is one of exactly three things, and the distinction is the point:
|
||
|
||
| | meaning | what the port should do |
|
||
|---|---|---|
|
||
| **decoded** | a field on the disc, with a disc-wide check | read it from the data |
|
||
| **measured** | not on the disc in any form we found, but the running game does *this* | hardcode it, and cite this page |
|
||
| **undecodable** | we looked in these places, it is not there, here is the reach of the negative | author it by hand, knowingly |
|
||
|
||
There is no fourth kind. If a row says *measured* or *undecodable*, the port is
|
||
**authoring** that value, not transcribing it — and it should be kept somewhere a
|
||
human can see it is a human decision, so that when it is later decoded the
|
||
authored version can be deleted.
|
||
|
||
## Status
|
||
|
||
| | Question | State | Answer / link |
|
||
|---|---|---|---|
|
||
| Q1 | keyframe time unit + ramp shape | ✅ answered, 🟡 one gap | ramp is **linear**; **2 units per rendered frame**; **`1 unit = 1/60 s` — settled**, the idle title presents at 28.5 fps so the game is 30 Hz. 🟡 **The interpolation law is settled; the group TIMELINE for multi-keyframe elements is not** — `palogo_gamearts` is still at full alpha 9 frames after its declared `a=32`, and its declared 80-frame fade-in never draws — [`ui-keyframe-time-unit.md`](../re/ui-keyframe-time-unit.md). ✅ **REPLICATED 2026-08-29 — for ANIMATION, read `+36` as the time the NEXT pose is reached.** Three elements across two screens: `palogo_gamearts` and `palogo_seta` hold full alpha for **83 frames** and `palogo_sqex` for **≥77**, where the current reading predicts **6–8** and the shifted one **80–102**. The elements that cannot discriminate (the `_eff` glows, on which the linear law was measured) fit both. ⚠️ Our decoder still defaults to the other reading (`SYLPHEED_KF_TIME_SHIFT=1` to flip) because it changes `rest()` on one element — but that is an unsound fallback guessing either way, so **static rendering is unaffected and animation timing should use the shift** |
|
||
| Q2 | which build is which screen state | ✅ answered | `GP_TITLE` is **8 screens shipped twice, EN/JP**: 4/7 title art, 2/3 the `PRESS Ⓐ` plate, 5/8 main menu, 6/9 `EXTRAS`, 0/1 and 10/11 two unidentified `DELTASABER` plates — [`ui-title-build-map.md`](../re/ui-title-build-map.md) |
|
||
| Q3 | paint order for the six screens | ✅ answered, ❔ tie-break | **decoded**: a `u16` layer key at `+0x0A` of each `T8aD` sprite header, stable-sorted with declaration index; unkeyed elements get an implied key. Confirmed on 5 measured orders + `EXTRAS` vs a capture. One residual: the **tie-break** is unknown and bites on one element of the title — [`structures/ui-paint-order-key.md`](../re/structures/ui-paint-order-key.md). ⚠️ **The key does not fully order a screen**: elements sharing a key are tied, and the tie-break is ❔ **undecodable from the bundle** — declaration table, `T8aD` header (exhaustive: every offset 0x00–0x7f at u8/u16/u32, both directions, **0** fields match the measured order against **64** for the control) and the RATC child order all give the same order the game does *not* use. Your exposure is **2 overlapping tied pairs on `EXTRAS`** — [`structures/ui-paint-order-derived-check.md`](../re/structures/ui-paint-order-derived-check.md) |
|
||
| Q4 | button → GamePart | ✅ answered | **measured** which screen all **5** buttons open — `NEW GAME` → `DIFFICULTY` → `SELECT DATA`, not a hang. The **GamePart id is still a name match**, not a measurement — [`menu-navigation-semantics.md`](../re/menu-navigation-semantics.md) |
|
||
| Q5 | navigation semantics | ✅ answered | **measured**: initial focus varies boot to boot (2× `TUTORIAL`, 2× `NEW GAME`); ⬆⬇ one step, **wraps both ends**; ⬅➡ do nothing; Ⓑ returns to the parent **with focus restored**; Ⓑ on the main menu → title; Ⓑ on the title → nothing — [`menu-navigation-semantics.md`](../re/menu-navigation-semantics.md) |
|
||
| Q6 | boot sequence + what drives it | ✅ answered | sequence **measured** end to end; the driver is **code, not data** — four search spaces closed, so the port **authors** the sequence — [`boot-config-and-gamepart-registry.md`](../re/boot-config-and-gamepart-registry.md) |
|
||
| Q7 | transitions | ✅ answered | a **fade through black**, drawn by the screen's own last-painting `.prm` quad. Fade-in ramp is **decoded** from its keyframes; the ~0.4 s fade-out is **measured** (not in the file) — [`screen-transitions.md`](../re/screen-transitions.md) |
|
||
| Q8 | menu audio bindings | ✅ answered | cue vocabulary + bank **decoded**; event binding is a **name match** (the authors' own event names). ✅ **You CAN have the SE audio** — ⚠️ an earlier version of this row said it was "undecodable from the disc"; that was **retracted** and the row was stale. Three cues are located in `Static.slb` and **decode to PCM**: d-pad move `0x1ec0` (4 packets), Ⓑ back `0x0ec0` (2), Ⓐ confirm `0x5d6c0` (6), all mono 48 kHz. The bank is a packed run of XMA waves with no delimiter, so a wave is only (offset, packet count) — and ⚠️ the file order is **not** cue-id order, so the index cannot be counted out — [`menu-audio-cues.md`](../re/menu-audio-cues.md) |
|
||
| Q9 | video binding + playback rules | ✅ answered | **decoded** from the movie manifest: `ADVERTISE_MOVIE`→`ADV.wmv` (boot intro *and* attract are one asset), `MS00A`→`S00A.wmv` is the new-game intro, `STAFF_ROLL`→the credits reel. ✅ **one Ⓐ skips a movie** (title at 57 s vs a 193 s baseline) — [`movie-binding.md`](../re/movie-binding.md) |
|
||
| Q10 | music-bank sub-wave roles (intro+loop?) | ✅ answered | **two stems of one performance, played together** — sample-synchronous, equal duration, 32/32 banks. **Concatenating is wrong.** Not a seamless loop either — [`structures/bgm-two-stems.md`](../re/structures/bgm-two-stems.md) |
|
||
| S1 | Ready Room go/no-go | ✅ **no-go** | it is 2D and enumerates fine (60 builds), but `GP_READY_ROOM.pak` holds **briefing/tactical-map** content, not the six-button Ready Room menu — [`ready-room-probe.md`](../re/ready-room-probe.md) |
|
||
|
||
## Already settled — the port can rely on these today
|
||
|
||
* **`GP_TITLE.pak` is eight screens, each shipped twice — English and Japanese.**
|
||
Build 4 is the English title art and 7 its Japanese twin; **2/3 are the
|
||
`PRESS Ⓐ BUTTON` plate, a build of their own** composited over the title and
|
||
faded in a beat later; 5/8 are the five-button main menu; 6/9 are the `EXTRAS`
|
||
submenu — the **only** submenu inside this archive. Builds 0/1 and 10/11 are a
|
||
`DELTASABER / SYLPHEED A.I.` plate that was **never seen running**, in the boot
|
||
path, any title-side screen, or the attract loop. ✅ measured against live
|
||
captures for the four English screens the boot path shows;
|
||
[`ui-title-build-map.md`](../re/ui-title-build-map.md).
|
||
**Withdrawn:** the earlier "builds 6/8/9 are submenus" — 8 is the Japanese main
|
||
menu. The other four main-menu buttons leave the archive, and where each one
|
||
goes is **measured** — see the button-destination bullet below.
|
||
* **Buttons are identifiable as data.** Element kind `0x3002` = button, `0x0` =
|
||
decoration, `0x10` = primitive. ✅ decoded **for the title-side screens**.
|
||
⚠️ `0x3002` is one member of a `0x3000` family with sub-bits, and it is not the
|
||
only button kind on the disc: `GP_READY_ROOM`'s 902 bundles contain **zero**
|
||
`0x3002` and use `0x3000` / `0x3004` / `0x300c` / `0x3008` instead. Nothing in
|
||
this milestone changes — every screen in scope is `GP_TITLE` — but do not ship
|
||
`kind == 0x3002` as a general button test.
|
||
(The kind is the 4th `u32` of the 60-byte declaration entry, at `+40`.)
|
||
* **The title's settled pose is `rest` — and always pass `--primitives`.**
|
||
Against a plate-free capture of the real screen, `screen render --build 4
|
||
--black` edge-correlates **0.9163 at (0,0)**, so the geometry of `rest` is the
|
||
arrived pose; the timeline is not needed for the title.
|
||
⚠️ **But without `--primitives` the whole frame is +13.14 too bright** (R +12.35,
|
||
G +13.58, B +13.48); with them, **+0.55**. The missing element is
|
||
**`pteff02.prm`**, the 25 % dim, and `--primitives` is off by default. **That is
|
||
the "washed-out cyan slab"** — a dim that should be there and isn't, not a glow
|
||
that shouldn't. Because the art is blue-dominant, the shortfall reads cyan.
|
||
🔴 **A second, separate defect — and it is the "slab".** With the dim in place
|
||
the residual is localised to one band (y ≈ 112–225): the game draws the logo's
|
||
`Z` **swoosh thin with a pink/magenta edge**, our render draws it **thick and
|
||
solid white**. Crop:
|
||
[`title-swoosh-capture-vs-render.png`](../re/captures/title-builds/title-swoosh-capture-vs-render.png).
|
||
The elements are `ptlogo_back2.t32`, `ptlogo_back2eff.t32` and
|
||
`ptlogo_back2eff1…5`. 🟡 Those five are also the group with the **known
|
||
unsolved paint-order tie-break** (key `0x8083`) — same screen, same elements —
|
||
but a blend-order swap explains white-instead-of-pink poorly.
|
||
🔴 **The pivot mismatch is NOT the cause — checked and refuted.** These elements'
|
||
declared pivots really do belong to the *other language's* sprite
|
||
(`ptlogo_back2`'s `(500,117)` is exactly half the Japanese 1000×234, not its own
|
||
English 1118×262; 24 of 109 title elements are off by > 8 px). **But it cannot
|
||
affect this render**: `blit` sizes a sprite from its **texture**, and applies the
|
||
pivot only as `kf.x − pivot·(scale−100)/100` — and **all seven swoosh elements
|
||
are scale `(100,100)` at every keyframe**, so the term is zero.
|
||
✅ **That formula is now MEASURED, not just implemented (2026-08-28).** The
|
||
title's two `ptloop` sweeps scale **600 %** and **800 %** vertically, where the
|
||
pivot term is worth 450 and 630 px, and the GPU capture puts both quad centres
|
||
at y **359.1** and **360.0** — against the formula's **360.0** for both.
|
||
Top-left anchoring predicts 810 and 990; treating the position as the centre
|
||
predicts 270. Horizontally the same term makes the group's `t` solved from
|
||
**position** agree with `t` solved from **vertex alpha** to **0.33 / 0.65
|
||
units**, versus ~8 units without it. So: **anchor scaling on the pivot, not on
|
||
the top-left corner.**
|
||
🟡 It does *not* prove interpolation is linear — both fields were inverted
|
||
through the same linear map, so a shared easing curve would cancel. It does
|
||
show position and alpha ride **one shared parameter**.
|
||
⚠️ It *would* bite `ptlogo1`/`ptlogo2`, which scale 100 → 150 during the
|
||
build-in. Not at rest, and not on the swoosh.
|
||
🔴 **Ruled out for the COLOUR**: every swoosh keyframe's fade is `0x??ffffff`
|
||
(white RGB, alpha only), no tint is non-white, and the texture decodes
|
||
*blue*-leaning (175,174,198).
|
||
🟡 **What is left is the BLEND.** Size and position are the texture's own and
|
||
match; fade, tint and texture colour are all ruled out. Seven overlapping
|
||
mostly-transparent sprites (`ptlogo_back2` 5.4 % opaque, its glow 10.3 %, the
|
||
five `eff` segments 10–23 %, all white or warm) stacked with plain alpha-over
|
||
saturate to opaque white — which is exactly what we draw, and would read as
|
||
"thicker" against the game's thin coloured stroke.
|
||
🟡 **And there is a candidate field for it.** The `T8aD` header word at
|
||
**`+0x04`** splits the title's sprites exactly along effect-vs-normal:
|
||
`pteff01`, `pteff03a`, `ptlogo_back2eff1…5`, `ptlogoall_eff`/`_eff2` are
|
||
**`0x8832`**; `ptlogo1`/`2`, `ptlogo_tm`, `ptbase2`, `ptlogo_back2`,
|
||
`ptcopyright` and `ptlogo_back2eff` are **`0x8830`**. One bit — **`0x02`**.
|
||
Disc-wide it is a real independent flag: 19 216 sprites, 18 distinct values,
|
||
bit `0x02` set in **27.1 %**, and it toggles against otherwise-identical words
|
||
(`0x8830`/`0x8832`, `0x0830`/`0x0832`, `0x0810`/`0x0812`, `0x0030`/`0x0032`).
|
||
⚠️ **Correlation only — untested.** Nothing yet shows it *means* additive; the
|
||
test is to blend bit-`0x02` sprites additively and re-correlate the title
|
||
against the capture. Note `ptlogo_back2eff` is `0x8830` despite its name, so
|
||
this is a field and not a naming pattern. ❔ Not diagnosed — but two candidate meanings are now dead: **not** "the name contains `eff`" — and not even the one-way "bit ⇒ `eff` name", which held 10/10 on the title but fails on **2 657 of 4 995** bit-set sprites disc-wide (`P(eff|set) = 0.468`). What survives is a 3.3× association, and the counterexamples are rings, glows and lights — effect-like art without the naming convention and **not** "the element is transient" (`pteff03`/`pteff03a` carry the bit and persist to `t=250`). 🛑 **Parked** — four candidate meanings are now dead (additive blend; `eff` name, both directions; transient element; **premultiplied alpha**, refuted because flagged sprites violate `RGB ≤ A` *more* than unflagged, 55.5 % vs 33.7 %) and none produced a positive account. It blocks nothing — your screens composite at 0.947 correlation against a capture without it. ⚠️ Practical note for an asset pipeline: `T8aD` headers appear in **RATC child order** (18/18 verified on build 4 against decoded dimensions), which is the only sound way to attribute a header to a name when two sprites share a size — and two here do.
|
||
⚠️ **The rotated draw is NOT the swoosh.** It was identified as the swoosh by
|
||
elimination; that is **refuted** — its quads span y −209…925 in screen space,
|
||
the swoosh is a band at y 126…360. ✅ **It is the two `ptloop` sweeps**
|
||
(`ptloop01.rat` → `pteff03.t32`, `ptloop02.rat` → `pteff03a.t32`), confirmed by
|
||
edge length: 400 × 1076 and 400 × 1444 against 399×180 at the two elements'
|
||
**different** declared scales, 600 % (= 1080) and 800 % (= 1440). What stands
|
||
from the old bullet: the game *does* submit rotated quads this compositor
|
||
cannot draw, and vertex colours are white.
|
||
✅ **SOLVED 2026-08-28 by draw capture — it is the GEOMETRY.** The game submits
|
||
the swoosh as **two rotated parallelograms** (edges `(0.54,−0.56)` and
|
||
`(0.44,0.79)`, ~45° and ~61°, extending to `y=±1.81` NDC).
|
||
`ui_layout::blit` draws **axis-aligned rectangles only**, so it blits the sprite
|
||
upright — right on average, right in position, wrong in shape, which is the
|
||
measured signature exactly. **A port that blits upright rects will have the same
|
||
defect.**
|
||
🔴 Vertex colour is refuted with it: every colour in the capture is
|
||
`<alpha>FFFFFF`, white RGB.
|
||
✅ **CLOSED 2026-08-28 — the rotation IS on the disc, at keyframe `+12`.**
|
||
It is a signed angle in **degrees**, clockwise-positive in screen space
|
||
(Y down), and `ui_layout::Keyframe` now carries it as `rotation_deg`.
|
||
Confirmed against the framebuffer, not against our own renderer: the two
|
||
`ptloop` records declare `+12` = **30** and **−45**, and the GPU capture
|
||
submits their quads at **+30.26°** and **−45.28°** — magnitude and sign, on two
|
||
different values. Corroborated separately by shape: `GP_BUNK` entry `117ca14f`
|
||
holds a group whose `+12` ramps **0 → 360** with position, scale and alpha all
|
||
constant — a spin in place. Disc-wide `+12` is non-zero in **14.50 %** of
|
||
83 862 keyframe blocks.
|
||
⚠️ **Two things the port must know about it.**
|
||
(1) **Rotation lives in BOTH the top-level table and nested `.rat` leaf
|
||
records.** ⚠️ I told you one iteration ago that it looked nested-only; that was
|
||
three archives' worth of pattern and **it is refuted** — `GP_DIALOG` and
|
||
`GP_DEBRIEFING_PILOTLOG` rotate top-level elements. The actionable half stands:
|
||
the *title's* rotations are nested, so a composer reading only the declaration
|
||
table gets zero rotation on exactly the elements that move there.
|
||
The clearest examples are top-level and show up in
|
||
`screen info --build 0 --geometry dat/GP_DIALOG.pak`: `pceff03.t32` and
|
||
`pceff04.t32` ramp `r=` **90 → 30 → 10 → 3 → 0** while their alpha ramps
|
||
0 → 255 and they slide into place — a swing-in that settles upright; and build
|
||
6's `pzeff02.t32` ramps **43 → 61 → 75 → 90** while scaling 112 % → 200 % and
|
||
fading to 0 — a spin-out burst.
|
||
(2) **`sylpheed-cli screen render` still does not rotate.** The field is
|
||
decoded, 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 — and per your own rule, the two of them agreeing about it means
|
||
nothing.
|
||
✅ **Checked 2026-08-29: this does NOT affect your five screens at rest.**
|
||
Title, main menu, `EXTRAS` and both splash halves have **zero** top-level
|
||
elements with a non-zero rotation. The only rotations on any of them are the
|
||
title's two nested `ptloop` records (`r = 30` and `−45`), and at rest those sit
|
||
at `x = 1521` and `x = −839` — a 399-wide sprite entirely off both edges of a
|
||
1280 screen. So a static composite is unaffected; the caveat applies only if
|
||
you animate the title's build-in, where the sweeps cross the screen rotated.
|
||
🟡 The neighbouring words `+4` and `+8` are still unexplained: signed, non-zero
|
||
in ~4.8 % / 4.6 % of blocks, almost entirely `±180`/`±90`. That distribution
|
||
looks like a flip flag rather than a free angle, but nothing observed turns on
|
||
them — **do not transcribe them as X/Y rotation.**
|
||
[`ui-keyframe-rotation.md`](../re/structures/ui-keyframe-rotation.md)
|
||
⚠️ The earlier "pink versus white" reading compared two differently-shaped
|
||
renderings and should be re-checked after geometry, not carried as a separate
|
||
defect.
|
||
❔ **Classified: undecodable from the disc, with reach.** Seven candidates
|
||
eliminated — pivot (inert at scale 100 *and* no measured displacement), `fade`,
|
||
`tint`, texture colour, additive blend via `+0x04` bit `0x02`, and
|
||
capture-not-settled (the band is identical from t = 4.0 s to t = 21.5 s). The
|
||
residual is stable and modest: band mean **+1.83**, edge-corr **0.70** vs ≈ 0.92
|
||
frame-wide. A next attempt should use a **per-draw GPU capture** of the running
|
||
guest — not another field — but ⚠️ that capture records prim/indices/shader
|
||
hashes/**texture bindings**/**vertex attributes** and **no blend state**, so it
|
||
can test a per-draw *vertex colour* today and would need a Canary change to dump
|
||
`RB_BLENDCONTROL`. ✅ And the plate-free capture is sound; use it.
|
||
🔴 **Refuted:** it is *not* that our dim covers the whole frame instead of
|
||
sitting beneath the UI — the logo reads +2.36 against a background of −0.74, so
|
||
the paint order is being honoured.
|
||
|
||
* **The title's motion, decoded and attributed.** After building in, the *title
|
||
art* is essentially static — a 22 s capture measures the wordmark region at
|
||
sd 0.06 and the bottom-right corner at sd 0.003. Two things do move:
|
||
* ✅ **Two slow light sweeps in build 4.** `ptloop01.rat` (an `opt `-linked
|
||
`RATC` at `0xbb5966`) sweeps `pteff03.t32` left→right over **450 units =
|
||
7.5 s**; `ptloop02.rat` sweeps `pteff03a.t32` right→left over **570 units =
|
||
9.5 s**. Ordinary 40-byte keyframe blocks from `+0x68`, three keyframes each.
|
||
* ✅ **The `PRESS Ⓐ BUTTON` plate pulses**, and it is the loudest thing on
|
||
screen — a capture's per-tile amplitude map puts **sd 7.65** in the band
|
||
x ≈ 318–954, y ≈ 560–672 against **0.06** on the wordmark. It is
|
||
`ptbtn00f.rat`, the plate's highlight variant (build **2**, not build 4),
|
||
whose alpha ramps **`0x00 → 0x06 → 0x4a → 0x50` (hold) `→ 0x4a → 0x06 → 0x00`**
|
||
over **eight** keyframes at `t = 6, 29, 35, 50, 58, 97, 105, ?` — a glow that
|
||
fades in and back out, **closing on fully transparent**, so it is a complete
|
||
cycle rather than a one-shot ramp.
|
||
🟡 Its cycle **length** is not readable, and this is now observed rather than
|
||
assumed: the eighth block's time slot literally contains the ASCII terminator
|
||
`end `, so the record ends there and the value does not exist. Declared span is
|
||
therefore **≥ 105 units = 1.75 s** against a measured **≈ 2.3 s** — which would
|
||
need a final step of ≈ 33 units. That number is **fitted to the measurement, not
|
||
read**; the port should take ≈ 2.3 s as measured.
|
||
⚠️ An earlier version of this bullet said "the title screen loops at ≈ 2.2 s"
|
||
and attributed it to `ptloop01/02`. Both halves were wrong: it is the **plate**,
|
||
and it is a different build.
|
||
* 🟡 **Our composite is brighter than the emulator's frame — measured, and you
|
||
would be *authoring* if you apply it.** Alignment is exact (best offset
|
||
dy=0 dx=0, correlation **0.9466**), so only the tone differs. Fitting on 16×16
|
||
patches that are flat in **both** images: `capture ≈ 255·(render/255)^γ` with
|
||
γ = **1.491** (main menu), **1.493** (`EXTRAS`), **1.338** (title).
|
||
⚠️ **Narrow reach.** Those flat patches span only render values ~0–60, where a
|
||
gamma and a plain scale are nearly indistinguishable — on both menus the errors
|
||
are 0.28 vs 0.34 and 0.22 vs 0.28. Only the title separates them (1.08 vs
|
||
9.02). Nothing here constrains midtones or highlights.
|
||
🔴 The held-out control **failed to discriminate**: the splash's 2 918 flat
|
||
patches are pure black (render 0–4), so every model scores ≈ 0.
|
||
🔴 **My "it may be the emulator" caveat is withdrawn.** I said canary applies
|
||
`kernel_display_gamma_type = 2` (BT.709) on output. It does not — that cvar is
|
||
a value a **kStub getter reports to the guest** (`VdGetCurrentDisplayGamma`),
|
||
which the game uses to build its own ramp; canary then applies **the guest's**
|
||
ramp from the `DC_LUT` registers in the swap path (`apply_gamma_table.ps` /
|
||
`apply_gamma_pwl.ps`). So there is no emulator post-process to subtract, and
|
||
any gamma in a capture is one the game installed.
|
||
✅ **Measured 2026-08-29: the game DOES query the display gamma.** Booted with
|
||
Kernel logging on (`--log_mask=12 --log_level=3`, which changes nothing about
|
||
the output), `VdGetCurrentDisplayGamma` is called once at video init, between
|
||
`VdGetSystemCommandBuffer` and `VdSetDisplayMode` — the moment a ramp-builder
|
||
would ask. Control in the same log: 359 `VdRetrainEDRAM` lines, so an absent
|
||
call would have shown.
|
||
🟡 **That it then writes the ramp is inferred, not observed** — but the chain
|
||
is closed: canary's swap-path gamma stage is a **pure 256-entry LUT** with no
|
||
other transfer (`apply_gamma_table.xesli`), and that LUT **defaults to
|
||
identity** (`CommandProcessor::Initialize`, whose comment says the linear
|
||
default is "what games set when starting with the sRGB return value"). Identity
|
||
cannot produce the measured γ ≈ 1.4, so a non-identity ramp was written.
|
||
⚠️ The weak joint is that this assumes our composite reproduces the *pre-ramp*
|
||
framebuffer; what carries it is that a systematic ~1.4 across three screens is
|
||
not the shape of a compositor bug. A fixed sRGB stage does not fit either
|
||
direction (encode brightens; decode darkens far more).
|
||
**Practical upshot for you is unchanged:** the darkening is the game's own
|
||
display ramp, so it belongs in a port as a display profile, not baked in.
|
||
⚠️ Note the ramp depends on the display type the game is *told*; canary
|
||
hard-codes TV/BT.709, which on hardware is a console setting. **So this is a
|
||
display profile, not a fixed property of the game** — reasonable to expose as a
|
||
setting rather than bake in.
|
||
[`structures/ui-render-tone-curve.md`](../re/structures/ui-render-tone-curve.md)
|
||
|
||
* 🟡 **`screen render` silently drops one full-screen element per screen — and
|
||
you must NOT simply draw it.** Auditing what the composer omits on your five
|
||
screens: everything is accounted for (`kind & 0x4` ghost instances, `.prm`
|
||
primitives, `loop*` animations) except **`pteff04.t32`** on the title and
|
||
**`pteff05.t32`** on both menus. Those are `kind 0x0`, one keyframe, rest
|
||
`a = 255`, pivot `(640,360)` — full-screen and opaque.
|
||
**Cause:** the element declares `pteff05.t32`, but the `T8aD` behind its `opt `
|
||
link is registered under the name **`8AX`**, so the sprite lookup misses and a
|
||
silent `continue` drops it.
|
||
✅ **It does not currently show,** because `ptbase.t32` (640×360, drawn at
|
||
200 %) is *the same artwork at half resolution* — its 2× upscale differs from
|
||
`8AX` by mean 2.05, and our background is pixel-identical to `8AX` in every
|
||
patch sampled.
|
||
✅ **SETTLED 2026-08-29 — the game draws the full-res `8AX`, so use it.**
|
||
Previously parked as "needs a per-draw capture"; it did not. The two carry the
|
||
same art at two resolutions, so what separates them is the detail `8AX` has
|
||
that an upscale cannot. Correlating the capture's departure-from-upscale
|
||
against the 8AX-only detail (both first mapped through the measured gamma):
|
||
main menu **+0.0475** vs controls +0.0032 / −0.0075, title **+0.0634** vs
|
||
+0.0095 / +0.0086 — **two screens, both 68 % of the theoretical ceiling, 7–15×
|
||
their matched controls**.
|
||
**So: resolve the name and draw `8AX` at 1:1.** Upscaling the 640×360 `ptbase`
|
||
2× is *wrong*, not merely softer. ⚠️ Do not draw **both** — an opaque
|
||
full-screen layer over an identical one costs fill and hides later changes; and
|
||
note `ptbase`'s element is the one carrying the keyframes, so you need its
|
||
timing with `8AX`'s pixels.
|
||
⚠️ It does not show whether `ptbase` is *also* drawn underneath — `8AX` is
|
||
~86 % opaque and would hide it either way.
|
||
[`structures/ui-8ax-fullres-background.md`](../re/structures/ui-8ax-fullres-background.md)
|
||
|
||
* ✅ **Paint order: your exposure is two element pairs, on one screen.** We use
|
||
an order *measured from the running game* where one exists and a derived order
|
||
(a sort on each sprite's layer key) elsewhere. Checked, rather than assumed:
|
||
the derived order reproduces the measured one **exactly** on the main menu
|
||
(0 inverted pairs) and the developer splash (0). On the **title** it differs by
|
||
8 pairs — **all same-layer-key ties** — and two of those are total occlusions
|
||
(`back2eff5` is 1133×280 and *fully contains* `back2eff3` and `back2eff4`;
|
||
derived puts it on top, the game puts it underneath). The title is unaffected
|
||
in practice because it has a measured order.
|
||
Per screen: title **measured**, main menu **measured**, developer splash
|
||
**measured**, publisher splash derived but with **0 ties** (fully determined),
|
||
and **`EXTRAS` derived with 15 tied pairs of which only 2 overlap**.
|
||
✅ **That narrows again to ONE, and the capture is consistent with it.** Of the
|
||
two overlapping pairs, `ptloop01`×`ptloop02` are `loop*` animations that
|
||
`compose` skips by default, so their tie is unreachable. The remaining pair is
|
||
`ptframe3`×`ptframe4`, overlapping 102×132 px — and against `live-extras.png`
|
||
that contested region correlates **+0.9622**, *better* than the whole frame
|
||
(+0.9440) and inside the range of regions where order cannot matter (+0.8502 /
|
||
+0.9903). 🟡 Consistent with, not proof — correlation cannot see a swap between
|
||
locally similar art. **15 → 2 → 1 → consistent** is the whole paint-order risk
|
||
on your five screens.
|
||
[`structures/ui-paint-order-derived-check.md`](../re/structures/ui-paint-order-derived-check.md)
|
||
|
||
* ✅ **How good are the five screens, actually?** One page with the numbers:
|
||
[`five-screens-acceptance.md`](../re/five-screens-acceptance.md). Rendered and
|
||
correlated against the live captures — title **0.9500**, main menu **0.9460**,
|
||
`EXTRAS` **0.9440**, publisher splash **0.9600**, developer splash **0.9643**,
|
||
and every one aligns at **exactly dy=0 dx=0** over a ±2 px search, so placement
|
||
and scale are right and the residual is tone and detail rather than geometry.
|
||
Every undrawn element is accounted for (ghost instances, `.prm` primitives,
|
||
`loop*` animations, and the `8AX` name mismatch whose art is on screen anyway) —
|
||
the counts add up exactly, with nothing unexplained. The residual, ranked: tone
|
||
(γ≈1.4, the game's own ramp), `8AX` resolution, one `EXTRAS` paint-order tie,
|
||
and rotation-not-rendered which does not affect these five at rest.
|
||
⚠️ Reach: static composites at rest against single frames — this says nothing
|
||
about animation.
|
||
|
||
* ✅ **A static composite is only meaningful for a screen that SETTLES — the two
|
||
splashes are animations.** Measured with its control: suppressing the `_eff`
|
||
glows takes the **publisher splash 0.9604 → 0.9982** and the **developer splash
|
||
0.9659 → 0.9980**, while the same edit makes the title −0.002, the main menu
|
||
**−0.092** and `EXTRAS` **−0.107** worse. The draw log says why — on the
|
||
developer splash the glows draw on frames 94–115 and the logos on 116–211, so
|
||
at the captured moment every glow is already finished, including the two with
|
||
plateaus that `rest_plateau` renders visible.
|
||
So `rest_plateau` is right where a screen settles and over-draws where it does
|
||
not. **There is no "resting pose" for the splashes** — they play through and
|
||
leave, and a static composite of them is a picture of one arbitrary frame
|
||
(≈0.998 for the frame these captures hold). **Play the timeline for the two
|
||
splashes; composite statically for title / main menu / `EXTRAS`.**
|
||
[`structures/ui-resting-pose.md`](../re/structures/ui-resting-pose.md)
|
||
|
||
* ❔ **A group's DURATION is in the data; its START TIME is not.** Testing whether
|
||
the splash timeline *played* reproduces the capture: each element is on screen
|
||
for its declared span to within 2 % (glows 44 units observed vs 45 declared;
|
||
logos 192 vs 195). But every glow declares the same times `15,30,45` and every
|
||
logo the same `15,30,190,194,206,210`, so on one clock they would overlap almost
|
||
entirely — and they **do not overlap at all**. The glows run frames 94–115, the
|
||
logos 116–211, strictly sequential.
|
||
🔴 The obvious candidate is dead: each group header carries an undecoded
|
||
**lead-in word**, and it is `0x00000000` for all seven elements. Not the
|
||
keyframe times, not that word, not declaration order, not the RATC child order.
|
||
⚠️ **So you must author the sequencing.** The observed order on the developer
|
||
splash — both glows, then both logos — is *measured for one screen*, not a
|
||
decoded rule.
|
||
✅ **Verified against its own refutation:** across all 235 captured frames,
|
||
**zero** contain both a glow and a logo; the switch is one clean boundary with
|
||
two sprites either side.
|
||
🔴 **And it goes further than start times — a bundle is a palette, not a
|
||
script.** Entry 11 declares *three* logo/glow pairs; only two are ever drawn.
|
||
`palogo_anima` gets **0 frames** while `palogo_gamearts` gets **95**, from
|
||
byte-identical keyframe times. (Reach: the capture covers frames 1–214, so
|
||
"never in the window".) **Compositing every element of a bundle does not
|
||
reproduce what the game shows over time** — it is right for a static screen
|
||
that settles, and it is not a timeline.
|
||
[`structures/ui-group-start-time.md`](../re/structures/ui-group-start-time.md)
|
||
|
||
* **Menu order is geometric.** Buttons sorted top-to-bottom by resting Y. This is
|
||
✅ correct for a vertical menu and is **not** a decoded neighbour graph — the
|
||
disc's real navigation structure is unknown, and `opt ` is *not* a focus link
|
||
(measured and refuted, see
|
||
[`ui-focus-and-effect-elements.md`](../re/structures/ui-focus-and-effect-elements.md)).
|
||
* **Highlighted states pair by name** — `ptbtn01.rat` ↔ `ptbtn01f.rat`. 🟡 a
|
||
naming convention that holds for all 54 real pairs, not a decoded field.
|
||
* **`rest()` was wrong for elements with no exit animation — fixed 2026-08-28.**
|
||
A trailing run of identical keyframes was always treated as the exit and
|
||
excluded; on an element that has no exit it *is* the hold, and `rest()` fell
|
||
back to the element's **first** keyframe — off-position and transparent. Six
|
||
elements on the main menu were affected, including `ptframe1`/`ptframe2`, the
|
||
bright circuit bracket around the menu, which both the port's composite **and**
|
||
`sylpheed-cli screen render` were dropping.
|
||
The rule now: **a trailing run is the hold exactly when it is visible** (alpha
|
||
≠ 0). ⚠️ Not the pose-equality test the report proposed — `pgptitle.rat`'s
|
||
trailing run also matches its last timed keyframe, and adopting that would erase
|
||
the word PAUSE. Oracle correlation over the bracket region improved
|
||
**0.9596 → 0.9748**; the PAUSE control is unchanged.
|
||
✅ **Disc-wide**: over 2 859 bundles / 13 991 elements, `rest` moves for **30**
|
||
(0.21 %) — **4 invisible → visible, 0 visible → invisible**. Tests green
|
||
(131 passed).
|
||
❔ **Back to the port agent:** on the English main menu exactly **two** elements
|
||
satisfy your pose-equality condition (`ptframe1`/`ptframe2`), so your six span
|
||
the whole export. If any of the other four have a **transparent** trailing run,
|
||
this rule leaves them alone on purpose. **Which screens are they on, and does a
|
||
capture show any of them drawn?** If so the alpha rule is incomplete.
|
||
This also closes the old ❔ on `ptframe1`/`ptframe2` "resting at alpha 0 but the
|
||
capture shows the frame plainly".
|
||
* **The resting pose is the hold**, not the first, last or longest-dwell keyframe;
|
||
a keyframe is the **start of a ramp**.
|
||
[`ui-resting-pose.md`](../re/structures/ui-resting-pose.md). ✅
|
||
* **That ramp is linear, and it runs at 2 keyframe time units per rendered
|
||
frame.** Measured frame-by-frame off the running game's own draw stream: a
|
||
declared 15-unit fade lands on `round(255·k/15)` for all seven of its samples,
|
||
with `k` stepping 2, 4, 6, 8, 10, 12, 14 on seven consecutive submitted frames.
|
||
**measured**, not decoded — the disc says `t=30`, it does not say what a `t` is.
|
||
🟡 **But a multi-keyframe element's TIMELINE does not reproduce (2026-08-28).**
|
||
The linear law and the 2-units-per-frame rate rest on the splash's `_eff`
|
||
glows, and those are exact. Applying *their* calibration — with no free
|
||
parameter left — to `palogo_gamearts` in the same bundle and the same frames:
|
||
the logo is still at `a=255` nine frames after its declared `a=32`, its
|
||
fade-out runs ~17 frames late, and its declared 80-frame fade-in is never
|
||
drawn at all (and that is not culling — the same element is submitted down to
|
||
`a=7` on the way out). Its declared fade-out also spends 12 of 16 units
|
||
dropping only 23/255 of the alpha; the capture shows no such plateau.
|
||
🔵 **Followed up, and the candidate is now strongly favoured — but not
|
||
adopted.** That `+36` holds the *next* pose's time is supported by a
|
||
calibration-free measurement: the observed full-alpha **hold : fade-out** ratio
|
||
is 83 : 13 frames = **6.38**, the shifted reading predicts **8.00**, and the
|
||
current reading predicts **0.25** — off by **26×**. With the glow's 2
|
||
units/frame fixed and nothing else free, the current reading says the logo
|
||
holds full alpha for **2.0 frames**; the game holds it for **83**. The shift
|
||
also makes `rest()`'s plain dwell rule pick the visible hold instead of a
|
||
fully transparent pose, and removes the decoder's "last block's time is
|
||
unreadable" special case.
|
||
~~🔴 **What stops it:** … `GP_TITLE` build 7 … twins should match in
|
||
brightness …~~ **Withdrawn 2026-08-28.** That render difference is **not**
|
||
evidence about the time association. It is one element — `ptlogo_eff3.t32`, a
|
||
transient bloom that grows `0 % → 200 %` at full alpha while rotating, then
|
||
collapses — and it has **no resting pose at all**. `rest()` falls through to
|
||
its dwell fallback and returns whichever end of that movement the indexing
|
||
lands on: the invisible frame as decoded, the 200 % peak shifted. An 896×389
|
||
sprite at 200 % is larger than the screen, which is the whole 13.1 % and the
|
||
whole luminance gap. I was comparing a heuristic, not a decode.
|
||
🔴 **And that is a real defect you should know about:** `rest()`'s dwell rule
|
||
is unsound *whenever it runs*. A dwell gap is time spent interpolating **from**
|
||
pose k **to** pose k+1; neither is held unless they are equal — which is a
|
||
plateau, and the plateau path has already returned by then. **So any element
|
||
with no two adjacent identical poses has a guessed rest pose**, in our renderer
|
||
and in anything built from it. Measured disc-wide: **2 305 of 15 493 elements
|
||
(14.88 %)** — ⚠️ **corrected 2026-08-29 down from a published 3 807 (24.57 %)**,
|
||
which counted 1 502 *single-keyframe* elements as guesses; those have one pose
|
||
and it is unambiguously their rest. Of the real 2 305, **195 get a degenerate
|
||
`scale = 0 %` pose**, and the two
|
||
candidate rules agree only **50.2 %** of the time.
|
||
✅ **This does not block you — your exposure is TWO elements, both on the
|
||
splashes.** The fallback is reached only by an element that is plateau-less
|
||
*and* multi-keyframe; a single-keyframe element short-circuits. Title, main
|
||
menu and `EXTRAS` reach it **zero** times, which is why three different
|
||
fallback rules render them to identical correlations. The publisher and
|
||
developer splashes reach it once each — and there, `SYLPHEED_REST_RULE=last`
|
||
(the final keyframe) scores **+0.9982** and **+0.9758** against the current
|
||
rule's +0.9600 and +0.9643. ⚠️ Measured against single frames of a transient
|
||
animation, so it fixes which pose matches *those* captures, not which is
|
||
canonically at rest. Default unchanged — it is better on both screens where it
|
||
fires and identical on the other three, but it would move 2 305 elements
|
||
disc-wide on two measurements.
|
||
✅ **And the picture is now coherent.** Applying the last keyframe to *every*
|
||
element (not just the plateau-less ones) collapses all five screens — title
|
||
0.9500→0.6819, main menu 0.9460→0.6416, `EXTRAS` 0.9440→0.5745, and both
|
||
splashes render **blank**. The reason is the model: a group is **entry → hold →
|
||
exit**, and the exit is the screen's *dismissal*. A displayed screen is sitting
|
||
at the **hold**, not at its final pose — so `rest_plateau` is right, and the
|
||
last keyframe is the *post-exit* state. It is right for a **transient** element
|
||
precisely because a transient's settled state is "gone". The draw capture agrees
|
||
independently: on the developer splash the `_eff` glows draw on frames 94–115
|
||
and the logos on 116–211, so the glows are already over when the logos are up.
|
||
**Three independent observables — animation timing, static composites and the
|
||
per-frame draw log — all support: plateau where there is one, last keyframe
|
||
where there is not.**
|
||
📊 **Structurally, `last` is defensible for 2 293 of the 2 305 (99.5 %)**: 1 618
|
||
end invisible (a transient, gone at rest), 675 end at maximum alpha (faded in
|
||
and stopped — the final pose *is* settled), and only **12** end visible below
|
||
full alpha, which are genuinely unclear. The dwell rule returns a mid-movement
|
||
frame by construction. ⚠️ The 1 618 rest on an assumption worth seeing: that
|
||
such an element's animation has finished by the time the screen settles — shown
|
||
by the draw log for the two splash glows, unshown for the rest.
|
||
📊 The disc-wide blast radius, for whoever decides: the rules **differ on 82.3 %**
|
||
of those 2 305, so "either is fine" is not available — and the current rule
|
||
returns a **zero-scale** (collapsed, pre-roll) pose for **195** of them against
|
||
**43** under `last`, a 4.5× reduction in provably-degenerate results. That
|
||
argues the same way as the captures, from the data's own structure. The single disagreement
|
||
is `palogo_anima_eff.t32` on the developer splash, and the current answer is
|
||
the defensible one there — see below. Still worth flagging plateau-less
|
||
elements in an export rather than silently inheriting our guess; it is one pass
|
||
over the keyframes.
|
||
✅ **One concrete part of it is fixed (2026-08-28): `scale = 0` no longer
|
||
renders at full size.** `blit`/`fill_quad` coerced `scale == 0` to 100 %, so an
|
||
element collapsed to nothing was drawn full-size. The disc settles the reading:
|
||
2 166 elements have a zero-scale keyframe, **not one is zero throughout**, and
|
||
1 762 grow back out of zero — so 0 means collapsed, not "unset". **Renders are
|
||
24/24 byte-identical** on `GP_TITLE` (all 16 builds), `GP_PAUSE_MENU` and
|
||
`GP_OPTIONS`, so nothing you rely on moves; the 126 elements the old
|
||
code actually painted are **all in `GP_READY_ROOM.pak`**, already a no-go.
|
||
[`structures/ui-rat-layout.md`](../re/structures/ui-rat-layout.md)
|
||
🔴 **"rest = last keyframe" is refuted** as the fix. The splash has three
|
||
sibling glows with identical structure and times, differing in one byte
|
||
(`a=212` vs `a=255` at `t=45`); that rule would make `anima_eff` alone
|
||
invisible while its two siblings stay lit. The capture agrees weakly — box-mean
|
||
ratios capture/render are gamearts 0.717, seta 0.723, **anima 0.772**, and a
|
||
glow we drew that the game does not would put anima *below* its siblings.
|
||
[`structures/ui-resting-pose.md`](../re/structures/ui-resting-pose.md)
|
||
🟡 **The shift is still not adopted**, now for a different reason: it flips
|
||
this element to the visibly wrong answer, so it and a decision about
|
||
plateau-less elements have to land together, and neither half has a capture to
|
||
verify against.
|
||
**Default unchanged**, experiment reachable via `SYLPHEED_KF_TIME_SHIFT=1`.
|
||
**What this means for you:** the interpolation *law* is settled (linear, 2
|
||
units/frame); a multi-keyframe group's *timing* is not — do not expect a
|
||
2-frame hold where the game holds 83.
|
||
✅ **The seconds conversion is settled: `1 unit = 1/60 s`, a 30 Hz title.** The
|
||
re-test this page used to name has been run — 300 submitted frames timed on the
|
||
**idle** title (nothing loading) came out at **28.8 and 28.3 fps**, the same
|
||
rate as the 27.6 fps measured during the loading splash. The competing 60 Hz
|
||
reading is excluded: it needs the emulator at 47 % of real time while idling on
|
||
a screen that costs ~5 draws per frame.
|
||
A second, independent line agrees — the transition quad is declared black for
|
||
12 units (0.20 s under this conversion) and a capture measured the pure-black
|
||
plateau at 0.17–0.23 s
|
||
([`screen-transitions.md`](../re/screen-transitions.md)).
|
||
⚠️ Still **measured, not decoded**: no field on the disc says "sixtieths of a
|
||
second".
|
||
* **The paint order is derivable from the file.** Each `T8aD` sprite header
|
||
carries a **`u16` layer key at `+0x0A`** (the upper half of the 32-bit word at
|
||
`+0x08` is zero in all 21 184 sprites on the disc). Paint order is that key,
|
||
**stable-sorted** so equal keys keep declaration order; elements with no sprite
|
||
(`.prm` primitives, `.tbm`) have no key and take an implied one — the backdrop
|
||
and dim quads sort early, the screen-transition fade (`pteff00.prm`,
|
||
`pfeff00.prm`) sorts **last**. ✅ decoded. Checked against five paint orders read
|
||
off the running game, one of them (`GP_SAVE_LOAD`'s slot-list header, 6
|
||
instances) **exact and independent of the screens the rule was fitted to**, and
|
||
against a fresh `EXTRAS` capture. It reorders 341 of the disc's 965 builds, so
|
||
it is not a no-op dressed as a rule.
|
||
🟡 **The one residual: ties.** Where two elements share a key the game
|
||
sometimes paints them in an order nothing predicts — eight candidates refuted,
|
||
including declaration order, RATC child order, keyframe times, resting X/Y and
|
||
`kind`. Measured cost: on the three screens with ground truth it changes the
|
||
blend of **one element on one screen** (a title glow). Take the stable sort and
|
||
accept that.
|
||
|
||
* **Ⓑ returns to the title, and that title still works.** Ⓐ on the Ⓑ-returned
|
||
title opens the main menu — measured, with Ⓐ on the boot title as the control in
|
||
the same run. (Only the *attract*-returned title is inert, which is an emulator-
|
||
harness curiosity, not a port concern.)
|
||
* **Menu movement, measured off the running game.** ⬆⬇ move one item per press
|
||
and **wrap at both ends** (5-item main menu and 3-item `EXTRAS` both). ⬅➡ do
|
||
nothing. Ⓑ goes up one level **and restores focus to the item you came from**;
|
||
Ⓑ on the main menu returns to the title; Ⓑ on the title does nothing. **Initial
|
||
focus is not stable**: four boots of the same script gave `TUTORIAL`,
|
||
`TUTORIAL`, `NEW GAME`, `NEW GAME`. Do not hardcode it; pick one and say you
|
||
picked it. All **measured**, none of it on the disc.
|
||
* **Each button's destination is measured; its GamePart id is not.**
|
||
**`NEW GAME` → `DIFFICULTY`** (`EASY`/`NORMAL`/`HARD`/`BACK`, opening on
|
||
`NORMAL`) **→ `SELECT DATA`** — it does *not* hang; the run then hits the
|
||
already-documented `sub_823070B0` cache crash, which is not a menu problem.
|
||
`LOAD GAME` → the save-slot list, `TUTORIAL` → the lesson list, `OPTIONS` →
|
||
the settings menu, `EXTRAS` → `GP_TITLE` build 6, `EXTRAS ▸ MISSION SELECT` →
|
||
the stage list. The
|
||
GamePart ids (`3`, `25`, `8`, `5`, `7`) are the entries of the decoded id table
|
||
whose **names match the screens seen**; that binding is authored, not measured.
|
||
|
||
* **A screen change is a fade through black.** Each screen carries a full-screen
|
||
black `.prm` quad that paints last (`pteff00.prm` / `pfeff00.prm`) whose
|
||
keyframe group *is* the transition: black at `T0`, clear by `T1`, clear until
|
||
`T2`, then back to black on exit. ✅ decoded, with a disc-wide check — and in
|
||
`GP_TITLE` exactly the six **screen** builds carry it while the six overlays do
|
||
not. The fade-in length is `T1 − T0` and is read from the file (0.87 s for
|
||
`EXTRAS`, 0.97 s main menu, 4.08 s title). ❔ **The fade-OUT length is not on
|
||
the disc** — the last keyframe has no time slot; **measured ~0.4 s**, twice.
|
||
The black hold measures 0.17–0.23 s. ⚠️ Do not time the fade-in off a capture's
|
||
brightness: the incoming screen's own element animations dominate it and run
|
||
much longer than the quad.
|
||
|
||
* **The movie manifest names every boot-side video by role.** `dat/tables.pak`
|
||
entry `0x5b983a08`: `LOGO1`–`LOGO4` → `logo1.wmv`–`logo4.wmv` (**not on the
|
||
disc** — this is why the splash is a screen), **`ADVERTISE_MOVIE` → `ADV.wmv`**,
|
||
`STAFF_ROLL` → `SYLPH_HD720p_8M-CBR_2ch.wmv`, **`MS00A` → `S00A.wmv`** (the
|
||
new-game intro, 93.9 s, with subtitle + `VOICE_S00A` + a text overlay),
|
||
`MS01A` → `S01A.wmv`. ✅ decoded — and **`S00A.wmv` is now also measured**: matched off the running game at 0.96–1.000 with a strictly monotone playhead over 25 consecutive 0.5 s samples. It starts ~4.5 s after Ⓐ on the save slot.
|
||
**The boot intro and the attract movie are the SAME asset** — there is no
|
||
separate boot slot, and 15 of 19 captured attract frames match `ADV.wmv` with a
|
||
monotonically advancing playhead ending at its full 137 s. One video, not two.
|
||
✅ The attract movie **plays to its end**; nothing cuts it short.
|
||
✅ **A movie is skippable with a single Ⓐ.** Measured: one tap ~45 s into the
|
||
boot brought the title at ~57 s against a ~193 s no-input baseline over three
|
||
boots, with Canary's own keystroke counter proving exactly one press was
|
||
delivered — and the skipped-to title is **fully functional** (`PRESS Ⓐ` plate
|
||
present, Ⓐ opens the main menu). What breaks the boot is *hammering*: 88
|
||
presses left a permanent black screen. One press is fine.
|
||
|
||
* **The menu's sound events are named on the disc.** `tables.pak`'s `SOUNDS`
|
||
record carries 322 `SE_*` cues, and the low block is the UI vocabulary — named
|
||
after the *event*: `SE_UI_CURSOR` (2), `SE_UI_DECIDE` (3), `SE_UI_CANSEL` (4),
|
||
`SE_UI_IMPOSI` (5, the error), `SE_UI_SUB_WIN_OPN`/`_CLS` (8/9),
|
||
`SE_UI_SPLASH_IN`/`_OUT` (12/13). ✅ decoded, full list in
|
||
[`data/se-ui-cues.txt`](../re/data/se-ui-cues.txt). They all live in **one**
|
||
bank — `BANK_SE` is a single field naming `Static.slb`, and 0 of the 322 has
|
||
its own `FILES` entry.
|
||
🟡 **Which event fires which cue is a name match**, not a measurement — strong,
|
||
because these are the authors' own event names, but nobody has watched the game
|
||
emit cue 2 on a d-pad press. The port is authoring it.
|
||
✅ **The SE audio IS extractable — by playing it.** (This corrects an earlier
|
||
"cannot be extracted" on this page.) The disc carries no index: `Static.slb` has
|
||
**0 `RIFF`/`seek`/`WAVE`** and there is **no XACT container anywhere** (0 ×
|
||
`XGSF`/`SDBK`/`WBND` in 1.08 GB of `sound.pak`; no `XACT` string in the
|
||
executable — those extensions are the authoring tool's). But Canary's
|
||
`--xma_param_probe=true` logs every stream's head bytes, and searching them in
|
||
`Static.slb` locates the wave exactly:
|
||
**d-pad move → `0x1ec0` (4 pkts / 8 192 B, 0.533 s); Ⓐ confirm → `0x5d6c0`
|
||
(6 pkts / 12 288 B, 1.016 s); Ⓑ back → `0x0ec0` (2 pkts / 4 096 B, 0.344 s)** —
|
||
every cue the five screens need. Move and back reproduce with identical head
|
||
bytes across **two independent boots**. Each matched at one offset only, and
|
||
the first two are contiguous
|
||
(`0x0ec0 + 4096 = 0x1ec0`) — the bank is a packed run of whole 2 048-byte
|
||
packets with no delimiters, which is why nothing could be scanned for.
|
||
❔ **⬅ and ➡ play nothing distinct** — no new stream on either, so leave them
|
||
silent (the probe dedups, so this excludes a *distinct* invalid cue, not a
|
||
quiet replay of an already-heard one).
|
||
🟡 The Ⓐ press both confirms and opens a screen, so whether its wave is the cue
|
||
named `SE_UI_DECIDE` or `SE_UI_SUB_WIN_OPN` is not separated.
|
||
⚠️ The order is **not** cue-id order, so the index must be observed per cue, not
|
||
counted. ✅ **The slices decode**: 0.533 s, 0.344 s and 1.016 s of mono 48 kHz
|
||
audio with the attack-and-decay shape of UI blips, via
|
||
`tools/re-capture/slb_extract_wave.py` — whose wrapper reproduces a known-good
|
||
`BGM_001` decode to the same 173.808875 s, so it is verified, not assumed.
|
||
|
||
* **The boot sequence is not data-driven — the port authors it.** ❔ Four places
|
||
were checked and the order is in none: `config.ini`'s `[SYSTEM]` is empty, the
|
||
movie manifest carries assets not transitions, the requested GamePart id lives
|
||
**only as a stack argument in flight** (no persistent field, no literal store),
|
||
and the string `GP_ADVERTISE_DEMO` has **zero xrefs**. A transition is a call
|
||
with an id argument, chosen by code.
|
||
🟡 **But the states have names, and the transition uses them.** `sub_821C6458`,
|
||
the title part's state function, calls **`sub_821CC860(…, "<NAME>", 0)`** with
|
||
`TITLE_SCREEN`, `TITLE_MENU` and `LOADING` — the three states measured off the
|
||
game, in the game's own words — and installs the result. So a transition is a
|
||
call with a **name** argument, which is also why `GP_ADVERTISE_DEMO` has no
|
||
xrefs: at this level the screen graph is name-keyed, not id-keyed.
|
||
✅ The argument is now **decoded** at 46 of that function's 48 call sites (28
|
||
distinct names). ⚠️ It is a **generic name-keyed lookup**, not a screen factory —
|
||
its arguments include `BG`, `BLACK`, `FADE`, `FILE`, `KEY`, `PAD`, `SOUND`,
|
||
`GAMMA_RGB`. An earlier version of this page claimed `DIFFICULTY` and
|
||
`EXTRA_MENU` as corroborated screen names; **neither is ever the argument**, and
|
||
only `TUTORIAL_MENU` survives.
|
||
✅ **And the state machine itself is decoded**: `state = this+136`, ten states
|
||
dispatched through a jump table at `0x821C6498`, with **18 transitions** each a
|
||
literal `li`/`stw`. States **0, 2, 8** install `TITLE_SCREEN`, `TITLE_MENU`,
|
||
`LOADING`. `4 → 0` is the only edge back to the title, reached from `2 → 4` —
|
||
which matches Ⓑ-returns-to-title as measured. Full graph in
|
||
[`data/title-state-machine.txt`](../re/data/title-state-machine.txt).
|
||
⚠️ **All of that is ONE PHASE.** `GamePart_Title` dispatches on an outer phase
|
||
field at `this+132` (five values) before reaching any of it: phase 0 is the
|
||
**developer splash** (`sub_821C5690`, the same function the corpus fingered
|
||
independently), phase 4 is the title/menu machine. The ten states and
|
||
eighteen edges above live inside phase 4 alone.
|
||
✅ **State 4's edge conditions are an event code** — `sub_821C6458`'s third
|
||
argument. State 4 is the input-waiting state (reached straight after the menu
|
||
is installed) and handles 6 of 26 events: **`0` → title**, **`3`, `5`, `8`,
|
||
`25` → `LOADING`**, `10` → state 5.
|
||
🟡 What the event *numbers* mean is not decoded — button id, menu row, or
|
||
message id — so the port still takes the button→destination map from
|
||
measurement. The one-to-title / four-to-loading shape matches the five-item
|
||
menu with Ⓑ, but that is a **count-match, not a mapping**. The sequence itself is fully measured:
|
||
splash → `ADV.wmv` → title + `PRESS Ⓐ` → (idle ~8–10 s → `ADV.wmv` in full →
|
||
title) → Ⓐ → main menu, with Ⓑ from the main menu returning to the title.
|
||
|
||
* **`config.ini` picks the language, and that is what picks the EN/JP build.**
|
||
The disc's **only** config file (400 bytes, at the root; one `find` over the
|
||
whole extract). Its `[LANGUAGE]` section maps the console's `XC_LANGUAGE_*`
|
||
value to `eng`/`jpn`/`deu`/`fra`/`esp`/`ita`, defaulting to `eng` — that code
|
||
selects `GP_TITLE`'s English or Japanese build and the `<lang>.pak` families.
|
||
✅ decoded.
|
||
❔ Its `[SYSTEM]` section — which the file's own comment says holds what the
|
||
game and every game part share — is **empty**, so the boot *order* is not in
|
||
disc-side configuration at all.
|
||
* **Five GameParts are named but never registered.** Pulling every
|
||
`RegisterToFactory<N, class silph::GamePart_X>` diagnostic string binds **24 of
|
||
the 29 ids to a C++ class**
|
||
([`data/gamepart-class-ids.txt`](../re/data/gamepart-class-ids.txt)). Ids `1`,
|
||
`2`, `16`, `18`, `28` have no registration site — including
|
||
**`GP_ADVERTISE_DEMO` (1)**, which agrees with the measurement that the attract
|
||
loop is the *title* replaying `ADV.wmv` rather than a separate part. 🟡 an
|
||
argument from a diagnostic string, not from the code.
|
||
Also: **`3` and `4` are both `GamePart_SaveLoad`** — one part, two ids.
|
||
|
||
* **The GamePart id table** — 29 entries at `.rdata 0x820A1630`, confirmed by the
|
||
executable's own registration strings. ✅ This is the screen vocabulary; which
|
||
button reaches which entry is Q4 and is *not* part of it.
|
||
* **The logo splash is a screen, not a video, and it renders.** `logo1`–`logo4`
|
||
are manifest-bound with no `.wmv` on the disc. ✅ The screen is **two** bundles
|
||
in `GP_TITLE.pak`, each shipped twice: entries **10/13** the white
|
||
`SQUARE ENIX` publisher logo, entries **11/14** `GAME ARTS`/`SETA`/`studio
|
||
anima`. ⚠️ They are **invisible to the default `screen list`/`render`** —
|
||
`is_build` rejects them for having no `.rat` child. Pass **`--all`**, which
|
||
renumbers `--build`. So the first of the five screens does have a reference
|
||
composite. ✅ **And a capture**: both halves edge-correlate to their renders at
|
||
**0.91** and **0.98** at zero shift, each rejected by the other (0.03, −0.03).
|
||
✅ **Its timing is DECODED, not just measured.** The splash **fades both ways**
|
||
and the bundles carry the keyframes: `SQUARE ENIX` `[15 30 235 239 251 255]`,
|
||
developer logos `[15 30 190 194 206 210]`, each with an `_eff` glow child on
|
||
`[15 30 45]`. Under `1 unit = 1/60 s` that is a 0.25 s ramp in, a **3.42 s**
|
||
/ **2.67 s** hold, and a **0.33 s** fade out — and a 10 fps capture measures
|
||
≈ 3.5 s / ≈ 2.4 s holds and **≈ 0.3 s** fade-outs. The visible brightness
|
||
overshoot on the way in is the `_eff` glow ramping after the logo, not a
|
||
rendering artifact. Wall-clock: logos ≈ 0.4–4.7 s and ≈ 4.9–8.4 s, then
|
||
`ADV.wmv` from ≈ 8.9 s. ⚠️ The cyan `SQUARE ENIX` at ≈ 9.5 s is the
|
||
**movie's** opening, not a third splash screen.
|
||
* **Sprites carry their own labels.** No font rendering or localisation is needed
|
||
for this milestone. ✅ — and the localisation is *already baked in*: the
|
||
Japanese screens are separate builds in the same pak, not a text swap.
|
||
|
||
## Facts the port will trip over
|
||
|
||
* **`ADV.wmv` is WMV3 video + WMA Pro audio**, 1280×720 at 30 fps, 137 s. Godot 4
|
||
plays only Ogg Theora natively. How to handle that is the port's decision, not
|
||
ours — but it is not optional.
|
||
* **The disc holds 3.3 GB of video, and exactly two files are in scope:**
|
||
`dat/movie/ADV.wmv` (the boot intro *and* the attract loop — one asset) and
|
||
`dat/movie/S00A.wmv` (the new-game intro, 93.9 s). Named from the movie
|
||
manifest, ✅ decoded.
|
||
* **`Static.slb` over-declares its size** by 616 768 bytes — it is the
|
||
highest-offset entry in `sound.pak` and its size field is an allocation size. A
|
||
reader must allow a short read there and only there.
|
||
* **Voice downmixes to mono, music does not.** The left-channel downmix is correct
|
||
for spoken lines and discards half a music mix.
|
||
* **A music bank is TWO STEMS THAT PLAY TOGETHER — do not concatenate.**
|
||
✅ The "10 KB + 4.47 MB + 4.67 MB" reading was wrong: the 10 KB is the bank
|
||
header, and a bank is exactly **two waves of identical duration** (32/32 banks
|
||
on the disc). `BGM_001`'s two are **sample-synchronous** — transient
|
||
correlation peaks at lag 0.00 s over ±5 s, and both stop at the same
|
||
millisecond, 167.663 s. Wave 1 is quieter, far more L/R-decorrelated and has
|
||
almost no bass, so it reads as a surround-rear pair or a second intensity
|
||
layer — 🟡 which of those is unsettled, and `ChannelMask` is `0x0002` on both,
|
||
so the file will not say. Today's 347 s concatenation plays the piece twice,
|
||
the second time as a bass-less stem.
|
||
❔ **And it is not a seamless loop**: `BGM_001` fades out at 167.663 s and is
|
||
followed by 6.15 s of silence, with no loop-point field identified. A menu loop
|
||
is authored.
|
||
✅ **The menu's music is `BGM_103`.** The cue *table* cannot say — its BGM
|
||
entries are numeric — but `GamePart_Title`'s `sub_821C5580` plays **cue 1103**,
|
||
and `BGM_103.slb`'s two declared waves (3 876 864 / 3 930 112 B) are
|
||
byte-for-byte the two streams the XMA probe saw decoding at the main menu.
|
||
Static code, disc census and runtime all agree. The port does **not** have to
|
||
choose a track.
|
||
* **`JNGL_001.slb` does not decode.** One bank in 9 519; its payload is not a whole
|
||
number of XMA1 packets from any known data offset.
|
||
|
||
## What is still open
|
||
|
||
Every question above is answered, so this is the honest residue rather than a
|
||
work queue. **None of it blocks the five screens.** (Q1's seconds conversion was
|
||
here until 2026-08-28 and is now settled.)
|
||
|
||
| | what | why it is stuck |
|
||
|---|---|---|
|
||
| 🟡 | **cue NAME → event binding** (Q8) | event→**wave** is measured for move/confirm/back; that the cursor's wave is the cue *named* `SE_UI_CURSOR` is still read off the authors' identifiers |
|
||
| ❔ | **the other ~319 SE cues** (Q8) | located one at a time by triggering them; only the three the menu needs have been done |
|
||
| 🟡 | **the paint-order tie-break** (Q3) | eight candidates refuted; costs one element's blend on one screen |
|
||
| 🟡 | **GamePart ids behind the buttons** (Q4) | the *screens* are measured; the ids are a name match onto the executable's class names |
|
||
| 🟡 | **the boot transitions in code** (Q6) | both levels decoded — phase at `this+132` (`entry→2`, `2→0`, `2→3`, `3→4`, `4→2`) and state at `this+136` inside phase 4. Phase 0 = splash (`LOGO`), phase 2 = title + `PRESS Ⓐ`, phase 4 = menu. Unknown: what the event *numbers* mean |
|
||
| ❔ | **builds 0/1 and 10/11**, the `DELTASABER` plates (Q2) | never seen anywhere in the boot path, the title-side screens or the attract loop. A mission load is the remaining candidate and this container kills runs before one completes |
|
||
|
||
(An earlier version of this table called the audio items blocked on "an emulator
|
||
whose audio path can be observed". That was wrong — this build already has
|
||
`--xma_param_probe`, and using it settled both.)
|
||
|
||
⚠️ **A container caveat that bounds all of the above:** the emulator has twice
|
||
been killed mid-run with no crash line in its own log (at ~50 s and ~145 s), on a
|
||
box sitting at ~1 GB free with swap exhausted. Dynamic experiments here have to
|
||
fit in roughly two minutes of guest time, which is why several of these residuals
|
||
are unfinished rather than unattempted.
|
||
|
||
## Reference data
|
||
|
||
Committed alongside the findings, so the port can be built without a disc in the
|
||
loop during development:
|
||
|
||
* `sylpheed-cli screen info --build <n> GP_TITLE.pak` — the element table, per
|
||
build, with pivots, kinds, focus links, keyframes and resting poses.
|
||
* `sylpheed-cli screen render` — the reference composite. When the port draws a
|
||
screen, this is what it should be diffed against; where they disagree, one of
|
||
them is wrong and the disagreement is worth reporting back.
|
||
* `docs/re/captures/` — framebuffer captures of the real screens, for anything
|
||
that has to be checked against the game rather than against our renderer.
|