# ✅ Option A implemented — the reference renderer rotates. ⚠️ It does not close the title. > 🔴 **Two claims below are withdrawn (2026-08-29), and the baseline is > unreproducible.** See [`ui-settle-time.md`](ui-settle-time.md). > > * **"Flat. No minimum."** is explained, not a property of rotation: > `ComposeOptions::at` was posing **leaves only**, so the scan moved the light > sweeps and never touched the top-level flashes. No `t` could have helped. > `at` now poses every element and the scan has a clear optimum. > * **"our renderer still does not draw `ptlogo1` / `ptlogo2` at all"** is > **wrong**. Both are drawn. The four elements the diagnostic reported are kind > `0x4` ghost instances, skipped deliberately. Hiding the real ones makes the > error *worse*. > * ❔ The **10.92** baseline is **not reproducible** — the same command gives > 14.07 at this document's own pre-change tag and 14.07 today — so the "1.7 % > better" verdict rests on a recipe that was not recorded. **Decision:** the human chose **Option A** (2026-08-29) — teach `sylpheed-formats`' own renderer to draw `rotation_deg`, so it and the port stay comparable and `verify-screen` keeps meaning *"someone is wrong"*. **Status:** ✅ implemented and controlled. 🔴 **and it does not measurably improve the title against the capture we hold** — reported here rather than quietly, because the improvement was the reason for doing it. ## What changed Three pieces, because rotation alone does nothing on the title: 1. **`blit` gained a rotated path.** `rotation_deg != 0` draws by **inverse mapping** over the rotated bounding box; forward-mapping a rotation leaves gaps. Rotation turns about the element's **pivot**, whose absolute position `(kf.x + pivot_x, kf.y + pivot_y)` is invariant under scale. ✅ `rotation_deg == 0` keeps the original forward-mapped path **byte for byte**, so screens that do not rotate cannot regress. 2. **`compose` draws a nested `.rat` leaf when the leaf carries geometry the parent does not** — the title's sweeps are exactly that case (parent fixed at (441,270) scale 100 %, leaf holding 600 %/800 % and ±30°/−45°). ⚠️ Not a blanket rule: a button's leaf *duplicates* its parent and the parent wins ([`ui-leaf-vs-parent-alpha.md`](ui-leaf-vs-parent-alpha.md)), so the leaf is used only when its pose genuinely differs. ⚠️ A leaf element resolves **no sprite of its own** — names resolve against the bundle a build was parsed from, and a leaf is parsed from its own slice. Its *name* is the sprite name, looked up in the parent bundle's table. 3. **`--at ` / `ComposeOptions::at`**, because the sweeps hold off-screen at `x = 1521` and a resting composite therefore *omits* them. ## 🔴 A trap this found, and it cost a wrong number first Posing **everything** at one global time is wrong. A top-level group's final keyframes are its **exit ramp** — the fade-out played when the screen leaves — and `rest()` deliberately stops at the last *hold* keyframe before it. Posing the title at t=358 walked every parent into its exit and drove the disagreement from **10.92 to 61.74**. ✅ `at` therefore poses **leaves only**; top-level elements keep `rest()`. That follows the decoded rule directly: the leaf runs on its own timeline and the parent's does not gate it. ## The controls | | | |---|---| | 0° and **360°** vs the unrotated path | **byte-identical** | | 90° on a 10×4 sprite | extents swap to **4×10** | | covered area under rotation | conserved to **< 15 %** | | centroid under rotation | stays on the pivot (< 1 px) | `rotation_control_known_angles` pins all four. **116 lib tests pass.** ## 🔴 The verification, which did not show what it was meant to Rendering the title against [`live-title-build4-no-plate.png`](../captures/title-builds/live-title-build4-no-plate.png) and scanning the pose time: | | mean abs difference | |---|---| | before (rest, no leaves, no rotation) | **10.92** | | after, scanned t = 0 … 600 | **10.73 – 11.17** | | best (t = 420) | 10.73 — **1.7 %** better | **Flat. No minimum.** Drawing the sweeps correctly does not measurably improve this comparison, and two things explain why without rescuing it: * the whole-frame mean is dominated by the **tone curve**, which [`title-residual-tone-vs-geometry.md`](title-residual-tone-vs-geometry.md) measures as the larger part of the *level* difference even where geometry is right; * our renderer still **does not draw `ptlogo1` / `ptlogo2` at all** (four elements, reported as "not drawn"), and that is a far larger spatial gap than two translucent sweeps. ⚠️ **So the honest claim is narrow:** rotation is implemented and correct in isolation, and no screen regressed. Whether it closes the port's **1.81 % of pixels differing** is **not established here** — that harness poses deliberately and counts differing pixels rather than mean level, and it is the place to judge it. ❔ The sweeps may simply be a small term. ## 🔴 A bug in this change, found by inspection before the tests found it The leaf branch set its "something was drawn" flag **unconditionally after calling `blit`** — but `blit` returns early on a **zero scale** (*"collapsed to nothing", not "unset"*). So a scale-0 leaf would have been treated as drawn, its parent skipped, and the element **blanked outright**. ⚠️ `pgloading_loop5`'s leaf is scale **(0, 0)**, so this was live on all four loading screens, and scale-0 is one of the failures this corpus is already named for. Fixed by skipping a zero-scale leaf pose before it can claim the draw. Loading builds render afterwards at 4.0 % non-black, unchanged in character. ## No regression | screen | before | after | |---|---|---| | `main_menu` | 9.26 | **9.26** | | `extras` | — | 9.75 |