Add independent Rust engine, deterministic mod API and asset pipeline
This commit is contained in:
1 parent
f2cce6f51b
commit
dbb1d4596b
53 files changed
+18314
-56
No files matched your search
+428
@@ -0,0 +1,428 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
+873
@@ -0,0 +1,873 @@
|
||||
//! Checked MUS decoding, the original GENMIDI/OPL3 driver behavior and digital DSP mixing.
|
||||
//!
|
||||
//! Driver and DSP behavior is ported from the retained GPL-2.0-or-later sources.
|
||||
//! The OPL driver credits Id Software (1993–1996) and Simon Howard (2005–2014).
|
||||
use crate::assets::{i16_at, i32_at};
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
mod opl3;
|
||||
use opl3::Chip as Opl3Chip;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MusicEvent {
|
||||
Off(u8, u8),
|
||||
On(u8, u8, u8),
|
||||
OnInherited(u8, u8),
|
||||
Bend(u8, u8),
|
||||
System(u8, u8),
|
||||
Control(u8, u8, u8),
|
||||
End,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MusScore {
|
||||
pub groups: Vec<(u32, Vec<MusicEvent>)>,
|
||||
pub channels: u8,
|
||||
}
|
||||
impl MusScore {
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self> {
|
||||
ensure!(bytes.get(..4) == Some(b"MUS\x1a"), "invalid MUS signature");
|
||||
let length = i16_at(bytes, 4)? as u16 as usize;
|
||||
let start = i16_at(bytes, 6)? as u16 as usize;
|
||||
let channels = i16_at(bytes, 8)? as u16;
|
||||
let instruments = i16_at(bytes, 12)? as u16 as usize;
|
||||
ensure!(channels <= 15, "MUS has too many primary channels");
|
||||
ensure!(
|
||||
start >= 16 + instruments * 2
|
||||
&& start.checked_add(length).is_some_and(|e| e <= bytes.len()),
|
||||
"MUS score range is invalid"
|
||||
);
|
||||
let data = &bytes[start..start + length];
|
||||
let mut p = 0usize;
|
||||
let mut tick = 0u32;
|
||||
let mut groups = Vec::new();
|
||||
let mut pending = Vec::new();
|
||||
let mut ended = false;
|
||||
let byte = |p: &mut usize| -> Result<u8> {
|
||||
let value = *data.get(*p).context("truncated MUS event")?;
|
||||
*p += 1;
|
||||
Ok(value)
|
||||
};
|
||||
while p < data.len() {
|
||||
let descriptor = byte(&mut p)?;
|
||||
let channel = descriptor & 15;
|
||||
let kind = (descriptor >> 4) & 7;
|
||||
let event = match kind {
|
||||
0 => MusicEvent::Off(channel, byte(&mut p)? & 127),
|
||||
1 => {
|
||||
let key = byte(&mut p)?;
|
||||
if key & 128 != 0 {
|
||||
let velocity = byte(&mut p)?;
|
||||
ensure!(velocity <= 127, "invalid MUS note velocity");
|
||||
MusicEvent::On(channel, key & 127, velocity)
|
||||
} else {
|
||||
MusicEvent::OnInherited(channel, key)
|
||||
}
|
||||
}
|
||||
2 => MusicEvent::Bend(channel, byte(&mut p)?),
|
||||
3 => {
|
||||
let control = byte(&mut p)?;
|
||||
ensure!((10..=14).contains(&control), "invalid MUS system event");
|
||||
MusicEvent::System(channel, control)
|
||||
}
|
||||
4 => {
|
||||
let control = byte(&mut p)?;
|
||||
let value = byte(&mut p)?;
|
||||
ensure!(
|
||||
control <= 14 && value <= 127,
|
||||
"invalid MUS controller event"
|
||||
);
|
||||
MusicEvent::Control(channel, control, value)
|
||||
}
|
||||
6 => MusicEvent::End,
|
||||
_ => bail!("unsupported MUS event {kind}"),
|
||||
};
|
||||
let end = matches!(event, MusicEvent::End);
|
||||
pending.push(event);
|
||||
if descriptor & 128 != 0 || end {
|
||||
groups.push((tick, std::mem::take(&mut pending)));
|
||||
if end {
|
||||
ended = true;
|
||||
break;
|
||||
}
|
||||
let mut delay = 0u32;
|
||||
for n in 0..5 {
|
||||
let v = byte(&mut p)?;
|
||||
delay = delay
|
||||
.checked_mul(128)
|
||||
.and_then(|x| x.checked_add((v & 127) as u32))
|
||||
.context("MUS delay overflow")?;
|
||||
if v & 128 == 0 {
|
||||
break;
|
||||
}
|
||||
ensure!(n < 4, "MUS delay too long");
|
||||
}
|
||||
tick = tick.checked_add(delay).context("MUS time overflow")?;
|
||||
}
|
||||
}
|
||||
ensure!(ended, "MUS score has no end event");
|
||||
Ok(Self {
|
||||
groups,
|
||||
channels: channels as u8,
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct Instrument {
|
||||
flags: u16,
|
||||
fixed: u8,
|
||||
fine: u8,
|
||||
voice: [[u8; 16]; 2],
|
||||
}
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Voice {
|
||||
channel: u8,
|
||||
key: u8,
|
||||
frequency: u16,
|
||||
velocity: u8,
|
||||
active: bool,
|
||||
instrument: usize,
|
||||
part: usize,
|
||||
note: i32,
|
||||
loaded: bool,
|
||||
car_volume: u8,
|
||||
mod_volume: u8,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct DigitalSample {
|
||||
pub rate: u32,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
impl DigitalSample {
|
||||
pub fn decode(data: &[u8]) -> Result<Self> {
|
||||
ensure!(i16_at(data, 0)? == 3, "not a digital sound patch");
|
||||
let rate = i16_at(data, 2)? as u16 as u32;
|
||||
let length = i32_at(data, 4)?;
|
||||
ensure!(
|
||||
rate > 0 && length > 32 && length as usize <= data.len().saturating_sub(8),
|
||||
"invalid digital sound header"
|
||||
);
|
||||
// DMX patches include sixteen bytes of padding at each end.
|
||||
Ok(Self {
|
||||
rate,
|
||||
data: data[24..8 + length as usize - 16].to_vec(),
|
||||
})
|
||||
}
|
||||
}
|
||||
struct SampleVoice {
|
||||
sample: DigitalSample,
|
||||
position: usize,
|
||||
step: usize,
|
||||
fraction: usize,
|
||||
remaining: usize,
|
||||
left: [i16; 256],
|
||||
right: [i16; 256],
|
||||
priority: i32,
|
||||
}
|
||||
struct Fade {
|
||||
level: i32,
|
||||
target: i32,
|
||||
add: i32,
|
||||
remainder: i32,
|
||||
steps: i32,
|
||||
accumulator: i32,
|
||||
}
|
||||
pub struct Mixer {
|
||||
chip: Opl3Chip,
|
||||
rate: u32,
|
||||
instruments: Vec<Instrument>,
|
||||
voices: [Voice; 18],
|
||||
free: VecDeque<usize>,
|
||||
allocated: Vec<usize>,
|
||||
pan: [u8; 16],
|
||||
program: [usize; 16],
|
||||
volume: [u8; 16],
|
||||
channel_volume: [u8; 16],
|
||||
velocity: [u8; 16],
|
||||
bend: [u8; 16],
|
||||
score: Option<MusScore>,
|
||||
group: usize,
|
||||
score_tick: u32,
|
||||
music_phase: u32,
|
||||
fader_phase: u32,
|
||||
last_volume: u8,
|
||||
fade: Option<Fade>,
|
||||
looping: bool,
|
||||
digital: [Option<SampleVoice>; 8],
|
||||
digital_channels: usize,
|
||||
digital_phase: u32,
|
||||
digital_hold: [i32; 2],
|
||||
pub music_volume: u8,
|
||||
pub fx_volume: u8,
|
||||
}
|
||||
impl Mixer {
|
||||
pub fn new(rate: u32, genmidi: &[u8]) -> Result<Self> {
|
||||
ensure!(
|
||||
genmidi.get(..8) == Some(b"#OPL_II#") && genmidi.len() >= 8 + 175 * 36,
|
||||
"invalid GENMIDI instrument table"
|
||||
);
|
||||
ensure!((8000..=192000).contains(&rate), "invalid mixer sample rate");
|
||||
let instruments = genmidi[8..8 + 175 * 36]
|
||||
.chunks_exact(36)
|
||||
.map(|b| Instrument {
|
||||
flags: u16::from_le_bytes([b[0], b[1]]),
|
||||
fixed: b[3],
|
||||
fine: b[2],
|
||||
voice: [b[4..20].try_into().unwrap(), b[20..36].try_into().unwrap()],
|
||||
})
|
||||
.collect();
|
||||
let mut chip = Opl3Chip::new(rate);
|
||||
initialize_chip(&mut chip);
|
||||
Ok(Self {
|
||||
chip,
|
||||
rate,
|
||||
instruments,
|
||||
voices: [Voice::default(); 18],
|
||||
free: (0..18).collect(),
|
||||
allocated: vec![],
|
||||
pan: [0x30; 16],
|
||||
program: [0; 16],
|
||||
volume: [127; 16],
|
||||
channel_volume: [100; 16],
|
||||
velocity: [0; 16],
|
||||
bend: [128; 16],
|
||||
score: None,
|
||||
group: 0,
|
||||
score_tick: 0,
|
||||
music_phase: 0,
|
||||
fader_phase: 0,
|
||||
last_volume: 0,
|
||||
fade: None,
|
||||
looping: false,
|
||||
digital: std::array::from_fn(|_| None),
|
||||
digital_channels: 2,
|
||||
digital_phase: 0,
|
||||
digital_hold: [0; 2],
|
||||
music_volume: 85,
|
||||
fx_volume: 85,
|
||||
})
|
||||
}
|
||||
pub fn play(&mut self, score: MusScore, looping: bool) {
|
||||
self.play_with_fade(score, looping, false);
|
||||
}
|
||||
pub fn play_with_fade(&mut self, score: MusScore, looping: bool, fade_in: bool) {
|
||||
self.all_off();
|
||||
self.score = Some(score);
|
||||
self.looping = looping;
|
||||
self.group = 0;
|
||||
self.score_tick = 0;
|
||||
self.fade = None;
|
||||
self.reset_channels();
|
||||
if fade_in {
|
||||
self.fade = Some(make_fade(0, 127));
|
||||
}
|
||||
}
|
||||
pub fn stop(&mut self) {
|
||||
self.all_off();
|
||||
self.score = None;
|
||||
self.fade = None;
|
||||
}
|
||||
pub fn stop_with_fade(&mut self) {
|
||||
let level = self
|
||||
.fade
|
||||
.as_ref()
|
||||
.map_or(self.music_volume as i32, |f| f.level);
|
||||
if level == 0 {
|
||||
self.stop();
|
||||
} else {
|
||||
self.fade = Some(make_fade(level, 0));
|
||||
}
|
||||
}
|
||||
pub fn set_fx_channels(&mut self, count: usize) {
|
||||
self.digital_channels = count.clamp(1, 8);
|
||||
for slot in &mut self.digital[self.digital_channels..] {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
fn release(&mut self, n: usize) {
|
||||
self.chip
|
||||
.write_reg_buffered(register(0xb0, n), (self.voices[n].frequency >> 8) as u8);
|
||||
self.voices[n].active = false;
|
||||
self.allocated.retain(|v| *v != n);
|
||||
self.free.push_back(n);
|
||||
}
|
||||
fn all_off(&mut self) {
|
||||
for n in self.allocated.clone() {
|
||||
self.release(n);
|
||||
}
|
||||
}
|
||||
fn off(&mut self, channel: u8, key: u8) {
|
||||
for n in self.allocated.clone() {
|
||||
if self.voices[n].channel == channel && self.voices[n].key == key {
|
||||
self.release(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn channel_off(&mut self, channel: u8) {
|
||||
for n in self.allocated.clone() {
|
||||
if self.voices[n].channel == channel {
|
||||
self.release(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn reset_channels(&mut self) {
|
||||
self.channel_volume.fill(100);
|
||||
let channels = self.score.as_ref().map_or(15, |s| s.channels);
|
||||
for c in 0..16 {
|
||||
self.channel_off(c);
|
||||
if c < channels || c == 15 {
|
||||
self.bend[c as usize] = 128;
|
||||
self.pan[c as usize] = 0x30;
|
||||
self.set_channel_volume(
|
||||
c,
|
||||
100.min(self.music_volume.min(127)).min(self.fade_level()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn on(&mut self, channel: u8, key: u8, velocity: u8) {
|
||||
if velocity == 0 {
|
||||
self.off(channel, key);
|
||||
return;
|
||||
}
|
||||
if channel == 15 && !(35..=81).contains(&key) {
|
||||
return;
|
||||
}
|
||||
let instrument = if channel == 15 {
|
||||
128 + (key - 35) as usize
|
||||
} else {
|
||||
self.program[channel as usize].min(127)
|
||||
};
|
||||
if self.free.is_empty() {
|
||||
let mut result = self.allocated[0];
|
||||
for &n in &self.allocated {
|
||||
if self.voices[n].part != 0 || self.voices[n].channel >= self.voices[result].channel
|
||||
{
|
||||
result = n;
|
||||
}
|
||||
}
|
||||
self.release(result);
|
||||
}
|
||||
self.voice_on(channel, key, velocity, instrument, 0);
|
||||
if self.instruments[instrument].flags & 4 != 0 {
|
||||
self.voice_on(channel, key, velocity, instrument, 1);
|
||||
}
|
||||
}
|
||||
fn voice_on(&mut self, channel: u8, key: u8, velocity: u8, instrument: usize, part: usize) {
|
||||
let Some(n) = self.free.pop_front() else {
|
||||
return;
|
||||
};
|
||||
let ins = self.instruments[instrument].clone();
|
||||
let v = ins.voice[part];
|
||||
if !self.voices[n].loaded
|
||||
|| self.voices[n].instrument != instrument
|
||||
|| self.voices[n].part != part
|
||||
{
|
||||
let op = operator(n);
|
||||
let bank = (n as u16 / 9) << 8;
|
||||
for (offset, base) in [(3u16, 7usize), (0, 0)] {
|
||||
let level = v[base + 4]
|
||||
| if base == 7 || v[6] & 1 != 0 {
|
||||
63
|
||||
} else {
|
||||
v[base + 5]
|
||||
};
|
||||
if base == 7 {
|
||||
self.voices[n].car_volume = level;
|
||||
} else {
|
||||
self.voices[n].mod_volume = level;
|
||||
}
|
||||
for (reg, value) in [
|
||||
(0x40, level),
|
||||
(0x20, v[base]),
|
||||
(0x60, v[base + 1]),
|
||||
(0x80, v[base + 2]),
|
||||
(0xe0, v[base + 3]),
|
||||
] {
|
||||
self.chip
|
||||
.write_reg_buffered((reg + op + offset) | bank, value);
|
||||
}
|
||||
}
|
||||
self.chip
|
||||
.write_reg_buffered(register(0xc0, n), v[6] | self.pan[channel as usize]);
|
||||
}
|
||||
let note = if ins.flags & 1 != 0 {
|
||||
ins.fixed as i32
|
||||
} else {
|
||||
(if channel == 15 { 60 } else { key as i32 })
|
||||
+ i16::from_le_bytes([v[14], v[15]]) as i32
|
||||
};
|
||||
let old = self.voices[n];
|
||||
self.voices[n] = Voice {
|
||||
channel,
|
||||
key,
|
||||
frequency: 0,
|
||||
velocity,
|
||||
active: true,
|
||||
instrument,
|
||||
part,
|
||||
note,
|
||||
loaded: true,
|
||||
..old
|
||||
};
|
||||
self.allocated.push(n);
|
||||
self.voice_volume(n);
|
||||
self.voice_frequency(n);
|
||||
}
|
||||
fn voice_volume(&mut self, n: usize) {
|
||||
let voice = self.voices[n];
|
||||
let v = self.instruments[voice.instrument].voice[voice.part];
|
||||
let midi = 2
|
||||
* (crate::legacy_tables::VOLUME_CURVE[self.volume[voice.channel as usize] as usize]
|
||||
+ 1);
|
||||
let full = (crate::legacy_tables::VOLUME_CURVE[voice.velocity as usize] * midi) >> 9;
|
||||
let car = (63 - full) as u8;
|
||||
if car != voice.car_volume & 63 {
|
||||
let op = operator(n);
|
||||
let bank = (n as u16 / 9) << 8;
|
||||
self.voices[n].car_volume = (voice.car_volume & 192) | car;
|
||||
self.chip
|
||||
.write_reg_buffered((0x43 + op) | bank, self.voices[n].car_volume);
|
||||
if v[6] & 1 != 0 && v[5] != 63 {
|
||||
let level = (voice.mod_volume & 192) | v[5].max(car);
|
||||
if level != voice.mod_volume {
|
||||
self.voices[n].mod_volume = level;
|
||||
self.chip
|
||||
.write_reg_buffered((0x40 + op) | bank, level | (v[4] & 192));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn voice_frequency(&mut self, n: usize) {
|
||||
let voice = self.voices[n];
|
||||
let ins = &self.instruments[voice.instrument];
|
||||
let fine = if voice.part != 0 {
|
||||
ins.fine as i32 / 2 - 64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let f = frequency(voice.note, self.bend[voice.channel as usize], fine);
|
||||
if f != voice.frequency {
|
||||
self.voices[n].frequency = f;
|
||||
self.chip.write_reg_buffered(register(0xa0, n), f as u8);
|
||||
self.chip
|
||||
.write_reg_buffered(register(0xb0, n), ((f >> 8) as u8) | 0x20);
|
||||
}
|
||||
}
|
||||
fn set_channel_volume(&mut self, c: u8, v: u8) {
|
||||
self.volume[c as usize] = v.min(127);
|
||||
// Preserve original physical voice iteration order, which affects buffered writes.
|
||||
for n in 0..18 {
|
||||
if self.voices[n].active && self.voices[n].channel == c {
|
||||
self.voice_volume(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn event(&mut self, e: MusicEvent) {
|
||||
match e {
|
||||
MusicEvent::Off(c, k) => self.off(c, k),
|
||||
MusicEvent::On(c, k, v) => {
|
||||
self.velocity[c as usize] = v;
|
||||
self.on(c, k, v);
|
||||
}
|
||||
MusicEvent::OnInherited(c, k) => self.on(c, k, self.velocity[c as usize]),
|
||||
MusicEvent::Bend(c, b) => {
|
||||
self.bend[c as usize] = b;
|
||||
let (updated, others): (Vec<_>, Vec<_>) = self
|
||||
.allocated
|
||||
.iter()
|
||||
.copied()
|
||||
.partition(|n| self.voices[*n].channel == c);
|
||||
for &n in &updated {
|
||||
self.voice_frequency(n);
|
||||
}
|
||||
self.allocated = others.into_iter().chain(updated).collect();
|
||||
}
|
||||
MusicEvent::System(c, 10 | 11 | 14) => {
|
||||
self.channel_off(c);
|
||||
if matches!(e, MusicEvent::System(_, 14)) {
|
||||
self.bend[c as usize] = 128;
|
||||
self.pan[c as usize] = 0x30;
|
||||
}
|
||||
}
|
||||
MusicEvent::Control(c, 0, p) => self.program[c as usize] = p.min(127) as usize,
|
||||
MusicEvent::Control(c, 3, v) => {
|
||||
self.channel_volume[c as usize] = v;
|
||||
self.set_channel_volume(
|
||||
c,
|
||||
v.min(self.music_volume.min(127)).min(self.fade_level()),
|
||||
);
|
||||
}
|
||||
MusicEvent::Control(c, 4, p) => {
|
||||
let pan = if p >= 96 {
|
||||
0x10
|
||||
} else if p <= 48 {
|
||||
0x20
|
||||
} else {
|
||||
0x30
|
||||
};
|
||||
if self.pan[c as usize] != pan {
|
||||
self.pan[c as usize] = pan;
|
||||
for n in 0..18 {
|
||||
let voice = self.voices[n];
|
||||
if voice.active && voice.channel == c {
|
||||
self.chip.write_reg_buffered(
|
||||
register(0xc0, n),
|
||||
self.instruments[voice.instrument].voice[voice.part][6] | pan,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MusicEvent::Control(c, 10 | 11, _) => self.channel_off(c),
|
||||
MusicEvent::Control(c, 14, _) => {
|
||||
self.channel_off(c);
|
||||
self.set_channel_volume(
|
||||
c,
|
||||
self.velocity[c as usize]
|
||||
.min(self.music_volume.min(127))
|
||||
.min(self.fade_level()),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn fade_level(&self) -> u8 {
|
||||
self.fade
|
||||
.as_ref()
|
||||
.map_or(127, |f| f.level.clamp(0, 127) as u8)
|
||||
}
|
||||
fn service_music(&mut self) {
|
||||
// Events are serviced at 70Hz after that output sample, matching MUS_Mix.
|
||||
let mut restarts = 0;
|
||||
loop {
|
||||
let group = self
|
||||
.score
|
||||
.as_ref()
|
||||
.and_then(|s| s.groups.get(self.group))
|
||||
.filter(|(time, _)| *time <= self.score_tick)
|
||||
.cloned();
|
||||
let Some((time, events)) = group else {
|
||||
break;
|
||||
};
|
||||
self.group += 1;
|
||||
for event in events {
|
||||
if matches!(event, MusicEvent::End) {
|
||||
if self.looping && time > 0 && restarts == 0 {
|
||||
self.group = 0;
|
||||
self.score_tick = 0;
|
||||
self.reset_channels();
|
||||
restarts += 1;
|
||||
} else {
|
||||
self.score = None;
|
||||
}
|
||||
} else {
|
||||
self.event(event);
|
||||
}
|
||||
}
|
||||
if self.score.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.score_tick = self.score_tick.saturating_add(1);
|
||||
}
|
||||
fn service_fader(&mut self) {
|
||||
let mut update = self.last_volume != self.music_volume;
|
||||
self.last_volume = self.music_volume;
|
||||
if let Some(f) = &mut self.fade {
|
||||
update = true;
|
||||
if f.level < self.music_volume as i32 || f.target < self.music_volume as i32 {
|
||||
let mut count = f.add;
|
||||
f.accumulator += f.remainder;
|
||||
if f.accumulator >= f.steps {
|
||||
f.accumulator -= f.steps;
|
||||
count += 1;
|
||||
}
|
||||
if f.target < f.level {
|
||||
f.level = (f.level - count).max(0);
|
||||
} else {
|
||||
f.level = (f.level + count)
|
||||
.min(f.target)
|
||||
.min(self.music_volume.min(127) as i32);
|
||||
}
|
||||
}
|
||||
if f.level == 0 && f.target == 0 {
|
||||
self.stop();
|
||||
return;
|
||||
}
|
||||
if f.level >= f.target && f.target != 0 {
|
||||
self.fade = None;
|
||||
} else if f.target != 0 && f.level >= self.music_volume.min(127) as i32 {
|
||||
self.fade = None;
|
||||
}
|
||||
}
|
||||
if update {
|
||||
let channels = self.score.as_ref().map_or(15, |s| s.channels);
|
||||
for c in 0..16 {
|
||||
if c < channels || c == 15 {
|
||||
let v = self.music_volume.min(127).min(self.fade_level());
|
||||
self.set_channel_volume(
|
||||
c,
|
||||
if c == 15 {
|
||||
v
|
||||
} else {
|
||||
v.min(self.channel_volume[c as usize])
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn sample(
|
||||
&mut self,
|
||||
sample: DigitalSample,
|
||||
pan: i32,
|
||||
pitch: i32,
|
||||
volume: i32,
|
||||
priority: i32,
|
||||
) {
|
||||
if sample.data.is_empty() || sample.rate == 0 {
|
||||
return;
|
||||
}
|
||||
let pan = pan.clamp(0, 255) as usize;
|
||||
let step = ((DSP_PITCH[pitch.clamp(0, 255) as usize] as u64 * sample.rate as u64
|
||||
+ 11025 / 2)
|
||||
/ 11025)
|
||||
.max(1) as usize;
|
||||
let remaining = sample.data.len().saturating_mul(256) / step;
|
||||
if remaining == 0 {
|
||||
return;
|
||||
}
|
||||
let slot = if let Some(n) = self.digital[..self.digital_channels]
|
||||
.iter()
|
||||
.position(Option::is_none)
|
||||
{
|
||||
n
|
||||
} else {
|
||||
let mut lowest = 0;
|
||||
let mut fewest = usize::MAX;
|
||||
let mut best = 0;
|
||||
for (n, v) in self.digital[..self.digital_channels].iter().enumerate() {
|
||||
let v = v.as_ref().unwrap();
|
||||
if v.priority > lowest {
|
||||
lowest = v.priority;
|
||||
best = n;
|
||||
} else if v.priority == lowest && v.remaining < fewest {
|
||||
fewest = v.remaining;
|
||||
best = n;
|
||||
}
|
||||
}
|
||||
if lowest < priority || lowest == 0 {
|
||||
return;
|
||||
}
|
||||
if lowest == priority && self.digital[best].as_ref().unwrap().sample != sample {
|
||||
if let Some(n) = self.digital[..self.digital_channels]
|
||||
.iter()
|
||||
.position(|v| v.as_ref().is_some_and(|v| v.sample == sample))
|
||||
{
|
||||
best = n;
|
||||
}
|
||||
}
|
||||
best
|
||||
};
|
||||
let volume = volume.clamp(0, 127) * self.fx_volume.min(127) as i32 / 127;
|
||||
let left = volume_table(DSP_PAN[255 - pan] as i32 * volume / 127);
|
||||
let right = volume_table(DSP_PAN[pan] as i32 * volume / 127);
|
||||
self.digital[slot] = Some(SampleVoice {
|
||||
sample,
|
||||
position: 0,
|
||||
step,
|
||||
fraction: (step & 255) >> 1,
|
||||
remaining,
|
||||
left,
|
||||
right,
|
||||
priority,
|
||||
});
|
||||
}
|
||||
fn service_digital(&mut self) {
|
||||
let mut sample = [0i32; 2];
|
||||
for slot in &mut self.digital[..self.digital_channels] {
|
||||
if let Some(v) = slot {
|
||||
if let Some(value) = v.sample.data.get(v.position) {
|
||||
sample[0] += v.left[*value as usize] as i32;
|
||||
sample[1] += v.right[*value as usize] as i32;
|
||||
}
|
||||
v.fraction += v.step & 255;
|
||||
let carry = usize::from(v.fraction >= 256);
|
||||
v.fraction &= 255;
|
||||
v.position = v.position.saturating_add((v.step >> 8) + carry);
|
||||
v.remaining -= 1;
|
||||
if v.remaining == 0 {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.digital_hold = sample.map(|v| v.clamp(-128, 127) * 128);
|
||||
}
|
||||
pub fn mix(&mut self, out: &mut [i16]) {
|
||||
// A partial stereo frame is explicitly silent, not leftover caller memory.
|
||||
out.fill(0);
|
||||
for frame in out.chunks_exact_mut(2) {
|
||||
let mut fm = [0i16; 2];
|
||||
self.chip.generate_resampled(&mut fm);
|
||||
self.music_phase += 70;
|
||||
while self.music_phase >= self.rate {
|
||||
self.music_phase -= self.rate;
|
||||
self.service_music();
|
||||
}
|
||||
self.fader_phase += 50;
|
||||
while self.fader_phase >= self.rate {
|
||||
self.fader_phase -= self.rate;
|
||||
self.service_fader();
|
||||
}
|
||||
self.digital_phase += 11025;
|
||||
while self.digital_phase >= self.rate {
|
||||
self.digital_phase -= self.rate;
|
||||
self.service_digital();
|
||||
}
|
||||
frame[0] = (fm[0] as i32 + self.digital_hold[0]).clamp(-32768, 32767) as i16;
|
||||
frame[1] = (fm[1] as i32 + self.digital_hold[1]).clamp(-32768, 32767) as i16;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn make_fade(level: i32, target: i32) -> Fade {
|
||||
let steps = (target - level).abs();
|
||||
Fade {
|
||||
level,
|
||||
target,
|
||||
add: steps / 51,
|
||||
remainder: steps % 51,
|
||||
steps: steps.max(1),
|
||||
accumulator: -((steps % 51) >> 1),
|
||||
}
|
||||
}
|
||||
fn volume_table(volume: i32) -> [i16; 256] {
|
||||
let mut value = -(volume / 2);
|
||||
let step = volume >> 8;
|
||||
let sub = volume & 255;
|
||||
let mut accumulator = sub >> 1;
|
||||
std::array::from_fn(|_| {
|
||||
let out = value as i16;
|
||||
value += step;
|
||||
accumulator += sub;
|
||||
while accumulator >= 256 {
|
||||
value += 1;
|
||||
accumulator -= 256;
|
||||
}
|
||||
out
|
||||
})
|
||||
}
|
||||
fn initialize_chip(chip: &mut Opl3Chip) {
|
||||
for r in 0x40..=0x55 {
|
||||
chip.write_reg_buffered(r, 63);
|
||||
}
|
||||
for r in 0x60..=0xf5 {
|
||||
chip.write_reg_buffered(r, 0);
|
||||
}
|
||||
for r in 1..0x40 {
|
||||
chip.write_reg_buffered(r, 0);
|
||||
}
|
||||
chip.write_reg_buffered(4, 0x60);
|
||||
chip.write_reg_buffered(4, 0x80);
|
||||
chip.write_reg_buffered(1, 0x20);
|
||||
chip.write_reg_buffered(0x105, 1);
|
||||
for r in 0x40..=0x55 {
|
||||
chip.write_reg_buffered(r | 0x100, 63);
|
||||
}
|
||||
for r in 0x60..=0xf5 {
|
||||
chip.write_reg_buffered(r | 0x100, 0);
|
||||
}
|
||||
for r in 1..0x40 {
|
||||
chip.write_reg_buffered(r | 0x100, 0);
|
||||
}
|
||||
chip.write_reg_buffered(8, 0x40);
|
||||
chip.write_reg_buffered(0x105, 1);
|
||||
}
|
||||
fn register(reg: u16, n: usize) -> u16 {
|
||||
(reg + n as u16 % 9) | ((n as u16 / 9) << 8)
|
||||
}
|
||||
fn operator(n: usize) -> u16 {
|
||||
[0, 1, 2, 8, 9, 10, 16, 17, 18][n % 9]
|
||||
}
|
||||
fn frequency(mut note: i32, bend: u8, fine: i32) -> u16 {
|
||||
while note < 0 {
|
||||
note += 12;
|
||||
}
|
||||
while note > 95 {
|
||||
note -= 12;
|
||||
}
|
||||
let index = (64 + 32 * note + (bend as i32 / 2 - 64) + fine).max(0) as usize;
|
||||
if index < 284 {
|
||||
crate::legacy_tables::FREQUENCY_CURVE[index]
|
||||
} else {
|
||||
crate::legacy_tables::FREQUENCY_CURVE[(index - 284) % 384 + 284]
|
||||
| ((((index - 284) / 384).min(7) as u16) << 10)
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn truncated_scores_are_rejected() {
|
||||
for n in 0..16 {
|
||||
assert!(MusScore::decode(&vec![0; n]).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn simple_score() {
|
||||
let mut data = b"MUS\x1a".to_vec();
|
||||
data.extend([5, 0, 16, 0, 1, 0, 0, 0, 0, 0, 0, 0]);
|
||||
data.extend([0x90, 0x80 | 60, 100, 1, 0x60]);
|
||||
let s = MusScore::decode(&data).unwrap();
|
||||
assert_eq!(s.groups.len(), 2);
|
||||
}
|
||||
#[test]
|
||||
fn bad_digital_length() {
|
||||
assert!(DigitalSample::decode(&[3, 0, 68, 172, 99, 0, 0, 0, 128]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
// Exact lookup values from the original digital mixer.
|
||||
const DSP_PITCH: [u16; 256] = [
|
||||
0x0040, 0x0040, 0x0041, 0x0042, 0x0042, 0x0043, 0x0044, 0x0045, 0x0045, 0x0046, 0x0047, 0x0048,
|
||||
0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004C, 0x004D, 0x004E, 0x004F, 0x0050, 0x0051, 0x0052,
|
||||
0x0053, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D,
|
||||
0x005E, 0x005F, 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A,
|
||||
0x006B, 0x006C, 0x006E, 0x006F, 0x0070, 0x0071, 0x0072, 0x0074, 0x0075, 0x0076, 0x0077, 0x0079,
|
||||
0x007A, 0x007B, 0x007D, 0x007E, 0x0080, 0x0081, 0x0082, 0x0084, 0x0085, 0x0087, 0x0088, 0x008A,
|
||||
0x008B, 0x008D, 0x008E, 0x0090, 0x0091, 0x0093, 0x0095, 0x0096, 0x0098, 0x0099, 0x009B, 0x009D,
|
||||
0x009F, 0x00A0, 0x00A2, 0x00A4, 0x00A6, 0x00A7, 0x00A9, 0x00AB, 0x00AD, 0x00AF, 0x00B1, 0x00B3,
|
||||
0x00B5, 0x00B7, 0x00B9, 0x00BB, 0x00BD, 0x00BF, 0x00C1, 0x00C3, 0x00C5, 0x00C7, 0x00C9, 0x00CB,
|
||||
0x00CE, 0x00D0, 0x00D2, 0x00D5, 0x00D7, 0x00D9, 0x00DC, 0x00DE, 0x00E0, 0x00E3, 0x00E5, 0x00E8,
|
||||
0x00EA, 0x00ED, 0x00EF, 0x00F2, 0x00F5, 0x00F7, 0x00FA, 0x00FD, 0x0100, 0x0102, 0x0105, 0x0108,
|
||||
0x010B, 0x010E, 0x0111, 0x0114, 0x0117, 0x011A, 0x011D, 0x0120, 0x0123, 0x0126, 0x0129, 0x012D,
|
||||
0x0130, 0x0133, 0x0137, 0x013A, 0x013D, 0x0141, 0x0144, 0x0148, 0x014C, 0x014F, 0x0153, 0x0157,
|
||||
0x015A, 0x015E, 0x0162, 0x0166, 0x016A, 0x016E, 0x0172, 0x0176, 0x017A, 0x017E, 0x0182, 0x0186,
|
||||
0x018A, 0x018F, 0x0193, 0x0197, 0x019C, 0x01A0, 0x01A5, 0x01A9, 0x01AE, 0x01B3, 0x01B8, 0x01BC,
|
||||
0x01C1, 0x01C6, 0x01CB, 0x01D0, 0x01D5, 0x01DA, 0x01DF, 0x01E5, 0x01EA, 0x01EF, 0x01F5, 0x01FA,
|
||||
0x0200, 0x0205, 0x020B, 0x0210, 0x0216, 0x021C, 0x0222, 0x0228, 0x022E, 0x0234, 0x023A, 0x0240,
|
||||
0x0247, 0x024D, 0x0253, 0x025A, 0x0260, 0x0267, 0x026E, 0x0275, 0x027B, 0x0282, 0x0289, 0x0290,
|
||||
0x0298, 0x029F, 0x02A6, 0x02AD, 0x02B5, 0x02BC, 0x02C4, 0x02CC, 0x02D4, 0x02DC, 0x02E3, 0x02EC,
|
||||
0x02F4, 0x02FC, 0x0304, 0x030D, 0x0315, 0x031E, 0x0326, 0x032F, 0x0338, 0x0341, 0x034A, 0x0353,
|
||||
0x035D, 0x0366, 0x036F, 0x0379, 0x0383, 0x038D, 0x0396, 0x03A0, 0x03AB, 0x03B5, 0x03BF, 0x03CA,
|
||||
0x03D4, 0x03DF, 0x03EA, 0x03F5,
|
||||
];
|
||||
|
||||
// Exact lookup values from the original digital mixer.
|
||||
const DSP_PAN: [u8; 256] = [
|
||||
0x0E, 0x13, 0x19, 0x1D, 0x22, 0x26, 0x2A, 0x2F, 0x33, 0x37, 0x3B, 0x3F, 0x41, 0x45, 0x49, 0x4C,
|
||||
0x50, 0x53, 0x55, 0x59, 0x5C, 0x5E, 0x61, 0x65, 0x67, 0x6A, 0x6C, 0x6F, 0x71, 0x74, 0x75, 0x78,
|
||||
0x7A, 0x7D, 0x7F, 0x80, 0x83, 0x85, 0x86, 0x89, 0x8B, 0x8C, 0x8E, 0x90, 0x92, 0x93, 0x95, 0x96,
|
||||
0x99, 0x9A, 0x9C, 0x9D, 0x9E, 0xA0, 0xA2, 0xA3, 0xA5, 0xA6, 0xA7, 0xA9, 0xAA, 0xAB, 0xAC, 0xAE,
|
||||
0xAF, 0xB0, 0xB1, 0xB2, 0xB3, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xBB, 0xBC, 0xBD, 0xBE, 0xBF,
|
||||
0xC0, 0xC1, 0xC2, 0xC3, 0xC4, 0xC5, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xCB, 0xCC, 0xCD, 0xCD,
|
||||
0xCD, 0xCE, 0xCF, 0xD0, 0xD1, 0xD2, 0xD2, 0xD3, 0xD3, 0xD4, 0xD5, 0xD6, 0xD6, 0xD6, 0xD7, 0xD8,
|
||||
0xD9, 0xD9, 0xD9, 0xDA, 0xDB, 0xDC, 0xDC, 0xDC, 0xDD, 0xDE, 0xDE, 0xDF, 0xDF, 0xE0, 0xE0, 0xE1,
|
||||
0xE1, 0xE1, 0xE2, 0xE3, 0xE4, 0xE4, 0xE4, 0xE5, 0xE5, 0xE6, 0xE6, 0xE6, 0xE7, 0xE7, 0xE7, 0xE8,
|
||||
0xE9, 0xE9, 0xE9, 0xE9, 0xEA, 0xEB, 0xEB, 0xEB, 0xEC, 0xEC, 0xEC, 0xEC, 0xED, 0xED, 0xED, 0xEE,
|
||||
0xEE, 0xEF, 0xEF, 0xEF, 0xF0, 0xF0, 0xF0, 0xF0, 0xF1, 0xF1, 0xF1, 0xF2, 0xF2, 0xF2, 0xF2, 0xF3,
|
||||
0xF3, 0xF3, 0xF3, 0xF4, 0xF4, 0xF4, 0xF5, 0xF5, 0xF5, 0xF5, 0xF6, 0xF6, 0xF6, 0xF6, 0xF7, 0xF7,
|
||||
0xF7, 0xF7, 0xF7, 0xF7, 0xF8, 0xF8, 0xF8, 0xF8, 0xF9, 0xF9, 0xF9, 0xF9, 0xF9, 0xF9, 0xF9, 0xFA,
|
||||
0xFA, 0xFA, 0xFA, 0xFB, 0xFB, 0xFB, 0xFB, 0xFB, 0xFB, 0xFC, 0xFC, 0xFC, 0xFC, 0xFC, 0xFC, 0xFC,
|
||||
0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFE, 0xFE, 0xFE, 0xFE, 0xFE, 0xFE, 0xFE, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
];
|
||||
@@ -0,0 +1,504 @@
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 2.1, February 1999
|
||||
|
||||
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
[This is the first released version of the Lesser GPL. It also counts
|
||||
as the successor of the GNU Library Public License, version 2, hence
|
||||
the version number 2.1.]
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
Licenses are intended to guarantee your freedom to share and change
|
||||
free software--to make sure the software is free for all its users.
|
||||
|
||||
This license, the Lesser General Public License, applies to some
|
||||
specially designated software packages--typically libraries--of the
|
||||
Free Software Foundation and other authors who decide to use it. You
|
||||
can use it too, but we suggest you first think carefully about whether
|
||||
this license or the ordinary General Public License is the better
|
||||
strategy to use in any particular case, based on the explanations below.
|
||||
|
||||
When we speak of free software, we are referring to freedom of use,
|
||||
not price. Our General Public Licenses are designed to make sure that
|
||||
you have the freedom to distribute copies of free software (and charge
|
||||
for this service if you wish); that you receive source code or can get
|
||||
it if you want it; that you can change the software and use pieces of
|
||||
it in new free programs; and that you are informed that you can do
|
||||
these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
distributors to deny you these rights or to ask you to surrender these
|
||||
rights. These restrictions translate to certain responsibilities for
|
||||
you if you distribute copies of the library or if you modify it.
|
||||
|
||||
For example, if you distribute copies of the library, whether gratis
|
||||
or for a fee, you must give the recipients all the rights that we gave
|
||||
you. You must make sure that they, too, receive or can get the source
|
||||
code. If you link other code with the library, you must provide
|
||||
complete object files to the recipients, so that they can relink them
|
||||
with the library after making changes to the library and recompiling
|
||||
it. And you must show them these terms so they know their rights.
|
||||
|
||||
We protect your rights with a two-step method: (1) we copyright the
|
||||
library, and (2) we offer you this license, which gives you legal
|
||||
permission to copy, distribute and/or modify the library.
|
||||
|
||||
To protect each distributor, we want to make it very clear that
|
||||
there is no warranty for the free library. Also, if the library is
|
||||
modified by someone else and passed on, the recipients should know
|
||||
that what they have is not the original version, so that the original
|
||||
author's reputation will not be affected by problems that might be
|
||||
introduced by others.
|
||||
|
||||
Finally, software patents pose a constant threat to the existence of
|
||||
any free program. We wish to make sure that a company cannot
|
||||
effectively restrict the users of a free program by obtaining a
|
||||
restrictive license from a patent holder. Therefore, we insist that
|
||||
any patent license obtained for a version of the library must be
|
||||
consistent with the full freedom of use specified in this license.
|
||||
|
||||
Most GNU software, including some libraries, is covered by the
|
||||
ordinary GNU General Public License. This license, the GNU Lesser
|
||||
General Public License, applies to certain designated libraries, and
|
||||
is quite different from the ordinary General Public License. We use
|
||||
this license for certain libraries in order to permit linking those
|
||||
libraries into non-free programs.
|
||||
|
||||
When a program is linked with a library, whether statically or using
|
||||
a shared library, the combination of the two is legally speaking a
|
||||
combined work, a derivative of the original library. The ordinary
|
||||
General Public License therefore permits such linking only if the
|
||||
entire combination fits its criteria of freedom. The Lesser General
|
||||
Public License permits more lax criteria for linking other code with
|
||||
the library.
|
||||
|
||||
We call this license the "Lesser" General Public License because it
|
||||
does Less to protect the user's freedom than the ordinary General
|
||||
Public License. It also provides other free software developers Less
|
||||
of an advantage over competing non-free programs. These disadvantages
|
||||
are the reason we use the ordinary General Public License for many
|
||||
libraries. However, the Lesser license provides advantages in certain
|
||||
special circumstances.
|
||||
|
||||
For example, on rare occasions, there may be a special need to
|
||||
encourage the widest possible use of a certain library, so that it becomes
|
||||
a de-facto standard. To achieve this, non-free programs must be
|
||||
allowed to use the library. A more frequent case is that a free
|
||||
library does the same job as widely used non-free libraries. In this
|
||||
case, there is little to gain by limiting the free library to free
|
||||
software only, so we use the Lesser General Public License.
|
||||
|
||||
In other cases, permission to use a particular library in non-free
|
||||
programs enables a greater number of people to use a large body of
|
||||
free software. For example, permission to use the GNU C Library in
|
||||
non-free programs enables many more people to use the whole GNU
|
||||
operating system, as well as its variant, the GNU/Linux operating
|
||||
system.
|
||||
|
||||
Although the Lesser General Public License is Less protective of the
|
||||
users' freedom, it does ensure that the user of a program that is
|
||||
linked with the Library has the freedom and the wherewithal to run
|
||||
that program using a modified version of the Library.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow. Pay close attention to the difference between a
|
||||
"work based on the library" and a "work that uses the library". The
|
||||
former contains code derived from the library, whereas the latter must
|
||||
be combined with the library in order to run.
|
||||
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License Agreement applies to any software library or other
|
||||
program which contains a notice placed by the copyright holder or
|
||||
other authorized party saying it may be distributed under the terms of
|
||||
this Lesser General Public License (also called "this License").
|
||||
Each licensee is addressed as "you".
|
||||
|
||||
A "library" means a collection of software functions and/or data
|
||||
prepared so as to be conveniently linked with application programs
|
||||
(which use some of those functions and data) to form executables.
|
||||
|
||||
The "Library", below, refers to any such software library or work
|
||||
which has been distributed under these terms. A "work based on the
|
||||
Library" means either the Library or any derivative work under
|
||||
copyright law: that is to say, a work containing the Library or a
|
||||
portion of it, either verbatim or with modifications and/or translated
|
||||
straightforwardly into another language. (Hereinafter, translation is
|
||||
included without limitation in the term "modification".)
|
||||
|
||||
"Source code" for a work means the preferred form of the work for
|
||||
making modifications to it. For a library, complete source code means
|
||||
all the source code for all modules it contains, plus any associated
|
||||
interface definition files, plus the scripts used to control compilation
|
||||
and installation of the library.
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running a program using the Library is not restricted, and output from
|
||||
such a program is covered only if its contents constitute a work based
|
||||
on the Library (independent of the use of the Library in a tool for
|
||||
writing it). Whether that is true depends on what the Library does
|
||||
and what the program that uses the Library does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Library's
|
||||
complete source code as you receive it, in any medium, provided that
|
||||
you conspicuously and appropriately publish on each copy an
|
||||
appropriate copyright notice and disclaimer of warranty; keep intact
|
||||
all the notices that refer to this License and to the absence of any
|
||||
warranty; and distribute a copy of this License along with the
|
||||
Library.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy,
|
||||
and you may at your option offer warranty protection in exchange for a
|
||||
fee.
|
||||
|
||||
2. You may modify your copy or copies of the Library or any portion
|
||||
of it, thus forming a work based on the Library, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) The modified work must itself be a software library.
|
||||
|
||||
b) You must cause the files modified to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
c) You must cause the whole of the work to be licensed at no
|
||||
charge to all third parties under the terms of this License.
|
||||
|
||||
d) If a facility in the modified Library refers to a function or a
|
||||
table of data to be supplied by an application program that uses
|
||||
the facility, other than as an argument passed when the facility
|
||||
is invoked, then you must make a good faith effort to ensure that,
|
||||
in the event an application does not supply such function or
|
||||
table, the facility still operates, and performs whatever part of
|
||||
its purpose remains meaningful.
|
||||
|
||||
(For example, a function in a library to compute square roots has
|
||||
a purpose that is entirely well-defined independent of the
|
||||
application. Therefore, Subsection 2d requires that any
|
||||
application-supplied function or table used by this function must
|
||||
be optional: if the application does not supply it, the square
|
||||
root function must still compute square roots.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Library,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Library, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote
|
||||
it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Library.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Library
|
||||
with the Library (or with a work based on the Library) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may opt to apply the terms of the ordinary GNU General Public
|
||||
License instead of this License to a given copy of the Library. To do
|
||||
this, you must alter all the notices that refer to this License, so
|
||||
that they refer to the ordinary GNU General Public License, version 2,
|
||||
instead of to this License. (If a newer version than version 2 of the
|
||||
ordinary GNU General Public License has appeared, then you can specify
|
||||
that version instead if you wish.) Do not make any other change in
|
||||
these notices.
|
||||
|
||||
Once this change is made in a given copy, it is irreversible for
|
||||
that copy, so the ordinary GNU General Public License applies to all
|
||||
subsequent copies and derivative works made from that copy.
|
||||
|
||||
This option is useful when you wish to copy part of the code of
|
||||
the Library into a program that is not a library.
|
||||
|
||||
4. You may copy and distribute the Library (or a portion or
|
||||
derivative of it, under Section 2) in object code or executable form
|
||||
under the terms of Sections 1 and 2 above provided that you accompany
|
||||
it with the complete corresponding machine-readable source code, which
|
||||
must be distributed under the terms of Sections 1 and 2 above on a
|
||||
medium customarily used for software interchange.
|
||||
|
||||
If distribution of object code is made by offering access to copy
|
||||
from a designated place, then offering equivalent access to copy the
|
||||
source code from the same place satisfies the requirement to
|
||||
distribute the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
5. A program that contains no derivative of any portion of the
|
||||
Library, but is designed to work with the Library by being compiled or
|
||||
linked with it, is called a "work that uses the Library". Such a
|
||||
work, in isolation, is not a derivative work of the Library, and
|
||||
therefore falls outside the scope of this License.
|
||||
|
||||
However, linking a "work that uses the Library" with the Library
|
||||
creates an executable that is a derivative of the Library (because it
|
||||
contains portions of the Library), rather than a "work that uses the
|
||||
library". The executable is therefore covered by this License.
|
||||
Section 6 states terms for distribution of such executables.
|
||||
|
||||
When a "work that uses the Library" uses material from a header file
|
||||
that is part of the Library, the object code for the work may be a
|
||||
derivative work of the Library even though the source code is not.
|
||||
Whether this is true is especially significant if the work can be
|
||||
linked without the Library, or if the work is itself a library. The
|
||||
threshold for this to be true is not precisely defined by law.
|
||||
|
||||
If such an object file uses only numerical parameters, data
|
||||
structure layouts and accessors, and small macros and small inline
|
||||
functions (ten lines or less in length), then the use of the object
|
||||
file is unrestricted, regardless of whether it is legally a derivative
|
||||
work. (Executables containing this object code plus portions of the
|
||||
Library will still fall under Section 6.)
|
||||
|
||||
Otherwise, if the work is a derivative of the Library, you may
|
||||
distribute the object code for the work under the terms of Section 6.
|
||||
Any executables containing that work also fall under Section 6,
|
||||
whether or not they are linked directly with the Library itself.
|
||||
|
||||
6. As an exception to the Sections above, you may also combine or
|
||||
link a "work that uses the Library" with the Library to produce a
|
||||
work containing portions of the Library, and distribute that work
|
||||
under terms of your choice, provided that the terms permit
|
||||
modification of the work for the customer's own use and reverse
|
||||
engineering for debugging such modifications.
|
||||
|
||||
You must give prominent notice with each copy of the work that the
|
||||
Library is used in it and that the Library and its use are covered by
|
||||
this License. You must supply a copy of this License. If the work
|
||||
during execution displays copyright notices, you must include the
|
||||
copyright notice for the Library among them, as well as a reference
|
||||
directing the user to the copy of this License. Also, you must do one
|
||||
of these things:
|
||||
|
||||
a) Accompany the work with the complete corresponding
|
||||
machine-readable source code for the Library including whatever
|
||||
changes were used in the work (which must be distributed under
|
||||
Sections 1 and 2 above); and, if the work is an executable linked
|
||||
with the Library, with the complete machine-readable "work that
|
||||
uses the Library", as object code and/or source code, so that the
|
||||
user can modify the Library and then relink to produce a modified
|
||||
executable containing the modified Library. (It is understood
|
||||
that the user who changes the contents of definitions files in the
|
||||
Library will not necessarily be able to recompile the application
|
||||
to use the modified definitions.)
|
||||
|
||||
b) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (1) uses at run time a
|
||||
copy of the library already present on the user's computer system,
|
||||
rather than copying library functions into the executable, and (2)
|
||||
will operate properly with a modified version of the library, if
|
||||
the user installs one, as long as the modified version is
|
||||
interface-compatible with the version that the work was made with.
|
||||
|
||||
c) Accompany the work with a written offer, valid for at
|
||||
least three years, to give the same user the materials
|
||||
specified in Subsection 6a, above, for a charge no more
|
||||
than the cost of performing this distribution.
|
||||
|
||||
d) If distribution of the work is made by offering access to copy
|
||||
from a designated place, offer equivalent access to copy the above
|
||||
specified materials from the same place.
|
||||
|
||||
e) Verify that the user has already received a copy of these
|
||||
materials or that you have already sent this user a copy.
|
||||
|
||||
For an executable, the required form of the "work that uses the
|
||||
Library" must include any data and utility programs needed for
|
||||
reproducing the executable from it. However, as a special exception,
|
||||
the materials to be distributed need not include anything that is
|
||||
normally distributed (in either source or binary form) with the major
|
||||
components (compiler, kernel, and so on) of the operating system on
|
||||
which the executable runs, unless that component itself accompanies
|
||||
the executable.
|
||||
|
||||
It may happen that this requirement contradicts the license
|
||||
restrictions of other proprietary libraries that do not normally
|
||||
accompany the operating system. Such a contradiction means you cannot
|
||||
use both them and the Library together in an executable that you
|
||||
distribute.
|
||||
|
||||
7. You may place library facilities that are a work based on the
|
||||
Library side-by-side in a single library together with other library
|
||||
facilities not covered by this License, and distribute such a combined
|
||||
library, provided that the separate distribution of the work based on
|
||||
the Library and of the other library facilities is otherwise
|
||||
permitted, and provided that you do these two things:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work
|
||||
based on the Library, uncombined with any other library
|
||||
facilities. This must be distributed under the terms of the
|
||||
Sections above.
|
||||
|
||||
b) Give prominent notice with the combined library of the fact
|
||||
that part of it is a work based on the Library, and explaining
|
||||
where to find the accompanying uncombined form of the same work.
|
||||
|
||||
8. You may not copy, modify, sublicense, link with, or distribute
|
||||
the Library except as expressly provided under this License. Any
|
||||
attempt otherwise to copy, modify, sublicense, link with, or
|
||||
distribute the Library is void, and will automatically terminate your
|
||||
rights under this License. However, parties who have received copies,
|
||||
or rights, from you under this License will not have their licenses
|
||||
terminated so long as such parties remain in full compliance.
|
||||
|
||||
9. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Library or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Library (or any work based on the
|
||||
Library), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Library or works based on it.
|
||||
|
||||
10. Each time you redistribute the Library (or any work based on the
|
||||
Library), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute, link with or modify the Library
|
||||
subject to these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties with
|
||||
this License.
|
||||
|
||||
11. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Library at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Library by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Library.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under any
|
||||
particular circumstance, the balance of the section is intended to apply,
|
||||
and the section as a whole is intended to apply in other circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
12. If the distribution and/or use of the Library is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Library under this License may add
|
||||
an explicit geographical distribution limitation excluding those countries,
|
||||
so that distribution is permitted only in or among countries not thus
|
||||
excluded. In such case, this License incorporates the limitation as if
|
||||
written in the body of this License.
|
||||
|
||||
13. The Free Software Foundation may publish revised and/or new
|
||||
versions of the Lesser General Public License from time to time.
|
||||
Such new versions will be similar in spirit to the present version,
|
||||
but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Library
|
||||
specifies a version number of this License which applies to it and
|
||||
"any later version", you have the option of following the terms and
|
||||
conditions either of that version or of any later version published by
|
||||
the Free Software Foundation. If the Library does not specify a
|
||||
license version number, you may choose any version ever published by
|
||||
the Free Software Foundation.
|
||||
|
||||
14. If you wish to incorporate parts of the Library into other free
|
||||
programs whose distribution conditions are incompatible with these,
|
||||
write to the author to ask for permission. For software which is
|
||||
copyrighted by the Free Software Foundation, write to the Free
|
||||
Software Foundation; we sometimes make exceptions for this. Our
|
||||
decision will be guided by the two goals of preserving the free status
|
||||
of all derivatives of our free software and of promoting the sharing
|
||||
and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
|
||||
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
|
||||
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
|
||||
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
|
||||
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
|
||||
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
|
||||
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
|
||||
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
|
||||
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
|
||||
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
|
||||
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
|
||||
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
|
||||
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
|
||||
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
|
||||
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
|
||||
DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Libraries
|
||||
|
||||
If you develop a new library, and you want it to be of the greatest
|
||||
possible use to the public, we recommend making it free software that
|
||||
everyone can redistribute and change. You can do so by permitting
|
||||
redistribution under these terms (or, alternatively, under the terms of the
|
||||
ordinary General Public License).
|
||||
|
||||
To apply these terms, attach the following notices to the library. It is
|
||||
safest to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least the
|
||||
"copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the library's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301
|
||||
USA
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the library, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the
|
||||
library `Frob' (a library for tweaking knobs) written by James Random
|
||||
Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1990
|
||||
Ty Coon, President of Vice
|
||||
|
||||
That's all there is to it!
|
||||
+1512
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,444 @@
|
||||
//! Checked original AGX cinematic deltas and a platform-independent 70 Hz player.
|
||||
//! MDS catalog files are RIFF MIDI streams; they are not the movie format.
|
||||
use crate::{
|
||||
assets::{Catalog, Sprite},
|
||||
audio::DigitalSample,
|
||||
simulation::{LegacyRng, SoundEvent},
|
||||
};
|
||||
use anyhow::{Context, Result, ensure};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeltaFrame {
|
||||
fill: Option<u8>,
|
||||
spans: Vec<(usize, Vec<u8>)>,
|
||||
}
|
||||
impl DeltaFrame {
|
||||
pub fn decode(bytes: &[u8], first: bool) -> Result<Self> {
|
||||
let fill = if first {
|
||||
Some(*bytes.first().context("missing cinematic fill byte")?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut offset = usize::from(first);
|
||||
let mut spans = Vec::new();
|
||||
loop {
|
||||
let header = bytes
|
||||
.get(offset..offset + 8)
|
||||
.context("truncated AGX span header")?;
|
||||
offset += 8;
|
||||
let read = |n: usize| u16::from_le_bytes([header[n], header[n + 1]]) as usize;
|
||||
if read(0) == 0 {
|
||||
break;
|
||||
}
|
||||
let position = read(4);
|
||||
let length = read(6);
|
||||
ensure!(
|
||||
length > 0
|
||||
&& position
|
||||
.checked_add(length)
|
||||
.is_some_and(|end| end <= 320 * 200),
|
||||
"AGX span exceeds 320x200 canvas"
|
||||
);
|
||||
let pixels = bytes
|
||||
.get(offset..offset + length)
|
||||
.context("truncated AGX span pixels")?;
|
||||
offset += length;
|
||||
spans.push((position, pixels.to_vec()));
|
||||
ensure!(spans.len() <= 64000, "too many AGX spans");
|
||||
}
|
||||
Ok(Self { fill, spans })
|
||||
}
|
||||
pub fn apply(&self, canvas: &mut Sprite) -> Result<()> {
|
||||
ensure!(
|
||||
canvas.width == 320 && canvas.height == 200 && canvas.pixels.len() == 64000,
|
||||
"AGX requires original 320x200 canvas"
|
||||
);
|
||||
if let Some(fill) = self.fill {
|
||||
canvas.pixels.fill(fill as u16);
|
||||
}
|
||||
for (start, pixels) in &self.spans {
|
||||
for (destination, source) in canvas.pixels[*start..*start + pixels.len()]
|
||||
.iter_mut()
|
||||
.zip(pixels)
|
||||
{
|
||||
*destination = *source as u16;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Kind {
|
||||
Intro,
|
||||
Landing,
|
||||
Death,
|
||||
CampaignEnd(u8),
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct ClipSpec {
|
||||
name: &'static str,
|
||||
count: u32,
|
||||
dwell: u32,
|
||||
repeats: u32,
|
||||
fade_in: u32,
|
||||
fade_out: u32,
|
||||
fade_color: [u8; 3],
|
||||
background: Option<&'static str>,
|
||||
}
|
||||
fn clip(name: &'static str, count: u32, dwell: u32, repeats: u32) -> ClipSpec {
|
||||
ClipSpec {
|
||||
name,
|
||||
count,
|
||||
dwell,
|
||||
repeats,
|
||||
fade_in: 0,
|
||||
fade_out: 0,
|
||||
fade_color: [0; 3],
|
||||
background: None,
|
||||
}
|
||||
}
|
||||
fn specs(kind: Kind) -> Vec<ClipSpec> {
|
||||
match kind {
|
||||
Kind::Intro => {
|
||||
let mut city = clip("CHASE_AGX", 30, 8, 1);
|
||||
city.fade_in = 128;
|
||||
let mut side = clip("SHIPSD1_AGX", 20, 18, 2);
|
||||
side.background = Some("FX_JETSND");
|
||||
let mut pilot = clip("PILOT_AGX", 21, 10, 1);
|
||||
pilot.background = Some("FX_IJETSND");
|
||||
let mut side_before_fire = side.clone();
|
||||
side_before_fire.repeats = 1;
|
||||
let mut firing = clip("SHIPSD2_AGX", 20, 18, 1);
|
||||
firing.background = Some("FX_JETSND");
|
||||
let mut explosion = clip("EXPLO_AGX", 22, 12, 1);
|
||||
explosion.background = Some("FX_EJETSND");
|
||||
explosion.fade_out = 60;
|
||||
explosion.fade_color = [255, 113, 12];
|
||||
vec![city, side, pilot, side_before_fire, firing, explosion]
|
||||
}
|
||||
Kind::Landing => {
|
||||
let mut landing = clip("LANDING_AGX", 33, 10, 1);
|
||||
landing.fade_out = 64;
|
||||
vec![landing]
|
||||
}
|
||||
Kind::Death => {
|
||||
let mut ground = clip("SDEATH_AGX", 6, 3, 8);
|
||||
ground.fade_out = 100;
|
||||
vec![clip("DOWN_AGX", 30, 11, 1), ground]
|
||||
}
|
||||
Kind::CampaignEnd(sector) => {
|
||||
let mut ending = match sector {
|
||||
0 => clip("GAME1END_AGX", 5, 4, 8),
|
||||
1 => clip("GAME2END_AGX", 25, 4, 1),
|
||||
_ => clip("GAME3END_AGX", 39, 8, 1),
|
||||
};
|
||||
ending.fade_out = 120;
|
||||
if sector >= 2 {
|
||||
ending.background = Some("FX_JETSND");
|
||||
}
|
||||
let mut base = clip("BASE_AGX", 30, 10, 1);
|
||||
base.fade_in = 128;
|
||||
let mut clips = vec![ending, base];
|
||||
clips.extend(specs(Kind::Landing));
|
||||
clips
|
||||
}
|
||||
}
|
||||
}
|
||||
struct Clip {
|
||||
spec: ClipSpec,
|
||||
frames: Vec<DeltaFrame>,
|
||||
background: Option<(SoundEvent, u64)>,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum Phase {
|
||||
FadeIn(u32),
|
||||
Frame(u32),
|
||||
FadeOut(u32),
|
||||
Finished,
|
||||
}
|
||||
pub struct Cinematic {
|
||||
pub image: Sprite,
|
||||
pub sounds: Vec<SoundEvent>,
|
||||
clips: Vec<Clip>,
|
||||
current: usize,
|
||||
frame: usize,
|
||||
repetition: u32,
|
||||
phase: Phase,
|
||||
clock: u64,
|
||||
ticks: u64,
|
||||
background_at: u64,
|
||||
rng: LegacyRng,
|
||||
}
|
||||
impl Cinematic {
|
||||
pub fn load(catalog: &Catalog, kind: Kind) -> Result<Self> {
|
||||
let mut clips = Vec::new();
|
||||
let mut total = 0_usize;
|
||||
for spec in specs(kind) {
|
||||
let start = catalog.id(spec.name)?;
|
||||
let mut frames = Vec::new();
|
||||
for index in 0..spec.count {
|
||||
let bytes = catalog.read(start + index)?;
|
||||
total += bytes.len();
|
||||
ensure!(total <= 32 * 1024 * 1024, "cinematic exceeds 32 MiB");
|
||||
frames.push(
|
||||
DeltaFrame::decode(&bytes, index == 0)
|
||||
.with_context(|| format!("{} frame {}", spec.name, index))?,
|
||||
);
|
||||
}
|
||||
let background = spec
|
||||
.background
|
||||
.and_then(|name| sound(name, 127, &mut LegacyRng::new(0)))
|
||||
.and_then(|event| {
|
||||
let sample = catalog
|
||||
.read(event.asset)
|
||||
.and_then(|b| DigitalSample::decode(&b))
|
||||
.ok()?;
|
||||
let ticks = (sample.data.len() as u64 * 70 * 128)
|
||||
.div_ceil(sample.rate as u64 * event.pitch.clamp(1, 255) as u64)
|
||||
.max(1);
|
||||
Some((event, ticks))
|
||||
});
|
||||
clips.push(Clip {
|
||||
spec,
|
||||
frames,
|
||||
background,
|
||||
});
|
||||
}
|
||||
let mut result = Self {
|
||||
image: Sprite::blank(320, 200),
|
||||
sounds: Vec::new(),
|
||||
clips,
|
||||
current: 0,
|
||||
frame: 0,
|
||||
repetition: 0,
|
||||
phase: Phase::Finished,
|
||||
clock: 0,
|
||||
ticks: 0,
|
||||
background_at: 0,
|
||||
rng: LegacyRng::new(0),
|
||||
};
|
||||
result.begin()?;
|
||||
Ok(result)
|
||||
}
|
||||
fn begin(&mut self) -> Result<()> {
|
||||
if self.current == self.clips.len() {
|
||||
self.phase = Phase::Finished;
|
||||
return Ok(());
|
||||
}
|
||||
self.frame = 0;
|
||||
self.repetition = 0;
|
||||
self.clips[self.current].frames[0].apply(&mut self.image)?;
|
||||
let spec = &self.clips[self.current].spec;
|
||||
self.phase = if spec.fade_in > 0 {
|
||||
Phase::FadeIn(spec.fade_in)
|
||||
} else {
|
||||
Phase::Frame(spec.dwell)
|
||||
};
|
||||
self.background_at = self.ticks;
|
||||
self.frame_sound();
|
||||
Ok(())
|
||||
}
|
||||
fn frame_sound(&mut self) {
|
||||
let name = self.clips[self.current].spec.name;
|
||||
let sound_name = match name {
|
||||
"CHASE_AGX" if self.frame == 4 || self.frame == 9 => Some("FX_FLYBY"),
|
||||
"SHIPSD2_AGX" if self.frame > 1 => Some("FX_INTROGUN"),
|
||||
"EXPLO_AGX" if self.frame == 21 || self.frame >= 8 && self.frame % 2 == 1 => {
|
||||
Some("FX_AIREXPLO")
|
||||
}
|
||||
"EXPLO_AGX" if (2..10).contains(&self.frame) => Some("FX_INTROHIT"),
|
||||
"GAME2END_AGX" if self.frame == 22 || self.frame == 24 => Some("FX_AIREXPLO"),
|
||||
"GAME3END_AGX" if self.frame == 30 || self.frame == 32 => Some("FX_AIREXPLO"),
|
||||
_ => None,
|
||||
};
|
||||
let pan = match (name, self.frame) {
|
||||
("CHASE_AGX", 4) => 210,
|
||||
("CHASE_AGX", 9) => 100,
|
||||
("GAME3END_AGX", _) => 100,
|
||||
("EXPLO_AGX", _) => 110 + self.rng.next_value() % 40,
|
||||
_ => 127,
|
||||
};
|
||||
if let Some(event) = sound_name.and_then(|name| sound(name, pan, &mut self.rng)) {
|
||||
self.sounds.push(event);
|
||||
}
|
||||
}
|
||||
pub fn advance(&mut self, nanos: u64) -> Result<()> {
|
||||
self.clock = self.clock.saturating_add(nanos.min(1_000_000_000) * 70);
|
||||
while self.clock >= 1_000_000_000 && !self.finished() {
|
||||
self.clock -= 1_000_000_000;
|
||||
self.ticks += 1;
|
||||
if let Some((event, period)) = &self.clips[self.current].background {
|
||||
if self.ticks >= self.background_at {
|
||||
self.sounds.push(event.clone());
|
||||
self.background_at = self.ticks + period;
|
||||
}
|
||||
}
|
||||
match self.phase {
|
||||
Phase::FadeIn(n) if n > 1 => self.phase = Phase::FadeIn(n - 1),
|
||||
Phase::FadeIn(_) => self.phase = Phase::Frame(self.clips[self.current].spec.dwell),
|
||||
Phase::Frame(n) if n > 1 => self.phase = Phase::Frame(n - 1),
|
||||
Phase::Frame(_) => {
|
||||
if self.frame + 1 < self.clips[self.current].frames.len() {
|
||||
self.frame += 1;
|
||||
} else if self.repetition + 1 < self.clips[self.current].spec.repeats {
|
||||
self.repetition += 1;
|
||||
self.frame = 0;
|
||||
} else {
|
||||
let fade = self.clips[self.current].spec.fade_out;
|
||||
if fade > 0 {
|
||||
self.phase = Phase::FadeOut(fade);
|
||||
} else {
|
||||
self.current += 1;
|
||||
self.begin()?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
self.clips[self.current].frames[self.frame].apply(&mut self.image)?;
|
||||
self.phase = Phase::Frame(self.clips[self.current].spec.dwell);
|
||||
self.frame_sound();
|
||||
}
|
||||
Phase::FadeOut(n) if n > 1 => self.phase = Phase::FadeOut(n - 1),
|
||||
Phase::FadeOut(_) => {
|
||||
self.current += 1;
|
||||
self.begin()?;
|
||||
}
|
||||
Phase::Finished => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn finished(&self) -> bool {
|
||||
matches!(self.phase, Phase::Finished)
|
||||
}
|
||||
pub fn palette(&self, base: &[[u8; 3]; 256]) -> [[u8; 3]; 256] {
|
||||
let (progress, total, target) = match self.phase {
|
||||
Phase::FadeIn(remaining) => {
|
||||
let total = self.clips[self.current].spec.fade_in;
|
||||
(total - remaining, total, [0; 3])
|
||||
}
|
||||
Phase::FadeOut(remaining) => {
|
||||
let spec = &self.clips[self.current].spec;
|
||||
(remaining, spec.fade_out, spec.fade_color)
|
||||
}
|
||||
_ => return *base,
|
||||
};
|
||||
std::array::from_fn(|i| {
|
||||
std::array::from_fn(|j| {
|
||||
((base[i][j] as u32 * progress + target[j] as u32 * (total - progress))
|
||||
/ total.max(1)) as u8
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
fn sound(name: &str, pan: i32, rng: &mut LegacyRng) -> Option<SoundEvent> {
|
||||
let definition = crate::legacy_tables::sounds()
|
||||
.into_iter()
|
||||
.find(|sound| sound.name == name)?;
|
||||
let pitch = definition.pitch
|
||||
+ if definition.random {
|
||||
rng.next_value() % 40 - 20
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Some(SoundEvent {
|
||||
asset: definition.asset,
|
||||
pan,
|
||||
pitch,
|
||||
volume: definition.volume,
|
||||
priority: definition.priority,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn delta(first: bool, offset: u16, pixels: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = if first { vec![5] } else { vec![] };
|
||||
for value in [1_u16, 0, offset, pixels.len() as u16] {
|
||||
bytes.extend(value.to_le_bytes());
|
||||
}
|
||||
bytes.extend(pixels);
|
||||
bytes.extend([0; 8]);
|
||||
bytes
|
||||
}
|
||||
#[test]
|
||||
fn deltas_preserve_unchanged_pixels_and_initial_fill() {
|
||||
let mut canvas = Sprite::blank(320, 200);
|
||||
DeltaFrame::decode(&delta(true, 4, &[1, 2, 3]), true)
|
||||
.unwrap()
|
||||
.apply(&mut canvas)
|
||||
.unwrap();
|
||||
assert_eq!(&canvas.pixels[..8], &[5, 5, 5, 5, 1, 2, 3, 5]);
|
||||
DeltaFrame::decode(&delta(false, 5, &[9]), false)
|
||||
.unwrap()
|
||||
.apply(&mut canvas)
|
||||
.unwrap();
|
||||
assert_eq!(&canvas.pixels[4..8], &[1, 9, 3, 5]);
|
||||
}
|
||||
#[test]
|
||||
fn malformed_delta_cannot_write_outside_canvas() {
|
||||
assert!(DeltaFrame::decode(&delta(false, 63999, &[1, 2]), false).is_err());
|
||||
let valid = delta(false, 5, &[1, 2]);
|
||||
for length in 0..valid.len() - 8 {
|
||||
assert!(DeltaFrame::decode(&valid[..length], false).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn player_timing_is_independent_of_render_rate() {
|
||||
fn player() -> Cinematic {
|
||||
let frames = vec![
|
||||
DeltaFrame::decode(&delta(true, 0, &[1]), true).unwrap(),
|
||||
DeltaFrame::decode(&delta(false, 0, &[2]), false).unwrap(),
|
||||
];
|
||||
let mut p = Cinematic {
|
||||
image: Sprite::blank(320, 200),
|
||||
sounds: vec![],
|
||||
clips: vec![Clip {
|
||||
spec: clip("fixture", 2, 8, 2),
|
||||
frames,
|
||||
background: None,
|
||||
}],
|
||||
current: 0,
|
||||
frame: 0,
|
||||
repetition: 0,
|
||||
phase: Phase::Finished,
|
||||
clock: 0,
|
||||
ticks: 0,
|
||||
background_at: 0,
|
||||
rng: LegacyRng::new(0),
|
||||
};
|
||||
p.begin().unwrap();
|
||||
p
|
||||
}
|
||||
let mut a = player();
|
||||
let mut b = player();
|
||||
a.advance(300_000_000).unwrap();
|
||||
for _ in 0..30 {
|
||||
b.advance(10_000_000).unwrap();
|
||||
}
|
||||
assert_eq!(a.image, b.image);
|
||||
assert_eq!(a.frame, b.frame);
|
||||
assert_eq!(a.repetition, b.repetition);
|
||||
assert_eq!(a.ticks, b.ticks);
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires original local game assets"]
|
||||
fn all_original_cinematic_sequences_decode() {
|
||||
let root = std::env::var("RAPTOR_TEST_DATA")
|
||||
.expect("set RAPTOR_TEST_DATA to the original asset directory");
|
||||
let catalog = Catalog::open(root).unwrap();
|
||||
for kind in [
|
||||
Kind::Intro,
|
||||
Kind::Landing,
|
||||
Kind::Death,
|
||||
Kind::CampaignEnd(0),
|
||||
Kind::CampaignEnd(1),
|
||||
Kind::CampaignEnd(2),
|
||||
] {
|
||||
let mut movie = Cinematic::load(&catalog, kind).unwrap();
|
||||
while !movie.finished() {
|
||||
movie.advance(1_000_000_000).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+905
@@ -0,0 +1,905 @@
|
||||
//! Versioned, namespaced content shared by the base game and mods.
|
||||
use crate::assets::{Catalog, LegacyEnemy, LegacyMap, Sprite};
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
|
||||
pub const API_VERSION: u32 = 1;
|
||||
#[derive(
|
||||
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct ContentId(pub String);
|
||||
impl ContentId {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self> {
|
||||
let id = Self(value.into());
|
||||
id.validate()?;
|
||||
Ok(id)
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let (ns, name) = self
|
||||
.0
|
||||
.split_once(':')
|
||||
.context("content IDs require namespace:name")?;
|
||||
ensure!(
|
||||
!ns.is_empty()
|
||||
&& !name.is_empty()
|
||||
&& !name.contains(':')
|
||||
&& self.0.len() <= 128
|
||||
&& self
|
||||
.0
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || ":_-/.".contains(c)),
|
||||
"invalid content ID {}",
|
||||
self.0
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl From<&str> for ContentId {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
}
|
||||
impl std::fmt::Display for ContentId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Art {
|
||||
pub asset: Option<u32>,
|
||||
pub png: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hd_png: Option<String>,
|
||||
pub logical_width: u32,
|
||||
pub logical_height: u32,
|
||||
#[serde(default = "one")]
|
||||
pub frames: u32,
|
||||
#[serde(default)]
|
||||
pub nearest: bool,
|
||||
}
|
||||
fn one() -> u32 {
|
||||
1
|
||||
}
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
fn shield() -> i32 {
|
||||
100
|
||||
}
|
||||
fn speed() -> i32 {
|
||||
2
|
||||
}
|
||||
fn rate() -> u32 {
|
||||
10
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Target {
|
||||
Air,
|
||||
Ground,
|
||||
All,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WeaponBehavior {
|
||||
Straight,
|
||||
Homing,
|
||||
Beam,
|
||||
Spread,
|
||||
Bomb,
|
||||
EnergyGrab,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WeaponDef {
|
||||
pub id: ContentId,
|
||||
pub name: String,
|
||||
pub damage: i32,
|
||||
pub speed: i32,
|
||||
pub cooldown: u32,
|
||||
pub behavior: WeaponBehavior,
|
||||
pub target: Target,
|
||||
#[serde(default)]
|
||||
pub art: Option<Art>,
|
||||
#[serde(default)]
|
||||
pub offsets: Vec<[i32; 2]>,
|
||||
#[serde(default)]
|
||||
pub script: Option<String>,
|
||||
#[serde(default)]
|
||||
pub legacy: Option<u32>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ItemDef {
|
||||
pub id: ContentId,
|
||||
pub name: String,
|
||||
pub price: u32,
|
||||
#[serde(default = "one")]
|
||||
pub max_count: u32,
|
||||
#[serde(default = "one")]
|
||||
pub initial_count: u32,
|
||||
#[serde(default)]
|
||||
pub weapon: Option<ContentId>,
|
||||
#[serde(default)]
|
||||
pub shield: i32,
|
||||
#[serde(default)]
|
||||
pub credits: u32,
|
||||
#[serde(default)]
|
||||
pub consumable: bool,
|
||||
#[serde(default)]
|
||||
pub special: bool,
|
||||
#[serde(default)]
|
||||
pub art: Option<Art>,
|
||||
#[serde(default)]
|
||||
pub script: Option<String>,
|
||||
#[serde(default)]
|
||||
pub legacy: Option<u32>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EnemyDef {
|
||||
pub id: ContentId,
|
||||
pub name: String,
|
||||
#[serde(default = "shield")]
|
||||
pub health: i32,
|
||||
#[serde(default = "speed")]
|
||||
pub speed: i32,
|
||||
#[serde(default)]
|
||||
pub reward: u32,
|
||||
#[serde(default)]
|
||||
pub ground: bool,
|
||||
#[serde(default)]
|
||||
pub boss: bool,
|
||||
#[serde(default = "rate")]
|
||||
pub cooldown: u32,
|
||||
#[serde(default)]
|
||||
pub path: Vec<[i32; 2]>,
|
||||
#[serde(default)]
|
||||
pub repeat: bool,
|
||||
#[serde(default)]
|
||||
pub weapon: Option<ContentId>,
|
||||
#[serde(default)]
|
||||
pub drop: Option<ContentId>,
|
||||
#[serde(default)]
|
||||
pub art: Option<Art>,
|
||||
#[serde(default)]
|
||||
pub script: Option<String>,
|
||||
#[serde(skip)]
|
||||
#[schemars(skip)]
|
||||
pub legacy: Option<LegacyEnemy>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SpawnDef {
|
||||
pub tick: u64,
|
||||
pub enemy: ContentId,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
#[serde(default)]
|
||||
pub difficulty: u32,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TileDef {
|
||||
pub art: Art,
|
||||
#[serde(default)]
|
||||
pub health: i32,
|
||||
#[serde(default)]
|
||||
pub reward: u32,
|
||||
#[serde(default)]
|
||||
pub destroyed: Option<Art>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MissionDef {
|
||||
pub id: ContentId,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub art: Option<Art>,
|
||||
pub duration: u64,
|
||||
#[serde(default = "yes")]
|
||||
pub auto_complete: bool,
|
||||
#[serde(default)]
|
||||
pub spawns: Vec<SpawnDef>,
|
||||
#[serde(default)]
|
||||
pub tiles: Vec<TileDef>,
|
||||
#[serde(default)]
|
||||
pub columns: u32,
|
||||
#[serde(default)]
|
||||
pub layout: Vec<usize>,
|
||||
#[serde(default)]
|
||||
pub next: Option<ContentId>,
|
||||
#[serde(default)]
|
||||
pub music: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub script: Option<String>,
|
||||
#[serde(default)]
|
||||
pub legacy_map: Option<u32>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CampaignDef {
|
||||
pub id: ContentId,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub art: Option<Art>,
|
||||
pub missions: Vec<ContentId>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SoundDef {
|
||||
pub id: ContentId,
|
||||
pub name: String,
|
||||
pub file: String,
|
||||
#[serde(default = "sound_volume")]
|
||||
pub volume: i32,
|
||||
#[serde(default = "sound_volume")]
|
||||
pub pan: i32,
|
||||
#[serde(default = "sound_pitch")]
|
||||
pub pitch: i32,
|
||||
#[serde(default = "one")]
|
||||
pub priority: u32,
|
||||
}
|
||||
fn sound_volume() -> i32 {
|
||||
127
|
||||
}
|
||||
fn sound_pitch() -> i32 {
|
||||
128
|
||||
}
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ContentPack {
|
||||
#[serde(default)]
|
||||
pub sounds: Vec<SoundDef>,
|
||||
#[serde(default)]
|
||||
pub weapons: Vec<WeaponDef>,
|
||||
#[serde(default)]
|
||||
pub items: Vec<ItemDef>,
|
||||
#[serde(default)]
|
||||
pub enemies: Vec<EnemyDef>,
|
||||
#[serde(default)]
|
||||
pub missions: Vec<MissionDef>,
|
||||
#[serde(default)]
|
||||
pub campaigns: Vec<CampaignDef>,
|
||||
#[serde(default)]
|
||||
pub overrides: Vec<ContentId>,
|
||||
}
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Registry {
|
||||
pub sounds: BTreeMap<ContentId, SoundDef>,
|
||||
pub weapons: BTreeMap<ContentId, WeaponDef>,
|
||||
pub items: BTreeMap<ContentId, ItemDef>,
|
||||
pub enemies: BTreeMap<ContentId, EnemyDef>,
|
||||
pub missions: BTreeMap<ContentId, MissionDef>,
|
||||
pub campaigns: BTreeMap<ContentId, CampaignDef>,
|
||||
pub owners: BTreeMap<ContentId, String>,
|
||||
pub asset_roots: BTreeMap<String, PathBuf>,
|
||||
pub overrides: Vec<String>,
|
||||
}
|
||||
impl Registry {
|
||||
pub fn apply(&mut self, pack: ContentPack, owner: &str) -> Result<()> {
|
||||
let mut candidate = self.clone();
|
||||
candidate.apply_inner(pack, owner)?;
|
||||
*self = candidate;
|
||||
Ok(())
|
||||
}
|
||||
fn apply_inner(&mut self, pack: ContentPack, owner: &str) -> Result<()> {
|
||||
let overrides: std::collections::BTreeSet<_> = pack.overrides.into_iter().collect();
|
||||
let mut supplied = std::collections::BTreeSet::new();
|
||||
macro_rules! insert {
|
||||
($values:expr,$map:ident) => {
|
||||
for value in $values {
|
||||
value.id.validate()?;
|
||||
ensure!(
|
||||
supplied.insert(value.id.clone()),
|
||||
"duplicate definition {}",
|
||||
value.id
|
||||
);
|
||||
let exists = self.owners.contains_key(&value.id);
|
||||
ensure!(
|
||||
!exists || self.$map.contains_key(&value.id),
|
||||
"override cannot change content type: {}",
|
||||
value.id
|
||||
);
|
||||
ensure!(
|
||||
exists == overrides.contains(&value.id),
|
||||
"{}: {} requires an explicit override and override targets must exist",
|
||||
owner,
|
||||
value.id
|
||||
);
|
||||
if !exists {
|
||||
ensure!(
|
||||
value.id.0.starts_with(&format!("{owner}:")),
|
||||
"{} does not belong to namespace {owner}",
|
||||
value.id
|
||||
);
|
||||
}
|
||||
if exists {
|
||||
self.overrides.push(format!(
|
||||
"{}: {} -> {owner}",
|
||||
value.id, self.owners[&value.id]
|
||||
));
|
||||
}
|
||||
self.owners.insert(value.id.clone(), owner.into());
|
||||
self.$map.insert(value.id.clone(), value);
|
||||
}
|
||||
};
|
||||
}
|
||||
insert!(pack.weapons, weapons);
|
||||
insert!(pack.items, items);
|
||||
insert!(pack.enemies, enemies);
|
||||
insert!(pack.missions, missions);
|
||||
insert!(pack.campaigns, campaigns);
|
||||
insert!(pack.sounds, sounds);
|
||||
ensure!(
|
||||
overrides.is_subset(&supplied),
|
||||
"override has no replacement definition"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let label = |name: &str, script: &Option<String>| -> Result<()> {
|
||||
ensure!(
|
||||
!name.trim().is_empty() && name.len() <= 128,
|
||||
"content name must contain 1–128 bytes"
|
||||
);
|
||||
ensure!(
|
||||
script
|
||||
.as_ref()
|
||||
.is_none_or(|s| !s.is_empty() && s.len() <= 4096),
|
||||
"invalid script path length"
|
||||
);
|
||||
Ok(())
|
||||
};
|
||||
let art = |a: &Option<Art>| -> Result<()> {
|
||||
if let Some(a) = a {
|
||||
ensure!(
|
||||
a.logical_width > 0
|
||||
&& a.logical_height > 0
|
||||
&& a.logical_width <= 4096
|
||||
&& a.logical_height <= 4096
|
||||
&& a.frames > 0
|
||||
&& a.frames <= 256
|
||||
&& (a.logical_width as u64 * a.logical_height as u64) <= 1_048_576,
|
||||
"invalid artwork dimensions/frames"
|
||||
);
|
||||
ensure!(
|
||||
a.asset.is_some() != a.png.is_some(),
|
||||
"art requires exactly one of asset or png"
|
||||
);
|
||||
ensure!(
|
||||
a.png
|
||||
.as_ref()
|
||||
.is_none_or(|p| !p.is_empty() && p.len() <= 4096),
|
||||
"invalid artwork path length"
|
||||
);
|
||||
ensure!(
|
||||
a.hd_png
|
||||
.as_ref()
|
||||
.is_none_or(|p| !p.is_empty() && p.len() <= 4096),
|
||||
"invalid HD artwork path length"
|
||||
);
|
||||
ensure!(
|
||||
a.asset
|
||||
.is_none_or(|id| id.checked_add(a.frames - 1).is_some()),
|
||||
"art animation asset ID overflow"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
for w in self.weapons.values() {
|
||||
label(&w.name, &w.script)?;
|
||||
ensure!(
|
||||
w.damage > 0
|
||||
&& w.damage <= 100000
|
||||
&& w.cooldown > 0
|
||||
&& w.speed.unsigned_abs() <= 128
|
||||
&& w.offsets.len() <= 64,
|
||||
"invalid weapon {}",
|
||||
w.id
|
||||
);
|
||||
ensure!(
|
||||
w.offsets
|
||||
.iter()
|
||||
.flatten()
|
||||
.all(|n| n.unsigned_abs() <= 32768),
|
||||
"weapon offset outside world bounds"
|
||||
);
|
||||
ensure!(
|
||||
w.legacy.is_none_or(|n| n < 15),
|
||||
"invalid original projectile ID"
|
||||
);
|
||||
art(&w.art)?;
|
||||
}
|
||||
for i in self.items.values() {
|
||||
label(&i.name, &i.script)?;
|
||||
ensure!(
|
||||
i.max_count > 0
|
||||
&& i.max_count <= 1_000_000
|
||||
&& i.initial_count > 0
|
||||
&& i.initial_count <= i.max_count,
|
||||
"invalid item {}",
|
||||
i.id
|
||||
);
|
||||
ensure!(
|
||||
i.shield.unsigned_abs() <= 100000 && i.legacy.is_none_or(|n| n < 24),
|
||||
"invalid item effects/original ID"
|
||||
);
|
||||
if let Some(w) = &i.weapon {
|
||||
ensure!(
|
||||
self.weapons.contains_key(w),
|
||||
"item {} references missing weapon {w}",
|
||||
i.id
|
||||
);
|
||||
}
|
||||
art(&i.art)?;
|
||||
}
|
||||
for e in self.enemies.values() {
|
||||
label(&e.name, &e.script)?;
|
||||
ensure!(
|
||||
e.health > 0
|
||||
&& e.health <= 100000
|
||||
&& e.cooldown > 0
|
||||
&& e.speed.unsigned_abs() <= 128
|
||||
&& e.path.len() <= 1024,
|
||||
"invalid enemy {}",
|
||||
e.id
|
||||
);
|
||||
ensure!(
|
||||
e.path.iter().flatten().all(|v| v.unsigned_abs() <= 32768),
|
||||
"enemy path outside world bounds"
|
||||
);
|
||||
if let Some(w) = &e.weapon {
|
||||
ensure!(
|
||||
self.weapons.contains_key(w),
|
||||
"enemy {} references missing weapon {w}",
|
||||
e.id
|
||||
);
|
||||
}
|
||||
if let Some(i) = &e.drop {
|
||||
ensure!(
|
||||
self.items.contains_key(i),
|
||||
"enemy {} references missing drop {i}",
|
||||
e.id
|
||||
);
|
||||
}
|
||||
art(&e.art)?;
|
||||
}
|
||||
for m in self.missions.values() {
|
||||
label(&m.name, &m.script)?;
|
||||
art(&m.art)?;
|
||||
ensure!(
|
||||
m.duration > 0
|
||||
&& m.duration <= 1_000_000
|
||||
&& m.spawns.len() <= 100000
|
||||
&& m.tiles.len() <= 65536
|
||||
&& m.layout.len() <= 2_000_000,
|
||||
"invalid mission {}",
|
||||
m.id
|
||||
);
|
||||
if !m.layout.is_empty() {
|
||||
ensure!(
|
||||
(1..=128).contains(&m.columns)
|
||||
&& m.layout.len() % m.columns as usize == 0
|
||||
&& m.layout.iter().all(|i| *i < m.tiles.len()),
|
||||
"invalid mission terrain {}",
|
||||
m.id
|
||||
);
|
||||
}
|
||||
for tile in &m.tiles {
|
||||
ensure!(
|
||||
(if m.legacy_map.is_some() { -32768 } else { 0 }..=100000)
|
||||
.contains(&tile.health),
|
||||
"invalid terrain health"
|
||||
);
|
||||
art(&Some(tile.art.clone()))?;
|
||||
art(&tile.destroyed)?;
|
||||
}
|
||||
ensure!(
|
||||
m.spawns.windows(2).all(|w| w[0].tick <= w[1].tick),
|
||||
"mission {} spawns must be sorted by tick",
|
||||
m.id
|
||||
);
|
||||
for s in &m.spawns {
|
||||
ensure!(
|
||||
s.tick <= 1_000_000
|
||||
&& s.difficulty <= if m.legacy_map.is_some() { 5 } else { 3 },
|
||||
"invalid spawn tick/difficulty"
|
||||
);
|
||||
ensure!(
|
||||
s.x.unsigned_abs() <= 32768 && s.y.unsigned_abs() <= 32768,
|
||||
"spawn coordinate outside world bounds"
|
||||
);
|
||||
ensure!(
|
||||
self.enemies.contains_key(&s.enemy),
|
||||
"mission {} references missing enemy {}",
|
||||
m.id,
|
||||
s.enemy
|
||||
);
|
||||
}
|
||||
if let Some(next) = &m.next {
|
||||
ensure!(
|
||||
self.missions.contains_key(next),
|
||||
"missing next mission {next}"
|
||||
);
|
||||
}
|
||||
}
|
||||
for c in self.campaigns.values() {
|
||||
label(&c.name, &None)?;
|
||||
art(&c.art)?;
|
||||
ensure!(c.missions.len() <= 100000, "campaign exceeds mission limit");
|
||||
ensure!(!c.missions.is_empty(), "empty campaign {}", c.id);
|
||||
for m in &c.missions {
|
||||
ensure!(
|
||||
self.missions.contains_key(m),
|
||||
"campaign {} references missing mission {m}",
|
||||
c.id
|
||||
);
|
||||
}
|
||||
}
|
||||
for sound in self.sounds.values() {
|
||||
label(&sound.name, &None)?;
|
||||
ensure!(
|
||||
!sound.file.is_empty()
|
||||
&& sound.file.len() <= 4096
|
||||
&& (0..=127).contains(&sound.volume)
|
||||
&& (0..=255).contains(&sound.pan)
|
||||
&& (0..=255).contains(&sound.pitch)
|
||||
&& sound.priority <= 100000,
|
||||
"invalid sound definition {}",
|
||||
sound.id
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn base(catalog: &Catalog) -> Result<Self> {
|
||||
let mut reg = Self::default();
|
||||
let mut pack = ContentPack::default();
|
||||
for o in crate::legacy_tables::objects() {
|
||||
let id = ContentId::from(o.id);
|
||||
let weapon = if o.index < 15 {
|
||||
Some(ContentId(format!(
|
||||
"base:projectile_{}",
|
||||
o.id.strip_prefix("base:").unwrap()
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let art = if o.asset > 0 {
|
||||
let s = catalog.sprite(o.asset)?;
|
||||
Some(Art {
|
||||
asset: Some(o.asset),
|
||||
png: None,
|
||||
hd_png: None,
|
||||
logical_width: s.width as u32,
|
||||
logical_height: s.height as u32,
|
||||
frames: o.frames.max(1),
|
||||
nearest: false,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
pack.items.push(ItemDef {
|
||||
id: id.clone(),
|
||||
name: o.name.into(),
|
||||
price: o.cost,
|
||||
max_count: o.max_count.max(1),
|
||||
initial_count: o.initial_count,
|
||||
weapon,
|
||||
shield: if o.index == 16 {
|
||||
25
|
||||
} else if o.index == 15 {
|
||||
100
|
||||
} else {
|
||||
0
|
||||
},
|
||||
credits: if o.index >= 18 { o.cost } else { 0 },
|
||||
consumable: o.index == 11,
|
||||
special: o.special,
|
||||
art,
|
||||
script: None,
|
||||
legacy: Some(o.index),
|
||||
});
|
||||
}
|
||||
for w in crate::legacy_tables::weapons() {
|
||||
let sprite = if w.asset > 0 {
|
||||
Some(catalog.sprite(w.asset)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
pack.weapons.push(WeaponDef {
|
||||
id: ContentId(format!(
|
||||
"base:projectile_{}",
|
||||
w.id.strip_prefix("base:").unwrap()
|
||||
)),
|
||||
name: w.name.into(),
|
||||
damage: w.damage.max(1),
|
||||
speed: w.speed,
|
||||
cooldown: w.cooldown.max(1),
|
||||
behavior: match w.index {
|
||||
4 | 8 | 7 | 2 => WeaponBehavior::Homing,
|
||||
5 | 12 | 13 | 14 => WeaponBehavior::Beam,
|
||||
9 | 11 => WeaponBehavior::Bomb,
|
||||
10 => WeaponBehavior::EnergyGrab,
|
||||
6 => WeaponBehavior::Spread,
|
||||
_ => WeaponBehavior::Straight,
|
||||
},
|
||||
target: match w.target {
|
||||
1 => Target::Air,
|
||||
2 | 3 | 4 => Target::Ground,
|
||||
_ => Target::All,
|
||||
},
|
||||
art: sprite.map(|sprite| Art {
|
||||
asset: Some(w.asset),
|
||||
png: None,
|
||||
hd_png: None,
|
||||
logical_width: sprite.width as u32,
|
||||
logical_height: sprite.height as u32,
|
||||
frames: w.frames.max(1),
|
||||
nearest: false,
|
||||
}),
|
||||
offsets: if w.index == 0 {
|
||||
vec![[-13, 0], [13, 0]]
|
||||
} else {
|
||||
vec![[0, 0]]
|
||||
},
|
||||
script: None,
|
||||
legacy: Some(w.index),
|
||||
});
|
||||
}
|
||||
pack.weapons.push(WeaponDef {
|
||||
id: "base:enemy_bullet".into(),
|
||||
name: "Enemy cannon".into(),
|
||||
damage: 3,
|
||||
speed: 4,
|
||||
cooldown: 12,
|
||||
behavior: WeaponBehavior::Straight,
|
||||
target: Target::All,
|
||||
art: Some(Art {
|
||||
asset: Some(0x101d6),
|
||||
png: None,
|
||||
hd_png: None,
|
||||
logical_width: 8,
|
||||
logical_height: 8,
|
||||
frames: 2,
|
||||
nearest: false,
|
||||
}),
|
||||
offsets: vec![[0, 0]],
|
||||
script: None,
|
||||
legacy: None,
|
||||
});
|
||||
for game in 0..4 {
|
||||
let Ok(bytes) = catalog.named(&format!("SPRITE{}_ITM", game + 1)) else {
|
||||
continue;
|
||||
};
|
||||
for (n, legacy) in LegacyEnemy::library(&bytes)?.into_iter().enumerate() {
|
||||
let Ok(asset) = catalog.id(&legacy.name) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(s) = catalog.sprite(asset) else {
|
||||
continue;
|
||||
};
|
||||
let f = &legacy.fields;
|
||||
pack.enemies.push(EnemyDef {
|
||||
id: ContentId(format!("base:enemy_{game}_{n}")),
|
||||
name: legacy.name.clone(),
|
||||
health: f[13].max(1),
|
||||
speed: f[18].clamp(0, 128),
|
||||
reward: f[14].max(0) as u32,
|
||||
ground: f[21] >= 3,
|
||||
boss: f[12] != 0,
|
||||
cooldown: f[3].max(1) as u32,
|
||||
path: legacy
|
||||
.flight_x
|
||||
.iter()
|
||||
.zip(&legacy.flight_y)
|
||||
.take(f[19].max(0) as usize)
|
||||
.map(|(x, y)| [*x as i32, *y as i32])
|
||||
.collect(),
|
||||
repeat: f[21] == 0,
|
||||
weapon: if f[22] > 0 {
|
||||
Some("base:enemy_bullet".into())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
drop: pack
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.legacy == Some(f[1] as u32))
|
||||
.map(|i| i.id.clone()),
|
||||
art: Some(Art {
|
||||
asset: Some(asset),
|
||||
png: None,
|
||||
hd_png: None,
|
||||
logical_width: s.width as u32,
|
||||
logical_height: s.height as u32,
|
||||
frames: f[7].max(1) as u32,
|
||||
nearest: false,
|
||||
}),
|
||||
script: None,
|
||||
legacy: Some(legacy),
|
||||
});
|
||||
}
|
||||
}
|
||||
for sector in 1..=3 {
|
||||
let mut missions = Vec::new();
|
||||
for wave in 1..=9 {
|
||||
let Ok(id) = catalog.id(&format!("MAP{wave}G{sector}_MAP")) else {
|
||||
continue;
|
||||
};
|
||||
let map = LegacyMap::decode(&catalog.read(id)?)?;
|
||||
let mut spawns = Vec::new();
|
||||
let mut group_row = None;
|
||||
for s in &map.spawns {
|
||||
let row = *group_row.get_or_insert(s.row);
|
||||
let eid = ContentId(format!("base:enemy_{}_{}", s.game, s.library));
|
||||
if pack.enemies.iter().any(|e| e.id == eid) {
|
||||
spawns.push(SpawnDef {
|
||||
tick: if row >= 139 {
|
||||
0
|
||||
} else {
|
||||
(56 + (138 - row) * 32) as u64
|
||||
},
|
||||
enemy: eid,
|
||||
x: 16 + s.x * 32,
|
||||
y: (if row >= 139 { -136 } else { -112 }) - (row - s.row) * 32,
|
||||
difficulty: s.difficulty.max(0) as u32,
|
||||
});
|
||||
}
|
||||
if s.link == 1 || s.link == -1 {
|
||||
group_row = None;
|
||||
}
|
||||
}
|
||||
spawns.sort_by_key(|s| s.tick);
|
||||
let mut tiles = Vec::new();
|
||||
let mut layout = Vec::new();
|
||||
let mut lookup = BTreeMap::new();
|
||||
for (flat, game) in map.tiles {
|
||||
let key = (flat, game);
|
||||
let pos = if let Some(p) = lookup.get(&key) {
|
||||
*p
|
||||
} else {
|
||||
let start = catalog.id(&format!("STARTG{}TILES", game + 1))? + 1;
|
||||
let lib = catalog.named(&format!("FLATSG{}_ITM", game + 1))?;
|
||||
let o = flat as usize * 8;
|
||||
let destroyed = crate::assets::i32_at(&lib, o)?;
|
||||
let health = crate::assets::i16_at(&lib, o + 4)?;
|
||||
let bounty = crate::assets::i16_at(&lib, o + 6)?;
|
||||
let make = |asset| Art {
|
||||
asset: Some(asset),
|
||||
png: None,
|
||||
hd_png: None,
|
||||
logical_width: 32,
|
||||
logical_height: 32,
|
||||
frames: 1,
|
||||
nearest: false,
|
||||
};
|
||||
let n = tiles.len();
|
||||
tiles.push(TileDef {
|
||||
art: make(start + flat as u32),
|
||||
health: if destroyed != flat as i32 {
|
||||
health as i32
|
||||
} else {
|
||||
0
|
||||
},
|
||||
reward: bounty.max(0) as u32,
|
||||
destroyed: if destroyed != flat as i32 {
|
||||
Some(make(start + destroyed as u32))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
});
|
||||
lookup.insert(key, n);
|
||||
n
|
||||
};
|
||||
layout.push(pos);
|
||||
}
|
||||
let mid = ContentId(format!("base:sector_{sector}_wave_{wave}"));
|
||||
missions.push(mid.clone());
|
||||
pack.missions.push(MissionDef {
|
||||
id: mid,
|
||||
name: format!("Sector {sector} / Wave {wave}"),
|
||||
art: None,
|
||||
duration: 4700,
|
||||
auto_complete: true,
|
||||
spawns,
|
||||
tiles,
|
||||
columns: 9,
|
||||
layout,
|
||||
next: if wave < 9 {
|
||||
Some(ContentId(format!("base:sector_{sector}_wave_{}", wave + 1)))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
music: catalog
|
||||
.id(&format!(
|
||||
"RAP{}_MUS",
|
||||
[
|
||||
[8, 2, 4, 7, 6, 2, 3, 4, 6],
|
||||
[3, 2, 4, 8, 6, 2, 8, 1, 6],
|
||||
[8, 4, 1, 7, 6, 2, 3, 4, 6],
|
||||
][sector - 1][wave - 1]
|
||||
))
|
||||
.ok(),
|
||||
script: None,
|
||||
legacy_map: Some(id),
|
||||
});
|
||||
}
|
||||
if !missions.is_empty() {
|
||||
pack.campaigns.push(CampaignDef {
|
||||
id: ContentId(format!("base:sector_{sector}")),
|
||||
name: ["Bravo Sector", "Tango Sector", "Outer Regions"][sector - 1].into(),
|
||||
art: None,
|
||||
missions,
|
||||
});
|
||||
}
|
||||
}
|
||||
reg.apply(pack, "base")?;
|
||||
reg.validate()?;
|
||||
Ok(reg)
|
||||
}
|
||||
pub fn art(&self, id: &ContentId) -> Option<&Art> {
|
||||
self.enemies
|
||||
.get(id)
|
||||
.and_then(|e| e.art.as_ref())
|
||||
.or_else(|| self.items.get(id).and_then(|e| e.art.as_ref()))
|
||||
.or_else(|| self.weapons.get(id).and_then(|e| e.art.as_ref()))
|
||||
.or_else(|| self.missions.get(id).and_then(|e| e.art.as_ref()))
|
||||
.or_else(|| self.campaigns.get(id).and_then(|e| e.art.as_ref()))
|
||||
}
|
||||
}
|
||||
pub fn art_sprite(
|
||||
catalog: &Catalog,
|
||||
registry: &Registry,
|
||||
owner: &str,
|
||||
art: &Art,
|
||||
frame: u32,
|
||||
) -> Result<Sprite> {
|
||||
art_sprite_variant(catalog, registry, owner, art, frame, false)
|
||||
}
|
||||
pub fn art_sprite_variant(
|
||||
catalog: &Catalog,
|
||||
registry: &Registry,
|
||||
owner: &str,
|
||||
art: &Art,
|
||||
frame: u32,
|
||||
hd: bool,
|
||||
) -> Result<Sprite> {
|
||||
ensure!(art.frames > 0, "art animation must have frames");
|
||||
let png = if hd {
|
||||
art.hd_png.as_ref().or(art.png.as_ref())
|
||||
} else {
|
||||
art.png.as_ref()
|
||||
};
|
||||
if let Some(id) = art.asset.filter(|_| !(hd && art.hd_png.is_some())) {
|
||||
catalog.sprite(
|
||||
id.checked_add(frame % art.frames)
|
||||
.context("art asset ID overflow")?,
|
||||
)
|
||||
} else {
|
||||
let root = registry
|
||||
.asset_roots
|
||||
.get(owner)
|
||||
.context("unknown artwork owner")?;
|
||||
let path =
|
||||
crate::assets::contained(root, std::path::Path::new(png.context("missing PNG")?))?;
|
||||
let sprite = Sprite::png(&path, &catalog.palette()?)?;
|
||||
ensure!(
|
||||
sprite.width % art.frames as usize == 0,
|
||||
"PNG strip width must be divisible by frame count"
|
||||
);
|
||||
let width = sprite.width / art.frames as usize;
|
||||
let x = (frame % art.frames) as usize * width;
|
||||
let mut out = Sprite::blank(width, sprite.height);
|
||||
for y in 0..out.height {
|
||||
out.pixels[y * width..(y + 1) * width].copy_from_slice(
|
||||
&sprite.pixels[y * sprite.width + x..y * sprite.width + x + width],
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
+1981
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,439 @@
|
||||
//! Opt-in remaster artwork tied to the original asset ID and animation canvas.
|
||||
//! Missing or rejected frames fall back to the original crisp sprite.
|
||||
use crate::assets::{Catalog, Sprite, contained};
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use image::{
|
||||
ImageReader, RgbaImage,
|
||||
imageops::{self, FilterType},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Manifest {
|
||||
pub format_version: u32,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
pub sequences: Vec<Sequence>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Sequence {
|
||||
pub name: String,
|
||||
pub base_asset: u32,
|
||||
pub frame_count: u32,
|
||||
pub frames: Vec<Frame>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Frame {
|
||||
pub frame: u32,
|
||||
pub file: PathBuf,
|
||||
pub logical_width: u32,
|
||||
pub logical_height: u32,
|
||||
/// Anchor relative to the original canvas. Version 1 requires [0, 0].
|
||||
pub origin: [i32; 2],
|
||||
/// Exact original catalog payload, so changes cannot quietly misalign art.
|
||||
pub source_sha256: String,
|
||||
/// Optional pixel rectangle [x, y, width, height] in an atlas PNG.
|
||||
#[serde(default)]
|
||||
pub crop: Option<[u32; 4]>,
|
||||
}
|
||||
struct Replacement {
|
||||
frame: Frame,
|
||||
rgba: RgbaImage,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct HdAssets {
|
||||
replacements: BTreeMap<u32, Replacement>,
|
||||
scaled: BTreeMap<(u32, usize), Sprite>,
|
||||
pub diagnostics: Vec<String>,
|
||||
}
|
||||
impl HdAssets {
|
||||
pub fn load(root: &Path, catalog: &Catalog) -> Self {
|
||||
let mut result = Self::default();
|
||||
if !root.exists() {
|
||||
return result;
|
||||
}
|
||||
match result.read(root, catalog) {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
// Reject the complete pack if its sequence metadata is invalid.
|
||||
result.replacements.clear();
|
||||
result
|
||||
.diagnostics
|
||||
.push(format!("HD pack {}: {e:#}", root.display()));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
fn read(&mut self, root: &Path, catalog: &Catalog) -> Result<()> {
|
||||
let bytes = fs::read(contained(root, Path::new("hd.json"))?)?;
|
||||
ensure!(bytes.len() <= 256 * 1024, "HD metadata exceeds 256 KiB");
|
||||
let manifest: Manifest = serde_json::from_slice(&bytes)?;
|
||||
ensure!(
|
||||
manifest.format_version == 1,
|
||||
"unsupported HD artwork format {}",
|
||||
manifest.format_version
|
||||
);
|
||||
ensure!(manifest.sequences.len() <= 512, "too many HD sequences");
|
||||
let mut ids = BTreeMap::new();
|
||||
for sequence in &manifest.sequences {
|
||||
ensure!(
|
||||
(1..=512).contains(&sequence.frame_count),
|
||||
"invalid frame count for {}",
|
||||
sequence.name
|
||||
);
|
||||
ensure!(
|
||||
sequence.frames.len() <= sequence.frame_count as usize,
|
||||
"too many supplied frames for {}",
|
||||
sequence.name
|
||||
);
|
||||
for frame in &sequence.frames {
|
||||
ensure!(
|
||||
frame.frame < sequence.frame_count,
|
||||
"{}: frame {} outside sequence",
|
||||
sequence.name,
|
||||
frame.frame
|
||||
);
|
||||
let id = sequence
|
||||
.base_asset
|
||||
.checked_add(frame.frame)
|
||||
.context("HD asset ID overflow")?;
|
||||
ensure!(ids.insert(id, ()).is_none(), "duplicate HD asset {id:05x}");
|
||||
ensure!(self.replacements.len() < 4096, "too many HD frames");
|
||||
match Self::read_frame(root, catalog, id, frame) {
|
||||
Ok(replacement) => {
|
||||
if self
|
||||
.replacements
|
||||
.values()
|
||||
.map(|r| r.rgba.as_raw().len())
|
||||
.sum::<usize>()
|
||||
+ replacement.rgba.as_raw().len()
|
||||
> 128 * 1024 * 1024
|
||||
{
|
||||
self.diagnostics.push(format!(
|
||||
"HD {id:05x}: pack exceeds 128 MiB artwork budget; using original"
|
||||
));
|
||||
} else {
|
||||
self.replacements.insert(id, replacement);
|
||||
}
|
||||
}
|
||||
Err(e) => self.diagnostics.push(format!(
|
||||
"HD {} frame {} ({id:05x}): {e:#}; using original",
|
||||
sequence.name, frame.frame
|
||||
)),
|
||||
}
|
||||
}
|
||||
if sequence.frames.len() != sequence.frame_count as usize {
|
||||
self.diagnostics.push(format!(
|
||||
"HD {}: {} of {} frames supplied; uncovered frames use original artwork",
|
||||
sequence.name,
|
||||
sequence.frames.len(),
|
||||
sequence.frame_count
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn read_frame(root: &Path, catalog: &Catalog, id: u32, frame: &Frame) -> Result<Replacement> {
|
||||
let source = catalog.read(id)?;
|
||||
let original = Sprite::decode(&source)?;
|
||||
validate_frame(frame, &original, &source)?;
|
||||
let path = contained(root, &frame.file)?;
|
||||
ensure!(
|
||||
fs::metadata(&path)?.len() <= 32 * 1024 * 1024,
|
||||
"HD PNG exceeds 32 MiB"
|
||||
);
|
||||
let mut reader = ImageReader::open(path)?.with_guessed_format()?;
|
||||
ensure!(
|
||||
reader.format() == Some(image::ImageFormat::Png),
|
||||
"HD 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 mut rgba = reader.decode()?.into_rgba8();
|
||||
ensure!(
|
||||
rgba.width() as u64 * rgba.height() as u64 <= 4_194_304,
|
||||
"HD PNG exceeds pixel limit"
|
||||
);
|
||||
if let Some([x, y, width, height]) = frame.crop {
|
||||
ensure!(
|
||||
width > 0
|
||||
&& height > 0
|
||||
&& x.checked_add(width).is_some_and(|n| n <= rgba.width())
|
||||
&& y.checked_add(height).is_some_and(|n| n <= rgba.height()),
|
||||
"HD atlas crop exceeds its PNG"
|
||||
);
|
||||
rgba = imageops::crop_imm(&rgba, x, y, width, height).to_image();
|
||||
}
|
||||
ensure!(
|
||||
rgba.width() as u64 * frame.logical_height as u64
|
||||
== rgba.height() as u64 * frame.logical_width as u64,
|
||||
"HD frame aspect must match the original canvas"
|
||||
);
|
||||
ensure!(
|
||||
rgba.width() >= frame.logical_width * 2 && rgba.height() >= frame.logical_height * 2,
|
||||
"HD artwork must supply at least twice the logical resolution"
|
||||
);
|
||||
ensure!(
|
||||
rgba.pixels().any(|p| p[3] == 0),
|
||||
"HD frame needs a transparent background"
|
||||
);
|
||||
ensure!(
|
||||
rgba.pixels().any(|p| p[3] >= 128),
|
||||
"HD frame has no visible pixels"
|
||||
);
|
||||
Ok(Replacement {
|
||||
frame: frame.clone(),
|
||||
rgba,
|
||||
})
|
||||
}
|
||||
pub fn coverage(&self) -> usize {
|
||||
self.replacements.len()
|
||||
}
|
||||
/// Return already detailed artwork at the canvas resolution; never run ScaleNx on it.
|
||||
pub fn sprite(
|
||||
&mut self,
|
||||
id: u32,
|
||||
scale: usize,
|
||||
palette: &[[u8; 3]; 256],
|
||||
) -> Result<Option<Sprite>> {
|
||||
ensure!(
|
||||
(1..=4).contains(&scale),
|
||||
"HD presentation scale must be 1..4"
|
||||
);
|
||||
let Some(replacement) = self.replacements.get(&id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !self.scaled.contains_key(&(id, scale)) {
|
||||
let width = replacement.frame.logical_width as usize * scale;
|
||||
let height = replacement.frame.logical_height as usize * scale;
|
||||
let resized = imageops::resize(
|
||||
&replacement.rgba,
|
||||
width as u32,
|
||||
height as u32,
|
||||
FilterType::Lanczos3,
|
||||
);
|
||||
let mut sprite = Sprite::blank(width, height);
|
||||
let mut colors = BTreeMap::new();
|
||||
for (destination, pixel) in sprite.pixels.iter_mut().zip(resized.pixels()) {
|
||||
if pixel[3] < 128 {
|
||||
continue;
|
||||
}
|
||||
let key = [pixel[0], pixel[1], pixel[2]];
|
||||
*destination = *colors.entry(key).or_insert_with(|| closest(palette, key));
|
||||
}
|
||||
if self
|
||||
.scaled
|
||||
.values()
|
||||
.map(|s| s.pixels.len() * 2)
|
||||
.sum::<usize>()
|
||||
+ sprite.pixels.len() * 2
|
||||
> 64 * 1024 * 1024
|
||||
{
|
||||
self.scaled.clear();
|
||||
}
|
||||
self.scaled.insert((id, scale), sprite);
|
||||
}
|
||||
Ok(self.scaled.get(&(id, scale)).cloned())
|
||||
}
|
||||
}
|
||||
fn validate_frame(frame: &Frame, original: &Sprite, source: &[u8]) -> Result<()> {
|
||||
ensure!(
|
||||
frame.logical_width as usize == original.width
|
||||
&& frame.logical_height as usize == original.height,
|
||||
"HD logical dimensions must equal original {}x{}",
|
||||
original.width,
|
||||
original.height
|
||||
);
|
||||
ensure!(
|
||||
frame.origin == [0, 0],
|
||||
"HD format 1 preserves the original top-left anchor [0, 0]"
|
||||
);
|
||||
ensure!(
|
||||
frame.source_sha256 == format!("{:x}", Sha256::digest(source)),
|
||||
"original source hash changed; regenerate or review this frame"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn closest(palette: &[[u8; 3]; 256], rgb: [u8; 3]) -> u16 {
|
||||
palette
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by_key(|(_, c)| {
|
||||
(0..3)
|
||||
.map(|j| (c[j] as i32 - rgb[j] as i32).pow(2))
|
||||
.sum::<i32>()
|
||||
})
|
||||
.unwrap()
|
||||
.0 as u16
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::assets::TRANSPARENT;
|
||||
fn frame(source: &[u8]) -> Frame {
|
||||
Frame {
|
||||
frame: 0,
|
||||
file: "ship.png".into(),
|
||||
logical_width: 2,
|
||||
logical_height: 2,
|
||||
origin: [0, 0],
|
||||
source_sha256: format!("{:x}", Sha256::digest(source)),
|
||||
crop: None,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn metadata_cannot_change_original_canvas_or_anchor() {
|
||||
let source = b"source";
|
||||
let original = Sprite::blank(2, 2);
|
||||
let mut f = frame(source);
|
||||
assert!(validate_frame(&f, &original, source).is_ok());
|
||||
f.logical_width = 3;
|
||||
assert!(validate_frame(&f, &original, source).is_err());
|
||||
f.logical_width = 2;
|
||||
f.origin = [1, 0];
|
||||
assert!(validate_frame(&f, &original, source).is_err());
|
||||
f.origin = [0, 0];
|
||||
assert!(validate_frame(&f, &original, b"changed").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn detailed_art_preserves_canvas_and_transparency_without_filtering_twice() {
|
||||
let source = b"source";
|
||||
let mut rgba = RgbaImage::new(8, 8);
|
||||
for y in 2..6 {
|
||||
for x in 2..6 {
|
||||
rgba.put_pixel(x, y, image::Rgba([255, 0, 0, 255]));
|
||||
}
|
||||
}
|
||||
let mut art = HdAssets::default();
|
||||
art.replacements.insert(
|
||||
1,
|
||||
Replacement {
|
||||
frame: frame(source),
|
||||
rgba,
|
||||
},
|
||||
);
|
||||
let mut palette = [[0; 3]; 256];
|
||||
palette[100] = [255, 0, 0];
|
||||
let s = art.sprite(1, 4, &palette).unwrap().unwrap();
|
||||
assert_eq!((s.width, s.height), (8, 8));
|
||||
assert_eq!(s.pixels[0], TRANSPARENT);
|
||||
assert_eq!(s.pixels[3 * 8 + 3], 100);
|
||||
assert!(art.sprite(2, 4, &palette).unwrap().is_none());
|
||||
}
|
||||
fn fixture() -> (tempfile::TempDir, Catalog, Manifest) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut source = Vec::new();
|
||||
for n in [1_i32, 0, 0, 2, 2] {
|
||||
source.extend(n.to_le_bytes());
|
||||
}
|
||||
source.extend([1, 2, 3, 4]);
|
||||
fs::write(dir.path().join("source"), &source).unwrap();
|
||||
fs::write(
|
||||
dir.path().join("INDEX"),
|
||||
format!("00001 {} source\n", source.len()),
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir(dir.path().join("hd")).unwrap();
|
||||
let mut png = RgbaImage::new(8, 8);
|
||||
for y in 2..6 {
|
||||
for x in 2..6 {
|
||||
png.put_pixel(x, y, image::Rgba([100, 20, 20, 255]));
|
||||
}
|
||||
}
|
||||
png.save(dir.path().join("hd/ship.png")).unwrap();
|
||||
let manifest = Manifest {
|
||||
format_version: 1,
|
||||
name: "Fixture".into(),
|
||||
description: String::new(),
|
||||
sequences: vec![Sequence {
|
||||
name: "ship".into(),
|
||||
base_asset: 1,
|
||||
frame_count: 2,
|
||||
frames: vec![frame(&source)],
|
||||
}],
|
||||
};
|
||||
let catalog = Catalog::open(dir.path()).unwrap();
|
||||
(dir, catalog, manifest)
|
||||
}
|
||||
fn pack(dir: &Path, catalog: &Catalog, manifest: &Manifest) -> HdAssets {
|
||||
fs::write(
|
||||
dir.join("hd/hd.json"),
|
||||
serde_json::to_vec(manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
HdAssets::load(&dir.join("hd"), catalog)
|
||||
}
|
||||
#[test]
|
||||
fn source_checked_pack_reports_partial_coverage_and_rejects_changed_data() {
|
||||
let (dir, catalog, mut manifest) = fixture();
|
||||
let assets = pack(dir.path(), &catalog, &manifest);
|
||||
assert_eq!(assets.coverage(), 1);
|
||||
assert!(assets.diagnostics[0].contains("1 of 2 frames"));
|
||||
manifest.sequences[0].frames[0].source_sha256 = "changed".into();
|
||||
let assets = pack(dir.path(), &catalog, &manifest);
|
||||
assert_eq!(assets.coverage(), 0);
|
||||
assert!(
|
||||
assets
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|m| m.contains("source hash changed"))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn invalid_atlas_and_path_cannot_escape_original_canvas() {
|
||||
let (dir, catalog, mut manifest) = fixture();
|
||||
manifest.sequences[0].frames[0].crop = Some([0, 0, 9, 8]);
|
||||
let assets = pack(dir.path(), &catalog, &manifest);
|
||||
assert_eq!(assets.coverage(), 0);
|
||||
assert!(
|
||||
assets
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|m| m.contains("crop exceeds"))
|
||||
);
|
||||
manifest.sequences[0].frames[0].crop = None;
|
||||
manifest.sequences[0].frames[0].file = "../source".into();
|
||||
let assets = pack(dir.path(), &catalog, &manifest);
|
||||
assert_eq!(assets.coverage(), 0);
|
||||
assert!(assets.diagnostics.iter().any(|m| m.contains("stay inside")));
|
||||
}
|
||||
#[test]
|
||||
fn duplicate_sequence_ids_reject_pack_atomically() {
|
||||
let (dir, catalog, mut manifest) = fixture();
|
||||
let duplicate = manifest.sequences[0].clone();
|
||||
manifest.sequences.push(duplicate);
|
||||
let assets = pack(dir.path(), &catalog, &manifest);
|
||||
assert_eq!(assets.coverage(), 0);
|
||||
assert!(
|
||||
assets
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|m| m.contains("duplicate HD asset"))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn missing_pack_is_clean_fallback() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// Build a real minimal catalog so the public loading path is exercised.
|
||||
fs::write(dir.path().join("a"), []).unwrap();
|
||||
fs::write(dir.path().join("INDEX"), "00001 0 a\n").unwrap();
|
||||
let catalog = Catalog::open(dir.path()).unwrap();
|
||||
let art = HdAssets::load(&dir.path().join("missing"), &catalog);
|
||||
assert_eq!(art.coverage(), 0);
|
||||
assert!(art.diagnostics.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,953 @@
|
||||
// Generated by tools/generate_legacy_tables.py; source is GPL-2.0-or-later.
|
||||
pub struct LegacyObject {
|
||||
pub index: u32,
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
pub asset: u32,
|
||||
pub frames: u32,
|
||||
pub cost: u32,
|
||||
pub max_count: u32,
|
||||
pub initial_count: u32,
|
||||
pub special: bool,
|
||||
}
|
||||
pub struct LegacyWeapon {
|
||||
pub index: u32,
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
pub asset: u32,
|
||||
pub frames: u32,
|
||||
pub damage: i32,
|
||||
pub speed: i32,
|
||||
pub cooldown: u32,
|
||||
pub target: u32,
|
||||
pub maxspeed: i32,
|
||||
pub startframe: u32,
|
||||
pub delay: bool,
|
||||
pub plot: bool,
|
||||
pub moving: bool,
|
||||
pub follow_x: bool,
|
||||
pub follow_y: bool,
|
||||
pub effect: bool,
|
||||
pub beam: u32,
|
||||
}
|
||||
pub fn objects() -> Vec<LegacyObject> {
|
||||
vec![
|
||||
LegacyObject {
|
||||
index: 0,
|
||||
id: "base:forward_guns",
|
||||
name: "Forward Guns",
|
||||
asset: 66224,
|
||||
frames: 1,
|
||||
cost: 12000,
|
||||
max_count: 1,
|
||||
initial_count: 1,
|
||||
special: false,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 1,
|
||||
id: "base:plasma_guns",
|
||||
name: "Plasma Guns",
|
||||
asset: 66225,
|
||||
frames: 2,
|
||||
cost: 78800,
|
||||
max_count: 1,
|
||||
initial_count: 1,
|
||||
special: false,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 2,
|
||||
id: "base:micro_missile",
|
||||
name: "Micro Missile",
|
||||
asset: 66227,
|
||||
frames: 2,
|
||||
cost: 175600,
|
||||
max_count: 1,
|
||||
initial_count: 1,
|
||||
special: false,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 3,
|
||||
id: "base:dumb_missile",
|
||||
name: "Dumb Missile",
|
||||
asset: 66229,
|
||||
frames: 1,
|
||||
cost: 145200,
|
||||
max_count: 1,
|
||||
initial_count: 1,
|
||||
special: true,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 4,
|
||||
id: "base:mini_gun",
|
||||
name: "Mini Gun",
|
||||
asset: 66230,
|
||||
frames: 4,
|
||||
cost: 250650,
|
||||
max_count: 1,
|
||||
initial_count: 1,
|
||||
special: true,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 5,
|
||||
id: "base:turret",
|
||||
name: "Turret",
|
||||
asset: 66234,
|
||||
frames: 4,
|
||||
cost: 512850,
|
||||
max_count: 1,
|
||||
initial_count: 1,
|
||||
special: true,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 6,
|
||||
id: "base:missile_pods",
|
||||
name: "Missile Pods",
|
||||
asset: 66238,
|
||||
frames: 1,
|
||||
cost: 204950,
|
||||
max_count: 1,
|
||||
initial_count: 1,
|
||||
special: true,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 7,
|
||||
id: "base:air_missile",
|
||||
name: "Air Missile",
|
||||
asset: 66239,
|
||||
frames: 1,
|
||||
cost: 63500,
|
||||
max_count: 1,
|
||||
initial_count: 1,
|
||||
special: true,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 8,
|
||||
id: "base:grd_missile",
|
||||
name: "Grd Missile",
|
||||
asset: 66240,
|
||||
frames: 1,
|
||||
cost: 110000,
|
||||
max_count: 1,
|
||||
initial_count: 1,
|
||||
special: true,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 9,
|
||||
id: "base:bomb",
|
||||
name: "Bomb",
|
||||
asset: 66266,
|
||||
frames: 1,
|
||||
cost: 98200,
|
||||
max_count: 1,
|
||||
initial_count: 1,
|
||||
special: true,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 10,
|
||||
id: "base:energy_grab",
|
||||
name: "Energy Grab",
|
||||
asset: 66241,
|
||||
frames: 4,
|
||||
cost: 300750,
|
||||
max_count: 1,
|
||||
initial_count: 1,
|
||||
special: true,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 11,
|
||||
id: "base:mega_bomb",
|
||||
name: "Mega Bomb",
|
||||
asset: 66245,
|
||||
frames: 1,
|
||||
cost: 32250,
|
||||
max_count: 5,
|
||||
initial_count: 1,
|
||||
special: false,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 12,
|
||||
id: "base:pulse_cannon",
|
||||
name: "Pulse Cannon",
|
||||
asset: 66246,
|
||||
frames: 2,
|
||||
cost: 725000,
|
||||
max_count: 1,
|
||||
initial_count: 1,
|
||||
special: true,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 13,
|
||||
id: "base:forward_laser",
|
||||
name: "Forward Laser",
|
||||
asset: 66248,
|
||||
frames: 4,
|
||||
cost: 1750000,
|
||||
max_count: 1,
|
||||
initial_count: 1,
|
||||
special: true,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 14,
|
||||
id: "base:death_ray",
|
||||
name: "Death Ray",
|
||||
asset: 66252,
|
||||
frames: 4,
|
||||
cost: 950000,
|
||||
max_count: 1,
|
||||
initial_count: 1,
|
||||
special: true,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 15,
|
||||
id: "base:super_shield",
|
||||
name: "Super Shield",
|
||||
asset: 66256,
|
||||
frames: 1,
|
||||
cost: 78500,
|
||||
max_count: 100,
|
||||
initial_count: 100,
|
||||
special: false,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 16,
|
||||
id: "base:energy",
|
||||
name: "Energy",
|
||||
asset: 66257,
|
||||
frames: 4,
|
||||
cost: 10000,
|
||||
max_count: 100,
|
||||
initial_count: 25,
|
||||
special: false,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 17,
|
||||
id: "base:detect",
|
||||
name: "Detect",
|
||||
asset: 66261,
|
||||
frames: 1,
|
||||
cost: 10000,
|
||||
max_count: 1,
|
||||
initial_count: 1,
|
||||
special: false,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 18,
|
||||
id: "base:itembuy1",
|
||||
name: "Itembuy1",
|
||||
asset: 66261,
|
||||
frames: 1,
|
||||
cost: 93800,
|
||||
max_count: 93800,
|
||||
initial_count: 93800,
|
||||
special: false,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 19,
|
||||
id: "base:itembuy2",
|
||||
name: "Itembuy2",
|
||||
asset: 66262,
|
||||
frames: 1,
|
||||
cost: 76000,
|
||||
max_count: 76000,
|
||||
initial_count: 76000,
|
||||
special: false,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 20,
|
||||
id: "base:itembuy3",
|
||||
name: "Itembuy3",
|
||||
asset: 66263,
|
||||
frames: 1,
|
||||
cost: 55700,
|
||||
max_count: 55700,
|
||||
initial_count: 55700,
|
||||
special: false,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 21,
|
||||
id: "base:itembuy4",
|
||||
name: "Itembuy4",
|
||||
asset: 66264,
|
||||
frames: 1,
|
||||
cost: 35200,
|
||||
max_count: 35200,
|
||||
initial_count: 35200,
|
||||
special: false,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 22,
|
||||
id: "base:itembuy5",
|
||||
name: "Itembuy5",
|
||||
asset: 66265,
|
||||
frames: 1,
|
||||
cost: 122500,
|
||||
max_count: 122500,
|
||||
initial_count: 122500,
|
||||
special: false,
|
||||
},
|
||||
LegacyObject {
|
||||
index: 23,
|
||||
id: "base:itembuy6",
|
||||
name: "Itembuy6",
|
||||
asset: 66267,
|
||||
frames: 4,
|
||||
cost: 50,
|
||||
max_count: 50,
|
||||
initial_count: 50,
|
||||
special: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
pub fn weapons() -> Vec<LegacyWeapon> {
|
||||
vec![
|
||||
LegacyWeapon {
|
||||
index: 0,
|
||||
id: "base:forward_guns",
|
||||
name: "Forward Guns",
|
||||
asset: 65987,
|
||||
frames: 4,
|
||||
damage: 1,
|
||||
speed: 8,
|
||||
cooldown: 2,
|
||||
target: 0,
|
||||
maxspeed: 16,
|
||||
startframe: 0,
|
||||
delay: false,
|
||||
plot: false,
|
||||
moving: true,
|
||||
follow_x: false,
|
||||
follow_y: false,
|
||||
effect: false,
|
||||
beam: 0,
|
||||
},
|
||||
LegacyWeapon {
|
||||
index: 1,
|
||||
id: "base:plasma_guns",
|
||||
name: "Plasma Guns",
|
||||
asset: 65999,
|
||||
frames: 2,
|
||||
damage: 2,
|
||||
speed: 4,
|
||||
cooldown: 10,
|
||||
target: 1,
|
||||
maxspeed: 8,
|
||||
startframe: 0,
|
||||
delay: false,
|
||||
plot: false,
|
||||
moving: true,
|
||||
follow_x: false,
|
||||
follow_y: false,
|
||||
effect: false,
|
||||
beam: 0,
|
||||
},
|
||||
LegacyWeapon {
|
||||
index: 2,
|
||||
id: "base:micro_missile",
|
||||
name: "Micro Missile",
|
||||
asset: 65985,
|
||||
frames: 2,
|
||||
damage: 2,
|
||||
speed: 2,
|
||||
cooldown: 4,
|
||||
target: 3,
|
||||
maxspeed: 8,
|
||||
startframe: 0,
|
||||
delay: false,
|
||||
plot: false,
|
||||
moving: true,
|
||||
follow_x: false,
|
||||
follow_y: false,
|
||||
effect: false,
|
||||
beam: 0,
|
||||
},
|
||||
LegacyWeapon {
|
||||
index: 3,
|
||||
id: "base:dumb_missile",
|
||||
name: "Dumb Missile",
|
||||
asset: 65978,
|
||||
frames: 3,
|
||||
damage: 4,
|
||||
speed: 2,
|
||||
cooldown: 10,
|
||||
target: 0,
|
||||
maxspeed: 12,
|
||||
startframe: 1,
|
||||
delay: true,
|
||||
plot: true,
|
||||
moving: true,
|
||||
follow_x: false,
|
||||
follow_y: false,
|
||||
effect: false,
|
||||
beam: 0,
|
||||
},
|
||||
LegacyWeapon {
|
||||
index: 4,
|
||||
id: "base:mini_gun",
|
||||
name: "Mini Gun",
|
||||
asset: 65987,
|
||||
frames: 4,
|
||||
damage: 1,
|
||||
speed: 8,
|
||||
cooldown: 1,
|
||||
target: 3,
|
||||
maxspeed: 10,
|
||||
startframe: 1,
|
||||
delay: false,
|
||||
plot: true,
|
||||
moving: true,
|
||||
follow_x: false,
|
||||
follow_y: false,
|
||||
effect: false,
|
||||
beam: 0,
|
||||
},
|
||||
LegacyWeapon {
|
||||
index: 5,
|
||||
id: "base:turret",
|
||||
name: "Turret",
|
||||
asset: 0,
|
||||
frames: 0,
|
||||
damage: 5,
|
||||
speed: 0,
|
||||
cooldown: 6,
|
||||
target: 0,
|
||||
maxspeed: 0,
|
||||
startframe: 0,
|
||||
delay: false,
|
||||
plot: false,
|
||||
moving: false,
|
||||
follow_x: false,
|
||||
follow_y: false,
|
||||
effect: false,
|
||||
beam: 1,
|
||||
},
|
||||
LegacyWeapon {
|
||||
index: 6,
|
||||
id: "base:missile_pods",
|
||||
name: "Missile Pods",
|
||||
asset: 65981,
|
||||
frames: 2,
|
||||
damage: 4,
|
||||
speed: 1,
|
||||
cooldown: 5,
|
||||
target: 1,
|
||||
maxspeed: 16,
|
||||
startframe: 0,
|
||||
delay: false,
|
||||
plot: false,
|
||||
moving: true,
|
||||
follow_x: false,
|
||||
follow_y: false,
|
||||
effect: false,
|
||||
beam: 0,
|
||||
},
|
||||
LegacyWeapon {
|
||||
index: 7,
|
||||
id: "base:air_missile",
|
||||
name: "Air Missile",
|
||||
asset: 65981,
|
||||
frames: 2,
|
||||
damage: 4,
|
||||
speed: 1,
|
||||
cooldown: 10,
|
||||
target: 1,
|
||||
maxspeed: 12,
|
||||
startframe: 0,
|
||||
delay: false,
|
||||
plot: false,
|
||||
moving: true,
|
||||
follow_x: false,
|
||||
follow_y: false,
|
||||
effect: false,
|
||||
beam: 0,
|
||||
},
|
||||
LegacyWeapon {
|
||||
index: 8,
|
||||
id: "base:grd_missile",
|
||||
name: "Grd Missile",
|
||||
asset: 65983,
|
||||
frames: 2,
|
||||
damage: 20,
|
||||
speed: 1,
|
||||
cooldown: 20,
|
||||
target: 2,
|
||||
maxspeed: 6,
|
||||
startframe: 0,
|
||||
delay: false,
|
||||
plot: false,
|
||||
moving: true,
|
||||
follow_x: false,
|
||||
follow_y: false,
|
||||
effect: false,
|
||||
beam: 0,
|
||||
},
|
||||
LegacyWeapon {
|
||||
index: 9,
|
||||
id: "base:bomb",
|
||||
name: "Bomb",
|
||||
asset: 66003,
|
||||
frames: 1,
|
||||
damage: 50,
|
||||
speed: 1,
|
||||
cooldown: 30,
|
||||
target: 4,
|
||||
maxspeed: 4,
|
||||
startframe: 0,
|
||||
delay: false,
|
||||
plot: false,
|
||||
moving: true,
|
||||
follow_x: false,
|
||||
follow_y: false,
|
||||
effect: false,
|
||||
beam: 0,
|
||||
},
|
||||
LegacyWeapon {
|
||||
index: 10,
|
||||
id: "base:energy_grab",
|
||||
name: "Energy Grab",
|
||||
asset: 66077,
|
||||
frames: 6,
|
||||
damage: 3,
|
||||
speed: 4,
|
||||
cooldown: 2,
|
||||
target: 5,
|
||||
maxspeed: 8,
|
||||
startframe: 0,
|
||||
delay: false,
|
||||
plot: false,
|
||||
moving: true,
|
||||
follow_x: false,
|
||||
follow_y: false,
|
||||
effect: false,
|
||||
beam: 0,
|
||||
},
|
||||
LegacyWeapon {
|
||||
index: 11,
|
||||
id: "base:mega_bomb",
|
||||
name: "Mega Bomb",
|
||||
asset: 65995,
|
||||
frames: 4,
|
||||
damage: 50,
|
||||
speed: 2,
|
||||
cooldown: 60,
|
||||
target: 0,
|
||||
maxspeed: 2,
|
||||
startframe: 0,
|
||||
delay: false,
|
||||
plot: true,
|
||||
moving: true,
|
||||
follow_x: false,
|
||||
follow_y: false,
|
||||
effect: true,
|
||||
beam: 0,
|
||||
},
|
||||
LegacyWeapon {
|
||||
index: 12,
|
||||
id: "base:pulse_cannon",
|
||||
name: "Pulse Cannon",
|
||||
asset: 66083,
|
||||
frames: 2,
|
||||
damage: 5,
|
||||
speed: 8,
|
||||
cooldown: 3,
|
||||
target: 0,
|
||||
maxspeed: 8,
|
||||
startframe: 0,
|
||||
delay: false,
|
||||
plot: false,
|
||||
moving: true,
|
||||
follow_x: false,
|
||||
follow_y: false,
|
||||
effect: false,
|
||||
beam: 0,
|
||||
},
|
||||
LegacyWeapon {
|
||||
index: 13,
|
||||
id: "base:forward_laser",
|
||||
name: "Forward Laser",
|
||||
asset: 66101,
|
||||
frames: 4,
|
||||
damage: 10,
|
||||
speed: 0,
|
||||
cooldown: 7,
|
||||
target: 1,
|
||||
maxspeed: 0,
|
||||
startframe: 0,
|
||||
delay: false,
|
||||
plot: false,
|
||||
moving: false,
|
||||
follow_x: true,
|
||||
follow_y: true,
|
||||
effect: true,
|
||||
beam: 2,
|
||||
},
|
||||
LegacyWeapon {
|
||||
index: 14,
|
||||
id: "base:death_ray",
|
||||
name: "Death Ray",
|
||||
asset: 66097,
|
||||
frames: 4,
|
||||
damage: 6,
|
||||
speed: 0,
|
||||
cooldown: 7,
|
||||
target: 3,
|
||||
maxspeed: 0,
|
||||
startframe: 0,
|
||||
delay: false,
|
||||
plot: false,
|
||||
moving: false,
|
||||
follow_x: true,
|
||||
follow_y: true,
|
||||
effect: true,
|
||||
beam: 2,
|
||||
},
|
||||
]
|
||||
}
|
||||
pub const O_GUN1: [i32; 8] = [1, 3, 5, 6, 5, 3, 1, 0];
|
||||
pub const O_GUN2: [i32; 8] = [1, 3, 6, 9, 6, 3, 2, 0];
|
||||
pub const O_GUN3: [i32; 8] = [2, 6, 8, 11, 8, 6, 2, 0];
|
||||
pub struct LegacySound {
|
||||
pub name: &'static str,
|
||||
pub asset: u32,
|
||||
pub pitch: i32,
|
||||
pub random: bool,
|
||||
pub volume: i32,
|
||||
pub priority: i32,
|
||||
}
|
||||
pub fn sounds() -> Vec<LegacySound> {
|
||||
vec![
|
||||
LegacySound {
|
||||
name: "FX_MON1",
|
||||
asset: 194,
|
||||
pitch: 128,
|
||||
random: true,
|
||||
volume: 127,
|
||||
priority: 1,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_MON2",
|
||||
asset: 199,
|
||||
pitch: 128,
|
||||
random: true,
|
||||
volume: 127,
|
||||
priority: 1,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_MON3",
|
||||
asset: 204,
|
||||
pitch: 128,
|
||||
random: true,
|
||||
volume: 127,
|
||||
priority: 1,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_MON4",
|
||||
asset: 209,
|
||||
pitch: 128,
|
||||
random: true,
|
||||
volume: 127,
|
||||
priority: 1,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_MON5",
|
||||
asset: 214,
|
||||
pitch: 128,
|
||||
random: true,
|
||||
volume: 127,
|
||||
priority: 1,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_MON6",
|
||||
asset: 219,
|
||||
pitch: 128,
|
||||
random: true,
|
||||
volume: 127,
|
||||
priority: 1,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_DAVE",
|
||||
asset: 224,
|
||||
pitch: 128,
|
||||
random: false,
|
||||
volume: 127,
|
||||
priority: 1,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_THEME",
|
||||
asset: 99,
|
||||
pitch: 128,
|
||||
random: false,
|
||||
volume: 127,
|
||||
priority: 0,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_AIREXPLO",
|
||||
asset: 104,
|
||||
pitch: 128,
|
||||
random: true,
|
||||
volume: 127,
|
||||
priority: 1,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_AIREXPLO2",
|
||||
asset: 109,
|
||||
pitch: 128,
|
||||
random: true,
|
||||
volume: 127,
|
||||
priority: 1,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_BONUS",
|
||||
asset: 114,
|
||||
pitch: 128,
|
||||
random: false,
|
||||
volume: 127,
|
||||
priority: 0,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_CRASH",
|
||||
asset: 119,
|
||||
pitch: 128,
|
||||
random: false,
|
||||
volume: 127,
|
||||
priority: 0,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_DOOR",
|
||||
asset: 124,
|
||||
pitch: 120,
|
||||
random: false,
|
||||
volume: 127,
|
||||
priority: 0,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_FLYBY",
|
||||
asset: 129,
|
||||
pitch: 120,
|
||||
random: false,
|
||||
volume: 127,
|
||||
priority: 0,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_EGRAB",
|
||||
asset: 134,
|
||||
pitch: 128,
|
||||
random: false,
|
||||
volume: 40,
|
||||
priority: 2,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_GEXPLO",
|
||||
asset: 139,
|
||||
pitch: 128,
|
||||
random: true,
|
||||
volume: 127,
|
||||
priority: 2,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_GUN",
|
||||
asset: 144,
|
||||
pitch: 125,
|
||||
random: true,
|
||||
volume: 30,
|
||||
priority: 10,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_JETSND",
|
||||
asset: 149,
|
||||
pitch: 120,
|
||||
random: false,
|
||||
volume: 120,
|
||||
priority: 4,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_LASER",
|
||||
asset: 154,
|
||||
pitch: 120,
|
||||
random: false,
|
||||
volume: 50,
|
||||
priority: 2,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_MISSLE",
|
||||
asset: 159,
|
||||
pitch: 120,
|
||||
random: true,
|
||||
volume: 50,
|
||||
priority: 3,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_SWEP",
|
||||
asset: 164,
|
||||
pitch: 128,
|
||||
random: false,
|
||||
volume: 127,
|
||||
priority: 1,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_TURRET",
|
||||
asset: 169,
|
||||
pitch: 128,
|
||||
random: true,
|
||||
volume: 60,
|
||||
priority: 1,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_WARNING",
|
||||
asset: 174,
|
||||
pitch: 128,
|
||||
random: false,
|
||||
volume: 100,
|
||||
priority: 2,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_BOSS1",
|
||||
asset: 179,
|
||||
pitch: 127,
|
||||
random: false,
|
||||
volume: 127,
|
||||
priority: 1,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_IJETSND",
|
||||
asset: 149,
|
||||
pitch: 235,
|
||||
random: false,
|
||||
volume: 45,
|
||||
priority: 1,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_EJETSND",
|
||||
asset: 149,
|
||||
pitch: 65,
|
||||
random: false,
|
||||
volume: 120,
|
||||
priority: 1,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_INTROHIT",
|
||||
asset: 144,
|
||||
pitch: 215,
|
||||
random: true,
|
||||
volume: 127,
|
||||
priority: 1,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_INTROGUN",
|
||||
asset: 144,
|
||||
pitch: 110,
|
||||
random: true,
|
||||
volume: 127,
|
||||
priority: 1,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_ENEMYSHOT",
|
||||
asset: 189,
|
||||
pitch: 100,
|
||||
random: true,
|
||||
volume: 50,
|
||||
priority: 1,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_ENEMYLASER",
|
||||
asset: 154,
|
||||
pitch: 70,
|
||||
random: true,
|
||||
volume: 120,
|
||||
priority: 1,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_ENEMYMISSLE",
|
||||
asset: 159,
|
||||
pitch: 140,
|
||||
random: true,
|
||||
volume: 55,
|
||||
priority: 2,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_ENEMYPLASMA",
|
||||
asset: 189,
|
||||
pitch: 127,
|
||||
random: true,
|
||||
volume: 127,
|
||||
priority: 2,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_SHIT",
|
||||
asset: 184,
|
||||
pitch: 132,
|
||||
random: true,
|
||||
volume: 127,
|
||||
priority: 1,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_HIT",
|
||||
asset: 144,
|
||||
pitch: 214,
|
||||
random: true,
|
||||
volume: 100,
|
||||
priority: 1,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_NOSHOOT",
|
||||
asset: 189,
|
||||
pitch: 254,
|
||||
random: false,
|
||||
volume: 40,
|
||||
priority: 2,
|
||||
},
|
||||
LegacySound {
|
||||
name: "FX_PULSE",
|
||||
asset: 189,
|
||||
pitch: 100,
|
||||
random: true,
|
||||
volume: 50,
|
||||
priority: 2,
|
||||
},
|
||||
]
|
||||
}
|
||||
pub const FREQUENCY_CURVE: [u16; 668] = [
|
||||
307, 307, 308, 308, 309, 310, 310, 311, 311, 312, 312, 313, 313, 314, 315, 315, 316, 316, 317,
|
||||
317, 318, 319, 319, 320, 320, 321, 322, 322, 323, 323, 324, 324, 325, 326, 326, 327, 327, 328,
|
||||
329, 329, 330, 330, 331, 332, 332, 333, 333, 334, 335, 335, 336, 336, 337, 338, 338, 339, 339,
|
||||
340, 341, 341, 342, 343, 343, 344, 344, 345, 346, 346, 347, 347, 348, 349, 349, 350, 351, 351,
|
||||
352, 353, 353, 354, 354, 355, 356, 356, 357, 358, 358, 359, 360, 360, 361, 362, 362, 363, 364,
|
||||
364, 365, 366, 366, 367, 368, 368, 369, 370, 370, 371, 372, 372, 373, 374, 374, 375, 376, 376,
|
||||
377, 378, 378, 379, 380, 380, 381, 382, 382, 383, 384, 385, 385, 386, 387, 387, 388, 389, 389,
|
||||
390, 391, 392, 392, 393, 394, 394, 395, 396, 397, 397, 398, 399, 399, 400, 401, 402, 402, 403,
|
||||
404, 404, 405, 406, 407, 407, 408, 409, 410, 410, 411, 412, 413, 413, 414, 415, 416, 416, 417,
|
||||
418, 419, 419, 420, 421, 422, 422, 423, 424, 425, 425, 426, 427, 428, 429, 429, 430, 431, 432,
|
||||
432, 433, 434, 435, 436, 436, 437, 438, 439, 440, 440, 441, 442, 443, 444, 444, 445, 446, 447,
|
||||
448, 448, 449, 450, 451, 452, 452, 453, 454, 455, 456, 457, 457, 458, 459, 460, 461, 462, 462,
|
||||
463, 464, 465, 466, 467, 467, 468, 469, 470, 471, 472, 472, 473, 474, 475, 476, 477, 478, 478,
|
||||
479, 480, 481, 482, 483, 484, 485, 485, 486, 487, 488, 489, 490, 491, 492, 493, 493, 494, 495,
|
||||
496, 497, 498, 499, 500, 501, 502, 502, 503, 504, 505, 506, 507, 508, 509, 510, 511, 512, 513,
|
||||
513, 514, 515, 516, 517, 518, 519, 520, 521, 522, 523, 524, 525, 526, 527, 528, 528, 529, 530,
|
||||
531, 532, 533, 534, 535, 536, 537, 538, 539, 540, 541, 542, 543, 544, 545, 546, 547, 548, 549,
|
||||
550, 551, 552, 553, 554, 555, 556, 557, 558, 559, 560, 561, 562, 563, 564, 565, 566, 567, 568,
|
||||
569, 570, 571, 572, 573, 574, 575, 576, 577, 578, 580, 581, 582, 583, 584, 585, 586, 587, 588,
|
||||
589, 590, 591, 592, 593, 594, 595, 596, 598, 599, 600, 601, 602, 603, 604, 605, 606, 607, 608,
|
||||
610, 611, 612, 613, 614, 615, 616, 617, 618, 620, 621, 622, 623, 624, 625, 626, 627, 629, 630,
|
||||
631, 632, 633, 634, 635, 637, 638, 639, 640, 641, 642, 644, 645, 646, 647, 648, 649, 651, 652,
|
||||
653, 654, 655, 656, 658, 659, 660, 661, 662, 664, 665, 666, 667, 668, 670, 671, 672, 673, 674,
|
||||
676, 677, 678, 679, 681, 682, 683, 684, 686, 687, 688, 689, 690, 692, 693, 694, 695, 697, 698,
|
||||
699, 701, 702, 703, 704, 706, 707, 708, 709, 711, 712, 713, 715, 716, 717, 718, 720, 721, 722,
|
||||
724, 725, 726, 728, 729, 730, 732, 733, 734, 736, 737, 738, 740, 741, 742, 744, 745, 746, 748,
|
||||
749, 750, 752, 753, 754, 756, 757, 758, 760, 761, 763, 764, 765, 767, 768, 770, 771, 772, 774,
|
||||
775, 777, 778, 779, 781, 782, 784, 785, 786, 788, 789, 791, 792, 794, 795, 796, 798, 799, 801,
|
||||
802, 804, 805, 807, 808, 809, 811, 812, 814, 815, 817, 818, 820, 821, 823, 824, 826, 827, 829,
|
||||
830, 832, 833, 835, 836, 838, 839, 841, 842, 844, 845, 847, 848, 850, 851, 853, 855, 856, 858,
|
||||
859, 861, 862, 864, 865, 867, 869, 870, 872, 873, 875, 876, 878, 880, 881, 883, 884, 886, 888,
|
||||
889, 891, 892, 894, 896, 897, 899, 900, 902, 904, 905, 907, 909, 910, 912, 914, 915, 917, 919,
|
||||
920, 922, 924, 925, 927, 929, 930, 932, 934, 935, 937, 939, 940, 942, 944, 945, 947, 949, 951,
|
||||
952, 954, 956, 957, 959, 961, 963, 964, 966, 968, 970, 971, 973, 975, 977, 978, 980, 982, 984,
|
||||
986, 987, 989, 991, 993, 995, 996, 998, 1000, 1002, 1004, 1005, 1007, 1009, 1011, 1013, 1014,
|
||||
1016, 1018, 1020, 1022, 876,
|
||||
];
|
||||
pub const VOLUME_CURVE: [u16; 128] = [
|
||||
0, 1, 3, 5, 6, 8, 10, 11, 13, 14, 16, 17, 19, 20, 22, 23, 25, 26, 27, 29, 30, 32, 33, 34, 36,
|
||||
37, 39, 41, 43, 45, 47, 49, 50, 52, 54, 55, 57, 59, 60, 61, 63, 64, 66, 67, 68, 69, 71, 72, 73,
|
||||
74, 75, 76, 77, 79, 80, 81, 82, 83, 84, 84, 85, 86, 87, 88, 89, 90, 91, 92, 92, 93, 94, 95, 96,
|
||||
96, 97, 98, 99, 99, 100, 101, 101, 102, 103, 103, 104, 105, 105, 106, 107, 107, 108, 109, 109,
|
||||
110, 110, 111, 112, 112, 113, 113, 114, 114, 115, 115, 116, 117, 117, 118, 118, 119, 119, 120,
|
||||
120, 121, 121, 122, 122, 123, 123, 123, 124, 124, 125, 125, 126, 126, 127, 127,
|
||||
];
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
pub mod assets;
|
||||
pub mod audio;
|
||||
pub mod cinematics;
|
||||
pub mod content;
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod desktop;
|
||||
pub mod hd;
|
||||
mod legacy_tables;
|
||||
pub mod modding;
|
||||
pub mod persistence;
|
||||
pub mod render;
|
||||
pub mod simulation;
|
||||
pub mod sound;
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
use anyhow::{Context, Result};
|
||||
use raptor_replica::{
|
||||
assets::{Catalog, decode_demo},
|
||||
content::{ContentId, Registry},
|
||||
modding::{self, ActiveMods},
|
||||
persistence::{Paths, Pilot},
|
||||
simulation::{Input, World},
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
fn main() {
|
||||
if let Err(e) = run() {
|
||||
eprintln!("{e:#}");
|
||||
std::process::exit(1)
|
||||
}
|
||||
}
|
||||
fn run() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let arg = |key: &str| {
|
||||
args.iter()
|
||||
.position(|s| s == key)
|
||||
.and_then(|i| args.get(i + 1))
|
||||
};
|
||||
if args.iter().any(|a| a == "--help" || a == "-h") {
|
||||
println!(
|
||||
"raptor-rust [--data DIR] [--mods DIR]\n --verify-assets Check catalog and all original mission definitions\n --validate-mods ID,... Validate selected local mod packages\n --schemas DIR Write mod API JSON schemas\n --headless MISSION --ticks N Deterministic simulation smoke run\n --replay ASSET_NAME --trace FILE Record legacy demo simulation states\n --import-legacy FILE --output FILE\n --smoke-frames N Exit desktop after N rendered frames\n --screenshot FILE Save the final desktop frame as PNG\n --preview-mission ID Start a mission directly\n --preview-cinematic KIND intro, landing, death, end1, end2, end3\n --profile-root DIR Use a separate pilot/config/cache directory\n --enabled ID,... Enable mods for headless/replay commands"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(path) = arg("--schemas") {
|
||||
modding::write_schemas(&PathBuf::from(path))?;
|
||||
println!("Wrote API v1 schemas to {path}");
|
||||
return Ok(());
|
||||
}
|
||||
let data = arg("--data")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data"));
|
||||
let catalog = Catalog::open(data)?;
|
||||
let base = Registry::base(&catalog)?;
|
||||
if args.iter().any(|a| a == "--verify-assets") {
|
||||
println!(
|
||||
"Verified {} assets; {} missions, {} enemies, {} weapons, {} items",
|
||||
catalog.verify()?,
|
||||
base.missions.len(),
|
||||
base.enemies.len(),
|
||||
base.weapons.len(),
|
||||
base.items.len()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let mut paths = Paths::discover()?;
|
||||
if let Some(root) = arg("--profile-root") {
|
||||
let root = PathBuf::from(root);
|
||||
paths.data = root.join("data");
|
||||
paths.config = root.join("config");
|
||||
paths.cache = root.join("cache");
|
||||
paths.legacy = root.join("legacy");
|
||||
}
|
||||
let root = arg("--mods")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| paths.mods());
|
||||
let discovery = modding::discover(&root);
|
||||
if let Some(ids) = arg("--validate-mods") {
|
||||
let enabled = ids
|
||||
.split(',')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
let active = ActiveMods::build(&base, &discovery, &enabled, Some(&catalog))?;
|
||||
println!(
|
||||
"Validated {} mods; {} override(s)",
|
||||
active.pins.len(),
|
||||
active.registry.overrides.len()
|
||||
);
|
||||
for err in discovery.errors {
|
||||
eprintln!("Discovery: {err}");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(path) = arg("--import-legacy") {
|
||||
let output = PathBuf::from(arg("--output").context("--import-legacy requires --output")?);
|
||||
let source = PathBuf::from(path).canonicalize()?;
|
||||
anyhow::ensure!(
|
||||
output.canonicalize().ok().as_ref() != Some(&source),
|
||||
"legacy import output must be separate from the original save"
|
||||
);
|
||||
let pilot = Pilot::import_legacy(&PathBuf::from(path), &base)?;
|
||||
pilot.save(&output)?;
|
||||
println!("Imported pilot to {}", output.display());
|
||||
return Ok(());
|
||||
}
|
||||
let enabled = arg("--enabled")
|
||||
.map(|s| {
|
||||
s.split(',')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Some(id) = arg("--headless") {
|
||||
let mut active = ActiveMods::build(&base, &discovery, &enabled, Some(&catalog))?;
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.mission = ContentId::new(id)?;
|
||||
let mut world = World::new(&pilot.mission, &pilot, &active.registry)?;
|
||||
let ticks = arg("--ticks")
|
||||
.map(|s| s.parse())
|
||||
.transpose()?
|
||||
.unwrap_or(500);
|
||||
for _ in 0..ticks {
|
||||
world.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)?;
|
||||
if world.outcome != raptor_replica::simulation::Outcome::Playing {
|
||||
break;
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"tick={} outcome={:?} enemies={} credits={} digest={}",
|
||||
world.tick,
|
||||
world.outcome,
|
||||
world.enemies.len(),
|
||||
world.player.credits,
|
||||
world.digest()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(name) = arg("--replay") {
|
||||
let (sector, wave, frames) = decode_demo(&catalog.named(name)?)?;
|
||||
let mission = ContentId(format!("base:sector_{}_wave_{}", sector + 1, wave + 1));
|
||||
let mut pilot = Pilot::demo(sector, &base)?;
|
||||
pilot.mission = mission.clone();
|
||||
let mut active = ActiveMods::build(&base, &discovery, &enabled, Some(&catalog))?;
|
||||
let mut world = World::new(&mission, &pilot, &active.registry)?;
|
||||
let mut trace = arg("--trace")
|
||||
.map(|path| std::fs::File::create(path))
|
||||
.transpose()?;
|
||||
let limit = arg("--ticks")
|
||||
.map(|s| s.parse::<usize>())
|
||||
.transpose()?
|
||||
.unwrap_or(frames.len());
|
||||
for f in frames.into_iter().take(limit) {
|
||||
world.step(
|
||||
Input {
|
||||
fire: f.buttons[0],
|
||||
cycle: f.buttons[1],
|
||||
bomb: f.buttons[2],
|
||||
absolute: Some([f.x as i32, f.y as i32]),
|
||||
demo_bank: Some(f.bank as i32),
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)?;
|
||||
if let Some(file) = &mut trace {
|
||||
use std::io::Write;
|
||||
writeln!(file, "{}", world.reference_state(&active.registry))?;
|
||||
}
|
||||
}
|
||||
println!("Replay tick={} digest={}", world.tick, world.digest());
|
||||
return Ok(());
|
||||
}
|
||||
#[cfg(feature = "desktop")]
|
||||
{
|
||||
let movie = arg("--preview-cinematic")
|
||||
.map(|s| match s.as_str() {
|
||||
"intro" => Ok(raptor_replica::cinematics::Kind::Intro),
|
||||
"landing" => Ok(raptor_replica::cinematics::Kind::Landing),
|
||||
"death" => Ok(raptor_replica::cinematics::Kind::Death),
|
||||
"end1" => Ok(raptor_replica::cinematics::Kind::CampaignEnd(0)),
|
||||
"end2" => Ok(raptor_replica::cinematics::Kind::CampaignEnd(1)),
|
||||
"end3" => Ok(raptor_replica::cinematics::Kind::CampaignEnd(2)),
|
||||
_ => Err(anyhow::anyhow!("unknown cinematic {s}")),
|
||||
})
|
||||
.transpose()?;
|
||||
raptor_replica::desktop::run(
|
||||
catalog,
|
||||
base,
|
||||
paths,
|
||||
root,
|
||||
arg("--smoke-frames").map(|s| s.parse()).transpose()?,
|
||||
arg("--screenshot").map(PathBuf::from),
|
||||
arg("--preview-mission")
|
||||
.map(|s| ContentId::new(s))
|
||||
.transpose()?,
|
||||
movie,
|
||||
)?;
|
||||
}
|
||||
#[cfg(not(feature = "desktop"))]
|
||||
{
|
||||
let _ = (catalog, base, paths, root);
|
||||
return Err(anyhow::anyhow!(
|
||||
"desktop support is disabled; use a headless command"
|
||||
));
|
||||
}
|
||||
#[cfg(feature = "desktop")]
|
||||
Ok(())
|
||||
}
|
||||
+814
@@ -0,0 +1,814 @@
|
||||
//! Local mod discovery, dependency ordering and transactional script callbacks.
|
||||
use crate::{
|
||||
assets::{Catalog, contained},
|
||||
content::{API_VERSION, ContentPack, Registry},
|
||||
simulation::LegacyRng,
|
||||
};
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use rhai::{
|
||||
AST, Dynamic, Engine, Scope,
|
||||
packages::{
|
||||
ArithmeticPackage, BasicArrayPackage, BasicFnPackage, BasicIteratorPackage,
|
||||
BasicMapPackage, BasicMathPackage, BasicStringPackage, BitFieldPackage, LogicPackage,
|
||||
MoreStringPackage, Package,
|
||||
},
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
use semver::{Version, VersionReq};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Manifest {
|
||||
/// Unique lowercase namespace; `base` is reserved for the original game.
|
||||
pub id: String,
|
||||
/// Display name shown in the in-game manager.
|
||||
pub name: String,
|
||||
/// Semantic package version, saved together with a SHA-256 content hash.
|
||||
pub version: String,
|
||||
/// Exact supported content/script API major version.
|
||||
pub api_version: u32,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Required, explicitly enabled mod IDs mapped to semantic version requirements.
|
||||
#[serde(default)]
|
||||
pub dependencies: BTreeMap<String, String>,
|
||||
/// IDs which cannot be enabled at the same time as this package.
|
||||
#[serde(default)]
|
||||
pub conflicts: Vec<String>,
|
||||
/// Relative path to the JSON content pack. Defaults to `content.json`.
|
||||
#[serde(default = "content_file")]
|
||||
pub content: String,
|
||||
/// Relative paths to every Rhai source referenced by this content pack.
|
||||
#[serde(default)]
|
||||
pub scripts: Vec<String>,
|
||||
}
|
||||
fn content_file() -> String {
|
||||
"content.json".into()
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ModPin {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
pub hash: String,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InstalledMod {
|
||||
pub root: PathBuf,
|
||||
pub manifest: Manifest,
|
||||
pub hash: String,
|
||||
}
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Discovery {
|
||||
pub mods: BTreeMap<String, InstalledMod>,
|
||||
pub errors: Vec<String>,
|
||||
/// Directory inventoried by this snapshot, used to reject new duplicate packages.
|
||||
pub root: Option<PathBuf>,
|
||||
}
|
||||
const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024;
|
||||
const MAX_PACKAGE_BYTES: u64 = 256 * 1024 * 1024;
|
||||
const MAX_FILES: usize = 10000;
|
||||
const MAX_DEPTH: usize = 16;
|
||||
|
||||
fn namespace(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id != "base"
|
||||
&& id
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "_-".contains(c))
|
||||
}
|
||||
fn relative_path(value: &str) -> Result<()> {
|
||||
let path = Path::new(value);
|
||||
ensure!(
|
||||
!value.is_empty()
|
||||
&& value.len() <= 512
|
||||
&& !path.is_absolute()
|
||||
&& path
|
||||
.components()
|
||||
.all(|c| matches!(c, std::path::Component::Normal(_))),
|
||||
"package paths must be relative and may not contain parent traversal: {value}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn read_package_file(root: &Path, relative: &Path) -> Result<Vec<u8>> {
|
||||
let path = contained(root, relative)?;
|
||||
let metadata = fs::symlink_metadata(&path)?;
|
||||
ensure!(metadata.is_file(), "package entry must be a regular file");
|
||||
ensure!(metadata.len() <= MAX_FILE_BYTES, "mod file exceeds 64 MiB");
|
||||
let data = fs::read(path)?;
|
||||
ensure!(
|
||||
data.len() as u64 <= MAX_FILE_BYTES,
|
||||
"mod file exceeds 64 MiB"
|
||||
);
|
||||
Ok(data)
|
||||
}
|
||||
fn load_package(path: &Path) -> Result<InstalledMod> {
|
||||
ensure!(
|
||||
fs::symlink_metadata(path)?.is_dir(),
|
||||
"mod folder must be a real directory"
|
||||
);
|
||||
let root = path.canonicalize()?;
|
||||
let mut files = Vec::new();
|
||||
let mut bytes = 0;
|
||||
walk(&root, &root, &mut files, &mut bytes, 0)?;
|
||||
files.sort();
|
||||
let manifest_data = read_package_file(&root, Path::new("mod.toml"))?;
|
||||
ensure!(
|
||||
manifest_data.len() <= 64 * 1024,
|
||||
"mod manifest exceeds 64 KiB"
|
||||
);
|
||||
let manifest: Manifest = toml::from_str(std::str::from_utf8(&manifest_data)?)?;
|
||||
ensure!(namespace(&manifest.id), "invalid/reserved mod namespace");
|
||||
ensure!(
|
||||
!manifest.name.trim().is_empty() && manifest.name.len() <= 128,
|
||||
"invalid mod display name"
|
||||
);
|
||||
ensure!(
|
||||
manifest.description.len() <= 4096,
|
||||
"mod description exceeds 4 KiB"
|
||||
);
|
||||
Version::parse(&manifest.version).context("invalid package version")?;
|
||||
ensure!(
|
||||
manifest.dependencies.len() <= 128 && manifest.conflicts.len() <= 128,
|
||||
"too many dependencies/conflicts"
|
||||
);
|
||||
for (dependency, version) in &manifest.dependencies {
|
||||
ensure!(
|
||||
namespace(dependency) && dependency != &manifest.id,
|
||||
"invalid/self dependency {dependency}"
|
||||
);
|
||||
VersionReq::parse(version)
|
||||
.with_context(|| format!("invalid version requirement for {dependency}"))?;
|
||||
}
|
||||
let conflicts: BTreeSet<_> = manifest.conflicts.iter().collect();
|
||||
ensure!(
|
||||
conflicts.len() == manifest.conflicts.len(),
|
||||
"duplicate conflict declaration"
|
||||
);
|
||||
for conflict in &manifest.conflicts {
|
||||
ensure!(
|
||||
namespace(conflict) && conflict != &manifest.id,
|
||||
"invalid/self conflict {conflict}"
|
||||
);
|
||||
}
|
||||
relative_path(&manifest.content)?;
|
||||
contained(&root, Path::new(&manifest.content))?;
|
||||
ensure!(
|
||||
manifest.scripts.len() <= 64,
|
||||
"too many scripts (maximum 64)"
|
||||
);
|
||||
let scripts: BTreeSet<_> = manifest.scripts.iter().collect();
|
||||
ensure!(
|
||||
scripts.len() == manifest.scripts.len(),
|
||||
"duplicate script declaration"
|
||||
);
|
||||
for script in &manifest.scripts {
|
||||
relative_path(script)?;
|
||||
contained(&root, Path::new(script))?;
|
||||
}
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(b"raptor-mod-package-v1\0");
|
||||
for relative in files {
|
||||
let name = relative
|
||||
.to_str()
|
||||
.context("package filenames must be UTF-8")?;
|
||||
let data = read_package_file(&root, &relative)?;
|
||||
hash.update((name.len() as u64).to_le_bytes());
|
||||
hash.update(name.as_bytes());
|
||||
hash.update((data.len() as u64).to_le_bytes());
|
||||
hash.update(data);
|
||||
}
|
||||
Ok(InstalledMod {
|
||||
root,
|
||||
manifest,
|
||||
hash: format!("{:x}", hash.finalize()),
|
||||
})
|
||||
}
|
||||
pub fn discover(root: &Path) -> Discovery {
|
||||
let mut out = Discovery::default();
|
||||
out.root = Some(root.canonicalize().unwrap_or_else(|_| root.to_path_buf()));
|
||||
if !root.exists() {
|
||||
return out;
|
||||
}
|
||||
let entries = match fs::read_dir(root) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
out.errors.push(e.to_string());
|
||||
return out;
|
||||
}
|
||||
};
|
||||
let mut entries: Vec<_> = entries.collect();
|
||||
entries.sort_by_key(|e| e.as_ref().ok().map(|e| e.path()));
|
||||
let mut ambiguous = BTreeSet::new();
|
||||
for entry in entries {
|
||||
let e = match entry {
|
||||
Ok(e) => e,
|
||||
Err(err) => {
|
||||
out.errors.push(format!("{}: {err}", root.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let kind = match e.file_type() {
|
||||
Ok(kind) => kind,
|
||||
Err(err) => {
|
||||
out.errors.push(format!("{}: {err}", e.path().display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if kind.is_symlink() {
|
||||
out.errors.push(format!(
|
||||
"{}: mod folders may not be symlinks",
|
||||
e.path().display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if !kind.is_dir() {
|
||||
continue;
|
||||
}
|
||||
match load_package(&e.path()) {
|
||||
Ok(m) => {
|
||||
let id = m.manifest.id.clone();
|
||||
if ambiguous.contains(&id) || out.mods.contains_key(&id) {
|
||||
out.errors.push(format!("duplicate installed mod {id}"));
|
||||
ambiguous.insert(id.clone());
|
||||
out.mods.remove(&id);
|
||||
} else {
|
||||
out.mods.insert(id, m);
|
||||
}
|
||||
}
|
||||
Err(err) => out.errors.push(format!("{}: {err:#}", e.path().display())),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn walk(
|
||||
root: &Path,
|
||||
current: &Path,
|
||||
files: &mut Vec<PathBuf>,
|
||||
bytes: &mut u64,
|
||||
depth: usize,
|
||||
) -> Result<()> {
|
||||
ensure!(
|
||||
depth <= MAX_DEPTH,
|
||||
"mod package directory depth exceeds {MAX_DEPTH}"
|
||||
);
|
||||
for e in fs::read_dir(current)? {
|
||||
let e = e?;
|
||||
let relative = e.path().strip_prefix(root)?.to_path_buf();
|
||||
ensure!(
|
||||
!e.file_type()?.is_symlink(),
|
||||
"mod packages may not contain symlinks"
|
||||
);
|
||||
if e.file_type()?.is_dir() {
|
||||
walk(root, &e.path(), files, bytes, depth + 1)?
|
||||
} else {
|
||||
ensure!(
|
||||
e.file_type()?.is_file(),
|
||||
"mod entries must be regular files"
|
||||
);
|
||||
ensure!(
|
||||
e.metadata()?.len() <= MAX_FILE_BYTES,
|
||||
"mod file exceeds 64 MiB"
|
||||
);
|
||||
*bytes = bytes
|
||||
.checked_add(e.metadata()?.len())
|
||||
.context("package size overflow")?;
|
||||
ensure!(*bytes <= MAX_PACKAGE_BYTES, "mod package exceeds 256 MiB");
|
||||
files.push(relative);
|
||||
ensure!(files.len() <= MAX_FILES, "mod package has too many files");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn order(discovery: &Discovery, enabled: &[String]) -> Result<Vec<String>> {
|
||||
let selected: BTreeSet<_> = enabled.iter().cloned().collect();
|
||||
ensure!(
|
||||
selected.len() == enabled.len(),
|
||||
"duplicate mod in load order"
|
||||
);
|
||||
let mut result = Vec::new();
|
||||
let mut visiting = BTreeSet::new();
|
||||
let mut done = BTreeSet::new();
|
||||
fn visit(
|
||||
id: &str,
|
||||
d: &Discovery,
|
||||
selected: &BTreeSet<String>,
|
||||
visiting: &mut BTreeSet<String>,
|
||||
done: &mut BTreeSet<String>,
|
||||
result: &mut Vec<String>,
|
||||
) -> Result<()> {
|
||||
if done.contains(id) {
|
||||
return Ok(());
|
||||
}
|
||||
ensure!(visiting.insert(id.into()), "cyclic mod dependency at {id}");
|
||||
let m = d
|
||||
.mods
|
||||
.get(id)
|
||||
.with_context(|| format!("mod {id} is not installed"))?;
|
||||
ensure!(
|
||||
m.manifest.api_version == API_VERSION,
|
||||
"{id}: requires API {}, game provides {API_VERSION}",
|
||||
m.manifest.api_version
|
||||
);
|
||||
for (dep, requirement) in &m.manifest.dependencies {
|
||||
ensure!(selected.contains(dep), "{id} requires enabled mod {dep}");
|
||||
let v = &d
|
||||
.mods
|
||||
.get(dep)
|
||||
.with_context(|| format!("missing dependency {dep}"))?
|
||||
.manifest
|
||||
.version;
|
||||
ensure!(
|
||||
VersionReq::parse(requirement)?.matches(&Version::parse(v)?),
|
||||
"{id} requires {dep} {requirement}, installed {v}"
|
||||
);
|
||||
visit(dep, d, selected, visiting, done, result)?;
|
||||
}
|
||||
for conflict in &m.manifest.conflicts {
|
||||
ensure!(
|
||||
!selected.contains(conflict),
|
||||
"{id} conflicts with {conflict}"
|
||||
);
|
||||
}
|
||||
visiting.remove(id);
|
||||
done.insert(id.into());
|
||||
result.push(id.into());
|
||||
Ok(())
|
||||
}
|
||||
for id in enabled {
|
||||
visit(
|
||||
id,
|
||||
discovery,
|
||||
&selected,
|
||||
&mut visiting,
|
||||
&mut done,
|
||||
&mut result,
|
||||
)?;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
pub struct ActiveMods {
|
||||
pub registry: Registry,
|
||||
pub pins: Vec<ModPin>,
|
||||
pub scripts: ScriptRuntime,
|
||||
}
|
||||
impl ActiveMods {
|
||||
pub fn build(
|
||||
base: &Registry,
|
||||
discovery: &Discovery,
|
||||
enabled: &[String],
|
||||
catalog: Option<&Catalog>,
|
||||
) -> Result<Self> {
|
||||
let mut registry = base.clone();
|
||||
let mut pins = Vec::new();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
let ordered = order(discovery, enabled)?;
|
||||
let refreshed = discovery.root.as_ref().map(|root| discover(root));
|
||||
for id in &ordered {
|
||||
let previous = &discovery.mods[id];
|
||||
if let Some(refreshed) = &refreshed {
|
||||
ensure!(
|
||||
refreshed.mods.contains_key(id),
|
||||
"{id}: package removed, invalid or duplicated since discovery; rescan the Mods screen ({})",
|
||||
refreshed.errors.join("; ")
|
||||
);
|
||||
}
|
||||
let current = load_package(&previous.root).with_context(|| {
|
||||
format!("{id}: package changed or was removed; rescan the Mods screen")
|
||||
})?;
|
||||
ensure!(
|
||||
current.manifest.id == *id && current.hash == previous.hash,
|
||||
"{id}: package changed since discovery; rescan the Mods screen before applying"
|
||||
);
|
||||
}
|
||||
for id in &ordered {
|
||||
let m = &discovery.mods[id];
|
||||
let content: ContentPack = serde_json::from_slice(&read_package_file(
|
||||
&m.root,
|
||||
Path::new(&m.manifest.content),
|
||||
)?)
|
||||
.with_context(|| format!("{id}: invalid content"))?;
|
||||
registry.asset_roots.insert(id.clone(), m.root.clone());
|
||||
registry.apply(content, &id)?;
|
||||
for path in &m.manifest.scripts {
|
||||
let source = read_package_file(&m.root, Path::new(path))?;
|
||||
let source = std::str::from_utf8(&source)
|
||||
.with_context(|| format!("{id}/{path}: source must be UTF-8"))?;
|
||||
scripts.add(&id, path, &source)?;
|
||||
}
|
||||
pins.push(ModPin {
|
||||
id: id.clone(),
|
||||
version: m.manifest.version.clone(),
|
||||
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))?;
|
||||
let palette = catalog
|
||||
.map(|cat| cat.palette())
|
||||
.transpose()?
|
||||
.unwrap_or([[0; 3]; 256]);
|
||||
let sprite = crate::assets::Sprite::png(&path, &palette)?;
|
||||
ensure!(
|
||||
sprite.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,
|
||||
"{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,
|
||||
"{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(())
|
||||
};
|
||||
for (id, owner) in ®istry.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
|
||||
.get(owner)
|
||||
.context("missing sound root")?;
|
||||
let bytes = read_package_file(root, Path::new(&sound.file))?;
|
||||
crate::sound::decode(&bytes)
|
||||
.with_context(|| format!("{owner}/{}: invalid sound", sound.file))?;
|
||||
}
|
||||
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 {
|
||||
ensure!(
|
||||
scripts.has(owner, path),
|
||||
"{owner}: script {path} must appear in manifest.scripts"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
for (id, owner) in ®istry.owners {
|
||||
if let Some(w) = registry.weapons.get(id) {
|
||||
validate_script(owner, &w.script)?;
|
||||
}
|
||||
if let Some(e) = registry.enemies.get(id) {
|
||||
validate_script(owner, &e.script)?;
|
||||
}
|
||||
if let Some(i) = registry.items.get(id) {
|
||||
validate_script(owner, &i.script)?;
|
||||
}
|
||||
if let Some(m) = registry.missions.get(id) {
|
||||
validate_script(owner, &m.script)?;
|
||||
}
|
||||
}
|
||||
for id in &ordered {
|
||||
ensure!(
|
||||
load_package(&discovery.mods[id].root)?.hash == discovery.mods[id].hash,
|
||||
"{id}: package changed while loading; rescan the Mods screen before applying"
|
||||
);
|
||||
}
|
||||
Ok(Self {
|
||||
registry,
|
||||
pins,
|
||||
scripts,
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Command {
|
||||
/// Add an enemy at an integer position in the original logical canvas.
|
||||
Spawn {
|
||||
enemy: String,
|
||||
x: i32,
|
||||
y: i32,
|
||||
},
|
||||
Projectile {
|
||||
weapon: String,
|
||||
x: i32,
|
||||
y: i32,
|
||||
vx: i32,
|
||||
vy: i32,
|
||||
#[serde(default)]
|
||||
hostile: bool,
|
||||
},
|
||||
Damage {
|
||||
/// Enemy entity ID; zero addresses the player.
|
||||
entity: u64,
|
||||
amount: i32,
|
||||
},
|
||||
Despawn {
|
||||
entity: u64,
|
||||
},
|
||||
Move {
|
||||
/// Existing enemy entity ID.
|
||||
entity: u64,
|
||||
x: i32,
|
||||
y: i32,
|
||||
},
|
||||
Velocity {
|
||||
entity: u64,
|
||||
vx: i32,
|
||||
vy: i32,
|
||||
},
|
||||
GiveItem {
|
||||
item: String,
|
||||
count: u32,
|
||||
},
|
||||
ConsumeItem {
|
||||
item: String,
|
||||
count: u32,
|
||||
},
|
||||
Credits {
|
||||
amount: i32,
|
||||
},
|
||||
Shield {
|
||||
amount: i32,
|
||||
},
|
||||
Effect {
|
||||
x: i32,
|
||||
y: i32,
|
||||
},
|
||||
Sound {
|
||||
asset: u32,
|
||||
},
|
||||
PlaySound {
|
||||
sound: String,
|
||||
},
|
||||
Objective {
|
||||
/// Stable namespaced objective key, at most 128 bytes.
|
||||
key: String,
|
||||
value: i32,
|
||||
},
|
||||
Complete,
|
||||
Fail,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct HookResult {
|
||||
/// Transactional commands, applied in order only when the callback succeeds.
|
||||
#[serde(default)]
|
||||
pub commands: Vec<Command>,
|
||||
/// JSON-serializable state shared by callbacks in this script; maximum 64 KiB.
|
||||
#[serde(default)]
|
||||
pub state: serde_json::Value,
|
||||
}
|
||||
pub struct ScriptRuntime {
|
||||
engine: Engine,
|
||||
scripts: BTreeMap<(String, String), AST>,
|
||||
pub states: BTreeMap<String, serde_json::Value>,
|
||||
pub rng: BTreeMap<String, LegacyRng>,
|
||||
budget: std::sync::Arc<std::sync::atomic::AtomicI64>,
|
||||
}
|
||||
impl ScriptRuntime {
|
||||
pub fn new() -> Self {
|
||||
// Rhai's default StandardPackage includes wall-clock timestamps and native sleep.
|
||||
// Use an explicit package allowlist so timing cannot bypass operation limits,
|
||||
// including through dynamically created function pointers.
|
||||
let mut engine = Engine::new_raw();
|
||||
ArithmeticPackage::new().register_into_engine(&mut engine);
|
||||
BasicStringPackage::new().register_into_engine(&mut engine);
|
||||
BasicIteratorPackage::new().register_into_engine(&mut engine);
|
||||
BasicFnPackage::new().register_into_engine(&mut engine);
|
||||
BitFieldPackage::new().register_into_engine(&mut engine);
|
||||
LogicPackage::new().register_into_engine(&mut engine);
|
||||
BasicMathPackage::new().register_into_engine(&mut engine);
|
||||
BasicArrayPackage::new().register_into_engine(&mut engine);
|
||||
BasicMapPackage::new().register_into_engine(&mut engine);
|
||||
MoreStringPackage::new().register_into_engine(&mut engine);
|
||||
engine
|
||||
.set_max_operations(100000)
|
||||
.set_max_call_levels(32)
|
||||
.set_max_expr_depths(64, 32)
|
||||
.set_max_array_size(4096)
|
||||
.set_max_map_size(1024)
|
||||
.set_max_string_size(16384)
|
||||
.set_max_modules(0);
|
||||
engine.disable_symbol("eval");
|
||||
engine.disable_symbol("import");
|
||||
engine.on_print(|_| {});
|
||||
engine.on_debug(|_, _, _| {});
|
||||
let budget = std::sync::Arc::new(std::sync::atomic::AtomicI64::new(500000));
|
||||
let counter = budget.clone();
|
||||
engine.on_progress(move |_| {
|
||||
if counter.fetch_sub(1, std::sync::atomic::Ordering::Relaxed) <= 0 {
|
||||
Some(Dynamic::UNIT)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
Self {
|
||||
engine,
|
||||
scripts: BTreeMap::new(),
|
||||
states: BTreeMap::new(),
|
||||
rng: BTreeMap::new(),
|
||||
budget,
|
||||
}
|
||||
}
|
||||
pub fn begin_tick(&self) {
|
||||
self.budget
|
||||
.store(500000, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
pub fn add(&mut self, owner: &str, path: &str, source: &str) -> Result<()> {
|
||||
ensure!(
|
||||
source.len() <= 1024 * 1024,
|
||||
"{owner}/{path}: script source exceeds 1 MiB"
|
||||
);
|
||||
let mut ast = self
|
||||
.engine
|
||||
.compile(source)
|
||||
.map_err(|e| anyhow::anyhow!("{owner}/{path}: {e}"))?;
|
||||
for function in ast.iter_functions() {
|
||||
if [
|
||||
"on_spawn",
|
||||
"on_tick",
|
||||
"on_fire",
|
||||
"on_hit",
|
||||
"on_death",
|
||||
"on_pickup",
|
||||
"on_use",
|
||||
"on_mission_start",
|
||||
"on_mission_end",
|
||||
]
|
||||
.contains(&function.name)
|
||||
{
|
||||
ensure!(
|
||||
function.params.len() == 3,
|
||||
"{owner}/{path}::{}: callbacks require (event, state, random)",
|
||||
function.name
|
||||
);
|
||||
}
|
||||
}
|
||||
ast.set_source(format!("{owner}/{path}"));
|
||||
self.scripts.insert((owner.into(), path.into()), ast);
|
||||
Ok(())
|
||||
}
|
||||
pub fn has(&self, owner: &str, path: &str) -> bool {
|
||||
self.scripts.contains_key(&(owner.into(), path.into()))
|
||||
}
|
||||
pub fn invoke(
|
||||
&mut self,
|
||||
owner: &str,
|
||||
path: &str,
|
||||
hook: &str,
|
||||
event: &serde_json::Value,
|
||||
seed: i32,
|
||||
) -> Result<Vec<Command>> {
|
||||
let ast = self
|
||||
.scripts
|
||||
.get(&(owner.into(), path.into()))
|
||||
.with_context(|| format!("{owner}/{path}::{hook}: script is not loaded"))?;
|
||||
if !ast
|
||||
.iter_functions()
|
||||
.any(|f| f.name == hook && f.params.len() == 3)
|
||||
{
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let key = format!("{owner}/{path}");
|
||||
let mut rng = *self.rng.get(&key).unwrap_or(&LegacyRng::new(seed));
|
||||
let random: Vec<i32> = (0..32).map(|_| rng.next_value()).collect();
|
||||
let state = self
|
||||
.states
|
||||
.get(&key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
let event = rhai::serde::to_dynamic(event.clone())?;
|
||||
let state = rhai::serde::to_dynamic(state)?;
|
||||
let random = rhai::serde::to_dynamic(random)?;
|
||||
let value = self
|
||||
.engine
|
||||
.call_fn::<Dynamic>(&mut Scope::new(), ast, hook, (event, state, random))
|
||||
.map_err(|e| anyhow::anyhow!("{owner}/{path}::{hook}: {e}"))?;
|
||||
let result: HookResult = rhai::serde::from_dynamic(&value)
|
||||
.map_err(|e| anyhow::anyhow!("{owner}/{path}::{hook}: invalid result: {e}"))?;
|
||||
ensure!(
|
||||
result.commands.len() <= 256,
|
||||
"{owner}/{path}::{hook}: callback emitted too many commands"
|
||||
);
|
||||
ensure!(
|
||||
serde_json::to_vec(&result.state)?.len() <= 65536,
|
||||
"{owner}/{path}::{hook}: callback state exceeds 64 KiB"
|
||||
);
|
||||
// World command validation is performed by the caller before committing these values.
|
||||
self.states.insert(key.clone(), result.state);
|
||||
self.rng.insert(key, rng);
|
||||
Ok(result.commands)
|
||||
}
|
||||
pub fn checkpoints(
|
||||
&self,
|
||||
) -> (
|
||||
BTreeMap<String, serde_json::Value>,
|
||||
BTreeMap<String, LegacyRng>,
|
||||
) {
|
||||
(self.states.clone(), self.rng.clone())
|
||||
}
|
||||
pub fn restore(
|
||||
&mut self,
|
||||
c: (
|
||||
BTreeMap<String, serde_json::Value>,
|
||||
BTreeMap<String, LegacyRng>,
|
||||
),
|
||||
) {
|
||||
self.states = c.0;
|
||||
self.rng = c.1;
|
||||
}
|
||||
}
|
||||
impl Default for ScriptRuntime {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
pub fn write_schemas(root: &Path) -> Result<()> {
|
||||
fs::create_dir_all(root)?;
|
||||
macro_rules! schema {
|
||||
($name:expr,$ty:ty) => {
|
||||
crate::persistence::atomic_write(
|
||||
&root.join($name),
|
||||
&serde_json::to_vec_pretty(&schemars::schema_for!($ty))?,
|
||||
)?;
|
||||
};
|
||||
}
|
||||
schema!("mod-v1.json", Manifest);
|
||||
schema!("content-v1.json", ContentPack);
|
||||
schema!("hook-v1.json", HookResult);
|
||||
schema!("weapon-projectile-v1.json", crate::content::WeaponDef);
|
||||
schema!("item-v1.json", crate::content::ItemDef);
|
||||
schema!("enemy-v1.json", crate::content::EnemyDef);
|
||||
schema!("mission-v1.json", crate::content::MissionDef);
|
||||
schema!("campaign-v1.json", crate::content::CampaignDef);
|
||||
schema!("sound-v1.json", crate::content::SoundDef);
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn bounded_script_and_state_rollback() {
|
||||
let mut rt = ScriptRuntime::new();
|
||||
rt.add("test", "bad.rhai", "fn on_tick(e,s,r) { while true {} }")
|
||||
.unwrap();
|
||||
assert!(
|
||||
rt.invoke("test", "bad.rhai", "on_tick", &serde_json::json!({}), 1)
|
||||
.is_err()
|
||||
);
|
||||
assert!(rt.states.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn script_can_emit_commands() {
|
||||
let mut rt = ScriptRuntime::new();
|
||||
rt.add("test","good.rhai","fn on_fire(e,s,r) { #{ commands: [#{ op: \"credits\", amount: 7 }], state: #{ calls: 1 } } }").unwrap();
|
||||
assert_eq!(
|
||||
rt.invoke("test", "good.rhai", "on_fire", &serde_json::json!({}), 1)
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(rt.states["test/good.rhai"]["calls"], 1);
|
||||
}
|
||||
#[test]
|
||||
fn io_is_not_exposed() {
|
||||
let mut rt = ScriptRuntime::new();
|
||||
rt.add(
|
||||
"test",
|
||||
"io.rhai",
|
||||
"fn on_tick(e,s,r){ read_file(\"/etc/passwd\") }",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
rt.invoke("test", "io.rhai", "on_tick", &serde_json::json!({}), 1)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
use crate::{
|
||||
content::{ContentId, Registry},
|
||||
modding::ModPin,
|
||||
};
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs::{self, OpenOptions},
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Paths {
|
||||
pub data: PathBuf,
|
||||
pub config: PathBuf,
|
||||
pub cache: PathBuf,
|
||||
pub legacy: PathBuf,
|
||||
}
|
||||
impl Paths {
|
||||
pub fn discover() -> Result<Self> {
|
||||
let home = PathBuf::from(std::env::var_os("HOME").context("HOME is unset")?);
|
||||
let dir = |name: &str, fallback: &str| {
|
||||
std::env::var_os(name)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| home.join(fallback))
|
||||
};
|
||||
Ok(Self {
|
||||
data: dir("XDG_DATA_HOME", ".local/share").join("RaptorReplica"),
|
||||
config: dir("XDG_CONFIG_HOME", ".config").join("RaptorReplica"),
|
||||
cache: dir("XDG_CACHE_HOME", ".cache").join("RaptorReplica"),
|
||||
legacy: home.join(".local/share/Raptor"),
|
||||
})
|
||||
}
|
||||
pub fn mods(&self) -> PathBuf {
|
||||
self.data.join("mods")
|
||||
}
|
||||
pub fn save_path(&self, slot: usize) -> PathBuf {
|
||||
self.data.join(format!("pilot-{slot:04}.json"))
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
pub hd_mode: bool,
|
||||
pub scale: usize,
|
||||
pub scanlines: bool,
|
||||
pub fullscreen: bool,
|
||||
pub borderless: bool,
|
||||
pub aspect_correct: bool,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub music_volume: u8,
|
||||
pub fx_volume: u8,
|
||||
pub fx_channels: usize,
|
||||
pub control: String,
|
||||
pub difficulty: u32,
|
||||
pub bindings: BTreeMap<String, String>,
|
||||
pub enabled_mods: Vec<String>,
|
||||
}
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hd_mode: false,
|
||||
scale: 4,
|
||||
scanlines: false,
|
||||
fullscreen: false,
|
||||
borderless: false,
|
||||
aspect_correct: true,
|
||||
width: 1280,
|
||||
height: 960,
|
||||
music_volume: 85,
|
||||
fx_volume: 85,
|
||||
fx_channels: 2,
|
||||
control: "keyboard".into(),
|
||||
difficulty: 2,
|
||||
bindings: [
|
||||
("up", "Up"),
|
||||
("down", "Down"),
|
||||
("left", "Left"),
|
||||
("right", "Right"),
|
||||
("fire", "Left Ctrl"),
|
||||
("special", "Left Alt"),
|
||||
("cycle", "Space"),
|
||||
("bomb", "Right Shift"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.into(), v.into()))
|
||||
.collect(),
|
||||
enabled_mods: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Config {
|
||||
pub fn load(paths: &Paths) -> Result<Self> {
|
||||
let path = paths.config.join("config.toml");
|
||||
let mut c = if path.exists() {
|
||||
toml::from_str(&fs::read_to_string(path)?)?
|
||||
} else {
|
||||
Self::import_ini(&paths.legacy.join("SETUP.INI"))?
|
||||
};
|
||||
ensure!(
|
||||
matches!(c.scale, 1 | 2 | 3 | 4)
|
||||
&& c.width >= 320
|
||||
&& c.height >= 200
|
||||
&& c.width <= 7680
|
||||
&& c.height <= 4320
|
||||
&& (1..=8).contains(&c.fx_channels)
|
||||
&& c.difficulty <= 3,
|
||||
"invalid video/difficulty settings"
|
||||
);
|
||||
c.music_volume = c.music_volume.min(127);
|
||||
c.fx_volume = c.fx_volume.min(127);
|
||||
Ok(c)
|
||||
}
|
||||
pub fn save(&self, paths: &Paths) -> Result<()> {
|
||||
atomic_write(
|
||||
&paths.config.join("config.toml"),
|
||||
toml::to_string_pretty(self)?.as_bytes(),
|
||||
)
|
||||
}
|
||||
pub fn import_ini(path: &Path) -> Result<Self> {
|
||||
let mut config = Self::default();
|
||||
if !path.exists() {
|
||||
return Ok(config);
|
||||
}
|
||||
let mut section = String::new();
|
||||
for line in fs::read_to_string(path)?.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('[') && line.ends_with(']') {
|
||||
section = line[1..line.len() - 1].to_ascii_lowercase();
|
||||
continue;
|
||||
}
|
||||
let Some((k, v)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let k = k.trim().to_ascii_lowercase();
|
||||
let value = v.trim().parse::<i32>().unwrap_or(0);
|
||||
match (section.as_str(), k.as_str()) {
|
||||
("video", "fullscreen") => config.fullscreen = value != 0,
|
||||
("video", "borderless") => config.borderless = value != 0,
|
||||
("video", "upscaler") => {
|
||||
config.scale = if [2, 3, 4].contains(&value) {
|
||||
value as usize
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
("video", "scanlines") => config.scanlines = value != 0,
|
||||
("video", "aspect_ratio_correct") => config.aspect_correct = value != 0,
|
||||
("music", "volume") => config.music_volume = value.clamp(0, 127) as u8,
|
||||
("soundfx", "volume") => config.fx_volume = value.clamp(0, 127) as u8,
|
||||
("soundfx", "channels") => config.fx_channels = value.clamp(1, 8) as usize,
|
||||
("setup", "control") => {
|
||||
config.control = match value {
|
||||
1 => "mouse",
|
||||
2 => "controller",
|
||||
_ => "keyboard",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
("keyboard", _) => {
|
||||
let action = match k.as_str() {
|
||||
"moveup" => "up",
|
||||
"movedn" => "down",
|
||||
"moveleft" => "left",
|
||||
"moveright" => "right",
|
||||
"fire" => "fire",
|
||||
"firesp" => "special",
|
||||
"changesp" => "cycle",
|
||||
"megafire" => "bomb",
|
||||
_ => continue,
|
||||
};
|
||||
if let Some(key) = legacy_scancode(value) {
|
||||
config.bindings.insert(action.into(), key.into());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
fn legacy_scancode(n: i32) -> Option<&'static str> {
|
||||
Some(match n {
|
||||
72 => "Up",
|
||||
80 => "Down",
|
||||
75 => "Left",
|
||||
77 => "Right",
|
||||
29 => "Left Ctrl",
|
||||
56 => "Left Alt",
|
||||
57 => "Space",
|
||||
54 => "Right Shift",
|
||||
17 => "W",
|
||||
30 => "A",
|
||||
31 => "S",
|
||||
32 => "D",
|
||||
16 => "Q",
|
||||
18 => "E",
|
||||
28 => "Return",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
|
||||
let parent = path.parent().context("output has no parent")?;
|
||||
fs::create_dir_all(parent)?;
|
||||
let mut n = 0u32;
|
||||
let (temp, mut file) = loop {
|
||||
let p = parent.join(format!(
|
||||
".{}.{}.{}.tmp",
|
||||
path.file_name()
|
||||
.context("missing output name")?
|
||||
.to_string_lossy(),
|
||||
std::process::id(),
|
||||
n
|
||||
));
|
||||
match OpenOptions::new().write(true).create_new(true).open(&p) {
|
||||
Ok(f) => break (p, f),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => n += 1,
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
};
|
||||
let result = (|| -> Result<()> {
|
||||
file.write_all(data)?;
|
||||
file.sync_all()?;
|
||||
fs::rename(&temp, path)?;
|
||||
fs::File::open(parent)?.sync_all()?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(temp);
|
||||
}
|
||||
result
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LegacyProgress {
|
||||
pub portrait: i32,
|
||||
pub sector: u32,
|
||||
pub waves: [u32; 3],
|
||||
pub difficulties: [u32; 4],
|
||||
pub training: bool,
|
||||
pub training_completed: bool,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Pilot {
|
||||
pub version: u32,
|
||||
pub name: String,
|
||||
pub callsign: String,
|
||||
pub credits: u32,
|
||||
pub difficulty: u32,
|
||||
pub inventory: BTreeMap<ContentId, u32>,
|
||||
pub selected: Option<ContentId>,
|
||||
pub completed: Vec<ContentId>,
|
||||
pub mission: ContentId,
|
||||
pub mods: Vec<ModPin>,
|
||||
pub script_state: BTreeMap<String, serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub script_rng: BTreeMap<String, crate::simulation::LegacyRng>,
|
||||
#[serde(default)]
|
||||
pub legacy_progress: Option<LegacyProgress>,
|
||||
}
|
||||
impl Default for Pilot {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: 1,
|
||||
name: "Pilot".into(),
|
||||
callsign: "RAPTOR".into(),
|
||||
credits: 0,
|
||||
difficulty: 2,
|
||||
inventory: [("base:forward_guns".into(), 1), ("base:energy".into(), 100)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
selected: None,
|
||||
completed: vec![],
|
||||
mission: "base:sector_1_wave_1".into(),
|
||||
mods: vec![],
|
||||
script_state: BTreeMap::new(),
|
||||
script_rng: BTreeMap::new(),
|
||||
legacy_progress: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Pilot {
|
||||
pub fn complete_mission(&mut self, registry: &Registry) -> Result<()> {
|
||||
let mission = registry
|
||||
.missions
|
||||
.get(&self.mission)
|
||||
.context("completed mission is unavailable")?;
|
||||
if !self.completed.contains(&self.mission) {
|
||||
self.completed.push(self.mission.clone());
|
||||
}
|
||||
let original = self
|
||||
.mission
|
||||
.0
|
||||
.strip_prefix("base:sector_")
|
||||
.and_then(|s| s.split_once("_wave_"))
|
||||
.and_then(|(s, w)| Some((s.parse::<usize>().ok()?, w.parse::<u32>().ok()?)))
|
||||
.filter(|(s, w)| (1..=3).contains(s) && (1..=9).contains(w));
|
||||
if let Some((sector, wave)) = original.filter(|_| mission.legacy_map.is_some()) {
|
||||
let sector = sector - 1;
|
||||
let mut progress = self.legacy_progress.clone().unwrap_or(LegacyProgress {
|
||||
portrait: 0,
|
||||
sector: sector as u32,
|
||||
waves: [0; 3],
|
||||
difficulties: [self.difficulty; 4],
|
||||
training: false,
|
||||
training_completed: false,
|
||||
});
|
||||
progress.difficulties[sector] = self.difficulty;
|
||||
let limit = if self.difficulty == 0 { 4 } else { 9 };
|
||||
if wave >= limit {
|
||||
if self.difficulty == 0 && !progress.training_completed {
|
||||
// Original graduation resets the training ship and awards
|
||||
// three 25-unit shields plus the initial 10,000 credits.
|
||||
self.inventory = [("base:forward_guns".into(), 1), ("base:energy".into(), 75)]
|
||||
.into_iter()
|
||||
.collect();
|
||||
self.selected = None;
|
||||
self.credits = 10000;
|
||||
progress.training_completed = true;
|
||||
for difficulty in &mut progress.difficulties {
|
||||
if *difficulty == 0 {
|
||||
*difficulty = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
progress.difficulties[sector] = (self.difficulty + 1).min(3);
|
||||
progress.waves[sector] = 0;
|
||||
if limit == 4 {
|
||||
progress.waves = [0; 3];
|
||||
progress.sector = 0;
|
||||
} else {
|
||||
progress.sector = ((sector + 1) % 3) as u32;
|
||||
}
|
||||
} else {
|
||||
progress.sector = sector as u32;
|
||||
progress.waves[sector] = wave;
|
||||
}
|
||||
let next_sector = progress.sector as usize;
|
||||
self.difficulty = progress.difficulties[next_sector];
|
||||
self.mission = ContentId(format!(
|
||||
"base:sector_{}_wave_{}",
|
||||
next_sector + 1,
|
||||
progress.waves[next_sector] + 1
|
||||
));
|
||||
self.legacy_progress = Some(progress);
|
||||
} else if let Some(next) = &mission.next {
|
||||
self.mission = next.clone();
|
||||
}
|
||||
self.validate(registry, &self.mods)
|
||||
}
|
||||
pub fn demo(sector: usize, registry: &Registry) -> Result<Self> {
|
||||
let mut p = Self {
|
||||
difficulty: 3,
|
||||
credits: 10000,
|
||||
..Self::default()
|
||||
};
|
||||
p.inventory.insert("base:detect".into(), 1);
|
||||
let equipment: &[(u32, u32)] = match sector {
|
||||
0 => &[(2, 1), (11, 1), (4, 1), (7, 1), (5, 1), (14, 1)],
|
||||
1 => &[(11, 4), (15, 100), (1, 1), (2, 1), (5, 1), (8, 1)],
|
||||
2 => &[(11, 4), (15, 100), (1, 1), (2, 1), (13, 1), (8, 1)],
|
||||
_ => anyhow::bail!("invalid demo sector"),
|
||||
};
|
||||
for (index, count) in equipment {
|
||||
let item = registry
|
||||
.items
|
||||
.values()
|
||||
.find(|i| i.legacy == Some(*index))
|
||||
.context("missing demo item")?;
|
||||
p.inventory.insert(item.id.clone(), *count);
|
||||
}
|
||||
if sector == 1 {
|
||||
p.credits += 0x50003;
|
||||
} else if sector == 2 {
|
||||
p.credits += 0xd5fff;
|
||||
}
|
||||
// DEMO_MakePlayer advances once after OBJS_Add selects the first
|
||||
// special weapon. Match that ordering rather than inventory sorting.
|
||||
p.selected = registry
|
||||
.items
|
||||
.values()
|
||||
.find(|i| i.legacy == Some([5, 8, 8][sector]))
|
||||
.map(|i| i.id.clone());
|
||||
Ok(p)
|
||||
}
|
||||
pub fn save(&self, path: &Path) -> Result<()> {
|
||||
atomic_write(path, &serde_json::to_vec_pretty(self)?)
|
||||
}
|
||||
pub fn load(path: &Path, registry: &Registry, pins: &[ModPin]) -> Result<Self> {
|
||||
let p: Self = serde_json::from_slice(&fs::read(path)?)?;
|
||||
p.validate(registry, pins)?;
|
||||
Ok(p)
|
||||
}
|
||||
pub fn validate(&self, registry: &Registry, pins: &[ModPin]) -> Result<()> {
|
||||
let p = self;
|
||||
ensure!(p.version == 1, "unsupported save version {}", p.version);
|
||||
ensure!(
|
||||
p.difficulty <= 3 && p.inventory.len() <= 20,
|
||||
"invalid pilot difficulty/inventory"
|
||||
);
|
||||
ensure!(
|
||||
p.mods == pins,
|
||||
"save requires these mod versions/content: {:?}; enable the matching mods in the Mods screen",
|
||||
p.mods
|
||||
);
|
||||
ensure!(
|
||||
registry.missions.contains_key(&p.mission),
|
||||
"save mission {} is unavailable",
|
||||
p.mission
|
||||
);
|
||||
for (id, count) in &p.inventory {
|
||||
ensure!(
|
||||
registry.items.contains_key(id),
|
||||
"saved item {id} is unavailable"
|
||||
);
|
||||
ensure!(
|
||||
*count <= registry.items[id].max_count,
|
||||
"saved quantity of {id} exceeds capacity"
|
||||
);
|
||||
}
|
||||
if let Some(id) = &p.selected {
|
||||
ensure!(
|
||||
p.inventory.get(id).is_some_and(|n| *n > 0)
|
||||
&& registry.items.get(id).is_some_and(|i| i.special),
|
||||
"equipped item {id} is unavailable"
|
||||
);
|
||||
}
|
||||
for id in &p.completed {
|
||||
ensure!(
|
||||
registry.missions.contains_key(id),
|
||||
"completed mission {id} is unavailable"
|
||||
);
|
||||
}
|
||||
if let Some(progress) = &p.legacy_progress {
|
||||
ensure!(
|
||||
progress.sector < 3
|
||||
&& progress.waves.iter().all(|w| *w < 9)
|
||||
&& progress.difficulties.iter().all(|d| *d < 4),
|
||||
"invalid saved campaign progression"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
serde_json::to_vec(&p.script_state)?.len() <= 4 * 1024 * 1024,
|
||||
"saved script state exceeds limit"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub fn buy(&mut self, id: &ContentId, registry: &Registry) -> Result<()> {
|
||||
let item = registry.items.get(id).context("unknown shop item")?;
|
||||
ensure!(self.credits >= item.price, "not enough credits");
|
||||
let count = self.inventory.get(id).copied().unwrap_or(0);
|
||||
ensure!(count < item.max_count, "item capacity reached");
|
||||
ensure!(
|
||||
count > 0 || self.inventory.len() < 20,
|
||||
"ship inventory is full"
|
||||
);
|
||||
self.credits -= item.price;
|
||||
self.inventory.insert(
|
||||
id.clone(),
|
||||
count.saturating_add(item.initial_count).min(item.max_count),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub fn sell(&mut self, id: &ContentId, registry: &Registry) -> Result<()> {
|
||||
let item = registry.items.get(id).context("unknown shop item")?;
|
||||
ensure!(
|
||||
id.0 != "base:forward_guns" && id.0 != "base:energy",
|
||||
"essential ship equipment cannot be sold"
|
||||
);
|
||||
let count = self.inventory.get_mut(id).context("item is not owned")?;
|
||||
ensure!(*count > 0, "item is not owned");
|
||||
*count -= 1;
|
||||
self.credits = self.credits.saturating_add(item.price / 2);
|
||||
if *count == 0 {
|
||||
self.inventory.remove(id);
|
||||
if self.selected.as_ref() == Some(id) {
|
||||
self.selected = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn import_legacy(path: &Path, registry: &Registry) -> Result<Self> {
|
||||
let bytes = fs::read(path)?;
|
||||
ensure!(
|
||||
(88..=88 + 20 * 24).contains(&bytes.len()) && (bytes.len() - 88) % 24 == 0,
|
||||
"invalid legacy save length"
|
||||
);
|
||||
let head = decrypt(&bytes[..88], b"CASTLE");
|
||||
let body = decrypt(&bytes[88..], b"CASTLE");
|
||||
let val = |o| crate::assets::i32_at(&head, o);
|
||||
let count = val(60)?;
|
||||
ensure!(
|
||||
count >= 0 && count as usize <= (body.len() / 24),
|
||||
"truncated legacy inventory"
|
||||
);
|
||||
let sector = val(44)?;
|
||||
ensure!((0..3).contains(§or), "invalid legacy sector");
|
||||
let wave = val(48 + sector as usize * 4)?;
|
||||
ensure!((0..9).contains(&wave), "invalid legacy wave");
|
||||
let difficulty = val(64 + sector as usize * 4)?;
|
||||
ensure!((0..4).contains(&difficulty), "invalid legacy difficulty");
|
||||
let waves = [val(48)?, val(52)?, val(56)?];
|
||||
let difficulties = [val(64)?, val(68)?, val(72)?, val(76)?];
|
||||
ensure!(
|
||||
waves.iter().all(|w| (0..9).contains(w)),
|
||||
"invalid legacy campaign progress"
|
||||
);
|
||||
ensure!(
|
||||
difficulties.iter().all(|d| (0..4).contains(d)),
|
||||
"invalid legacy campaign difficulty"
|
||||
);
|
||||
ensure!(
|
||||
[val(80)?, val(84)?].iter().all(|f| *f == 0 || *f == 1),
|
||||
"invalid legacy training flags"
|
||||
);
|
||||
let text = |r: std::ops::Range<usize>| {
|
||||
String::from_utf8_lossy(head[r].split(|b| *b == 0).next().unwrap()).into_owned()
|
||||
};
|
||||
let mut p = Self {
|
||||
name: text(0..20),
|
||||
callsign: text(20..32),
|
||||
credits: val(36)? as u32,
|
||||
difficulty: difficulty as u32,
|
||||
mission: ContentId(format!("base:sector_{}_wave_{}", sector + 1, wave + 1)),
|
||||
inventory: BTreeMap::new(),
|
||||
legacy_progress: Some(LegacyProgress {
|
||||
portrait: val(32)?,
|
||||
sector: sector as u32,
|
||||
waves: waves.map(|w| w as u32),
|
||||
difficulties: difficulties.map(|d| d as u32),
|
||||
training: val(80)? != 0,
|
||||
training_completed: val(84)? != 0,
|
||||
}),
|
||||
..Self::default()
|
||||
};
|
||||
let special = val(40)?;
|
||||
for r in body.chunks_exact(24).take(count as usize) {
|
||||
let count = crate::assets::i32_at(r, 8)?;
|
||||
let typ = crate::assets::i32_at(r, 12)?;
|
||||
ensure!(count >= 0, "negative legacy item count");
|
||||
let item = registry
|
||||
.items
|
||||
.values()
|
||||
.find(|i| i.legacy == Some(typ as u32))
|
||||
.context("unknown legacy item")?;
|
||||
p.inventory.insert(item.id.clone(), count as u32);
|
||||
if typ == special {
|
||||
p.selected = Some(item.id.clone());
|
||||
}
|
||||
}
|
||||
for s in 0..3 {
|
||||
let w = val(48 + s * 4)?;
|
||||
for i in 1..=w {
|
||||
p.completed
|
||||
.push(ContentId(format!("base:sector_{}_wave_{i}", s + 1)));
|
||||
}
|
||||
}
|
||||
p.validate(registry, &[])?;
|
||||
Ok(p)
|
||||
}
|
||||
}
|
||||
fn decrypt(bytes: &[u8], key: &[u8]) -> Vec<u8> {
|
||||
let mut index = 25 % key.len();
|
||||
let mut previous = key[index];
|
||||
bytes
|
||||
.iter()
|
||||
.map(|x| {
|
||||
let clear = x.wrapping_sub(key[index]).wrapping_sub(previous);
|
||||
previous = *x;
|
||||
index = (index + 1) % key.len();
|
||||
clear
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn writes_replace_atomically() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let p = tmp.path().join("save.json");
|
||||
atomic_write(&p, b"first").unwrap();
|
||||
atomic_write(&p, b"second").unwrap();
|
||||
assert_eq!(fs::read(p).unwrap(), b"second");
|
||||
assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 1);
|
||||
}
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
//! Palette-indexed rendering. Filters copy colors, never blend palette entries.
|
||||
use crate::assets::{Sprite, TRANSPARENT};
|
||||
use anyhow::{Result, ensure};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{collections::BTreeMap, fs, path::PathBuf};
|
||||
|
||||
pub fn upscale(sprite: &Sprite, factor: usize) -> Result<Sprite> {
|
||||
ensure!(
|
||||
matches!(factor, 1 | 2 | 3 | 4),
|
||||
"scale must be 1, 2, 3 or 4"
|
||||
);
|
||||
ensure!(
|
||||
sprite.width > 0
|
||||
&& sprite.height > 0
|
||||
&& sprite.pixels.len() == sprite.width * sprite.height,
|
||||
"invalid indexed image"
|
||||
);
|
||||
ensure!(
|
||||
sprite.width as u64 * sprite.height as u64 * (factor * factor) as u64 <= 16_777_216,
|
||||
"derived image exceeds pixel limit"
|
||||
);
|
||||
if factor == 1 {
|
||||
return Ok(sprite.clone());
|
||||
}
|
||||
if factor == 4 {
|
||||
return upscale(&upscale(sprite, 2)?, 2);
|
||||
}
|
||||
let mut out = Sprite::blank(sprite.width * factor, sprite.height * factor);
|
||||
for y in 0..sprite.height {
|
||||
for x in 0..sprite.width {
|
||||
let sample = |dx: isize, dy: isize| {
|
||||
sprite.pixels[(y as isize + dy).clamp(0, sprite.height as isize - 1) as usize
|
||||
* sprite.width
|
||||
+ (x as isize + dx).clamp(0, sprite.width as isize - 1) as usize]
|
||||
};
|
||||
let [a, b, c, d, e, f, g, h, i] = [
|
||||
sample(-1, -1),
|
||||
sample(0, -1),
|
||||
sample(1, -1),
|
||||
sample(-1, 0),
|
||||
sample(0, 0),
|
||||
sample(1, 0),
|
||||
sample(-1, 1),
|
||||
sample(0, 1),
|
||||
sample(1, 1),
|
||||
];
|
||||
let p = if factor == 2 {
|
||||
if b != h && d != f {
|
||||
[
|
||||
if d == b { d } else { e },
|
||||
if b == f { f } else { e },
|
||||
if d == h { d } else { e },
|
||||
if h == f { f } else { e },
|
||||
e,
|
||||
e,
|
||||
e,
|
||||
e,
|
||||
e,
|
||||
]
|
||||
} else {
|
||||
[e; 9]
|
||||
}
|
||||
} else if b != h && d != f {
|
||||
[
|
||||
if d == b { d } else { e },
|
||||
if (d == b && e != c) || (b == f && e != a) {
|
||||
b
|
||||
} else {
|
||||
e
|
||||
},
|
||||
if b == f { f } else { e },
|
||||
if (d == b && e != g) || (d == h && e != a) {
|
||||
d
|
||||
} else {
|
||||
e
|
||||
},
|
||||
e,
|
||||
if (b == f && e != i) || (h == f && e != c) {
|
||||
f
|
||||
} else {
|
||||
e
|
||||
},
|
||||
if d == h { d } else { e },
|
||||
if (d == h && e != i) || (h == f && e != g) {
|
||||
h
|
||||
} else {
|
||||
e
|
||||
},
|
||||
if h == f { f } else { e },
|
||||
]
|
||||
} else {
|
||||
[e; 9]
|
||||
};
|
||||
for dy in 0..factor {
|
||||
for dx in 0..factor {
|
||||
out.pixels[(y * factor + dy) * out.width + x * factor + dx] =
|
||||
p[dy * factor + dx];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
pub fn nearest(sprite: &Sprite, factor: usize) -> Sprite {
|
||||
let mut out = Sprite::blank(sprite.width * factor, sprite.height * factor);
|
||||
for y in 0..out.height {
|
||||
for x in 0..out.width {
|
||||
out.pixels[y * out.width + x] = sprite.pixels[(y / factor) * sprite.width + x / factor];
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub struct SpriteCache {
|
||||
memory: BTreeMap<String, Sprite>,
|
||||
root: Option<PathBuf>,
|
||||
}
|
||||
impl SpriteCache {
|
||||
pub fn new(root: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
memory: BTreeMap::new(),
|
||||
root,
|
||||
}
|
||||
}
|
||||
pub fn get(&mut self, sprite: &Sprite, factor: usize, pixelated: bool) -> Result<&Sprite> {
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(b"raptor-scale-v1");
|
||||
hash.update(sprite.width.to_le_bytes());
|
||||
hash.update(sprite.height.to_le_bytes());
|
||||
hash.update([factor as u8, pixelated as u8]);
|
||||
for p in &sprite.pixels {
|
||||
hash.update(p.to_le_bytes());
|
||||
}
|
||||
let key = format!("{:x}", hash.finalize());
|
||||
if !self.memory.contains_key(&key) {
|
||||
let saved = self
|
||||
.root
|
||||
.as_ref()
|
||||
.and_then(|r| fs::read(r.join(format!("{key}.json"))).ok())
|
||||
.and_then(|b| serde_json::from_slice::<Sprite>(&b).ok())
|
||||
.filter(|s| {
|
||||
s.width == sprite.width * factor
|
||||
&& s.height == sprite.height * factor
|
||||
&& s.pixels.len() == s.width * s.height
|
||||
&& s.pixels.iter().all(|p| *p <= TRANSPARENT)
|
||||
});
|
||||
let scaled = if let Some(s) = saved {
|
||||
s
|
||||
} else if pixelated {
|
||||
nearest(sprite, factor)
|
||||
} else {
|
||||
upscale(sprite, factor)?
|
||||
};
|
||||
if let Some(root) = &self.root {
|
||||
fs::create_dir_all(root)?;
|
||||
if !root.join(format!("{key}.json")).exists() {
|
||||
crate::persistence::atomic_write(
|
||||
&root.join(format!("{key}.json")),
|
||||
&serde_json::to_vec(&scaled)?,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if self
|
||||
.memory
|
||||
.values()
|
||||
.map(|s| s.pixels.len() * 2)
|
||||
.sum::<usize>()
|
||||
+ scaled.pixels.len() * 2
|
||||
> 128 * 1024 * 1024
|
||||
{
|
||||
self.memory.clear();
|
||||
}
|
||||
self.memory.insert(key.clone(), scaled);
|
||||
}
|
||||
Ok(&self.memory[&key])
|
||||
}
|
||||
}
|
||||
pub struct Canvas {
|
||||
pub image: Sprite,
|
||||
pub scale: usize,
|
||||
}
|
||||
impl Canvas {
|
||||
pub fn new(scale: usize) -> Self {
|
||||
Self {
|
||||
image: Sprite {
|
||||
width: 320 * scale,
|
||||
height: 200 * scale,
|
||||
pixels: vec![0; 320 * 200 * scale * scale],
|
||||
},
|
||||
scale,
|
||||
}
|
||||
}
|
||||
pub fn clear(&mut self, color: u16) {
|
||||
self.image.pixels.fill(color)
|
||||
}
|
||||
pub fn blit(&mut self, sprite: &Sprite, x: i32, y: i32) {
|
||||
for sy in 0..sprite.height {
|
||||
let yy = y + sy as i32;
|
||||
if yy < 0 || yy >= self.image.height as i32 {
|
||||
continue;
|
||||
}
|
||||
for sx in 0..sprite.width {
|
||||
let xx = x + sx as i32;
|
||||
if xx < 0 || xx >= self.image.width as i32 {
|
||||
continue;
|
||||
}
|
||||
let c = sprite.pixels[sy * sprite.width + sx];
|
||||
if c != TRANSPARENT {
|
||||
self.image.pixels[yy as usize * self.image.width + xx as usize] = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn rect(&mut self, x: i32, y: i32, w: i32, h: i32, color: u16) {
|
||||
let s = self.scale as i32;
|
||||
for yy in (y * s).max(0)..((y + h) * s).min(self.image.height as i32) {
|
||||
for xx in (x * s).max(0)..((x + w) * s).min(self.image.width as i32) {
|
||||
self.image.pixels[yy as usize * self.image.width + xx as usize] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn shade(&mut self, mask: &Sprite, x: i32, y: i32, table: &[u8; 256]) {
|
||||
for sy in 0..mask.height {
|
||||
let yy = y + sy as i32;
|
||||
if yy < 0 || yy >= self.image.height as i32 {
|
||||
continue;
|
||||
}
|
||||
for sx in 0..mask.width {
|
||||
let xx = x + sx as i32;
|
||||
if xx < 0
|
||||
|| xx >= self.image.width as i32
|
||||
|| mask.pixels[sy * mask.width + sx] == TRANSPARENT
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let p = &mut self.image.pixels[yy as usize * self.image.width + xx as usize];
|
||||
if *p < 256 {
|
||||
*p = table[*p as usize] as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn rgba(&self, palette: &[[u8; 3]; 256], scanlines: bool) -> Vec<u8> {
|
||||
let mut out = vec![0; self.image.pixels.len() * 4];
|
||||
for (i, p) in self.image.pixels.iter().enumerate() {
|
||||
let color = palette[(*p).min(255) as usize];
|
||||
let dark = scanlines && (i / self.image.width) % (self.scale * 3) == self.scale * 2;
|
||||
for j in 0..3 {
|
||||
out[i * 4 + j] = if dark { color[j] / 2 } else { color[j] };
|
||||
}
|
||||
out[i * 4 + 3] = 255;
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn uniform_image_unchanged() {
|
||||
let s = Sprite {
|
||||
width: 2,
|
||||
height: 2,
|
||||
pixels: vec![9; 4],
|
||||
};
|
||||
for n in [2, 3, 4] {
|
||||
assert!(upscale(&s, n).unwrap().pixels.iter().all(|p| *p == 9));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn scale_four_is_two_passes() {
|
||||
let s = Sprite {
|
||||
width: 3,
|
||||
height: 3,
|
||||
pixels: vec![0, 1, 0, 1, 1, 0, 0, 0, TRANSPARENT],
|
||||
};
|
||||
assert_eq!(
|
||||
upscale(&s, 4).unwrap(),
|
||||
upscale(&upscale(&s, 2).unwrap(), 2).unwrap()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn scale_three_guard_preserves_cross() {
|
||||
let s = Sprite {
|
||||
width: 3,
|
||||
height: 3,
|
||||
pixels: vec![1, 2, 3, 4, 5, 4, 6, 2, 7],
|
||||
};
|
||||
let out = upscale(&s, 3).unwrap();
|
||||
for y in 3..6 {
|
||||
for x in 3..6 {
|
||||
assert_eq!(out.pixels[y * 9 + x], 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn clipped_transparency() {
|
||||
let mut c = Canvas::new(1);
|
||||
c.clear(3);
|
||||
c.blit(
|
||||
&Sprite {
|
||||
width: 2,
|
||||
height: 1,
|
||||
pixels: vec![8, TRANSPARENT],
|
||||
},
|
||||
-1,
|
||||
0,
|
||||
);
|
||||
assert_eq!(c.image.pixels[0], 3);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BitmapFont {
|
||||
height: usize,
|
||||
glyphs: Vec<Option<Sprite>>,
|
||||
widths: Vec<usize>,
|
||||
}
|
||||
impl BitmapFont {
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self> {
|
||||
let height = crate::assets::i32_at(bytes, 0)?;
|
||||
ensure!(
|
||||
(1..=64).contains(&height) && bytes.len() >= 772,
|
||||
"invalid bitmap font"
|
||||
);
|
||||
let mut glyphs = Vec::new();
|
||||
let mut widths = Vec::new();
|
||||
for ch in 0..256 {
|
||||
let offset = crate::assets::i16_at(bytes, 4 + ch * 2)?;
|
||||
let width = bytes[516 + ch] as usize;
|
||||
widths.push(width);
|
||||
if offset < 0 || width == 0 {
|
||||
glyphs.push(None);
|
||||
continue;
|
||||
}
|
||||
let start = 772 + offset as usize;
|
||||
let len = width * height as usize;
|
||||
let data = bytes
|
||||
.get(start..start + len)
|
||||
.ok_or_else(|| anyhow::anyhow!("truncated font glyph {ch}"))?;
|
||||
glyphs.push(Some(Sprite {
|
||||
width,
|
||||
height: height as usize,
|
||||
pixels: data
|
||||
.iter()
|
||||
.map(|v| if *v == 0 { TRANSPARENT } else { *v as u16 })
|
||||
.collect(),
|
||||
}));
|
||||
}
|
||||
Ok(Self {
|
||||
height: height as usize,
|
||||
glyphs,
|
||||
widths,
|
||||
})
|
||||
}
|
||||
pub fn text(&self, canvas: &mut Canvas, x: i32, y: i32, text: &str, color: u16) {
|
||||
let mut cursor = x;
|
||||
for ch in text.bytes() {
|
||||
if let Some(g) = &self.glyphs[ch as usize] {
|
||||
let mut glyph = g.clone();
|
||||
for p in &mut glyph.pixels {
|
||||
if *p != TRANSPARENT {
|
||||
*p = (*p + color.saturating_sub(1)).min(255);
|
||||
}
|
||||
}
|
||||
canvas.blit(
|
||||
&nearest(&glyph, canvas.scale),
|
||||
cursor * canvas.scale as i32,
|
||||
y * canvas.scale as i32,
|
||||
);
|
||||
}
|
||||
cursor += self.widths[ch as usize] as i32 + 1;
|
||||
if cursor > 314 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn height(&self) -> usize {
|
||||
self.height
|
||||
}
|
||||
}
|
||||
+2352
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,96 @@
|
||||
//! Checked package-local PCM WAV loading, independent of SDL2.
|
||||
//! RIFF chunks are little-endian and padded to a two-byte boundary.
|
||||
use crate::audio::DigitalSample;
|
||||
use anyhow::{Context, Result, ensure};
|
||||
|
||||
pub fn decode(bytes: &[u8]) -> Result<DigitalSample> {
|
||||
ensure!(bytes.len() <= 8 * 1024 * 1024, "sound exceeds 8 MiB");
|
||||
if !bytes.starts_with(b"RIFF") {
|
||||
return DigitalSample::decode(bytes);
|
||||
}
|
||||
let u32_at = |offset| -> Result<u32> {
|
||||
Ok(u32::from_le_bytes(
|
||||
bytes
|
||||
.get(offset..offset + 4)
|
||||
.context("truncated WAV field")?
|
||||
.try_into()?,
|
||||
))
|
||||
};
|
||||
ensure!(
|
||||
bytes.get(8..12) == Some(b"WAVE") && u32_at(4)? as usize == bytes.len().saturating_sub(8),
|
||||
"invalid RIFF/WAVE length"
|
||||
);
|
||||
let mut format = None;
|
||||
let mut audio = None;
|
||||
let mut offset = 12;
|
||||
while offset < bytes.len() {
|
||||
let tag = bytes
|
||||
.get(offset..offset + 4)
|
||||
.context("truncated WAV chunk")?;
|
||||
let length = u32_at(offset + 4)? as usize;
|
||||
let start = offset + 8;
|
||||
let end = start
|
||||
.checked_add(length)
|
||||
.context("WAV chunk size overflow")?;
|
||||
let chunk = bytes
|
||||
.get(start..end)
|
||||
.context("truncated WAV chunk payload")?;
|
||||
match tag {
|
||||
b"fmt " => {
|
||||
ensure!(
|
||||
format.is_none() && chunk.len() >= 16,
|
||||
"duplicate/truncated WAV format"
|
||||
);
|
||||
let u16_at = |n| u16::from_le_bytes([chunk[n], chunk[n + 1]]);
|
||||
let rate = u32::from_le_bytes(chunk[4..8].try_into()?);
|
||||
let channels = u16_at(2) as usize;
|
||||
let bits = u16_at(14) as usize;
|
||||
ensure!(
|
||||
u16_at(0) == 1
|
||||
&& matches!(channels, 1 | 2)
|
||||
&& matches!(bits, 8 | 16)
|
||||
&& (8000..=48000).contains(&rate),
|
||||
"sound must be 8/16-bit PCM WAV, mono/stereo, 8–48 kHz"
|
||||
);
|
||||
let align = channels * bits / 8;
|
||||
ensure!(
|
||||
u16_at(12) as usize == align
|
||||
&& u32::from_le_bytes(chunk[8..12].try_into()?) == rate * align as u32,
|
||||
"invalid WAV block/byte rate"
|
||||
);
|
||||
format = Some((rate, channels, bits, align));
|
||||
}
|
||||
b"data" => {
|
||||
ensure!(audio.is_none(), "duplicate WAV data");
|
||||
audio = Some(chunk);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
offset = end
|
||||
.checked_add(length & 1)
|
||||
.context("WAV padding overflow")?;
|
||||
ensure!(offset <= bytes.len(), "truncated WAV padding");
|
||||
}
|
||||
let (rate, channels, bits, align) = format.context("WAV has no format")?;
|
||||
let audio = audio.context("WAV has no audio data")?;
|
||||
ensure!(
|
||||
!audio.is_empty() && audio.len() % align == 0 && audio.len() / align <= rate as usize * 30,
|
||||
"invalid WAV sample count (maximum 30 seconds)"
|
||||
);
|
||||
let data = audio
|
||||
.chunks_exact(align)
|
||||
.map(|frame| {
|
||||
let total: i32 = (0..channels)
|
||||
.map(|channel| {
|
||||
if bits == 8 {
|
||||
(frame[channel] as i32 - 128) * 256
|
||||
} else {
|
||||
i16::from_le_bytes([frame[channel * 2], frame[channel * 2 + 1]]) as i32
|
||||
}
|
||||
})
|
||||
.sum();
|
||||
((total / channels as i32) / 256 + 128).clamp(0, 255) as u8
|
||||
})
|
||||
.collect();
|
||||
Ok(DigitalSample { rate, data })
|
||||
}
|
||||
Reference in new issue
Block a user