Make RustRaptor the repository root and track its playable bundle
This commit is contained in:
1 parent
4b1edefeec
commit
8f459796b1
9860 files changed
+21084
-61019
No files matched your search
+470
@@ -0,0 +1,470 @@
|
||||
//! 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);
|
||||
// Plain mod PNGs carry no palette-animation metadata. Keep their colors
|
||||
// out of the reserved water/fire/blink entries (240..=255), which change
|
||||
// during gameplay. Cache repeated RGB values; alpha is handled separately.
|
||||
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[..240]
|
||||
.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());
|
||||
}
|
||||
#[test]
|
||||
fn ordinary_mod_png_does_not_acquire_animated_palette_indices() {
|
||||
let mut palette = [[0; 3]; 256];
|
||||
palette[238] = [3, 5, 7];
|
||||
palette[239] = [250, 235, 250];
|
||||
for (offset, color) in palette[240..].iter_mut().enumerate() {
|
||||
*color = [255, 240 + offset as u8, 255];
|
||||
}
|
||||
let mut input = image::RgbaImage::new(19, 1);
|
||||
for offset in 0..16 {
|
||||
let color = palette[240 + offset];
|
||||
input.put_pixel(
|
||||
offset as u32,
|
||||
0,
|
||||
image::Rgba([color[0], color[1], color[2], 255]),
|
||||
);
|
||||
}
|
||||
input.put_pixel(16, 0, image::Rgba([3, 5, 7, 255]));
|
||||
input.put_pixel(17, 0, image::Rgba([255, 255, 255, 127]));
|
||||
input.put_pixel(18, 0, image::Rgba([255, 240, 255, 128]));
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("ordinary-mod.png");
|
||||
input.save(&path).unwrap();
|
||||
|
||||
let sprite = Sprite::png(&path, &palette).unwrap();
|
||||
assert_eq!(&sprite.pixels[..16], &[239; 16]);
|
||||
assert_eq!(sprite.pixels[16], 238);
|
||||
assert_eq!(sprite.pixels[17], TRANSPARENT);
|
||||
assert_eq!(sprite.pixels[18], 239);
|
||||
let visible_colors = |active: &[[u8; 3]; 256]| {
|
||||
sprite
|
||||
.pixels
|
||||
.iter()
|
||||
.filter(|&&index| index != TRANSPARENT)
|
||||
.map(|&index| active[index as usize])
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let original_colors = visible_colors(&palette);
|
||||
palette[240..].fill([20, 40, 60]);
|
||||
assert_eq!(visible_colors(&palette), original_colors);
|
||||
}
|
||||
}
|
||||
+1151
File diff suppressed because it is too large.
Load diff
@@ -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,784 @@
|
||||
//! 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},
|
||||
simulation::{LegacyRng, SoundEvent},
|
||||
sound::SoundBank,
|
||||
};
|
||||
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,
|
||||
OpeningCredits,
|
||||
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>,
|
||||
picture_palette: Option<&'static str>,
|
||||
discard_last_frame: bool,
|
||||
}
|
||||
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,
|
||||
picture_palette: None,
|
||||
discard_last_frame: false,
|
||||
}
|
||||
}
|
||||
fn movie_clip(name: &'static str, count: u32, frame_rate: u32, repeats: u32) -> ClipSpec {
|
||||
// Native GFX_WaitUpdate takes frames per second, then waits integer
|
||||
// 70 / frame_rate service ticks. It does not take a dwell in ticks.
|
||||
clip(name, count, 70 / frame_rate.clamp(1, 70), repeats)
|
||||
}
|
||||
fn specs(kind: Kind) -> Vec<ClipSpec> {
|
||||
match kind {
|
||||
Kind::OpeningCredits => {
|
||||
let mut apogee = clip("APOGEE_PIC", 1, 120, 1);
|
||||
apogee.fade_in = 64;
|
||||
apogee.fade_out = 63;
|
||||
apogee.picture_palette = Some("POGPAL_DAT");
|
||||
let mut cygnus = clip("CYGNUS_PIC", 1, 195, 1);
|
||||
cygnus.fade_in = 64;
|
||||
cygnus.fade_out = 63;
|
||||
cygnus.picture_palette = Some("CYGPAL_DAT");
|
||||
vec![apogee, cygnus]
|
||||
}
|
||||
Kind::Intro => {
|
||||
let mut city = movie_clip("CHASE_AGX", 30, 8, 1);
|
||||
city.fade_in = 128;
|
||||
let mut side = movie_clip("SHIPSD1_AGX", 20, 18, 2);
|
||||
side.background = Some("FX_JETSND");
|
||||
let mut pilot = movie_clip("PILOT_AGX", 21, 10, 1);
|
||||
pilot.background = Some("FX_IJETSND");
|
||||
let mut side_before_fire = side.clone();
|
||||
side_before_fire.repeats = 1;
|
||||
// MOVIE_Play stops the prelude's background patch. The firing
|
||||
// half of INTRO_Side2 does not start that patch again.
|
||||
let firing = movie_clip("SHIPSD2_AGX", 20, 18, 1);
|
||||
let mut explosion = movie_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 = movie_clip("LANDING_AGX", 33, 10, 1);
|
||||
landing.fade_out = 64;
|
||||
// The native last FRAME starts with M_FADEOUT, which fades the
|
||||
// previously displayed frame and never renders its own delta.
|
||||
landing.discard_last_frame = true;
|
||||
vec![landing]
|
||||
}
|
||||
Kind::Death => {
|
||||
let mut ground = movie_clip("SDEATH_AGX", 6, 3, 8);
|
||||
ground.fade_out = 100;
|
||||
vec![movie_clip("DOWN_AGX", 30, 11, 1), ground]
|
||||
}
|
||||
Kind::CampaignEnd(sector) => {
|
||||
let mut ending = match sector {
|
||||
0 => movie_clip("GAME1END_AGX", 5, 4, 8),
|
||||
1 => movie_clip("GAME2END_AGX", 25, 4, 1),
|
||||
_ => movie_clip("GAME3END_AGX", 39, 8, 1),
|
||||
};
|
||||
ending.fade_out = 120;
|
||||
if sector >= 2 {
|
||||
ending.background = Some("FX_JETSND");
|
||||
}
|
||||
let mut base = movie_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)>,
|
||||
palette: Option<[[u8; 3]; 256]>,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum Phase {
|
||||
FadeIn(u32),
|
||||
Frame(u32),
|
||||
FadeOut(u32),
|
||||
Finished,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CinematicAudioEvent {
|
||||
Play(SoundEvent),
|
||||
Stop(u32),
|
||||
}
|
||||
pub struct Cinematic {
|
||||
pub image: Sprite,
|
||||
/// Ordered patches and stops; draining two separate queues would reorder
|
||||
/// an outgoing clip's stop after the next clip starts the same patch.
|
||||
pub audio_events: Vec<CinematicAudioEvent>,
|
||||
pub music_events: Vec<(&'static str, bool)>,
|
||||
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;
|
||||
let mut sound_bank = SoundBank::default();
|
||||
for mut spec in specs(kind) {
|
||||
let start = catalog.id(spec.name)?;
|
||||
let mut frames = Vec::new();
|
||||
let palette = if let Some(name) = spec.picture_palette {
|
||||
let picture = Sprite::decode(&catalog.named(spec.name)?)?;
|
||||
ensure!(
|
||||
picture.width == 320 && picture.height == 200,
|
||||
"opening logo requires 320x200 canvas"
|
||||
);
|
||||
let pixels = picture
|
||||
.pixels
|
||||
.into_iter()
|
||||
.map(|p| p.min(255) as u8)
|
||||
.collect();
|
||||
frames.push(DeltaFrame {
|
||||
fill: Some(0),
|
||||
spans: vec![(0, pixels)],
|
||||
});
|
||||
if spec.name == "APOGEE_PIC" {
|
||||
let score = crate::audio::MusScore::decode(&catalog.named("APOGEE_MUS")?)?;
|
||||
spec.dwell = score
|
||||
.groups
|
||||
.last()
|
||||
.map_or(120, |(tick, _)| (*tick + 1).max(120));
|
||||
}
|
||||
Some(crate::assets::decode_palette(&catalog.named(name)?)?)
|
||||
} else {
|
||||
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))?,
|
||||
);
|
||||
}
|
||||
None
|
||||
};
|
||||
if spec.discard_last_frame {
|
||||
frames.pop();
|
||||
}
|
||||
let background = if let Some(name) = spec.background {
|
||||
let event = sound(name, 127, &mut LegacyRng::new(0))
|
||||
.with_context(|| format!("unknown cinematic background sound {name}"))?;
|
||||
// FX IDs are zero-byte catalog markers; the sound bank
|
||||
// resolves their +4 digital device variants.
|
||||
let sample = sound_bank.original(catalog, event.asset)?;
|
||||
let ticks = sample.duration_ticks(event.pitch, 70).max(1);
|
||||
Some((event, ticks))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
clips.push(Clip {
|
||||
spec,
|
||||
frames,
|
||||
background,
|
||||
palette,
|
||||
});
|
||||
}
|
||||
let mut result = Self {
|
||||
image: Sprite::blank(320, 200),
|
||||
audio_events: Vec::new(),
|
||||
music_events: 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)
|
||||
};
|
||||
if let Some((event, period)) = &self.clips[self.current].background {
|
||||
self.audio_events
|
||||
.push(CinematicAudioEvent::Play(event.clone()));
|
||||
self.background_at = self.ticks + period;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn end_clip(&mut self) {
|
||||
let clip = &self.clips[self.current];
|
||||
if let Some((event, _)) = &clip.background {
|
||||
self.audio_events
|
||||
.push(CinematicAudioEvent::Stop(event.asset));
|
||||
}
|
||||
match clip.spec.name {
|
||||
"CHASE_AGX" => self.audio_events.push(CinematicAudioEvent::Stop(129)),
|
||||
"SHIPSD2_AGX" => self.audio_events.push(CinematicAudioEvent::Stop(144)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
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", frame) if (2..10).contains(&frame) => 110 + self.rng.next_value() % 40,
|
||||
_ => 127,
|
||||
};
|
||||
if let Some(event) = sound_name.and_then(|name| sound(name, pan, &mut self.rng)) {
|
||||
self.audio_events.push(CinematicAudioEvent::Play(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.audio_events
|
||||
.push(CinematicAudioEvent::Play(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(_) => {
|
||||
let spec = &self.clips[self.current].spec;
|
||||
if spec.name == "APOGEE_PIC" {
|
||||
self.music_events.push(("APOGEE_MUS", false));
|
||||
}
|
||||
self.phase = Phase::Frame(spec.dwell);
|
||||
}
|
||||
Phase::Frame(n) if n > 1 => {
|
||||
let spec = &self.clips[self.current].spec;
|
||||
if spec.name == "CYGNUS_PIC" {
|
||||
let elapsed = spec.dwell - n + 1;
|
||||
if elapsed == 3 || elapsed == 120 {
|
||||
if let Some(event) = sound("FX_BOSS1", 127, &mut self.rng) {
|
||||
self.audio_events.push(CinematicAudioEvent::Play(event));
|
||||
}
|
||||
}
|
||||
if elapsed == 135 {
|
||||
self.music_events.push(("RINTRO_MUS", true));
|
||||
}
|
||||
}
|
||||
self.phase = Phase::Frame(n - 1);
|
||||
}
|
||||
Phase::Frame(_) => {
|
||||
// MOVIE_Play dispatches a frame's patch after its timed
|
||||
// display, before processing the frame's final fade.
|
||||
self.frame_sound();
|
||||
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.end_clip();
|
||||
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);
|
||||
}
|
||||
Phase::FadeOut(n) if n > 1 => self.phase = Phase::FadeOut(n - 1),
|
||||
Phase::FadeOut(_) => {
|
||||
self.end_clip();
|
||||
self.current += 1;
|
||||
self.begin()?;
|
||||
}
|
||||
Phase::Finished => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn finished(&self) -> bool {
|
||||
matches!(self.phase, Phase::Finished)
|
||||
}
|
||||
/// Active standalone picture and its source palette, before any fade.
|
||||
/// AGX delta films retain their original indexed frame composition.
|
||||
pub fn still_picture(&self) -> Option<(&'static str, &[[u8; 3]; 256])> {
|
||||
let clip = self.clips.get(self.current)?;
|
||||
Some((clip.spec.name, clip.palette.as_ref()?))
|
||||
}
|
||||
pub fn palette(&self, base: &[[u8; 3]; 256]) -> [[u8; 3]; 256] {
|
||||
let base = self
|
||||
.clips
|
||||
.get(self.current)
|
||||
.and_then(|clip| clip.palette.as_ref())
|
||||
.unwrap_or(base);
|
||||
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 synthetic_player(specs: Vec<ClipSpec>) -> Cinematic {
|
||||
let clips = specs
|
||||
.into_iter()
|
||||
.map(|spec| {
|
||||
let count = spec.count - u32::from(spec.discard_last_frame);
|
||||
let frames = (0..count)
|
||||
.map(|frame| {
|
||||
DeltaFrame::decode(&delta(frame == 0, 0, &[(frame + 1) as u8]), frame == 0)
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
let palette = spec.picture_palette.map(|_| [[80, 120, 200]; 256]);
|
||||
Clip {
|
||||
spec,
|
||||
frames,
|
||||
background: None,
|
||||
palette,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut player = Cinematic {
|
||||
image: Sprite::blank(320, 200),
|
||||
audio_events: vec![],
|
||||
music_events: vec![],
|
||||
clips,
|
||||
current: 0,
|
||||
frame: 0,
|
||||
repetition: 0,
|
||||
phase: Phase::Finished,
|
||||
clock: 0,
|
||||
ticks: 0,
|
||||
background_at: 0,
|
||||
rng: LegacyRng::new(0),
|
||||
};
|
||||
player.begin().unwrap();
|
||||
player
|
||||
}
|
||||
fn drain_patches(player: &mut Cinematic) -> impl Iterator<Item = SoundEvent> + use<> {
|
||||
std::mem::take(&mut player.audio_events)
|
||||
.into_iter()
|
||||
.filter_map(|event| match event {
|
||||
CinematicAudioEvent::Play(event) => Some(event),
|
||||
CinematicAudioEvent::Stop(_) => None,
|
||||
})
|
||||
}
|
||||
fn tick(player: &mut Cinematic) {
|
||||
// Round upward by less than a nanosecond so each call services one
|
||||
// 70 Hz tick, without losing a tick to integer division.
|
||||
player.advance(1_000_000_000_u64.div_ceil(70)).unwrap();
|
||||
}
|
||||
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),
|
||||
audio_events: vec![],
|
||||
music_events: vec![],
|
||||
clips: vec![Clip {
|
||||
spec: clip("fixture", 2, 8, 2),
|
||||
frames,
|
||||
background: None,
|
||||
palette: 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]
|
||||
fn original_movie_rates_and_frame_cues_follow_native_wait_update() {
|
||||
let expected = [
|
||||
(Kind::Intro, 925),
|
||||
(Kind::Landing, 288),
|
||||
(Kind::Death, 1384),
|
||||
(Kind::CampaignEnd(0), 1426),
|
||||
(Kind::CampaignEnd(1), 1171),
|
||||
(Kind::CampaignEnd(2), 1058),
|
||||
];
|
||||
for (kind, ticks) in expected {
|
||||
let mut movie = synthetic_player(specs(kind));
|
||||
while !movie.finished() {
|
||||
tick(&mut movie);
|
||||
}
|
||||
assert_eq!(movie.ticks, ticks, "{kind:?}");
|
||||
}
|
||||
let mut movie = synthetic_player(specs(Kind::Intro));
|
||||
let mut cues = Vec::new();
|
||||
while !movie.finished() {
|
||||
tick(&mut movie);
|
||||
for event in drain_patches(&mut movie) {
|
||||
cues.push((movie.ticks, event.asset, event.pan));
|
||||
}
|
||||
}
|
||||
assert_eq!(&cues[..2], &[(168, 129, 210), (208, 129, 100)]);
|
||||
assert_eq!(cues[2], (704, 144, 127));
|
||||
assert_eq!(cues[19], (755, 144, 127));
|
||||
assert_eq!(cues[20].0, 770);
|
||||
assert!((110..150).contains(&cues[20].2));
|
||||
assert_eq!(cues.last(), Some(&(865, 104, 127)));
|
||||
assert!(specs(Kind::Intro)[4].background.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn background_stop_precedes_the_next_clip_play_even_in_a_batched_advance() {
|
||||
let mut movie = synthetic_player(vec![clip("first", 1, 2, 1), clip("second", 1, 2, 1)]);
|
||||
for clip in &mut movie.clips {
|
||||
clip.background = Some((
|
||||
sound("FX_JETSND", 127, &mut LegacyRng::new(0)).unwrap(),
|
||||
100,
|
||||
));
|
||||
}
|
||||
movie.begin().unwrap();
|
||||
movie.advance(100_000_000).unwrap();
|
||||
let commands: Vec<_> = std::mem::take(&mut movie.audio_events)
|
||||
.into_iter()
|
||||
.map(|event| match event {
|
||||
CinematicAudioEvent::Play(event) => ("play", event.asset),
|
||||
CinematicAudioEvent::Stop(asset) => ("stop", asset),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
commands,
|
||||
[("play", 149), ("stop", 149), ("play", 149), ("stop", 149)]
|
||||
);
|
||||
assert!(movie.finished());
|
||||
}
|
||||
#[test]
|
||||
fn opening_logos_have_their_own_palettes_and_native_music_cue_times() {
|
||||
let mut movie = synthetic_player(specs(Kind::OpeningCredits));
|
||||
let base = [[240, 20, 16]; 256];
|
||||
assert_eq!(movie.palette(&base), [[0; 3]; 256]);
|
||||
let mut music = Vec::new();
|
||||
let mut patches = Vec::new();
|
||||
while !movie.finished() {
|
||||
tick(&mut movie);
|
||||
match movie.ticks {
|
||||
32 => assert_eq!(movie.palette(&base), [[40, 60, 100]; 256]),
|
||||
64 | 311 => assert_eq!(movie.palette(&base), [[80, 120, 200]; 256]),
|
||||
247 => assert_eq!(movie.palette(&base), [[0; 3]; 256]),
|
||||
_ => {}
|
||||
}
|
||||
for event in std::mem::take(&mut movie.music_events) {
|
||||
music.push((movie.ticks, event));
|
||||
}
|
||||
for event in drain_patches(&mut movie) {
|
||||
patches.push((movie.ticks, event.asset));
|
||||
}
|
||||
}
|
||||
assert_eq!(movie.ticks, 569);
|
||||
assert_eq!(
|
||||
music,
|
||||
[(64, ("APOGEE_MUS", false)), (446, ("RINTRO_MUS", true))]
|
||||
);
|
||||
assert_eq!(patches, [(314, 179), (431, 179)]);
|
||||
assert_eq!(movie.palette(&base), base);
|
||||
}
|
||||
#[test]
|
||||
fn still_picture_exposes_each_unfaded_card_palette_and_excludes_agx_frames() {
|
||||
let mut movie = synthetic_player(specs(Kind::OpeningCredits));
|
||||
movie.clips[1].palette = Some([[200, 60, 40]; 256]);
|
||||
let base = [[240, 20, 16]; 256];
|
||||
let (name, palette) = movie.still_picture().unwrap();
|
||||
assert_eq!(name, "APOGEE_PIC");
|
||||
assert_eq!(*palette, [[80, 120, 200]; 256]);
|
||||
assert_eq!(movie.palette(&base), [[0; 3]; 256]);
|
||||
for _ in 0..32 {
|
||||
tick(&mut movie);
|
||||
}
|
||||
assert_eq!(movie.palette(&base), [[40, 60, 100]; 256]);
|
||||
assert_eq!(*movie.still_picture().unwrap().1, [[80, 120, 200]; 256]);
|
||||
while movie.ticks < 247 {
|
||||
tick(&mut movie);
|
||||
}
|
||||
let (name, palette) = movie.still_picture().unwrap();
|
||||
assert_eq!(name, "CYGNUS_PIC");
|
||||
assert_eq!(*palette, [[200, 60, 40]; 256]);
|
||||
assert_eq!(movie.palette(&base), [[0; 3]; 256]);
|
||||
while !movie.finished() {
|
||||
tick(&mut movie);
|
||||
}
|
||||
assert!(movie.still_picture().is_none());
|
||||
let mut intro = synthetic_player(specs(Kind::Intro));
|
||||
while !intro.finished() {
|
||||
assert!(intro.still_picture().is_none());
|
||||
tick(&mut intro);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires original local game assets"]
|
||||
fn original_opening_palettes_and_complete_intro_timeline_are_preserved() {
|
||||
let root = std::env::var("RAPTOR_TEST_DATA")
|
||||
.expect("set RAPTOR_TEST_DATA to the original asset directory");
|
||||
let catalog = Catalog::open(root).unwrap();
|
||||
let apogee_palette =
|
||||
crate::assets::decode_palette(&catalog.named("POGPAL_DAT").unwrap()).unwrap();
|
||||
let cygnus_palette =
|
||||
crate::assets::decode_palette(&catalog.named("CYGPAL_DAT").unwrap()).unwrap();
|
||||
let mut movie = Cinematic::load(&catalog, Kind::OpeningCredits).unwrap();
|
||||
let mut music = Vec::new();
|
||||
let mut patches = Vec::new();
|
||||
while !movie.finished() {
|
||||
tick(&mut movie);
|
||||
match movie.ticks {
|
||||
64 => assert_eq!(movie.palette(&catalog.palette().unwrap()), apogee_palette),
|
||||
708 => assert_eq!(movie.palette(&catalog.palette().unwrap()), cygnus_palette),
|
||||
_ => {}
|
||||
}
|
||||
for event in std::mem::take(&mut movie.music_events) {
|
||||
music.push((movie.ticks, event));
|
||||
}
|
||||
for event in drain_patches(&mut movie) {
|
||||
patches.push((movie.ticks, event.asset));
|
||||
}
|
||||
}
|
||||
assert_eq!(movie.ticks, 966);
|
||||
assert_eq!(
|
||||
music,
|
||||
[(64, ("APOGEE_MUS", false)), (843, ("RINTRO_MUS", true))]
|
||||
);
|
||||
assert_eq!(patches, [(711, 179), (828, 179)]);
|
||||
let mut intro = Cinematic::load(&catalog, Kind::Intro).unwrap();
|
||||
for index in [1, 2, 3, 5] {
|
||||
let (event, period) = intro.clips[index].background.as_ref().unwrap();
|
||||
assert_eq!(event.asset, 149);
|
||||
assert!(*period > 0);
|
||||
}
|
||||
assert!(intro.clips[4].background.is_none());
|
||||
let mut cues = Vec::new();
|
||||
while !intro.finished() {
|
||||
tick(&mut intro);
|
||||
for event in drain_patches(&mut intro) {
|
||||
if event.asset != 149 {
|
||||
cues.push((intro.ticks, event.asset, event.pan));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(intro.ticks, 925);
|
||||
assert_eq!(&cues[..2], &[(168, 129, 210), (208, 129, 100)]);
|
||||
assert_eq!(cues.last(), Some(&(865, 104, 127)));
|
||||
assert!(intro.music_events.is_empty());
|
||||
}
|
||||
#[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::OpeningCredits,
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+999
@@ -0,0 +1,999 @@
|
||||
//! 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,
|
||||
/// Optional shop information, up to 4096 UTF-8 bytes.
|
||||
#[serde(default)]
|
||||
pub description: 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>,
|
||||
}
|
||||
impl ItemDef {
|
||||
/// Sales remove one ordinary unit, or one original shield refill/module.
|
||||
pub fn resale_price(&self) -> u32 {
|
||||
if matches!(self.id.0.as_str(), "base:energy" | "base:super_shield") {
|
||||
self.price / 2
|
||||
} else {
|
||||
self.price / self.initial_count.max(1) / 2
|
||||
}
|
||||
}
|
||||
}
|
||||
#[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>,
|
||||
/// Package music registry ID. Mutually exclusive with legacy `music`.
|
||||
#[serde(default)]
|
||||
pub music_track: Option<ContentId>,
|
||||
#[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,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MusicDef {
|
||||
pub id: ContentId,
|
||||
pub name: String,
|
||||
/// Package-local checked MUS score, played through original GENMIDI FM.
|
||||
pub file: String,
|
||||
#[serde(default = "yes")]
|
||||
pub looping: bool,
|
||||
}
|
||||
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 music: Vec<MusicDef>,
|
||||
#[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 music: BTreeMap<ContentId, MusicDef>,
|
||||
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);
|
||||
insert!(pack.music, music);
|
||||
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.description.len() <= 4096,
|
||||
"item {} description exceeds 4096 bytes",
|
||||
i.id
|
||||
);
|
||||
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)?;
|
||||
ensure!(
|
||||
m.music.is_none() || m.music_track.is_none(),
|
||||
"mission {} cannot select both music and music_track",
|
||||
m.id
|
||||
);
|
||||
if let Some(track) = &m.music_track {
|
||||
ensure!(
|
||||
self.music.contains_key(track),
|
||||
"mission {} references missing music track {track}",
|
||||
m.id
|
||||
);
|
||||
}
|
||||
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
|
||||
);
|
||||
}
|
||||
for music in self.music.values() {
|
||||
label(&music.name, &None)?;
|
||||
let path = std::path::Path::new(&music.file);
|
||||
ensure!(
|
||||
!music.file.is_empty()
|
||||
&& music.file.len() <= 512
|
||||
&& !path.is_absolute()
|
||||
&& path
|
||||
.components()
|
||||
.all(|component| matches!(component, std::path::Component::Normal(_)))
|
||||
&& path
|
||||
.extension()
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("mus")),
|
||||
"music {} requires a package-local .mus file without parent traversal",
|
||||
music.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(),
|
||||
description: match o.index {
|
||||
0 => "Paired forward guns. Fire automatically with the main fire control and strike air or ground targets.",
|
||||
1 => "Twin plasma guns. An always-equipped upgrade that fires alongside the forward guns at airborne targets.",
|
||||
2 => "Automatic micro missiles. Fire alongside the forward guns and seek airborne or ground enemies.",
|
||||
3 => "Selectable unguided missile launcher. Direct its forward shots at airborne or ground targets.",
|
||||
4 => "Selectable rapid-fire cannon. A concentrated stream of rounds for air and ground targets.",
|
||||
5 => "Selectable tracking turret. Instantly aims its gunfire at airborne targets; it cannot damage ground targets.",
|
||||
6 => "Selectable missile pods. Launch a spread of missiles against airborne targets.",
|
||||
7 => "Selectable air-to-air missiles. Seek airborne enemies; they cannot damage ground targets.",
|
||||
8 => "Selectable air-to-ground missiles. Seek ground targets; they cannot damage aircraft.",
|
||||
9 => "Selectable ground bomb. Damages ground targets and neighboring destructible terrain.",
|
||||
10 => "Selectable Energy Grab. Drains susceptible airborne enemies and suppresses their weapons.",
|
||||
11 => "One-use Mega Bomb. The bomb control damages visible enemies and terrain and clears hostile shots.",
|
||||
12 => "Selectable pulse cannon. A powerful forward energy weapon for air and ground targets.",
|
||||
13 => "Selectable forward laser. A high-end forward energy beam for airborne and ground targets.",
|
||||
14 => "Selectable Death Ray. Tracks airborne or ground targets with an energy beam.",
|
||||
15 => "Extra shielding with 100 charge per module. The equipped module absorbs hits first; a reserve replaces it when destroyed.",
|
||||
16 => "Restores 25 energy. With normal energy full, a smaller recharge goes to the equipped super shield.",
|
||||
17 => "Always-equipped damage detector. Shows a scanning target-health display during flight.",
|
||||
_ => "Valuable salvage collected during a mission for credits.",
|
||||
}.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,
|
||||
},
|
||||
// Describe effective gameplay, including native firing/beam
|
||||
// exceptions to the historical hit-type metadata.
|
||||
target: if w.index == 5 {
|
||||
Target::Air
|
||||
} else if w.index == 13 {
|
||||
Target::All
|
||||
} else {
|
||||
match w.target {
|
||||
1 | 5 => Target::Air,
|
||||
2 | 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(),
|
||||
music_track: None,
|
||||
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)
|
||||
}
|
||||
}
|
||||
+8337
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
];
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
pub mod assets;
|
||||
pub mod audio;
|
||||
pub mod cinematics;
|
||||
pub mod content;
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod desktop;
|
||||
pub mod hd;
|
||||
mod legacy_tables;
|
||||
#[cfg(feature = "desktop")]
|
||||
mod menu_art;
|
||||
pub mod mod_selection;
|
||||
pub mod modding;
|
||||
pub mod persistence;
|
||||
pub mod render;
|
||||
pub mod simulation;
|
||||
pub mod sound;
|
||||
pub mod terrain_hd;
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
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::{Path, PathBuf};
|
||||
|
||||
fn package_at(executable: &Path, working: &Path, development: &Path) -> PathBuf {
|
||||
let directory = executable.parent().unwrap_or(working);
|
||||
if directory.file_name().is_some_and(|name| name == "bin") {
|
||||
return directory.parent().unwrap_or(directory).to_owned();
|
||||
}
|
||||
for candidate in [directory, working] {
|
||||
if candidate.join("data/INDEX").is_file() {
|
||||
return candidate.to_owned();
|
||||
}
|
||||
}
|
||||
development.to_owned()
|
||||
}
|
||||
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))
|
||||
};
|
||||
let benchmark = args.iter().any(|a| a == "--benchmark");
|
||||
anyhow::ensure!(
|
||||
!benchmark || arg("--smoke-frames").is_some(),
|
||||
"--benchmark requires --smoke-frames"
|
||||
);
|
||||
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-hd Validate every supplied original-content HD frame\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 --benchmark Report startup and frame timings (with --smoke-frames)\n --screenshot FILE Save the final desktop frame as PNG\n --preview-mission ID Start a mission directly\n --preview-cinematic KIND credits, 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 package = package_at(
|
||||
&std::env::current_exe()?,
|
||||
&std::env::current_dir()?,
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")),
|
||||
);
|
||||
let data = arg("--data").map(PathBuf::from).unwrap_or_else(|| {
|
||||
if package.join("data/INDEX").is_file() || package.join("bin/raptor-rust").is_file() {
|
||||
package.join("data")
|
||||
} else {
|
||||
package.join("../data")
|
||||
}
|
||||
});
|
||||
let catalog = Catalog::open(&data)
|
||||
.with_context(|| format!("Could not open game data at {}", data.display()))?;
|
||||
if args.iter().any(|a| a == "--validate-hd") {
|
||||
let hd_root = catalog.root.join("hd");
|
||||
anyhow::ensure!(
|
||||
hd_root.join("hd.json").is_file(),
|
||||
"no HD pack at {}",
|
||||
hd_root.display()
|
||||
);
|
||||
let mut hd = raptor_replica::hd::HdAssets::load_lazy(&hd_root, &catalog, None);
|
||||
let report = hd.validate_all();
|
||||
println!(
|
||||
"HD pack: {} registered, {} validated, {} rejected supplied frames",
|
||||
report.registered, report.validated, report.rejected
|
||||
);
|
||||
for diagnostic in &hd.diagnostics {
|
||||
eprintln!("HD: {diagnostic}");
|
||||
}
|
||||
anyhow::ensure!(
|
||||
report.registered > 0 && report.validated == report.registered && report.rejected == 0,
|
||||
"HD pack validation failed"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
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(|| {
|
||||
if paths.mods().is_dir() {
|
||||
paths.mods()
|
||||
} else {
|
||||
package.join("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() {
|
||||
"credits" => Ok(raptor_replica::cinematics::Kind::OpeningCredits),
|
||||
"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,
|
||||
benchmark,
|
||||
)?;
|
||||
}
|
||||
#[cfg(not(feature = "desktop"))]
|
||||
{
|
||||
let _ = (catalog, base, paths, root, benchmark);
|
||||
return Err(anyhow::anyhow!(
|
||||
"desktop support is disabled; use a headless command"
|
||||
));
|
||||
}
|
||||
#[cfg(feature = "desktop")]
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod package_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn relocated_bin_uses_its_own_package_even_when_catalog_is_missing() {
|
||||
assert_eq!(
|
||||
package_at(
|
||||
Path::new("/copied/RustRaptor/bin/raptor-rust"),
|
||||
Path::new("/elsewhere"),
|
||||
Path::new("/old/build")
|
||||
),
|
||||
Path::new("/copied/RustRaptor")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn direct_executable_prefers_its_sibling_data_over_working_directory() {
|
||||
let folder = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(folder.path().join("data")).unwrap();
|
||||
std::fs::write(folder.path().join("data/INDEX"), "").unwrap();
|
||||
assert_eq!(
|
||||
package_at(
|
||||
&folder.path().join("raptor-rust"),
|
||||
Path::new("/elsewhere"),
|
||||
Path::new("/old/build")
|
||||
),
|
||||
folder.path()
|
||||
);
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
//! Full-color menu illustration; indexed gameplay and movie palettes stay separate.
|
||||
use crate::{assets::TRANSPARENT, render::Canvas};
|
||||
use image::{DynamicImage, RgbaImage, imageops::FilterType};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub const MENU_PANEL: u16 = 257;
|
||||
pub const MENU_CARD: u16 = 258;
|
||||
pub const MENU_FOCUS: u16 = 259;
|
||||
pub const MENU_TEXT: u16 = 260;
|
||||
pub const MENU_MUTED: u16 = 261;
|
||||
pub const MENU_ACCENT: u16 = 262;
|
||||
pub const MENU_RULE: u16 = 263;
|
||||
|
||||
pub struct MenuArt {
|
||||
original: RgbaImage,
|
||||
resized: BTreeMap<(usize, usize, usize), RgbaImage>,
|
||||
}
|
||||
impl MenuArt {
|
||||
pub fn bundled() -> Self {
|
||||
let original = image::load_from_memory(include_bytes!("../art/menu-background-v1.png"))
|
||||
.expect("bundled menu artwork is a checked PNG")
|
||||
.into_rgba8();
|
||||
Self {
|
||||
original,
|
||||
resized: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
pub fn compose(
|
||||
&mut self,
|
||||
canvas: &Canvas,
|
||||
palette: &[[u8; 3]; 256],
|
||||
scanlines: bool,
|
||||
) -> Vec<u8> {
|
||||
let background = self
|
||||
.resized
|
||||
.entry((canvas.scale, canvas.image.width, canvas.image.height))
|
||||
.or_insert_with(|| {
|
||||
DynamicImage::ImageRgba8(self.original.clone())
|
||||
.resize_to_fill(
|
||||
canvas.image.width as u32,
|
||||
canvas.image.height as u32,
|
||||
FilterType::Lanczos3,
|
||||
)
|
||||
.into_rgba8()
|
||||
});
|
||||
let mut rgba = background.as_raw().clone();
|
||||
for (i, index) in canvas.image.pixels.iter().enumerate() {
|
||||
let color = if matches!(*index, MENU_PANEL | MENU_CARD | MENU_FOCUS) {
|
||||
let (tint, opacity) = match *index {
|
||||
MENU_CARD => ([10u16, 17, 23], 72),
|
||||
MENU_FOCUS => ([49u16, 38, 27], 82),
|
||||
_ => ([12u16, 18, 29], 85),
|
||||
};
|
||||
[0, 1, 2].map(|channel| {
|
||||
((tint[channel] * opacity + rgba[i * 4 + channel] as u16 * (100 - opacity))
|
||||
/ 100) as u8
|
||||
})
|
||||
} else if *index == MENU_TEXT {
|
||||
[221, 225, 224]
|
||||
} else if *index == MENU_MUTED {
|
||||
[147, 163, 171]
|
||||
} else if *index == MENU_ACCENT {
|
||||
[237, 180, 105]
|
||||
} else if *index == MENU_RULE {
|
||||
[65, 79, 85]
|
||||
} else if *index == TRANSPARENT {
|
||||
[rgba[i * 4], rgba[i * 4 + 1], rgba[i * 4 + 2]]
|
||||
} else {
|
||||
palette[(*index).min(255) as usize]
|
||||
};
|
||||
let dark =
|
||||
scanlines && (i / canvas.image.width) % (canvas.scale * 3) == canvas.scale * 2;
|
||||
for j in 0..3 {
|
||||
rgba[i * 4 + j] = if dark { color[j] / 2 } else { color[j] };
|
||||
}
|
||||
rgba[i * 4 + 3] = 255;
|
||||
}
|
||||
rgba
|
||||
}
|
||||
pub fn sidebars(&self) -> RgbaImage {
|
||||
let mut image = DynamicImage::ImageRgba8(self.original.clone())
|
||||
.resize_to_fill(800, 450, FilterType::Lanczos3)
|
||||
.into_rgba8();
|
||||
for pixel in image.pixels_mut() {
|
||||
for channel in &mut pixel.0[..3] {
|
||||
*channel = (*channel as u16 * 23 / 100) as u8;
|
||||
}
|
||||
pixel[3] = 255;
|
||||
}
|
||||
image
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn menu_overlay_preserves_full_color_background_and_opaque_zero() {
|
||||
let mut art = MenuArt::bundled();
|
||||
let mut canvas = Canvas::new(2);
|
||||
canvas.clear(TRANSPARENT);
|
||||
let mut palette = [[0; 3]; 256];
|
||||
palette[7] = [240, 210, 160];
|
||||
let background = art.compose(&canvas, &palette, false);
|
||||
assert!(background.chunks_exact(4).any(|pixel| pixel[0] != pixel[1]));
|
||||
canvas.rect(10, 10, 3, 3, 7);
|
||||
canvas.rect(20, 20, 3, 3, 0);
|
||||
let overlay = art.compose(&canvas, &palette, false);
|
||||
let amber = (20 * canvas.image.width + 20) * 4;
|
||||
assert_eq!(&overlay[amber..amber + 4], &[240, 210, 160, 255]);
|
||||
let black = (40 * canvas.image.width + 40) * 4;
|
||||
assert_eq!(&overlay[black..black + 4], &[0, 0, 0, 255]);
|
||||
assert_eq!(&background[..4], &overlay[..4]);
|
||||
assert_eq!(art.resized.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Dependency-aware plans for the manager's pending selections.
|
||||
//! Planning never changes active content, installed files or the caller's list.
|
||||
use crate::{
|
||||
content::API_VERSION,
|
||||
modding::{self, Discovery},
|
||||
};
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use semver::{Version, VersionReq};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Check and select the requested package and its installed dependencies.
|
||||
/// Existing independent priority is preserved; dependencies precede dependents.
|
||||
pub fn plan_enable(discovery: &Discovery, enabled: &[String], id: &str) -> Result<Vec<String>> {
|
||||
let mut selected = enabled.to_vec();
|
||||
if !selected.iter().any(|value| value == id) {
|
||||
selected.push(id.into());
|
||||
}
|
||||
let roots = selected.clone();
|
||||
let mut visiting = BTreeSet::new();
|
||||
let mut done = BTreeSet::new();
|
||||
|
||||
fn visit(
|
||||
discovery: &Discovery,
|
||||
id: &str,
|
||||
selected: &mut Vec<String>,
|
||||
visiting: &mut BTreeSet<String>,
|
||||
done: &mut BTreeSet<String>,
|
||||
) -> Result<()> {
|
||||
if done.contains(id) {
|
||||
return Ok(());
|
||||
}
|
||||
ensure!(
|
||||
visiting.len() < 256,
|
||||
"dependency chain exceeds 256 packages at {id}"
|
||||
);
|
||||
ensure!(visiting.insert(id.into()), "cyclic mod dependency at {id}");
|
||||
let package = discovery
|
||||
.mods
|
||||
.get(id)
|
||||
.with_context(|| format!("mod {id} is not installed; install it, then rescan Mods"))?;
|
||||
ensure!(
|
||||
package.manifest.api_version == API_VERSION,
|
||||
"{id}: requires API {}, game provides {API_VERSION}",
|
||||
package.manifest.api_version
|
||||
);
|
||||
for (dependency, requirement) in &package.manifest.dependencies {
|
||||
let installed = discovery.mods.get(dependency).with_context(|| {
|
||||
format!("{id} requires {dependency} {requirement}; install it, then rescan Mods")
|
||||
})?;
|
||||
let version = Version::parse(&installed.manifest.version)
|
||||
.with_context(|| format!("{dependency}: invalid installed version"))?;
|
||||
ensure!(
|
||||
VersionReq::parse(requirement)?.matches(&version),
|
||||
"{id} requires {dependency} {requirement}, installed {version}"
|
||||
);
|
||||
if !selected.contains(dependency) {
|
||||
selected.push(dependency.clone());
|
||||
}
|
||||
visit(discovery, dependency, selected, visiting, done)?;
|
||||
}
|
||||
visiting.remove(id);
|
||||
done.insert(id.into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
for root in roots {
|
||||
visit(discovery, &root, &mut selected, &mut visiting, &mut done)?;
|
||||
}
|
||||
// Reuse activation's API/conflict/version/order checks. Nothing is committed
|
||||
// if any selected package or newly discovered dependency fails validation.
|
||||
modding::order(discovery, &selected)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DisablePlan {
|
||||
/// Pending selection after accepting this plan.
|
||||
pub enabled: Vec<String>,
|
||||
/// Requested package, dependents and unused dependencies, in current order.
|
||||
pub removed: Vec<String>,
|
||||
}
|
||||
|
||||
/// Plan a removal with dependent packages and their now-unused dependencies.
|
||||
/// Dependencies needed by another remaining package stay selected. The manager
|
||||
/// confirms plans affecting multiple packages; a single package toggles directly.
|
||||
pub fn plan_disable(discovery: &Discovery, enabled: &[String], id: &str) -> DisablePlan {
|
||||
let mut removed = BTreeSet::new();
|
||||
if enabled.iter().any(|value| value == id) {
|
||||
removed.insert(id.to_string());
|
||||
}
|
||||
loop {
|
||||
let before = removed.len();
|
||||
for candidate in enabled {
|
||||
if discovery.mods.get(candidate).is_some_and(|package| {
|
||||
package
|
||||
.manifest
|
||||
.dependencies
|
||||
.keys()
|
||||
.any(|dependency| removed.contains(dependency))
|
||||
}) {
|
||||
removed.insert(candidate.clone());
|
||||
}
|
||||
}
|
||||
if removed.len() == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
fn dependencies(discovery: &Discovery, roots: &BTreeSet<String>) -> BTreeSet<String> {
|
||||
let mut found = BTreeSet::new();
|
||||
let mut queue: Vec<_> = roots.iter().cloned().collect();
|
||||
while let Some(id) = queue.pop() {
|
||||
if let Some(package) = discovery.mods.get(&id) {
|
||||
for dependency in package.manifest.dependencies.keys() {
|
||||
if found.insert(dependency.clone()) {
|
||||
queue.push(dependency.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
let candidates = dependencies(discovery, &removed);
|
||||
let remaining_roots = enabled
|
||||
.iter()
|
||||
.filter(|id| !removed.contains(*id) && !candidates.contains(*id))
|
||||
.cloned()
|
||||
.collect();
|
||||
let still_needed = dependencies(discovery, &remaining_roots);
|
||||
let selected: BTreeSet<_> = enabled.iter().cloned().collect();
|
||||
removed.extend(
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter(|id| selected.contains(id) && !still_needed.contains(id)),
|
||||
);
|
||||
DisablePlan {
|
||||
enabled: enabled
|
||||
.iter()
|
||||
.filter(|id| !removed.contains(*id))
|
||||
.cloned()
|
||||
.collect(),
|
||||
removed: enabled
|
||||
.iter()
|
||||
.filter(|id| removed.contains(*id))
|
||||
.cloned()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
+824
@@ -0,0 +1,824 @@
|
||||
//! 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 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(music) = registry.music.get(id) {
|
||||
let root = registry
|
||||
.asset_roots
|
||||
.get(owner)
|
||||
.context("missing music root")?;
|
||||
crate::sound::load_music(root, &music.file).with_context(|| {
|
||||
format!("{owner}/{}: invalid music {}", music.file, music.id)
|
||||
})?;
|
||||
}
|
||||
if let Some(m) = registry.missions.get(id) {
|
||||
for tile in &m.tiles {
|
||||
validate_art(owner, &tile.art)?;
|
||||
if let Some(a) = &tile.destroyed {
|
||||
validate_art(owner, a)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let validate_script = |owner: &str, path: &Option<String>| -> Result<()> {
|
||||
if let Some(path) = path {
|
||||
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);
|
||||
schema!("music-v1.json", crate::content::MusicDef);
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
+1179
File diff suppressed because it is too large.
Load diff
+532
@@ -0,0 +1,532 @@
|
||||
//! 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};
|
||||
|
||||
/// Original tiny gun rounds and sparse final ground-explosion embers lose
|
||||
/// important pixels under edge filtering; preserve their exact pixel area.
|
||||
pub fn prefers_nearest(asset: u32) -> bool {
|
||||
matches!(asset, 0x101c3..=0x101c6 | 0x10178..=0x1018d)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// Original VGA light/dark lookup. Inputs are six-bit RGB components, not SDL
|
||||
/// eight-bit colors. GFX_Remap uses Manhattan distance and the last equal match.
|
||||
pub fn legacy_light_table(palette: &[[u8; 3]; 256], level: i32) -> [u8; 256] {
|
||||
std::array::from_fn(|index| {
|
||||
let target = palette[index].map(|component| (component as i32 + level).clamp(0, 63));
|
||||
let mut best = 0;
|
||||
let mut distance = i32::MAX;
|
||||
for (candidate, color) in palette.iter().enumerate() {
|
||||
let difference: i32 = (0..3)
|
||||
.map(|channel| (color[channel] as i32 - target[channel]).abs())
|
||||
.sum();
|
||||
if difference <= distance {
|
||||
best = candidate as u8;
|
||||
distance = difference;
|
||||
}
|
||||
}
|
||||
best
|
||||
})
|
||||
}
|
||||
|
||||
/// SHADOW_GAdd / GFX_ShadeShape: preserve native mask spans at offset (-3,+4).
|
||||
pub fn shade_ground_shadow(canvas: &mut Canvas, mask: &Sprite, x: f32, y: f32, table: &[u8; 256]) {
|
||||
let scaled = nearest(mask, canvas.scale);
|
||||
canvas.shade(
|
||||
&scaled,
|
||||
((x - 3.0) * canvas.scale as f32).round() as i32,
|
||||
((y + 4.0) * canvas.scale as f32).round() as i32,
|
||||
table,
|
||||
);
|
||||
}
|
||||
|
||||
fn shadow_project(coordinate: i32, center: i32) -> i32 {
|
||||
// GFX_3DPoint: view=(160,100,1000), objectZ=1280, distance=200.
|
||||
// Keep division before arithmetic shift; negative coordinates round down
|
||||
// after the original signed integer division.
|
||||
((((coordinate as i64 - center as i64) * (200 << 11)) / 280) >> 11) as i32 + center
|
||||
}
|
||||
|
||||
/// SHADOW_Draw's projected horizontal spans, with duplicate projected rows
|
||||
/// suppressed. Fractional positions translate the indexed mask smoothly on the
|
||||
/// high-resolution canvas; integer positions retain the original projection.
|
||||
pub fn shade_air_shadow(canvas: &mut Canvas, mask: &Sprite, x: f32, y: f32, table: &[u8; 256]) {
|
||||
let base_x = x.floor() as i32 - 10;
|
||||
let base_y = y.floor() as i32 + 20;
|
||||
let fraction_x = (x - x.floor()) * (5.0 / 7.0);
|
||||
let fraction_y = (y - y.floor()) * (5.0 / 7.0);
|
||||
let scale = canvas.scale as f32;
|
||||
let mut previous_row = None;
|
||||
for row in 0..mask.height {
|
||||
let source = &mask.pixels[row * mask.width..(row + 1) * mask.width];
|
||||
if source.iter().all(|pixel| *pixel == TRANSPARENT) {
|
||||
continue;
|
||||
}
|
||||
let projected_y = shadow_project(base_y + row as i32, 100);
|
||||
if previous_row == Some(projected_y) {
|
||||
continue;
|
||||
}
|
||||
previous_row = Some(projected_y);
|
||||
let top = ((projected_y as f32 + fraction_y) * scale).round() as i32;
|
||||
let bottom = ((projected_y as f32 + 1.0 + fraction_y) * scale).round() as i32;
|
||||
let mut column = 0;
|
||||
while column < mask.width {
|
||||
if source[column] == TRANSPARENT {
|
||||
column += 1;
|
||||
continue;
|
||||
}
|
||||
let start = column;
|
||||
while column < mask.width && source[column] != TRANSPARENT {
|
||||
column += 1;
|
||||
}
|
||||
let left = ((shadow_project(base_x + start as i32, 160) as f32 + fraction_x) * scale)
|
||||
.round() as i32;
|
||||
let right =
|
||||
((shadow_project(base_x + column as i32 - 1, 160) as f32 + 1.0 + fraction_x)
|
||||
* scale)
|
||||
.round() as i32;
|
||||
for yy in top.max(0)..bottom.min(canvas.image.height as i32) {
|
||||
for xx in left.max(0)..right.min(canvas.image.width as i32) {
|
||||
let pixel =
|
||||
&mut canvas.image.pixels[yy as usize * canvas.image.width + xx as usize];
|
||||
if *pixel < 256 {
|
||||
*pixel = table[*pixel as usize] as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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>,
|
||||
}
|
||||
// Mod labels are UTF-8; catalog bitmap fonts have only 256 glyphs. Keep width
|
||||
// measurement and drawing on the same normalized character stream.
|
||||
fn bitmap_chars(text: &str) -> impl Iterator<Item = u8> + '_ {
|
||||
text.chars().flat_map(|character| {
|
||||
match character {
|
||||
'\u{2010}'..='\u{2015}' | '\u{2212}' => [Some(b'-'), None, None],
|
||||
'\u{2018}' | '\u{2019}' => [Some(b'\''), None, None],
|
||||
'\u{201c}' | '\u{201d}' => [Some(b'"'), None, None],
|
||||
'\u{2026}' => [Some(b'.'), Some(b'.'), Some(b'.')],
|
||||
'\u{00a0}' => [Some(b' '), None, None],
|
||||
character if character.is_control() => [Some(b' '), None, None],
|
||||
character if (character as u32) <= 255 => [Some(character as u8), None, None],
|
||||
_ => [Some(b'?'), None, None],
|
||||
}
|
||||
.into_iter()
|
||||
.flatten()
|
||||
})
|
||||
}
|
||||
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 bitmap_chars(text) {
|
||||
if let Some(g) = &self.glyphs[ch as usize] {
|
||||
let mut glyph = g.clone();
|
||||
for p in &mut glyph.pixels {
|
||||
if *p != TRANSPARENT {
|
||||
*p = if color > TRANSPARENT {
|
||||
color
|
||||
} else {
|
||||
(*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 > (canvas.image.width / canvas.scale) as i32 - 6 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Logical pixel width, including the same one-pixel glyph spacing as `text`.
|
||||
pub fn text_width(&self, text: &str) -> usize {
|
||||
bitmap_chars(text)
|
||||
.map(|byte| self.widths[byte as usize] + 1)
|
||||
.sum()
|
||||
}
|
||||
pub fn height(&self) -> usize {
|
||||
self.height
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod font_label_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn unicode_mod_punctuation_draws_and_measures_like_supported_glyphs() {
|
||||
let font = BitmapFont {
|
||||
height: 1,
|
||||
glyphs: (0..256)
|
||||
.map(|index| {
|
||||
Some(Sprite {
|
||||
width: 1,
|
||||
height: 1,
|
||||
pixels: vec![index as u16],
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
widths: vec![1; 256],
|
||||
};
|
||||
let mut unicode = Canvas::new(2);
|
||||
let mut ascii = Canvas::new(2);
|
||||
let label = "Iron Coast — ‘Stormwall’…";
|
||||
let normalized = "Iron Coast - 'Stormwall'...";
|
||||
font.text(&mut unicode, 4, 4, label, 1);
|
||||
font.text(&mut ascii, 4, 4, normalized, 1);
|
||||
assert_eq!(unicode.image.pixels, ascii.image.pixels);
|
||||
assert_eq!(font.text_width(label), font.text_width(normalized));
|
||||
assert_eq!(font.text_width("未知"), font.text_width("??"));
|
||||
}
|
||||
}
|
||||
+3441
File diff suppressed because it is too large.
Load diff
+325
@@ -0,0 +1,325 @@
|
||||
//! Checked package-local PCM WAV and MUS loading, independent of SDL2.
|
||||
//! RIFF chunks are little-endian and padded to a two-byte boundary.
|
||||
use crate::{
|
||||
assets::Catalog,
|
||||
audio::{DigitalSample, Mixer, MusScore},
|
||||
content::{ContentId, Registry},
|
||||
simulation::SoundEvent,
|
||||
};
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use std::{collections::BTreeMap, io::Read, path::Path};
|
||||
|
||||
/// Original FX IDs name empty catalog markers. Their digital device variant is
|
||||
/// four entries later, matching SND_Setup's SND_DIGITAL offset.
|
||||
const DIGITAL_VARIANT: u32 = 4;
|
||||
const MOD_CACHE_LIMIT: usize = 64 * 1024 * 1024;
|
||||
pub const MAX_MUSIC_BYTES: usize = 128 * 1024;
|
||||
const MAX_MUSIC_EVENTS: usize = 16384;
|
||||
const MAX_MUSIC_TICKS: u32 = 30 * 60 * 70;
|
||||
const MUSIC_CACHE_LIMIT: usize = 16 * 1024 * 1024;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SoundBank {
|
||||
original: BTreeMap<u32, DigitalSample>,
|
||||
mods: BTreeMap<ContentId, DigitalSample>,
|
||||
music: BTreeMap<ContentId, MusScore>,
|
||||
}
|
||||
impl SoundBank {
|
||||
pub fn preload_originals(&mut self, catalog: &Catalog) -> Result<()> {
|
||||
for sound in crate::legacy_tables::sounds() {
|
||||
self.original(catalog, sound.asset)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn original(&mut self, catalog: &Catalog, marker: u32) -> Result<DigitalSample> {
|
||||
if !self.original.contains_key(&marker) {
|
||||
let is_marker = crate::legacy_tables::sounds()
|
||||
.iter()
|
||||
.any(|s| s.asset == marker);
|
||||
let asset = if is_marker {
|
||||
marker
|
||||
.checked_add(DIGITAL_VARIANT)
|
||||
.context("sound variant overflow")?
|
||||
} else {
|
||||
marker
|
||||
};
|
||||
let sample = DigitalSample::decode(&catalog.read(asset)?)
|
||||
.with_context(|| format!("original sound {marker:#x}, digital asset {asset:#x}"))?;
|
||||
self.original.insert(marker, sample);
|
||||
}
|
||||
Ok(self.original[&marker].clone())
|
||||
}
|
||||
pub fn queue_original(
|
||||
&mut self,
|
||||
catalog: &Catalog,
|
||||
mixer: &mut Mixer,
|
||||
event: &SoundEvent,
|
||||
) -> Result<()> {
|
||||
let sample = self.original(catalog, event.asset)?;
|
||||
mixer.sample(sample, event.pan, event.pitch, event.volume, event.priority);
|
||||
Ok(())
|
||||
}
|
||||
pub fn mod_sample(&mut self, registry: &Registry, id: &ContentId) -> Result<DigitalSample> {
|
||||
let definition = registry
|
||||
.sounds
|
||||
.get(id)
|
||||
.context("missing mod sound definition")?;
|
||||
if !self.mods.contains_key(id) {
|
||||
let owner = registry.owners.get(id).context("missing sound owner")?;
|
||||
let root = registry
|
||||
.asset_roots
|
||||
.get(owner)
|
||||
.context("missing sound asset root")?;
|
||||
let path = crate::assets::contained(root, Path::new(&definition.file))?;
|
||||
let sample =
|
||||
decode(&std::fs::read(path)?).with_context(|| format!("mod sound {id}"))?;
|
||||
if self.mods.values().map(|s| s.data.len()).sum::<usize>() + sample.data.len()
|
||||
> MOD_CACHE_LIMIT
|
||||
{
|
||||
self.mods.clear();
|
||||
}
|
||||
self.mods.insert(id.clone(), sample);
|
||||
}
|
||||
Ok(self.mods[id].clone())
|
||||
}
|
||||
pub fn queue_mod(
|
||||
&mut self,
|
||||
registry: &Registry,
|
||||
mixer: &mut Mixer,
|
||||
id: &ContentId,
|
||||
) -> Result<()> {
|
||||
let sample = self.mod_sample(registry, id)?;
|
||||
let definition = ®istry.sounds[id];
|
||||
mixer.sample(
|
||||
sample,
|
||||
definition.pan,
|
||||
definition.pitch,
|
||||
definition.volume,
|
||||
definition.priority as i32,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub fn clear_mods(&mut self) {
|
||||
self.mods.clear();
|
||||
self.music.clear();
|
||||
}
|
||||
/// Cache validated package scores independently from the digital FX bus.
|
||||
pub fn mod_music(&mut self, registry: &Registry, id: &ContentId) -> Result<MusScore> {
|
||||
if !self.music.contains_key(id) {
|
||||
let definition = registry
|
||||
.music
|
||||
.get(id)
|
||||
.context("missing mod music definition")?;
|
||||
let owner = registry.owners.get(id).context("missing music owner")?;
|
||||
let root = registry
|
||||
.asset_roots
|
||||
.get(owner)
|
||||
.context("missing music asset root")?;
|
||||
let score =
|
||||
load_music(root, &definition.file).with_context(|| format!("music {id}"))?;
|
||||
if self.music.values().map(score_size).sum::<usize>() + score_size(&score)
|
||||
> MUSIC_CACHE_LIMIT
|
||||
{
|
||||
self.music.clear();
|
||||
}
|
||||
self.music.insert(id.clone(), score);
|
||||
}
|
||||
Ok(self.music[id].clone())
|
||||
}
|
||||
pub fn original_count(&self) -> usize {
|
||||
self.original.len()
|
||||
}
|
||||
}
|
||||
fn score_size(score: &MusScore) -> usize {
|
||||
score.groups.len() * std::mem::size_of::<(u32, Vec<crate::audio::MusicEvent>)>()
|
||||
+ score
|
||||
.groups
|
||||
.iter()
|
||||
.map(|(_, events)| events.len() * std::mem::size_of::<crate::audio::MusicEvent>())
|
||||
.sum::<usize>()
|
||||
}
|
||||
|
||||
/// Extra limits apply only to untrusted mod tracks; the legacy MUS decoder and
|
||||
/// its native timing/controller compatibility remain unchanged.
|
||||
pub fn decode_music(bytes: &[u8]) -> Result<MusScore> {
|
||||
ensure!(bytes.len() <= MAX_MUSIC_BYTES, "music exceeds 128 KiB");
|
||||
let score = MusScore::decode(bytes)?;
|
||||
let mut count = 0;
|
||||
let mut at_tick = 0;
|
||||
let mut simultaneous = 0;
|
||||
for (tick, events) in &score.groups {
|
||||
ensure!(
|
||||
*tick <= MAX_MUSIC_TICKS,
|
||||
"music duration exceeds 30 minutes"
|
||||
);
|
||||
count += events.len();
|
||||
ensure!(count <= MAX_MUSIC_EVENTS, "music exceeds 16384 events");
|
||||
if *tick != at_tick {
|
||||
at_tick = *tick;
|
||||
simultaneous = 0;
|
||||
}
|
||||
simultaneous += events.len();
|
||||
ensure!(
|
||||
simultaneous <= 256,
|
||||
"music exceeds 256 events on one service tick"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
score.groups.last().is_some_and(|(tick, _)| *tick > 0),
|
||||
"music must have positive duration"
|
||||
);
|
||||
Ok(score)
|
||||
}
|
||||
|
||||
pub fn load_music(root: &Path, file: &str) -> Result<MusScore> {
|
||||
let path = crate::assets::contained(root, Path::new(file))?;
|
||||
let metadata = std::fs::metadata(&path)?;
|
||||
ensure!(metadata.is_file(), "music must be a regular file");
|
||||
ensure!(
|
||||
metadata.len() <= MAX_MUSIC_BYTES as u64,
|
||||
"music exceeds 128 KiB"
|
||||
);
|
||||
let mut bytes = Vec::with_capacity(metadata.len() as usize);
|
||||
std::fs::File::open(path)?
|
||||
.take(MAX_MUSIC_BYTES as u64 + 1)
|
||||
.read_to_end(&mut bytes)?;
|
||||
decode_music(&bytes)
|
||||
}
|
||||
|
||||
/// UI cues use the native metadata without consuming gameplay RNG.
|
||||
pub fn ui_event(name: &str) -> Result<SoundEvent> {
|
||||
let sound = crate::legacy_tables::sounds()
|
||||
.into_iter()
|
||||
.find(|s| s.name == name)
|
||||
.context("unknown original UI sound")?;
|
||||
Ok(SoundEvent {
|
||||
asset: sound.asset,
|
||||
pan: 127,
|
||||
pitch: sound.pitch,
|
||||
volume: sound.volume,
|
||||
priority: sound.priority,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write a captured production stereo callback as a standard PCM16 WAV.
|
||||
pub fn encode_capture(capture: &crate::audio::AudioCapture) -> Result<Vec<u8>> {
|
||||
ensure!(
|
||||
capture.samples.len() % 2 == 0,
|
||||
"partial captured stereo frame"
|
||||
);
|
||||
let size = capture
|
||||
.samples
|
||||
.len()
|
||||
.checked_mul(2)
|
||||
.and_then(|n| u32::try_from(n).ok())
|
||||
.context("audio capture too large")?;
|
||||
let mut bytes = Vec::with_capacity(size as usize + 44);
|
||||
bytes.extend_from_slice(b"RIFF");
|
||||
bytes.extend_from_slice(&(36 + size).to_le_bytes());
|
||||
bytes.extend_from_slice(b"WAVEfmt ");
|
||||
bytes.extend_from_slice(&16_u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&1_u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&2_u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&capture.rate.to_le_bytes());
|
||||
bytes.extend_from_slice(&(capture.rate * 4).to_le_bytes());
|
||||
bytes.extend_from_slice(&4_u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&16_u16.to_le_bytes());
|
||||
bytes.extend_from_slice(b"data");
|
||||
bytes.extend_from_slice(&size.to_le_bytes());
|
||||
for sample in &capture.samples {
|
||||
bytes.extend_from_slice(&sample.to_le_bytes());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
//! Contextual, indexed terrain remaster composition.
|
||||
//!
|
||||
//! The complete original terrain layer is filtered before replacements are
|
||||
//! applied. Reviewed neighbors share detailed edge colors; mixed coverage
|
||||
//! approaches original context, so a tile can be reused with different neighbors. Coordinates
|
||||
//! include the original two-pixel scrolling gutter; interpolation is applied
|
||||
//! once to the completed layer, rather than separately to individual tiles.
|
||||
use crate::{
|
||||
assets::{Sprite, TRANSPARENT},
|
||||
content::{Art, TileDef},
|
||||
};
|
||||
use anyhow::{Result, ensure};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const LEVELS: usize = 16;
|
||||
const UNSET: u16 = u16::MAX;
|
||||
const MAX_PREPARED_PIXELS: usize = 320 * 268 * 16;
|
||||
const MAX_PREPARED_KEY_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Use the native settled flag for native map debris. A hit does not replace
|
||||
/// those tiles until the original destruction animation has completed.
|
||||
pub fn selected_art(tile: &TileDef, legacy: bool, dead: bool, settled: bool) -> &Art {
|
||||
if if legacy { settled } else { dead } {
|
||||
tile.destroyed.as_ref().unwrap_or(&tile.art)
|
||||
} else {
|
||||
&tile.art
|
||||
}
|
||||
}
|
||||
|
||||
/// Preserve a mod's logical canvas when its source PNG already has more detail.
|
||||
/// This is only for the joined original layer, never the HD replacement.
|
||||
pub fn logical_sprite(source: &Sprite, width: usize, height: usize) -> Result<Sprite> {
|
||||
ensure!(
|
||||
source.width > 0
|
||||
&& source.height > 0
|
||||
&& source.pixels.len() == source.width * source.height
|
||||
&& width > 0
|
||||
&& height > 0
|
||||
&& width <= 4096
|
||||
&& height <= 4096,
|
||||
"invalid terrain canvas"
|
||||
);
|
||||
if (source.width, source.height) == (width, height) {
|
||||
return Ok(source.clone());
|
||||
}
|
||||
let mut logical = Sprite::blank(width, height);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
logical.pixels[y * width + x] = source.pixels
|
||||
[(y * source.height / height) * source.width + x * source.width / width];
|
||||
}
|
||||
}
|
||||
Ok(logical)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OverlayResult {
|
||||
Applied,
|
||||
InvalidCanvas,
|
||||
NonOpaque,
|
||||
}
|
||||
|
||||
pub struct Replacement {
|
||||
pub image: Sprite,
|
||||
/// Original logical position, including the scrolling gutter.
|
||||
pub position: [i32; 2],
|
||||
pub logical_size: [usize; 2],
|
||||
}
|
||||
pub struct Composition {
|
||||
pub image: Sprite,
|
||||
pub results: Vec<OverlayResult>,
|
||||
}
|
||||
|
||||
/// A bounded 2 MiB lookup of indexed edge transitions and one row-aligned
|
||||
/// viewport strip. PNG/atlas caches belong to HdAssets; whole maps stay unloaded.
|
||||
#[derive(Default)]
|
||||
pub struct TerrainHd {
|
||||
palette: Option<[[u8; 3]; 256]>,
|
||||
transitions: Vec<u16>,
|
||||
prepared: Option<(Vec<u8>, Sprite)>,
|
||||
}
|
||||
impl TerrainHd {
|
||||
pub fn cache_bytes(&self) -> usize {
|
||||
self.transitions.len() * std::mem::size_of::<u16>()
|
||||
}
|
||||
|
||||
pub fn prepared_bytes(&self) -> usize {
|
||||
self.prepared
|
||||
.as_ref()
|
||||
.map_or(0, |(key, image)| key.len() + image.pixels.len() * 2)
|
||||
}
|
||||
|
||||
pub fn cached_layer(&self, key: &[u8]) -> Option<&Sprite> {
|
||||
self.prepared
|
||||
.as_ref()
|
||||
.filter(|(saved, _)| saved == key)
|
||||
.map(|(_, image)| image)
|
||||
}
|
||||
|
||||
/// Retain one row-aligned viewport strip, never an entire mission map. Its
|
||||
/// key contains the selected art, active content hashes and render options.
|
||||
pub fn retain_layer(&mut self, key: Vec<u8>, image: Sprite) {
|
||||
if key.len() <= MAX_PREPARED_KEY_BYTES
|
||||
&& image.pixels.len() <= MAX_PREPARED_PIXELS
|
||||
&& image.pixels.len() == image.width.saturating_mul(image.height)
|
||||
{
|
||||
self.prepared = Some((key, image));
|
||||
} else {
|
||||
self.prepared = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the visible set together. HD/HD neighbors share detailed edge
|
||||
/// colors; HD/original neighbors use the original contextual edge. This
|
||||
/// prevents a grid of low-resolution borders across fully remastered maps.
|
||||
pub fn compose(
|
||||
&mut self,
|
||||
context: &Sprite,
|
||||
replacements: &[Replacement],
|
||||
scale: usize,
|
||||
palette: &[[u8; 3]; 256],
|
||||
) -> Composition {
|
||||
let mut image = context.clone();
|
||||
let mut results = Vec::with_capacity(replacements.len());
|
||||
let mut accepted = BTreeMap::new();
|
||||
for (index, tile) in replacements.iter().enumerate() {
|
||||
let result = validate(context, context, &tile.image, tile.logical_size, scale);
|
||||
if result == OverlayResult::Applied {
|
||||
accepted.insert((tile.position, tile.logical_size), index);
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
if accepted.is_empty() {
|
||||
return Composition { image, results };
|
||||
}
|
||||
self.prepare_palette(palette);
|
||||
// Keep a pristine joined HD context. Every edge reads this same layer,
|
||||
// so the result cannot depend on the overlay order or previous blending.
|
||||
let mut detailed = context.clone();
|
||||
for (index, tile) in replacements.iter().enumerate() {
|
||||
if accepted.get(&(tile.position, tile.logical_size)) != Some(&index) {
|
||||
continue;
|
||||
}
|
||||
let [left, top] = tile
|
||||
.position
|
||||
.map(|value| value.saturating_mul(scale as i32));
|
||||
let start_x = (-(left as i64)).clamp(0, tile.image.width as i64) as usize;
|
||||
let end_x =
|
||||
(context.width as i64 - left as i64).clamp(0, tile.image.width as i64) as usize;
|
||||
let start_y = (-(top as i64)).clamp(0, tile.image.height as i64) as usize;
|
||||
let end_y =
|
||||
(context.height as i64 - top as i64).clamp(0, tile.image.height as i64) as usize;
|
||||
if start_x >= end_x || start_y >= end_y {
|
||||
continue;
|
||||
}
|
||||
for y in start_y..end_y {
|
||||
let destination = (top as i64 + y as i64) as usize * context.width
|
||||
+ (left as i64 + start_x as i64) as usize;
|
||||
let source = y * tile.image.width + start_x;
|
||||
let length = end_x - start_x;
|
||||
let originals = &context.pixels[destination..destination + length];
|
||||
let hd = &tile.image.pixels[source..source + length];
|
||||
let output = &mut detailed.pixels[destination..destination + length];
|
||||
if originals.iter().all(|color| *color < 240) && hd.iter().all(|color| *color < 240)
|
||||
{
|
||||
output.copy_from_slice(hd);
|
||||
} else {
|
||||
for ((output, original), hd) in output.iter_mut().zip(originals).zip(hd) {
|
||||
if *original < TRANSPARENT {
|
||||
*output = if *original < 240 && *hd < 240 {
|
||||
*hd
|
||||
} else {
|
||||
self.transition(*original, *hd, LEVELS - 1, palette)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
image.pixels.copy_from_slice(&detailed.pixels);
|
||||
for (index, tile) in replacements.iter().enumerate() {
|
||||
if accepted.get(&(tile.position, tile.logical_size)) != Some(&index) {
|
||||
continue;
|
||||
}
|
||||
let [left, top] = tile
|
||||
.position
|
||||
.map(|value| value.saturating_mul(scale as i32));
|
||||
let band = (2 * scale)
|
||||
.min(tile.image.width / 2)
|
||||
.min(tile.image.height / 2)
|
||||
.max(1);
|
||||
let edges = self.tile_edges(tile, &accepted, &detailed, context, scale, band, palette);
|
||||
let weights: [usize; 8] = std::array::from_fn(|distance| edge_weight(distance, band));
|
||||
for sy in 0..tile.image.height {
|
||||
let yy = top as i64 + sy as i64;
|
||||
if yy < 0 || yy >= context.height as i64 {
|
||||
continue;
|
||||
}
|
||||
let inner_row = sy >= band && sy < tile.image.height - band;
|
||||
let first_end = if inner_row { band } else { tile.image.width };
|
||||
let second_start = if inner_row {
|
||||
tile.image.width - band
|
||||
} else {
|
||||
tile.image.width
|
||||
};
|
||||
// Interior pixels already came from the joined HD layer. Only
|
||||
// the two edge segments (or a top/bottom row) need blending.
|
||||
for sx in (0..first_end).chain(second_start..tile.image.width) {
|
||||
let xx = left as i64 + sx as i64;
|
||||
if xx < 0 || xx >= context.width as i64 {
|
||||
continue;
|
||||
}
|
||||
let destination = yy as usize * context.width + xx as usize;
|
||||
let original = context.pixels[destination];
|
||||
if original >= TRANSPARENT {
|
||||
continue;
|
||||
}
|
||||
let raw = detailed.pixels[destination];
|
||||
let horizontal = if sx < band {
|
||||
Some((0, sx))
|
||||
} else if tile.image.width - sx - 1 < band {
|
||||
Some((1, tile.image.width - sx - 1))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let vertical = if sy < band {
|
||||
Some((2, sy))
|
||||
} else if tile.image.height - sy - 1 < band {
|
||||
Some((3, tile.image.height - sy - 1))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (target, distance) =
|
||||
if let (Some((hside, hdistance)), Some((vside, vdistance))) =
|
||||
(horizontal, vertical)
|
||||
{
|
||||
let corner = (vside - 2) * 2 + hside;
|
||||
(
|
||||
edges.corners[corner]
|
||||
.color(hdistance, vdistance, band, original, raw, palette),
|
||||
hdistance.min(vdistance),
|
||||
)
|
||||
} else if let Some((side, distance)) = horizontal {
|
||||
(
|
||||
edges.color(side, sy, distance, original, raw, palette),
|
||||
distance,
|
||||
)
|
||||
} else {
|
||||
let (side, distance) = vertical.unwrap();
|
||||
(
|
||||
edges.color(side, sx, distance, original, raw, palette),
|
||||
distance,
|
||||
)
|
||||
};
|
||||
image.pixels[destination] =
|
||||
self.transition(target, raw, weights[distance], palette);
|
||||
}
|
||||
}
|
||||
}
|
||||
Composition { image, results }
|
||||
}
|
||||
|
||||
/// Overlay one full opaque logical tile on a previously joined/scaled layer.
|
||||
/// `context` is the unchanged original layer, even after adjacent overlays.
|
||||
/// The outermost subpixel remains exactly that context; a smooth transition
|
||||
/// reaches full HD detail two logical pixels inward, without a raw-pixel strip.
|
||||
pub fn overlay(
|
||||
&mut self,
|
||||
layer: &mut Sprite,
|
||||
context: &Sprite,
|
||||
replacement: &Sprite,
|
||||
position: [i32; 2],
|
||||
logical_size: [usize; 2],
|
||||
scale: usize,
|
||||
palette: &[[u8; 3]; 256],
|
||||
) -> OverlayResult {
|
||||
let result = validate(layer, context, replacement, logical_size, scale);
|
||||
if result != OverlayResult::Applied {
|
||||
return result;
|
||||
}
|
||||
self.prepare_palette(palette);
|
||||
let [x, y] = position.map(|coordinate| coordinate.saturating_mul(scale as i32));
|
||||
let band = (2 * scale)
|
||||
.min(replacement.width / 2)
|
||||
.min(replacement.height / 2)
|
||||
.max(1);
|
||||
for sy in 0..replacement.height {
|
||||
let yy = y as i64 + sy as i64;
|
||||
if yy < 0 || yy >= layer.height as i64 {
|
||||
continue;
|
||||
}
|
||||
for sx in 0..replacement.width {
|
||||
let xx = x as i64 + sx as i64;
|
||||
if xx < 0 || xx >= layer.width as i64 {
|
||||
continue;
|
||||
}
|
||||
let destination = yy as usize * layer.width + xx as usize;
|
||||
let original = context.pixels[destination];
|
||||
if original >= TRANSPARENT {
|
||||
// Sparse/transparent terrain is not a full opaque replacement.
|
||||
continue;
|
||||
}
|
||||
let hd = replacement.pixels[sy * replacement.width + sx];
|
||||
let distance = sx
|
||||
.min(sy)
|
||||
.min(replacement.width - sx - 1)
|
||||
.min(replacement.height - sy - 1);
|
||||
let weight = edge_weight(distance, band);
|
||||
layer.pixels[destination] = self.transition(original, hd, weight, palette);
|
||||
}
|
||||
}
|
||||
OverlayResult::Applied
|
||||
}
|
||||
|
||||
fn prepare_palette(&mut self, palette: &[[u8; 3]; 256]) {
|
||||
if self.palette.as_ref() != Some(palette) {
|
||||
self.palette = Some(*palette);
|
||||
self.transitions.resize(256 * 256 * LEVELS, UNSET);
|
||||
self.transitions.fill(UNSET);
|
||||
}
|
||||
}
|
||||
|
||||
fn tile_edges(
|
||||
&mut self,
|
||||
tile: &Replacement,
|
||||
accepted: &BTreeMap<([i32; 2], [usize; 2]), usize>,
|
||||
detailed: &Sprite,
|
||||
context: &Sprite,
|
||||
scale: usize,
|
||||
band: usize,
|
||||
palette: &[[u8; 3]; 256],
|
||||
) -> EdgeTargets {
|
||||
let [width, height] = tile.logical_size;
|
||||
let [left, top] = tile
|
||||
.position
|
||||
.map(|value| value.saturating_mul(scale as i32) as i64);
|
||||
let right = left + tile.image.width as i64;
|
||||
let bottom = top + tile.image.height as i64;
|
||||
let neighbor = |dx: i32, dy: i32| {
|
||||
accepted.contains_key(&(
|
||||
[
|
||||
tile.position[0].saturating_add(dx.saturating_mul(width as i32)),
|
||||
tile.position[1].saturating_add(dy.saturating_mul(height as i32)),
|
||||
],
|
||||
tile.logical_size,
|
||||
))
|
||||
};
|
||||
let adjacent = [
|
||||
neighbor(-1, 0),
|
||||
neighbor(1, 0),
|
||||
neighbor(0, -1),
|
||||
neighbor(0, 1),
|
||||
];
|
||||
let sides = std::array::from_fn(|side| {
|
||||
if !adjacent[side] {
|
||||
return Vec::new();
|
||||
}
|
||||
let length = if side < 2 {
|
||||
tile.image.height
|
||||
} else {
|
||||
tile.image.width
|
||||
};
|
||||
// Pair pristine detail at each mirrored distance across the join.
|
||||
// Repeating only the outer edge's sample throughout this band
|
||||
// stretches fine grain into ribs even when the source art joins.
|
||||
(0..length * band)
|
||||
.map(|sample| {
|
||||
let offset = sample / band;
|
||||
let distance = (sample % band) as i64;
|
||||
let points = match side {
|
||||
0 => [
|
||||
[left - 1 - distance, top + offset as i64],
|
||||
[left + distance, top + offset as i64],
|
||||
],
|
||||
1 => [
|
||||
[right - 1 - distance, top + offset as i64],
|
||||
[right + distance, top + offset as i64],
|
||||
],
|
||||
2 => [
|
||||
[left + offset as i64, top - 1 - distance],
|
||||
[left + offset as i64, top + distance],
|
||||
],
|
||||
_ => [
|
||||
[left + offset as i64, bottom - 1 - distance],
|
||||
[left + offset as i64, bottom + distance],
|
||||
],
|
||||
};
|
||||
self.shared_target(detailed, context, &points, palette)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let corners = std::array::from_fn(|corner| {
|
||||
let hside = corner % 2;
|
||||
let vside = 2 + corner / 2;
|
||||
let dx = if hside == 0 { -1 } else { 1 };
|
||||
let dy = if vside == 2 { -1 } else { 1 };
|
||||
let complete = adjacent[hside] && adjacent[vside] && neighbor(dx, dy);
|
||||
let bx = if hside == 0 { left } else { right };
|
||||
let by = if vside == 2 { top } else { bottom };
|
||||
CornerTarget {
|
||||
complete,
|
||||
// The same distance-aware rule applies in both axes at four
|
||||
// HD tiles' intersection, avoiding a flat corner-color patch.
|
||||
targets: if complete {
|
||||
(0..band * band)
|
||||
.map(|sample| {
|
||||
let dx = (sample % band) as i64;
|
||||
let dy = (sample / band) as i64;
|
||||
self.shared_target(
|
||||
detailed,
|
||||
context,
|
||||
&[
|
||||
[bx - 1 - dx, by - 1 - dy],
|
||||
[bx + dx, by - 1 - dy],
|
||||
[bx - 1 - dx, by + dy],
|
||||
[bx + dx, by + dy],
|
||||
],
|
||||
palette,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
}
|
||||
});
|
||||
EdgeTargets {
|
||||
band,
|
||||
adjacent,
|
||||
sides,
|
||||
corners,
|
||||
}
|
||||
}
|
||||
|
||||
fn shared_target(
|
||||
&mut self,
|
||||
detailed: &Sprite,
|
||||
context: &Sprite,
|
||||
points: &[[i64; 2]],
|
||||
palette: &[[u8; 3]; 256],
|
||||
) -> Option<(u16, u16)> {
|
||||
let mut values = [0_u16; 4];
|
||||
let mut first = None;
|
||||
for (point, [x, y]) in points.iter().enumerate() {
|
||||
if *x < 0 || *y < 0 || *x >= context.width as i64 || *y >= context.height as i64 {
|
||||
return None;
|
||||
}
|
||||
let index = *y as usize * context.width + *x as usize;
|
||||
let source = context.pixels[index];
|
||||
let detail = detailed.pixels[index];
|
||||
if source >= TRANSPARENT || detail >= TRANSPARENT {
|
||||
return None;
|
||||
}
|
||||
let first_source = *first.get_or_insert(source);
|
||||
if !same_material(source, first_source, palette) {
|
||||
return None;
|
||||
}
|
||||
values[point] = detail;
|
||||
}
|
||||
let top = self.transition(values[0], values[1], LEVELS / 2, palette);
|
||||
let target = if points.len() == 2 {
|
||||
top
|
||||
} else {
|
||||
let bottom = self.transition(values[2], values[3], LEVELS / 2, palette);
|
||||
self.transition(top, bottom, LEVELS / 2, palette)
|
||||
};
|
||||
Some((target, first.unwrap()))
|
||||
}
|
||||
|
||||
fn transition(
|
||||
&mut self,
|
||||
original: u16,
|
||||
hd: u16,
|
||||
weight: usize,
|
||||
palette: &[[u8; 3]; 256],
|
||||
) -> u16 {
|
||||
if weight == 0 || original == hd {
|
||||
return original;
|
||||
}
|
||||
// A color nearest to a blinking light must not make ordinary terrain
|
||||
// blink. Conversely, remastered water/fire must remain in the original
|
||||
// cycling group. Single-index glow/flash masks keep their exact label.
|
||||
let range = match original {
|
||||
240..=244 => 240..245,
|
||||
245..=249 => 245..250,
|
||||
250..=255 => return original,
|
||||
_ => 0..240,
|
||||
};
|
||||
if weight == LEVELS - 1 && range.contains(&(hd as usize)) {
|
||||
return hd;
|
||||
}
|
||||
let key = (original as usize * 256 + hd as usize) * LEVELS + weight;
|
||||
if self.transitions[key] != UNSET {
|
||||
return self.transitions[key];
|
||||
}
|
||||
let color: [i32; 3] = std::array::from_fn(|channel| {
|
||||
(palette[original as usize][channel] as usize * (LEVELS - 1 - weight)
|
||||
+ palette[hd as usize][channel] as usize * weight
|
||||
+ (LEVELS - 1) / 2) as i32
|
||||
/ (LEVELS - 1) as i32
|
||||
});
|
||||
let result = range
|
||||
.min_by_key(|index| {
|
||||
(0..3)
|
||||
.map(|channel| (color[channel] - palette[*index][channel] as i32).pow(2))
|
||||
.sum::<i32>()
|
||||
})
|
||||
.unwrap() as u16;
|
||||
self.transitions[key] = result;
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
struct EdgeTargets {
|
||||
band: usize,
|
||||
adjacent: [bool; 4],
|
||||
sides: [Vec<Option<(u16, u16)>>; 4],
|
||||
corners: [CornerTarget; 4],
|
||||
}
|
||||
impl EdgeTargets {
|
||||
fn color(
|
||||
&self,
|
||||
side: usize,
|
||||
offset: usize,
|
||||
distance: usize,
|
||||
original: u16,
|
||||
raw: u16,
|
||||
palette: &[[u8; 3]; 256],
|
||||
) -> u16 {
|
||||
if !self.adjacent[side] {
|
||||
return original;
|
||||
}
|
||||
self.sides[side][offset * self.band + distance]
|
||||
.filter(|(_, source)| same_material(original, *source, palette))
|
||||
.map_or(raw, |(target, _)| target)
|
||||
}
|
||||
}
|
||||
struct CornerTarget {
|
||||
complete: bool,
|
||||
targets: Vec<Option<(u16, u16)>>,
|
||||
}
|
||||
impl CornerTarget {
|
||||
fn color(
|
||||
&self,
|
||||
horizontal_distance: usize,
|
||||
vertical_distance: usize,
|
||||
band: usize,
|
||||
original: u16,
|
||||
raw: u16,
|
||||
palette: &[[u8; 3]; 256],
|
||||
) -> u16 {
|
||||
if !self.complete {
|
||||
return original;
|
||||
}
|
||||
self.targets[vertical_distance * band + horizontal_distance]
|
||||
.filter(|(_, source)| same_material(original, *source, palette))
|
||||
.map_or(raw, |(target, _)| target)
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn same_material(first: u16, second: u16, palette: &[[u8; 3]; 256]) -> bool {
|
||||
first == second
|
||||
|| (group(first) == group(second)
|
||||
&& (0..3).all(|channel| {
|
||||
palette[first as usize][channel].abs_diff(palette[second as usize][channel]) <= 24
|
||||
}))
|
||||
}
|
||||
|
||||
fn validate(
|
||||
layer: &Sprite,
|
||||
context: &Sprite,
|
||||
replacement: &Sprite,
|
||||
logical_size: [usize; 2],
|
||||
scale: usize,
|
||||
) -> OverlayResult {
|
||||
let [width, height] = logical_size;
|
||||
if !matches!(scale, 1 | 2 | 3 | 4)
|
||||
|| width == 0
|
||||
|| height == 0
|
||||
|| replacement.width != width.saturating_mul(scale)
|
||||
|| replacement.height != height.saturating_mul(scale)
|
||||
|| replacement.pixels.len() != replacement.width.saturating_mul(replacement.height)
|
||||
|| context.width != layer.width
|
||||
|| context.height != layer.height
|
||||
|| layer.pixels.len() != layer.width.saturating_mul(layer.height)
|
||||
|| context.pixels.len() != layer.pixels.len()
|
||||
{
|
||||
return OverlayResult::InvalidCanvas;
|
||||
}
|
||||
if replacement.pixels.iter().any(|pixel| *pixel >= TRANSPARENT) {
|
||||
return OverlayResult::NonOpaque;
|
||||
}
|
||||
OverlayResult::Applied
|
||||
}
|
||||
|
||||
fn group(original: u16) -> std::ops::Range<usize> {
|
||||
match original {
|
||||
240..=244 => 240..245,
|
||||
245..=249 => 245..250,
|
||||
250..=255 => original as usize..original as usize + 1,
|
||||
_ => 0..240,
|
||||
}
|
||||
}
|
||||
fn edge_weight(distance: usize, band: usize) -> usize {
|
||||
if distance >= band {
|
||||
return LEVELS - 1;
|
||||
}
|
||||
// Integer smoothstep t²(3-2t), rounded to sixteen indexed weights.
|
||||
let denominator = band * band * band;
|
||||
(distance * distance * (3 * band - 2 * distance) * (LEVELS - 1) + denominator / 2) / denominator
|
||||
}
|
||||
Reference in new issue
Block a user