Unify Iron Coast mission names and cinematic mission previews

This commit is contained in:
raptorreplica committed 2026-10-19 00:10:38 +11:00
1 parent 668b3ff86d
commit 2d95e145a5
406 files changed
+134963 -245

No files matched your search

+10 -1
View File
@@ -629,6 +629,7 @@ impl Registry {
}
pub fn base(catalog: &Catalog) -> Result<Self> {
let mut reg = Self::default();
reg.asset_roots.insert("base".into(), catalog.root.clone());
let mut pack = ContentPack::default();
for o in crate::legacy_tables::objects() {
let id = ContentId::from(o.id);
@@ -903,7 +904,15 @@ impl Registry {
pack.missions.push(MissionDef {
id: mid,
name: format!("Sector {sector} / Wave {wave}"),
art: None,
art: Some(Art {
asset: None,
png: Some(format!("briefings/sector_{sector}_wave_{wave}_sd.png")),
hd_png: Some(format!("briefings/sector_{sector}_wave_{wave}_hd.png")),
logical_width: 320,
logical_height: 200,
frames: 1,
nearest: false,
}),
duration: 4700,
auto_complete: true,
spawns,
+305 -12
View File
@@ -1425,6 +1425,7 @@ impl Host {
self.music("MAINMENU_MUS");
}
self.mod_art.clear();
self.menu_art.clear_briefing();
if self.pending_load.is_some() {
self.message = "Mods applied; review pilot differences before loading".into();
self.go(Screen::ConfirmLoadMods);
@@ -2533,29 +2534,113 @@ impl Host {
{
self.font.text(canvas, 160, 48 + index as i32 * 9, line, 48);
}
if let Some(mut art) = mission.art.clone() {
let height = (132 * art.logical_height / art.logical_width).min(61);
art.logical_width = 132;
art.logical_height = height;
self.art(canvas, &art, &owner, 0, 160.0, 70.0)?;
use crate::menu_art::{BRIEFING_HEIGHT, BRIEFING_WIDTH, MENU_BRIEFING, MENU_RULE};
canvas.rect(
159,
65,
BRIEFING_WIDTH as i32 + 2,
BRIEFING_HEIGHT as i32 + 2,
MENU_RULE,
);
if let Some(art) = mission.art.clone() {
let png = if self.config.hd_mode {
art.hd_png.as_ref().or(art.png.as_ref())
} else {
art.png.as_ref()
};
if let Some(png) =
png.filter(|_| art.asset.is_none() || self.config.hd_mode && art.hd_png.is_some())
{
let key = format!(
"{owner}:{}:{}:{}",
serde_json::to_string(&art)?,
self.config.hd_mode,
canvas.scale
);
if !self.menu_art.has_briefing(&key) {
let root = self
.active
.registry
.asset_roots
.get(&owner)
.context("unknown briefing artwork owner")?;
let path = crate::assets::contained(root, std::path::Path::new(png))?;
let image = crate::assets::checked_png(&path)?;
self.menu_art
.set_briefing(key, image, art.frames, canvas.scale)?;
}
canvas.rect(
160,
66,
BRIEFING_WIDTH as i32,
BRIEFING_HEIGHT as i32,
MENU_BRIEFING,
);
} else {
// Keep indexed asset briefings compatible without allocating a
// canvas from authored logical dimensions merely to shrink it.
let key = format!(
"indexed:{owner}:{}:{}:{}",
serde_json::to_string(&art)?,
self.config.hd_mode,
canvas.scale
);
if !self.menu_art.has_briefing(&key) {
let asset = art.asset.context("missing indexed briefing asset")?;
let hd = if self.config.hd_mode {
self.hd.sprite(asset, canvas.scale, &self.palette)?
} else {
None
};
let sprite = match hd {
Some(sprite) => sprite,
None => self.raw(asset)?,
};
let image = image::RgbaImage::from_fn(
sprite.width as u32,
sprite.height as u32,
|x, y| {
let index = sprite.pixels[y as usize * sprite.width + x as usize];
if index == crate::assets::TRANSPARENT {
image::Rgba([0, 0, 0, 0])
} else {
let color = self.palette[index.min(255) as usize];
image::Rgba([color[0], color[1], color[2], 255])
}
},
);
self.menu_art.set_briefing(key, image, 1, canvas.scale)?;
}
canvas.rect(
160,
66,
BRIEFING_WIDTH as i32,
BRIEFING_HEIGHT as i32,
MENU_BRIEFING,
);
}
} else if mission.columns > 0 {
// Third-party missions may omit briefing art. Their map overview is
// still deterministic and does not start or advance a simulation.
let columns = mission.columns as usize;
let tile_width = (132 / columns.min(12)).max(1);
for position in 0..(columns * 4).min(mission.layout.len()) {
let tile_width = (BRIEFING_WIDTH as usize / columns.min(12)).clamp(1, 32);
let left = 160 + (BRIEFING_WIDTH as usize - columns.min(12) * tile_width) / 2;
let rows = BRIEFING_HEIGHT as usize / tile_width;
for position in 0..(columns * rows).min(mission.layout.len()) {
if position % columns >= 12 {
continue;
}
if let Some(tile) = mission.tiles.get(mission.layout[position]) {
let mut art = tile.art.clone();
art.logical_width = tile_width as u32;
art.logical_height = 15;
art.logical_height = tile_width as u32;
self.art(
canvas,
&art,
&owner,
0,
(160 + position % columns * tile_width) as f32,
(70 + position / columns * 15) as f32,
(left + position % columns * tile_width) as f32,
(66 + position / columns * tile_width) as f32,
)?;
}
}
@@ -2563,7 +2648,7 @@ impl Host {
self.font.text(
canvas,
160,
138,
140,
if self.pilot.completed.contains(&id) {
"STATUS COMPLETE"
} else if self.pilot.mission == id {
@@ -2576,7 +2661,7 @@ impl Host {
self.font.text(
canvas,
160,
149,
150,
&format!(
"{} DIFFICULTY",
["TRAINING", "ROOKIE", "VETERAN", "ELITE"][self.config.difficulty as usize]
@@ -5061,6 +5146,214 @@ mod tests {
.unwrap();
(dir, host)
}
#[test]
fn mission_briefing_preserves_full_color_sd_hd_and_does_not_advance_pilot_or_scripts() {
let (_dir, mut host) = ui_fixture();
let root = host.catalog.root.clone();
image::RgbaImage::from_pixel(320, 200, image::Rgba([201, 23, 51, 255]))
.save(root.join("briefing-sd.png"))
.unwrap();
image::RgbaImage::from_pixel(1280, 800, image::Rgba([31, 207, 83, 255]))
.save(root.join("briefing-hd.png"))
.unwrap();
let id: ContentId = "base:sector_1_wave_1".into();
host.active.registry.asset_roots.insert("base".into(), root);
host.active.registry.missions.get_mut(&id).unwrap().art = Some(Art {
asset: None,
png: Some("briefing-sd.png".into()),
hd_png: Some("briefing-hd.png".into()),
logical_width: 320,
logical_height: 200,
frames: 1,
nearest: false,
});
host.go(Screen::Missions);
let before = serde_json::json!({"pilot":host.pilot,"world":host.world,"states":host.active.scripts.states,"rng":host.active.scripts.rng});
for scale in 1..=4 {
host.config.scale = scale;
for (hd, expected) in [(false, [201, 23, 51, 255]), (true, [31, 207, 83, 255])] {
host.config.hd_mode = hd;
let image = host.draw().unwrap();
assert_eq!(
(image.image.width, image.image.height),
(400 * scale, 225 * scale)
);
assert_eq!(
image
.image
.pixels
.iter()
.filter(|p| **p == crate::menu_art::MENU_BRIEFING)
.count(),
132 * 70 * scale * scale
);
let rgba = host.present_rgba(&image);
let index = (((MENU_OFFSET[1] as usize + 70) * scale) * image.image.width
+ (MENU_OFFSET[0] as usize + 164) * scale)
* 4;
assert_eq!(
&rgba[index..index + 4],
&expected,
"full color and variant at scale {scale}"
);
}
}
assert_eq!(
serde_json::json!({"pilot":host.pilot,"world":host.world,"states":host.active.scripts.states,"rng":host.active.scripts.rng}),
before
);
host.active
.registry
.missions
.get_mut(&id)
.unwrap()
.art
.as_mut()
.unwrap()
.png = Some("../outside.png".into());
host.config.hd_mode = false;
assert!(
host.draw().is_err(),
"briefing path must remain contained in the artwork owner's root"
);
}
#[test]
fn indexed_mission_briefing_uses_actual_catalog_frame_and_bounds_thumbnail_allocation() {
let (_dir, mut host) = ui_fixture();
let root = host.catalog.root.clone();
let bytes = [1_i32, 0, 0, 4, 2]
.into_iter()
.flat_map(i32::to_le_bytes)
.chain([7_u8; 8])
.collect::<Vec<_>>();
std::fs::write(root.join("briefing-asset"), &bytes).unwrap();
let mut index = std::fs::read_to_string(root.join("INDEX")).unwrap();
index.push_str(&format!("3 {} briefing-asset\n", bytes.len()));
std::fs::write(root.join("INDEX"), index).unwrap();
host.catalog = Catalog::open(root).unwrap();
host.palette[7] = [17, 99, 211];
let id: ContentId = "base:sector_1_wave_1".into();
host.active.registry.missions.get_mut(&id).unwrap().art = Some(Art {
asset: Some(3),
png: None,
hd_png: None,
logical_width: 4096,
logical_height: 4096,
frames: 1,
nearest: false,
});
host.config.hd_mode = false;
host.config.scale = 4;
host.go(Screen::Missions);
let canvas = host.draw().unwrap();
let rgba = host.present_rgba(&canvas);
let index = (((MENU_OFFSET[1] as usize + 70) * 4) * canvas.image.width
+ (MENU_OFFSET[0] as usize + 164) * 4)
* 4;
assert_eq!(&rgba[index..index + 4], &[17, 99, 211, 255]);
assert!(host.world.is_none());
let mission = host.active.registry.missions.get_mut(&id).unwrap();
mission.art = None;
mission.columns = 1;
mission.layout = vec![0, 0];
mission.tiles = vec![crate::content::TileDef {
art: Art {
asset: Some(3),
png: None,
hd_png: None,
logical_width: 32,
logical_height: 32,
frames: 1,
nearest: false,
},
health: 0,
reward: 0,
destroyed: None,
}];
let fallback = host.draw().unwrap();
let index = (82 * 4) * fallback.image.width + 254 * 4;
assert_eq!(
fallback.image.pixels[index], 7,
"one-column missions without art need a visible centered map fallback"
);
}
#[test]
#[ignore = "requires bundled original data and mission illustrations; writes isolated full menu captures"]
fn all_mission_briefing_previews_capture_without_changing_simulation() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let catalog_root = std::env::var_os("RAPTOR_TEST_DATA")
.map(PathBuf::from)
.unwrap_or_else(|| root.join("data"));
let output = PathBuf::from(
std::env::var_os("RAPTOR_MISSION_PREVIEW_OUTPUT")
.expect("choose an isolated screenshot output directory"),
);
std::fs::create_dir_all(&output).unwrap();
let catalog = Catalog::open(catalog_root).unwrap();
let base = Registry::base(&catalog).unwrap();
let temp = tempfile::tempdir().unwrap();
let paths = Paths {
data: temp.path().join("saves"),
config: temp.path().join("config"),
cache: temp.path().join("cache"),
legacy: temp.path().join("legacy"),
};
let mut host = Host::new(catalog, base, paths, root.join("mods"), true).unwrap();
let enabled = host.discovery.mods.keys().cloned().collect::<Vec<_>>();
host.active =
ActiveMods::build(&host.base, &host.discovery, &enabled, Some(&host.catalog)).unwrap();
host.config.scale = 4;
host.config.scanlines = false;
host.go(Screen::Missions);
let filter = std::env::var("RAPTOR_MISSION_PREVIEW_FILTER").ok();
let ids = host.missions();
let mut captures = Vec::new();
for (selected, id) in ids.iter().enumerate() {
if filter
.as_ref()
.is_some_and(|filter| !filter.split(',').any(|wanted| wanted == id.0))
{
continue;
}
host.selected = selected;
for hd in [false, true] {
host.config.hd_mode = hd;
let before = serde_json::json!({"pilot":host.pilot,"world":host.world,"states":host.active.scripts.states,"rng":host.active.scripts.rng});
let canvas = host.draw().unwrap();
let rgba = host.present_rgba(&canvas);
let name = format!(
"{}-{}.png",
id.0.replace(':', "_"),
if hd { "hd" } else { "sd" }
);
image::save_buffer(
output.join(&name),
&rgba,
canvas.image.width as u32,
canvas.image.height as u32,
image::ColorType::Rgba8,
)
.unwrap();
assert_eq!(
serde_json::json!({"pilot":host.pilot,"world":host.world,"states":host.active.scripts.states,"rng":host.active.scripts.rng}),
before
);
captures.push(serde_json::json!({"id":id,"name":host.active.registry.missions[id].name,"hd":hd,"file":name,"width":canvas.image.width,"height":canvas.image.height,"has_briefing":host.active.registry.missions[id].art.is_some()}));
}
}
assert!(
!captures.is_empty(),
"filter must select an installed mission"
);
assert!(
!host.paths.data.exists(),
"preview mode must not create user saves"
);
std::fs::write(output.join("mission-preview-captures.json"),serde_json::to_vec_pretty(&serde_json::json!({"available_missions":ids.len(),"enabled_mods":enabled,"captures":captures,"scope":"Menu renderer only; no SDL window or gameplay simulation started"})).unwrap()).unwrap();
}
#[test]
fn terrain_palette_prepared_key_and_domain_reach_the_desktop_consumer() {
use sha2::{Digest,Sha256};
+1
View File
@@ -87,6 +87,7 @@ fn run() -> Result<()> {
}
let base = Registry::base(&catalog)?;
if args.iter().any(|a| a == "--verify-assets") {
modding::validate_artwork(&base, Some(&catalog))?;
println!(
"Verified {} assets; {} missions, {} enemies, {} weapons, {} items",
catalog.verify()?,
+198 -1
View File
@@ -10,9 +10,14 @@ pub const MENU_TEXT: u16 = 260;
pub const MENU_MUTED: u16 = 261;
pub const MENU_ACCENT: u16 = 262;
pub const MENU_RULE: u16 = 263;
/// A menu-only full-color briefing viewport; never a gameplay palette entry.
pub const MENU_BRIEFING: u16 = 264;
pub const BRIEFING_WIDTH: u32 = 132;
pub const BRIEFING_HEIGHT: u32 = 70;
pub struct MenuArt {
original: RgbaImage,
briefing: Option<(String, RgbaImage)>,
resized: BTreeMap<(usize, usize, usize), RgbaImage>,
}
impl MenuArt {
@@ -22,9 +27,50 @@ impl MenuArt {
.into_rgba8();
Self {
original,
briefing: None,
resized: BTreeMap::new(),
}
}
pub fn clear_briefing(&mut self) {
self.briefing = None;
}
pub fn has_briefing(&self, key: &str) -> bool {
self.briefing
.as_ref()
.is_some_and(|(cached, _)| cached == key)
}
/// Cache only the current thumbnail. A huge mod list must not retain every
/// decoded full-size source; checked PNG decoding happens before this call.
pub fn set_briefing(
&mut self,
key: String,
image: RgbaImage,
frames: u32,
scale: usize,
) -> anyhow::Result<()> {
anyhow::ensure!(
frames > 0 && image.width() % frames == 0,
"invalid briefing strip"
);
anyhow::ensure!(scale > 0 && scale <= 8, "invalid briefing display scale");
let frame_width = image.width() / frames;
anyhow::ensure!(
frame_width > 0 && image.height() > 0,
"empty briefing image"
);
let first = image::imageops::crop_imm(&image, 0, 0, frame_width, image.height()).to_image();
// This matches the cinematic Stormwall briefing: a landscape image
// fills one shared viewport without stretching its aircraft or terrain.
let image = DynamicImage::ImageRgba8(first)
.resize_to_fill(
BRIEFING_WIDTH * scale as u32,
BRIEFING_HEIGHT * scale as u32,
FilterType::Lanczos3,
)
.into_rgba8();
self.briefing = Some((key, image));
Ok(())
}
pub fn compose(
&mut self,
canvas: &Canvas,
@@ -44,8 +90,37 @@ impl MenuArt {
.into_rgba8()
});
let mut rgba = background.as_raw().clone();
// Locate the marker on the final menu canvas. This follows the host's
// centered 16:9 menu offset and also works for direct menu captures.
let briefing_origin = canvas
.image
.pixels
.iter()
.enumerate()
.filter(|(_, index)| **index == MENU_BRIEFING)
.fold(None, |origin: Option<(usize, usize)>, (index, _)| {
let point = (index % canvas.image.width, index / canvas.image.width);
Some(origin.map_or(point, |(x, y)| (x.min(point.0), y.min(point.1))))
});
for (i, index) in canvas.image.pixels.iter().enumerate() {
let color = if matches!(*index, MENU_PANEL | MENU_CARD | MENU_FOCUS) {
let color = if *index == MENU_BRIEFING {
let pixel = self
.briefing
.as_ref()
.and_then(|(_, image)| {
let (left, top) = briefing_origin?;
let x = i % canvas.image.width - left;
let y = i / canvas.image.width - top;
(x < image.width() as usize && y < image.height() as usize)
.then(|| image.get_pixel(x as u32, y as u32).0)
})
.unwrap_or([0, 0, 0, 255]);
[0, 1, 2].map(|channel| {
((pixel[channel] as u16 * pixel[3] as u16
+ rgba[i * 4 + channel] as u16 * (255 - pixel[3] as u16))
/ 255) as u8
})
} else if matches!(*index, MENU_PANEL | MENU_CARD | MENU_FOCUS) {
let (tint, opacity) = match *index {
MENU_CARD => ([10u16, 17, 23], 72),
MENU_FOCUS => ([49u16, 38, 27], 82),
@@ -113,4 +188,126 @@ mod tests {
assert_eq!(&background[..4], &overlay[..4]);
assert_eq!(art.resized.len(), 1);
}
#[test]
fn briefing_crop_preserves_square_geometry_and_uses_frame_zero() {
let mut art = MenuArt::bundled();
let source = RgbaImage::from_fn(320, 80, |x, y| {
image::Rgba(if x >= 160 {
[255, 0, 0, 255]
} else if (60..100).contains(&x) && (20..60).contains(&y) {
[0, 255, 0, 255]
} else {
[0, 0, 0, 255]
})
});
art.set_briefing("strip".into(), source, 2, 1).unwrap();
let image = &art.briefing.as_ref().unwrap().1;
assert_eq!(image.dimensions(), (BRIEFING_WIDTH, BRIEFING_HEIGHT));
let points: Vec<_> = image
.enumerate_pixels()
.filter(|(_, _, p)| p[1] > 200)
.map(|(x, y, _)| (x, y))
.collect();
let width = points.iter().map(|p| p.0).max().unwrap()
- points.iter().map(|p| p.0).min().unwrap()
+ 1;
let height = points.iter().map(|p| p.1).max().unwrap()
- points.iter().map(|p| p.1).min().unwrap()
+ 1;
assert!(
width.abs_diff(height) <= 1,
"aircraft geometry must not be stretched: {width}x{height}"
);
assert!(
!image.pixels().any(|p| p[0] > 0),
"later animation frames must not leak into frame zero"
);
}
#[test]
fn briefing_marker_follows_menu_offset_and_does_not_change_palette_pixels() {
let mut art = MenuArt::bundled();
art.set_briefing(
"full-color".into(),
RgbaImage::from_pixel(320, 200, image::Rgba([17, 123, 231, 255])),
1,
2,
)
.unwrap();
let mut canvas = Canvas::new(2);
canvas.clear(TRANSPARENT);
canvas.rect(
70,
60,
BRIEFING_WIDTH as i32,
BRIEFING_HEIGHT as i32,
MENU_BRIEFING,
);
canvas.rect(10, 10, 2, 2, 7);
let mut palette = [[0; 3]; 256];
palette[7] = [101, 102, 103];
let rgba = art.compose(&canvas, &palette, false);
for (x, y) in [(140, 120), (403, 259)] {
let index = (y * canvas.image.width + x) * 4;
assert_eq!(&rgba[index..index + 4], &[17, 123, 231, 255]);
}
let index = (20 * canvas.image.width + 20) * 4;
assert_eq!(&rgba[index..index + 4], &[101, 102, 103, 255]);
}
#[test]
fn briefing_alpha_and_scanlines_apply_inside_the_same_viewport() {
let mut art = MenuArt::bundled();
let mut canvas = Canvas::new(1);
canvas.clear(TRANSPARENT);
let palette = [[0; 3]; 256];
let background = art.compose(&canvas, &palette, false);
art.set_briefing(
"alpha".into(),
RgbaImage::from_pixel(132, 70, image::Rgba([20, 80, 150, 128])),
1,
1,
)
.unwrap();
canvas.rect(40, 30, 132, 70, MENU_BRIEFING);
let plain = art.compose(&canvas, &palette, false);
let scan = art.compose(&canvas, &palette, true);
for (x, y) in [(40, 30), (41, 32)] {
let i = (y * canvas.image.width + x) * 4;
for c in 0..3 {
let expected = ([20u16, 80, 150][c] * 128 + background[i + c] as u16 * 127) / 255;
assert_eq!(plain[i + c], expected as u8);
assert_eq!(
scan[i + c],
if y % 3 == 2 {
expected as u8 / 2
} else {
expected as u8
}
);
}
}
assert_eq!(&plain[..4], &background[..4]);
}
#[test]
fn briefing_cache_is_one_thumbnail_and_invalid_strips_are_rejected() {
let mut art = MenuArt::bundled();
art.set_briefing("sd".into(), RgbaImage::new(320, 200), 1, 1)
.unwrap();
assert!(art.has_briefing("sd"));
art.set_briefing("hd".into(), RgbaImage::new(1280, 800), 1, 4)
.unwrap();
assert!(!art.has_briefing("sd"));
assert!(art.has_briefing("hd"));
assert_eq!(art.briefing.as_ref().unwrap().1.dimensions(), (528, 280));
assert!(
art.set_briefing("bad".into(), RgbaImage::new(7, 4), 2, 1)
.is_err()
);
assert!(
art.set_briefing("bad".into(), RgbaImage::new(8, 4), 0, 1)
.is_err()
);
assert!(art.has_briefing("hd"));
art.clear_briefing();
assert!(!art.has_briefing("hd"));
}
}
+294 -51
View File
@@ -378,6 +378,72 @@ pub struct ActiveMods {
pub pins: Vec<ModPin>,
pub scripts: ScriptRuntime,
}
/// Validate referenced artwork files for every content owner, including bundled
/// mission briefings. Native base sprites are already checked by the catalog;
/// mod-referenced native frames retain their existing explicit checks here.
/// PNG decoding is shared by CLI verification and transactional mod activation.
pub fn validate_artwork(registry: &Registry, catalog: Option<&Catalog>) -> Result<()> {
registry.validate()?;
let validate_art = |owner: &str, art: &crate::content::Art| -> Result<()> {
for png in art.png.iter().chain(art.hd_png.iter()) {
let root = registry
.asset_roots
.get(owner)
.with_context(|| format!("{owner}/{png}: missing artwork root"))?;
let path = contained(root, Path::new(png))
.with_context(|| format!("{owner}/{png}: missing or unsafe artwork path; restore this file inside its artwork root"))?;
// 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)
.with_context(|| format!("{owner}/{png}: invalid PNG artwork"))?;
let width = image.width() as usize;
let height = image.height() as usize;
ensure!(
width % art.frames as usize == 0,
"{owner}/{png}: PNG strip width must divide evenly into frames"
);
ensure!(
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!(
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"
);
}
}
if let (Some(id), Some(cat)) = (art.asset.filter(|_| owner != "base"), catalog) {
for frame in 0..art.frames {
cat.sprite(id.checked_add(frame).context("asset ID overflow")?)?;
}
}
Ok(())
};
for (id, owner) in &registry.owners {
let mut artwork = Vec::new();
artwork.extend(registry.art(id));
if let Some(mission) = registry.missions.get(id) {
for tile in &mission.tiles {
artwork.push(&tile.art);
artwork.extend(tile.destroyed.as_ref());
}
}
for art in artwork {
validate_art(owner, art).map_err(|error| {
let context = format!("invalid artwork for {id} (owner {owner}): {error:#}");
error.context(context)
})?;
}
}
Ok(())
}
impl ActiveMods {
pub fn build(
base: &Registry,
@@ -428,53 +494,11 @@ impl ActiveMods {
hash: m.hash.clone(),
});
}
registry.validate()?;
let validate_art = |owner: &str, art: &crate::content::Art| -> Result<()> {
for png in art.png.iter().chain(art.hd_png.iter()) {
let root = registry
.asset_roots
.get(owner)
.context("missing PNG root")?;
let path = contained(root, Path::new(png))?;
// 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!(
width % art.frames as usize == 0,
"{owner}/{png}: PNG strip width must divide evenly into frames"
);
ensure!(
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!(
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"
);
}
}
if let (Some(id), Some(cat)) = (art.asset, catalog) {
for frame in 0..art.frames {
cat.sprite(id.checked_add(frame).context("asset ID overflow")?)?;
}
}
Ok(())
};
validate_artwork(&registry, catalog)?;
for (id, owner) in &registry.owners {
if owner == "base" {
continue;
}
if let Some(art) = registry.art(id) {
validate_art(owner, art)?;
}
if let Some(sound) = registry.sounds.get(id) {
let root = registry
.asset_roots
@@ -493,14 +517,6 @@ impl ActiveMods {
format!("{owner}/{}: invalid music {}", music.file, music.id)
})?;
}
if let Some(m) = registry.missions.get(id) {
for tile in &m.tiles {
validate_art(owner, &tile.art)?;
if let Some(a) = &tile.destroyed {
validate_art(owner, a)?;
}
}
}
}
let validate_script = |owner: &str, path: &Option<String>| -> Result<()> {
if let Some(path) = path {
@@ -873,6 +889,233 @@ pub fn write_schemas(root: &Path) -> Result<()> {
#[cfg(test)]
mod tests {
use super::*;
fn artwork_fixture() -> (tempfile::TempDir, Registry) {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("artwork");
fs::create_dir(&root).unwrap();
for (name, width, height) in [("sd.png", 4, 2), ("hd.png", 8, 4)] {
image::RgbaImage::from_pixel(width, height, image::Rgba([31, 92, 173, 255]))
.save(root.join(name))
.unwrap();
}
let pack = serde_json::from_value(serde_json::json!({"missions":[{
"id":"base:art_mission", "name":"Artwork fixture", "duration":10,
"art":{"png":"sd.png","hd_png":"hd.png","logical_width":4,"logical_height":2}
}]}))
.unwrap();
let mut registry = Registry::default();
registry.asset_roots.insert("base".into(), root);
registry.apply(pack, "base").unwrap();
(temp, registry)
}
fn artwork_error(registry: &Registry) -> String {
match validate_artwork(registry, None) {
Ok(()) => panic!("expected artwork validation failure"),
Err(error) => format!("{error:#}"),
}
}
#[test]
fn referenced_artwork_valid_base_sd_hd_passes_direct_and_activation_checks() {
let (_temp, registry) = artwork_fixture();
validate_artwork(&registry, None).unwrap();
let active = ActiveMods::build(&registry, &Discovery::default(), &[], None).unwrap();
assert!(active.pins.is_empty());
assert!(active.scripts.states.is_empty());
assert_eq!(active.registry.missions.len(), 1);
}
#[test]
fn referenced_artwork_missing_base_sd_or_hd_reports_content_owner_and_filename() {
for name in ["sd.png", "hd.png"] {
let (_temp, registry) = artwork_fixture();
fs::remove_file(registry.asset_roots["base"].join(name)).unwrap();
let error = artwork_error(&registry);
assert!(error.contains("base:art_mission"), "{error}");
assert!(error.contains(&format!("base/{name}")), "{error}");
assert!(error.contains("restore this file"), "{error}");
assert!(ActiveMods::build(&registry, &Discovery::default(), &[], None).is_err());
}
}
#[test]
fn referenced_artwork_corrupt_base_png_is_rejected_with_filename() {
let (_temp, registry) = artwork_fixture();
fs::write(registry.asset_roots["base"].join("sd.png"), b"not a PNG").unwrap();
let error = artwork_error(&registry);
assert!(
error.contains("base:art_mission")
&& error.contains("base/sd.png")
&& error.contains("invalid PNG"),
"{error}"
);
}
#[test]
fn referenced_artwork_base_aspect_and_hd_minimum_resolution_are_checked() {
for (name, width, height, expected) in [
("sd.png", 4, 3, "aspect"),
("hd.png", 4, 2, "at least twice"),
] {
let (_temp, registry) = artwork_fixture();
image::RgbaImage::new(width, height)
.save(registry.asset_roots["base"].join(name))
.unwrap();
let error = artwork_error(&registry);
assert!(
error.contains("base:art_mission")
&& error.contains(name)
&& error.contains(expected),
"{error}"
);
}
}
#[test]
fn referenced_artwork_base_animation_strip_divisibility_is_checked() {
let (_temp, mut registry) = artwork_fixture();
registry
.missions
.get_mut(&crate::content::ContentId::from("base:art_mission"))
.unwrap()
.art
.as_mut()
.unwrap()
.frames = 2;
let root = &registry.asset_roots["base"];
image::RgbaImage::new(7, 2)
.save(root.join("sd.png"))
.unwrap();
image::RgbaImage::new(16, 4)
.save(root.join("hd.png"))
.unwrap();
assert!(artwork_error(&registry).contains("strip width"));
image::RgbaImage::new(8, 2)
.save(root.join("sd.png"))
.unwrap();
validate_artwork(&registry, None).unwrap();
}
#[test]
fn referenced_artwork_base_parent_and_absolute_paths_are_rejected() {
for absolute in [false, true] {
let (temp, mut registry) = artwork_fixture();
let outside = temp.path().join("outside.png");
image::RgbaImage::new(4, 2).save(&outside).unwrap();
let art = registry
.missions
.get_mut(&crate::content::ContentId::from("base:art_mission"))
.unwrap()
.art
.as_mut()
.unwrap();
art.png = Some(if absolute {
outside.to_str().unwrap().into()
} else {
"../outside.png".into()
});
let error = artwork_error(&registry);
assert!(
error.contains("base:art_mission")
&& error.contains("unsafe artwork path")
&& error.contains("inside its root"),
"{error}"
);
}
}
#[cfg(unix)]
#[test]
fn referenced_artwork_base_symlink_escape_is_rejected() {
let (temp, mut registry) = artwork_fixture();
let outside = temp.path().join("outside.png");
image::RgbaImage::new(4, 2).save(&outside).unwrap();
std::os::unix::fs::symlink(&outside, registry.asset_roots["base"].join("escape.png"))
.unwrap();
registry
.missions
.get_mut(&crate::content::ContentId::from("base:art_mission"))
.unwrap()
.art
.as_mut()
.unwrap()
.png = Some("escape.png".into());
let error = artwork_error(&registry);
assert!(
error.contains("base:art_mission") && error.contains("symlink escapes"),
"{error}"
);
}
#[test]
fn referenced_artwork_base_weapons_campaigns_and_tile_variants_are_not_skipped() {
for kind in ["weapon", "campaign", "terrain", "destroyed terrain"] {
let (_temp, mut registry) = artwork_fixture();
let good = registry.missions[&crate::content::ContentId::from("base:art_mission")]
.art
.clone()
.unwrap();
let mut bad = good.clone();
bad.png = Some("missing.png".into());
if kind == "weapon" || kind == "campaign" {
let value = if kind == "weapon" {
serde_json::json!({"weapons":[{"id":"base:extra","name":"Extra","damage":1,"speed":1,"cooldown":1,"behavior":"straight","target":"all","art":bad}]})
} else {
serde_json::json!({"campaigns":[{"id":"base:extra","name":"Extra","missions":["base:art_mission"],"art":bad}]})
};
registry
.apply(serde_json::from_value(value).unwrap(), "base")
.unwrap();
} else {
let mission = registry
.missions
.get_mut(&crate::content::ContentId::from("base:art_mission"))
.unwrap();
mission.columns = 1;
mission.layout = vec![0];
mission.tiles = vec![crate::content::TileDef {
art: if kind == "terrain" { bad.clone() } else { good },
destroyed: (kind == "destroyed terrain").then_some(bad),
health: 1,
reward: 0,
}];
}
let error = artwork_error(&registry);
assert!(error.contains("missing.png"), "{kind}: {error}");
}
}
#[test]
fn referenced_artwork_explicit_mod_override_uses_winning_owner_root() {
let (temp, registry) = artwork_fixture();
let mods = temp.path().join("mods");
let alpha = mods.join("alpha");
fs::create_dir_all(&alpha).unwrap();
fs::write(
alpha.join("mod.toml"),
"id = \"alpha\"\nname = \"Alpha\"\nversion = \"1.0.0\"\napi_version = 1\n",
)
.unwrap();
let content = serde_json::json!({"overrides":["base:art_mission"],"missions":[{"id":"base:art_mission","name":"Override","duration":10,"art":{"png":"sd.png","hd_png":"hd.png","logical_width":4,"logical_height":2}}]});
fs::write(
alpha.join("content.json"),
serde_json::to_vec(&content).unwrap(),
)
.unwrap();
for name in ["sd.png", "hd.png"] {
fs::copy(registry.asset_roots["base"].join(name), alpha.join(name)).unwrap();
fs::remove_file(registry.asset_roots["base"].join(name)).unwrap();
}
let active =
ActiveMods::build(&registry, &discover(&mods), &["alpha".into()], None).unwrap();
assert_eq!(
active.registry.owners[&crate::content::ContentId::from("base:art_mission")],
"alpha"
);
// The mod-owned briefing itself is also required, rather than merely
// accepting its winning owner and ignoring mission artwork again.
fs::write(alpha.join("hd.png"), b"broken PNG").unwrap();
let error = match ActiveMods::build(&registry, &discover(&mods), &["alpha".into()], None) {
Ok(_) => panic!("corrupt mod briefing accepted"),
Err(error) => format!("{error:#}"),
};
assert!(
error.contains("base:art_mission") && error.contains("alpha/hd.png"),
"{error}"
);
}
#[test]
fn bounded_script_and_state_rollback() {
let mut rt = ScriptRuntime::new();