//! Checked decoders for the loose catalog and the original little-endian assets. use anyhow::{Context, Result, bail, ensure}; use serde::{Deserialize, Serialize}; use std::{ collections::{BTreeMap, HashMap}, fs, path::{Component, Path, PathBuf}, }; pub const TRANSPARENT: u16 = 256; #[derive(Debug, Clone)] pub struct Entry { pub id: u32, pub size: usize, pub path: PathBuf, } #[derive(Debug, Clone)] pub struct Catalog { pub root: PathBuf, pub entries: BTreeMap, names: BTreeMap, } pub fn contained(root: &Path, relative: &Path) -> Result { ensure!( !relative.is_absolute() && relative .components() .all(|c| matches!(c, Component::Normal(_))), "asset path must stay inside its root: {}", relative.display() ); let root = root.canonicalize().context("asset root does not exist")?; let path = root .join(relative) .canonicalize() .with_context(|| format!("missing asset {}", relative.display()))?; ensure!(path.starts_with(&root), "asset symlink escapes its root"); Ok(path) } impl Catalog { pub fn open(root: impl AsRef) -> Result { let root = root.as_ref().to_path_buf(); let index = fs::read_to_string(root.join("INDEX"))?; let mut entries = BTreeMap::new(); let mut names = BTreeMap::new(); for (line_no, line) in index.lines().enumerate() { if line.trim().is_empty() { continue; } let fields: Vec<_> = line.split_whitespace().collect(); ensure!( fields.len() == 3, "INDEX line {}: expected id, size, path", line_no + 1 ); let id = u32::from_str_radix(fields[0], 16)?; let size = fields[1].parse::()?; ensure!(size <= 64 * 1024 * 1024, "asset exceeds 64 MiB"); let path = PathBuf::from(fields[2]); contained(&root, &path)?; ensure!( entries .insert( id, Entry { id, size, path: path.clone() } ) .is_none(), "duplicate asset id {id:x}" ); let name = path .file_name() .context("missing asset name")? .to_string_lossy() .to_ascii_uppercase(); names.entry(name).or_insert(id); } ensure!(!entries.is_empty(), "empty catalog"); Ok(Self { root, entries, names, }) } pub fn id(&self, name: &str) -> Result { self.names .get(&name.to_ascii_uppercase()) .copied() .with_context(|| format!("missing asset {name}")) } pub fn read(&self, id: u32) -> Result> { let entry = self .entries .get(&id) .with_context(|| format!("unknown asset {id:x}"))?; let data = fs::read(contained(&self.root, &entry.path)?)?; ensure!( data.len() == entry.size, "asset {id:x}: INDEX size {} != actual {}", entry.size, data.len() ); Ok(data) } pub fn named(&self, name: &str) -> Result> { self.read(self.id(name)?) } pub fn sprite(&self, id: u32) -> Result { Sprite::decode(&self.read(id)?).with_context(|| format!("sprite {id:x}")) } pub fn verify(&self) -> Result { for id in self.entries.keys() { self.read(*id)?; } Ok(self.entries.len()) } pub fn palette(&self) -> Result<[[u8; 3]; 256]> { decode_palette(&self.named("PALETTE_DAT")?) } } pub fn i32_at(data: &[u8], off: usize) -> Result { let end = off.checked_add(4).context("32-bit field offset overflow")?; Ok(i32::from_le_bytes( data.get(off..end) .context("truncated 32-bit field")? .try_into()?, )) } pub fn i16_at(data: &[u8], off: usize) -> Result { let end = off.checked_add(2).context("16-bit field offset overflow")?; Ok(i16::from_le_bytes( data.get(off..end) .context("truncated 16-bit field")? .try_into()?, )) } pub fn decode_palette(data: &[u8]) -> Result<[[u8; 3]; 256]> { ensure!(data.len() == 768, "palette must contain 768 bytes"); Ok(std::array::from_fn(|i| { std::array::from_fn(|j| { let x = data[i * 3 + j]; (x.min(63) << 2) | (x.min(63) >> 4) }) })) } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Sprite { pub width: usize, pub height: usize, pub pixels: Vec, } impl Sprite { pub fn blank(width: usize, height: usize) -> Self { Self { width, height, pixels: vec![TRANSPARENT; width * height], } } pub fn decode(data: &[u8]) -> Result { let typ = i32_at(data, 0)?; let width = i32_at(data, 12)?; let height = i32_at(data, 16)?; ensure!( (1..=4096).contains(&width) && (1..=4096).contains(&height) && width as i64 * height as i64 <= 1_048_576, "invalid sprite dimensions {width}x{height}" ); let mut out = Self::blank(width as usize, height as usize); match typ { 1 => { let bytes = data .get(20..20 + out.pixels.len()) .context("truncated raw picture")?; for (dst, src) in out.pixels.iter_mut().zip(bytes) { *dst = *src as u16; } } 0 => { let mut off = 20; let mut runs = 0; loop { let x = i32_at(data, off)?; let y = i32_at(data, off + 4)?; let dest = i32_at(data, off + 8)?; let len = i32_at(data, off + 12)?; off += 16; if dest == -1 { break; } ensure!( x >= 0 && y >= 0 && len > 0 && x.checked_add(len).is_some_and(|n| n <= width) && y < height, "invalid sprite span" ); let bytes = data .get(off..off + len as usize) .context("truncated sprite span")?; let start = y as usize * out.width + x as usize; for (dst, src) in out.pixels[start..start + len as usize] .iter_mut() .zip(bytes) { *dst = *src as u16; } off += len as usize; runs += 1; ensure!(runs <= out.pixels.len(), "too many sprite spans"); } } _ => bail!("unsupported picture type {typ}"), } Ok(out) } pub fn png(path: &Path, palette: &[[u8; 3]; 256]) -> Result { 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 mut out = Self::blank(image.width() as usize, image.height() as usize); // Palette art repeats colors extensively. Cache by RGB (alpha is handled // separately) so terrain atlases do not search 256 entries per pixel. let mut colors = HashMap::<[u8; 3], u16>::new(); for (dst, p) in out.pixels.iter_mut().zip(image.pixels()) { if p[3] < 128 { continue; } *dst = *colors.entry([p[0], p[1], p[2]]).or_insert_with(|| { palette .iter() .enumerate() .min_by_key(|(_, c)| { (0..3) .map(|j| (p[j] as i32 - c[j] as i32).pow(2)) .sum::() }) .unwrap() .0 as u16 }); } Ok(out) } } #[derive(Debug, Clone)] pub struct LegacyMap { pub tiles: Vec<(u16, u16)>, pub spawns: Vec, } #[derive(Debug, Clone)] pub struct LegacySpawn { pub link: i32, pub library: usize, pub x: i32, pub row: i32, pub game: usize, pub difficulty: i32, } impl LegacyMap { pub fn decode(data: &[u8]) -> Result { let size = i32_at(data, 0)?; let off = i32_at(data, 4)?; let count = i32_at(data, 8)?; ensure!( size >= 5412 && size as usize == data.len() && off == 5412 && count >= 0, "invalid map header" ); ensure!( (count as usize) < 65536 && 5412 + count as usize * 24 <= data.len(), "truncated map spawns" ); let mut tiles = Vec::new(); for i in 0..1350 { let off = 12 + i * 4; let flat = i16_at(data, off)?; let game = i16_at(data, off + 2)?; ensure!( flat >= 0 && (0..4).contains(&game), "invalid tile reference" ); tiles.push((flat as u16, game as u16)); } let mut spawns = Vec::new(); for i in 0..count as usize { let o = 5412 + i * 24; let lib = i32_at(data, o + 4)?; let game = i32_at(data, o + 16)?; ensure!(lib >= 0 && (0..4).contains(&game), "invalid enemy library"); spawns.push(LegacySpawn { link: i32_at(data, o)?, library: lib as usize, x: i32_at(data, o + 8)?, row: i32_at(data, o + 12)?, game: game as usize, difficulty: i32_at(data, o + 20)?, }); } Ok(Self { tiles, spawns }) } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LegacyEnemy { pub name: String, pub fields: Vec, pub shoot_type: Vec, pub engine_x: Vec, pub engine_y: Vec, pub engine_width: Vec, pub gun_x: Vec, pub gun_y: Vec, pub flight_x: Vec, pub flight_y: Vec, } impl LegacyEnemy { pub const SIZE: usize = 528; pub fn library(data: &[u8]) -> Result> { ensure!(data.len() % Self::SIZE == 0, "truncated enemy library"); data.chunks_exact(Self::SIZE) .map(|record| { let name = String::from_utf8(record[..16].split(|x| *x == 0).next().unwrap().to_vec())?; let fields = (0..26) .map(|i| i32_at(record, 16 + i * 4)) .collect::>>()?; let shorts = |offset: usize, count: usize| -> Result> { (0..count).map(|i| i16_at(record, offset + i * 2)).collect() }; ensure!( (0..=30).contains(&fields[19]) && (0..=24).contains(&fields[22]), "invalid enemy path or guns" ); Ok(Self { name, fields, shoot_type: shorts(120, 24)?, engine_x: shorts(168, 24)?, engine_y: shorts(216, 24)?, engine_width: shorts(264, 24)?, gun_x: shorts(312, 24)?, gun_y: shorts(360, 24)?, flight_x: shorts(408, 30)?, flight_y: shorts(468, 30)?, }) }) .collect() } } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct DemoFrame { pub buttons: [bool; 4], pub x: i16, pub y: i16, pub bank: i16, } pub fn decode_demo(data: &[u8]) -> Result<(usize, usize, Vec)> { ensure!(data.len() >= 12 && data.len() % 12 == 0, "truncated demo"); let count = i16_at(data, 8)?; ensure!( count >= 0 && count as usize * 12 <= data.len(), "invalid demo frame count" ); let sector = i16_at(data, 4)?; let wave = i16_at(data, 6)?; ensure!( (0..3).contains(§or) && (0..9).contains(&wave), "invalid demo mission" ); let frames = data[12..] .chunks_exact(12) .take(count as usize) .map(|f| { Ok(DemoFrame { buttons: std::array::from_fn(|i| f[i] != 0), x: i16_at(f, 4)?, y: i16_at(f, 6)?, bank: i16_at(f, 8)?, }) }) .collect::>()?; Ok((sector as usize, wave as usize, frames)) } #[cfg(test)] mod tests { use super::*; #[test] fn truncation_is_an_error() { for n in 0..20 { assert!(Sprite::decode(&vec![0; n]).is_err()); } } #[test] fn raw_picture() { let mut b = Vec::new(); for x in [1i32, 0, 0, 2, 1] { b.extend(x.to_le_bytes()) } b.extend([0, 7]); assert_eq!(Sprite::decode(&b).unwrap().pixels, vec![0, 7]); } #[test] fn span_outside_canvas_rejected() { let mut b = Vec::new(); for x in [0i32, 0, 0, 2, 1, 1, 0, 1, 2] { b.extend(x.to_le_bytes()) } b.extend([3, 4]); assert!(Sprite::decode(&b).is_err()); } }