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:
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4226,6 +4226,7 @@ fn compose_screen(
|
||||
ComposeOptions {
|
||||
include_focus: focus,
|
||||
include_animated: animated,
|
||||
..Default::default()
|
||||
},
|
||||
Some(&visible),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user