diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index 974761d..0f8a35e 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -268,12 +268,27 @@ authored version can be deleted. 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. + ~~🔴 **What stops it:** … `GP_TITLE` build 7 … twins should match in + brightness …~~ **Withdrawn 2026-08-28.** That render difference is **not** + evidence about the time association. It is one element — `ptlogo_eff3.t32`, a + transient bloom that grows `0 % → 200 %` at full alpha while rotating, then + collapses — and it has **no resting pose at all**. `rest()` falls through to + its dwell fallback and returns whichever end of that movement the indexing + lands on: the invisible frame as decoded, the 200 % peak shifted. An 896×389 + sprite at 200 % is larger than the screen, which is the whole 13.1 % and the + whole luminance gap. I was comparing a heuristic, not a decode. + 🔴 **And that is a real defect you should know about:** `rest()`'s dwell rule + is unsound *whenever it runs*. A dwell gap is time spent interpolating **from** + pose k **to** pose k+1; neither is held unless they are equal — which is a + plateau, and the plateau path has already returned by then. **So any element + with no two adjacent identical poses has a guessed rest pose**, in our renderer + and in anything built from it. You can test for that in one pass over the + keyframes; consider flagging it rather than inheriting our guess. + [`structures/ui-resting-pose.md`](../re/structures/ui-resting-pose.md) + 🟡 **The shift is still not adopted**, now for a different reason: it flips + this element to the visibly wrong answer, so it and a decision about + plateau-less elements have to land together, and neither half has a capture to + verify against. **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 diff --git a/docs/re/METHOD.md b/docs/re/METHOD.md index c784afc..3224ee9 100644 --- a/docs/re/METHOD.md +++ b/docs/re/METHOD.md @@ -380,3 +380,19 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the 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. +* **Before believing a render diff refutes a decode, find out which element moved.** + I reported a 13 % render change as evidence against a keyframe-time hypothesis, + on a brightness argument about language twins. One element accounted for all of + it, and that element is a transient flash with **no resting pose** — so the + difference was our own `rest()` heuristic guessing, and it would have guessed + under either reading. A whole-image statistic (pixels changed, mean luminance, + correlation) tells you *that* something moved, never *what*, and an argument + built on it can be entirely about your own code. Localise the diff to an + element and read its keyframes first. +* **A fallback that only runs when its precondition fails is not a fallback.** + `rest()` picks the longest-dwell keyframe when no plateau exists — but a dwell + gap is time spent *moving between* two poses, and the only case where an + endpoint is genuinely held is when the two poses are equal, which is exactly + the plateau the first path already caught. So the fallback is guaranteed to be + guessing every time it is reached. When a heuristic's justification is the same + condition that routes around it, it has no justification. diff --git a/docs/re/REFUTED.md b/docs/re/REFUTED.md index 621eac4..07b979a 100644 --- a/docs/re/REFUTED.md +++ b/docs/re/REFUTED.md @@ -391,3 +391,14 @@ neighbourhood, not just the line. 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) +* "the `GP_TITLE` build 7 render difference is evidence against the keyframe-time + shift" → **mine, and withdrawn.** It is one element, `ptlogo_eff3.t32`, a + transient bloom with no resting pose; `rest()`'s dwell fallback returns a + different endpoint of the same movement under each reading. The brightness + comparison measured our heuristic, not the decode. + [`structures/ui-resting-pose.md`](structures/ui-resting-pose.md) +* "`rest()`'s longest-dwell fallback picks the pose the element rests at" → + **refuted structurally.** A dwell gap is time spent interpolating *between* + poses; an endpoint is only held when the two poses are equal, which is a + plateau, which the earlier path already returned for. Every element that + reaches the fallback has a guessed rest pose. diff --git a/docs/re/structures/ui-resting-pose.md b/docs/re/structures/ui-resting-pose.md index 2334f24..b119aab 100644 --- a/docs/re/structures/ui-resting-pose.md +++ b/docs/re/structures/ui-resting-pose.md @@ -251,3 +251,72 @@ the bounding box of `ptframe1`/`ptframe2` and nothing else"*. trailing run, this rule deliberately leaves them alone — that exclusion is what protects the word PAUSE. If a capture shows one of them drawn, the alpha rule is incomplete and needs a third discriminator. **Which screens are they on?** + +--- + +## 🔴 The dwell fallback is unsound whenever it actually runs (2026-08-28) + +`rest()` tries `rest_plateau()` first and, failing that, picks the keyframe with +the longest **dwell** — the largest gap `t[k+1] − t[k]`. That rule is not sound, +and the reason is structural rather than a tuning problem. + +A gap between `t[k]` and `t[k+1]` is time the element spends **interpolating from +pose `k` to pose `k+1`**. Neither pose is *held* during it — unless the two poses +are equal, which is exactly a plateau, and the plateau path has already handled +that case and returned. **So by the time the fallback runs, it is guaranteed that +no pose is held, and the rule is choosing an endpoint of a movement.** + +### The element that exposed it + +`GP_TITLE` build 7, `ptlogo_eff3.t32` — a transient bloom: + +``` + 46: (98,42) 100%,100% a=0 + 61: (108,72) 0%,0% a=0 +103: (108,72) 200%,200% a=255 r=80 + -: (108,72) 0%,0% a=0 r=150 +``` + +No two adjacent poses are equal, so there is no plateau. The longest gap is +`61 → 103` (42 units), during which the sprite grows from nothing to **200 %** at +full alpha while rotating 80°, then collapses again. The rule returns whichever +end of that movement the indexing lands on: + +| | returned "rest pose" | +|---|---| +| as decoded | `(108,72) 0%,0% a=0` — invisible | +| with `SYLPHEED_KF_TIME_SHIFT=1` | `(108,72) 200%,200% a=255` — the peak | + +An 896×389 sprite at 200 % scale is 1792×778 — larger than the screen. Painting +it permanently is what made build 7's render 13.1 % different and 4.9 luminance +units brighter. + +**The element has no resting pose.** It is a flash; after it plays there is +nothing. Neither answer is *derived* — one of them is merely harmless. + +### 🔴 What this retracts + +Last iteration I reported the build 7 render difference as evidence **against** +the keyframe-time shift, on the reasoning that language twins should match in +brightness. **Withdrawn.** The difference is not about the time association at +all: it is the dwell fallback guessing, and it would guess on this element under +any reading of the times. The brightness comparison was measuring a heuristic, +not a decode. + +What that leaves: the case *for* the shift (a factor of 26 on the hold:fade-out +ratio, [`ui-keyframe-time-unit.md`](../ui-keyframe-time-unit.md)) is no longer +opposed by render evidence — 10 of 11 builds are byte-identical and the 11th +differs only through an unsound heuristic. + +🟡 **It is still not adopted**, for a different reason than before: adopting it +would flip this element to the visibly wrong answer, so the shift and a decision +about what `rest()` should do for plateau-less elements have to land together, +and the second half has no capture to verify against either. + +### ⚠️ What the port should take from this + +Any element whose keyframes contain **no two adjacent identical poses** has a +`rest()` result that is guessed, not decoded — in our renderer and in anything +built from it. That is a property a consumer can test for itself in one pass over +the keyframes, and it is worth flagging in an export rather than silently +inheriting our guess.