From 18947218b45051b2987cff741c963c451fa2b308 Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Tue, 18 Aug 2026 16:20:26 +0000 Subject: [PATCH] formats: a UI element scales about its pivot, not its keyframe corner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compositor read a keyframe as `top-left = (X,Y)`, `size = decoded · scale` and ignored the declared pivot. That is right at 100 %, which is every element the format was ever checked against — the pause menu, the ARSENAL chip ruler — and wrong for every element that is scaled. Measured against a framebuffer capture of Canary on the title screen. `GP_TITLE.pak` build 7 element 13 is `ptbase2.t32`: 640x360, pivot (320,180), one keyframe at (320,180) with scale 200 %. From the corner that is a 1280x720 rect at 320..1600 x 180..900 — a quarter-screen slab with the top-left quadrant bare. Anchored at the pivot it is (0,0)..(1280,720), and the capture shows the background art reaching all four edges. Normalised cross-correlation of the composite against the capture, searched over +-40 px, peaks at (0,0): 0.90 on the planet limb, 0.72 on the lower-left ship. `ptcopyright.t32` calibrates the other half: unscaled, 694x20 at (293,655), and the capture's glyph run is x 295..986 / y 700..718 once the 45 px of window chrome is taken off. So the keyframe really is the top-left at 1:1. Disc-wide this moves 865 of 5 130 resting placements. The pause menu's own `pgpeff01` glow stops hanging off the menu frame to the bottom-right and centres on it. `ComposeOptions::backdrop` comes with it: the default dim slate stands in for the PRMD dim-quad behind an in-mission screen, but comparing against a framebuffer needs the black the game actually composites over, so `screen render --black` can ask for it. --- crates/sylpheed-cli/src/main.rs | 22 ++++- crates/sylpheed-formats/src/ui_layout.rs | 104 +++++++++++++++++++++-- crates/sylpheed-viewer/src/iso_loader.rs | 1 + 3 files changed, 116 insertions(+), 11 deletions(-) diff --git a/crates/sylpheed-cli/src/main.rs b/crates/sylpheed-cli/src/main.rs index 58a62e9..c5635b0 100644 --- a/crates/sylpheed-cli/src/main.rs +++ b/crates/sylpheed-cli/src/main.rs @@ -153,6 +153,11 @@ enum ScreenCommands { /// Draw `loop*` sprite animations #[arg(long)] animated: bool, + /// Start the canvas black instead of the default dim slate — what the + /// game composites over on a screen carrying its own background, and so + /// what a framebuffer capture must be compared against. + #[arg(long)] + black: bool, }, } @@ -297,8 +302,15 @@ async fn main() -> Result<()> { Commands::Screen { cmd } => match cmd { ScreenCommands::List { pak } => cmd_screen_list(&pak), ScreenCommands::Info { pak, build } => cmd_screen_info(&pak, build), - ScreenCommands::Render { pak, output, build, focus, animated } => { - cmd_screen_render(&pak, &output, build, focus, animated) + ScreenCommands::Render { + pak, + output, + build, + focus, + animated, + black, + } => { + cmd_screen_render(&pak, &output, build, focus, animated, black) } }, Commands::Save { cmd } => match cmd { @@ -433,6 +445,7 @@ fn cmd_screen_render( want: Option, focus: bool, animated: bool, + black: bool, ) -> Result<()> { use sylpheed_formats::ui_layout::{self, ComposeOptions}; let builds = screen_builds(pak)?; @@ -445,6 +458,11 @@ fn cmd_screen_render( ComposeOptions { include_focus: focus, include_animated: animated, + backdrop: if black { + [0, 0, 0, 255] + } else { + ComposeOptions::default().backdrop + }, }, None, ); diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index 63a71b8..bad0918 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -439,6 +439,13 @@ pub struct ComposeOptions { pub include_focus: bool, /// Draw `loop*` sprite animations. pub include_animated: bool, + /// Colour the canvas starts at. The default dim slate stands in for the + /// PRMD dim-quad plus the live 3D scene behind an in-mission screen; a + /// screen that carries its own full-screen background wants **black**, + /// which is what the game composites over — comparing a composite against a + /// framebuffer capture needs the backdrop to match, or every partially + /// transparent pixel is off by the backdrop. + pub backdrop: [u8; 4], } impl Default for ComposeOptions { @@ -446,6 +453,7 @@ impl Default for ComposeOptions { Self { include_focus: false, include_animated: false, + backdrop: [14, 14, 20, 255], } } } @@ -480,7 +488,7 @@ pub fn compose( // an in-mission screen. let mut canvas = vec![0u8; (w as usize) * (h as usize) * 4]; for px in canvas.chunks_exact_mut(4) { - px.copy_from_slice(&[14, 14, 20, 255]); + px.copy_from_slice(&opts.backdrop); } let mut drawn = Vec::new(); let mut missing = Vec::new(); @@ -505,7 +513,7 @@ pub fn compose( missing.push(sprite.clone()); continue; }; - blit(&mut canvas, w, h, &img, kf); + blit(&mut canvas, w, h, &img, kf, el.pivot_x, el.pivot_y); drawn.push(el.index); } ComposedScreen { @@ -519,7 +527,25 @@ pub fn compose( /// Alpha-blend one sprite onto the canvas at a keyframe's placement, with tint /// and scale. Placements may be negative or run off the edge, so both axes clip. -fn blit(canvas: &mut [u8], cw: u32, ch: u32, img: &t8ad::T8adImage, kf: &Keyframe) { +/// +/// **A keyframe's X/Y is the element's top-left at 1:1, and scale grows it about +/// the declared pivot, not about that corner.** Measured against a framebuffer +/// capture of the running title screen: `ptbase2.t32` is a 640×360 background +/// placed at (320,180) with `scale = 200%` and pivot (320,180). Growing from the +/// corner puts it at 320..1600 × 180..900 — a quarter-screen slab. Anchoring the +/// pivot gives top-left `(320,180) − (320,180)·(2−1) = (0,0)` and a 1280×720 +/// rect, which is what the game draws. At 100 % the pivot cancels, which is why +/// every unscaled element — and so every ruler this format was checked against — +/// was unaffected. See `docs/re/structures/ui-rat-layout.md`. +fn blit( + canvas: &mut [u8], + cw: u32, + ch: u32, + img: &t8ad::T8adImage, + kf: &Keyframe, + pivot_x: u32, + pivot_y: u32, +) { let (sw, sh) = (img.width, img.height); if sw == 0 || sh == 0 { return; @@ -528,30 +554,33 @@ fn blit(canvas: &mut [u8], cw: u32, ch: u32, img: &t8ad::T8adImage, kf: &Keyfram let sy_pct = if kf.scale_y == 0 { 100 } else { kf.scale_y }; let dw = (sw * sx_pct / 100).max(1); let dh = (sh * sy_pct / 100).max(1); + // 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; let (tr, tg, tb, ta) = ( (kf.tint >> 24) & 0xff, (kf.tint >> 16) & 0xff, (kf.tint >> 8) & 0xff, kf.tint & 0xff, ); - for oy in 0..dh { - let ty = kf.y + oy as i32; + for row in 0..dh { + let ty = oy + row as i32; if ty < 0 { continue; } if ty >= ch as i32 { break; } - let syi = (oy * sh / dh).min(sh - 1); - for ox in 0..dw { - let tx = kf.x + ox as i32; + let syi = (row * sh / dh).min(sh - 1); + for col in 0..dw { + let tx = ox + col as i32; if tx < 0 { continue; } if tx >= cw as i32 { break; } - let sxi = (ox * sw / dw).min(sw - 1); + let sxi = (col * sw / dw).min(sw - 1); let si = ((syi * sw + sxi) * 4) as usize; if si + 3 >= img.rgba.len() { continue; @@ -720,6 +749,63 @@ mod tests { assert_eq!(opt_link(&r).as_deref(), Some("pgpbtn00f.rat")); } + #[test] + fn scaling_grows_about_the_pivot_not_the_corner() { + // Measured against the real title screen: `ptbase2.t32` is 640x360 with + // pivot (320,180), placed at (320,180) with scale 200%. The game draws + // it as the full-screen background — top-left (0,0), 1280x720. Growing + // from the keyframe corner instead paints a quarter-screen slab and + // leaves the top-left quadrant bare. See + // `docs/re/structures/ui-rat-layout.md`. + let img = t8ad::T8adImage { + width: 640, + height: 360, + rgba: vec![255u8; 640 * 360 * 4], + }; + let k = Keyframe { + fade: 0xffff_ffff, + scale_x: 200, + scale_y: 200, + tint: 0xffff_ffff, + x: 320, + y: 180, + time: None, + }; + let (w, h) = (1280u32, 720u32); + let mut canvas = vec![0u8; (w * h * 4) as usize]; + blit(&mut canvas, w, h, &img, &k, 320, 180); + // Every pixel is covered; the four corners are the cheap witnesses. + for (x, y) in [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)] { + let i = ((y * w + x) * 4) as usize; + assert_eq!( + canvas[i], 255, + "pixel ({x},{y}) not covered — the background is not full-screen" + ); + } + } + + #[test] + fn a_hundred_percent_element_lands_on_its_keyframe_corner() { + // The other half of the rule, and the reason the corner reading survived + // this long: at 100% the pivot cancels, so no unscaled element moves. + // `ptcopyright.t32` is 694x20 at (293,655), and the capture's glyph run + // starts at x = 295 — inside that rect, not offset by a pivot. + let img = t8ad::T8adImage { + width: 694, + height: 20, + rgba: vec![255u8; 694 * 20 * 4], + }; + let k = kf(293, 655, 0); + let (w, h) = (1280u32, 720u32); + let mut canvas = vec![0u8; (w * h * 4) as usize]; + blit(&mut canvas, w, h, &img, &k, 309, 10); + let at = |x: u32, y: u32| canvas[((y * w + x) * 4) as usize]; + assert_eq!(at(293, 655), 255, "top-left corner is the keyframe"); + assert_eq!(at(986, 674), 255, "bottom-right corner is corner + size"); + assert_eq!(at(292, 655), 0, "nothing left of the keyframe X"); + assert_eq!(at(293, 654), 0, "nothing above the keyframe Y"); + } + #[test] fn fallback_scans_records_when_the_table_is_unusable() { // The old `.rat`-only reading, kept as a recovery path. diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index b420e28..fe17da7 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -4226,6 +4226,7 @@ fn compose_screen( ComposeOptions { include_focus: focus, include_animated: animated, + ..Default::default() }, Some(&visible), );