From 6f23f4d11342b98be69312ae494ec7096ac5b040 Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Fri, 28 Aug 2026 23:03:55 +0000 Subject: [PATCH] re(ui): the keyframe-time shift is favoured 26x by timing, rejected by a render Follow-up on last iteration's unadopted candidate (+36 holds the NEXT pose's time, not its own). Two new results, pointing opposite ways, and both are reported. FOR, and calibration-free: the observed full-alpha hold : fade-out ratio on palogo_gamearts is 83 : 13 frames = 6.38. The shifted reading predicts 8.00. The current reading predicts 0.25 -- off by 26x. With the glow's 2 units/frame fixed and nothing else free, the current reading says the logo holds full alpha for 2.0 frames; the capture holds it for 83. This is no longer the shape argument the candidate rested on. Also for: rest()'s plain max-dwell fallback picks a=0 -- a transparent pose, for a publisher logo -- under the current reading, and the visible a=255 hold under the shift. Only the rest_plateau special case rescues the render today, and that is the case the port agent reported a bug in. AGAINST: rendering every build of six UI paks under both readings, 10 of 11 compared are byte-identical and one changes -- GP_TITLE build 7, the Japanese twin of build 4, by 13.1% of pixels. Build 4, the one verified against a live capture, is unchanged either way, so the single build the shift moves is the one with no capture to adjudicate it. The proxy goes against the shift: language twins are the same artwork, and build 7 reads 70.94 mean luminance as decoded against build 4's 71.41, but 76.32 shifted. Correlation does not separate them (0.6206 vs 0.6201). These constrain different things -- timing versus pose selection -- and rest() is a heuristic layered on the times, so moving the times moves its tie-breaks. Adopting the shift means revisiting that heuristic in the same change, with no build-7 capture to verify against. Default UNCHANGED. Experiment reachable via SYLPHEED_KF_TIME_SHIFT=1. Shifted-mode suite: 122 passed, 0 failed across 3 suites at commit time. --- crates/sylpheed-formats/src/ui_layout.rs | 13 +++- docs/port/HANDOFF.md | 26 +++++-- docs/re/METHOD.md | 8 ++ docs/re/REFUTED.md | 6 ++ docs/re/ui-keyframe-time-unit.md | 93 ++++++++++++++++++++++++ 5 files changed, 139 insertions(+), 7 deletions(-) diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index 71eac23..d375450 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -450,6 +450,8 @@ fn mark_focused_states(elements: &mut [Element]) { /// Read the placement region that follows the declaration table, filling in each /// element's keyframe group. fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec { + // Experiment gate, default off; see the `time` field below. + let shift_times = std::env::var("SYLPHEED_KF_TIME_SHIFT").as_deref() == Ok("1"); let count = elements.len(); let mut order = Vec::with_capacity(count); let mut pos = DECL_TABLE_AT + count * DECL_ENTRY; @@ -485,7 +487,16 @@ fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec { x: be32(bundle, blk + 28) as i32, y: be32(bundle, blk + 32) as i32, // Only a block wholly inside the group carries a time. - time: (blk + 40 <= group_end).then(|| be32(bundle, blk + 36)), + // + // ⚠️ Which block a time word BELONGS TO is under test — see + // `docs/re/ui-keyframe-time-unit.md`. Set `SYLPHEED_KF_TIME_SHIFT=1` + // to read `W[k-1]` as block `k`'s time ("the word is the time the + // NEXT pose is reached") instead of `W[k]`. Default is unchanged. + time: if shift_times { + (k >= 1).then(|| be32(bundle, blk - KEYFRAME + 36)) + } else { + (blk + 40 <= group_end).then(|| be32(bundle, blk + 36)) + }, }); } elements[idx].keyframes = group; diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index f2f0657..974761d 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -258,12 +258,26 @@ authored version can be deleted. drawn at all (and that is not culling — the same element is submitted down to `a=7` on the way out). Its declared fade-out also spends 12 of 16 units dropping only 23/255 of the alpha; the capture shows no such plateau. - A candidate — that `+36` holds the *next* keyframe's time — fixes the shape - (RMS 4.05 vs 12.13 on two elements) and removes the decoder's "last block's - time is unreadable" special case, but does **not** explain the missing fade-in - or the lateness, and the `_eff` elements cannot discriminate between the two - readings. **Not adopted; the decoder is unchanged.** Take the interpolation law - as settled and treat a multi-keyframe group's absolute timing as unverified. + 🔵 **Followed up, and the candidate is now strongly favoured — but not + adopted.** That `+36` holds the *next* pose's time is supported by a + calibration-free measurement: the observed full-alpha **hold : fade-out** ratio + is 83 : 13 frames = **6.38**, the shifted reading predicts **8.00**, and the + current reading predicts **0.25** — off by **26×**. With the glow's 2 + units/frame fixed and nothing else free, the current reading says the logo + holds full alpha for **2.0 frames**; the game holds it for **83**. The shift + also makes `rest()`'s plain dwell rule pick the visible hold instead of a + fully transparent pose, and removes the decoder's "last block's time is + unreadable" special case. + 🔴 **What stops it:** rendering every build of six UI paks under both readings, + 10 of 11 are byte-identical and one changes — `GP_TITLE` build 7, the Japanese + twin of build 4, by 13.1 % of pixels. Build 4 (the one with a live capture) is + unchanged, so the only build the shift moves is the one with no capture to + judge it, and the proxy goes against: twins should match in brightness, and + build 7 is 70.94 as decoded against build 4's 71.41, but 76.32 shifted. + **Default unchanged**, experiment reachable via `SYLPHEED_KF_TIME_SHIFT=1`. + **What this means for you:** the interpolation *law* is settled (linear, 2 + units/frame); a multi-keyframe group's *timing* is not — do not expect a + 2-frame hold where the game holds 83. ✅ **The seconds conversion is settled: `1 unit = 1/60 s`, a 30 Hz title.** The re-test this page used to name has been run — 300 submitted frames timed on the **idle** title (nothing loading) came out at **28.8 and 28.3 fps**, the same diff --git a/docs/re/METHOD.md b/docs/re/METHOD.md index 2a151e6..c784afc 100644 --- a/docs/re/METHOD.md +++ b/docs/re/METHOD.md @@ -372,3 +372,11 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the fix against A's own next landmark, then apply it to element B in the same frames with **nothing left to tune**. That is what turned "the shapes look different" into "still at 255 nine frames after it should read 32". +* **Two measurements can both be right and still disagree, when a heuristic sits + between them.** A change to the keyframe time association is favoured 26× by an + animation measurement and rejected by a static-render comparison. Both are + sound: `rest()` picks a pose by *longest dwell*, which is a heuristic layered on + top of the times, so moving the times moves its tie-breaks. The mistake would + be to treat one as refuting the other and drop the loser. Name what each + measurement actually constrains — here *timing* versus *pose selection* — before + deciding they conflict. diff --git a/docs/re/REFUTED.md b/docs/re/REFUTED.md index 4ff01ba..621eac4 100644 --- a/docs/re/REFUTED.md +++ b/docs/re/REFUTED.md @@ -385,3 +385,9 @@ neighbourhood, not just the line. after its declared `a=32`, and its declared 80-frame fade-in is never drawn. The `_eff` glows do reproduce, exactly — so this is about the group timeline, not about the interpolation law. [`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md) +* "the `_eff` elements' agreement is the whole case for the keyframe-time shift, + so it stays a shape argument" → superseded. The **hold duration** is + calibration-free and decides it: observed 83 frames of full alpha against a + predicted **2.0** as decoded and **80.0** shifted. The shift is nonetheless + **not adopted** — it moves `GP_TITLE` build 7 by 13 % of pixels, away from its + verified English twin's brightness. [`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md) diff --git a/docs/re/ui-keyframe-time-unit.md b/docs/re/ui-keyframe-time-unit.md index 209de25..267b25a 100644 --- a/docs/re/ui-keyframe-time-unit.md +++ b/docs/re/ui-keyframe-time-unit.md @@ -240,3 +240,96 @@ I first reported the `_eff` glows as holding "a constant α ≈ 33" and read tha 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*. + +Gated by `SYLPHEED_KF_TIME_SHIFT=1`, default unchanged. 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.