Files
Sylpheed/docs/re/ui-keyframe-time-unit.md
Sylpheed RE agent 8c1a5669b8 re(ui): Q1 -- the interpolation law holds, the group timeline does not
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.
2026-08-28 22:56:17 +00:00

12 KiB
Raw Blame History

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 (the raw draw stream) and 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), 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; output committed at 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.