formats: a keyframe's time comes before its pose, and none of them was missing
The placement region is `frames` records of `{u32 time; 36-byte pose}` after an
8-byte header, so the time word PRECEDES the pose it belongs to. Our parser's
40-byte window opened at the pose, four bytes into the record, and then read the
word at its `+36` as that pose's time -- which is the NEXT pose's. Every pose
field was right; only the time association slipped by one.
Two things the corpus has carried for weeks are that off-by-one and nothing
else: "a group's data stops 4 bytes short of its final block's time slot", and
"the last keyframe carries no time". The group is not short (8 + frames*40 is
exact) and no time is missing -- the first pose's time is the lead-in word at
`header + 8` that `parse_placements` skipped without asking what it was.
Disc-wide, 33 archives, 13 991 groups, each test with a control:
A lead-in prepended to the shifted times is non-decreasing 13991/13991
B a non-zero lead-in is strictly below the next time 5058/5058
control (another group's lead-in, same bundle) 70.9%
C multi-segment alpha ramp at a constant rate, corrected 857/1540
the same, under the old reading 0/1042
C is the one that cannot be argued with: interpolation between keyframes is
linear, and under the old reading not one multi-keyframe ramp on the disc comes
out at a constant rate.
Adoption is free on every static composite, which is what the corpus previously
declined it over. `SYLPHEED_KF_TIME_SHIFT=1` moved GP_TITLE build 7 by 13.1% of
its pixels because it left pose 0 untimed; with the lead-in restored, all 12
GP_TITLE builds render byte-identically, and across 217 builds in six archives
only two elements pick a different rest pose -- both times between two poses
that are equally invisible.
`SYLPHEED_KF_TIME_SHIFT` is gone; `SYLPHEED_KF_TIME_LEGACY=1` restores the old
reading for A/B work.
ui_header_time_disc needed one line: 546 bundles whose every group is a single
static pose now report max_time = 0 where they previously reported no time at
all. Excluding them, the result it guards strengthened -- the bound holds over
2 859 bundles instead of 2 313, still with zero violations.
Not established: the executable's own parser. Reach is written down.
docs/re/ui-keyframe-record-layout.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsxw1A9JseUw99Yw1ZRQzY
This commit is contained in:
14
docs/re/data/kf-record-census.txt
Normal file
14
docs/re/data/kf-record-census.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
paks scanned : 33
|
||||
placement groups : 13991
|
||||
|
||||
A. lead-in prepended to the shifted times is non-decreasing
|
||||
13991/13991 = 100.000%
|
||||
|
||||
B. non-zero lead-in is strictly less than the next time
|
||||
5058/5058 = 100.000%
|
||||
control (another group's lead-in, same bundle): 35837/50580 = 70.852%
|
||||
gap to the next time, most common: [(10, 2076), (1, 2022), (30, 116), (40, 80), (12, 78), (90, 78), (149, 78), (20, 78)]
|
||||
|
||||
C. constant d(alpha)/d(time) across a multi-segment ramp
|
||||
corrected (time precedes pose): 857/1540 = 55.649%
|
||||
old (+36 is own time) : 0/1042 = 0.000%
|
||||
224
docs/re/ui-keyframe-record-layout.md
Normal file
224
docs/re/ui-keyframe-record-layout.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# 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 n−1
|
||||
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 94–101, **7 f** |
|
||||
| hold | t 15→30 (7.5 f) | t 30→45 (7.5 f) | frames 101–107, **7 f** |
|
||||
| fade out | t 30→45 (7.5 f) | **untimed** | frames 108–115, **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 ≤116–198, **≥ 83 f** |
|
||||
| fade out | t 190→210, **10 f** | t 194→? , untimed end | frames 199–211, **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.
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user