Preserve full opaque HD backgrounds and source-supported small cues

This commit is contained in:
raptorreplica committed 2026-10-12 08:40:14 +11:00
1 parent a02c9b0679
commit e95d060b0c
13 files changed
+1022 -33

No files matched your search

BIN
View File
Binary file not shown.
Binary file not shown.
+36 -6
View File
@@ -1,8 +1,7 @@
{ {
"format_version": 1, "format_version": 1,
"status": "approved_artwork_bundled_and_cli_validated", "status": "approved_artwork_and_updated_loader_bundled_cli_validated",
"release_scope": "Shared Linux and Windows original-content artwork; gameplay and executable bytes unchanged from Stage2 release", "release_scope": "Shared Linux and Windows original-content artwork with reviewed bounded HD loader fixes; simulation and mod content unchanged",
"source_commit": "0e8a27c7288a388dc335ec71233358af42cb3aa5",
"assembly": { "assembly": {
"approved_hd_frames": 2132, "approved_hd_frames": 2132,
"complete_sequences": 875, "complete_sequences": 875,
@@ -14,11 +13,12 @@
"hd_manifest_sha256": "f88eb9210a58a9ca6b6a53702fd4c2fc81fe361960f904002e2bbe8e528c8366", "hd_manifest_sha256": "f88eb9210a58a9ca6b6a53702fd4c2fc81fe361960f904002e2bbe8e528c8366",
"provenance_sha256": "3e386dfdd6917daa81adcddd87c55062be4bde56f91e67bb6b5ed7833c405129", "provenance_sha256": "3e386dfdd6917daa81adcddd87c55062be4bde56f91e67bb6b5ed7833c405129",
"baseline_1148_bindings_unchanged": true, "baseline_1148_bindings_unchanged": true,
"original_catalog_binary_and_sources_unchanged": true,
"data_files": 7415, "data_files": 7415,
"data_bytes": 2357732944, "data_bytes": 2357732944,
"binary_sha256": "7e62ae1b0e8d18670a7c6c3d4f6419878f696f62ba4c7dd2ee9ebf7545f648de", "binary_sha256": "9688cdbd57b0d31049bac431a5bb2e864bb3ce7abbaca732634359ad7a53d8a9",
"windows_binary_sha256": "63d040f5d5c76bad15a74ab1ef54ca992a0fd9cd8b7a46f8506d8cce4fbec275" "windows_binary_sha256": "ad2ee7f5cc8e90ea371eccf2a509cd99263c6c880a6d42e2886cdc017c24561b",
"original_catalog_assets_unchanged": true,
"other_rust_sources_unchanged": true
}, },
"validation": [ "validation": [
{ {
@@ -74,5 +74,35 @@
"all_three_full_frames_inspected": true, "all_three_full_frames_inspected": true,
"findings": "Player, HUD, canal, building and sprite layers retain source placements without new clipping. Mixed original/HD terrain density patches remain visible until remaining original pictures are remastered.", "findings": "Player, HUD, canal, building and sprite layers retain source placements without new clipping. Mixed original/HD terrain density patches remain visible until remaining original pictures are remastered.",
"limits": "Representative startup scenes; not whole-mission completion, fullHD coverage, whole-map seamlessness or hardware performance certification" "limits": "Representative startup scenes; not whole-mission completion, fullHD coverage, whole-map seamlessness or hardware performance certification"
},
"gameplay_baseline_source_commit": "0e8a27c7288a388dc335ec71233358af42cb3aa5",
"loader_source_sha256": "73f912a225c8b280789fda3cbf4bd26deb4632beff5bcd8d30a7b1fa8c93dd1d",
"loader_epoch_sha256": "921752d978024461827d239250d49b4fbfcc0e2e3ecd4b80dc3d682050961efb",
"prior_executables": {
"linux_sha256": "7e62ae1b0e8d18670a7c6c3d4f6419878f696f62ba4c7dd2ee9ebf7545f648de",
"windows_sha256": "63d040f5d5c76bad15a74ab1ef54ca992a0fd9cd8b7a46f8506d8cce4fbec275",
"scope": "2132 artwork publication before loader update; historical native capture evidence below uses these executables"
},
"updated_loader_validation": {
"utc": "2026-10-11T21:38:31.029538+00:00",
"approved_hd_registered": 2132,
"approved_hd_validated": 2132,
"rejected": 0,
"original_assets_verified": 6449,
"mods_validated": 45,
"overrides": 1,
"focused_rust_hd_tests_passed": 40,
"headless_library_tests_passed": 84,
"headless_library_tests_ignored": 3,
"matched_python_tests_passed": 19,
"existing_python_hd_tests_passed": 7,
"format_and_diff_checks": "passed",
"full_default_raster_continuity": "in progress; no inherited current-renderer claim",
"new_archive_execution": "pending final export",
"portable_library_and_integration_suite": "passed: cargo test --locked --no-default-features --tests",
"unchanged_baseline_pins": {
"original_data": 6450,
"unchanged_other_rust_sources": 17
}
} }
} }
+11
View File
@@ -6,6 +6,17 @@ The runtime can register the complete original picture catalog without retaining
Registration does not imply a PNG has been decoded. `registered_count()` reports source-checked candidates that have not been rejected; `validated_count()` and the compatibility alias `coverage()` report frames whose artwork has passed validation. First use checks PNG format, crop bounds, matching aspect ratio, at least 2× logical resolution, visible pixels and source-aware transparency. Transparent originals require transparent negative space; opaque originals accept opaque art. A failed frame returns `Ok(None)` from `sprite`, records one diagnostic and keeps native fallback until the pack is reloaded. Registration does not imply a PNG has been decoded. `registered_count()` reports source-checked candidates that have not been rejected; `validated_count()` and the compatibility alias `coverage()` report frames whose artwork has passed validation. First use checks PNG format, crop bounds, matching aspect ratio, at least 2× logical resolution, visible pixels and source-aware transparency. Transparent originals require transparent negative space; opaque originals accept opaque art. A failed frame returns `Ok(None)` from `sprite`, records one diagnostic and keeps native fallback until the pack is reloaded.
A fully opaque original may also use a complete, fully opaque PNG whose dimensions result from rounding one common scale by less than half a pixel on each axis. This accepts a generated 1,586×992 background for a 320×200 original without discarding its border. It does not permit partial crops, transparent artwork, square canvases with unequal axes, or arbitrary aspect changes. Every raw alpha value is checked before downsampling; rounded-frame cache entries use a separate identity and repeat the opacity check.
## Optional frame presentation policies
Both policies below default to disabled. They require explicit frame metadata and source/art review; loader validation alone does not approve a remaster.
- `preserve_reserved_coverage: true` retains an original gameplay palette label 250–255 at a feathered edge only when its resident logical cell has mean alpha of at least 128 and at least half its area has alpha of 128 or greater. Missing or sparse artwork stays transparent. Ordinary colors, water/fire classes and static-card rendering keep their existing behavior.
- `native_nearest_cues: [[x, y], ...]` samples the unchanged resident RGBA at the nearest pixel for up to 64 explicitly listed original cells, only at gameplay scale 1. Coordinates must be sorted lexicographically, unique, within an opaque original canvas, and refer to ordinary source indices below 240. This can preserve a small raw-supported warning color that averaging dulls. It does not recolor artwork or reconstruct absent details; other cells, higher scales and static-card rendering keep their existing sampler.
Opt-in metadata participates in both derived and indexed cache identities. Frames without these policies retain their previous cache keys and pixels. The candidate and original-art audit tools validate the same metadata restrictions as the Rust loader.
`HdAssets::load` performs full eager validation using the same bounded caches. `validate_all()` can also validate a lazily registered pack; its report distinguishes registered, validated and rejected frames. Full validation can therefore be used in a CLI or audit without raising runtime memory limits. It validates supplied artwork and does not infer coverage for absent originals. `HdAssets::load` performs full eager validation using the same bounded caches. `validate_all()` can also validate a lazily registered pack; its report distinguishes registered, validated and rejected frames. Full validation can therefore be used in a CLI or audit without raising runtime memory limits. It validates supplied artwork and does not infer coverage for absent originals.
## Memory bounds ## Memory bounds
+9 -3
View File
@@ -59,10 +59,16 @@ mods, captures SDL gameplay and publishes the zip as a workflow artifact.
## Current runtime and validation ## Current runtime and validation
The included GNU executable uses the reviewed **1,148-frame HD snapshot**; The included GNU executable uses the reviewed **2,132-frame HD snapshot**;
the shared manifest binds **6,848 data files** and the package contains the shared manifest binds **7,415 data files** and the package contains
**45 installed mods**. Its SHA-256 is **45 installed mods**. Its SHA-256 is
`63d040f5d5c76bad15a74ab1ef54ca992a0fd9cd8b7a46f8506d8cce4fbec275`. `ad2ee7f5cc8e90ea371eccf2a509cd99263c6c880a6d42e2886cdc017c24561b`.
This build includes the bounded opaque-background rounding and optional
per-frame presentation policies documented in [hd-streaming.md](hd-streaming.md).
Existing approved artwork uses the default policies. Fresh archive execution
and native Windows CI checks for this successor are recorded separately from
the historical checks below; they are not inherited from unchanged artwork.
The [1,148-frame native Windows run](https://github.com/Quintine/RustRaptor/actions/runs/38126069413) The [1,148-frame native Windows run](https://github.com/Quintine/RustRaptor/actions/runs/38126069413)
passed portable tests, exact committed GNU runtime checks, the MSVC desktop passed portable tests, exact committed GNU runtime checks, the MSVC desktop
build and exported-folder catalog, HD, mod and SDL gameplay validation. That build and exported-folder catalog, HD, mod and SDL gameplay validation. That
+7 -7
View File
@@ -3,7 +3,7 @@
"platform": "Linux x86_64", "platform": "Linux x86_64",
"catalog_entries": 6449, "catalog_entries": 6449,
"approved_hd_frames": 2132, "approved_hd_frames": 2132,
"binary_sha256": "7e62ae1b0e8d18670a7c6c3d4f6419878f696f62ba4c7dd2ee9ebf7545f648de", "binary_sha256": "9688cdbd57b0d31049bac431a5bb2e864bb3ce7abbaca732634359ad7a53d8a9",
"data_files": 7415, "data_files": 7415,
"data_bytes": 2357732944, "data_bytes": 2357732944,
"files": { "files": {
@@ -7424,11 +7424,11 @@
"data/ui/WINGAME_SWD": "304630a1223419d69723902ffd22c2c4eea0388491e37dfca663fcbe78e99aff" "data/ui/WINGAME_SWD": "304630a1223419d69723902ffd22c2c4eea0388491e37dfca663fcbe78e99aff"
}, },
"system_dependencies": [ "system_dependencies": [
"\tlinux-vdso.so.1 (0x00007f6e5830f000)", "\tlinux-vdso.so.1 (0x00007fde2b83d000)",
"\tlibSDL2-2.0.so.0 => /home/quintine/.local/lib/libSDL2-2.0.so.0 (0x00007f6e57600000)", "\tlibSDL2-2.0.so.0 => /home/quintine/.local/lib/libSDL2-2.0.so.0 (0x00007fde2aa00000)",
"\tlibgcc_s.so.1 => /usr/lib/libgcc_s.so.1 (0x00007f6e579d3000)", "\tlibgcc_s.so.1 => /usr/lib/libgcc_s.so.1 (0x00007fde2b7e9000)",
"\tlibm.so.6 => /usr/lib/libm.so.6 (0x00007f6e5789c000)", "\tlibm.so.6 => /usr/lib/libm.so.6 (0x00007fde2acc9000)",
"\tlibc.so.6 => /usr/lib/libc.so.6 (0x00007f6e57200000)", "\tlibc.so.6 => /usr/lib/libc.so.6 (0x00007fde2a600000)",
"\t/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007f6e58311000)" "\t/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007fde2b83f000)"
] ]
} }
+1 -1
View File
@@ -37,7 +37,7 @@
] ]
}, },
"files": { "files": {
"bin/raptor-rust.exe": "63d040f5d5c76bad15a74ab1ef54ca992a0fd9cd8b7a46f8506d8cce4fbec275", "bin/raptor-rust.exe": "ad2ee7f5cc8e90ea371eccf2a509cd99263c6c880a6d42e2886cdc017c24561b",
"bin/SDL2.dll": "53e8fa7e9ed43c30bd650c39396f0d9d2067d2fff0b926c9433b75202592e4aa", "bin/SDL2.dll": "53e8fa7e9ed43c30bd650c39396f0d9d2067d2fff0b926c9433b75202592e4aa",
"bin/SDL2-WINDOWS-LICENSE.txt": "97f35b302b361680ec1e891e95d2d52097bb95abff361434916d99dc1305f127", "bin/SDL2-WINDOWS-LICENSE.txt": "97f35b302b361680ec1e891e95d2d52097bb95abff361434916d99dc1305f127",
"run.cmd": "b007382d9ed3560191856514a9ebd4e429801fc25428840e304bb297f3553269" "run.cmd": "b007382d9ed3560191856514a9ebd4e429801fc25428840e304bb297f3553269"
+723 -11
View File
@@ -60,6 +60,18 @@ pub struct Frame {
/// Optional pixel rectangle [x, y, width, height] in an atlas PNG. /// Optional pixel rectangle [x, y, width, height] in an atlas PNG.
#[serde(default)] #[serde(default)]
pub crop: Option<[u32; 4]>, pub crop: Option<[u32; 4]>,
/// 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]>,
}
fn is_false(value: &bool) -> bool {
!*value
} }
struct Replacement { struct Replacement {
frame: Frame, frame: Frame,
@@ -369,6 +381,15 @@ impl HdAssets {
fn frame_key(frame: &Frame, info: &PngInfo, requires_alpha: bool) -> [u8; 32] { fn frame_key(frame: &Frame, info: &PngInfo, requires_alpha: bool) -> [u8; 32] {
let mut digest = Sha256::new(); let mut digest = Sha256::new();
digest.update(KERNEL_VERSION); 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) {
// Only the new rounded opaque path needs a new validation identity.
// Existing exact-aspect cache keys and raster bytes stay unchanged.
digest.update(b"full-frame-opaque-rounding-v1");
}
digest.update(info.digest); digest.update(info.digest);
digest.update(frame.source_sha256.as_bytes()); digest.update(frame.source_sha256.as_bytes());
digest.update(frame.logical_width.to_le_bytes()); digest.update(frame.logical_width.to_le_bytes());
@@ -382,6 +403,18 @@ impl HdAssets {
digest.update(value.to_le_bytes()); 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());
}
}
}
digest.finalize().into() digest.finalize().into()
} }
fn rgba_frame(&mut self, id: u32) -> Result<Option<([u8; 32], RgbaImage)>> { fn rgba_frame(&mut self, id: u32) -> Result<Option<([u8; 32], RgbaImage)>> {
@@ -395,16 +428,22 @@ impl HdAssets {
let requires_alpha = record.requires_alpha; let requires_alpha = record.requires_alpha;
let result = (|| -> Result<([u8; 32], RgbaImage)> { let result = (|| -> Result<([u8; 32], RgbaImage)> {
let (path, info) = self.png(&frame.file)?; let (path, info) = self.png(&frame.file)?;
let (width, height) = frame_dimensions(&frame, &info)?; let (width, height) = frame_dimensions(&frame, &info, requires_alpha)?;
let requires_opaque =
!exact_aspect(width, height, frame.logical_width, frame.logical_height);
let key = Self::frame_key(&frame, &info, requires_alpha); let key = Self::frame_key(&frame, &info, requires_alpha);
if let Some(rgba) = self.rgba.get(&key) { if let Some(rgba) = self.rgba.get(&key) {
return Ok((key, rgba.clone())); return Ok((key, rgba.clone()));
} }
let target_width = width.min(frame.logical_width * 4); let target_width = width.min(frame.logical_width * 4);
let target_height = height.min(frame.logical_height * 4); let target_height = height.min(frame.logical_height * 4);
if let Some(rgba) = if let Some(rgba) = self.read_disk_cache(
self.read_disk_cache(&key, target_width, target_height, requires_alpha) &key,
{ target_width,
target_height,
requires_alpha,
requires_opaque,
) {
self.disk_hits += 1; self.disk_hits += 1;
self.rgba.insert(key, rgba.clone(), rgba.as_raw().len()); self.rgba.insert(key, rgba.clone(), rgba.as_raw().len());
return Ok((key, rgba)); return Ok((key, rgba));
@@ -436,6 +475,9 @@ impl HdAssets {
rgba rgba
}; };
validate_rgba(&rgba, requires_alpha)?; 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. // Keep at most the highest usable 4x resolution, never generator originals.
if rgba.width() > target_width || rgba.height() > target_height { if rgba.width() > target_width || rgba.height() > target_height {
rgba = imageops::resize(&rgba, target_width, target_height, FilterType::Lanczos3); rgba = imageops::resize(&rgba, target_width, target_height, FilterType::Lanczos3);
@@ -469,6 +511,7 @@ impl HdAssets {
width: u32, width: u32,
height: u32, height: u32,
requires_alpha: bool, requires_alpha: bool,
requires_opaque: bool,
) -> Option<RgbaImage> { ) -> Option<RgbaImage> {
let path = self.cache_path(key)?; let path = self.cache_path(key)?;
if !path.exists() { if !path.exists() {
@@ -505,6 +548,7 @@ impl HdAssets {
let rgba = RgbaImage::from_raw(width, height, bytes[CACHE_HEADER..].to_vec()) let rgba = RgbaImage::from_raw(width, height, bytes[CACHE_HEADER..].to_vec())
.context("derived cache image dimensions")?; .context("derived cache image dimensions")?;
validate_rgba(&rgba, requires_alpha)?; validate_rgba(&rgba, requires_alpha)?;
validate_opaque_rounding(&rgba, requires_opaque)?;
Ok(rgba) Ok(rgba)
})(); })();
match read { match read {
@@ -734,10 +778,24 @@ impl HdAssets {
for (index, (destination, pixel)) in for (index, (destination, pixel)) in
sprite.pixels.iter_mut().zip(resized.pixels()).enumerate() sprite.pixels.iter_mut().zip(resized.pixels()).enumerate()
{ {
if pixel[3] < 128 { let pixel = if policy == PalettePolicy::SourceClasses
continue; && scale == 1
} && !frame.native_nearest_cues.is_empty()
let rgb = [pixel[0], pixel[1], pixel[2]]; && 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 = let logical =
(index / width / scale) * frame.logical_width as usize + (index % width / scale); (index / width / scale) * frame.logical_width as usize + (index % width / scale);
let source_label = if policy == PalettePolicy::SourceClasses { let source_label = if policy == PalettePolicy::SourceClasses {
@@ -748,6 +806,19 @@ impl HdAssets {
} else { } else {
0 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 == PalettePolicy::SourceClasses
&& 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 *destination = *colors
.entry((rgb, source_label)) .entry((rgb, source_label))
.or_insert_with(|| match policy { .or_insert_with(|| match policy {
@@ -760,6 +831,35 @@ impl HdAssets {
Ok(Some(sprite)) 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>> { fn bounded_read(path: &Path, limit: u64) -> Result<Vec<u8>> {
let file = fs::File::open(path)?; let file = fs::File::open(path)?;
let size = file.metadata()?.len(); let size = file.metadata()?.len();
@@ -786,7 +886,26 @@ fn image_limits() -> image::Limits {
limits.max_alloc = Some(64 * 1024 * 1024); limits.max_alloc = Some(64 * 1024 * 1024);
limits limits
} }
fn frame_dimensions(frame: &Frame, png: &PngInfo) -> Result<(u32, u32)> { 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
}
fn frame_dimensions(frame: &Frame, png: &PngInfo, requires_alpha: bool) -> Result<(u32, u32)> {
let (width, height) = if let Some([x, y, width, height]) = frame.crop { let (width, height) = if let Some([x, y, width, height]) = frame.crop {
ensure!( ensure!(
width > 0 width > 0
@@ -800,8 +919,17 @@ fn frame_dimensions(frame: &Frame, png: &PngInfo) -> Result<(u32, u32)> {
(png.width, png.height) (png.width, png.height)
}; };
ensure!( ensure!(
width as u64 * frame.logical_height as u64 == height as u64 * frame.logical_width as u64, exact_aspect(width, height, frame.logical_width, frame.logical_height)
"HD frame aspect must match the original canvas" || (!requires_alpha
&& 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 opaque frame may use subpixel integer rounding"
); );
ensure!( ensure!(
width as u64 >= frame.logical_width as u64 * 2 width as u64 >= frame.logical_width as u64 * 2
@@ -810,6 +938,13 @@ fn frame_dimensions(frame: &Frame, png: &PngInfo) -> Result<(u32, u32)> {
); );
Ok((width, height)) 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<()> { fn validate_rgba(rgba: &RgbaImage, requires_alpha: bool) -> Result<()> {
ensure!( ensure!(
!requires_alpha || rgba.pixels().any(|p| p[3] == 0), !requires_alpha || rgba.pixels().any(|p| p[3] == 0),
@@ -829,6 +964,28 @@ fn validate_frame(frame: &Frame, original: &Sprite, source: &[u8]) -> Result<()>
original.width, original.width,
original.height 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!( ensure!(
frame.origin == [0, 0], frame.origin == [0, 0],
"HD format 1 preserves the original top-left anchor [0, 0]" "HD format 1 preserves the original top-left anchor [0, 0]"
@@ -877,6 +1034,8 @@ mod tests {
origin: [0, 0], origin: [0, 0],
source_sha256: format!("{:x}", Sha256::digest(source)), source_sha256: format!("{:x}", Sha256::digest(source)),
crop: None, crop: None,
preserve_reserved_coverage: false,
native_nearest_cues: Vec::new(),
} }
} }
#[test] #[test]
@@ -1534,4 +1693,557 @@ mod tests {
.any(|message| message.contains("limit") || message.contains("dimension")) .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_transparent_sources_partial_crops_or_low_resolution() {
let mut f = aspect_frame(320, 200);
assert!(frame_dimensions(&f, &aspect_info(1586, 992), true).is_err());
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);
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_ne!(
HdAssets::frame_key(&f, &aspect_info(1586, 992), false),
previous_key(&f, &aspect_info(1586, 992), false)
);
}
}
#[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,
preserve_reserved_coverage: false,
native_nearest_cues: 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]),
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,
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
);
}
} }
+76
View File
@@ -0,0 +1,76 @@
import sys
sys.dont_write_bytecode=True
from pathlib import Path
import hashlib,importlib.util,json,tempfile,unittest
from PIL import Image
OUT=Path(__file__).resolve().parent
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/"tools"))
import hd_art_audit as audit
class PythonRoundingMirror(unittest.TestCase):
def setUp(self):
self.temp=tempfile.TemporaryDirectory(prefix='python-fixture-',dir=OUT)
self.addCleanup(self.temp.cleanup)
self.root=Path(self.temp.name)
self.payload=b'synthetic original payload'
self.picture=Image.new('RGBA',(8,5),(80,100,120,255))
self.frame={'source_sha256':hashlib.sha256(self.payload).hexdigest(),'logical_width':8,'logical_height':5,'origin':[0,0],'file':'fixture.png'}
def artwork(self,size,alpha=255):
Image.new('RGBA',size,(80,100,120,alpha)).save(self.root/'fixture.png')
def check(self):
return audit.validate_hd_frame(self.root,self.frame,self.payload,self.picture)
def test_full_opaque_landscape_has_only_integer_aspect_rounding(self):
self.picture=Image.new('RGBA',(320,200),(80,100,120,255))
self.frame.update(logical_width=320,logical_height=200,crop=[0,0,1586,992])
self.artwork((1586,992))
self.assertEqual(self.check(),1280*800*4)
def test_full_image_none_and_explicit_crop_accept_rounding(self):
self.artwork((17,11))
self.assertEqual(self.check(),17*11*4)
self.frame['crop']=[0,0,17,11]
self.assertEqual(self.check(),17*11*4)
def test_portrait_rounding_accepts_and_arbitrary_aspect_rejects(self):
self.picture=Image.new('RGBA',(5,8),(0,0,0,255))
self.frame.update(logical_width=5,logical_height=8)
self.artwork((11,17));self.assertEqual(self.check(),11*17*4)
self.artwork((12,17))
with self.assertRaisesRegex(ValueError,'aspect differs'):self.check()
def test_nonopaque_raw_rejected_before_resampling(self):
for alpha in (0,128,254):
with self.subTest(alpha=alpha):
raw=Image.new('RGBA',(161,101),(0,0,0,255));raw.putpixel((160,100),(0,0,0,alpha))
raw.save(self.root/'fixture.png')
with self.assertRaisesRegex(ValueError,'fully opaque'):self.check()
def test_transparent_original_cannot_use_rounding(self):
self.picture.putpixel((0,0),(0,0,0,0));self.artwork((17,11))
with self.assertRaisesRegex(ValueError,'aspect differs'):self.check()
def test_partial_crop_never_gets_exception(self):
self.artwork((18,12))
for crop in ([0,0,17,11],[1,1,17,11]):
with self.subTest(crop=crop):
self.frame['crop']=crop
with self.assertRaisesRegex(ValueError,'aspect differs'):self.check()
def test_exact_aspect_transparent_source_and_cropped_atlas_are_unchanged(self):
self.picture.putpixel((0,0),(0,0,0,0));self.artwork((20,12))
raw=Image.open(self.root/'fixture.png').convert('RGBA');raw.putpixel((2,2),(0,0,0,0));raw.save(self.root/'fixture.png')
self.frame['crop']=[2,2,16,10]
self.assertEqual(self.check(),16*10*4)
def test_half_tie_wrong_aspect_low_resolution_hash_and_origin_stay_rejected(self):
self.assertFalse(audit.integer_rounded_aspect(5,6,2,2))
self.artwork((15,10))
with self.assertRaisesRegex(ValueError,'twice'):self.check()
self.artwork((17,11));self.frame['source_sha256']='wrong'
with self.assertRaisesRegex(ValueError,'hash'):self.check()
self.frame['source_sha256']=hashlib.sha256(self.payload).hexdigest();self.frame['origin']=[1,0]
with self.assertRaisesRegex(ValueError,'anchor'):self.check()
def test_common_scale_nearest_integer_fixtures(self):
checked=0
for w in range(1,17):
for h in range(1,17):
for numerator in range(128,1025):
self.assertTrue(audit.integer_rounded_aspect((w*numerator+32)//64,(h*numerator+32)//64,w,h))
checked+=1
self.assertEqual(checked,229632)
if __name__=='__main__':unittest.main(verbosity=2)
+66
View File
@@ -0,0 +1,66 @@
import sys
from pathlib import Path
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/"tools"))
import copy, struct, unittest, tempfile,hashlib
from pathlib import Path
from PIL import Image
from hd_frame_policy import validate_frame_policy, _indices
class FramePolicyTests(unittest.TestCase):
def setUp(self):
self.frame={"logical_width":2,"logical_height":2}
self.raw=struct.pack("<iiiii",1,0,0,2,2)+bytes([5,239,0,30])
def test_absent_or_explicit_default_metadata_is_valid(self):
validate_frame_policy(self.frame,self.raw)
self.frame.update(preserve_reserved_coverage=False,native_nearest_cues=[])
validate_frame_policy(self.frame,self.raw)
def test_valid_explicit_flags_and_sorted_ordinary_cells(self):
self.frame.update(preserve_reserved_coverage=True,native_nearest_cues=[[0,0],[1,0]])
validate_frame_policy(self.frame,self.raw)
def test_coverage_type_is_strict(self):
for value in [0,1,"yes",None,[],{}]:
with self.subTest(value=value),self.assertRaises(ValueError):
validate_frame_policy(dict(self.frame,preserve_reserved_coverage=value))
def test_coordinate_structure_bounds_types_order_duplicates_limit(self):
for value in [None,"yes",{},[[0]],[[0,0,0]],[[True,0]],[[0.0,0]],[[-1,0]],[[2,0]],[[0,2]],[[0,0],[0,0]],[[1,0],[0,0]],[[0,0]]*65]:
with self.subTest(value=value),self.assertRaises(ValueError):
validate_frame_policy(dict(self.frame,native_nearest_cues=value),self.raw)
def test_ordinary_cues_never_use_reserved_source_indices(self):
self.frame['native_nearest_cues']=[[0,0]]
for label in [240,244,245,249,250,255]:
with self.subTest(label=label),self.assertRaises(ValueError):
validate_frame_policy(self.frame,self.raw[:20]+bytes([label,5,5,5]))
def test_sparse_source_requires_actual_complete_coverage(self):
header=struct.pack('<iiiii',0,0,0,2,2)
end=struct.pack('<iiii',0,0,-1,0)
sparse=header+struct.pack('<iiii',0,0,0,2)+bytes([5,5])+end
self.frame['native_nearest_cues']=[[0,0]]
with self.assertRaises(ValueError):validate_frame_policy(self.frame,sparse)
complete=header+struct.pack('<iiii',0,0,0,2)+bytes([5,5])+struct.pack('<iiii',0,1,0,2)+bytes([5,5])+end
validate_frame_policy(self.frame,complete)
def test_checked_source_rejects_invalid_or_truncated_original(self):
self.frame['native_nearest_cues']=[[0,0]]
for payload in [b'',self.raw[:22],struct.pack('<iiiii',1,0,0,4096,4096),struct.pack('<iiiii',3,0,0,2,2)]:
with self.subTest(payload=payload),self.assertRaises(ValueError):validate_frame_policy(self.frame,payload)
def test_matched_candidate_tool_checks_flags_and_exact_target_bindings(self):
from hd_candidate_review import proposal_frames
f=dict(self.frame,frame=0,file='a.png',origin=[0,0],source_sha256='00',native_nearest_cues=[[0,0]])
pack={'sequences':[{'name':'a','base_asset':1,'frame_count':1,'frames':[f]}]}
self.assertEqual(set(proposal_frames(pack)),{'00001'})
bad=copy.deepcopy(pack);bad['sequences'][0]['frames'][0]['preserve_reserved_coverage']=1
with self.assertRaises(ValueError):proposal_frames(bad)
bad=copy.deepcopy(pack);bad['targets']=[dict(f,asset='00001',native_nearest_cues=[])]
with self.assertRaises(ValueError):proposal_frames(bad)
def test_matched_art_audit_checks_original_index_and_transparency(self):
from hd_art_audit import validate_hd_frame
with tempfile.TemporaryDirectory() as temp:
root=Path(temp);Image.new('RGBA',(8,8),(10,20,30,255)).save(root/'a.png')
f=dict(self.frame,frame=0,file='a.png',origin=[0,0],source_sha256=hashlib.sha256(self.raw).hexdigest(),native_nearest_cues=[[0,0]])
picture=Image.new('RGBA',(2,2),(10,20,30,255))
self.assertEqual(validate_hd_frame(root,f,self.raw,picture),8*8*4)
reserved=self.raw[:20]+bytes([253,5,5,5]);f['source_sha256']=hashlib.sha256(reserved).hexdigest()
with self.assertRaisesRegex(ValueError,'ordinary'):validate_hd_frame(root,f,reserved,picture)
def test_unknown_frame_policy_is_rejected(self):
with self.assertRaisesRegex(ValueError,'unknown'):validate_frame_policy(dict(self.frame,imaginary_cue_rule=True))
if __name__=='__main__':unittest.main()
+23 -4
View File
@@ -12,6 +12,7 @@ import struct
from pathlib import Path from pathlib import Path
from PIL import Image from PIL import Image
from hd_art_source import decode from hd_art_source import decode
from hd_frame_policy import FRAME_KEYS, validate_frame_policy
CATEGORIES = ["Player banking", "Bosses", "Enemies and ground objects", "Player and enemy projectiles", "Explosions and effects", "Equipment and pickups", "Terrain", "Menus, portraits, HUD and other pictures"] CATEGORIES = ["Player banking", "Bosses", "Enemies and ground objects", "Player and enemy projectiles", "Explosions and effects", "Equipment and pickups", "Terrain", "Menus, portraits, HUD and other pictures"]
@@ -23,8 +24,16 @@ def checked_palette(payload):
raise ValueError("invalid VGA palette") raise ValueError("invalid VGA palette")
return [tuple((min(v, 63) << 2) | (min(v, 63) >> 4) for v in payload[n:n + 3]) for n in range(0, 768, 3)] return [tuple((min(v, 63) << 2) | (min(v, 63) >> 4) for v in payload[n:n + 3]) for n in range(0, 768, 3)]
def integer_rounded_aspect(width, height, logical_width, logical_height):
"""One common scale rounded by less than half a pixel on each axis."""
return min(width, height, logical_width, logical_height) > 0 and (
2 * abs(width * logical_height - height * logical_width)
< logical_width + logical_height
)
def validate_hd_frame(root, frame, payload, picture): def validate_hd_frame(root, frame, payload, picture):
"""Mirror loader checks; artistic approval is deliberately a separate step.""" """Mirror loader checks; artistic approval is deliberately a separate step."""
validate_frame_policy(frame, payload)
if frame["source_sha256"] != hashlib.sha256(payload).hexdigest(): if frame["source_sha256"] != hashlib.sha256(payload).hexdigest():
raise ValueError("original source hash changed") raise ValueError("original source hash changed")
if [frame["logical_width"], frame["logical_height"]] != list(picture.size): if [frame["logical_width"], frame["logical_height"]] != list(picture.size):
@@ -45,17 +54,26 @@ def validate_hd_frame(root, frame, payload, picture):
if max(source.size) > 4096 or source.width * source.height > 4_194_304: if max(source.size) > 4096 or source.width * source.height > 4_194_304:
raise ValueError("PNG dimensions exceed runtime limit") raise ValueError("PNG dimensions exceed runtime limit")
image = source.convert("RGBA") image = source.convert("RGBA")
png_size = image.size
source_alpha_min, source_alpha_max = picture.getchannel("A").getextrema()
if frame.get("crop") is not None: if frame.get("crop") is not None:
x, y, width, height = frame["crop"] x, y, width, height = frame["crop"]
if min(x, y) < 0 or min(width, height) <= 0 or x + width > image.width or y + height > image.height: if min(x, y) < 0 or min(width, height) <= 0 or x + width > image.width or y + height > image.height:
raise ValueError("atlas crop exceeds PNG") raise ValueError("atlas crop exceeds PNG")
image = image.crop((x, y, x + width, y + height)) image = image.crop((x, y, x + width, y + height))
if image.width * picture.height != image.height * picture.width: rounded = image.width * picture.height != image.height * picture.width
raise ValueError("PNG aspect differs from original canvas") if rounded and not (
source_alpha_min == source_alpha_max == 255
and image.size == png_size
and integer_rounded_aspect(image.width, image.height, picture.width, picture.height)
):
raise ValueError("PNG aspect differs from original canvas; only a full opaque frame may use subpixel integer rounding")
if image.width < picture.width * 2 or image.height < picture.height * 2: if image.width < picture.width * 2 or image.height < picture.height * 2:
raise ValueError("artwork needs at least twice the logical resolution") raise ValueError("artwork needs at least twice the logical resolution")
alpha_min, alpha_max = image.getchannel("A").getextrema() alpha_min, alpha_max = image.getchannel("A").getextrema()
if picture.getchannel("A").getextrema()[0] == 0 and alpha_min != 0: if rounded and (alpha_min, alpha_max) != (255, 255):
raise ValueError("HD integer-aspect rounding requires a fully opaque source and PNG")
if source_alpha_min == 0 and alpha_min != 0:
raise ValueError("transparent original needs a transparent HD background") raise ValueError("transparent original needs a transparent HD background")
if alpha_max < 128: if alpha_max < 128:
raise ValueError("artwork has no visible pixels") raise ValueError("artwork has no visible pixels")
@@ -176,8 +194,9 @@ def audit(data, project, runtime_path=None):
if not 1 <= sequence["frame_count"] <= 512 or len(sequence["frames"]) > sequence["frame_count"]: if not 1 <= sequence["frame_count"] <= 512 or len(sequence["frames"]) > sequence["frame_count"]:
raise ValueError("HD pack has invalid frame count") raise ValueError("HD pack has invalid frame count")
for frame in sequence["frames"]: for frame in sequence["frames"]:
if set(frame) - {"frame","file","logical_width","logical_height","origin","source_sha256","crop"}: if set(frame) - FRAME_KEYS:
raise ValueError("unknown HD frame fields") raise ValueError("unknown HD frame fields")
validate_frame_policy(frame)
asset = sequence["base_asset"] + frame["frame"] asset = sequence["base_asset"] + frame["frame"]
if not 0 <= frame["frame"] < sequence["frame_count"] or asset in supplied: if not 0 <= frame["frame"] < sequence["frame_count"] or asset in supplied:
raise ValueError("HD pack has duplicate ID or invalid frame index") raise ValueError("HD pack has duplicate ID or invalid frame index")
+2 -1
View File
@@ -15,7 +15,7 @@ from pathlib import Path
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
FRAME_KEYS = {"frame", "file", "logical_width", "logical_height", "origin", "source_sha256", "crop"} from hd_frame_policy import FRAME_KEYS, validate_frame_policy
def proposal_frames(proposal, expected=None): def proposal_frames(proposal, expected=None):
@@ -30,6 +30,7 @@ def proposal_frames(proposal, expected=None):
for frame in sequence["frames"]: for frame in sequence["frames"]:
if set(frame) - FRAME_KEYS: if set(frame) - FRAME_KEYS:
raise ValueError("unknown manifest frame fields") raise ValueError("unknown manifest frame fields")
validate_frame_policy(frame)
if not 0 <= frame["frame"] < sequence["frame_count"]: if not 0 <= frame["frame"] < sequence["frame_count"]:
raise ValueError("frame outside sequence") raise ValueError("frame outside sequence")
identity = f"{base + frame['frame']:05x}" identity = f"{base + frame['frame']:05x}"
+68
View File
@@ -0,0 +1,68 @@
"""Strict optional HD presentation metadata; source fidelity requires peer review."""
import struct
FRAME_KEYS = {"frame", "file", "logical_width", "logical_height", "origin",
"source_sha256", "crop", "preserve_reserved_coverage", "native_nearest_cues"}
def _indices(payload):
if len(payload) < 20:
raise ValueError("truncated original picture")
kind, _, _, width, height = struct.unpack_from("<iiiii", payload)
if not (1 <= width <= 4096 and 1 <= height <= 4096 and width * height <= 1_048_576):
raise ValueError("invalid original canvas")
if kind == 1:
if len(payload) < 20 + width * height:
raise ValueError("truncated original raw picture")
return width, height, payload[20:20 + width * height]
if kind != 0:
raise ValueError("unsupported original picture type")
out = [256] * (width * height)
off = 20
runs = 0
while True:
if off + 16 > len(payload):
raise ValueError("truncated original picture span")
x, y, dest, length = struct.unpack_from("<iiii", payload, off)
off += 16
if dest == -1:
break
if not (x >= 0 and 0 <= y < height and length > 0 and x + length <= width
and off + length <= len(payload)):
raise ValueError("invalid original picture span")
start = y * width + x
out[start:start + length] = payload[off:off + length]
off += length
runs += 1
if runs > len(out):
raise ValueError("too many original picture spans")
return width, height, out
def validate_frame_policy(frame, source_payload=None):
if set(frame) - FRAME_KEYS:
raise ValueError("unknown HD frame fields")
if type(frame.get("preserve_reserved_coverage", False)) is not bool:
raise ValueError("preserve_reserved_coverage must be a boolean")
cues = frame.get("native_nearest_cues", [])
if not isinstance(cues, list) or len(cues) > 64:
raise ValueError("native_nearest_cues must contain at most64 cells")
width, height = frame["logical_width"], frame["logical_height"]
if type(width) is not int or type(height) is not int or not (0 < width < 2**32 and 0 < height < 2**32):
raise ValueError("logical dimensions must be nonzero u32")
previous = None
for cue in cues:
if not isinstance(cue, list) or len(cue) != 2 or any(type(n) is not int or not 0 <= n < 2**32 for n in cue):
raise ValueError("native nearest cue must be a pair of u32 coordinates")
if cue[0] >= width or cue[1] >= height:
raise ValueError("native nearest cue outside original logical canvas")
key = tuple(cue)
if previous is not None and key <= previous:
raise ValueError("native nearest cues must be sorted and unique")
previous = key
if source_payload is not None and cues:
source_width, source_height, indices = _indices(source_payload)
if (source_width, source_height) != (width, height):
raise ValueError("logical canvas differs from original")
if any(index == 256 for index in indices):
raise ValueError("native nearest cues require an opaque original canvas")
if any(indices[y * width + x] >= 240 for x, y in cues):
raise ValueError("native nearest cue must be an ordinary original source cell")