Render scripted boss phases and accelerate validated mod loading
This commit is contained in:
1 parent
9226a13433
commit
0e8a27c728
15 files changed
+403
-78
No files matched your search
+24
-17
@@ -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
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user