Files
Sylpheed/docs/re/ui-keyframe-time-unit.md
Sylpheed RE agent d18a99a055 re(ui): the keyframe-time shift is favoured 26x by timing, rejected by a render
Follow-up on last iteration's unadopted candidate (+36 holds the NEXT
pose's time, not its own). Two new results, pointing opposite ways, and
both are reported.

FOR, and calibration-free: the observed full-alpha hold : fade-out ratio
on palogo_gamearts is 83 : 13 frames = 6.38. The shifted reading predicts
8.00. The current reading predicts 0.25 -- off by 26x. 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 capture holds it for 83. This
is no longer the shape argument the candidate rested on.

Also for: rest()'s plain max-dwell fallback picks a=0 -- a transparent
pose, for a publisher logo -- under the current reading, and the visible
a=255 hold under the shift. Only the rest_plateau special case rescues
the render today, and that is the case the port agent reported a bug in.

AGAINST: rendering every build of six UI paks under both readings, 10 of
11 compared are byte-identical and one changes -- GP_TITLE build 7, the
Japanese twin of build 4, by 13.1% of pixels. Build 4, the one verified
against a live capture, is unchanged either way, so the single build the
shift moves is the one with no capture to adjudicate it. The proxy goes
against the shift: language twins are the same artwork, and build 7 reads
70.94 mean luminance as decoded against build 4's 71.41, but 76.32
shifted. Correlation does not separate them (0.6206 vs 0.6201).

These constrain different things -- timing versus pose selection -- and
rest() is a heuristic layered on the times, so moving the times moves its
tie-breaks. Adopting the shift means revisiting that heuristic in the
same change, with no build-7 capture to verify against.

Default UNCHANGED. Experiment reachable via SYLPHEED_KF_TIME_SHIFT=1.
Shifted-mode suite: 122 passed, 0 failed across 3 suites at commit time.
2026-08-28 23:03:55 +00:00

336 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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·(1k/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
108115.
## 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 101107, **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 116198 and fades out
over frames 199211.
## 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 **199211**, 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).
### ✅ Settled the next iteration: the hold duration decides it
The section above left the candidate unadopted because its whole case rested on
one fade-out's *shape*. There is a much blunter measurement in the same capture,
and it needs **no calibration at all** — just the ratio of two observed spans:
| | full-alpha hold : fade-out | ratio |
|---|---|---|
| **observed** | 83 frames : 13 frames | **6.38** |
| as decoded | 4 units : 16 units | 0.25 — **off by 26×** |
| `+36` = the NEXT pose's time | 160 units : 20 units | **8.00** |
With the glow's 2 units/frame fixed and nothing else free, the current reading
predicts `palogo_gamearts` holds full alpha for **2.0 frames**. The capture holds
it for **83**. The shifted reading predicts **80.0**.
That is no longer a shape argument, and it is not a fit — it is a factor of 26.
### A fifth argument, from the corpus's own behaviour
`Element::rest()` tries `rest_plateau()` first and falls back to picking the
keyframe with the longest **dwell**. Run that fallback on `palogo_gamearts`:
| reading | dwell winner |
|---|---|
| as decoded | **`a=0`** (dwell 160) — a fully transparent pose, for a logo |
| shifted | **`a=255`** (dwell 160) — the visible hold |
Under the current reading the plain dwell rule picks an invisible pose for a
publisher logo, and only the `rest_plateau` special case rescues the render. That
special case has been repeatedly troublesome — it is the one the port agent
reported a bug in on 2026-08-28. Under the shifted reading the dwell rule is
simply correct on its own.
### What the shift is, exactly
Blocks `0…n1`; `W[k]` is the word at block `k`'s `+36`. `W[n1]` lies outside the
group, because a group owns `frames·40 4` bytes.
* **as decoded:** `kf[k].time = W[k]`; the last keyframe has no time, as a special
case for the missing word.
* **shifted:** `kf[k].time = W[k1]`; the **first** keyframe has no time, and
`kf[n1]` takes `W[n2]` — which exists. Nothing is missing and nothing is
special-cased: `W[k]` is simply *the time at which pose `k+1` is reached*.
Gated by `SYLPHEED_KF_TIME_SHIFT=1`, default unchanged. See below for what
adopting it would cost.
### 🔴 …and what it costs — which is why the default is UNCHANGED
Rendering every build of six UI paks under both readings, and byte-comparing:
| | result |
|---|---|
| builds rendered identically | 10 of 11 compared |
| builds that changed | **`GP_TITLE` build 7** — 13.1 % of pixels |
Build 7 is the **Japanese twin of build 4**, and build 4 — the one verified
against a live capture — is byte-identical under both readings. So the one build
the shift moves is the one with no capture to adjudicate it. The available proxy
says the shift makes it *worse*:
| build 7 rendered | corr vs verified build 4 | mean luminance |
|---|---|---|
| as decoded | 0.6206 | **70.94** (build 4: 71.41) |
| shifted | 0.6201 | 76.32 |
Two language twins are the same artwork with different text, so their brightness
should match closely. As decoded it matches to **0.5**; shifted it is **4.9**
brighter. Correlation does not separate them (0.6206 vs 0.6201) — the luminance
does.
### The honest position
These two results are about **different things**, and both are real:
* For **animation timing** — what the port actually needs to play a screen — the
shifted reading is favoured by a factor of 26 on a calibration-free
measurement, and the current reading predicts a 2-frame hold where the game
holds 83.
* For **resting-pose selection** — what `screen render` does — the current
reading matches the EN/JP twin brightness and the shifted one does not.
They can both be true: `rest()`'s dwell fallback is a heuristic layered *on top
of* the times, and moving the times moves its tie-breaks. Adopting the shift
would mean revisiting that heuristic in the same change, and there is no capture
of build 7 to verify the result against.
**So the default stays as decoded.** 🟡 The port should treat a multi-keyframe
group's *timing* as unverified — and specifically should not expect a
2-frame hold where the game holds 83 — while `rest()` and `screen render` remain
as they are.