formats: a UI element scales about its pivot, not its keyframe corner

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.
This commit is contained in:
Sylpheed RE agent
2026-08-18 16:20:26 +00:00
parent cb6205ccfb
commit e6a55b5fd4
3 changed files with 116 additions and 11 deletions

View File

@@ -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<usize>,
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,
);

View File

@@ -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)·(21) = (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.

View File

@@ -4226,6 +4226,7 @@ fn compose_screen(
ComposeOptions {
include_focus: focus,
include_animated: animated,
..Default::default()
},
Some(&visible),
);