diff --git a/bin/raptor-rust b/bin/raptor-rust index f02ada5f..d8538ed6 100755 Binary files a/bin/raptor-rust and b/bin/raptor-rust differ diff --git a/bin/raptor-rust.exe b/bin/raptor-rust.exe index face6b14..5b907b72 100755 Binary files a/bin/raptor-rust.exe and b/bin/raptor-rust.exe differ diff --git a/docs/hd-streaming.md b/docs/hd-streaming.md index 917974f9..00199bfb 100644 --- a/docs/hd-streaming.md +++ b/docs/hd-streaming.md @@ -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 diff --git a/runtime-manifest.json b/runtime-manifest.json index 3a28c697..7768c4ac 100644 --- a/runtime-manifest.json +++ b/runtime-manifest.json @@ -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)" ] } diff --git a/runtime-windows-manifest.json b/runtime-windows-manifest.json index b1263d60..1e078833 100644 --- a/runtime-windows-manifest.json +++ b/runtime-windows-manifest.json @@ -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" diff --git a/src/hd.rs b/src/hd.rs index 3bb53a7c..09f0319d 100644 --- a/src/hd.rs +++ b/src/hd.rs @@ -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::>(); + 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::>(); + 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()); } } diff --git a/tools/hd_art_audit.py b/tools/hd_art_audit.py index cd75f871..2e6a0ab7 100644 --- a/tools/hd_art_audit.py +++ b/tools/hd_art_audit.py @@ -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") diff --git a/tools/test_hd_art_audit.py b/tools/test_hd_art_audit.py new file mode 100644 index 00000000..6b131d08 --- /dev/null +++ b/tools/test_hd_art_audit.py @@ -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(" 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("