Render scripted boss phases and accelerate validated mod loading

This commit is contained in:
raptorreplica committed 2026-10-11 20:49:37 +11:00
1 parent 9226a13433
commit 0e8a27c728
15 files changed
+403 -78

No files matched your search

+24 -17
View File
@@ -220,23 +220,7 @@ impl Sprite {
Ok(out)
}
pub fn png(path: &Path, palette: &[[u8; 3]; 256]) -> Result<Self> {
let mut reader = image::ImageReader::open(path)?.with_guessed_format()?;
ensure!(
reader.format() == Some(image::ImageFormat::Png),
"mod artwork must be PNG"
);
let mut limits = image::Limits::default();
limits.max_image_width = Some(4096);
limits.max_image_height = Some(4096);
limits.max_alloc = Some(64 * 1024 * 1024);
reader.limits(limits);
let image = reader.decode()?.into_rgba8();
ensure!(
image.width() <= 4096
&& image.height() <= 4096
&& image.width() as u64 * image.height() as u64 <= 4_194_304,
"PNG exceeds 4096px"
);
let image = checked_png(path)?;
let mut out = Self::blank(image.width() as usize, image.height() as usize);
// Plain mod PNGs carry no palette-animation metadata. Keep their colors
// out of the reserved water/fire/blink entries (240..=255), which change
@@ -262,6 +246,29 @@ impl Sprite {
Ok(out)
}
}
/// Fully decode untrusted PNG artwork with the renderer's existing limits.
/// Validation needs only the checked canvas, not indexed color conversion.
pub(crate) fn checked_png(path: &Path) -> Result<image::RgbaImage> {
let mut reader = image::ImageReader::open(path)?.with_guessed_format()?;
ensure!(
reader.format() == Some(image::ImageFormat::Png),
"mod artwork must be PNG"
);
let mut limits = image::Limits::default();
limits.max_image_width = Some(4096);
limits.max_image_height = Some(4096);
limits.max_alloc = Some(64 * 1024 * 1024);
reader.limits(limits);
let image = reader.decode()?.into_rgba8();
ensure!(
image.width() <= 4096
&& image.height() <= 4096
&& image.width() as u64 * image.height() as u64 <= 4_194_304,
"PNG exceeds 4096px"
);
Ok(image)
}
#[derive(Debug, Clone)]
pub struct LegacyMap {
pub tiles: Vec<(u16, u16)>,
+120 -2
View File
@@ -3221,7 +3221,7 @@ impl Host {
if def.legacy.is_some() {
e.art_frame
} else {
e.frame
e.pinned_art_frame.unwrap_or(e.frame)
},
interp(e.x, e.px),
interp(e.y, e.py),
@@ -3258,7 +3258,7 @@ impl Host {
if def.legacy.is_some() {
e.art_frame
} else {
e.frame
e.pinned_art_frame.unwrap_or(e.frame)
},
interp(e.x, e.px),
interp(e.y, e.py),
@@ -5531,6 +5531,114 @@ mod tests {
host.catalog = Catalog::open(host.catalog.root.clone()).unwrap();
}
#[test]
fn scripted_enemy_phase_frames_reach_sd_hd_sprites_and_both_shadow_layers() {
let (_directory, mut host) = ui_fixture();
install_player_fixture(&mut host);
let root = host.catalog.root.clone();
let mut palette = [0_u8; 768];
palette[5 * 3] = 63;
palette[8 * 3 + 1] = 63;
palette[70 * 3 + 2] = 63;
std::fs::write(root.join("PALETTE_DAT"), palette).unwrap();
let mut index = std::fs::read_to_string(root.join("INDEX")).unwrap();
for id in 0x10005..=0x1000f {
let name = format!("HUD_{id:x}");
let bytes = [1_i32, 0, 0, 1, 1]
.into_iter()
.flat_map(i32::to_le_bytes)
.chain([0_u8])
.collect::<Vec<_>>();
std::fs::write(root.join(&name), &bytes).unwrap();
index.push_str(&format!("{id:x} {} {name}\n", bytes.len()));
}
std::fs::write(root.join("INDEX"), index).unwrap();
host.catalog = Catalog::open(&root).unwrap();
host.palette = host.catalog.palette().unwrap();
host.shadow_table = [9; 256];
host.air_shadow_table = [9; 256];
let artwork = root.join("phases.png");
let mut strip = image::RgbaImage::new(8, 4);
for y in 0..4 {
for x in 0..2 {
strip.put_pixel(x, y, image::Rgba([255, 0, 0, 255]));
}
}
for y in 0..2 {
for x in 4..8 {
strip.put_pixel(x, y, image::Rgba([0, 255, 0, 255]));
}
}
strip.save(&artwork).unwrap();
image::imageops::resize(&strip, 32, 16, image::imageops::FilterType::Nearest)
.save(root.join("phases-hd.png"))
.unwrap();
let pack = serde_json::from_value(serde_json::json!({"enemies":[{
"id":"fixture:phase","name":"Phase fixture","health":10,
"art":{"png":"phases.png","hd_png":"phases-hd.png",
"logical_width":4,"logical_height":4,"frames":2,"nearest":true}
}]}))
.unwrap();
host.active.registry.apply(pack, "fixture").unwrap();
host.active
.registry
.asset_roots
.insert("fixture".into(), root);
for ground in [true, false] {
host.active
.registry
.enemies
.get_mut(&"fixture:phase".into())
.unwrap()
.ground = ground;
let mut world =
World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap();
world.draw_player = false;
let entity = world
.spawn(&"fixture:phase".into(), 100, 80, &host.active.registry)
.unwrap();
host.world = Some(world);
for hd in [false, true] {
host.config.hd_mode = hd;
for scale in [1, 4] {
let mut pictures = Vec::new();
for frame in [Some(0), Some(1), None] {
host.world
.as_mut()
.unwrap()
.apply_commands(
&[crate::modding::Command::Animation { entity, frame }],
&host.active.registry,
)
.unwrap();
let digest = host.world.as_ref().unwrap().digest();
let mut canvas = Canvas::new(scale);
canvas.clear(70);
host.scene(&mut canvas).unwrap();
let expected = if frame == Some(1) { 8 } else { 5 };
assert_eq!(
canvas.image.pixels[80 * scale * canvas.image.width + 100 * scale],
expected
);
assert_eq!(host.world.as_ref().unwrap().digest(), digest);
pictures.push(canvas.image);
}
assert_eq!(pictures[0], pictures[2], "reset restores normal animation");
assert_ne!(pictures[0], pictures[1]);
let shadow_changes = pictures[0]
.pixels
.iter()
.zip(&pictures[1].pixels)
.filter(|(a, b)| **a != **b && (**a == 9 || **b == 9))
.count();
assert!(
shadow_changes > 0,
"phase mask must reach ground={ground}, HD={hd}, scale={scale} shadow"
);
}
}
}
}
#[test]
fn original_player_mapping_retains_every_bank_and_only_dark_missions_use_flash_frames() {
let (_directory, mut host) = ui_fixture();
install_player_fixture(&mut host);
@@ -7978,6 +8086,8 @@ mod tests {
.spawn(&boss, (320 - width as i32) / 2, 12, &host.active.registry)
.unwrap();
host.screen = Screen::Game;
let mut previous_sd = None;
let mut previous_hd = None;
for frame in 0..frames {
host.world
.as_mut()
@@ -7995,6 +8105,14 @@ mod tests {
host.config.hd_mode = true;
let hd = capture(&mut host, &format!("mission-{n:02}-boss-{frame}-hd"));
assert_ne!(sd, hd, "Stage2 supplied SD and HD art should differ");
if let Some(previous) = previous_sd {
assert_ne!(sd, previous, "SD boss {n} must show phase frame {frame}");
}
if let Some(previous) = previous_hd {
assert_ne!(hd, previous, "HD boss {n} must show phase frame {frame}");
}
previous_sd = Some(sd);
previous_hd = Some(hd);
}
host.world = None;
}
+13 -10
View File
@@ -436,24 +436,27 @@ impl ActiveMods {
.get(owner)
.context("missing PNG root")?;
let path = contained(root, Path::new(png))?;
let palette = catalog
.map(|cat| cat.palette())
.transpose()?
.unwrap_or([[0; 3]; 256]);
let sprite = crate::assets::Sprite::png(&path, &palette)?;
// Retain catalog palette validation even though no indexed output
// is needed here. Rendering still uses Sprite::png unchanged.
if let Some(catalog) = catalog {
catalog.palette()?;
}
let image = crate::assets::checked_png(&path)?;
let width = image.width() as usize;
let height = image.height() as usize;
ensure!(
sprite.width % art.frames as usize == 0,
width % art.frames as usize == 0,
"{owner}/{png}: PNG strip width must divide evenly into frames"
);
ensure!(
sprite.width / art.frames as usize * art.logical_height as usize
== sprite.height * art.logical_width as usize,
width / art.frames as usize * art.logical_height as usize
== height * art.logical_width as usize,
"{owner}/{png}: artwork canvas aspect differs from logical canvas"
);
if art.hd_png.as_ref() == Some(png) {
ensure!(
sprite.width / art.frames as usize >= 2 * art.logical_width as usize
&& sprite.height >= 2 * art.logical_height as usize,
width / art.frames as usize >= 2 * art.logical_width as usize
&& height >= 2 * art.logical_height as usize,
"{owner}/{png}: HD artwork needs at least twice the logical resolution"
);
}