Q1's gate asks whether the ramp is linear. It is, and that result stands: it rests on the splash's _eff glows, which reproduce exactly. This adds the part that does not. The test is a calibration, not a fit. Fix the clock on palogo_gamearts_eff -- declared 15-unit fade-in 0@15 -> 255@30 against captured alphas 34,68,102,136,170,204,238, a constant step of 34, giving t = 2f - 171 -- then check that against the glow's own next landmark: its declared hold ends t=45, predicted frame 108.0, observed last full-alpha frame 107. Then apply it to palogo_gamearts in the same bundle and the same frames, with no free parameter left: declared a=232 at t=206 -> frame 188.5, observed alpha 255 declared a= 32 at t=210 -> frame 190.5, observed alpha 255 The logo is still at full alpha nine frames after it should read 32; its fade-out runs ~17 frames late; its declared 80-frame fade-in is never drawn. Not culling -- the same element is submitted down to a=7 on the way out. Calibration-free version: the declared fade-out spends 12 of 16 units dropping 23/255 of the alpha, and the capture has no such plateau. Candidate, offered and NOT adopted: if +36 held the NEXT keyframe's time, the fade-out shape fits (RMS 4.05 vs 12.13, two elements) and the decoder's "last block's time is unreadable" special case disappears -- the last block would simply have no successor. Rejected for now because it explains neither the missing fade-in nor the lateness, and because the _eff elements cannot discriminate between the readings at all (with four blocks the shift only relabels the phases). Decoder unchanged. Also withdrawn, mine, within the iteration: "the _eff glows hold a constant alpha 33". They ramp 34 -> 255 in steps of 34. I printed the series minimum and read it as its range, with a "14 distinct colours" column sitting next to it saying otherwise.
243 lines
12 KiB
Markdown
243 lines
12 KiB
Markdown
# What a keyframe time is worth, and what shape the ramp has
|
||
|
||
**Status:** ✅ `CONFIRMED` for the two things the port is blocked on — the ramp is
|
||
**linear**, and the animation clock advances **2 keyframe time units per frame the
|
||
game submits**. 🟡 the conversion to *seconds* rests on one further step: the game
|
||
was measured presenting **27.6 frames/second**, which reads as a 30 Hz title
|
||
running at ~92 % under the emulator, giving **1 unit = 1/60 s**. That last step is
|
||
reasoning over a measurement, not a measurement — see *the reach of the negative*
|
||
below.
|
||
|
||
This answers `docs/port/MISSION.md` **Q1**. Everything animated downstream — the
|
||
title wordmark zoom, the main menu's staggered buttons, every fade — is scaled by
|
||
this number.
|
||
|
||
## The measurement
|
||
|
||
The question cannot be asked of a screen that is sitting still, and
|
||
`log_ui_draws` armed *at* a screen only ever sees the steady state. So arm it
|
||
repeatedly through the boot and keep every log: each F10 opens a new numbered
|
||
file and **closes** the previous one, which stays on disk complete. Re-arming
|
||
every 3 s tiles the whole approach to a screen, and whichever file straddles the
|
||
build contains it. `tools/re-capture/screen_build_capture.sh` does this; it sends
|
||
no pad input at all, because Ⓐ during the boot has ended a run on a permanent
|
||
black screen (`canary-scripted-input-traps.md`).
|
||
|
||
The developer-logo splash is the cheap target: it is the first thing the guest
|
||
draws, roughly ten seconds in, and its bundle (`GP_TITLE.pak`, `--all --build 11`
|
||
/ `14` — the `palogo` group) declares short, unambiguous ramps.
|
||
|
||
The capture is committed: [`captures/ui-timing/splash-build-draws.log`](captures/ui-timing/splash-build-draws.log)
|
||
(the raw draw stream) and [`captures/ui-timing/splash-build-quads.csv`](captures/ui-timing/splash-build-quads.csv)
|
||
(`tools/re-capture/kf_time_probe.py`, one row per quad per frame: pixel rect and
|
||
the per-vertex colour whose high byte is the element's fade).
|
||
|
||
Frame numbers are the emulator's **VdSwap count** — frames the guest submitted —
|
||
so an emulator running at 80 % of real time does not move them. That is the whole
|
||
reason to measure in this unit rather than with a stopwatch.
|
||
|
||
### The quads are the splash, by position and size
|
||
|
||
Every sprite in the capture lands on its declared placement:
|
||
|
||
| capture quad | declared element (build 11) | declared placement |
|
||
|---|---|---|
|
||
| `666x65 @ (307,331)` | `palogo_sqex.t32` 666×68 | (309,330) |
|
||
| `525x90 @ (378,155)` | `palogo_gamearts_eff.t32` 521×91 | (379,154) |
|
||
| `262x108 @ (512,306)` | `palogo_seta_eff.t32` 261×110 | (511,305) |
|
||
| `499x72 @ (390,162)` | `palogo_gamearts.t32` 500×71 | (390,164) |
|
||
| `243x86 @ (518,317)` | `palogo_seta.t32` 240×89 | (521,316) |
|
||
|
||
(A quad runs a few pixels under its sprite; that offset is already recorded in
|
||
`ui-title-paint-order-capture.md` and is not what is being measured here.)
|
||
|
||
## Result 1 — the ramp is linear, exactly
|
||
|
||
`palogo_gamearts_eff` and `palogo_seta_eff` declare a fade-in of
|
||
`t=15 a=0 → t=30 a=255`: a **15-unit** ramp. Their fade alpha, frame by frame,
|
||
straight out of the capture:
|
||
|
||
| frame | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 |
|
||
|---|---|---|---|---|---|---|---|---|
|
||
| alpha | `0x22` 34 | `0x44` 68 | `0x66` 102 | `0x88` 136 | `0xAA` 170 | `0xCC` 204 | `0xEE` 238 | `0xFF` 255 |
|
||
| ⇒ units into the ramp *k* | 2 | 4 | 6 | 8 | 10 | 12 | 14 | ≥15 |
|
||
|
||
`round(255·k/15) = 17k` reproduces **all seven samples with zero error**. An eased
|
||
ramp cannot do that: any ease-in/ease-out would bend the first and last steps, and
|
||
these are a constant 34 throughout. **Linear interpolation, refuted-nothing-left.**
|
||
|
||
The same element's fade-*out* gives the same law with a one-count offset —
|
||
`255·(1−k/15) − 1` = 254, 220, 186, 152, 118, 84, 67, 33 for
|
||
*k* = 0, 2, 4, 6, 8, 10, 11, 13, which is exactly what the capture holds on frames
|
||
108–115.
|
||
|
||
## Result 2 — 2 time units per submitted frame
|
||
|
||
Read *k* off the row above: 2, 4, 6, 8, 10, 12, 14 on seven consecutive submitted
|
||
frames. **The clock advances 2.000 units per frame, over six consecutive
|
||
intervals, with no residual.** The fade-out shows the same 2-per-frame step with
|
||
one single-unit frame (*k* goes 10 → 11 → 13), so the quantum underneath is 1 unit
|
||
and the normal step is two of them.
|
||
|
||
Cross-checks in the same capture, all consistent:
|
||
|
||
* the declared hold `t=30 → t=45` (15 units) is held for frames 101–107, **7
|
||
frames** ≈ 14 units;
|
||
* `palogo_sqex`'s fade-out steps in multiples of 17 per frame (−34 with the odd
|
||
−17), the same 255/15 quantum;
|
||
* the `gamearts`/`seta` pair holds full alpha for frames 116–198 and fades out
|
||
over frames 199–211.
|
||
|
||
## Result 3 — the conversion to seconds, and how far it reaches
|
||
|
||
300 submitted frames took **10.87 s** wall-clock, measured by arming one bounded
|
||
capture over the splash and timing it to its own `[UI-CAP] done` line:
|
||
**27.6 frames/second**.
|
||
|
||
With 2 units per frame that is **55 units/second measured**. The two readings that
|
||
fit are:
|
||
|
||
* **30 Hz present, 60-unit-per-second timeline** — the emulator running at 92 % of
|
||
real time. A 30 Hz renderer stepping a 60 Hz timeline is exactly the 2-units-
|
||
per-frame quantum that was measured, and 92 % is an ordinary number for this
|
||
container.
|
||
* 60 Hz present at 46 % of real time, giving a 120-unit-per-second timeline. This
|
||
requires the emulator to be running at *less than half speed* while drawing four
|
||
quads over a black screen, and it requires the game's timeline to tick at 120 Hz.
|
||
|
||
**Taking the first**: `1 unit = 1/60 s`. The title build (`GP_TITLE.pak` build 4,
|
||
t = 16 … 269) is then **4.2 s**, and the main menu build (build 5, t = 12 … 80)
|
||
**1.1 s**.
|
||
|
||
**The reach of this negative** *(as written before the test below — kept for the
|
||
reasoning)*: the present rate was measured *during the boot splash*, where the
|
||
guest is also streaming from the ISO, so it is a lower bound on the emulator's
|
||
speed and cannot by itself exclude the 60 Hz reading. What would settle it is the
|
||
same 300-frame timing taken on the **idle title screen**, where nothing is
|
||
loading — if that also comes out near 28 fps the game is 30 Hz and the unit is
|
||
1/60 s; if it doubles to ~55 fps the game is 60 Hz and every duration on this page
|
||
halves.
|
||
|
||
## ✅ That test was run — 2026-08-28. The game is 30 Hz; `1 unit = 1/60 s`
|
||
|
||
Reached the title with a single Ⓐ to skip the intro
|
||
([`movie-binding.md`](movie-binding.md)), let it settle 12 s so nothing was
|
||
loading, then armed the bounded 300-frame capture and timed it to its own
|
||
`[UI-CAP] done` line:
|
||
|
||
| | 300 frames in | rate |
|
||
|---|---|---|
|
||
| idle title, trial 1 | 10.40 s | **28.8 fps** |
|
||
| idle title, trial 2 | *arm produced no capture* | — |
|
||
| idle title, trial 3 | 10.60 s | **28.3 fps** |
|
||
| *(prior)* boot splash | 10.87 s | 27.6 fps |
|
||
|
||
**~28.5 fps on an idle title — the same rate as the loading splash.** By this
|
||
page's own criterion that settles it: the title presents at **30 Hz**, the
|
||
timeline ticks at **60 units/second**, and **`1 unit = 1/60 s`**.
|
||
|
||
**The 60 Hz reading is now excluded, not merely disfavoured.** It would require
|
||
the emulator to be running at 47 % of real time *while sitting idle on a static
|
||
title* — and the capture says that screen costs **1 526 draws over 300 frames,
|
||
about 5 draws per frame**. Nothing there is expensive enough to halve the
|
||
emulator's speed, and the splash and the idle title returning the same rate is
|
||
exactly what a constant ~95 %-of-real-time emulator looks like.
|
||
|
||
So the durations on this page stand as written: title build 4 ≈ **4.2 s**, main
|
||
menu build 5 ≈ **1.1 s**, and `EXTRAS` build 6's declared fade-in ≈ **0.87 s**.
|
||
|
||
⚠️ Still **measured, not decoded** — no field on the disc says "sixtieths of a
|
||
second". What changed is that the measurement now has an idle-state control and
|
||
the competing reading is ruled out.
|
||
|
||
## What this does not say
|
||
|
||
* Nothing here is a *decode*: no field on the disc says "sixtieths of a second".
|
||
The number is measured from the running game, and the port is transcribing a
|
||
measurement, not a disc value.
|
||
* The splash drew `palogo_sqex`, then `palogo_gamearts` + `palogo_seta` with their
|
||
`_eff` sprites — but **not** `palogo_anima` (declared at 388×136 @ (446,449)) and
|
||
not `palogo_sqex_eff`, in the ~7 s of capture after the pair faded out. The
|
||
`_sqex_eff` absence is explainable (the capture joined the SQUARE ENIX logo
|
||
mid-hold); the missing studio-anima logo is not, and is left as an open
|
||
observation for Q2 rather than a claim.
|
||
|
||
---
|
||
|
||
## 🟡 Open: the `palogo_*` LOGO elements do not play their declared timeline
|
||
|
||
**Added 2026-08-28.** Everything above stands — it rests on the `_eff` glows, and
|
||
they reproduce exactly. What follows is a **different element in the same bundle
|
||
and the same capture**, and it does not.
|
||
|
||
Reproduce with
|
||
[`tools/re-capture/splash_ramp_check.py`](../../tools/re-capture/splash_ramp_check.py);
|
||
output committed at [`data/splash-ramp-check.txt`](data/splash-ramp-check.txt).
|
||
|
||
### Why this is a test and not a fit
|
||
|
||
The clock is calibrated on `palogo_gamearts_eff` — its declared 15-unit fade-in
|
||
`0@15 → 255@30` against its captured alphas 34, 68, 102, 136, 170, 204, 238, a
|
||
constant step of exactly 34. That gives `t = 2f − 171`, and the calibration
|
||
checks itself: the glow's declared hold ends at `t=45`, predicted **frame 108.0**,
|
||
and the observed last full-alpha frame is **107**.
|
||
|
||
That calibration is then applied to `palogo_gamearts` — same bundle, same frames,
|
||
**no free parameter left**:
|
||
|
||
| declared | predicted frame | observed alpha |
|
||
|---|---|---|
|
||
| `a=0` at `t=15` | 93.0 | not drawn |
|
||
| `a=0` at `t=30` | 100.5 | not drawn |
|
||
| `a=255` at `t=190` | 180.5 | 255 |
|
||
| `a=232` at `t=206` | 188.5 | **255** |
|
||
| `a=32` at `t=210` | 190.5 | **255** |
|
||
|
||
The logo is still at full alpha nine frames after it should have been at `a=32`.
|
||
Its fade-out actually runs frames **199–211**, some 17 frames late, and its
|
||
declared 80-frame fade-in (`t=30→190`) is **not drawn at all** — the element's
|
||
first appearance, frame 116, is already at 255.
|
||
|
||
⚠️ "Not drawn" is not a culling artefact: the same element is submitted all the
|
||
way down to `a=7` on the way out, so low-alpha quads plainly do reach the GPU.
|
||
|
||
### The shape mismatch, which needs no calibration at all
|
||
|
||
The declared fade-out spends **12 of its 16 units** dropping only **23/255** of
|
||
the alpha — a near-flat leg — then **200/255** in the remaining 4. The captured
|
||
per-frame drops are `1, 11, 6, 22, 34, 33, 17, 33, 33, 17, 25, 8, 8`. There is no
|
||
near-flat leg.
|
||
|
||
### 🟡 A candidate, offered as one and NOT adopted
|
||
|
||
If the word at `+36` were the time of the **next** keyframe rather than of its own
|
||
block, the logo's fade-out would read `255@190 → 232@194 → 32@206 → 0@210`:
|
||
slow, fast, slow — which is the captured shape. Fitting both readings to the
|
||
captured fade-out gives RMS alpha error **4.05** shifted against **12.13** as
|
||
decoded, on two elements independently (`gamearts` and `sqex`).
|
||
|
||
It also removes a special case. The decoder currently notes that a group's data
|
||
"stops 4 bytes short of its final block's time slot", so the last block's time is
|
||
unreadable. Under the shifted reading the last block simply *has no successor*,
|
||
so it has no time word — the same bytes, no special case.
|
||
|
||
**Not adopted, for three reasons:**
|
||
|
||
1. It does not explain the missing fade-in, which is the larger anomaly.
|
||
2. It does not fix the 17-frame lateness of the fade-out.
|
||
3. The `_eff` elements **do not discriminate** between the two readings — I
|
||
checked: with four blocks the shift merely relabels which phase is which, and
|
||
both reproduce the observed fade-in / hold / fade-out. So the entire case for
|
||
the shift rests on one element's fade-out shape.
|
||
|
||
**The decoder is unchanged.** 🟡 What a port should take from this: the
|
||
interpolation *law* is settled (linear, 2 units per frame), and the *group
|
||
timeline* for multi-keyframe elements is not.
|
||
|
||
### 🔴 One thing I got wrong in the course of this
|
||
|
||
I first reported the `_eff` glows as holding "a constant α ≈ 33" and read that as
|
||
contradicting the declared 255 plateau. They do no such thing — they ramp 34 →
|
||
255 in exact steps of 34. I had printed only the series' **minimum** and read it
|
||
as its range. Withdrawn; the trap is in [METHOD.md](METHOD.md).
|