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

240 lines
12 KiB
Markdown
Raw Permalink 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.
# 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](../port/MISSION.md) **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`](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**:
```text
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`](../../crates/sylpheed-formats/src/ui_layout.rs) 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`](data/kf-record-census.txt). Run it with
```bash
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`](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`](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`](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`](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`](../../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.