Package RustRaptor and refine controls, menus, saves, sound and mod selection

This commit is contained in:
raptorreplica committed 2026-10-10 13:47:41 +11:00
1 parent 0844f6dffa
commit e17bc1003e
649 files changed
+8652 -2677

No files matched your search

-439
View File
@@ -1,439 +0,0 @@
//! Opt-in remaster artwork tied to the original asset ID and animation canvas.
//! Missing or rejected frames fall back to the original crisp sprite.
use crate::assets::{Catalog, Sprite, contained};
use anyhow::{Context, Result, ensure};
use image::{
ImageReader, RgbaImage,
imageops::{self, FilterType},
};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub format_version: u32,
pub name: String,
#[serde(default)]
pub description: String,
pub sequences: Vec<Sequence>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Sequence {
pub name: String,
pub base_asset: u32,
pub frame_count: u32,
pub frames: Vec<Frame>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Frame {
pub frame: u32,
pub file: PathBuf,
pub logical_width: u32,
pub logical_height: u32,
/// Anchor relative to the original canvas. Version 1 requires [0, 0].
pub origin: [i32; 2],
/// Exact original catalog payload, so changes cannot quietly misalign art.
pub source_sha256: String,
/// Optional pixel rectangle [x, y, width, height] in an atlas PNG.
#[serde(default)]
pub crop: Option<[u32; 4]>,
}
struct Replacement {
frame: Frame,
rgba: RgbaImage,
}
#[derive(Default)]
pub struct HdAssets {
replacements: BTreeMap<u32, Replacement>,
scaled: BTreeMap<(u32, usize), Sprite>,
pub diagnostics: Vec<String>,
}
impl HdAssets {
pub fn load(root: &Path, catalog: &Catalog) -> Self {
let mut result = Self::default();
if !root.exists() {
return result;
}
match result.read(root, catalog) {
Ok(()) => {}
Err(e) => {
// Reject the complete pack if its sequence metadata is invalid.
result.replacements.clear();
result
.diagnostics
.push(format!("HD pack {}: {e:#}", root.display()));
}
}
result
}
fn read(&mut self, root: &Path, catalog: &Catalog) -> Result<()> {
let bytes = fs::read(contained(root, Path::new("hd.json"))?)?;
ensure!(bytes.len() <= 256 * 1024, "HD metadata exceeds 256 KiB");
let manifest: Manifest = serde_json::from_slice(&bytes)?;
ensure!(
manifest.format_version == 1,
"unsupported HD artwork format {}",
manifest.format_version
);
ensure!(manifest.sequences.len() <= 512, "too many HD sequences");
let mut ids = BTreeMap::new();
for sequence in &manifest.sequences {
ensure!(
(1..=512).contains(&sequence.frame_count),
"invalid frame count for {}",
sequence.name
);
ensure!(
sequence.frames.len() <= sequence.frame_count as usize,
"too many supplied frames for {}",
sequence.name
);
for frame in &sequence.frames {
ensure!(
frame.frame < sequence.frame_count,
"{}: frame {} outside sequence",
sequence.name,
frame.frame
);
let id = sequence
.base_asset
.checked_add(frame.frame)
.context("HD asset ID overflow")?;
ensure!(ids.insert(id, ()).is_none(), "duplicate HD asset {id:05x}");
ensure!(self.replacements.len() < 4096, "too many HD frames");
match Self::read_frame(root, catalog, id, frame) {
Ok(replacement) => {
if self
.replacements
.values()
.map(|r| r.rgba.as_raw().len())
.sum::<usize>()
+ replacement.rgba.as_raw().len()
> 128 * 1024 * 1024
{
self.diagnostics.push(format!(
"HD {id:05x}: pack exceeds 128 MiB artwork budget; using original"
));
} else {
self.replacements.insert(id, replacement);
}
}
Err(e) => self.diagnostics.push(format!(
"HD {} frame {} ({id:05x}): {e:#}; using original",
sequence.name, frame.frame
)),
}
}
if sequence.frames.len() != sequence.frame_count as usize {
self.diagnostics.push(format!(
"HD {}: {} of {} frames supplied; uncovered frames use original artwork",
sequence.name,
sequence.frames.len(),
sequence.frame_count
));
}
}
Ok(())
}
fn read_frame(root: &Path, catalog: &Catalog, id: u32, frame: &Frame) -> Result<Replacement> {
let source = catalog.read(id)?;
let original = Sprite::decode(&source)?;
validate_frame(frame, &original, &source)?;
let path = contained(root, &frame.file)?;
ensure!(
fs::metadata(&path)?.len() <= 32 * 1024 * 1024,
"HD PNG exceeds 32 MiB"
);
let mut reader = ImageReader::open(path)?.with_guessed_format()?;
ensure!(
reader.format() == Some(image::ImageFormat::Png),
"HD artwork must be PNG"
);
let mut limits = image::Limits::default();
limits.max_image_width = Some(4096);
limits.max_image_height = Some(4096);
limits.max_alloc = Some(64 * 1024 * 1024);
reader.limits(limits);
let mut rgba = reader.decode()?.into_rgba8();
ensure!(
rgba.width() as u64 * rgba.height() as u64 <= 4_194_304,
"HD PNG exceeds pixel limit"
);
if let Some([x, y, width, height]) = frame.crop {
ensure!(
width > 0
&& height > 0
&& x.checked_add(width).is_some_and(|n| n <= rgba.width())
&& y.checked_add(height).is_some_and(|n| n <= rgba.height()),
"HD atlas crop exceeds its PNG"
);
rgba = imageops::crop_imm(&rgba, x, y, width, height).to_image();
}
ensure!(
rgba.width() as u64 * frame.logical_height as u64
== rgba.height() as u64 * frame.logical_width as u64,
"HD frame aspect must match the original canvas"
);
ensure!(
rgba.width() >= frame.logical_width * 2 && rgba.height() >= frame.logical_height * 2,
"HD artwork must supply at least twice the logical resolution"
);
ensure!(
rgba.pixels().any(|p| p[3] == 0),
"HD frame needs a transparent background"
);
ensure!(
rgba.pixels().any(|p| p[3] >= 128),
"HD frame has no visible pixels"
);
Ok(Replacement {
frame: frame.clone(),
rgba,
})
}
pub fn coverage(&self) -> usize {
self.replacements.len()
}
/// Return already detailed artwork at the canvas resolution; never run ScaleNx on it.
pub fn sprite(
&mut self,
id: u32,
scale: usize,
palette: &[[u8; 3]; 256],
) -> Result<Option<Sprite>> {
ensure!(
(1..=4).contains(&scale),
"HD presentation scale must be 1..4"
);
let Some(replacement) = self.replacements.get(&id) else {
return Ok(None);
};
if !self.scaled.contains_key(&(id, scale)) {
let width = replacement.frame.logical_width as usize * scale;
let height = replacement.frame.logical_height as usize * scale;
let resized = imageops::resize(
&replacement.rgba,
width as u32,
height as u32,
FilterType::Lanczos3,
);
let mut sprite = Sprite::blank(width, height);
let mut colors = BTreeMap::new();
for (destination, pixel) in sprite.pixels.iter_mut().zip(resized.pixels()) {
if pixel[3] < 128 {
continue;
}
let key = [pixel[0], pixel[1], pixel[2]];
*destination = *colors.entry(key).or_insert_with(|| closest(palette, key));
}
if self
.scaled
.values()
.map(|s| s.pixels.len() * 2)
.sum::<usize>()
+ sprite.pixels.len() * 2
> 64 * 1024 * 1024
{
self.scaled.clear();
}
self.scaled.insert((id, scale), sprite);
}
Ok(self.scaled.get(&(id, scale)).cloned())
}
}
fn validate_frame(frame: &Frame, original: &Sprite, source: &[u8]) -> Result<()> {
ensure!(
frame.logical_width as usize == original.width
&& frame.logical_height as usize == original.height,
"HD logical dimensions must equal original {}x{}",
original.width,
original.height
);
ensure!(
frame.origin == [0, 0],
"HD format 1 preserves the original top-left anchor [0, 0]"
);
ensure!(
frame.source_sha256 == format!("{:x}", Sha256::digest(source)),
"original source hash changed; regenerate or review this frame"
);
Ok(())
}
fn closest(palette: &[[u8; 3]; 256], rgb: [u8; 3]) -> u16 {
palette
.iter()
.enumerate()
.min_by_key(|(_, c)| {
(0..3)
.map(|j| (c[j] as i32 - rgb[j] as i32).pow(2))
.sum::<i32>()
})
.unwrap()
.0 as u16
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assets::TRANSPARENT;
fn frame(source: &[u8]) -> Frame {
Frame {
frame: 0,
file: "ship.png".into(),
logical_width: 2,
logical_height: 2,
origin: [0, 0],
source_sha256: format!("{:x}", Sha256::digest(source)),
crop: None,
}
}
#[test]
fn metadata_cannot_change_original_canvas_or_anchor() {
let source = b"source";
let original = Sprite::blank(2, 2);
let mut f = frame(source);
assert!(validate_frame(&f, &original, source).is_ok());
f.logical_width = 3;
assert!(validate_frame(&f, &original, source).is_err());
f.logical_width = 2;
f.origin = [1, 0];
assert!(validate_frame(&f, &original, source).is_err());
f.origin = [0, 0];
assert!(validate_frame(&f, &original, b"changed").is_err());
}
#[test]
fn detailed_art_preserves_canvas_and_transparency_without_filtering_twice() {
let source = b"source";
let mut rgba = RgbaImage::new(8, 8);
for y in 2..6 {
for x in 2..6 {
rgba.put_pixel(x, y, image::Rgba([255, 0, 0, 255]));
}
}
let mut art = HdAssets::default();
art.replacements.insert(
1,
Replacement {
frame: frame(source),
rgba,
},
);
let mut palette = [[0; 3]; 256];
palette[100] = [255, 0, 0];
let s = art.sprite(1, 4, &palette).unwrap().unwrap();
assert_eq!((s.width, s.height), (8, 8));
assert_eq!(s.pixels[0], TRANSPARENT);
assert_eq!(s.pixels[3 * 8 + 3], 100);
assert!(art.sprite(2, 4, &palette).unwrap().is_none());
}
fn fixture() -> (tempfile::TempDir, Catalog, Manifest) {
let dir = tempfile::tempdir().unwrap();
let mut source = Vec::new();
for n in [1_i32, 0, 0, 2, 2] {
source.extend(n.to_le_bytes());
}
source.extend([1, 2, 3, 4]);
fs::write(dir.path().join("source"), &source).unwrap();
fs::write(
dir.path().join("INDEX"),
format!("00001 {} source\n", source.len()),
)
.unwrap();
fs::create_dir(dir.path().join("hd")).unwrap();
let mut png = RgbaImage::new(8, 8);
for y in 2..6 {
for x in 2..6 {
png.put_pixel(x, y, image::Rgba([100, 20, 20, 255]));
}
}
png.save(dir.path().join("hd/ship.png")).unwrap();
let manifest = Manifest {
format_version: 1,
name: "Fixture".into(),
description: String::new(),
sequences: vec![Sequence {
name: "ship".into(),
base_asset: 1,
frame_count: 2,
frames: vec![frame(&source)],
}],
};
let catalog = Catalog::open(dir.path()).unwrap();
(dir, catalog, manifest)
}
fn pack(dir: &Path, catalog: &Catalog, manifest: &Manifest) -> HdAssets {
fs::write(
dir.join("hd/hd.json"),
serde_json::to_vec(manifest).unwrap(),
)
.unwrap();
HdAssets::load(&dir.join("hd"), catalog)
}
#[test]
fn source_checked_pack_reports_partial_coverage_and_rejects_changed_data() {
let (dir, catalog, mut manifest) = fixture();
let assets = pack(dir.path(), &catalog, &manifest);
assert_eq!(assets.coverage(), 1);
assert!(assets.diagnostics[0].contains("1 of 2 frames"));
manifest.sequences[0].frames[0].source_sha256 = "changed".into();
let assets = pack(dir.path(), &catalog, &manifest);
assert_eq!(assets.coverage(), 0);
assert!(
assets
.diagnostics
.iter()
.any(|m| m.contains("source hash changed"))
);
}
#[test]
fn invalid_atlas_and_path_cannot_escape_original_canvas() {
let (dir, catalog, mut manifest) = fixture();
manifest.sequences[0].frames[0].crop = Some([0, 0, 9, 8]);
let assets = pack(dir.path(), &catalog, &manifest);
assert_eq!(assets.coverage(), 0);
assert!(
assets
.diagnostics
.iter()
.any(|m| m.contains("crop exceeds"))
);
manifest.sequences[0].frames[0].crop = None;
manifest.sequences[0].frames[0].file = "../source".into();
let assets = pack(dir.path(), &catalog, &manifest);
assert_eq!(assets.coverage(), 0);
assert!(assets.diagnostics.iter().any(|m| m.contains("stay inside")));
}
#[test]
fn duplicate_sequence_ids_reject_pack_atomically() {
let (dir, catalog, mut manifest) = fixture();
let duplicate = manifest.sequences[0].clone();
manifest.sequences.push(duplicate);
let assets = pack(dir.path(), &catalog, &manifest);
assert_eq!(assets.coverage(), 0);
assert!(
assets
.diagnostics
.iter()
.any(|m| m.contains("duplicate HD asset"))
);
}
#[test]
fn missing_pack_is_clean_fallback() {
let dir = tempfile::tempdir().unwrap();
// Build a real minimal catalog so the public loading path is exercised.
fs::write(dir.path().join("a"), []).unwrap();
fs::write(dir.path().join("INDEX"), "00001 0 a\n").unwrap();
let catalog = Catalog::open(dir.path()).unwrap();
let art = HdAssets::load(&dir.path().join("missing"), &catalog);
assert_eq!(art.coverage(), 0);
assert!(art.diagnostics.is_empty());
}
}