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.
This commit is contained in:
Sylpheed RE agent
2026-08-28 23:03:55 +00:00
parent 531840b417
commit 6f23f4d113
5 changed files with 139 additions and 7 deletions

View File

@@ -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<usize> {
// 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<usize> {
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;

View File

@@ -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

View File

@@ -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.

View File

@@ -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)

View File

@@ -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…n1`; `W[k]` is the word at block `k`'s `+36`. `W[n1]` 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[k1]`; the **first** keyframe has no time, and
`kf[n1]` takes `W[n2]` — 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.