Files
Sylpheed/docs/re/ui-keyframe-record-layout.md

12 KiB
Raw Blame History

A keyframe's time word comes before its pose — the placement record, decoded

Status: CONFIRMED, decoded. The field, plus a disc-wide check (13 991 placement groups over 33 archives, three tests, each with a control) and a regression test that runs against the disc (crates/sylpheed-formats/tests/ui_keyframe_record_disc.rs).

This closes the one thing MISSION Q1 still had open — "the interpolation law is settled; the group TIMELINE for multi-keyframe elements is not" — and it dissolves, rather than decides, the argument in ui-keyframe-time-unit.md about whether to adopt SYLPHEED_KF_TIME_SHIFT. Both sides of that argument were reasoning about a missing word that is not missing.

The record

A build bundle's placement region is a run of groups, one per element. A group is an 8-byte header followed by frame_count records of 40 bytes:

u32  element_index
u32  frame_count
                            ┐
u32  time                   │  record 0   ← the time comes FIRST
36   pose                   ┘
u32  time                   ┐  record 1
36   pose                   ┘
…
u32  time                   ┐  record n1
36   pose                   ┘

Total group size: 8 + frame_count * 40.

The 36-byte pose is what the parser already reads correctly — fade ARGB, the three signed rotation words, scale X/Y, tint, X, Y — at offsets 0…35 of the pose, i.e. 4…39 of the record.

What was wrong, and why it looked right for so long

Our parser opened its 40-byte window at the pose, four bytes into record 0, and then read the word at window +36 as that pose's time. That word is record k+1's time — the time of the next pose. Every pose field lands correctly (the window is aligned to a pose, and poses are what it reads); only the time association slips by one.

Two long-standing oddities in the corpus are that off-by-one, and nothing else:

the oddity as recorded what it actually was
"a group's data stops 4 bytes short of its final block's time slot — that word is already the next group's element index" the group is not short. 8 + frames*40 is exact. The parser was reading 4 bytes past the last pose because its window began 4 bytes early
"the last keyframe carries no time"Keyframe::time was Option<u32>, None on every group's final pose the final pose's time is the previous stride's +36 word. Every pose is timed
the stray time = 1869640736 (= "ohnm", ASCII from the next record) that "silently corrupts the max-dwell pick in Element::rest" the same over-read

The first pose's time is the group's lead-in word at header + 8 — the word parse_placements skipped as "one lead-in word" without asking what it was.

The disc-wide check

tools/re-capture/kf_record_census.py, output committed at data/kf-record-census.txt. Run it with

python3 tools/re-capture/kf_record_census.py "$SYLPHEED_DISC"/dat/*.pak

A. The lead-in word takes its place in the sequence

Prepending the lead-in to the shifted time series must give a non-decreasing sequence. 13 991 of 13 991 groups — 100.000 %. (15 493 including single-pose groups, which are trivially ordered; the regression test counts those and also finds 0 out of order.)

B. The 5 058 non-zero lead-ins are times, not padding

If the lead-in were padding, a flag, or a count, 5 058 of them would not all happen to fall strictly below the group's next time.

result
non-zero lead-ins 5 058
strictly less than the next time 5 058 — 100.000 %, none equal
control: another group's lead-in from the same bundle 35 837 / 50 580 = 70.9 %

The gap to the next time piles up at 10 (2 076 groups) and 1 (2 022) — ramp lengths, not arbitrary numbers. And the values themselves read as times: GP_DIALOG entry 9's pzeff02.t32 runs 167 → 197 → 217 → 232; entry 25's pznoise.rat runs 40 → 80 → 230 → 260.

C. A multi-keyframe ramp only runs at a constant rate under this reading

Interpolation between two keyframes is linear — measured against the running game, in ui-keyframe-time-unit.md. So where an author chains three or more keyframes through a monotone alpha ramp, a correct time assignment should often make d(alpha)/d(time) come out constant, and a wrong one should scramble it.

reading multi-segment alpha ramps at a constant rate (±6 %)
corrected — time precedes pose 857 / 1 540 = 55.6 %
old — +36 is the block's own time 0 / 1 042 = 0.0 %

Zero. Not one ramp on the whole disc. The 44 % that are not constant under the corrected reading are genuinely shaped ramps — authors do place keyframes unevenly — so 56 % is a floor, not a fit.

A worked example, pgloading_loop4.rat on GP_TITLE build 11:

times alphas rate per unit
corrected 0, 4, 6, 7, 8, 32, 38 0, 128, 192, 224, 255, 255, 0 32, 32, 32, 31 — then hold, then out
old 4, 6, 7, 8, 32, 38, (none) 0, 128, 192, 224, 255, 255, 0 64, 64, 32, 1.3 — then hold, then an untimed fade-out

What it costs to adopt: nothing, on every static composite

This is the change the corpus previously declined to make, because SYLPHEED_KF_TIME_SHIFT=1 moved GP_TITLE build 7 by 13.1 % of its pixels and made the EN/JP twin brightness disagree (70.94 vs 76.32 against build 4's 71.41). That was the missing first time word, not the shift.

With the lead-in restored as pose 0's time:

check result
GP_TITLE, all 12 builds rendered under both readings 12 / 12 byte-identical PNGs, build 7 included
217 builds over 6 UI archives, rest() pose per element 2 builds differ: GP_TITLE 7 and GP_DIALOG 31
what those 2 differences are ptlogo_eff3.t32: (98,42) vs (108,72)both α = 0, so neither paints. pzstg14_2.t32: one pixel of Y
renders of those 2 builds identical

So the build-7 luminance objection is withdrawn: it was rest()'s dwell fallback picking the 200 %-scale bloom because pose 0 had no time to be compared against. Given a time, the dwell rule picks an invisible pose — the same visible result the old reading produced, by a rule that is now sound.

⚠️ Element::rest() is unchanged and is still a heuristic. The times feed it; they do not fix it. structures/ui-resting-pose.md stands as written.

Against the oracle

The committed log_ui_draws capture of the developer splash (captures/ui-timing/splash-build-quads.csv) is the check that this is the game's reading and not merely a tidier one.

palogo_gamearts_eff.t32 — lead-in 0, W = [15, 30, 45, ], alphas [0, 255, 255, 0]:

phase corrected old captured
fade in t 0→15 (7.5 f) t 15→30 (7.5 f) frames 94101, 7 f
hold t 15→30 (7.5 f) t 30→45 (7.5 f) frames 101107, 7 f
fade out t 30→45 (7.5 f) untimed frames 108115, 8 f

The glow's durations do not discriminate — that was already recorded — but its end does: the old reading cannot say when the fade-out finishes, and the capture plainly shows it finishing.

palogo_gamearts.t32 — lead-in 0, W = [15, 30, 190, 194, 206, 210, ], alphas [0, 0, 255, 255, 232, 32, 0]:

corrected old captured
fade in t 15→30, 7.5 f, in the same window as its own glow t 30→190, 80 f already at 255 when the quad first appears (frame 116)
hold at 255 t 30→190, 80 f t 190→194, 2 f frames ≤116198, ≥ 83 f
fade out t 190→210, 10 f t 194→? , untimed end frames 199211, 13 f

A logo whose bloom layer fades in over 7.5 frames while the logo itself takes 80 is not a thing anyone authored. This replicates the 26× result already in ui-keyframe-time-unit.md and adds the reason.

⚠️ Reach. The capture's absolute frame numbers sit about 18 frames later than the glow-derived calibration t = 2f 171 predicts for the logo — the fade-out starts at frame 199 where the calibration says 180.5. Durations match; the two elements' groups do not appear to start on the same frame. That offset is not explained here and is not needed for this result, which is about which word is which. It is the same lateness ui-keyframe-time-unit.md records as "17 frames late" and leaves open.

And the corpus had already used this reading without noticing

ui-title-build-map.md's splash timing table — written on 2026-08-28 against a 10 fps capture, and agreeing with it to ±0.1 s — reads palogo_sqex.t32's declared [15 30 235 239 251 255 ] as

the table says the OLD reading actually gives the corrected reading gives
hold at α=255 30 → 235 = 3.42 s measured ≈3.5 s 235 → 239 = 0.07 s 30 → 235 = 3.42 s
fade out 235 → 255 = 0.33 s measured ≈0.3 s 239 → ? — the α=0 pose is untimed 235 → 255 = 0.33 s

Its author paired each time with the pose that reaches it, by eye, because that is the only pairing that produces a sensible splash — and then checked it against a capture, which agreed. The record layout is what that pairing was.

What changed in the code

crates/sylpheed-formats/src/ui_layout.rs:

  • parse_placements reads header + 8 as pose 0's time and the previous stride's +36 as pose k's. Every pose gets a time.
  • SYLPHEED_KF_TIME_SHIFT is gone. SYLPHEED_KF_TIME_LEGACY=1 restores the old reading for A/B work.
  • Keyframe::time stays Option<u32> only so the legacy gate still type-checks. Under the default it is always Some.

New test, disc-gated: tests/ui_keyframe_record_disc.rs — every pose timed and ordered (15 493 groups), and ≥ 45 % of multi-segment alpha ramps at a constant rate (the old reading scores 0 %).

⚠️ tests/ui_header_time_disc.rs needed one line: bundles whose every group is a single static pose now report max_time = 0 where before they reported no time at all, and 546 of them were swamping the ratio histogram's zero bucket. The result it guards strengthened — the bound max_time ≤ header +0x08 now holds over 2 859 bundles instead of 2 313, still with 0 violations, and the newly readable times are the latest in every group.

What is NOT established

The executable's own parser was not found. Reach: queried the disassembly database for functions carrying a mulli by 40 (the record stride) and by 60 (the declaration-entry stride) — 26 functions have the first, none have both, and PowerPC compilers synthesise both constants as shift-adds, so the query is weak rather than negative. Nothing here rests on a database row; every number above comes from the disc bytes or from a committed capture. Finding the interpolator would upgrade this from decoded from the container's own arithmetic and a disc-wide census to decoded from the code, and would also settle the 18-frame group-start offset above.

For the port

The pose values you already have do not move. What moves is when each pose is reached:

  • pose k's time is the word before it, not after it;
  • pose 0 has a time — usually 0, but 5 058 groups on the disc start late;
  • the last pose has a time, so an exit ramp now has an end. Anything you authored to cover "the final keyframe has no time" can come out.

Static composites are unaffected: screen render produces byte-identical output on all 12 GP_TITLE builds.