Support transparent full-canvas HD rounding and rebuild runtimes
Accept strict common-scale integer rounding for complete transparent PNG canvases while retaining source-aware alpha checks and existing opaque/exact cache identities. Add separate cache identity for newly eligible rounded transparent frames and matching Rust/Python regression coverage. Bundle rebuilt Linux and GNU Windows executables against the unchanged reviewed4978 asset pack. Validation: 31 Rust HD tests, 7 Python audit tests, and 341 portable tests passed; zero failures. Original-data integration validated all4978 remasters at scales1–4 with bounded caches. Three fresh software SDL1920x1080 menu/mission captures are pixel-identical to published controls. Windows PE/dependency and complete shared data hashes verified; fresh Wine asset and45-mod validation passed. Artwork geometry remains independently reviewed; no held candidate is admitted by the format change.
This commit is contained in:
1 parent
e6a3d552f5
commit
5956d17785
8 files changed
+404
-25
No files matched your search
Binary file not shown.
Binary file not shown.
@@ -6,7 +6,7 @@ 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.
|
||||
|
||||
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.
|
||||
A complete PNG for either an opaque or transparent original may use dimensions produced by rounding one common scale by less than half a pixel on each axis. The strict rule is `2 * abs(width * logical_height - height * logical_width) < logical_width + logical_height`; logical dimensions still equal the original, and both axes must supply at least 2× resolution. For example, 1,586×992 fits a 320×200 background, and 1,402×1,122 fits a transparent 40×32 sprite. Selection must cover the whole PNG, with no crop or an explicit full-canvas crop; inexact partial crops, conflicting half-way ties and arbitrary aspect changes remain rejected. Rounded opaque originals still require every raw PNG alpha value to be 255 before downsampling. Transparent originals retain zero-alpha negative space and at least one visible pixel with alpha ≥128, checked on both raw and resident RGBA and again on disk-cache reads. Exact-frame and rounded-opaque cache keys stay unchanged; newly eligible transparent rounded frames use a separate namespace. This dimension rule adds no per-asset policy and does not approve source contours or reconstruct missing artwork.
|
||||
|
||||
## Optional frame presentation policies
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"platform": "Linux x86_64",
|
||||
"catalog_entries": 6449,
|
||||
"approved_hd_frames": 4978,
|
||||
"binary_sha256": "9688cdbd57b0d31049bac431a5bb2e864bb3ce7abbaca732634359ad7a53d8a9",
|
||||
"binary_sha256": "43aef5c389866edd5ccad381d27952f26b329a88e537c8e26a88988d6fdd4fcd",
|
||||
"data_files": 8986,
|
||||
"data_bytes": 7104297543,
|
||||
"files": {
|
||||
@@ -8995,11 +8995,11 @@
|
||||
"data/ui/WINGAME_SWD": "304630a1223419d69723902ffd22c2c4eea0388491e37dfca663fcbe78e99aff"
|
||||
},
|
||||
"system_dependencies": [
|
||||
"\tlinux-vdso.so.1 (0x00007f68a0e56000)",
|
||||
"\tlibSDL2-2.0.so.0 => /home/quintine/.local/lib/libSDL2-2.0.so.0 (0x00007f68a0000000)",
|
||||
"\tlibgcc_s.so.1 => /usr/lib/libgcc_s.so.1 (0x00007f68a0e02000)",
|
||||
"\tlibm.so.6 => /usr/lib/libm.so.6 (0x00007f68a02c9000)",
|
||||
"\tlibc.so.6 => /usr/lib/libc.so.6 (0x00007f689fc00000)",
|
||||
"\t/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007f68a0e58000)"
|
||||
"\tlinux-vdso.so.1 (0x00007f9063d21000)",
|
||||
"\tlibSDL2-2.0.so.0 => /home/quintine/Projects/RaptorReplica/RustRaptor/lib/libSDL2-2.0.so.0 (0x00007f9063000000)",
|
||||
"\tlibgcc_s.so.1 => /usr/lib/libgcc_s.so.1 (0x00007f90633d3000)",
|
||||
"\tlibm.so.6 => /usr/lib/libm.so.6 (0x00007f906329c000)",
|
||||
"\tlibc.so.6 => /usr/lib/libc.so.6 (0x00007f9062c00000)",
|
||||
"\t/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007f9063d23000)"
|
||||
]
|
||||
}
|
||||
@@ -37,7 +37,7 @@
|
||||
]
|
||||
},
|
||||
"files": {
|
||||
"bin/raptor-rust.exe": "ad2ee7f5cc8e90ea371eccf2a509cd99263c6c880a6d42e2886cdc017c24561b",
|
||||
"bin/raptor-rust.exe": "546f18a1e0f5df3d601058ffbd63e74190505878fa677a75c310ef1ff5c9dd51",
|
||||
"bin/SDL2.dll": "53e8fa7e9ed43c30bd650c39396f0d9d2067d2fff0b926c9433b75202592e4aa",
|
||||
"bin/SDL2-WINDOWS-LICENSE.txt": "97f35b302b361680ec1e891e95d2d52097bb95abff361434916d99dc1305f127",
|
||||
"run.cmd": "b007382d9ed3560191856514a9ebd4e429801fc25428840e304bb297f3553269"
|
||||
|
||||
@@ -386,9 +386,13 @@ impl HdAssets {
|
||||
.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");
|
||||
// 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());
|
||||
@@ -429,8 +433,8 @@ impl HdAssets {
|
||||
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 =
|
||||
!exact_aspect(width, height, frame.logical_width, frame.logical_height);
|
||||
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()));
|
||||
@@ -905,7 +909,8 @@ fn integer_rounded_aspect(
|
||||
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)> {
|
||||
// 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)> {
|
||||
let (width, height) = if let Some([x, y, width, height]) = frame.crop {
|
||||
ensure!(
|
||||
width > 0
|
||||
@@ -920,8 +925,7 @@ fn frame_dimensions(frame: &Frame, png: &PngInfo, requires_alpha: bool) -> Resul
|
||||
};
|
||||
ensure!(
|
||||
exact_aspect(width, height, frame.logical_width, frame.logical_height)
|
||||
|| (!requires_alpha
|
||||
&& width == png.width
|
||||
|| (width == png.width
|
||||
&& height == png.height
|
||||
&& integer_rounded_aspect(
|
||||
width,
|
||||
@@ -929,7 +933,7 @@ fn frame_dimensions(frame: &Frame, png: &PngInfo, requires_alpha: bool) -> Resul
|
||||
frame.logical_width,
|
||||
frame.logical_height,
|
||||
)),
|
||||
"HD frame aspect must match the original canvas; only a full opaque frame may use subpixel integer rounding"
|
||||
"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
|
||||
@@ -1746,9 +1750,9 @@ mod tests {
|
||||
assert!(!integer_rounded_aspect(u32::MAX, 1, 1, u32::MAX));
|
||||
}
|
||||
#[test]
|
||||
fn rounded_exception_never_admits_transparent_sources_partial_crops_or_low_resolution() {
|
||||
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_err());
|
||||
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]);
|
||||
@@ -1882,6 +1886,13 @@ mod tests {
|
||||
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());
|
||||
@@ -1912,10 +1923,250 @@ mod tests {
|
||||
previous_key(&f, &aspect_info(1600, 1000), false)
|
||||
);
|
||||
f.crop = Some([0, 0, 1586, 992]);
|
||||
assert_ne!(
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,15 +63,14 @@ def validate_hd_frame(root, frame, payload, picture):
|
||||
image = image.crop((x, y, x + width, y + height))
|
||||
rounded = image.width * picture.height != image.height * picture.width
|
||||
if rounded and not (
|
||||
source_alpha_min == source_alpha_max == 255
|
||||
and image.size == png_size
|
||||
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")
|
||||
raise ValueError("PNG aspect differs from original canvas; only a full frame may use subpixel integer rounding")
|
||||
if image.width < picture.width * 2 or image.height < picture.height * 2:
|
||||
raise ValueError("artwork needs at least twice the logical resolution")
|
||||
alpha_min, alpha_max = image.getchannel("A").getextrema()
|
||||
if rounded and (alpha_min, alpha_max) != (255, 255):
|
||||
if rounded and source_alpha_min != 0 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")
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Full-canvas integer rounding eligibility; this is not artwork approval."""
|
||||
import hashlib
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
from hd_art_audit import integer_rounded_aspect, validate_hd_frame
|
||||
from hd_art_source import decode
|
||||
|
||||
|
||||
class FullCanvasRoundingAuditTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
|
||||
def case(self, logical=(8, 5), natural=(101, 63), transparent=True):
|
||||
width, height = logical
|
||||
payload = bytearray(struct.pack("<iiiii", 0 if transparent else 1, 0, 0, width, height))
|
||||
if transparent:
|
||||
# Real legacy span payload with both occupied and empty source cells.
|
||||
start = 1 if width > 2 else 0
|
||||
length = max(1, width - 2)
|
||||
rows = range(1, height - 1) if height > 2 else range(1)
|
||||
for y in rows:
|
||||
payload.extend(struct.pack("<iiii", start, y, y * width + start, length))
|
||||
payload.extend(bytes([7] * length))
|
||||
payload.extend(struct.pack("<iiii", 0, 0, -1, 0))
|
||||
else:
|
||||
payload.extend(bytes([7] * (width * height)))
|
||||
payload = bytes(payload)
|
||||
source = self.root / "source.bin"
|
||||
source.write_bytes(payload)
|
||||
picture = decode(source, [(80, 100, 120)] * 256)
|
||||
raw = Image.new("RGBA", natural, (80, 100, 120, 0 if transparent else 255))
|
||||
if transparent:
|
||||
x0, y0 = natural[0] // 4, natural[1] // 4
|
||||
raw.paste((80, 100, 120, 255), (x0, y0, natural[0] - x0, natural[1] - y0))
|
||||
raw.save(self.root / "ship.png")
|
||||
frame = {"frame": 0, "file": "ship.png", "logical_width": width,
|
||||
"logical_height": height, "origin": [0, 0],
|
||||
"source_sha256": hashlib.sha256(payload).hexdigest()}
|
||||
return frame, payload, picture
|
||||
|
||||
def validate(self, frame, payload, picture):
|
||||
return validate_hd_frame(self.root, frame, payload, picture)
|
||||
|
||||
def test_full_canvas_and_explicit_full_crop_accept_both_original_alpha_classes(self):
|
||||
for logical, natural in [((8, 5), (101, 63)), ((5, 8), (63, 101)),
|
||||
((40, 32), (1402, 1122))]:
|
||||
for transparent in [False, True]:
|
||||
with self.subTest(logical=logical, natural=natural, transparent=transparent):
|
||||
frame, payload, picture = self.case(logical, natural, transparent)
|
||||
expected = min(natural[0], logical[0] * 4) * min(natural[1], logical[1] * 4) * 4
|
||||
self.assertEqual(self.validate(frame, payload, picture), expected)
|
||||
frame["crop"] = [0, 0, *natural]
|
||||
self.assertEqual(self.validate(frame, payload, picture), expected)
|
||||
|
||||
def test_inexact_partial_crop_rejected_but_exact_partial_crop_unchanged(self):
|
||||
for transparent in [False, True]:
|
||||
frame, payload, picture = self.case(transparent=transparent)
|
||||
for crop in [[0, 0, 100, 63], [1, 0, 100, 63], [0, 0, 101, 62]]:
|
||||
with self.subTest(transparent=transparent, crop=crop):
|
||||
changed = {**frame, "crop": crop}
|
||||
with self.assertRaisesRegex(ValueError, "aspect differs"):
|
||||
self.validate(changed, payload, picture)
|
||||
frame["crop"] = [1, 1, 80, 50]
|
||||
self.assertEqual(self.validate(frame, payload, picture), 32 * 20 * 4)
|
||||
|
||||
def test_strict_ties_aspect_and_minimum_resolution_are_not_relaxed(self):
|
||||
self.assertFalse(integer_rounded_aspect(5, 6, 2, 2))
|
||||
self.assertFalse(integer_rounded_aspect(0, 6, 2, 2))
|
||||
self.assertFalse(integer_rounded_aspect(5, 6, 0, 2))
|
||||
for logical, natural, message in [((2, 2), (5, 6), "aspect differs"),
|
||||
((8, 5), (101, 64), "aspect differs"),
|
||||
((8, 5), (15, 10), "twice")]:
|
||||
with self.subTest(logical=logical, natural=natural):
|
||||
frame, payload, picture = self.case(logical, natural)
|
||||
with self.assertRaisesRegex(ValueError, message):
|
||||
self.validate(frame, payload, picture)
|
||||
|
||||
def test_transparent_original_keeps_background_and_visible_alpha_checks(self):
|
||||
frame, payload, picture = self.case()
|
||||
for alpha, message in [(255, "transparent HD background"),
|
||||
(0, "no visible pixels"), (127, "no visible pixels")]:
|
||||
with self.subTest(alpha=alpha):
|
||||
raw = Image.new("RGBA", (101, 63), (80, 100, 120, alpha))
|
||||
if alpha == 127:
|
||||
raw.putpixel((0, 0), (0, 0, 0, 0))
|
||||
raw.save(self.root / "ship.png")
|
||||
with self.assertRaisesRegex(ValueError, message):
|
||||
self.validate(frame, payload, picture)
|
||||
|
||||
def test_rounded_opaque_original_still_requires_every_raw_alpha_to_be_255(self):
|
||||
frame, payload, picture = self.case(transparent=False)
|
||||
for alpha in [0, 128, 254]:
|
||||
with self.subTest(alpha=alpha):
|
||||
raw = Image.new("RGBA", (101, 63), (80, 100, 120, 255))
|
||||
raw.putpixel((100, 62), (80, 100, 120, alpha))
|
||||
raw.save(self.root / "ship.png")
|
||||
with self.assertRaisesRegex(ValueError, "fully opaque"):
|
||||
self.validate(frame, payload, picture)
|
||||
|
||||
def test_source_canvas_anchor_hash_path_and_crop_contracts_are_retained(self):
|
||||
frame, payload, picture = self.case()
|
||||
for fields, message in [({"logical_width": 9}, "logical canvas"),
|
||||
({"origin": [1, 0]}, "top-left anchor"),
|
||||
({"source_sha256": "changed"}, "source hash"),
|
||||
({"file": "../escape.png"}, "stay relative"),
|
||||
({"crop": [1, 0, 101, 63]}, "crop exceeds"),
|
||||
({"crop": [0, 0, 0, 63]}, "crop exceeds")]:
|
||||
with self.subTest(fields=fields):
|
||||
with self.assertRaisesRegex(ValueError, message):
|
||||
self.validate({**frame, **fields}, payload, picture)
|
||||
|
||||
def test_malformed_or_non_png_art_stays_rejected(self):
|
||||
frame, payload, picture = self.case()
|
||||
(self.root / "ship.png").write_bytes(b"not an image")
|
||||
with self.assertRaises(OSError):
|
||||
self.validate(frame, payload, picture)
|
||||
Image.new("RGB", (101, 63), (80, 100, 120)).save(self.root / "ship.png", format="JPEG")
|
||||
with self.assertRaisesRegex(ValueError, "must be PNG"):
|
||||
self.validate(frame, payload, picture)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user