Add rational atlas cells and bounded authenticated Original-detail retention with standalone synthetic regression checks. Rebuild Linux and Windows runtimes; validate all 5384 supplied frames and preserve three exact 1080p regression controls.
3136 lines
134 KiB
Rust
3136 lines
134 KiB
Rust
//! Opt-in remaster artwork tied to the original asset ID and animation canvas.
|
||
//! Runtime loading checks source metadata first, then decodes artwork on demand.
|
||
//! Rejected frames fall back to the original crisp sprite, with a diagnostic.
|
||
use crate::assets::{Catalog, Sprite, TRANSPARENT, 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,
|
||
io::{Cursor, Read, Write},
|
||
path::{Path, PathBuf},
|
||
time::SystemTime,
|
||
};
|
||
|
||
const MAX_METADATA_BYTES: u64 = 8 * 1024 * 1024;
|
||
const MAX_SEQUENCES: usize = 8192;
|
||
const MAX_FRAMES: usize = 16384;
|
||
const CACHE_BYTES: usize = 32 * 1024 * 1024;
|
||
const MAX_SOURCE_LABEL_BYTES: usize = 32 * 1024 * 1024;
|
||
const DISK_CACHE_BYTES: u64 = 512 * 1024 * 1024;
|
||
const MAX_PNG_BYTES: u64 = 32 * 1024 * 1024;
|
||
const MAX_PNG_PIXELS: u64 = 4_194_304;
|
||
const CACHE_MAGIC: &[u8; 8] = b"RRHD0002";
|
||
const CACHE_HEADER: usize = 80;
|
||
const KERNEL_VERSION: &[u8] = b"raptor-hd-lanczos3-rgba-v1";
|
||
|
||
#[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>,
|
||
}
|
||
/// Equal rational viewports in an unchanged atlas, not fitted pixel crops.
|
||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||
#[serde(deny_unknown_fields)]
|
||
pub struct AtlasGrid {
|
||
pub columns: u32,
|
||
pub rows: u32,
|
||
pub column: u32,
|
||
pub row: u32,
|
||
}
|
||
#[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]>,
|
||
/// Opt-in equal atlas cells; mutually exclusive with a pixel crop.
|
||
/// Absent values retain the original sampler, serialization and cache keys.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub atlas_grid: Option<AtlasGrid>,
|
||
/// Explicit, independently reviewed gameplay-only source250..255 alpha
|
||
/// coverage. Does not assign reserved labels to ordinary source cells.
|
||
#[serde(default, skip_serializing_if = "is_false")]
|
||
pub preserve_reserved_coverage: bool,
|
||
/// Up to64 sorted unique [x,y] ordinary original cells, sampled from
|
||
/// untouched resident RGBA with nearest only at gameplay scale1.
|
||
/// Other cells/scales and static palettes retain their existing sampler.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub native_nearest_cues: Vec<[u32; 2]>,
|
||
/// Explicit retained Original detail, never generated HD. Gameplay
|
||
/// SourceClasses only, at all1–4 scales; painted Source indices0–255,
|
||
/// including Original cycling/fire/blink labels. TRANSPARENT is excluded.
|
||
/// Legacy lists<=64 retain their exact behavior. Lists65..128 must retain
|
||
/// no more than one quarter of the authentic painted Source cells.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub original_detail_cells: Vec<[u32; 2]>,
|
||
}
|
||
/// Validated Source-only footprint metadata. Fields cannot be forged by callers.
|
||
#[derive(Clone)]
|
||
pub struct OriginalDetailCells {
|
||
cells: Vec<[u32; 2]>,
|
||
logical_size: [usize; 2],
|
||
}
|
||
impl OriginalDetailCells {
|
||
pub fn cells(&self) -> &[[u32; 2]] { &self.cells }
|
||
pub fn logical_size(&self) -> [usize; 2] { self.logical_size }
|
||
#[cfg(test)]
|
||
pub(crate) fn for_test(cells: Vec<[u32; 2]>, logical_size: [usize; 2]) -> Self {
|
||
Self { cells, logical_size }
|
||
}
|
||
}
|
||
fn is_false(value: &bool) -> bool {
|
||
!*value
|
||
}
|
||
struct Replacement {
|
||
frame: Frame,
|
||
requires_alpha: bool,
|
||
/// None means every source location is ordinary. Special locations retain
|
||
/// their original cycling group or exact glow/flash label.
|
||
source_labels: Option<Vec<u8>>,
|
||
/// Only opt-in frames retain full indexed Source for Original ScaleNx.
|
||
original_detail_source: Option<Sprite>,
|
||
validated: bool,
|
||
rejected: bool,
|
||
}
|
||
#[derive(Clone)]
|
||
struct PngInfo {
|
||
size: u64,
|
||
modified: Option<SystemTime>,
|
||
digest: [u8; 32],
|
||
width: u32,
|
||
height: u32,
|
||
}
|
||
struct CacheEntry<V> {
|
||
value: V,
|
||
bytes: usize,
|
||
used: u64,
|
||
}
|
||
struct LruCache<K, V> {
|
||
entries: BTreeMap<K, CacheEntry<V>>,
|
||
bytes: usize,
|
||
limit: usize,
|
||
clock: u64,
|
||
}
|
||
impl<K: Ord + Clone, V> LruCache<K, V> {
|
||
fn new(limit: usize) -> Self {
|
||
Self {
|
||
entries: BTreeMap::new(),
|
||
bytes: 0,
|
||
limit,
|
||
clock: 0,
|
||
}
|
||
}
|
||
fn get(&mut self, key: &K) -> Option<&V> {
|
||
self.clock = self.clock.wrapping_add(1);
|
||
let entry = self.entries.get_mut(key)?;
|
||
entry.used = self.clock;
|
||
Some(&entry.value)
|
||
}
|
||
fn insert(&mut self, key: K, value: V, bytes: usize) {
|
||
if let Some(old) = self.entries.remove(&key) {
|
||
self.bytes -= old.bytes;
|
||
}
|
||
if bytes > self.limit {
|
||
return;
|
||
}
|
||
while self.bytes + bytes > self.limit {
|
||
let Some(oldest) = self
|
||
.entries
|
||
.iter()
|
||
.min_by_key(|(_, entry)| entry.used)
|
||
.map(|(key, _)| key.clone())
|
||
else {
|
||
break;
|
||
};
|
||
self.bytes -= self.entries.remove(&oldest).unwrap().bytes;
|
||
}
|
||
self.clock = self.clock.wrapping_add(1);
|
||
self.bytes += bytes;
|
||
self.entries.insert(
|
||
key,
|
||
CacheEntry {
|
||
value,
|
||
bytes,
|
||
used: self.clock,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
/// Decoded validation and metadata registration are deliberately separate.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct ValidationReport {
|
||
pub registered: usize,
|
||
pub validated: usize,
|
||
pub rejected: usize,
|
||
}
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct CacheStats {
|
||
pub atlas_bytes: usize,
|
||
pub rgba_bytes: usize,
|
||
pub indexed_bytes: usize,
|
||
pub indexed_entries: usize,
|
||
pub png_decodes: usize,
|
||
pub disk_hits: usize,
|
||
pub disk_writes: usize,
|
||
pub source_label_bytes: usize,
|
||
}
|
||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||
enum PalettePolicy {
|
||
SourceClasses,
|
||
Static,
|
||
// Baseline HD for terrain edges; same sampler/classes, no Original patch.
|
||
SourceClassesBaseline,
|
||
}
|
||
impl PalettePolicy {
|
||
fn source_classes(self) -> bool { self != Self::Static }
|
||
}
|
||
pub struct HdAssets {
|
||
root: PathBuf,
|
||
disk_cache: Option<PathBuf>,
|
||
replacements: BTreeMap<u32, Replacement>,
|
||
png_info: BTreeMap<PathBuf, PngInfo>,
|
||
atlases: LruCache<[u8; 32], RgbaImage>,
|
||
rgba: LruCache<[u8; 32], RgbaImage>,
|
||
scaled: LruCache<([u8; 32], usize, [u8; 32], PalettePolicy), Sprite>,
|
||
png_decodes: usize,
|
||
disk_hits: usize,
|
||
disk_writes: usize,
|
||
disk_bytes: Option<u64>,
|
||
disk_limit: u64,
|
||
metadata_rejected: usize,
|
||
source_label_bytes: usize,
|
||
original_detail_source_bytes: usize,
|
||
pub diagnostics: Vec<String>,
|
||
}
|
||
impl Default for HdAssets {
|
||
fn default() -> Self {
|
||
Self {
|
||
root: PathBuf::new(),
|
||
disk_cache: None,
|
||
replacements: BTreeMap::new(),
|
||
png_info: BTreeMap::new(),
|
||
atlases: LruCache::new(CACHE_BYTES),
|
||
rgba: LruCache::new(CACHE_BYTES),
|
||
scaled: LruCache::new(CACHE_BYTES),
|
||
png_decodes: 0,
|
||
disk_hits: 0,
|
||
disk_writes: 0,
|
||
disk_bytes: None,
|
||
disk_limit: DISK_CACHE_BYTES,
|
||
metadata_rejected: 0,
|
||
source_label_bytes: 0,
|
||
original_detail_source_bytes: 0,
|
||
diagnostics: Vec::new(),
|
||
}
|
||
}
|
||
}
|
||
impl HdAssets {
|
||
/// Validate every supplied PNG/crop immediately, using bounded caches.
|
||
/// This is intended for validation tools and tests, rather than startup.
|
||
pub fn load(root: &Path, catalog: &Catalog) -> Self {
|
||
let mut result = Self::load_lazy(root, catalog, None);
|
||
result.validate_all();
|
||
result
|
||
}
|
||
/// Register source-checked frames without retaining or decoding all artwork.
|
||
/// PNG validation happens on first use; `validated_count` reports progress.
|
||
/// An optional private profile cache stores checksum-checked derived RGBA.
|
||
pub fn load_lazy(root: &Path, catalog: &Catalog, cache_dir: Option<&Path>) -> Self {
|
||
let mut result = Self::default();
|
||
if !root.exists() {
|
||
return result;
|
||
}
|
||
result.root = root.to_path_buf();
|
||
result.disk_cache = cache_dir.map(Path::to_path_buf);
|
||
if let Err(e) = result.read(root, catalog) {
|
||
// Invalid sequence metadata rejects the complete pack atomically.
|
||
result.replacements.clear();
|
||
result.diagnostics.clear();
|
||
result
|
||
.diagnostics
|
||
.push(format!("HD pack {}: {e:#}", root.display()));
|
||
}
|
||
result
|
||
}
|
||
fn read(&mut self, root: &Path, catalog: &Catalog) -> Result<()> {
|
||
let path = contained(root, Path::new("hd.json"))?;
|
||
ensure!(
|
||
fs::metadata(&path)?.len() <= MAX_METADATA_BYTES,
|
||
"HD metadata exceeds 8 MiB"
|
||
);
|
||
let bytes = bounded_read(&path, MAX_METADATA_BYTES)?;
|
||
let manifest: Manifest = serde_json::from_slice(&bytes)?;
|
||
ensure!(
|
||
manifest.format_version == 1,
|
||
"unsupported HD artwork format {}",
|
||
manifest.format_version
|
||
);
|
||
ensure!(
|
||
manifest.sequences.len() <= MAX_SEQUENCES,
|
||
"too many HD sequences"
|
||
);
|
||
let mut ids = BTreeMap::new();
|
||
// Check the entire structural manifest before accepting any frames.
|
||
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!(ids.len() <= MAX_FRAMES, "too many HD frames");
|
||
}
|
||
}
|
||
for sequence in &manifest.sequences {
|
||
for frame in &sequence.frames {
|
||
let id = sequence.base_asset + frame.frame;
|
||
let prepared = (|| -> Result<Replacement> {
|
||
let source = catalog.read(id)?;
|
||
let original = Sprite::decode(&source)?;
|
||
validate_frame(frame, &original, &source)?;
|
||
// Check containment now and again whenever artwork is read.
|
||
let path = contained(root, &frame.file)?;
|
||
ensure!(
|
||
fs::metadata(path)?.len() <= MAX_PNG_BYTES,
|
||
"HD PNG exceeds 32 MiB"
|
||
);
|
||
let source_labels = if original.pixels.iter().any(|p| (240..=255).contains(p)) {
|
||
Some(
|
||
original
|
||
.pixels
|
||
.iter()
|
||
.map(|p| if (240..=255).contains(p) { *p as u8 } else { 0 })
|
||
.collect::<Vec<_>>(),
|
||
)
|
||
} else {
|
||
None
|
||
};
|
||
let label_bytes = source_labels.as_ref().map_or(0, Vec::len);
|
||
let detail_bytes = if frame.original_detail_cells.is_empty() { 0 }
|
||
else { original.pixels.len() * std::mem::size_of::<u16>() };
|
||
ensure!(source_reference_budget(self.source_label_bytes,
|
||
self.original_detail_source_bytes, label_bytes, detail_bytes),
|
||
"HD retained Source references exceed combined32 MiB");
|
||
self.source_label_bytes += label_bytes;
|
||
self.original_detail_source_bytes += detail_bytes;
|
||
Ok(Replacement {
|
||
frame: frame.clone(),
|
||
requires_alpha: original.pixels.contains(&TRANSPARENT),
|
||
source_labels,
|
||
original_detail_source: if frame.original_detail_cells.is_empty() { None }
|
||
else { Some(original.clone()) },
|
||
validated: false,
|
||
rejected: false,
|
||
})
|
||
})();
|
||
match prepared {
|
||
Ok(replacement) => {
|
||
self.replacements.insert(id, replacement);
|
||
}
|
||
Err(e) => {
|
||
self.metadata_rejected += 1;
|
||
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 png(&mut self, file: &Path) -> Result<(PathBuf, PngInfo)> {
|
||
let path = contained(&self.root, file)?;
|
||
let metadata = fs::metadata(&path)?;
|
||
ensure!(metadata.len() <= MAX_PNG_BYTES, "HD PNG exceeds 32 MiB");
|
||
let modified = metadata.modified().ok();
|
||
if let Some(info) = self.png_info.get(&path) {
|
||
if info.size == metadata.len() && info.modified.is_some() && info.modified == modified {
|
||
return Ok((path.clone(), info.clone()));
|
||
}
|
||
}
|
||
let bytes = bounded_read(&path, MAX_PNG_BYTES)?;
|
||
let mut reader = ImageReader::new(Cursor::new(&bytes)).with_guessed_format()?;
|
||
ensure!(
|
||
reader.format() == Some(image::ImageFormat::Png),
|
||
"HD artwork must be PNG"
|
||
);
|
||
reader.limits(image_limits());
|
||
let (width, height) = reader.into_dimensions()?;
|
||
ensure!(
|
||
width > 0
|
||
&& height > 0
|
||
&& width <= 4096
|
||
&& height <= 4096
|
||
&& width as u64 * height as u64 <= MAX_PNG_PIXELS,
|
||
"HD PNG exceeds pixel limit"
|
||
);
|
||
let info = PngInfo {
|
||
size: bytes.len() as u64,
|
||
modified,
|
||
digest: Sha256::digest(&bytes).into(),
|
||
width,
|
||
height,
|
||
};
|
||
self.png_info.insert(path.clone(), info.clone());
|
||
Ok((path, info))
|
||
}
|
||
fn frame_key(frame: &Frame, info: &PngInfo, requires_alpha: bool) -> [u8; 32] {
|
||
let mut digest = Sha256::new();
|
||
digest.update(KERNEL_VERSION);
|
||
let (width, height) = frame
|
||
.crop
|
||
.map(|[_, _, width, height]| (width, height))
|
||
.unwrap_or((info.width, info.height));
|
||
if frame.atlas_grid.is_none()
|
||
&& !exact_aspect(width, height, frame.logical_width, frame.logical_height) {
|
||
// Preserve exact and rounded-opaque identities. Newly eligible
|
||
// transparent rounded frames have their own alpha-validation namespace.
|
||
if requires_alpha {
|
||
digest.update(b"full-frame-transparent-rounding-v1");
|
||
} else {
|
||
digest.update(b"full-frame-opaque-rounding-v1");
|
||
}
|
||
}
|
||
digest.update(info.digest);
|
||
digest.update(frame.source_sha256.as_bytes());
|
||
digest.update(frame.logical_width.to_le_bytes());
|
||
digest.update(frame.logical_height.to_le_bytes());
|
||
for value in frame.origin {
|
||
digest.update(value.to_le_bytes());
|
||
}
|
||
digest.update([u8::from(requires_alpha), u8::from(frame.crop.is_some())]);
|
||
if let Some(crop) = frame.crop {
|
||
for value in crop {
|
||
digest.update(value.to_le_bytes());
|
||
}
|
||
}
|
||
if let Some(grid) = frame.atlas_grid {
|
||
digest.update(b"raptor-hd-rational-atlas-grid-lanczos3-f32-v1");
|
||
for value in [grid.columns, grid.rows, grid.column, grid.row] {
|
||
digest.update(value.to_le_bytes());
|
||
}
|
||
digest.update(info.width.to_le_bytes());
|
||
digest.update(info.height.to_le_bytes());
|
||
}
|
||
// Preserve exact legacy disk/indexed cache keys when both policies are
|
||
// absent/default. Every opt-in has a distinct, versioned namespace.
|
||
if frame.preserve_reserved_coverage || !frame.native_nearest_cues.is_empty() {
|
||
digest.update(b"raptor-hd-frame-presentation-policy-v1");
|
||
digest.update([u8::from(frame.preserve_reserved_coverage)]);
|
||
digest.update((frame.native_nearest_cues.len() as u32).to_le_bytes());
|
||
for cue in &frame.native_nearest_cues {
|
||
for coordinate in cue {
|
||
digest.update(coordinate.to_le_bytes());
|
||
}
|
||
}
|
||
}
|
||
if !frame.original_detail_cells.is_empty() {
|
||
digest.update(original_detail_policy_tag(frame.original_detail_cells.len(), false));
|
||
digest.update((frame.original_detail_cells.len() as u32).to_le_bytes());
|
||
for cell in &frame.original_detail_cells {
|
||
for coordinate in cell { digest.update(coordinate.to_le_bytes()); }
|
||
}
|
||
}
|
||
digest.finalize().into()
|
||
}
|
||
fn rgba_frame(&mut self, id: u32) -> Result<Option<([u8; 32], RgbaImage)>> {
|
||
let Some(record) = self.replacements.get(&id) else {
|
||
return Ok(None);
|
||
};
|
||
if record.rejected {
|
||
return Ok(None);
|
||
}
|
||
let frame = record.frame.clone();
|
||
let requires_alpha = record.requires_alpha;
|
||
let result = (|| -> Result<([u8; 32], RgbaImage)> {
|
||
let (path, info) = self.png(&frame.file)?;
|
||
let (width, height) = frame_dimensions(&frame, &info, requires_alpha)?;
|
||
let requires_opaque = !requires_alpha
|
||
&& !exact_aspect(width, height, frame.logical_width, frame.logical_height);
|
||
let key = Self::frame_key(&frame, &info, requires_alpha);
|
||
if let Some(rgba) = self.rgba.get(&key) {
|
||
return Ok((key, rgba.clone()));
|
||
}
|
||
let target_width = width.min(frame.logical_width * 4);
|
||
let target_height = height.min(frame.logical_height * 4);
|
||
if let Some(rgba) = self.read_disk_cache(
|
||
&key,
|
||
target_width,
|
||
target_height,
|
||
requires_alpha,
|
||
requires_opaque,
|
||
) {
|
||
self.disk_hits += 1;
|
||
self.rgba.insert(key, rgba.clone(), rgba.as_raw().len());
|
||
return Ok((key, rgba));
|
||
}
|
||
let mut rgba = if let Some(atlas) = self.atlases.get(&info.digest) {
|
||
selected_frame(atlas, &frame, target_width, target_height, requires_alpha)?
|
||
} else {
|
||
let bytes = bounded_read(&path, MAX_PNG_BYTES)?;
|
||
ensure!(
|
||
bytes.len() as u64 <= MAX_PNG_BYTES
|
||
&& <[u8; 32]>::from(Sha256::digest(&bytes)) == info.digest,
|
||
"HD PNG changed while loading"
|
||
);
|
||
let mut reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format()?;
|
||
ensure!(
|
||
reader.format() == Some(image::ImageFormat::Png),
|
||
"HD artwork must be PNG"
|
||
);
|
||
reader.limits(image_limits());
|
||
let atlas = reader.decode()?.into_rgba8();
|
||
ensure!(
|
||
atlas.width() == info.width && atlas.height() == info.height,
|
||
"HD PNG changed while loading"
|
||
);
|
||
self.png_decodes += 1;
|
||
let rgba = selected_frame(&atlas, &frame, target_width, target_height, requires_alpha)?;
|
||
let bytes = atlas.as_raw().len();
|
||
self.atlases.insert(info.digest, atlas, bytes);
|
||
rgba
|
||
};
|
||
validate_rgba(&rgba, requires_alpha)?;
|
||
// Check every original PNG pixel before resampling can round a tiny
|
||
// alpha defect back to 255. Full-frame selection retains all pixels.
|
||
validate_opaque_rounding(&rgba, requires_opaque)?;
|
||
// Keep at most the highest usable 4x resolution, never generator originals.
|
||
if rgba.width() > target_width || rgba.height() > target_height {
|
||
rgba = imageops::resize(&rgba, target_width, target_height, FilterType::Lanczos3);
|
||
}
|
||
validate_rgba(&rgba, requires_alpha)?;
|
||
self.write_disk_cache(&key, &rgba);
|
||
self.rgba.insert(key, rgba.clone(), rgba.as_raw().len());
|
||
Ok((key, rgba))
|
||
})();
|
||
match result {
|
||
Ok(frame) => {
|
||
self.replacements.get_mut(&id).unwrap().validated = true;
|
||
Ok(Some(frame))
|
||
}
|
||
Err(error) => {
|
||
self.replacements.get_mut(&id).unwrap().rejected = true;
|
||
self.diagnostics
|
||
.push(format!("HD {id:05x}: {error:#}; using original"));
|
||
Ok(None)
|
||
}
|
||
}
|
||
}
|
||
fn cache_path(&self, key: &[u8; 32]) -> Option<PathBuf> {
|
||
self.disk_cache
|
||
.as_ref()
|
||
.map(|root| root.join(format!("{}.rgba", hex_digest(key))))
|
||
}
|
||
fn read_disk_cache(
|
||
&mut self,
|
||
key: &[u8; 32],
|
||
width: u32,
|
||
height: u32,
|
||
requires_alpha: bool,
|
||
requires_opaque: bool,
|
||
) -> Option<RgbaImage> {
|
||
let path = self.cache_path(key)?;
|
||
if !path.exists() {
|
||
return None;
|
||
}
|
||
let read = (|| -> Result<RgbaImage> {
|
||
let root = self.disk_cache.as_ref().unwrap();
|
||
let path = contained(root, path.file_name().context("cache filename")?.as_ref())?;
|
||
let expected = width as u64 * height as u64 * 4;
|
||
ensure!(
|
||
expected <= MAX_PNG_PIXELS * 4,
|
||
"derived cache exceeds pixel limit"
|
||
);
|
||
ensure!(
|
||
fs::metadata(&path)?.len() == CACHE_HEADER as u64 + expected,
|
||
"derived cache size mismatch"
|
||
);
|
||
let bytes = bounded_read(&path, CACHE_HEADER as u64 + expected)?;
|
||
ensure!(
|
||
bytes.len() == CACHE_HEADER + expected as usize,
|
||
"derived cache changed while reading"
|
||
);
|
||
ensure!(&bytes[..8] == CACHE_MAGIC, "derived cache version mismatch");
|
||
ensure!(
|
||
u32::from_le_bytes(bytes[8..12].try_into()?) == width
|
||
&& u32::from_le_bytes(bytes[12..16].try_into()?) == height,
|
||
"derived cache dimensions mismatch"
|
||
);
|
||
ensure!(&bytes[16..48] == key, "derived cache key mismatch");
|
||
ensure!(
|
||
Sha256::digest(&bytes[CACHE_HEADER..]).as_slice() == &bytes[48..80],
|
||
"derived cache checksum mismatch"
|
||
);
|
||
let rgba = RgbaImage::from_raw(width, height, bytes[CACHE_HEADER..].to_vec())
|
||
.context("derived cache image dimensions")?;
|
||
validate_rgba(&rgba, requires_alpha)?;
|
||
validate_opaque_rounding(&rgba, requires_opaque)?;
|
||
Ok(rgba)
|
||
})();
|
||
match read {
|
||
Ok(rgba) => Some(rgba),
|
||
Err(error) => {
|
||
self.diagnostics.push(format!(
|
||
"HD derived cache {}: {error:#}; rebuilding",
|
||
path.display()
|
||
));
|
||
// Removing a symlink removes the link itself, never its target.
|
||
let _ = fs::remove_file(path);
|
||
self.disk_bytes = None;
|
||
None
|
||
}
|
||
}
|
||
}
|
||
fn write_disk_cache(&mut self, key: &[u8; 32], rgba: &RgbaImage) {
|
||
let Some(path) = self.cache_path(key) else {
|
||
return;
|
||
};
|
||
let write = (|| -> Result<()> {
|
||
let root = self.disk_cache.as_ref().unwrap();
|
||
fs::create_dir_all(root)?;
|
||
let root = root.canonicalize()?;
|
||
let destination = root.join(path.file_name().context("cache filename")?);
|
||
let bytes = CACHE_HEADER as u64 + rgba.as_raw().len() as u64;
|
||
let old_bytes = fs::symlink_metadata(&destination)
|
||
.ok()
|
||
.filter(|metadata| metadata.is_file())
|
||
.map_or(0, |metadata| metadata.len());
|
||
self.prune_disk_cache(&root, &destination, bytes, old_bytes)?;
|
||
let temporary = root.join(format!(
|
||
".{}-{}-{}.tmp",
|
||
hex_digest(key),
|
||
std::process::id(),
|
||
self.disk_writes
|
||
));
|
||
let mut file = fs::OpenOptions::new()
|
||
.write(true)
|
||
.create_new(true)
|
||
.open(&temporary)?;
|
||
let result = (|| -> Result<()> {
|
||
file.write_all(CACHE_MAGIC)?;
|
||
file.write_all(&rgba.width().to_le_bytes())?;
|
||
file.write_all(&rgba.height().to_le_bytes())?;
|
||
file.write_all(key)?;
|
||
file.write_all(&Sha256::digest(rgba.as_raw()))?;
|
||
file.write_all(rgba.as_raw())?;
|
||
drop(file);
|
||
fs::rename(&temporary, destination)?;
|
||
self.disk_bytes =
|
||
Some(self.disk_bytes.unwrap_or(0).saturating_sub(old_bytes) + bytes);
|
||
Ok(())
|
||
})();
|
||
if result.is_err() {
|
||
let _ = fs::remove_file(&temporary);
|
||
}
|
||
result
|
||
})();
|
||
// Cache availability must never disable correctly decoded artwork.
|
||
if write.is_ok() {
|
||
self.disk_writes += 1;
|
||
}
|
||
}
|
||
fn prune_disk_cache(
|
||
&mut self,
|
||
root: &Path,
|
||
destination: &Path,
|
||
new_bytes: u64,
|
||
old_bytes: u64,
|
||
) -> Result<()> {
|
||
ensure!(
|
||
new_bytes <= self.disk_limit,
|
||
"derived frame exceeds disk cache budget"
|
||
);
|
||
// Most writes need no directory walk. Recheck periodically and whenever full.
|
||
if let Some(bytes) = self.disk_bytes {
|
||
if self.disk_writes % 64 != 0
|
||
&& bytes.saturating_sub(old_bytes) + new_bytes <= self.disk_limit
|
||
{
|
||
return Ok(());
|
||
}
|
||
}
|
||
let mut files = Vec::new();
|
||
let mut bytes = 0_u64;
|
||
for entry in fs::read_dir(root)? {
|
||
let entry = entry?;
|
||
let name = entry.file_name();
|
||
let name = name.to_string_lossy();
|
||
if name.len() != 69
|
||
|| !name.ends_with(".rgba")
|
||
|| !name.as_bytes()[..64]
|
||
.iter()
|
||
.all(|byte| byte.is_ascii_hexdigit())
|
||
|| !entry.file_type()?.is_file()
|
||
{
|
||
continue;
|
||
}
|
||
let metadata = entry.metadata()?;
|
||
bytes = bytes.saturating_add(metadata.len());
|
||
files.push((
|
||
metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
|
||
entry.path(),
|
||
metadata.len(),
|
||
));
|
||
}
|
||
files.sort_by(|left, right| (&left.0, &left.1).cmp(&(&right.0, &right.1)));
|
||
for (_, path, size) in files {
|
||
if bytes.saturating_sub(old_bytes) + new_bytes <= self.disk_limit {
|
||
break;
|
||
}
|
||
if path == destination {
|
||
continue;
|
||
}
|
||
if fs::remove_file(path).is_ok() {
|
||
bytes = bytes.saturating_sub(size);
|
||
}
|
||
}
|
||
self.disk_bytes = Some(bytes);
|
||
ensure!(
|
||
bytes.saturating_sub(old_bytes) + new_bytes <= self.disk_limit,
|
||
"derived disk cache budget unavailable"
|
||
);
|
||
Ok(())
|
||
}
|
||
/// Frames with valid catalog identity and file containment, including pending PNGs.
|
||
pub fn registered_count(&self) -> usize {
|
||
self.replacements
|
||
.values()
|
||
.filter(|frame| !frame.rejected)
|
||
.count()
|
||
}
|
||
/// Frames whose complete PNG, crop, transparency and dimensions were checked.
|
||
pub fn validated_count(&self) -> usize {
|
||
self.replacements
|
||
.values()
|
||
.filter(|frame| frame.validated && !frame.rejected)
|
||
.count()
|
||
}
|
||
/// Compatibility alias for decoded, accepted coverage. Lazy registration alone is not coverage.
|
||
pub fn coverage(&self) -> usize {
|
||
self.validated_count()
|
||
}
|
||
pub fn validate_all(&mut self) -> ValidationReport {
|
||
let ids: Vec<_> = self.replacements.keys().copied().collect();
|
||
for id in ids {
|
||
let _ = self.rgba_frame(id);
|
||
}
|
||
ValidationReport {
|
||
registered: self.registered_count(),
|
||
validated: self.validated_count(),
|
||
rejected: self.metadata_rejected
|
||
+ self
|
||
.replacements
|
||
.values()
|
||
.filter(|frame| frame.rejected)
|
||
.count(),
|
||
}
|
||
}
|
||
pub fn cache_stats(&self) -> CacheStats {
|
||
CacheStats {
|
||
atlas_bytes: self.atlases.bytes,
|
||
rgba_bytes: self.rgba.bytes,
|
||
indexed_bytes: self.scaled.bytes,
|
||
indexed_entries: self.scaled.entries.len(),
|
||
png_decodes: self.png_decodes,
|
||
disk_hits: self.disk_hits,
|
||
disk_writes: self.disk_writes,
|
||
source_label_bytes: self.source_label_bytes,
|
||
}
|
||
}
|
||
/// Return detailed artwork at canvas resolution; never run ScaleNx on it.
|
||
/// Indexed caches include palette identity, so fades/cycling cannot reuse stale colors.
|
||
pub fn sprite(
|
||
&mut self,
|
||
id: u32,
|
||
scale: usize,
|
||
palette: &[[u8; 3]; 256],
|
||
) -> Result<Option<Sprite>> {
|
||
self.sprite_with_policy(id, scale, palette, PalettePolicy::SourceClasses)
|
||
}
|
||
/// Standalone cards use all 256 colors of their own unfaded static palette.
|
||
/// Gameplay cycling/flash classes do not apply; fades happen at presentation.
|
||
pub fn sprite_static_palette(
|
||
&mut self,
|
||
id: u32,
|
||
scale: usize,
|
||
palette: &[[u8; 3]; 256],
|
||
) -> Result<Option<Sprite>> {
|
||
self.sprite_with_policy(id, scale, palette, PalettePolicy::Static)
|
||
}
|
||
/// Baseline source-class HD plus trusted retention metadata, for terrain.
|
||
/// Condition shared join inputs with joined Original samples, then restore
|
||
/// exact retained footprints after all neighbor joins.
|
||
pub fn terrain_sprite(&mut self, id: u32, scale: usize, palette: &[[u8; 3]; 256])
|
||
-> Result<Option<(Sprite, Option<OriginalDetailCells>)>>
|
||
{
|
||
let has_details = self.replacements.get(&id)
|
||
.is_some_and(|r| !r.frame.original_detail_cells.is_empty());
|
||
let policy = if has_details { PalettePolicy::SourceClassesBaseline }
|
||
else { PalettePolicy::SourceClasses };
|
||
let Some(image) = self.sprite_with_policy(id, scale, palette, policy)? else { return Ok(None) };
|
||
let details = self.replacements.get(&id).filter(|r| !r.frame.original_detail_cells.is_empty())
|
||
.map(|r| OriginalDetailCells { cells: r.frame.original_detail_cells.clone(),
|
||
logical_size: [r.frame.logical_width as usize, r.frame.logical_height as usize] });
|
||
Ok(Some((image, details)))
|
||
}
|
||
pub fn original_detail_source_bytes(&self) -> usize { self.original_detail_source_bytes }
|
||
/// None for all default frames, without opening/decoding their PNGs.
|
||
/// Opt-ins bind actual resident raw identity; failed eligibility cannot reuse
|
||
/// the formerly valid hybrid prepared layer.
|
||
pub fn original_detail_cache_identity(&mut self, id: u32) -> Option<[u8; 32]> {
|
||
let frame = self.replacements.get(&id)?.frame.clone();
|
||
if frame.original_detail_cells.is_empty() { return None; }
|
||
if let Ok(Some((key, _))) = self.rgba_frame(id) { return Some(key); }
|
||
let mut digest = Sha256::new();
|
||
digest.update(original_detail_policy_tag(frame.original_detail_cells.len(), true));
|
||
digest.update(serde_json::to_vec(&frame).ok()?);
|
||
Some(digest.finalize().into())
|
||
}
|
||
fn sprite_with_policy(
|
||
&mut self,
|
||
id: u32,
|
||
scale: usize,
|
||
palette: &[[u8; 3]; 256],
|
||
policy: PalettePolicy,
|
||
) -> Result<Option<Sprite>> {
|
||
ensure!(
|
||
(1..=4).contains(&scale),
|
||
"HD presentation scale must be 1..4"
|
||
);
|
||
let palette_key: [u8; 32] = Sha256::digest(palette.as_flattened()).into();
|
||
// Common repeated draws should only copy the final indexed canvas.
|
||
if let Some(record) = self.replacements.get(&id).filter(|record| !record.rejected) {
|
||
let frame = record.frame.clone();
|
||
let requires_alpha = record.requires_alpha;
|
||
if let Ok((_, info)) = self.png(&frame.file) {
|
||
let key = Self::frame_key(&frame, &info, requires_alpha);
|
||
if let Some(sprite) = self.scaled.get(&(key, scale, palette_key, policy)).cloned() {
|
||
self.replacements.get_mut(&id).unwrap().validated = true;
|
||
return Ok(Some(sprite));
|
||
}
|
||
}
|
||
}
|
||
let Some((key, rgba)) = self.rgba_frame(id)? else {
|
||
return Ok(None);
|
||
};
|
||
let scaled_key = (key, scale, palette_key, policy);
|
||
let replacement = self.replacements.get(&id).unwrap();
|
||
let frame = &replacement.frame;
|
||
let width = frame.logical_width as usize * scale;
|
||
let height = frame.logical_height as usize * scale;
|
||
let resized = imageops::resize(&rgba, width as u32, height as u32, FilterType::Lanczos3);
|
||
let mut sprite = Sprite::blank(width, height);
|
||
let mut colors = BTreeMap::new();
|
||
for (index, (destination, pixel)) in
|
||
sprite.pixels.iter_mut().zip(resized.pixels()).enumerate()
|
||
{
|
||
let pixel = if policy.source_classes()
|
||
&& scale == 1
|
||
&& !frame.native_nearest_cues.is_empty()
|
||
&& frame
|
||
.native_nearest_cues
|
||
.binary_search(&[(index % width) as u32, (index / width) as u32])
|
||
.is_ok()
|
||
{
|
||
// Exact nearest sample center, using integer ratios to avoid
|
||
// temporary canvases/float boundary rounding. No RGB remap.
|
||
let x = ((2 * (index % width) as u64 + 1) * rgba.width() as u64
|
||
/ (2 * frame.logical_width as u64)) as u32;
|
||
let y = ((2 * (index / width) as u64 + 1) * rgba.height() as u64
|
||
/ (2 * frame.logical_height as u64)) as u32;
|
||
rgba.get_pixel(x, y)
|
||
} else {
|
||
pixel
|
||
};
|
||
let logical =
|
||
(index / width / scale) * frame.logical_width as usize + (index % width / scale);
|
||
let source_label = if policy.source_classes() {
|
||
replacement
|
||
.source_labels
|
||
.as_ref()
|
||
.map_or(0, |labels| labels[logical])
|
||
} else {
|
||
0
|
||
};
|
||
if pixel[3] < 128 {
|
||
// Require meaningful existing alpha support in the bounded
|
||
// resident logical cell; missing/mostly empty artwork remains
|
||
// transparent. This guard is not source-fidelity approval.
|
||
let supported = policy.source_classes()
|
||
&& frame.preserve_reserved_coverage
|
||
&& (250..=255).contains(&source_label)
|
||
&& reserved_cell_supported(&rgba, frame, logical);
|
||
if !supported {
|
||
continue;
|
||
}
|
||
}
|
||
let rgb = [pixel[0], pixel[1], pixel[2]];
|
||
*destination = *colors
|
||
.entry((rgb, source_label))
|
||
.or_insert_with(|| match policy {
|
||
PalettePolicy::SourceClasses | PalettePolicy::SourceClassesBaseline => closest(palette, rgb, source_label),
|
||
PalettePolicy::Static => closest_in_range(palette, rgb, 0..256),
|
||
});
|
||
}
|
||
if policy == PalettePolicy::SourceClasses && !frame.original_detail_cells.is_empty() {
|
||
let original = replacement.original_detail_source.as_ref()
|
||
.context("validated retained Original reference missing")?;
|
||
let control = crate::render::upscale(original, scale)?;
|
||
retain_original_detail_samples(&mut sprite, &control, &frame.original_detail_cells, scale)?;
|
||
}
|
||
self.scaled
|
||
.insert(scaled_key, sprite.clone(), sprite.pixels.len() * 2);
|
||
Ok(Some(sprite))
|
||
}
|
||
}
|
||
/// At most 5x5 samples: resident RGBA is capped at4x logical dimensions.
|
||
/// Fractional cell edges support valid non-integral2x..4x source canvases.
|
||
/// Both mean alpha and alpha>=128 area must cover at least half the cell.
|
||
fn reserved_cell_supported(rgba: &RgbaImage, frame: &Frame, logical: usize) -> bool {
|
||
let sx = rgba.width() as f64 / frame.logical_width as f64;
|
||
let sy = rgba.height() as f64 / frame.logical_height as f64;
|
||
let x = logical % frame.logical_width as usize;
|
||
let y = logical / frame.logical_width as usize;
|
||
let (x0, x1, y0, y1) = (
|
||
x as f64 * sx,
|
||
((x + 1) as f64 * sx).min(rgba.width() as f64),
|
||
y as f64 * sy,
|
||
((y + 1) as f64 * sy).min(rgba.height() as f64),
|
||
);
|
||
let mut alpha = 0.0;
|
||
let mut covered = 0.0;
|
||
for yy in y0.floor() as u32..y1.ceil() as u32 {
|
||
for xx in x0.floor() as u32..x1.ceil() as u32 {
|
||
let weight = (x1.min(xx as f64 + 1.0) - x0.max(xx as f64)).max(0.0)
|
||
* (y1.min(yy as f64 + 1.0) - y0.max(yy as f64)).max(0.0);
|
||
let a = rgba.get_pixel(xx, yy)[3];
|
||
alpha += weight * a as f64;
|
||
if a >= 128 {
|
||
covered += weight;
|
||
}
|
||
}
|
||
}
|
||
alpha >= 128.0 * sx * sy && covered >= 0.5 * sx * sy
|
||
}
|
||
fn bounded_read(path: &Path, limit: u64) -> Result<Vec<u8>> {
|
||
let file = fs::File::open(path)?;
|
||
let size = file.metadata()?.len();
|
||
ensure!(size <= limit, "file exceeds {limit} byte limit");
|
||
let mut bytes = Vec::with_capacity(size as usize);
|
||
file.take(limit + 1).read_to_end(&mut bytes)?;
|
||
ensure!(
|
||
bytes.len() as u64 <= limit,
|
||
"file grew beyond {limit} byte limit"
|
||
);
|
||
Ok(bytes)
|
||
}
|
||
fn cropped_frame(atlas: &RgbaImage, crop: Option<[u32; 4]>) -> RgbaImage {
|
||
if let Some([x, y, width, height]) = crop {
|
||
imageops::crop_imm(atlas, x, y, width, height).to_image()
|
||
} else {
|
||
atlas.clone()
|
||
}
|
||
}
|
||
fn validate_grid_metadata(frame: &Frame) -> Result<()> {
|
||
if let Some(grid) = frame.atlas_grid {
|
||
ensure!(frame.crop.is_none(), "atlas_grid and crop are mutually exclusive");
|
||
ensure!(grid.columns > 0 && grid.rows > 0, "atlas grid axes must be nonzero");
|
||
ensure!(grid.columns as u64 * grid.rows as u64 <= MAX_FRAMES as u64,
|
||
"atlas grid exceeds cell limit");
|
||
ensure!(grid.column < grid.columns && grid.row < grid.rows,
|
||
"atlas grid cell is outside its declared axes");
|
||
}
|
||
Ok(())
|
||
}
|
||
#[derive(Clone, Copy)]
|
||
struct GridViewport {
|
||
left: f64,
|
||
top: f64,
|
||
right: f64,
|
||
bottom: f64,
|
||
// Bounding raw pixels with positive area inside this viewport. Fractional
|
||
// boundary pixels remain shared; no strips are dropped or pixels rewritten.
|
||
pixels: [u32; 4],
|
||
resident: [u32; 2],
|
||
}
|
||
fn grid_viewport(frame: &Frame, width: u32, height: u32) -> Result<GridViewport> {
|
||
validate_grid_metadata(frame)?;
|
||
let grid = frame.atlas_grid.context("missing atlas grid")?;
|
||
ensure!(width > 0 && height > 0 && frame.logical_width > 0 && frame.logical_height > 0,
|
||
"invalid atlas or logical dimensions");
|
||
// All eligibility arithmetic is exact. Only the resampling kernel converts
|
||
// these rational bounds to floating point; there is no integer crop fitting.
|
||
ensure!(width as u128 * frame.logical_height as u128 * grid.rows as u128
|
||
== height as u128 * frame.logical_width as u128 * grid.columns as u128,
|
||
"rational atlas cell aspect differs from original canvas");
|
||
let x_den = grid.columns as u64 * frame.logical_width as u64;
|
||
let y_den = grid.rows as u64 * frame.logical_height as u64;
|
||
ensure!(width as u64 >= 2 * x_den && height as u64 >= 2 * y_den,
|
||
"rational atlas cell needs at least twice the logical resolution");
|
||
let factor = (width as u64 / x_den).min(height as u64 / y_den).min(4) as u32;
|
||
let x0 = width as u64 * grid.column as u64;
|
||
let x1 = width as u64 * (grid.column as u64 + 1);
|
||
let y0 = height as u64 * grid.row as u64;
|
||
let y1 = height as u64 * (grid.row as u64 + 1);
|
||
Ok(GridViewport {
|
||
left: x0 as f64 / grid.columns as f64,
|
||
right: x1 as f64 / grid.columns as f64,
|
||
top: y0 as f64 / grid.rows as f64,
|
||
bottom: y1 as f64 / grid.rows as f64,
|
||
pixels: [(x0 / grid.columns as u64) as u32,
|
||
(y0 / grid.rows as u64) as u32,
|
||
x1.div_ceil(grid.columns as u64) as u32,
|
||
y1.div_ceil(grid.rows as u64) as u32],
|
||
resident: [frame.logical_width * factor, frame.logical_height * factor],
|
||
})
|
||
}
|
||
fn grid_lanczos(value: f64) -> f64 {
|
||
if value == 0.0 { return 1.0; }
|
||
if value.abs() >= 3.0 { return 0.0; }
|
||
let value = value * std::f64::consts::PI;
|
||
(value.sin() / value) * ((value / 3.0).sin() / (value / 3.0))
|
||
}
|
||
fn grid_axis_weights(start: f64, end: f64, output: u32) -> Result<Vec<Vec<(u32, f32)>>> {
|
||
let scale = ((end - start) / output as f64).max(1.0);
|
||
(0..output).map(|destination| {
|
||
let center = start + (destination as f64 + 0.5) * (end - start) / output as f64;
|
||
let lower = (center - 3.0 * scale).floor().max(start.floor()) as u32;
|
||
let upper = (center + 3.0 * scale).ceil().min(end.ceil()) as u32;
|
||
let mut weights: Vec<(u32, f64)> = (lower..upper).filter_map(|index| {
|
||
let overlap = ((index as f64 + 1.0).min(end) - (index as f64).max(start)).max(0.0);
|
||
let weight = grid_lanczos((index as f64 + 0.5 - center) / scale) * overlap;
|
||
(weight != 0.0).then_some((index, weight))
|
||
}).collect();
|
||
let total: f64 = weights.iter().map(|(_, value)| *value).sum();
|
||
// Positive-size validated viewports and center-supported Lanczos always
|
||
// have a nonzero sum; keep this deterministic precondition explicit.
|
||
ensure!(total.is_finite() && total.abs() > 1e-12, "invalid rational atlas filter weights");
|
||
Ok(weights.drain(..).map(|(index, weight)| (index, (weight / total) as f32)).collect())
|
||
}).collect()
|
||
}
|
||
fn selected_frame(atlas: &RgbaImage, frame: &Frame, width: u32, height: u32,
|
||
requires_alpha: bool) -> Result<RgbaImage> {
|
||
if frame.atlas_grid.is_none() { return Ok(cropped_frame(atlas, frame.crop)); }
|
||
let viewport = grid_viewport(frame, atlas.width(), atlas.height())?;
|
||
ensure!(viewport.resident == [width, height], "rational viewport resident mismatch");
|
||
let [left, top, right, bottom] = viewport.pixels;
|
||
if viewport.left.fract() == 0.0 && viewport.top.fract() == 0.0
|
||
&& viewport.right.fract() == 0.0 && viewport.bottom.fract() == 0.0 {
|
||
// Integral cells keep the existing crop/resize implementation. In
|
||
// particular a 1x1 opt-in never allocates a new floating-point canvas.
|
||
return Ok(cropped_frame(atlas, Some([left, top, right-left, bottom-top])));
|
||
}
|
||
let mut transparent = false;
|
||
let mut visible = false;
|
||
for y in top..bottom {
|
||
for x in left..right {
|
||
let alpha = atlas.get_pixel(x, y)[3];
|
||
transparent |= alpha == 0;
|
||
visible |= alpha >= 128;
|
||
}
|
||
}
|
||
ensure!(!requires_alpha || transparent, "HD frame needs a transparent background matching its original");
|
||
ensure!(visible, "HD frame has no visible pixels");
|
||
let x_weights = grid_axis_weights(viewport.left, viewport.right, width)?;
|
||
let y_weights = grid_axis_weights(viewport.top, viewport.bottom, height)?;
|
||
// A separable in-memory sampler; no export/padding/alpha repair. Only this
|
||
// cell's raw pixel areas participate, including fractional boundary areas.
|
||
let rows = (bottom - top) as usize;
|
||
ensure!(width as usize * rows <= MAX_PNG_PIXELS as usize,
|
||
"rational atlas temporary canvas exceeds limit");
|
||
let mut horizontal = vec![[0.0f32; 4]; width as usize * rows];
|
||
for y in top..bottom {
|
||
for (x, weights) in x_weights.iter().enumerate() {
|
||
let sample = &mut horizontal[(y - top) as usize * width as usize + x];
|
||
for &(source_x, weight) in weights {
|
||
let pixel = atlas.get_pixel(source_x, y);
|
||
for channel in 0..4 { sample[channel] += pixel[channel] as f32 * weight; }
|
||
}
|
||
}
|
||
}
|
||
let mut output = RgbaImage::new(width, height);
|
||
for (y, weights) in y_weights.iter().enumerate() {
|
||
for x in 0..width as usize {
|
||
let mut sample = [0.0f32; 4];
|
||
for &(source_y, weight) in weights {
|
||
let pixel = horizontal[(source_y - top) as usize * width as usize + x];
|
||
for channel in 0..4 { sample[channel] += pixel[channel] * weight; }
|
||
}
|
||
output.put_pixel(x as u32, y as u32,
|
||
image::Rgba(sample.map(|value| value.clamp(0.0, 255.0).round() as u8)));
|
||
}
|
||
}
|
||
Ok(output)
|
||
}
|
||
fn image_limits() -> image::Limits {
|
||
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);
|
||
limits
|
||
}
|
||
fn exact_aspect(width: u32, height: u32, logical_width: u32, logical_height: u32) -> bool {
|
||
width as u128 * logical_height as u128 == height as u128 * logical_width as u128
|
||
}
|
||
/// A common scale must fit both integer dimensions with less than half a pixel
|
||
/// of rounding on each axis. Strict inequality excludes an overlap consisting
|
||
/// only of conflicting half-way ties (for example, a square PNG with unequal axes).
|
||
fn integer_rounded_aspect(
|
||
width: u32,
|
||
height: u32,
|
||
logical_width: u32,
|
||
logical_height: u32,
|
||
) -> bool {
|
||
if width == 0 || height == 0 || logical_width == 0 || logical_height == 0 {
|
||
return false;
|
||
}
|
||
let a = width as u128 * logical_height as u128;
|
||
let b = height as u128 * logical_width as u128;
|
||
2 * a.abs_diff(b) < logical_width as u128 + logical_height as u128
|
||
}
|
||
// Dimension eligibility is independent of alpha; rgba_frame checks source-aware alpha.
|
||
fn frame_dimensions(frame: &Frame, png: &PngInfo, _requires_alpha: bool) -> Result<(u32, u32)> {
|
||
if frame.atlas_grid.is_some() {
|
||
let viewport = grid_viewport(frame, png.width, png.height)?;
|
||
return Ok((viewport.resident[0], viewport.resident[1]));
|
||
}
|
||
let (width, height) = if let Some([x, y, width, height]) = frame.crop {
|
||
ensure!(
|
||
width > 0
|
||
&& height > 0
|
||
&& x.checked_add(width).is_some_and(|n| n <= png.width)
|
||
&& y.checked_add(height).is_some_and(|n| n <= png.height),
|
||
"HD atlas crop exceeds its PNG"
|
||
);
|
||
(width, height)
|
||
} else {
|
||
(png.width, png.height)
|
||
};
|
||
ensure!(
|
||
exact_aspect(width, height, frame.logical_width, frame.logical_height)
|
||
|| (width == png.width
|
||
&& height == png.height
|
||
&& integer_rounded_aspect(
|
||
width,
|
||
height,
|
||
frame.logical_width,
|
||
frame.logical_height,
|
||
)),
|
||
"HD frame aspect must match the original canvas; only a full frame may use subpixel integer rounding"
|
||
);
|
||
ensure!(
|
||
width as u64 >= frame.logical_width as u64 * 2
|
||
&& height as u64 >= frame.logical_height as u64 * 2,
|
||
"HD artwork must supply at least twice the logical resolution"
|
||
);
|
||
Ok((width, height))
|
||
}
|
||
fn validate_opaque_rounding(rgba: &RgbaImage, requires_opaque: bool) -> Result<()> {
|
||
ensure!(
|
||
!requires_opaque || rgba.pixels().all(|pixel| pixel[3] == 255),
|
||
"HD integer-aspect rounding requires a fully opaque source and PNG"
|
||
);
|
||
Ok(())
|
||
}
|
||
fn validate_rgba(rgba: &RgbaImage, requires_alpha: bool) -> Result<()> {
|
||
ensure!(
|
||
!requires_alpha || rgba.pixels().any(|p| p[3] == 0),
|
||
"HD frame needs a transparent background matching its original"
|
||
);
|
||
ensure!(
|
||
rgba.pixels().any(|p| p[3] >= 128),
|
||
"HD frame has no visible pixels"
|
||
);
|
||
Ok(())
|
||
}
|
||
fn validate_frame(frame: &Frame, original: &Sprite, source: &[u8]) -> Result<()> {
|
||
validate_grid_metadata(frame)?;
|
||
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.native_nearest_cues.len() <= 64,
|
||
"native nearest cue list exceeds64 cells"
|
||
);
|
||
ensure!(
|
||
frame.native_nearest_cues.windows(2).all(|w| w[0] < w[1]),
|
||
"native nearest cues must be sorted and unique"
|
||
);
|
||
ensure!(
|
||
frame.native_nearest_cues.is_empty() || !original.pixels.contains(&TRANSPARENT),
|
||
"native nearest cues are limited to opaque original gameplay canvases"
|
||
);
|
||
for &[x, y] in &frame.native_nearest_cues {
|
||
ensure!(
|
||
x < frame.logical_width && y < frame.logical_height,
|
||
"native nearest cue outside original logical canvas"
|
||
);
|
||
ensure!(
|
||
original.pixels[y as usize * original.width + x as usize] < 240,
|
||
"native nearest cue must be an ordinary original source cell"
|
||
);
|
||
}
|
||
ensure!(original_detail_bound(&frame.original_detail_cells, original),
|
||
"Original details need<=64 legacy cells or65..128 cells within one quarter of painted Source");
|
||
ensure!(frame.original_detail_cells.windows(2).all(|w| w[0] < w[1]), "Original details sorted and unique");
|
||
for &[x, y] in &frame.original_detail_cells {
|
||
ensure!(x < frame.logical_width && y < frame.logical_height, "Original detail outside logical canvas");
|
||
ensure!(original.pixels[y as usize * original.width + x as usize] < TRANSPARENT,
|
||
"retained Original detail must be a painted Source index0–255");
|
||
ensure!(frame.native_nearest_cues.binary_search(&[x, y]).is_err(),
|
||
"Original detail and PNG-nearest policies may not overlap");
|
||
}
|
||
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(())
|
||
}
|
||
// Count decoded painted Source indices, not canvas area or generated PNG alpha.
|
||
// Legacy lists keep all prior validation/sampling/cache behavior. The caller
|
||
// still validates coordinates, order, uniqueness, painted membership and cues.
|
||
fn original_detail_bound(cells: &[[u32; 2]], original: &Sprite) -> bool {
|
||
let count = cells.len();
|
||
count <= 64 || (count <= 128
|
||
&& count * 4 <= original.pixels.iter().filter(|&&p| p <= 255).count())
|
||
}
|
||
fn original_detail_policy_tag(count: usize, fallback: bool) -> &'static [u8] {
|
||
match (count <= 64, fallback) {
|
||
(true, false) => b"raptor-retained-original-details-v2-painted0255",
|
||
(true, true) => b"retained-original-rejected-fallback-v2-painted0255",
|
||
(false, false) => b"raptor-retained-original-details-v3-max128-quarter-painted0255",
|
||
(false, true) => b"retained-original-rejected-fallback-v3-max128-quarter-painted0255",
|
||
}
|
||
}
|
||
fn source_reference_budget(labels: usize, details: usize, new_labels: usize, new_details: usize) -> bool {
|
||
labels.checked_add(details).and_then(|n| n.checked_add(new_labels))
|
||
.and_then(|n| n.checked_add(new_details)).is_some_and(|n| n <= MAX_SOURCE_LABEL_BYTES)
|
||
}
|
||
fn retain_original_detail_samples(image: &mut Sprite, original_control: &Sprite,
|
||
cells: &[[u32; 2]], scale: usize) -> Result<()> {
|
||
ensure!((1..=4).contains(&scale) && image.width == original_control.width
|
||
&& image.height == original_control.height && image.pixels.len() == original_control.pixels.len(),
|
||
"retained Original control extent");
|
||
for &[x, y] in cells {
|
||
for dy in 0..scale {
|
||
let start = (y as usize * scale + dy) * image.width + x as usize * scale;
|
||
ensure!(start + scale <= image.pixels.len(), "retained Original footprint extent");
|
||
image.pixels[start..start+scale].copy_from_slice(&original_control.pixels[start..start+scale]);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
fn hex_digest(key: &[u8; 32]) -> String {
|
||
key.iter().map(|byte| format!("{byte:02x}")).collect()
|
||
}
|
||
fn closest(palette: &[[u8; 3]; 256], rgb: [u8; 3], source_label: u8) -> u16 {
|
||
let range = match source_label {
|
||
240..=244 => 240..245,
|
||
245..=249 => 245..250,
|
||
250..=255 => return source_label as u16,
|
||
_ => 0..240,
|
||
};
|
||
closest_in_range(palette, rgb, range)
|
||
}
|
||
fn closest_in_range(palette: &[[u8; 3]; 256], rgb: [u8; 3], range: std::ops::Range<usize>) -> u16 {
|
||
palette
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(index, _)| range.contains(index))
|
||
.min_by_key(|(_, color)| {
|
||
(0..3)
|
||
.map(|j| (color[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, atlas_grid: None,
|
||
preserve_reserved_coverage: false,
|
||
native_nearest_cues: Vec::new(),
|
||
original_detail_cells: Vec::new(),
|
||
}
|
||
}
|
||
#[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 dir = tempfile::tempdir().unwrap();
|
||
rgba.save(dir.path().join("ship.png")).unwrap();
|
||
let mut art = HdAssets {
|
||
root: dir.path().to_path_buf(),
|
||
..HdAssets::default()
|
||
};
|
||
art.replacements.insert(
|
||
1,
|
||
Replacement {
|
||
frame: frame(source),
|
||
requires_alpha: true,
|
||
source_labels: None,
|
||
original_detail_source: None,
|
||
validated: false,
|
||
rejected: false,
|
||
},
|
||
);
|
||
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 hd_colors_keep_source_animation_classes_at_every_scale() {
|
||
let (dir, catalog, mut manifest) = fixture();
|
||
let rgb = [255, 100, 30];
|
||
RgbaImage::from_pixel(8, 8, image::Rgba([rgb[0], rgb[1], rgb[2], 255]))
|
||
.save(dir.path().join("hd/ship.png"))
|
||
.unwrap();
|
||
let mut palette = [[0; 3]; 256];
|
||
palette[7] = [240, 90, 30];
|
||
palette[244] = rgb;
|
||
palette[245] = rgb;
|
||
palette[251] = rgb;
|
||
let mut ordinary = pack(dir.path(), &catalog, &manifest);
|
||
for scale in 1..=4 {
|
||
let sprite = ordinary.sprite(1, scale, &palette).unwrap().unwrap();
|
||
assert!(
|
||
sprite.pixels.iter().all(|pixel| *pixel == 7),
|
||
"static highlights must not acquire water, fire or blink indices"
|
||
);
|
||
}
|
||
assert_eq!(ordinary.cache_stats().source_label_bytes, 0);
|
||
|
||
let mut source = fs::read(dir.path().join("source")).unwrap();
|
||
source[20..24].copy_from_slice(&[5, 242, 247, 253]);
|
||
fs::write(dir.path().join("source"), &source).unwrap();
|
||
manifest.sequences[0].frames[0].source_sha256 = format!("{:x}", Sha256::digest(&source));
|
||
let mut animated = pack(dir.path(), &catalog, &manifest);
|
||
for scale in 1..=4 {
|
||
let sprite = animated.sprite(1, scale, &palette).unwrap().unwrap();
|
||
for y in 0..sprite.height {
|
||
for x in 0..sprite.width {
|
||
assert_eq!(
|
||
sprite.pixels[y * sprite.width + x],
|
||
[7, 244, 245, 253][(y / scale) * 2 + x / scale],
|
||
"palette class changed at scale {scale}, ({x},{y})"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
assert_eq!(animated.cache_stats().source_label_bytes, 4);
|
||
assert!(animated.cache_stats().source_label_bytes <= MAX_SOURCE_LABEL_BYTES);
|
||
}
|
||
#[test]
|
||
fn static_card_palettes_use_all_colors_without_crossing_gameplay_cache_policy() {
|
||
let (dir, catalog, mut manifest) = fixture();
|
||
let rgb = [255, 100, 30];
|
||
RgbaImage::from_pixel(8, 8, image::Rgba([rgb[0], rgb[1], rgb[2], 255]))
|
||
.save(dir.path().join("hd/ship.png"))
|
||
.unwrap();
|
||
let mut source = fs::read(dir.path().join("source")).unwrap();
|
||
source[20..24].copy_from_slice(&[5, 242, 247, 253]);
|
||
fs::write(dir.path().join("source"), &source).unwrap();
|
||
manifest.sequences[0].frames[0].source_sha256 = format!("{:x}", Sha256::digest(&source));
|
||
let mut assets = pack(dir.path(), &catalog, &manifest);
|
||
let mut palette = [[0; 3]; 256];
|
||
for index in [7, 243, 247] {
|
||
palette[index] = [240, 90, 30];
|
||
}
|
||
palette[251] = rgb;
|
||
for scale in 1..=4 {
|
||
for static_palette in [true, false, true] {
|
||
let sprite = if static_palette {
|
||
assets.sprite_static_palette(1, scale, &palette)
|
||
} else {
|
||
assets.sprite(1, scale, &palette)
|
||
}
|
||
.unwrap()
|
||
.unwrap();
|
||
for y in 0..sprite.height {
|
||
for x in 0..sprite.width {
|
||
assert_eq!(
|
||
sprite.pixels[y * sprite.width + x],
|
||
if static_palette {
|
||
251
|
||
} else {
|
||
[7, 243, 247, 253][(y / scale) * 2 + x / scale]
|
||
},
|
||
"palette policy collision: static={static_palette}, scale={scale}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let before = assets.cache_stats();
|
||
assert_eq!(before.indexed_entries, 8);
|
||
assert_eq!(before.png_decodes, 1);
|
||
palette[251] = [0; 3];
|
||
palette[252] = rgb;
|
||
assert!(
|
||
assets
|
||
.sprite_static_palette(1, 4, &palette)
|
||
.unwrap()
|
||
.unwrap()
|
||
.pixels
|
||
.iter()
|
||
.all(|pixel| *pixel == 252)
|
||
);
|
||
let after = assets.cache_stats();
|
||
assert_eq!(after.indexed_entries, 9);
|
||
assert_eq!(after.png_decodes, before.png_decodes);
|
||
assert_eq!(after.rgba_bytes, before.rgba_bytes);
|
||
assert_eq!(after.disk_writes, before.disk_writes);
|
||
}
|
||
#[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());
|
||
}
|
||
#[test]
|
||
fn opaque_originals_accept_opaque_hd_but_transparent_originals_require_alpha() {
|
||
let (dir, catalog, manifest) = fixture();
|
||
let png_path = dir
|
||
.path()
|
||
.join("hd")
|
||
.join(&manifest.sequences[0].frames[0].file);
|
||
image::RgbaImage::from_pixel(8, 8, image::Rgba([80, 100, 120, 255]))
|
||
.save(&png_path)
|
||
.unwrap();
|
||
let source = catalog.read(manifest.sequences[0].base_asset).unwrap();
|
||
let original = Sprite::decode(&source).unwrap();
|
||
let assets = pack(dir.path(), &catalog, &manifest);
|
||
if original.pixels.contains(&TRANSPARENT) {
|
||
assert_eq!(assets.coverage(), 0);
|
||
} else {
|
||
assert_eq!(assets.coverage(), 1);
|
||
}
|
||
// A genuine span sprite must keep transparent negative space.
|
||
let span = [0_i32, 0, 0, 2, 2, 0, 0, 0, 1]
|
||
.into_iter()
|
||
.flat_map(i32::to_le_bytes)
|
||
.chain([7])
|
||
.chain([0_i32, 0, -1, 0].into_iter().flat_map(i32::to_le_bytes))
|
||
.collect::<Vec<_>>();
|
||
std::fs::write(dir.path().join("source"), &span).unwrap();
|
||
std::fs::write(
|
||
dir.path().join("INDEX"),
|
||
format!("00001 {} source\n", span.len()),
|
||
)
|
||
.unwrap();
|
||
let catalog = Catalog::open(dir.path()).unwrap();
|
||
let mut manifest = manifest;
|
||
manifest.sequences[0].frames[0].source_sha256 = format!("{:x}", Sha256::digest(&span));
|
||
let assets = pack(dir.path(), &catalog, &manifest);
|
||
assert_eq!(assets.coverage(), 0);
|
||
assert!(
|
||
assets
|
||
.diagnostics
|
||
.iter()
|
||
.any(|diagnostic| diagnostic.contains("transparent background"))
|
||
);
|
||
}
|
||
fn lazy_pack(
|
||
dir: &Path,
|
||
catalog: &Catalog,
|
||
manifest: &Manifest,
|
||
cache: Option<&Path>,
|
||
) -> HdAssets {
|
||
fs::write(
|
||
dir.join("hd/hd.json"),
|
||
serde_json::to_vec(manifest).unwrap(),
|
||
)
|
||
.unwrap();
|
||
HdAssets::load_lazy(&dir.join("hd"), catalog, cache)
|
||
}
|
||
#[test]
|
||
fn full_catalog_sized_pack_registers_and_validates_without_resident_growth() {
|
||
let (dir, _, mut manifest) = fixture();
|
||
let source = fs::read(dir.path().join("source")).unwrap();
|
||
let count = 5753;
|
||
fs::write(
|
||
dir.path().join("INDEX"),
|
||
(1..=count)
|
||
.map(|id| format!("{id:05x} {} source\n", source.len()))
|
||
.collect::<String>(),
|
||
)
|
||
.unwrap();
|
||
let catalog = Catalog::open(dir.path()).unwrap();
|
||
manifest.sequences = (1..=count)
|
||
.map(|id| Sequence {
|
||
name: format!("asset-{id}"),
|
||
base_asset: id,
|
||
frame_count: 1,
|
||
frames: vec![frame(&source)],
|
||
})
|
||
.collect();
|
||
let mut assets = lazy_pack(dir.path(), &catalog, &manifest, None);
|
||
assert!(assets.diagnostics.is_empty());
|
||
assert_eq!(assets.registered_count(), count as usize);
|
||
assert_eq!(assets.coverage(), 0);
|
||
assert_eq!(assets.cache_stats().png_decodes, 0);
|
||
assets.sprite(5000, 4, &[[0; 3]; 256]).unwrap().unwrap();
|
||
assert_eq!(assets.validated_count(), 1);
|
||
let report = assets.validate_all();
|
||
assert_eq!(
|
||
report,
|
||
ValidationReport {
|
||
registered: count as usize,
|
||
validated: count as usize,
|
||
rejected: 0
|
||
}
|
||
);
|
||
let stats = assets.cache_stats();
|
||
assert_eq!(stats.png_decodes, 1);
|
||
assert!(stats.atlas_bytes + stats.rgba_bytes + stats.indexed_bytes < 1024);
|
||
}
|
||
#[test]
|
||
fn lazy_corrupt_png_falls_back_once_and_never_claims_decoded_coverage() {
|
||
let (dir, catalog, manifest) = fixture();
|
||
fs::write(dir.path().join("hd/ship.png"), b"corrupt png").unwrap();
|
||
let mut assets = lazy_pack(dir.path(), &catalog, &manifest, None);
|
||
assert_eq!(assets.registered_count(), 1);
|
||
assert_eq!(assets.validated_count(), 0);
|
||
assert!(assets.sprite(1, 4, &[[0; 3]; 256]).unwrap().is_none());
|
||
let diagnostics = assets.diagnostics.len();
|
||
assert!(assets.sprite(1, 4, &[[0; 3]; 256]).unwrap().is_none());
|
||
assert_eq!(assets.diagnostics.len(), diagnostics);
|
||
assert_eq!(assets.registered_count(), 0);
|
||
assert_eq!(assets.coverage(), 0);
|
||
}
|
||
#[test]
|
||
fn indexed_cache_tracks_palette_identity() {
|
||
let (dir, catalog, manifest) = fixture();
|
||
let mut assets = lazy_pack(dir.path(), &catalog, &manifest, None);
|
||
let mut palette = [[0; 3]; 256];
|
||
palette[100] = [100, 20, 20];
|
||
assert_eq!(
|
||
assets.sprite(1, 4, &palette).unwrap().unwrap().pixels[3 * 8 + 3],
|
||
100
|
||
);
|
||
palette[100] = [0, 0, 0];
|
||
palette[101] = [100, 20, 20];
|
||
assert_eq!(
|
||
assets.sprite(1, 4, &palette).unwrap().unwrap().pixels[3 * 8 + 3],
|
||
101
|
||
);
|
||
assert_eq!(assets.cache_stats().indexed_entries, 2);
|
||
assert_eq!(assets.cache_stats().png_decodes, 1);
|
||
}
|
||
#[test]
|
||
fn megapixel_sources_keep_only_four_times_logical_rgba() {
|
||
let (dir, catalog, manifest) = fixture();
|
||
RgbaImage::from_pixel(1024, 1024, image::Rgba([100, 20, 20, 255]))
|
||
.save(dir.path().join("hd/ship.png"))
|
||
.unwrap();
|
||
let mut assets = lazy_pack(dir.path(), &catalog, &manifest, None);
|
||
let sprite = assets.sprite(1, 4, &[[0; 3]; 256]).unwrap().unwrap();
|
||
assert_eq!((sprite.width, sprite.height), (8, 8));
|
||
assert_eq!(assets.cache_stats().rgba_bytes, 8 * 8 * 4);
|
||
assert!(assets.cache_stats().atlas_bytes <= CACHE_BYTES);
|
||
}
|
||
#[test]
|
||
fn adjacent_atlas_frames_share_decode_across_lru_evictions() {
|
||
let (dir, _, mut manifest) = fixture();
|
||
let source = fs::read(dir.path().join("source")).unwrap();
|
||
fs::write(
|
||
dir.path().join("INDEX"),
|
||
(1..=3)
|
||
.map(|id| format!("{id:05x} {} source\n", source.len()))
|
||
.collect::<String>(),
|
||
)
|
||
.unwrap();
|
||
let catalog = Catalog::open(dir.path()).unwrap();
|
||
let mut atlas = RgbaImage::new(24, 8);
|
||
for frame in 0..3 {
|
||
for y in 2..6 {
|
||
for x in 2..6 {
|
||
atlas.put_pixel(
|
||
frame * 8 + x,
|
||
y,
|
||
image::Rgba([50 + frame as u8 * 50, 20, 20, 255]),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
atlas.save(dir.path().join("hd/ship.png")).unwrap();
|
||
manifest.sequences[0].frame_count = 3;
|
||
manifest.sequences[0].frames = (0..3)
|
||
.map(|index| {
|
||
let mut frame = frame(&source);
|
||
frame.frame = index;
|
||
frame.crop = Some([index * 8, 0, 8, 8]);
|
||
frame
|
||
})
|
||
.collect();
|
||
let mut assets = lazy_pack(dir.path(), &catalog, &manifest, None);
|
||
assets.rgba.limit = 8 * 8 * 4;
|
||
assets.scaled.limit = 8 * 8 * 2;
|
||
let mut palette = [[0; 3]; 256];
|
||
for index in 0..3 {
|
||
palette[100 + index] = [50 + index as u8 * 50, 20, 20];
|
||
}
|
||
for id in [1, 2, 3, 1] {
|
||
let sprite = assets.sprite(id, 4, &palette).unwrap().unwrap();
|
||
assert_eq!(sprite.pixels[3 * 8 + 3], 100 + id as u16 - 1);
|
||
let stats = assets.cache_stats();
|
||
assert!(stats.rgba_bytes <= 256 && stats.indexed_bytes <= 128);
|
||
}
|
||
assert_eq!(assets.cache_stats().png_decodes, 1);
|
||
assert_eq!(assets.validated_count(), 3);
|
||
assert_eq!(assets.cache_stats().indexed_entries, 1);
|
||
}
|
||
#[test]
|
||
fn derived_cache_verifies_checksums_and_binds_png_and_source_identity() {
|
||
let (dir, catalog, manifest) = fixture();
|
||
let cache = dir.path().join("cache");
|
||
let mut first = lazy_pack(dir.path(), &catalog, &manifest, Some(&cache));
|
||
first.sprite(1, 4, &[[0; 3]; 256]).unwrap().unwrap();
|
||
assert_eq!(first.cache_stats().disk_writes, 1);
|
||
let cached = fs::read_dir(&cache)
|
||
.unwrap()
|
||
.next()
|
||
.unwrap()
|
||
.unwrap()
|
||
.path();
|
||
let mut bytes = fs::read(&cached).unwrap();
|
||
*bytes.last_mut().unwrap() ^= 1;
|
||
fs::write(&cached, bytes).unwrap();
|
||
let mut second = lazy_pack(dir.path(), &catalog, &manifest, Some(&cache));
|
||
second.sprite(1, 4, &[[0; 3]; 256]).unwrap().unwrap();
|
||
assert!(
|
||
second
|
||
.diagnostics
|
||
.iter()
|
||
.any(|message| message.contains("checksum mismatch"))
|
||
);
|
||
assert_eq!(second.cache_stats().png_decodes, 1);
|
||
let mut third = lazy_pack(dir.path(), &catalog, &manifest, Some(&cache));
|
||
third.sprite(1, 4, &[[0; 3]; 256]).unwrap().unwrap();
|
||
assert_eq!(third.cache_stats().png_decodes, 0);
|
||
assert_eq!(third.cache_stats().disk_hits, 1);
|
||
// New PNG contents must not reuse the previous derived frame.
|
||
RgbaImage::from_pixel(8, 8, image::Rgba([10, 30, 80, 255]))
|
||
.save(dir.path().join("hd/ship.png"))
|
||
.unwrap();
|
||
let mut fourth = lazy_pack(dir.path(), &catalog, &manifest, Some(&cache));
|
||
fourth.sprite(1, 4, &[[0; 3]; 256]).unwrap().unwrap();
|
||
assert_eq!(fourth.cache_stats().disk_hits, 0);
|
||
assert_eq!(fourth.cache_stats().png_decodes, 1);
|
||
assert_eq!(fs::read_dir(&cache).unwrap().count(), 2);
|
||
// A reviewed new original payload creates a distinct cache identity.
|
||
let mut changed_source = fs::read(dir.path().join("source")).unwrap();
|
||
changed_source[20] = 9;
|
||
fs::write(dir.path().join("source"), &changed_source).unwrap();
|
||
let catalog = Catalog::open(dir.path()).unwrap();
|
||
let mut changed_manifest = manifest.clone();
|
||
changed_manifest.sequences[0].frames[0].source_sha256 =
|
||
format!("{:x}", Sha256::digest(&changed_source));
|
||
let mut fifth = lazy_pack(dir.path(), &catalog, &changed_manifest, Some(&cache));
|
||
fifth.sprite(1, 4, &[[0; 3]; 256]).unwrap().unwrap();
|
||
assert_eq!(fifth.cache_stats().disk_hits, 0);
|
||
assert_eq!(fifth.cache_stats().png_decodes, 1);
|
||
assert_eq!(fs::read_dir(&cache).unwrap().count(), 3);
|
||
// An unreviewed changed original remains rejected while cache files exist.
|
||
let mut invalid_manifest = manifest;
|
||
invalid_manifest.sequences[0].frames[0].source_sha256 = "changed".into();
|
||
let mut rejected = lazy_pack(dir.path(), &catalog, &invalid_manifest, Some(&cache));
|
||
assert_eq!(rejected.registered_count(), 0);
|
||
assert!(rejected.sprite(1, 4, &[[0; 3]; 256]).unwrap().is_none());
|
||
}
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn lazy_artwork_and_derived_cache_cannot_follow_escaping_symlinks() {
|
||
use std::os::unix::fs::symlink;
|
||
let (dir, catalog, manifest) = fixture();
|
||
let outside = tempfile::tempdir().unwrap();
|
||
let cache = dir.path().join("cache");
|
||
let mut assets = lazy_pack(dir.path(), &catalog, &manifest, Some(&cache));
|
||
assets.sprite(1, 4, &[[0; 3]; 256]).unwrap().unwrap();
|
||
let cached = fs::read_dir(&cache)
|
||
.unwrap()
|
||
.next()
|
||
.unwrap()
|
||
.unwrap()
|
||
.path();
|
||
let outside_file = outside.path().join("must-stay");
|
||
fs::write(&outside_file, b"original").unwrap();
|
||
fs::remove_file(&cached).unwrap();
|
||
symlink(&outside_file, &cached).unwrap();
|
||
let mut again = lazy_pack(dir.path(), &catalog, &manifest, Some(&cache));
|
||
again.sprite(1, 4, &[[0; 3]; 256]).unwrap().unwrap();
|
||
assert_eq!(fs::read(&outside_file).unwrap(), b"original");
|
||
assert!(
|
||
again
|
||
.diagnostics
|
||
.iter()
|
||
.any(|message| message.contains("symlink escapes"))
|
||
);
|
||
let artwork = dir.path().join("hd/ship.png");
|
||
fs::remove_file(&artwork).unwrap();
|
||
symlink(&outside_file, &artwork).unwrap();
|
||
// Repeat draws must check containment before reusing their indexed cache.
|
||
assert!(assets.sprite(1, 4, &[[0; 3]; 256]).unwrap().is_none());
|
||
assert!(
|
||
assets
|
||
.diagnostics
|
||
.iter()
|
||
.any(|message| message.contains("symlink escapes"))
|
||
);
|
||
}
|
||
#[test]
|
||
fn lru_eviction_preserves_recently_used_entries() {
|
||
let mut cache = LruCache::new(8);
|
||
cache.insert(1, "first", 4);
|
||
cache.insert(2, "second", 4);
|
||
assert_eq!(cache.get(&1), Some(&"first"));
|
||
cache.insert(3, "third", 4);
|
||
assert_eq!(cache.get(&2), None);
|
||
assert_eq!(cache.get(&1), Some(&"first"));
|
||
assert_eq!(cache.get(&3), Some(&"third"));
|
||
assert_eq!(cache.bytes, 8);
|
||
}
|
||
|
||
#[test]
|
||
fn derived_disk_budget_evicts_only_generated_cache_files() {
|
||
let (dir, catalog, manifest) = fixture();
|
||
let cache = dir.path().join("cache");
|
||
fs::create_dir(&cache).unwrap();
|
||
fs::write(cache.join("leave-me.txt"), b"keep").unwrap();
|
||
let mut assets = lazy_pack(dir.path(), &catalog, &manifest, Some(&cache));
|
||
// Exactly one 8x8 derived RGBA frame plus its checked header fits.
|
||
assets.disk_limit = (CACHE_HEADER + 8 * 8 * 4) as u64;
|
||
assets.sprite(1, 4, &[[0; 3]; 256]).unwrap().unwrap();
|
||
RgbaImage::from_pixel(8, 8, image::Rgba([11, 31, 81, 255]))
|
||
.save(dir.path().join("hd/ship.png"))
|
||
.unwrap();
|
||
let mut updated = lazy_pack(dir.path(), &catalog, &manifest, Some(&cache));
|
||
updated.disk_limit = assets.disk_limit;
|
||
updated.sprite(1, 4, &[[0; 3]; 256]).unwrap().unwrap();
|
||
let generated: Vec<_> = fs::read_dir(&cache)
|
||
.unwrap()
|
||
.map(Result::unwrap)
|
||
.filter(|entry| {
|
||
entry
|
||
.path()
|
||
.extension()
|
||
.is_some_and(|extension| extension == "rgba")
|
||
})
|
||
.collect();
|
||
assert_eq!(generated.len(), 1);
|
||
assert!(generated[0].metadata().unwrap().len() <= assets.disk_limit);
|
||
assert_eq!(fs::read(cache.join("leave-me.txt")).unwrap(), b"keep");
|
||
assert_eq!(updated.cache_stats().disk_writes, 1);
|
||
assert!(
|
||
updated
|
||
.diagnostics
|
||
.iter()
|
||
.all(|message| !message.contains("derived cache"))
|
||
);
|
||
}
|
||
#[test]
|
||
#[ignore = "requires the local original catalog and generated HD artwork"]
|
||
fn original_hd_pack_validates_with_bounded_residency() {
|
||
let root = std::env::var_os("RAPTOR_TEST_DATA").expect("set RAPTOR_TEST_DATA");
|
||
let catalog = Catalog::open(PathBuf::from(root)).unwrap();
|
||
let mut assets = HdAssets::load_lazy(&catalog.root.join("hd"), &catalog, None);
|
||
let registered = assets.registered_count();
|
||
assert!(
|
||
registered > 0,
|
||
"local HD pack is missing or invalid: {:?}",
|
||
assets.diagnostics
|
||
);
|
||
let report = assets.validate_all();
|
||
assert_eq!(report.registered, registered, "{:?}", assets.diagnostics);
|
||
assert_eq!(report.validated, registered, "{:?}", assets.diagnostics);
|
||
assert_eq!(report.rejected, 0, "{:?}", assets.diagnostics);
|
||
let palette = catalog.palette().unwrap();
|
||
let ids: Vec<_> = assets.replacements.keys().copied().collect();
|
||
for id in ids {
|
||
for scale in 1..=4 {
|
||
let sprite = assets.sprite(id, scale, &palette).unwrap().unwrap();
|
||
let frame = &assets.replacements.get(&id).unwrap().frame;
|
||
assert_eq!(
|
||
(sprite.width, sprite.height),
|
||
(
|
||
frame.logical_width as usize * scale,
|
||
frame.logical_height as usize * scale
|
||
)
|
||
);
|
||
assert!(sprite.pixels.iter().any(|pixel| *pixel != TRANSPARENT));
|
||
let stats = assets.cache_stats();
|
||
assert!(
|
||
stats.atlas_bytes <= CACHE_BYTES
|
||
&& stats.rgba_bytes <= CACHE_BYTES
|
||
&& stats.indexed_bytes <= CACHE_BYTES
|
||
);
|
||
}
|
||
}
|
||
println!(
|
||
"HD checked {} frames at scales 1..4; cache stats {:?}",
|
||
registered,
|
||
assets.cache_stats()
|
||
);
|
||
}
|
||
#[test]
|
||
fn oversized_png_is_rejected_before_decode_or_resident_allocation() {
|
||
let (dir, catalog, manifest) = fixture();
|
||
RgbaImage::from_pixel(4097, 1, image::Rgba([100, 20, 20, 255]))
|
||
.save(dir.path().join("hd/ship.png"))
|
||
.unwrap();
|
||
let mut assets = lazy_pack(dir.path(), &catalog, &manifest, None);
|
||
assert!(assets.sprite(1, 4, &[[0; 3]; 256]).unwrap().is_none());
|
||
assert_eq!(assets.cache_stats().png_decodes, 0);
|
||
assert_eq!(assets.cache_stats().atlas_bytes, 0);
|
||
assert_eq!(assets.cache_stats().rgba_bytes, 0);
|
||
assert!(
|
||
assets
|
||
.diagnostics
|
||
.iter()
|
||
.any(|message| message.contains("limit") || message.contains("dimension"))
|
||
);
|
||
}
|
||
fn aspect_info(width: u32, height: u32) -> PngInfo {
|
||
PngInfo {
|
||
size: 0,
|
||
modified: None,
|
||
digest: [0; 32],
|
||
width,
|
||
height,
|
||
}
|
||
}
|
||
fn aspect_frame(width: u32, height: u32) -> Frame {
|
||
let mut f = frame(b"synthetic source");
|
||
f.logical_width = width;
|
||
f.logical_height = height;
|
||
f
|
||
}
|
||
#[test]
|
||
fn opaque_full_frame_accepts_only_common_scale_integer_rounding() {
|
||
let mut f = aspect_frame(320, 200);
|
||
let info = aspect_info(1586, 992);
|
||
assert_eq!(frame_dimensions(&f, &info, false).unwrap(), (1586, 992));
|
||
f.crop = Some([0, 0, 1586, 992]);
|
||
assert_eq!(frame_dimensions(&f, &info, false).unwrap(), (1586, 992));
|
||
// Swapping both logical and artwork axes retains the same rule.
|
||
let portrait = aspect_frame(200, 320);
|
||
assert!(frame_dimensions(&portrait, &aspect_info(992, 1586), false).is_ok());
|
||
// The PNG can be generated at any common scale, not just an integer one.
|
||
assert!(frame_dimensions(&aspect_frame(8, 5), &aspect_info(17, 11), false).is_ok());
|
||
for (width, height) in [
|
||
(1280, 799),
|
||
(1280, 801),
|
||
(1587, 991),
|
||
(1586, 993),
|
||
(1024, 1024),
|
||
] {
|
||
assert!(
|
||
frame_dimensions(&aspect_frame(320, 200), &aspect_info(width, height), false)
|
||
.is_err()
|
||
);
|
||
}
|
||
// Equal square axes cannot be rounded in conflicting directions at a tie.
|
||
assert!(!integer_rounded_aspect(5, 6, 2, 2));
|
||
assert!(!integer_rounded_aspect(0, 6, 2, 2));
|
||
assert!(!integer_rounded_aspect(5, 6, 0, 2));
|
||
// Arithmetic is bounded even for values beyond valid image dimensions.
|
||
assert!(integer_rounded_aspect(
|
||
u32::MAX,
|
||
u32::MAX,
|
||
u32::MAX,
|
||
u32::MAX
|
||
));
|
||
assert!(!integer_rounded_aspect(u32::MAX, 1, 1, u32::MAX));
|
||
}
|
||
#[test]
|
||
fn rounded_exception_never_admits_partial_crops_or_low_resolution() {
|
||
let mut f = aspect_frame(320, 200);
|
||
assert!(frame_dimensions(&f, &aspect_info(1586, 992), true).is_ok());
|
||
f.crop = Some([1, 0, 1586, 992]);
|
||
assert!(frame_dimensions(&f, &aspect_info(1587, 992), false).is_err());
|
||
f.crop = Some([0, 0, 1586, 992]);
|
||
assert!(frame_dimensions(&f, &aspect_info(1600, 1000), false).is_err());
|
||
// Exact-ratio atlas crops and transparent artwork keep existing semantics.
|
||
f.crop = Some([8, 8, 1280, 800]);
|
||
assert!(frame_dimensions(&f, &aspect_info(1600, 1000), true).is_ok());
|
||
assert!(frame_dimensions(&aspect_frame(8, 5), &aspect_info(15, 10), false).is_err());
|
||
}
|
||
#[test]
|
||
fn rounded_dimensions_generated_by_one_scale_are_accepted() {
|
||
// Independent fixtures generated by conventional nearest-integer rounding
|
||
// at 1/64 scale steps, including ties, portrait ratios and tiny canvases.
|
||
let mut checked = 0;
|
||
for logical_width in 1_u32..=16 {
|
||
for logical_height in 1_u32..=16 {
|
||
for scale_numerator in 128_u32..=1024 {
|
||
let width = (logical_width * scale_numerator + 32) / 64;
|
||
let height = (logical_height * scale_numerator + 32) / 64;
|
||
assert!(integer_rounded_aspect(
|
||
width,
|
||
height,
|
||
logical_width,
|
||
logical_height
|
||
));
|
||
checked += 1;
|
||
}
|
||
}
|
||
}
|
||
assert_eq!(checked, 229632);
|
||
}
|
||
fn rectangular_opaque_fixture() -> (tempfile::TempDir, Catalog, Manifest) {
|
||
let (dir, _, mut manifest) = fixture();
|
||
let mut source = [1_i32, 0, 0, 8, 5]
|
||
.into_iter()
|
||
.flat_map(i32::to_le_bytes)
|
||
.collect::<Vec<_>>();
|
||
source.extend([7_u8; 40]);
|
||
fs::write(dir.path().join("source"), &source).unwrap();
|
||
fs::write(
|
||
dir.path().join("INDEX"),
|
||
format!("00001 {} source\n", source.len()),
|
||
)
|
||
.unwrap();
|
||
let catalog = Catalog::open(dir.path()).unwrap();
|
||
let f = &mut manifest.sequences[0].frames[0];
|
||
f.logical_width = 8;
|
||
f.logical_height = 5;
|
||
f.source_sha256 = format!("{:x}", Sha256::digest(&source));
|
||
manifest.sequences[0].frame_count = 1;
|
||
RgbaImage::from_pixel(17, 11, image::Rgba([80, 100, 120, 255]))
|
||
.save(dir.path().join("hd/ship.png"))
|
||
.unwrap();
|
||
(dir, catalog, manifest)
|
||
}
|
||
#[test]
|
||
fn actual_rounded_png_loads_all_scales_and_warms_its_validated_disk_cache() {
|
||
let (dir, catalog, manifest) = rectangular_opaque_fixture();
|
||
let cache = dir.path().join("cache");
|
||
let mut first = lazy_pack(dir.path(), &catalog, &manifest, Some(&cache));
|
||
for scale in 1..=4 {
|
||
let sprite = first.sprite(1, scale, &[[0; 3]; 256]).unwrap().unwrap();
|
||
assert_eq!((sprite.width, sprite.height), (8 * scale, 5 * scale));
|
||
assert!(sprite.pixels.iter().all(|pixel| *pixel != TRANSPARENT));
|
||
}
|
||
assert!(first.diagnostics.is_empty(), "{:?}", first.diagnostics);
|
||
assert_eq!(first.cache_stats().png_decodes, 1);
|
||
assert_eq!(first.cache_stats().disk_writes, 1);
|
||
let mut warm = lazy_pack(dir.path(), &catalog, &manifest, Some(&cache));
|
||
warm.sprite(1, 4, &[[0; 3]; 256]).unwrap().unwrap();
|
||
assert_eq!(warm.cache_stats().disk_hits, 1);
|
||
assert_eq!(warm.cache_stats().png_decodes, 0);
|
||
assert!(warm.diagnostics.is_empty());
|
||
}
|
||
#[test]
|
||
fn a_single_nonopaque_raw_pixel_cannot_be_hidden_by_resampling_or_a_warm_cache() {
|
||
let (dir, catalog, manifest) = rectangular_opaque_fixture();
|
||
let cache = dir.path().join("cache");
|
||
let mut first = lazy_pack(dir.path(), &catalog, &manifest, Some(&cache));
|
||
first.sprite(1, 4, &[[0; 3]; 256]).unwrap().unwrap();
|
||
for alpha in [0, 128, 254] {
|
||
// An isolated defect well above the four-times resident resolution.
|
||
let mut raw = RgbaImage::from_pixel(161, 101, image::Rgba([80, 100, 120, 255]));
|
||
raw.put_pixel(160, 100, image::Rgba([80, 100, 120, alpha]));
|
||
raw.save(dir.path().join("hd/ship.png")).unwrap();
|
||
let mut changed = lazy_pack(dir.path(), &catalog, &manifest, Some(&cache));
|
||
assert!(changed.sprite(1, 4, &[[0; 3]; 256]).unwrap().is_none());
|
||
assert_eq!(changed.cache_stats().disk_hits, 0);
|
||
assert_eq!(changed.cache_stats().disk_writes, 0);
|
||
assert!(
|
||
changed
|
||
.diagnostics
|
||
.iter()
|
||
.any(|line| line.contains("fully opaque"))
|
||
);
|
||
}
|
||
}
|
||
#[test]
|
||
fn checksum_valid_nonopaque_rounded_cache_is_rebuilt_from_original_png() {
|
||
let (dir, catalog, manifest) = rectangular_opaque_fixture();
|
||
let cache = dir.path().join("cache");
|
||
let mut first = lazy_pack(dir.path(), &catalog, &manifest, Some(&cache));
|
||
first.sprite(1, 4, &[[0; 3]; 256]).unwrap().unwrap();
|
||
let path = fs::read_dir(&cache)
|
||
.unwrap()
|
||
.next()
|
||
.unwrap()
|
||
.unwrap()
|
||
.path();
|
||
let mut bytes = fs::read(&path).unwrap();
|
||
bytes[CACHE_HEADER + 3] = 254;
|
||
let checksum = Sha256::digest(&bytes[CACHE_HEADER..]);
|
||
bytes[48..80].copy_from_slice(&checksum);
|
||
fs::write(&path, bytes).unwrap();
|
||
let mut changed = lazy_pack(dir.path(), &catalog, &manifest, Some(&cache));
|
||
assert!(changed.sprite(1, 4, &[[0; 3]; 256]).unwrap().is_some());
|
||
assert_eq!(changed.cache_stats().disk_hits, 0);
|
||
assert_eq!(changed.cache_stats().png_decodes, 1);
|
||
assert!(
|
||
changed
|
||
.diagnostics
|
||
.iter()
|
||
.any(|line| line.contains("fully opaque"))
|
||
);
|
||
let mut healed = lazy_pack(dir.path(), &catalog, &manifest, Some(&cache));
|
||
healed.sprite(1, 4, &[[0; 3]; 256]).unwrap().unwrap();
|
||
assert_eq!(healed.cache_stats().disk_hits, 1);
|
||
}
|
||
#[test]
|
||
fn rounded_cache_identity_is_separate_and_exact_frame_keys_are_unchanged() {
|
||
fn previous_key(frame: &Frame, info: &PngInfo, requires_alpha: bool) -> [u8; 32] {
|
||
let mut digest = Sha256::new();
|
||
digest.update(KERNEL_VERSION);
|
||
let (width, height) = frame
|
||
.crop
|
||
.map(|[_, _, width, height]| (width, height))
|
||
.unwrap_or((info.width, info.height));
|
||
if !exact_aspect(width, height, frame.logical_width, frame.logical_height) {
|
||
digest.update(b"full-frame-opaque-rounding-v1");
|
||
}
|
||
digest.update(info.digest);
|
||
digest.update(frame.source_sha256.as_bytes());
|
||
digest.update(frame.logical_width.to_le_bytes());
|
||
digest.update(frame.logical_height.to_le_bytes());
|
||
for value in frame.origin {
|
||
digest.update(value.to_le_bytes());
|
||
}
|
||
digest.update([u8::from(requires_alpha), u8::from(frame.crop.is_some())]);
|
||
if let Some(crop) = frame.crop {
|
||
for value in crop {
|
||
digest.update(value.to_le_bytes());
|
||
}
|
||
}
|
||
digest.finalize().into()
|
||
}
|
||
let mut f = aspect_frame(320, 200);
|
||
for info in [aspect_info(1600, 1000), aspect_info(1280, 800)] {
|
||
for requires_alpha in [false, true] {
|
||
assert_eq!(
|
||
HdAssets::frame_key(&f, &info, requires_alpha),
|
||
previous_key(&f, &info, requires_alpha)
|
||
);
|
||
}
|
||
}
|
||
f.crop = Some([10, 20, 1280, 800]);
|
||
assert_eq!(
|
||
HdAssets::frame_key(&f, &aspect_info(1600, 1000), false),
|
||
previous_key(&f, &aspect_info(1600, 1000), false)
|
||
);
|
||
f.crop = Some([0, 0, 1586, 992]);
|
||
assert_eq!(
|
||
HdAssets::frame_key(&f, &aspect_info(1586, 992), false),
|
||
previous_key(&f, &aspect_info(1586, 992), false)
|
||
);
|
||
assert_ne!(
|
||
HdAssets::frame_key(&f, &aspect_info(1586, 992), true),
|
||
previous_key(&f, &aspect_info(1586, 992), true)
|
||
);
|
||
}
|
||
#[test]
|
||
fn transparent_full_frame_uses_the_same_strict_rounding_bounds() {
|
||
for (lw, lh, width, height) in [
|
||
(40, 32, 1402, 1122), // observed natural generator canvas
|
||
(32, 40, 1122, 1402),
|
||
(8, 5, 101, 63),
|
||
(5, 8, 63, 101),
|
||
] {
|
||
let mut f = aspect_frame(lw, lh);
|
||
let info = aspect_info(width, height);
|
||
for requires_alpha in [false, true] {
|
||
assert_eq!(
|
||
frame_dimensions(&f, &info, requires_alpha).unwrap(),
|
||
(width, height)
|
||
);
|
||
}
|
||
f.crop = Some([0, 0, width, height]);
|
||
assert_eq!(frame_dimensions(&f, &info, true).unwrap(), (width, height));
|
||
}
|
||
let mut f = aspect_frame(8, 5);
|
||
for crop in [
|
||
[0, 0, 100, 63], // strict rounding fits, but selection is not whole PNG
|
||
[1, 0, 100, 63],
|
||
[0, 0, 101, 62],
|
||
[0, 0, 0, 63],
|
||
[u32::MAX, 0, 101, 63],
|
||
] {
|
||
f.crop = Some(crop);
|
||
for requires_alpha in [false, true] {
|
||
assert!(frame_dimensions(&f, &aspect_info(101, 63), requires_alpha).is_err());
|
||
}
|
||
}
|
||
f.crop = None;
|
||
for info in [aspect_info(101, 64), aspect_info(15, 10), aspect_info(0, 0)] {
|
||
assert!(frame_dimensions(&f, &info, true).is_err());
|
||
}
|
||
assert!(frame_dimensions(&aspect_frame(2, 2), &aspect_info(5, 6), true).is_err());
|
||
// Existing exact-aspect transparent atlas extraction is unchanged.
|
||
f.crop = Some([1, 1, 16, 10]);
|
||
assert!(frame_dimensions(&f, &aspect_info(101, 63), true).is_ok());
|
||
}
|
||
fn rectangular_transparent_rounded_fixture() -> (tempfile::TempDir, Catalog, Manifest) {
|
||
let (dir, _, mut manifest) = fixture();
|
||
let mut source = [0_i32, 0, 0, 8, 5]
|
||
.into_iter()
|
||
.flat_map(i32::to_le_bytes)
|
||
.collect::<Vec<_>>();
|
||
for y in 1_i32..4 {
|
||
source.extend(
|
||
[2_i32, y, y * 8 + 2, 4]
|
||
.into_iter()
|
||
.flat_map(i32::to_le_bytes),
|
||
);
|
||
source.extend(if y == 2 { [7, 252, 253, 7] } else { [7; 4] });
|
||
}
|
||
source.extend([0_i32, 0, -1, 0].into_iter().flat_map(i32::to_le_bytes));
|
||
fs::write(dir.path().join("source"), &source).unwrap();
|
||
fs::write(
|
||
dir.path().join("INDEX"),
|
||
format!("00001 {} source\n", source.len()),
|
||
)
|
||
.unwrap();
|
||
let catalog = Catalog::open(dir.path()).unwrap();
|
||
let original = Sprite::decode(&source).unwrap();
|
||
assert_eq!((original.width, original.height), (8, 5));
|
||
assert!(original.pixels.contains(&TRANSPARENT));
|
||
assert_eq!(original.pixels[2 * 8 + 3], 252);
|
||
assert_eq!(original.pixels[2 * 8 + 4], 253);
|
||
let f = &mut manifest.sequences[0].frames[0];
|
||
f.logical_width = 8;
|
||
f.logical_height = 5;
|
||
f.source_sha256 = format!("{:x}", Sha256::digest(&source));
|
||
manifest.sequences[0].frame_count = 1;
|
||
// Large enough to exercise resident 4x downsampling. This test geometry
|
||
// has broad alpha support; it does not assert arbitrary source fidelity.
|
||
let mut raw = RgbaImage::new(101, 63);
|
||
for y in 12..51 {
|
||
for x in 25..76 {
|
||
raw.put_pixel(x, y, image::Rgba([80, 100, 120, 255]));
|
||
}
|
||
}
|
||
raw.save(dir.path().join("hd/ship.png")).unwrap();
|
||
(dir, catalog, manifest)
|
||
}
|
||
#[test]
|
||
fn transparent_rounded_png_is_identical_cold_and_warm_at_all_scales() {
|
||
for explicit_full_crop in [false, true] {
|
||
let (dir, catalog, mut manifest) = rectangular_transparent_rounded_fixture();
|
||
if explicit_full_crop {
|
||
manifest.sequences[0].frames[0].crop = Some([0, 0, 101, 63]);
|
||
}
|
||
let cache = dir.path().join("cache");
|
||
let mut cold = lazy_pack(dir.path(), &catalog, &manifest, Some(&cache));
|
||
let palette = [[0; 3]; 256];
|
||
let mut expected = Vec::new();
|
||
for scale in 1..=4 {
|
||
let sprite = cold.sprite(1, scale, &palette).unwrap().unwrap();
|
||
assert_eq!((sprite.width, sprite.height), (8 * scale, 5 * scale));
|
||
assert_eq!(sprite.pixels[0], TRANSPARENT);
|
||
assert_eq!(sprite.pixels[sprite.pixels.len() - 1], TRANSPARENT);
|
||
assert!(sprite.pixels.iter().any(|p| *p != TRANSPARENT));
|
||
for (x, label) in [(3, 252), (4, 253)] {
|
||
let i = (2 * scale + scale / 2) * sprite.width + x * scale + scale / 2;
|
||
assert_eq!(
|
||
sprite.pixels[i], label,
|
||
"original lamp class at scale {scale}"
|
||
);
|
||
}
|
||
expected.push(sprite.pixels);
|
||
}
|
||
let (_, resident) = cold.rgba_frame(1).unwrap().unwrap();
|
||
assert_eq!(resident.dimensions(), (32, 20));
|
||
assert!(resident.pixels().any(|p| p[3] == 0));
|
||
assert!(resident.pixels().any(|p| p[3] >= 128));
|
||
assert_eq!(cold.cache_stats().png_decodes, 1);
|
||
assert_eq!(cold.cache_stats().disk_writes, 1);
|
||
assert!(cold.diagnostics.is_empty(), "{:?}", cold.diagnostics);
|
||
let mut warm = lazy_pack(dir.path(), &catalog, &manifest, Some(&cache));
|
||
for scale in 1..=4 {
|
||
let sprite = warm.sprite(1, scale, &palette).unwrap().unwrap();
|
||
assert_eq!(sprite.pixels, expected[scale - 1]);
|
||
}
|
||
assert_eq!(warm.cache_stats().disk_hits, 1);
|
||
assert_eq!(warm.cache_stats().png_decodes, 0);
|
||
assert_eq!(warm.cache_stats().disk_writes, 0);
|
||
assert!(warm.diagnostics.is_empty());
|
||
}
|
||
}
|
||
#[test]
|
||
fn checksum_valid_rounded_transparent_cache_still_requires_background_and_visible_art() {
|
||
let (dir, catalog, manifest) = rectangular_transparent_rounded_fixture();
|
||
let cache = dir.path().join("cache");
|
||
let palette = [[0; 3]; 256];
|
||
let mut cold = lazy_pack(dir.path(), &catalog, &manifest, Some(&cache));
|
||
let expected = (1..=4)
|
||
.map(|scale| cold.sprite(1, scale, &palette).unwrap().unwrap().pixels)
|
||
.collect::<Vec<_>>();
|
||
let path = fs::read_dir(&cache)
|
||
.unwrap()
|
||
.next()
|
||
.unwrap()
|
||
.unwrap()
|
||
.path();
|
||
for alpha in [255, 0, 127] {
|
||
let mut bytes = fs::read(&path).unwrap();
|
||
for p in bytes[CACHE_HEADER..].chunks_exact_mut(4) {
|
||
p[3] = alpha;
|
||
}
|
||
if alpha == 127 {
|
||
// Keep a transparent background so this reaches the distinct
|
||
// no-visible-art check instead of the missing-background check.
|
||
bytes[CACHE_HEADER + 3] = 0;
|
||
}
|
||
let checksum = Sha256::digest(&bytes[CACHE_HEADER..]);
|
||
bytes[48..80].copy_from_slice(&checksum);
|
||
fs::write(&path, bytes).unwrap();
|
||
let mut rebuilt = lazy_pack(dir.path(), &catalog, &manifest, Some(&cache));
|
||
for scale in 1..=4 {
|
||
assert_eq!(
|
||
rebuilt.sprite(1, scale, &palette).unwrap().unwrap().pixels,
|
||
expected[scale - 1]
|
||
);
|
||
}
|
||
assert_eq!(rebuilt.cache_stats().disk_hits, 0);
|
||
assert_eq!(rebuilt.cache_stats().png_decodes, 1);
|
||
assert_eq!(rebuilt.cache_stats().disk_writes, 1);
|
||
let reason = if alpha == 255 {
|
||
"transparent background"
|
||
} else {
|
||
"no visible pixels"
|
||
};
|
||
assert!(rebuilt.diagnostics.iter().any(|d| d.contains(reason)));
|
||
let mut healed = lazy_pack(dir.path(), &catalog, &manifest, Some(&cache));
|
||
for scale in 1..=4 {
|
||
assert_eq!(
|
||
healed.sprite(1, scale, &palette).unwrap().unwrap().pixels,
|
||
expected[scale - 1]
|
||
);
|
||
}
|
||
assert_eq!(healed.cache_stats().disk_hits, 1);
|
||
assert_eq!(healed.cache_stats().png_decodes, 0);
|
||
assert!(healed.diagnostics.is_empty());
|
||
}
|
||
}
|
||
#[test]
|
||
fn transparent_rounded_raw_and_resident_keep_both_alpha_validations() {
|
||
let (dir, catalog, manifest) = rectangular_transparent_rounded_fixture();
|
||
for alpha in [255, 0, 127] {
|
||
let mut raw = RgbaImage::from_pixel(101, 63, image::Rgba([80, 100, 120, alpha]));
|
||
if alpha == 127 {
|
||
raw.put_pixel(0, 0, image::Rgba([0, 0, 0, 0]));
|
||
}
|
||
raw.save(dir.path().join("hd/ship.png")).unwrap();
|
||
let mut assets = lazy_pack(dir.path(), &catalog, &manifest, None);
|
||
assert!(assets.sprite(1, 4, &[[0; 3]; 256]).unwrap().is_none());
|
||
let reason = if alpha == 255 {
|
||
"transparent background"
|
||
} else {
|
||
"no visible pixels"
|
||
};
|
||
assert!(assets.diagnostics.iter().any(|d| d.contains(reason)));
|
||
assert_eq!(assets.cache_stats().disk_writes, 0);
|
||
}
|
||
// Valid raw alpha alone cannot bypass validation after resident filtering.
|
||
let mut raw = RgbaImage::new(101, 63);
|
||
raw.put_pixel(50, 31, image::Rgba([80, 100, 120, 255]));
|
||
assert!(validate_rgba(&raw, true).is_ok());
|
||
raw.save(dir.path().join("hd/ship.png")).unwrap();
|
||
let mut assets = lazy_pack(dir.path(), &catalog, &manifest, None);
|
||
assert!(assets.sprite(1, 4, &[[0; 3]; 256]).unwrap().is_none());
|
||
assert!(
|
||
assets
|
||
.diagnostics
|
||
.iter()
|
||
.any(|d| d.contains("no visible pixels"))
|
||
);
|
||
}
|
||
#[test]
|
||
fn transparent_rounded_fixture_rejects_changed_canvas_anchor_and_malformed_png() {
|
||
let (dir, catalog, manifest) = rectangular_transparent_rounded_fixture();
|
||
for (logical_width, origin) in [(9, [0, 0]), (8, [1, 0])] {
|
||
let mut changed = manifest.clone();
|
||
changed.sequences[0].frames[0].logical_width = logical_width;
|
||
changed.sequences[0].frames[0].origin = origin;
|
||
let mut assets = lazy_pack(dir.path(), &catalog, &changed, None);
|
||
assert_eq!(assets.registered_count(), 0);
|
||
assert!(assets.sprite(1, 4, &[[0; 3]; 256]).unwrap().is_none());
|
||
assert_eq!(assets.cache_stats().png_decodes, 0);
|
||
}
|
||
fs::write(dir.path().join("hd/ship.png"), b"not a PNG").unwrap();
|
||
let mut assets = lazy_pack(dir.path(), &catalog, &manifest, None);
|
||
assert!(assets.sprite(1, 4, &[[0; 3]; 256]).unwrap().is_none());
|
||
assert_eq!(assets.cache_stats().png_decodes, 0);
|
||
assert_eq!(assets.cache_stats().disk_writes, 0);
|
||
assert!(!assets.diagnostics.is_empty());
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod policy_proposal_tests {
|
||
use super::*;
|
||
fn frame() -> Frame {
|
||
Frame {
|
||
frame: 0,
|
||
file: "a.png".into(),
|
||
logical_width: 2,
|
||
logical_height: 2,
|
||
origin: [0, 0],
|
||
source_sha256: "00".into(),
|
||
crop: None, atlas_grid: None,
|
||
preserve_reserved_coverage: false,
|
||
native_nearest_cues: Vec::new(),
|
||
original_detail_cells: Vec::new(),
|
||
}
|
||
}
|
||
fn assets(label: u8, mode: bool, rgba: RgbaImage) -> (tempfile::TempDir, HdAssets) {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
rgba.save(dir.path().join("a.png")).unwrap();
|
||
let mut f = frame();
|
||
f.preserve_reserved_coverage = mode;
|
||
let mut h = HdAssets {
|
||
root: dir.path().to_path_buf(),
|
||
..HdAssets::default()
|
||
};
|
||
h.replacements.insert(
|
||
1,
|
||
Replacement {
|
||
frame: f,
|
||
requires_alpha: true,
|
||
source_labels: Some(vec![label, 0, 0, 0]),
|
||
original_detail_source: None,
|
||
validated: false,
|
||
rejected: false,
|
||
},
|
||
);
|
||
(dir, h)
|
||
}
|
||
fn feathered() -> RgbaImage {
|
||
let mut raw = RgbaImage::new(8, 8);
|
||
for y in 0..4 {
|
||
for x in 1..4 {
|
||
raw.put_pixel(x, y, image::Rgba([0, 220, 80, 255]));
|
||
}
|
||
}
|
||
raw
|
||
}
|
||
#[test]
|
||
fn reserved_opt_in_restores_supported_feathered_cell_at_every_scale() {
|
||
let (_dir, mut h) = assets(253, true, feathered());
|
||
for scale in 1..=4 {
|
||
let s = h.sprite(1, scale, &[[0; 3]; 256]).unwrap().unwrap();
|
||
for y in 0..scale {
|
||
for x in 0..scale {
|
||
assert_eq!(s.pixels[y * s.width + x], 253);
|
||
}
|
||
}
|
||
assert_eq!(*s.pixels.last().unwrap(), TRANSPARENT);
|
||
}
|
||
}
|
||
#[test]
|
||
fn default_keeps_alpha_cutoff_before_existing_source_labels() {
|
||
let (_dir, mut h) = assets(253, false, feathered());
|
||
let s = h.sprite(1, 4, &[[0; 3]; 256]).unwrap().unwrap();
|
||
assert_eq!(s.pixels[0], TRANSPARENT);
|
||
assert_eq!(s.pixels[1], 253);
|
||
}
|
||
#[test]
|
||
fn opt_in_does_not_restore_missing_or_sparse_reserved_art() {
|
||
for sparse in [false, true] {
|
||
let mut raw = RgbaImage::new(8, 8);
|
||
for y in 4..8 {
|
||
for x in 4..8 {
|
||
raw.put_pixel(x, y, image::Rgba([240, 20, 30, 255]));
|
||
}
|
||
}
|
||
if sparse {
|
||
raw.put_pixel(3, 3, image::Rgba([0, 255, 0, 255]));
|
||
}
|
||
let (_dir, mut h) = assets(253, true, raw);
|
||
for scale in 1..=4 {
|
||
let s = h.sprite(1, scale, &[[0; 3]; 256]).unwrap().unwrap();
|
||
assert_eq!(s.pixels[0], TRANSPARENT, "missing lamp was invented");
|
||
}
|
||
}
|
||
}
|
||
#[test]
|
||
fn no_new_reserved_or_water_fire_coverage_at_non_flash_locations() {
|
||
for label in [0, 239, 240, 244, 245, 249] {
|
||
let (_dir, mut h) = assets(label, true, feathered());
|
||
let s = h.sprite(1, 4, &[[0; 3]; 256]).unwrap().unwrap();
|
||
assert_eq!(s.pixels[0], TRANSPARENT, "only250..255 may be preserved");
|
||
assert!(s.pixels.iter().all(|&i| i == TRANSPARENT || i < 250));
|
||
}
|
||
}
|
||
#[test]
|
||
fn source_cue_policy_never_changes_static_palette_alpha() {
|
||
let (_a, mut legacy) = assets(253, false, feathered());
|
||
let (_b, mut policy) = assets(253, true, feathered());
|
||
let mut p = [[0; 3]; 256];
|
||
p[255] = [0, 220, 80];
|
||
for scale in 1..=4 {
|
||
assert_eq!(
|
||
legacy
|
||
.sprite_static_palette(1, scale, &p)
|
||
.unwrap()
|
||
.unwrap()
|
||
.pixels,
|
||
policy
|
||
.sprite_static_palette(1, scale, &p)
|
||
.unwrap()
|
||
.unwrap()
|
||
.pixels
|
||
);
|
||
}
|
||
}
|
||
#[test]
|
||
fn legacy_frame_serialization_and_cache_identity_stay_exact() {
|
||
let f = frame();
|
||
let serialized = serde_json::to_value(&f).unwrap();
|
||
assert!(serialized.get("preserve_reserved_coverage").is_none());
|
||
assert!(serialized.get("native_nearest_cues").is_none());
|
||
let round: Frame = serde_json::from_value(serialized).unwrap();
|
||
assert!(!round.preserve_reserved_coverage);
|
||
assert!(round.native_nearest_cues.is_empty());
|
||
let info = PngInfo {
|
||
size: 1,
|
||
modified: None,
|
||
digest: [19; 32],
|
||
width: 8,
|
||
height: 8,
|
||
};
|
||
// Old algorithm independently enumerated, with no policy namespace.
|
||
let mut old = Sha256::new();
|
||
old.update(KERNEL_VERSION);
|
||
old.update(info.digest);
|
||
old.update(f.source_sha256.as_bytes());
|
||
old.update(f.logical_width.to_le_bytes());
|
||
old.update(f.logical_height.to_le_bytes());
|
||
for v in f.origin {
|
||
old.update(v.to_le_bytes());
|
||
}
|
||
old.update([1, 0]);
|
||
assert_eq!(
|
||
HdAssets::frame_key(&f, &info, true),
|
||
<[u8; 32]>::from(old.finalize())
|
||
);
|
||
let mut coverage = f.clone();
|
||
coverage.preserve_reserved_coverage = true;
|
||
let mut nearest = f.clone();
|
||
nearest.native_nearest_cues = vec![[0, 0]];
|
||
assert_ne!(
|
||
HdAssets::frame_key(&f, &info, true),
|
||
HdAssets::frame_key(&coverage, &info, true)
|
||
);
|
||
assert_ne!(
|
||
HdAssets::frame_key(&coverage, &info, true),
|
||
HdAssets::frame_key(&nearest, &info, true)
|
||
);
|
||
assert_ne!(
|
||
HdAssets::frame_key(&nearest, &info, true),
|
||
HdAssets::frame_key(&f, &info, true)
|
||
);
|
||
}
|
||
#[test]
|
||
fn native_nearest_is_restricted_to_opaque_original_canvas() {
|
||
let mut f = frame();
|
||
f.native_nearest_cues = vec![[0, 0]];
|
||
let mut original = Sprite::blank(2, 2);
|
||
original.pixels.fill(1);
|
||
f.source_sha256 = format!("{:x}", Sha256::digest(b"data"));
|
||
assert!(validate_frame(&f, &original, b"data").is_ok());
|
||
original.pixels[0] = TRANSPARENT;
|
||
assert!(validate_frame(&f, &original, b"data").is_err());
|
||
}
|
||
#[test]
|
||
fn malformed_policy_metadata_fails_strictly() {
|
||
let mut j = serde_json::to_value(frame()).unwrap();
|
||
j["native_nearest_cues"] = "yes".into();
|
||
assert!(serde_json::from_value::<Frame>(j).is_err());
|
||
let mut j = serde_json::to_value(frame()).unwrap();
|
||
j["preserve_reserved_coverage"] = "yes".into();
|
||
assert!(serde_json::from_value::<Frame>(j).is_err());
|
||
}
|
||
#[test]
|
||
fn fractional_resident_cell_guard_is_bounded_and_requires_majority_support() {
|
||
let f = frame();
|
||
let opaque = RgbaImage::from_pixel(5, 5, image::Rgba([1, 2, 3, 255]));
|
||
for i in 0..4 {
|
||
assert!(reserved_cell_supported(&opaque, &f, i));
|
||
}
|
||
let empty = RgbaImage::new(5, 5);
|
||
for i in 0..4 {
|
||
assert!(!reserved_cell_supported(&empty, &f, i));
|
||
}
|
||
let faint = RgbaImage::from_pixel(5, 5, image::Rgba([1, 2, 3, 127]));
|
||
for i in 0..4 {
|
||
assert!(!reserved_cell_supported(&faint, &f, i));
|
||
}
|
||
}
|
||
#[test]
|
||
fn fractional_last_cell_does_not_read_outside_resident_canvas() {
|
||
let mut f = frame();
|
||
f.logical_width = 56;
|
||
f.logical_height = 40;
|
||
let rgba = RgbaImage::from_pixel(219, 157, image::Rgba([1, 2, 3, 255]));
|
||
for logical in 0..56 * 40 {
|
||
assert!(reserved_cell_supported(&rgba, &f, logical));
|
||
}
|
||
}
|
||
#[test]
|
||
fn ordinary_cue_coordinates_reject_duplicates_order_reserved_bounds_and_excess() {
|
||
let source = b"data";
|
||
let mut f = frame();
|
||
f.source_sha256 = format!("{:x}", Sha256::digest(source));
|
||
let mut original = Sprite::blank(2, 2);
|
||
original.pixels.fill(5);
|
||
for cells in [
|
||
vec![[0, 0], [0, 0]],
|
||
vec![[1, 0], [0, 0]],
|
||
vec![[2, 0]],
|
||
vec![[0, 2]],
|
||
vec![[0, 0]; 65],
|
||
] {
|
||
f.native_nearest_cues = cells;
|
||
assert!(validate_frame(&f, &original, source).is_err());
|
||
}
|
||
f.native_nearest_cues = vec![[0, 0]];
|
||
for label in [240, 244, 245, 249, 250, 255, TRANSPARENT] {
|
||
original.pixels[0] = label;
|
||
assert!(validate_frame(&f, &original, source).is_err());
|
||
}
|
||
original.pixels[0] = 239;
|
||
assert!(validate_frame(&f, &original, source).is_ok());
|
||
}
|
||
#[test]
|
||
fn nearest_cue_cache_identity_includes_every_sorted_coordinate() {
|
||
let info = PngInfo {
|
||
size: 1,
|
||
modified: None,
|
||
digest: [19; 32],
|
||
width: 8,
|
||
height: 8,
|
||
};
|
||
let mut a = frame();
|
||
a.native_nearest_cues = vec![[0, 0], [1, 0]];
|
||
let mut b = a.clone();
|
||
b.native_nearest_cues[1] = [1, 1];
|
||
assert_ne!(
|
||
HdAssets::frame_key(&a, &info, false),
|
||
HdAssets::frame_key(&b, &info, false)
|
||
);
|
||
}
|
||
#[test]
|
||
fn hidden_bright_rgb_cannot_invent_alpha_for_a_missing_reserved_lamp() {
|
||
let mut raw = RgbaImage::from_pixel(8, 8, image::Rgba([0, 255, 255, 0]));
|
||
for y in 4..8 {
|
||
for x in 4..8 {
|
||
raw.put_pixel(x, y, image::Rgba([255, 20, 20, 255]));
|
||
}
|
||
}
|
||
let (_dir, mut h) = assets(254, true, raw);
|
||
for scale in 1..=4 {
|
||
let s = h.sprite(1, scale, &[[0; 3]; 256]).unwrap().unwrap();
|
||
assert_eq!(s.pixels[0], TRANSPARENT);
|
||
}
|
||
}
|
||
#[test]
|
||
fn native_ordinary_cue_reads_generated_rgb_without_changing_other_pixels_or_static_mode() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let mut raw = RgbaImage::from_pixel(8, 8, image::Rgba([0, 0, 0, 255]));
|
||
raw.put_pixel(2, 2, image::Rgba([255, 150, 30, 255]));
|
||
raw.save(dir.path().join("a.png")).unwrap();
|
||
let make = |cues| {
|
||
let mut f = frame();
|
||
f.native_nearest_cues = cues;
|
||
let mut h = HdAssets {
|
||
root: dir.path().to_path_buf(),
|
||
..HdAssets::default()
|
||
};
|
||
h.replacements.insert(
|
||
1,
|
||
Replacement {
|
||
frame: f,
|
||
requires_alpha: false,
|
||
source_labels: None,
|
||
original_detail_source: None,
|
||
validated: false,
|
||
rejected: false,
|
||
},
|
||
);
|
||
h
|
||
};
|
||
let mut old = make(Vec::new());
|
||
let mut cue = make(vec![[0, 0]]);
|
||
let mut palette = [[0; 3]; 256];
|
||
palette[7] = [255, 150, 30];
|
||
palette[254] = [255, 150, 30];
|
||
for scale in 1..=4 {
|
||
let before = old.sprite(1, scale, &palette).unwrap().unwrap();
|
||
let after = cue.sprite(1, scale, &palette).unwrap().unwrap();
|
||
if scale == 1 {
|
||
assert_eq!(before.pixels[0], 0);
|
||
assert_eq!(after.pixels[0], 7);
|
||
assert_eq!(&before.pixels[1..], &after.pixels[1..]);
|
||
} else {
|
||
assert_eq!(before.pixels, after.pixels);
|
||
}
|
||
assert_eq!(
|
||
old.sprite_static_palette(1, scale, &palette)
|
||
.unwrap()
|
||
.unwrap()
|
||
.pixels,
|
||
cue.sprite_static_palette(1, scale, &palette)
|
||
.unwrap()
|
||
.unwrap()
|
||
.pixels
|
||
);
|
||
}
|
||
// A missing ordinary raw cue remains black; no original palette index
|
||
// or color is copied into artwork by this policy.
|
||
raw.put_pixel(2, 2, image::Rgba([0, 0, 0, 255]));
|
||
raw.save(dir.path().join("a.png")).unwrap();
|
||
let mut missing = make(vec![[0, 0]]);
|
||
assert_eq!(
|
||
missing.sprite(1, 1, &palette).unwrap().unwrap().pixels[0],
|
||
0
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod retained_original_detail_tests {
|
||
use super::*;
|
||
fn source() -> Sprite { Sprite { width: 2, height: 2, pixels: vec![19,20,18,18] } }
|
||
fn frame() -> Frame { Frame { frame: 0, file: "raw.png".into(), logical_width: 2,
|
||
logical_height: 2, origin: [0,0], source_sha256: format!("{:x}",Sha256::digest(b"source")),
|
||
crop: None, atlas_grid: None, preserve_reserved_coverage: false, native_nearest_cues: Vec::new(),
|
||
original_detail_cells: Vec::new() } }
|
||
#[test]
|
||
fn typed_serialization_defaults_and_source_constraints() {
|
||
let f=frame();let j=serde_json::to_value(&f).unwrap();
|
||
assert!(j.get("original_detail_cells").is_none());
|
||
let round:Frame=serde_json::from_value(j.clone()).unwrap();assert!(round.original_detail_cells.is_empty());
|
||
for value in [serde_json::json!([[true,0]]),serde_json::json!([[0.0,0]]),serde_json::json!([[-1,0]])] {
|
||
let mut bad=j.clone();bad["original_detail_cells"]=value;
|
||
assert!(serde_json::from_value::<Frame>(bad).is_err());
|
||
}
|
||
for cells in [vec![[0,0],[0,0]],vec![[1,0],[0,0]],vec![[2,0]],vec![[0,2]]] {
|
||
let mut bad=f.clone();bad.original_detail_cells=cells;
|
||
assert!(validate_frame(&bad,&source(),b"source").is_err());
|
||
}
|
||
let mut selected=f.clone();selected.original_detail_cells=vec![[0,0]];
|
||
assert!(validate_frame(&selected,&source(),b"source").is_ok());
|
||
for label in [0,19,239,240,243,245,249,250,252,253,255] {
|
||
let mut original=source();original.pixels[0]=label;
|
||
assert!(validate_frame(&selected,&original,b"source").is_ok());
|
||
}
|
||
for label in [TRANSPARENT,257,u16::MAX] {
|
||
let mut original=source();original.pixels[0]=label;
|
||
assert!(validate_frame(&selected,&original,b"source").is_err());
|
||
}
|
||
selected.native_nearest_cues=vec![[0,0]];
|
||
assert!(validate_frame(&selected,&source(),b"source").is_err());
|
||
}
|
||
#[test]
|
||
fn footprint_all_scales_and_outside_exact() {
|
||
let original=Sprite {width:3,height:3,pixels:vec![0,243,0,243,19,0,0,0,0]};
|
||
for scale in 1..=4 {
|
||
let control=crate::render::upscale(&original,scale).unwrap();
|
||
let mut image=Sprite {width:3*scale,height:3*scale,pixels:vec![80;9*scale*scale]};
|
||
retain_original_detail_samples(&mut image,&control,&[[1,1]],scale).unwrap();
|
||
for y in 0..image.height {for x in 0..image.width {
|
||
let i=y*image.width+x;
|
||
assert_eq!(image.pixels[i],if x/scale==1&&y/scale==1{control.pixels[i]}else{80});
|
||
}}
|
||
}
|
||
}
|
||
#[test]
|
||
fn empty_keys_unchanged_and_optins_distinct() {
|
||
let f=frame();let info=PngInfo{size:1,modified:None,digest:[1;32],width:8,height:8};
|
||
let old=HdAssets::frame_key(&f,&info,false);
|
||
let mut explicit_empty=f.clone();explicit_empty.original_detail_cells=Vec::new();
|
||
assert_eq!(old,HdAssets::frame_key(&explicit_empty,&info,false));
|
||
let mut a=f.clone();a.original_detail_cells=vec![[0,0]];
|
||
let mut b=a.clone();b.original_detail_cells=vec![[1,0]];
|
||
assert_ne!(old,HdAssets::frame_key(&a,&info,false));
|
||
assert_ne!(HdAssets::frame_key(&a,&info,false),HdAssets::frame_key(&b,&info,false));
|
||
}
|
||
#[test]
|
||
fn source_reference_memory_is_combined_bounded_and_overflow_safe() {
|
||
assert!(source_reference_budget(MAX_SOURCE_LABEL_BYTES,0,0,0));
|
||
assert!(source_reference_budget(0,MAX_SOURCE_LABEL_BYTES,0,0));
|
||
assert!(!source_reference_budget(MAX_SOURCE_LABEL_BYTES,0,0,1));
|
||
assert!(!source_reference_budget(0,MAX_SOURCE_LABEL_BYTES,1,0));
|
||
assert!(!source_reference_budget(usize::MAX,0,1,0));
|
||
}
|
||
#[test]
|
||
fn real_sampler_retains_source_only_for_gameplay_not_baseline_or_static() {
|
||
let dir=tempfile::tempdir().unwrap();
|
||
image::RgbaImage::from_pixel(8,8,image::Rgba([255,0,0,255])).save(dir.path().join("raw.png")).unwrap();
|
||
let mut h=HdAssets {root:dir.path().into(),..HdAssets::default()};
|
||
let mut f=frame();f.original_detail_cells=vec![[0,0]];
|
||
h.replacements.insert(1,Replacement {frame:f,requires_alpha:false,source_labels:None,
|
||
original_detail_source:Some(source()),validated:false,rejected:false});
|
||
let mut palette=[[0;3];256];palette[80]=[255,0,0];
|
||
for scale in 1..=4 {
|
||
let hybrid=h.sprite(1,scale,&palette).unwrap().unwrap();
|
||
let (baseline,details)=h.terrain_sprite(1,scale,&palette).unwrap().unwrap();
|
||
let static_card=h.sprite_static_palette(1,scale,&palette).unwrap().unwrap();
|
||
assert_eq!(details.unwrap().cells(),&[[0,0]]);
|
||
assert!(baseline.pixels.iter().all(|p|*p==80));assert_eq!(baseline,static_card);
|
||
for y in 0..2*scale {for x in 0..2*scale {
|
||
assert_eq!(hybrid.pixels[y*2*scale+x],if x<scale&&y<scale{19}else{80});
|
||
}}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn functional_palette_indices_retained_at_all_scales_and_Static_PNG_unaffected() {
|
||
let dir=tempfile::tempdir().unwrap();
|
||
let raw=image::RgbaImage::from_fn(8,8,|x,y|if x<4&&y>=4 {image::Rgba([255,0,0,255])}else{image::Rgba([0,0,0,0])});
|
||
raw.save(dir.path().join("raw.png")).unwrap();
|
||
let raw_before=std::fs::read(dir.path().join("raw.png")).unwrap();
|
||
let original=Sprite{width:2,height:2,pixels:vec![252,253,80,TRANSPARENT]};
|
||
let mut h=HdAssets{root:dir.path().into(),..HdAssets::default()};let mut f=frame();f.original_detail_cells=vec![[0,0],[1,0]];
|
||
h.replacements.insert(1,Replacement{frame:f,requires_alpha:true,source_labels:Some(vec![252,253,0,0]),original_detail_source:Some(original.clone()),validated:false,rejected:false});
|
||
let mut a=[[0;3];256];a[80]=[255,0,0];a[252]=[255,255,0];a[253]=[0,255,0];
|
||
let mut b=a;b[252]=[0,0,0];b[253]=[255,255,255];
|
||
for scale in 1..=4 {
|
||
let control=crate::render::upscale(&original,scale).unwrap();
|
||
let baseline=h.terrain_sprite(1,scale,&a).unwrap().unwrap().0;
|
||
let hybrid_a=h.sprite(1,scale,&a).unwrap().unwrap();let hybrid_b=h.sprite(1,scale,&b).unwrap().unwrap();
|
||
let static_a=h.sprite_static_palette(1,scale,&a).unwrap().unwrap();
|
||
let mut no_details=h.replacements.get(&1).unwrap().frame.clone();no_details.original_detail_cells.clear();
|
||
let saved=h.replacements.get(&1).unwrap().frame.clone();h.replacements.get_mut(&1).unwrap().frame=no_details;
|
||
let static_default=h.sprite_static_palette(1,scale,&a).unwrap().unwrap();h.replacements.get_mut(&1).unwrap().frame=saved;
|
||
assert_eq!(static_a,static_default,"retention must never alter Static sampling");
|
||
assert_eq!(hybrid_a,hybrid_b,"functional indices survive changing palette values");
|
||
let rendered_a=crate::render::Canvas{image:hybrid_a.clone(),scale}.rgba(&a,false);
|
||
let rendered_b=crate::render::Canvas{image:hybrid_b.clone(),scale}.rgba(&b,false);
|
||
for y in 0..2*scale {for x in 0..2*scale {
|
||
let i=y*2*scale+x;let expected=if y<scale{control.pixels[i]}else{baseline.pixels[i]};
|
||
assert_eq!(hybrid_a.pixels[i],expected);
|
||
if expected==252||expected==253 {
|
||
assert_eq!(&rendered_a[4*i..4*i+3],&a[expected as usize]);
|
||
assert_eq!(&rendered_b[4*i..4*i+3],&b[expected as usize]);
|
||
assert_ne!(&rendered_a[4*i..4*i+3],&rendered_b[4*i..4*i+3]);
|
||
}
|
||
}}
|
||
}
|
||
assert_eq!(std::fs::read(dir.path().join("raw.png")).unwrap(),raw_before,"no PNG/alpha edit");
|
||
}
|
||
#[test]
|
||
fn reserved_logical_cell_can_truthfully_retain_transparent_OriginalNx_subpixels() {
|
||
let original=Sprite{width:3,height:3,pixels:vec![TRANSPARENT,TRANSPARENT,80,TRANSPARENT,252,80,80,80,80]};
|
||
let mut had_mixed=false;
|
||
for scale in 1..=4 {
|
||
let control=crate::render::upscale(&original,scale).unwrap();
|
||
let mut image=Sprite{width:3*scale,height:3*scale,pixels:vec![80;9*scale*scale]};
|
||
retain_original_detail_samples(&mut image,&control,&[[1,1]],scale).unwrap();
|
||
for y in 0..image.height {for x in 0..image.width {
|
||
let i=y*image.width+x;let selected=x/scale==1&&y/scale==1;
|
||
assert_eq!(image.pixels[i],if selected{control.pixels[i]}else{80});
|
||
had_mixed|=selected&&control.pixels[i]==TRANSPARENT;
|
||
}}
|
||
}
|
||
assert!(had_mixed,"real ScaleNx diagonal control includes transparent subpixel");
|
||
}
|
||
#[test]
|
||
fn retained_reserved_metadata_never_rescues_invalid_PNG_eligibility() {
|
||
for invisible in [false,true] {
|
||
let dir=tempfile::tempdir().unwrap();
|
||
let (w,hgt)=if invisible{(8,8)}else{(8,5)};
|
||
let raw=image::RgbaImage::from_fn(w,hgt,|x,y|if invisible||(x==0&&y==0){image::Rgba([0,0,0,0])}else{image::Rgba([255,0,0,255])});
|
||
raw.save(dir.path().join("raw.png")).unwrap();
|
||
let mut h=HdAssets{root:dir.path().into(),..HdAssets::default()};let mut f=frame();f.original_detail_cells=vec![[0,0]];
|
||
let original=Sprite{width:2,height:2,pixels:vec![252,80,80,TRANSPARENT]};
|
||
h.replacements.insert(1,Replacement{frame:f,requires_alpha:true,source_labels:Some(vec![252,0,0,0]),original_detail_source:Some(original),validated:false,rejected:false});
|
||
assert!(h.sprite(1,1,&[[0;3];256]).unwrap().is_none());
|
||
assert!(h.terrain_sprite(1,1,&[[0;3];256]).unwrap().is_none());
|
||
assert!(!h.diagnostics.is_empty());
|
||
}
|
||
}
|
||
fn ordinary_only_v1_frame_key(frame: &Frame, info: &PngInfo, requires_alpha: bool) -> [u8; 32] {
|
||
let mut digest = Sha256::new();
|
||
digest.update(KERNEL_VERSION);
|
||
let (width, height) = frame
|
||
.crop
|
||
.map(|[_, _, width, height]| (width, height))
|
||
.unwrap_or((info.width, info.height));
|
||
if !exact_aspect(width, height, frame.logical_width, frame.logical_height) {
|
||
// Preserve exact and rounded-opaque identities. Newly eligible
|
||
// transparent rounded frames have their own alpha-validation namespace.
|
||
if requires_alpha {
|
||
digest.update(b"full-frame-transparent-rounding-v1");
|
||
} else {
|
||
digest.update(b"full-frame-opaque-rounding-v1");
|
||
}
|
||
}
|
||
digest.update(info.digest);
|
||
digest.update(frame.source_sha256.as_bytes());
|
||
digest.update(frame.logical_width.to_le_bytes());
|
||
digest.update(frame.logical_height.to_le_bytes());
|
||
for value in frame.origin {
|
||
digest.update(value.to_le_bytes());
|
||
}
|
||
digest.update([u8::from(requires_alpha), u8::from(frame.crop.is_some())]);
|
||
if let Some(crop) = frame.crop {
|
||
for value in crop {
|
||
digest.update(value.to_le_bytes());
|
||
}
|
||
}
|
||
// Preserve exact legacy disk/indexed cache keys when both policies are
|
||
// absent/default. Every opt-in has a distinct, versioned namespace.
|
||
if frame.preserve_reserved_coverage || !frame.native_nearest_cues.is_empty() {
|
||
digest.update(b"raptor-hd-frame-presentation-policy-v1");
|
||
digest.update([u8::from(frame.preserve_reserved_coverage)]);
|
||
digest.update((frame.native_nearest_cues.len() as u32).to_le_bytes());
|
||
for cue in &frame.native_nearest_cues {
|
||
for coordinate in cue {
|
||
digest.update(coordinate.to_le_bytes());
|
||
}
|
||
}
|
||
}
|
||
if !frame.original_detail_cells.is_empty() {
|
||
digest.update(b"raptor-retained-original-details-v1");
|
||
digest.update((frame.original_detail_cells.len() as u32).to_le_bytes());
|
||
for cell in &frame.original_detail_cells {
|
||
for coordinate in cell { digest.update(coordinate.to_le_bytes()); }
|
||
}
|
||
}
|
||
digest.finalize().into()
|
||
}
|
||
#[test]
|
||
fn painted_domain_nonempty_keys_are_versioned_while_defaults_are_identical() {
|
||
let f=frame();let info=PngInfo{size:1,modified:None,digest:[1;32],width:8,height:8};
|
||
assert_eq!(HdAssets::frame_key(&f,&info,false),ordinary_only_v1_frame_key(&f,&info,false));
|
||
let mut selected=f;selected.original_detail_cells=vec![[0,0]];
|
||
assert_ne!(HdAssets::frame_key(&selected,&info,false),ordinary_only_v1_frame_key(&selected,&info,false));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod rational_atlas_grid_tests {
|
||
use super::*;
|
||
fn frame() -> Frame {
|
||
Frame { frame: 0, file: "synthetic.png".into(), logical_width: 1,
|
||
logical_height: 1, origin: [0, 0], source_sha256: "00".repeat(32),
|
||
crop: None, atlas_grid: Some(AtlasGrid { columns: 2, rows: 2, column: 0, row: 0 }),
|
||
preserve_reserved_coverage: false, native_nearest_cues: Vec::new(),
|
||
original_detail_cells: Vec::new() }
|
||
}
|
||
fn info() -> PngInfo {
|
||
PngInfo { size: 1, modified: None, digest: [1; 32], width: 17, height: 17 }
|
||
}
|
||
#[test]
|
||
fn rational_cells_retain_exact_fractional_bounds_and_last_pixel() {
|
||
let mut f = frame(); f.logical_width=32; f.logical_height=32;
|
||
f.atlas_grid=Some(AtlasGrid { columns:4,rows:2,column:3,row:1 });
|
||
let view=grid_viewport(&f,1774,887).unwrap();
|
||
assert_eq!((view.left,view.top,view.right,view.bottom),(1330.5,443.5,1774.0,887.0));
|
||
assert_eq!(view.pixels,[1330,443,1774,887]);
|
||
assert_eq!(view.resident,[128,128]);
|
||
let weights=grid_axis_weights(view.left,view.right,128).unwrap();
|
||
assert!(weights.iter().flatten().all(|(x,_)|*x>=1330&&*x<1774));
|
||
}
|
||
#[test]
|
||
fn invalid_axes_indices_products_aspects_and_crop_are_rejected() {
|
||
for grid in [
|
||
AtlasGrid{columns:0,rows:2,column:0,row:0},
|
||
AtlasGrid{columns:2,rows:0,column:0,row:0},
|
||
AtlasGrid{columns:129,rows:129,column:0,row:0},
|
||
AtlasGrid{columns:2,rows:2,column:2,row:0},
|
||
AtlasGrid{columns:2,rows:2,column:0,row:2},
|
||
] { let mut f=frame(); f.atlas_grid=Some(grid);assert!(grid_viewport(&f,5,5).is_err()); }
|
||
let mut f=frame();f.crop=Some([0,0,2,2]);assert!(grid_viewport(&f,5,5).is_err());
|
||
assert!(grid_viewport(&frame(),5,6).is_err());
|
||
assert!(grid_viewport(&frame(),3,3).is_err());
|
||
}
|
||
#[test]
|
||
fn absent_grid_adds_no_serialized_field_and_null_stays_absent() {
|
||
let mut f=frame();f.atlas_grid=None;
|
||
let serialized=serde_json::to_value(&f).unwrap();
|
||
assert!(serialized.get("atlas_grid").is_none());
|
||
let mut explicit=serialized.clone();explicit["atlas_grid"]=serde_json::Value::Null;
|
||
let decoded:Frame=serde_json::from_value(explicit).unwrap();
|
||
assert_eq!(serde_json::to_value(decoded).unwrap(),serialized);
|
||
}
|
||
#[test]
|
||
fn grid_json_rejects_unknown_boolean_negative_and_float_fields() {
|
||
for value in [
|
||
r#"{"columns":2,"rows":2,"column":0,"row":0,"extra":1}"#,
|
||
r#"{"columns":true,"rows":2,"column":0,"row":0}"#,
|
||
r#"{"columns":2,"rows":2,"column":-1,"row":0}"#,
|
||
r#"{"columns":2,"rows":2,"column":0.0,"row":0}"#,
|
||
] { assert!(serde_json::from_str::<AtlasGrid>(value).is_err()); }
|
||
}
|
||
#[test]
|
||
fn grid_cache_identity_binds_layout_cell_source_png_and_kernel_namespace() {
|
||
let mut f=frame();let mut metadata=info();
|
||
let mut keys=std::collections::BTreeSet::new();
|
||
for row in 0..2 {for column in 0..2 {
|
||
f.atlas_grid=Some(AtlasGrid{columns:2,rows:2,column,row});
|
||
assert!(keys.insert(HdAssets::frame_key(&f,&metadata,false)));
|
||
}}
|
||
f.atlas_grid=Some(AtlasGrid{columns:4,rows:4,column:0,row:0});
|
||
assert!(keys.insert(HdAssets::frame_key(&f,&metadata,false)));
|
||
f.atlas_grid=None;assert!(keys.insert(HdAssets::frame_key(&f,&metadata,false)));
|
||
f.atlas_grid=Some(AtlasGrid{columns:2,rows:2,column:0,row:0});
|
||
metadata.digest=[2;32];assert!(keys.insert(HdAssets::frame_key(&f,&metadata,false)));
|
||
f.source_sha256="11".repeat(32);assert!(keys.insert(HdAssets::frame_key(&f,&metadata,false)));
|
||
}
|
||
#[test]
|
||
fn constant_fractional_cell_is_constant_without_neighbor_invention() {
|
||
let f=frame();let raw=RgbaImage::from_pixel(5,5,image::Rgba([40,80,120,255]));
|
||
let old=raw.as_raw().clone();
|
||
let selected=selected_frame(&raw,&f,2,2,false).unwrap();
|
||
assert!(selected.pixels().all(|p|p.0==[40,80,120,255]));
|
||
assert_eq!(*raw.as_raw(),old);
|
||
}
|
||
#[test]
|
||
fn fractional_boundary_pixel_is_shared_but_wholly_neighboring_pixels_are_not() {
|
||
let raw=RgbaImage::from_fn(5,5,|x,_|image::Rgba(
|
||
if x<2 {[255,0,0,255]} else if x==2 {[0,255,0,255]} else {[0,0,255,255]}));
|
||
let left=selected_frame(&raw,&frame(),2,2,false).unwrap();
|
||
assert!(left.pixels().all(|p|p[2]==0));
|
||
let mut f=frame();f.atlas_grid.as_mut().unwrap().column=1;
|
||
let right=selected_frame(&raw,&f,2,2,false).unwrap();
|
||
assert!(right.pixels().all(|p|p[0]==0));
|
||
let a=grid_axis_weights(0.0,2.5,2).unwrap();let b=grid_axis_weights(2.5,5.0,2).unwrap();
|
||
assert!(a.iter().flatten().any(|(x,w)|*x==2&&*w!=0.0));
|
||
assert!(b.iter().flatten().any(|(x,w)|*x==2&&*w!=0.0));
|
||
}
|
||
#[test]
|
||
fn integral_selection_uses_original_crop_without_an_extra_sampler() {
|
||
let f=frame();let raw=RgbaImage::from_fn(8,8,|x,y|image::Rgba([x as u8,y as u8,0,255]));
|
||
assert_eq!(selected_frame(&raw,&f,4,4,false).unwrap(),
|
||
imageops::crop_imm(&raw,0,0,4,4).to_image());
|
||
}
|
||
#[test]
|
||
fn raw_alpha_validation_is_before_fractional_filtering() {
|
||
let opaque=RgbaImage::from_pixel(5,5,image::Rgba([1,2,3,255]));
|
||
assert!(selected_frame(&opaque,&frame(),2,2,true).is_err());
|
||
let hidden=RgbaImage::new(5,5);
|
||
assert!(selected_frame(&hidden,&frame(),2,2,true).is_err());
|
||
}
|
||
}
|
||
|
||
|
||
#[cfg(test)]
|
||
mod extended_retained_original_bound_tests {
|
||
use super::*;
|
||
fn source(painted: usize, canvas: usize) -> Sprite {
|
||
let mut pixels = vec![TRANSPARENT; canvas];
|
||
for (i, p) in pixels.iter_mut().take(painted).enumerate() { *p = (i % 256) as u16; }
|
||
Sprite { width: canvas, height: 1, pixels }
|
||
}
|
||
#[test]
|
||
fn exact_quarter_edges_and_hard_maximum() {
|
||
for (n, painted, expected) in [(64, 0, true), (65, 260, true),
|
||
(65, 259, false), (73, 625, true), (128, 512, true),
|
||
(128, 511, false), (129, 4096, false)] {
|
||
assert_eq!(original_detail_bound(&vec![[0,0]; n], &source(painted,4096)), expected);
|
||
}
|
||
}
|
||
#[test]
|
||
fn transparent_canvas_never_inflates_fraction() {
|
||
assert!(!original_detail_bound(&vec![[0,0];65], &source(259,4096)));
|
||
assert!(original_detail_bound(&vec![[0,0];65], &source(260,4096)));
|
||
}
|
||
#[test]
|
||
fn legacy_tags_remain_exact_and_extended_namespaces_are_new() {
|
||
for n in [0,1,64] {
|
||
assert_eq!(original_detail_policy_tag(n,false), b"raptor-retained-original-details-v2-painted0255");
|
||
assert_eq!(original_detail_policy_tag(n,true), b"retained-original-rejected-fallback-v2-painted0255");
|
||
}
|
||
assert_ne!(original_detail_policy_tag(65,false),original_detail_policy_tag(64,false));
|
||
assert_ne!(original_detail_policy_tag(65,true),original_detail_policy_tag(64,true));
|
||
assert_eq!(original_detail_policy_tag(65,false),original_detail_policy_tag(128,false));
|
||
}
|
||
}
|