# What a keyframe time is worth, and what shape the ramp has > ## ✅ 2026-08-29 — the argument on this page about WHICH BLOCK OWNS A TIME is over > > It was never a choice between two readings. A placement group is > `frames` records of `{u32 time; 36-byte pose}` after an 8-byte header, so the > time word **precedes** its pose; the group's "lead-in word" is pose 0's time, > and **no** time is missing. `SYLPHEED_KF_TIME_SHIFT` had the association right > and pose 0 untimed, which is the only reason it looked like it cost build 7 > 13.1 % of its pixels. Decoded disc-wide, with controls, in > [`ui-keyframe-record-layout.md`](ui-keyframe-record-layout.md); the gate is now > `SYLPHEED_KF_TIME_LEGACY=1`. > > **Everything else on this page stands** — the ramp is linear, the clock advances > 2 units per submitted frame, and `1 unit = 1/60 s` is measured. Read the > sections below with that correction applied: where a table pairs a time with a > pose, the pairing is the corrected one. **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: ⚠️ The rows span **two** bundles, not one: `palogo_sqex` is in `--all --build 10` and the `gamearts`/`seta` group is in `--all --build 11`. This table said "build 11" over all five until a corpus-wide index audit ([`structures/build-ordinal-vs-entry.md`](structures/build-ordinal-vs-entry.md)) checked the header against the bundle. Every placement below re-verified and correct; only the label was wrong. | capture quad | declared element | in bundle | declared placement | |---|---|---|---| | `666x65 @ (307,331)` | `palogo_sqex.t32` 666×68 | `--all --build 10` | (309,330) | | `525x90 @ (378,155)` | `palogo_gamearts_eff.t32` 521×91 | `--all --build 11` | (379,154) | | `262x108 @ (512,306)` | `palogo_seta_eff.t32` 261×110 | `--all --build 11` | (511,305) | | `499x72 @ (390,162)` | `palogo_gamearts.t32` 500×71 | `--all --build 11` | (390,164) | | `243x86 @ (518,317)` | `palogo_seta.t32` 240×89 | `--all --build 11` | (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). ### ✅ 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…n−1`; `W[k]` is the word at block `k`'s `+36`. `W[n−1]` 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[k−1]`; the **first** keyframe has no time, and `kf[n−1]` takes `W[n−2]` — which exists. Nothing is missing and nothing is special-cased: `W[k]` is simply *the time at which pose `k+1` is reached*. 🔴 **This sentence used to read "Gated by `SYLPHEED_KF_TIME_SHIFT=1`, default unchanged."** That gate **no longer exists** — it was removed with the [record-layout fix](ui-keyframe-record-layout.md) and appears **nowhere in `crates/`**. ⚠️ A reader following the old instruction sets an environment variable that does nothing, gets default behaviour, and concludes the two readings agree: **a stale instruction that no-ops manufactures a false confirmation**, which is worse than a stale description. 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. --- ## ✅ Replicated: three elements, two screens — evidence that SUPPORTED the shifted reading ⚠️ **This heading used to end "and the shifted reading wins every time".** Demoted because the shifted reading was itself superseded: the [record-layout fix](ui-keyframe-record-layout.md) established the same association by a better route and timed pose 0 as well, which the shifted reading never did. The evidence below stands; it is now evidence for the *corrected* reading, and the gate it was collected behind no longer exists. **2026-08-29.** The case for reading `+36` as *"the time the NEXT pose is reached"* rested on one element's fade-out shape, then on one element's hold duration. Both splash halves supply more, and they all say the same thing. Phase durations in frames (2 units/frame, from the `_eff` glow ramp): | element | screen | observed hold at `a=255` | as decoded | shifted | |---|---|---|---|---| | `palogo_gamearts` | developer splash | **83 f** | 8 f | **80 f** | | `palogo_seta` | developer splash | **83 f** | 6 f | **80 f** | | `palogo_sqex` | publisher splash | **≥ 77 f** * | 6 f | **102 f** | \* the capture opens mid-hold at frame 1, so 77 is a floor, not the length. The two readings predict **opposite structures** for these elements. On `palogo_gamearts`, as decoded: `hold 8f, in 80f, hold 2f, out 6f, out 2f` — an eighty-frame *fade-in* and a two-frame hold. Shifted: `in 8f, hold 80f, out 2f, out 6f, out 2f` — an eight-frame fade-in and an eighty-frame *hold*. The capture shows a **83-frame hold** and no fade-in at all. ### And the elements that cannot discriminate are not contradicted `palogo_gamearts_eff` observed `in 7f, hold 7f, out 8f`. As decoded it reads `in 8f, hold 8f`; shifted it reads `hold 8f, out 8f`. Both fit — with only four blocks the shift relabels which phase is which without changing any duration. So the glows, which are what Q1's linear law was measured on, **do not argue against the shift**; they simply say nothing about it. ### 🟡 Why the decoder's default is still unchanged The one thing that opposes the shift is `Element::rest()` on `ptlogo_eff3.t32`, where the shifted reading makes the longest-dwell fallback return the bloom's 200 % peak instead of an invisible frame. But that fallback is **unsound whenever it runs** ([resting pose](structures/ui-resting-pose.md)) — it returns an endpoint of a movement, and neither endpoint is held. Checked: the shift does not fix it either, so this is not a case of two readings disagreeing about the times. It is a heuristic guessing, in both. **So the two questions are separable, and only one of them has evidence.** The times govern animation; `rest()` picks a static pose through a rule that consults them only after its own precondition has failed. ### ⚠️ What a port should do For **animation timing**, read `+36` as the *next* pose's time: three elements across two screens, each off by an order of magnitude under the other reading. For **static composites**, nothing changes — `screen render` is unaffected, and the five screens' correlations stand ([acceptance](five-screens-acceptance.md)). 🟡 Classified **measured, not decoded**: this is three elements in one game screen family, not a disc-wide field check, and our own decoder still defaults to the other reading behind `SYLPHEED_KF_TIME_SHIFT=1` — ⚠️ **a gate since removed; there is no way to select the old reading today, and nothing to set.** ## ⚠️ 2026-08-30 — "the game presents at 27.6 fps" is not separated from "my container runs the guest slow" `sylpheed-port` warned that their boot timings carry a host deficit — **+6.7 %…+6.9 % long, 5 runs, both videos, quiet, resolution-independent**. ⚠️ This reached this page twice in superseded forms: first as a flat **+6.7 %**, then as a spread **+2.4 %…+6.7 %** with a 720p-versus-432p contrast. **Both are struck** — the low figure came from a *contended* run, and nothing about resolution survives. The deficit is real; the mechanism they first described is not Every rate here is a frame count over a wall-clock window on one container: | | window | rate | |---|---|---| | idle title, trials 1–3 | 10.40 / — / 10.60 s | 28.8 / 28.3 fps | | boot splash | 10.87 s | 27.6 fps | 🔴 **A guest running at ~92 % of real time produces exactly these numbers**, and so does a game genuinely presenting at 27.6 fps. The two hypotheses are **indistinguishable by any measurement on this page**, and the three trials do not separate them — they share the container, so they are three samples of one confound, not three independent confirmations. This is the same shape as the plate-pulse phase lock: agreement across runs that measures the instrument. The same applies to the **8.5 %** splash-dwell excess in [`boot-order-and-splash-dwell.md`](boot-order-and-splash-dwell.md) — `1.085` and `1/0.92` are the same number arriving from the two readings. ✅ **The port is not exposed.** It authors **declared units** — 240 and 195 — and reads seconds from nothing here. That row is still `decoded`, and its evidence is the disc, not the clock. ❌ **What will not settle it, and I checked:** the `BGM_103` loop figures. Both the `9.44 s` start and the `61.87 s` cycle are wall-clock derived — that page states it outright, *"two derivations, neither converting bits to seconds"* — so they carry the same container pacing and cannot audit it. **What would settle it** is a media-length reference: play an asset whose duration is fixed by its own data — a movie, or a wave with a declared byte rate — and compare its wall clock against its media length in *this* container. Not run.