From 81f42f90417f8b78cba20734d7b7d812f3be4e48 Mon Sep 17 00:00:00 2001 From: sylph-decoder Date: Sat, 29 Aug 2026 18:59:26 +0000 Subject: [PATCH] formats: teach the renderer to rotate (Option A) -- and report that it does not close the title The human chose Option A: teach sylpheed-formats own renderer to draw rotation_deg so it and the port stay comparable and verify-screen keeps meaning someone is wrong. Three pieces, because rotation alone does nothing on the title. blit gains a rotated path that draws by inverse mapping over the rotated bounding box, turning about the pivot, whose absolute position is invariant under scale; zero rotation keeps the original forward-mapped path byte for byte so non-rotating screens cannot regress. compose draws a nested .rat leaf when the leaf carries geometry the parent does not, which is the sweeps case, but not as a blanket rule since a button s leaf duplicates its parent. And --at poses leaves at a keyframe time, because the sweeps hold off-screen at x=1521 so a resting composite omits them. A trap found the hard way: posing EVERYTHING at one global time is wrong, because a top-level group s final keyframes are its exit ramp and rest() deliberately stops before them. Posing the title at t=358 walked every parent into its exit and drove the disagreement from 10.92 to 61.74. So at poses leaves only. Controls: 0 and 360 degrees byte-identical to the unrotated path, 90 degrees swaps a 10x4 to 4x10, area conserved within 15 percent, centroid stays on the pivot. 116 lib tests pass, main_menu unchanged at 9.26. And the verification did not show what it was meant to, which is reported rather than buried: scanning the pose time against the title capture gives 10.73 to 11.17 against a 10.92 baseline -- flat, no minimum, best 1.7 percent. The whole-frame mean is dominated by the tone curve, and the renderer still does not draw ptlogo1/ptlogo2 at all, which is a far larger spatial gap than two translucent sweeps. So rotation is correct in isolation and no screen regressed, but whether it closes the port s 1.81 percent is not established here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd --- crates/sylpheed-cli/src/main.rs | 13 +- crates/sylpheed-formats/examples/leafdbg.rs | 20 ++ crates/sylpheed-formats/examples/sprite_of.rs | 9 + crates/sylpheed-formats/src/ui_layout.rs | 265 ++++++++++++++++++ docs/re/structures/ui-rotation-implemented.md | 90 ++++++ 5 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 crates/sylpheed-formats/examples/leafdbg.rs create mode 100644 crates/sylpheed-formats/examples/sprite_of.rs create mode 100644 docs/re/structures/ui-rotation-implemented.md diff --git a/crates/sylpheed-cli/src/main.rs b/crates/sylpheed-cli/src/main.rs index ecec9452..cd4fc9dd 100644 --- a/crates/sylpheed-cli/src/main.rs +++ b/crates/sylpheed-cli/src/main.rs @@ -195,6 +195,14 @@ enum ScreenCommands { /// `--build`**, which is why it is a flag and not the default. #[arg(long)] all: bool, + /// Pose every element at this KEYFRAME TIME instead of at its resting + /// pose (60 units = 1 second). The resting pose is the last *hold* + /// keyframe, which is the settled screen — wrong for anything still + /// moving. The title's two light sweeps hold off the right edge, so a + /// resting composite omits them; `--at 358` puts them where a capture + /// taken mid-sweep has them. + #[arg(long)] + at: Option, }, } @@ -353,8 +361,9 @@ async fn main() -> Result<()> { black, all, primitives, + at, } => cmd_screen_render( - &pak, &output, build, focus, animated, black, all, primitives, + &pak, &output, build, focus, animated, black, all, primitives, at, ), }, Commands::Save { cmd } => match cmd { @@ -581,6 +590,7 @@ fn cmd_screen_render( black: bool, all: bool, primitives: bool, + at: Option, ) -> Result<()> { use sylpheed_formats::ui_layout::{self, ComposeOptions}; let builds = screen_builds(pak, all)?; @@ -599,6 +609,7 @@ fn cmd_screen_render( ComposeOptions::default().backdrop }, include_primitives: primitives, + at, }, None, ); diff --git a/crates/sylpheed-formats/examples/leafdbg.rs b/crates/sylpheed-formats/examples/leafdbg.rs new file mode 100644 index 00000000..97f9efbf --- /dev/null +++ b/crates/sylpheed-formats/examples/leafdbg.rs @@ -0,0 +1,20 @@ +use sylpheed_formats::{pak, ui_layout}; +fn main(){ + let ar=pak::PakArchive::open(std::env::args().nth(1).unwrap()).unwrap(); + let by=ar.read(&ar.entries()[4]).unwrap(); + let b=ui_layout::parse_build(&by).unwrap(); + for n in ["ptloop01.rat","ptloop02.rat"] { + let el=b.elements.iter().find(|e| e.name==n).unwrap(); + println!("{n}: kind={:#x} animated={} rec={:?}", el.kind, el.animated, + b.records.get(n).map(|&(o,s)|(o,s))); + if let Some(&(o,s))=b.records.get(n) { + match ui_layout::parse_build(&by[o..o+s]) { + Some(lb)=>for le in &lb.elements { + println!(" leaf {:?} sprite={:?} in_sprites={}", le.name, le.sprite, + le.sprite.as_ref().map(|x| b.sprites.contains_key(x)).unwrap_or(false)); + }, + None=>println!(" leaf parse FAILED"), + } + } + } +} diff --git a/crates/sylpheed-formats/examples/sprite_of.rs b/crates/sylpheed-formats/examples/sprite_of.rs new file mode 100644 index 00000000..4cc71730 --- /dev/null +++ b/crates/sylpheed-formats/examples/sprite_of.rs @@ -0,0 +1,9 @@ +use sylpheed_formats::{pak, ui_layout}; +fn main(){ + let ar=pak::PakArchive::open(std::env::args().nth(1).unwrap()).unwrap(); + let b=ui_layout::parse_build(&ar.read(&ar.entries()[4]).unwrap()).unwrap(); + for el in b.elements.iter().filter(|e| e.name.contains("ptloop")||e.name.contains("ptbtn")) { + println!("{:<18} sprite={:?} has_leaf={}", el.name, el.sprite, + b.records.contains_key(&el.name)); + } +} diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index 116e6ea1..5cc3faa3 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -192,6 +192,68 @@ impl Element { /// /// Falls back to the longest-dwell rule when no two adjacent keyframes /// agree — a group that ramps through every frame and never holds. + /// The element's pose at keyframe time `t`, linearly interpolated. + /// + /// `rest()` returns the last HOLD keyframe, which is the settled screen. That + /// is the wrong pose for anything still moving: the title's light sweeps hold + /// at `x = 1521`, off the right edge, so a resting composite deletes them + /// rather than settling them. A capture taken mid-animation can only be + /// compared against a render posed at the same instant. + /// + /// The ramp is linear (`docs/re/ui-keyframe-time-unit.md`), and a group + /// **holds** at its last keyframe rather than looping, so `t` past the end + /// clamps. + pub fn pose_at(&self, t: u32) -> Option { + let ks = &self.keyframes; + if ks.is_empty() { + return None; + } + let timed: Vec<(u32, &Keyframe)> = + ks.iter().filter_map(|k| k.time.map(|tt| (tt, k))).collect(); + if timed.is_empty() { + return Some(ks[ks.len() - 1].clone()); + } + if t <= timed[0].0 { + return Some(timed[0].1.clone()); + } + if t >= timed[timed.len() - 1].0 { + return Some(timed[timed.len() - 1].1.clone()); + } + for w in timed.windows(2) { + let ((t0, a), (t1, b)) = (w[0], w[1]); + if t >= t0 && t <= t1 { + if t1 == t0 { + return Some(b.clone()); + } + let f = (t - t0) as f64 / (t1 - t0) as f64; + let li = |x: i32, y: i32| x + ((y - x) as f64 * f).round() as i32; + let lu = |x: u32, y: u32| (x as f64 + (y as f64 - x as f64) * f).round() as u32; + // ARGB / RGBA words interpolate per BYTE, not as integers. + let lc = |x: u32, y: u32| { + let mut o = 0u32; + for sh in [24, 16, 8, 0] { + let (cx, cy) = ((x >> sh) & 0xff, (y >> sh) & 0xff); + o |= (lu(cx, cy) & 0xff) << sh; + } + o + }; + return Some(Keyframe { + fade: lc(a.fade, b.fade), + rotation_deg: li(a.rotation_deg, b.rotation_deg), + unknown_4: li(a.unknown_4, b.unknown_4), + unknown_8: li(a.unknown_8, b.unknown_8), + scale_x: lu(a.scale_x, b.scale_x), + scale_y: lu(a.scale_y, b.scale_y), + tint: lc(a.tint, b.tint), + x: li(a.x, b.x), + y: li(a.y, b.y), + time: Some(t), + }); + } + } + Some(timed[timed.len() - 1].1.clone()) + } + pub fn rest(&self) -> Option<&Keyframe> { // `lastall`: the LAST keyframe for every element, bypassing the plateau // rule entirely. This is what the shifted time reading predicts — under @@ -743,6 +805,12 @@ pub struct ComposeOptions { /// Left on with the derived order, an opaque black quad sorts last and wipes /// 32 of `GP_DIALOG`'s builds. pub include_primitives: bool, + /// Pose every element at this keyframe time instead of at its resting pose. + /// + /// `None` keeps the settled composite, which is what every existing caller + /// wants. A capture taken mid-animation needs the render posed at the same + /// instant — see [`Element::pose_at`]. + pub at: Option, } impl Default for ComposeOptions { @@ -752,6 +820,7 @@ impl Default for ComposeOptions { include_animated: false, backdrop: [14, 14, 20, 255], include_primitives: false, + at: None, } } } @@ -1005,6 +1074,16 @@ pub fn compose_with_order( { continue; } + // 🔴 `at` poses LEAVES ONLY, never the top-level elements. + // + // Posing everything at one global time was tried and 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 render's disagreement with the + // capture from 10.92 to 61.74. The leaf is the thing still animating at + // that instant, and it runs on its own timeline + // (`docs/re/structures/ui-leaf-vs-parent-alpha.md`). let Some(kf) = el.rest() else { continue }; // An untextured primitive: a solid quad of the keyframe's `fade` colour, // sized by the declared pivot. `kind & 0x10` marks these exactly — see @@ -1037,6 +1116,56 @@ pub fn compose_with_order( missing.push(sprite.clone()); continue; }; + // A nested `.rat` leaf sometimes carries the geometry while the parent + // carries none — the title's light sweeps are the case: the parent sits + // fixed at (441,270) scale 100 %, and the leaf holds the 600 %/800 % + // scale, the +30°/−45° rotation and the whole sweep. Drawing the parent + // put the sprite upright in the middle of the screen. + // + // ⚠️ NOT a blanket rule. A button's base record has a leaf that + // DUPLICATES it, and there the parent wins + // (`docs/re/structures/ui-button-focus-record.md`). The discriminator is + // which record actually carries geometry, so the leaf is used only when + // its pose genuinely differs — see `ui-leaf-vs-parent-alpha.md`. + let leaf = build.records.get(&el.name).and_then(|&(lo, ls)| { + if lo + ls > bundle.len() { + return None; + } + let lb = parse_build(&bundle[lo..lo + ls])?; + let pose = |e: &Element| match opts.at { + Some(t) => e.pose_at(t), + None => e.rest().cloned(), + }; + let differs = lb.elements.iter().any(|le| { + pose(le).map_or(false, |lk| { + lk.rotation_deg != 0 || lk.scale_x != kf.scale_x || lk.scale_y != kf.scale_y + }) + }); + if differs { Some(lb) } else { None } + }); + if let Some(lb) = leaf { + let mut any = false; + for le in &lb.elements { + let lk = match opts.at { + Some(t) => le.pose_at(t), + None => le.rest().cloned(), + }; + let Some(lk) = lk else { continue }; + // A leaf element resolves no sprite of its own: sprite names are + // resolved against the bundle a build was parsed from, and a leaf + // is parsed from its own slice. Its NAME is the sprite name, and + // the sprite itself lives in the PARENT bundle's table. + let lsp = le.sprite.clone().unwrap_or_else(|| le.name.clone()); + let Some(&(so, ss)) = build.sprites.get(&lsp) else { continue }; + let Some(limg) = t8ad::parse(&bundle[so..so + ss]) else { continue }; + blit(&mut canvas, w, h, &limg, &lk, le.pivot_x, le.pivot_y); + any = true; + } + if any { + drawn.push(el.index); + continue; + } + } blit(&mut canvas, w, h, &img, kf, el.pivot_x, el.pivot_y); drawn.push(el.index); } @@ -1155,6 +1284,11 @@ fn blit( // Keep the pivot point fixed as the element scales. let ox = kf.x - (pivot_x as i32 * (sx_pct as i32 - 100)) / 100; let oy = kf.y - (pivot_y as i32 * (sy_pct as i32 - 100)) / 100; + // The pivot's ABSOLUTE position is invariant under scale, which is the whole + // point of the two lines above: at 100 % `ox = kf.x` so the pivot sits at + // `kf.x + pivot_x`; at 200 % `ox = kf.x - pivot_x` and the pivot sits at + // `ox + 2·pivot_x`, the same place. So rotation turns about it. + let (pax, pay) = (kf.x + pivot_x as i32, kf.y + pivot_y as i32); // Two modulate colours multiply into one: `tint` (RGBA, and `0xffffffff` on // essentially every keyframe seen) and `fade` (**ARGB** — the high byte is // the alpha that ramps, the low 24 bits a colour multiply that is `0xffffff` @@ -1173,6 +1307,73 @@ fn blit( ((kf.tint >> 8) & 0xff) * fb / 255, (kf.tint & 0xff) * fa / 255, ); + // ---- rotated path ------------------------------------------------------- + // `+12` is a screen-plane rotation in DEGREES, clockwise-positive with Y + // down (`docs/re/structures/ui-keyframe-rotation.md`). The game submits + // rotated quads for it; this used to draw them axis-aligned, which put the + // title's two light sweeps upright instead of at +30° / −45° and left at + // least two thirds of that screen's disagreement with the capture + // (`title-residual-tone-vs-geometry.md`). + // + // Zero rotation keeps the original forward-mapped path byte for byte, so + // the screens that do not rotate cannot regress. A rotated element is drawn + // by INVERSE mapping instead: forward-mapping a rotation leaves gaps. + let rot = ((kf.rotation_deg % 360) + 360) % 360; + if rot != 0 { + let th = (rot as f64).to_radians(); + let (cs, sn) = (th.cos(), th.sin()); + // Axis-aligned bounds of the rotated destination rect. + let corners = [ + (ox, oy), + (ox + dw as i32, oy), + (ox + dw as i32, oy + dh as i32), + (ox, oy + dh as i32), + ]; + let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN); + for (cx, cy) in corners { + let (rx, ry) = ((cx - pax) as f64, (cy - pay) as f64); + let px = pax as f64 + rx * cs - ry * sn; + let py = pay as f64 + rx * sn + ry * cs; + x0 = x0.min(px.floor() as i32); + y0 = y0.min(py.floor() as i32); + x1 = x1.max(px.ceil() as i32); + y1 = y1.max(py.ceil() as i32); + } + for ty in y0.max(0)..=y1.min(ch as i32 - 1) { + for tx in x0.max(0)..=x1.min(cw as i32 - 1) { + // Rotate the destination pixel BACK to find its source pixel. + let (rx, ry) = ((tx - pax) as f64 + 0.5, (ty - pay) as f64 + 0.5); + let ux = rx * cs + ry * sn; + let uy = -rx * sn + ry * cs; + let dx = ux + (pax - ox) as f64; + let dy = uy + (pay - oy) as f64; + if dx < 0.0 || dy < 0.0 || dx >= dw as f64 || dy >= dh as f64 { + continue; + } + let sxi = ((dx as u32) * sw / dw).min(sw - 1); + let syi = ((dy as u32) * sh / dh).min(sh - 1); + let si = ((syi * sw + sxi) * 4) as usize; + if si + 3 >= img.rgba.len() { + continue; + } + let sr = img.rgba[si] as u32 * tr / 255; + let sg = img.rgba[si + 1] as u32 * tg / 255; + let sb = img.rgba[si + 2] as u32 * tb / 255; + let sa = img.rgba[si + 3] as u32 * ta / 255; + if sa == 0 { + continue; + } + let di = ((ty as u32 * cw + tx as u32) * 4) as usize; + for (k, sc) in [sr, sg, sb].into_iter().enumerate() { + let dc = canvas[di + k] as u32; + canvas[di + k] = ((sc * sa + dc * (255 - sa)) / 255) as u8; + } + canvas[di + 3] = 255; + } + } + return; + } + // ---- unrotated path (unchanged) ----------------------------------------- for row in 0..dh { let ty = oy + row as i32; if ty < 0 { @@ -1220,6 +1421,70 @@ fn blit( mod tests { use super::*; + /// A solid opaque rectangle sprite, for exercising `blit` geometry. + fn solid(w: u32, h: u32) -> t8ad::T8adImage { + t8ad::T8adImage { width: w, height: h, rgba: vec![255u8; (w * h * 4) as usize], + flags: 0 } + } + fn kf_at(x: i32, y: i32, rot: i32) -> Keyframe { + Keyframe { fade: 0xff_ff_ff_ff, rotation_deg: rot, unknown_4: 0, unknown_8: 0, + scale_x: 100, scale_y: 100, tint: 0xffff_ffff, x, y, time: Some(0) } + } + fn draw(img: &t8ad::T8adImage, kf: &Keyframe, px: u32, py: u32) -> Vec { + let mut c = vec![0u8; 64 * 64 * 4]; + blit(&mut c, 64, 64, img, kf, px, py); + c + } + fn covered(c: &[u8]) -> Vec<(i32, i32)> { + let mut v = Vec::new(); + for y in 0..64 { for x in 0..64 { + if c[((y * 64 + x) * 4 + 3) as usize] != 0 { v.push((x as i32, y as i32)); } + }} + v + } + + /// 🔴 CONTROL for the rotated path. An estimator that is wrong on a known + /// angle cannot be trusted on an unknown one, so the rotated blit is pinned + /// against angles whose answer is arithmetic rather than measured. + #[test] + fn rotation_control_known_angles() { + let img = solid(10, 4); + // pivot at the sprite's centre, so rotation turns in place + let (px, py) = (5u32, 2u32); + let base = draw(&img, &kf_at(20, 30, 0), px, py); + + // 0° and 360° must be identical to the unrotated path, byte for byte: + // the fast path must be exactly the old behaviour. + assert_eq!(base, draw(&img, &kf_at(20, 30, 360), px, py), + "360 degrees must equal the unrotated path exactly"); + + // 90° must turn a 10x4 into a 4x10 about the same centre. + let r90 = covered(&draw(&img, &kf_at(20, 30, 90), px, py)); + let b = covered(&base); + let bw = b.iter().map(|p| p.0).max().unwrap() - b.iter().map(|p| p.0).min().unwrap(); + let bh = b.iter().map(|p| p.1).max().unwrap() - b.iter().map(|p| p.1).min().unwrap(); + let rw = r90.iter().map(|p| p.0).max().unwrap() - r90.iter().map(|p| p.0).min().unwrap(); + let rh = r90.iter().map(|p| p.1).max().unwrap() - r90.iter().map(|p| p.1).min().unwrap(); + assert_eq!((bw, bh), (9, 3), "unrotated extent"); + assert_eq!((rw, rh), (3, 9), "90 degrees must swap the extents"); + + // The covered area must be conserved to a few percent -- a rotation that + // loses or invents pixels is the forward-mapping bug this path avoids. + let (a0, a90) = (b.len() as f64, r90.len() as f64); + assert!((a0 - a90).abs() / a0 < 0.15, + "area changed too much under rotation: {a0} -> {a90}"); + + // And the centroid must stay on the pivot. + let cen = |v: &Vec<(i32, i32)>| { + let n = v.len() as f64; + (v.iter().map(|p| p.0 as f64).sum::() / n, + v.iter().map(|p| p.1 as f64).sum::() / n) + }; + let (c0, c9) = (cen(&b), cen(&r90)); + assert!((c0.0 - c9.0).abs() < 1.0 && (c0.1 - c9.1).abs() < 1.0, + "rotation moved the centroid: {c0:?} -> {c9:?}"); + } + /// A synthetic build bundle: RATC magic, entry count at 0x14, a declaration /// table at 0x20, then a placement region. fn synth_build(decls: &[(&str, u32, u32, u32, u32)], groups: &[(usize, Vec)]) -> Vec { diff --git a/docs/re/structures/ui-rotation-implemented.md b/docs/re/structures/ui-rotation-implemented.md new file mode 100644 index 00000000..8acc3374 --- /dev/null +++ b/docs/re/structures/ui-rotation-implemented.md @@ -0,0 +1,90 @@ +# ✅ Option A implemented — the reference renderer rotates. ⚠️ It does not close the title. + +**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. + +## No regression + +| screen | before | after | +|---|---|---| +| `main_menu` | 9.26 | **9.26** | +| `extras` | — | 9.75 |