Package RustRaptor and refine controls, menus, saves, sound and mod selection
This commit is contained in:
1 parent
0844f6dffa
commit
e17bc1003e
649 files changed
+8652
-2677
No files matched your search
-428
@@ -1,428 +0,0 @@
|
||||
//! 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<u32, Entry>,
|
||||
names: BTreeMap<String, u32>,
|
||||
}
|
||||
pub fn contained(root: &Path, relative: &Path) -> Result<PathBuf> {
|
||||
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<Path>) -> Result<Self> {
|
||||
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::<usize>()?;
|
||||
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<u32> {
|
||||
self.names
|
||||
.get(&name.to_ascii_uppercase())
|
||||
.copied()
|
||||
.with_context(|| format!("missing asset {name}"))
|
||||
}
|
||||
pub fn read(&self, id: u32) -> Result<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
self.read(self.id(name)?)
|
||||
}
|
||||
pub fn sprite(&self, id: u32) -> Result<Sprite> {
|
||||
Sprite::decode(&self.read(id)?).with_context(|| format!("sprite {id:x}"))
|
||||
}
|
||||
pub fn verify(&self) -> Result<usize> {
|
||||
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<i32> {
|
||||
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<i16> {
|
||||
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<u16>,
|
||||
}
|
||||
impl Sprite {
|
||||
pub fn blank(width: usize, height: usize) -> Self {
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
pixels: vec![TRANSPARENT; width * height],
|
||||
}
|
||||
}
|
||||
pub fn decode(data: &[u8]) -> Result<Self> {
|
||||
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<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 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::<i32>()
|
||||
})
|
||||
.unwrap()
|
||||
.0 as u16
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LegacyMap {
|
||||
pub tiles: Vec<(u16, u16)>,
|
||||
pub spawns: Vec<LegacySpawn>,
|
||||
}
|
||||
#[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<Self> {
|
||||
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<i32>,
|
||||
pub shoot_type: Vec<i16>,
|
||||
pub engine_x: Vec<i16>,
|
||||
pub engine_y: Vec<i16>,
|
||||
pub engine_width: Vec<i16>,
|
||||
pub gun_x: Vec<i16>,
|
||||
pub gun_y: Vec<i16>,
|
||||
pub flight_x: Vec<i16>,
|
||||
pub flight_y: Vec<i16>,
|
||||
}
|
||||
impl LegacyEnemy {
|
||||
pub const SIZE: usize = 528;
|
||||
pub fn library(data: &[u8]) -> Result<Vec<Self>> {
|
||||
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::<Result<Vec<_>>>()?;
|
||||
let shorts = |offset: usize, count: usize| -> Result<Vec<i16>> {
|
||||
(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<DemoFrame>)> {
|
||||
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::<Result<_>>()?;
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user