From 0b938c04ea3a0e294da3540567b64a1107ef1dd2 Mon Sep 17 00:00:00 2001 From: raptorreplica Date: Sun, 11 Oct 2026 08:32:51 +1100 Subject: [PATCH] Preserve palette semantics for mod PNGs and HD opening cards --- RustRaptor/docs/hd-opening-cards.md | 37 +++++ RustRaptor/docs/hd-streaming.md | 4 +- RustRaptor/docs/modding-api.md | 2 +- RustRaptor/src/assets.rs | 48 +++++- RustRaptor/src/cinematics.rs | 37 +++++ RustRaptor/src/desktop.rs | 239 +++++++++++++++++++++++++++- RustRaptor/src/hd.rs | 111 ++++++++++++- RustRaptor/tests/hd_terrain.rs | 77 +++++++-- 8 files changed, 528 insertions(+), 27 deletions(-) create mode 100644 RustRaptor/docs/hd-opening-cards.md diff --git a/RustRaptor/docs/hd-opening-cards.md b/RustRaptor/docs/hd-opening-cards.md new file mode 100644 index 00000000..5a6141f7 --- /dev/null +++ b/RustRaptor/docs/hd-opening-cards.md @@ -0,0 +1,37 @@ +# Opening-credit HD rendering + +The existing HD setting can display supplied replacements for the standalone +opening-credit pictures. Each keeps its original 320×200 canvas and top-left +origin, with original-picture fallback when a replacement is absent or rejected. + +| Picture | Original ID | Unfaded source palette | +| --- | --- | --- | +| `APOGEE_PIC` | `1002b` | `POGPAL_DAT` | +| `CYGNUS_PIC` | `1002d` | `CYGPAL_DAT` | + +`Cinematic::still_picture` exposes the current card and its unfaded palette. +`HdAssets::sprite_static_palette` quantizes supplied artwork using all 256 colors +of that palette. Gameplay water/fire/flash index classes do not apply to these +static palettes. The indexed cache distinguishes this policy from ordinary +gameplay sprites while sharing source validation, RGBA residency and derived +disk assets. Existing cinematic presentation applies the fade afterward, so +black or partially faded colors never become quantization inputs. + +Opening timing, music and PCM patch generation are unchanged. AGX delta films +and bitmap fonts retain their classic artwork and playback. This rendering +support adds no artwork coverage; actual opening-card remasters still require +source-palette, composition and artistic review before registration. + +Synthetic tests cover both cards at 1× and 4×, colors above index 239, complete +fade phases, missing/rejected replacement fallback, separate cache policies, +and unchanged world/pilot/RNG plus cinematic audio/music event streams. The +complete synthetic opening remains 569 ticks. Local original-data checks also +pass: the opening retains 966 ticks and the AGX intro retains 925 ticks, with +their original cue times and palettes. The focused run passed 28 portable checks +and both original-data cinematic checks on 2026-10-10. + +Run the desktop route fixtures from `RustRaptor/` with +`cargo test --locked --lib opening_card`. The cinematic and palette/cache suites +are `cargo test --locked --lib cinematics::tests::` and +`cargo test --locked --lib hd::tests::`. Original-data checks require +`RAPTOR_TEST_DATA=../data cargo test --locked --lib cinematics::tests:: -- --ignored`. diff --git a/RustRaptor/docs/hd-streaming.md b/RustRaptor/docs/hd-streaming.md index 6b4a81a8..e0a54f4c 100644 --- a/RustRaptor/docs/hd-streaming.md +++ b/RustRaptor/docs/hd-streaming.md @@ -16,12 +16,14 @@ Three least-recently-used caches each have a 32 MiB ceiling: - Decoded atlas images, keyed by the exact source PNG SHA-256. - Cropped, resampled RGBA frames, keyed by PNG identity, original payload SHA-256, crop, canvas, anchor, alpha requirement and kernel version. -- Indexed sprites, keyed by the derived frame identity, presentation scale and complete palette identity. +- Indexed sprites, keyed by the derived frame identity, presentation scale, complete palette identity and palette policy. Resident cached pixels total at most 96 MiB, irrespective of catalog coverage. Decoder input, resampling work buffers and returned owned sprites are additional bounded working allocations; callers should retain only currently needed sprites. Eviction removes cached pixels, not frame registrations or validated coverage. Adjacent crops reuse the decoded atlas; an atlas is not cloned in full merely to extract a small crop. Repeated draws copy only the final indexed canvas. Palette changes cannot reuse stale indexed colors. Source palette labels have a separate hard 32 MiB metadata ceiling. Frames using only ordinary colors need no label map. Frames containing animated labels retain one byte per original logical pixel; transparent and ordinary source locations use the ordinary class. Quantization restricts ordinary HD pixels to indices 0–239, water to 240–244 and fire to 245–249. Original glow/flash labels 250–255 remain exact. Newly visible HD pixels outside the original silhouette use ordinary colors. This prevents a bright static highlight from acquiring fire, water or blinking behavior. Cache color keys include the source class; equal RGB values in different classes cannot share an incorrect palette result. +Standalone opening cards use `sprite_static_palette` with all 256 entries of their own unfaded palette. Its indexed cache policy is distinct from gameplay source classes; both share the same checked PNG and RGBA caches. Cinematic fades apply during presentation. See [hd-opening-cards.md](hd-opening-cards.md) for the route and fallback checks. + ## Derived disk cache The optional cache belongs under the separate Rust profile cache, for example `RaptorReplica/cache/hd-art-v1`. It stores original-independent derived RGBA files; originals and raw generations are never overwritten. Each filename binds the source PNG hash, exact original payload hash, crop, logical dimensions, anchor, alpha policy and resampling kernel. Palette-dependent indexed sprites remain in memory only. diff --git a/RustRaptor/docs/modding-api.md b/RustRaptor/docs/modding-api.md index cff46311..8b1dd244 100644 --- a/RustRaptor/docs/modding-api.md +++ b/RustRaptor/docs/modding-api.md @@ -122,7 +122,7 @@ An `art` object requires exactly one of `asset` (an original numeric catalog ID) } ``` -PNG animations are horizontal strips of equally sized frames. For example, four 64×64 frames use a 256×64 PNG and may declare logical dimensions of 16×16. The strip width must divide evenly by `frames`; supply consistent canvases and alignment across frames. Alpha below 128 is transparent. Opaque colors are mapped to the selected game palette so palette effects remain compatible. Artwork already detailed enough for the selected rendering scale bypasses automatic sprite enlargement. Original asset animations use consecutive catalog IDs. Terrain artwork and destroyed terrain replacements follow the same format. +PNG animations are horizontal strips of equally sized frames. For example, four 64×64 frames use a 256×64 PNG and may declare logical dimensions of 16×16. The strip width must divide evenly by `frames`; supply consistent canvases and alignment across frames. Alpha below 128 is transparent. Opaque colors are mapped to ordinary entries 0–239 of the selected game palette. Plain PNGs carry no palette-animation labels, so their highlights cannot acquire reserved water, fire or blinking indices 240–255. Artwork already detailed enough for the selected rendering scale bypasses automatic sprite enlargement. Original asset animations use consecutive catalog IDs and retain their native palette labels. Terrain artwork and destroyed terrain replacements follow the same format. Artwork dimensions and decoding allocation are bounded. PNGs may be up to 4096 pixels in either direction, with at most 4,194,304 pixels and a 64 MiB decoding allocation limit. Logical dimensions are separately validated and stay independent of physical PNG resolution. The extracted original catalog and local base-game remaster proofs stay outside version control; authored mod artwork and sounds are included in Git. diff --git a/RustRaptor/src/assets.rs b/RustRaptor/src/assets.rs index aad17f04..0578eeb8 100644 --- a/RustRaptor/src/assets.rs +++ b/RustRaptor/src/assets.rs @@ -238,15 +238,16 @@ impl Sprite { "PNG exceeds 4096px" ); let mut out = Self::blank(image.width() as usize, image.height() as usize); - // Palette art repeats colors extensively. Cache by RGB (alpha is handled - // separately) so terrain atlases do not search 256 entries per pixel. + // Plain mod PNGs carry no palette-animation metadata. Keep their colors + // out of the reserved water/fire/blink entries (240..=255), which change + // during gameplay. Cache repeated RGB values; alpha is handled separately. let mut colors = HashMap::<[u8; 3], u16>::new(); for (dst, p) in out.pixels.iter_mut().zip(image.pixels()) { if p[3] < 128 { continue; } *dst = *colors.entry([p[0], p[1], p[2]]).or_insert_with(|| { - palette + palette[..240] .iter() .enumerate() .min_by_key(|(_, c)| { @@ -425,4 +426,45 @@ mod tests { b.extend([3, 4]); assert!(Sprite::decode(&b).is_err()); } + #[test] + fn ordinary_mod_png_does_not_acquire_animated_palette_indices() { + let mut palette = [[0; 3]; 256]; + palette[238] = [3, 5, 7]; + palette[239] = [250, 235, 250]; + for (offset, color) in palette[240..].iter_mut().enumerate() { + *color = [255, 240 + offset as u8, 255]; + } + let mut input = image::RgbaImage::new(19, 1); + for offset in 0..16 { + let color = palette[240 + offset]; + input.put_pixel( + offset as u32, + 0, + image::Rgba([color[0], color[1], color[2], 255]), + ); + } + input.put_pixel(16, 0, image::Rgba([3, 5, 7, 255])); + input.put_pixel(17, 0, image::Rgba([255, 255, 255, 127])); + input.put_pixel(18, 0, image::Rgba([255, 240, 255, 128])); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("ordinary-mod.png"); + input.save(&path).unwrap(); + + let sprite = Sprite::png(&path, &palette).unwrap(); + assert_eq!(&sprite.pixels[..16], &[239; 16]); + assert_eq!(sprite.pixels[16], 238); + assert_eq!(sprite.pixels[17], TRANSPARENT); + assert_eq!(sprite.pixels[18], 239); + let visible_colors = |active: &[[u8; 3]; 256]| { + sprite + .pixels + .iter() + .filter(|&&index| index != TRANSPARENT) + .map(|&index| active[index as usize]) + .collect::>() + }; + let original_colors = visible_colors(&palette); + palette[240..].fill([20, 40, 60]); + assert_eq!(visible_colors(&palette), original_colors); + } } diff --git a/RustRaptor/src/cinematics.rs b/RustRaptor/src/cinematics.rs index 04e4ddd9..07e56478 100644 --- a/RustRaptor/src/cinematics.rs +++ b/RustRaptor/src/cinematics.rs @@ -414,6 +414,12 @@ impl Cinematic { pub fn finished(&self) -> bool { matches!(self.phase, Phase::Finished) } + /// Active standalone picture and its source palette, before any fade. + /// AGX delta films retain their original indexed frame composition. + pub fn still_picture(&self) -> Option<(&'static str, &[[u8; 3]; 256])> { + let clip = self.clips.get(self.current)?; + Some((clip.spec.name, clip.palette.as_ref()?)) + } pub fn palette(&self, base: &[[u8; 3]; 256]) -> [[u8; 3]; 256] { let base = self .clips @@ -670,6 +676,37 @@ mod tests { assert_eq!(movie.palette(&base), base); } #[test] + fn still_picture_exposes_each_unfaded_card_palette_and_excludes_agx_frames() { + let mut movie = synthetic_player(specs(Kind::OpeningCredits)); + movie.clips[1].palette = Some([[200, 60, 40]; 256]); + let base = [[240, 20, 16]; 256]; + let (name, palette) = movie.still_picture().unwrap(); + assert_eq!(name, "APOGEE_PIC"); + assert_eq!(*palette, [[80, 120, 200]; 256]); + assert_eq!(movie.palette(&base), [[0; 3]; 256]); + for _ in 0..32 { + tick(&mut movie); + } + assert_eq!(movie.palette(&base), [[40, 60, 100]; 256]); + assert_eq!(*movie.still_picture().unwrap().1, [[80, 120, 200]; 256]); + while movie.ticks < 247 { + tick(&mut movie); + } + let (name, palette) = movie.still_picture().unwrap(); + assert_eq!(name, "CYGNUS_PIC"); + assert_eq!(*palette, [[200, 60, 40]; 256]); + assert_eq!(movie.palette(&base), [[0; 3]; 256]); + while !movie.finished() { + tick(&mut movie); + } + assert!(movie.still_picture().is_none()); + let mut intro = synthetic_player(specs(Kind::Intro)); + while !intro.finished() { + assert!(intro.still_picture().is_none()); + tick(&mut intro); + } + } + #[test] #[ignore = "requires original local game assets"] fn original_opening_palettes_and_complete_intro_timeline_are_preserved() { let root = std::env::var("RAPTOR_TEST_DATA") diff --git a/RustRaptor/src/desktop.rs b/RustRaptor/src/desktop.rs index f269aa44..81ba099b 100644 --- a/RustRaptor/src/desktop.rs +++ b/RustRaptor/src/desktop.rs @@ -3189,7 +3189,21 @@ impl Host { crate::assets::TRANSPARENT }); if let Some(movie) = &self.cinematic { - canvas.blit(&nearest(&movie.image, canvas.scale), 0, 0); + let replacement = if self.config.hd_mode { + if let Some((name, palette)) = movie.still_picture() { + self.hd + .sprite_static_palette(self.catalog.id(name)?, canvas.scale, palette)? + } else { + None + } + } else { + None + }; + canvas.blit( + &replacement.unwrap_or_else(|| nearest(&movie.image, canvas.scale)), + 0, + 0, + ); return Ok(canvas); } if self.world.is_some() { @@ -4945,6 +4959,229 @@ mod tests { assert_eq!(host.hd.validated_count(), 1); assert!(host.hd.diagnostics.is_empty()); } + fn opening_card_fixture() -> (tempfile::TempDir, Host) { + use sha2::{Digest, Sha256}; + let (dir, mut host) = ui_fixture(); + let root = host.catalog.root.clone(); + let hd_root = root.join("hd"); + std::fs::create_dir(&hd_root).unwrap(); + let mut index = std::fs::read_to_string(root.join("INDEX")).unwrap(); + let mut sequences = Vec::new(); + for (asset, name, palette_name, source_index, colors) in [ + ( + 0x1002b, + "APOGEE_PIC", + "POGPAL_DAT", + 5, + [[63, 0, 0], [0, 63, 0]], + ), + ( + 0x1002d, + "CYGNUS_PIC", + "CYGPAL_DAT", + 7, + [[0, 0, 63], [63, 0, 0]], + ), + ] { + let source = [1_i32, 0, 0, 320, 200] + .into_iter() + .flat_map(i32::to_le_bytes) + .chain(vec![source_index; 320 * 200]) + .collect::>(); + std::fs::write(root.join(name), &source).unwrap(); + index.push_str(&format!("{asset:x} {} {name}\n", source.len())); + let mut palette = [0; 768]; + palette[source_index as usize * 3..source_index as usize * 3 + 3] + .copy_from_slice(&[0, 63, 63]); + for (entry, color) in [(42, colors[0]), (250, colors[1])] { + palette[entry * 3..entry * 3 + 3].copy_from_slice(&color); + } + std::fs::write(root.join(palette_name), palette).unwrap(); + index.push_str(&format!("{:x} 768 {palette_name}\n", asset + 1)); + let file = format!("{name}.png"); + image::RgbaImage::from_fn(640, 400, |x, _| { + let color = colors[usize::from(x >= 320)].map(|v| v * 4 + v / 16); + image::Rgba([color[0], color[1], color[2], 255]) + }) + .save(hd_root.join(&file)) + .unwrap(); + sequences.push(serde_json::json!({ + "name":name,"base_asset":asset,"frame_count":1,"frames":[{ + "frame":0,"file":file,"logical_width":320,"logical_height":200, + "origin":[0,0],"source_sha256":format!("{:x}",Sha256::digest(&source)) + }] + })); + } + let mut score = b"MUS\x1a".to_vec(); + score.extend(1_u16.to_le_bytes()); + score.extend(16_u16.to_le_bytes()); + score.extend([0; 8]); + score.push(0x60); + std::fs::write(root.join("APOGEE_MUS"), &score).unwrap(); + index.push_str(&format!("9999 {} APOGEE_MUS\n", score.len())); + std::fs::write(root.join("INDEX"), index).unwrap(); + std::fs::write( + hd_root.join("hd.json"), + serde_json::to_vec(&serde_json::json!({ + "format_version":1,"name":"Opening card fixture","sequences":sequences + })) + .unwrap(), + ) + .unwrap(); + host.catalog = Catalog::open(&root).unwrap(); + // Wrong-for-cards gameplay palette makes accidental global quantization + // visibly different from either source-specific static palette. + host.palette[8] = [255, 0, 0]; + host.palette[9] = [0, 255, 0]; + host.palette[10] = [0, 0, 255]; + host.world = + Some(World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap()); + host.show_movie(MovieKind::OpeningCredits, Screen::Main) + .unwrap(); + (dir, host) + } + fn cinematic_audio_snapshot(movie: &Cinematic) -> Vec { + movie + .audio_events + .iter() + .map(|event| match event { + CinematicAudioEvent::Play(sound) => serde_json::json!({"play":sound}), + CinematicAudioEvent::Stop(asset) => serde_json::json!({"stop":asset}), + }) + .collect() + } + #[test] + fn supplied_hd_opening_cards_use_unfaded_source_palettes_without_advancing_movies_or_world() { + let (_dir, mut host) = opening_card_fixture(); + assert!(!host.config.hd_mode); + host.config.scanlines = false; + let mut reference = Cinematic::load(&host.catalog, MovieKind::OpeningCredits).unwrap(); + let pilot_before = serde_json::to_value(&host.pilot).unwrap(); + let world_before = host.world.as_ref().unwrap().digest(); + let rng_before = host.world.as_ref().unwrap().rng; + let mut drawn_cards = std::collections::BTreeSet::new(); + let sample_ticks = [0, 32, 64, 184, 215, 247, 279, 311, 506, 537, 568]; + for tick in 0..=569 { + let movie = host.cinematic.as_ref().unwrap(); + assert_eq!( + movie.finished(), + reference.finished(), + "timing at tick {tick}" + ); + assert_eq!(movie.image, reference.image); + assert_eq!( + movie.palette(&host.palette), + reference.palette(&host.palette) + ); + assert_eq!(movie.music_events, reference.music_events); + assert_eq!( + cinematic_audio_snapshot(movie), + cinematic_audio_snapshot(&reference) + ); + if sample_ticks.contains(&tick) { + let (name, _) = reference.still_picture().unwrap(); + drawn_cards.insert(name); + let original = if name == "APOGEE_PIC" { 5 } else { 7 }; + for scale in [1, 4] { + host.config.scale = scale; + for hd in [false, true] { + host.config.hd_mode = hd; + let canvas = host.draw().unwrap(); + let rgba = host.present_rgba(&canvas); + assert_eq!( + (canvas.image.width, canvas.image.height), + (320 * scale, 200 * scale) + ); + assert!(!canvas.image.pixels.contains(&crate::assets::TRANSPARENT)); + for (x, y) in [(0, 0), (80, 100), (240, 100), (319, 199)] { + let pixel = y * scale * canvas.image.width + x * scale; + let expected = if hd { + if x < 160 { 42 } else { 250 } + } else { + original + }; + assert_eq!( + canvas.image.pixels[pixel], expected, + "{name}, tick={tick}, scale={scale}, hd={hd}" + ); + let color = reference.palette(&host.palette)[expected as usize]; + assert_eq!( + &rgba[pixel * 4..pixel * 4 + 4], + &[color[0], color[1], color[2], 255] + ); + } + assert_eq!(host.world.as_ref().unwrap().digest(), world_before); + assert_eq!(host.world.as_ref().unwrap().rng, rng_before); + assert_eq!(serde_json::to_value(&host.pilot).unwrap(), pilot_before); + let movie = host.cinematic.as_ref().unwrap(); + assert_eq!(movie.image, reference.image); + assert_eq!( + movie.palette(&host.palette), + reference.palette(&host.palette) + ); + assert_eq!(movie.music_events, reference.music_events); + assert_eq!( + cinematic_audio_snapshot(movie), + cinematic_audio_snapshot(&reference) + ); + } + } + } + if reference.finished() { + assert_eq!(tick, 569); + break; + } + let nanos = 1_000_000_000_u64.div_ceil(70); + host.cinematic.as_mut().unwrap().advance(nanos).unwrap(); + reference.advance(nanos).unwrap(); + } + assert_eq!(drawn_cards, ["APOGEE_PIC", "CYGNUS_PIC"].into()); + assert_eq!( + reference.music_events, + [("APOGEE_MUS", false), ("RINTRO_MUS", true)] + ); + assert_eq!(reference.audio_events.len(), 2); + assert_eq!(host.hd.validated_count(), 2); + assert!(host.hd.diagnostics.is_empty()); + // Fade phases reuse the same palette-bound indexed sprites rather than + // repeatedly quantizing against changing or completely black colors. + assert_eq!(host.hd.cache_stats().indexed_entries, 4); + assert_eq!(host.hd.cache_stats().png_decodes, 2); + } + #[test] + fn missing_or_source_mismatched_hd_opening_card_keeps_native_picture_and_palette() { + for mismatched in [false, true] { + let (_dir, mut host) = opening_card_fixture(); + let manifest_path = host.catalog.root.join("hd/hd.json"); + let mut manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap(); + if mismatched { + manifest["sequences"][0]["frames"][0]["source_sha256"] = "changed".into(); + } else { + manifest["sequences"][0]["frames"] = serde_json::json!([]); + } + std::fs::write(manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap(); + host.config.hd_mode = true; + host.config.scanlines = false; + host.cinematic + .as_mut() + .unwrap() + .advance(64 * 1_000_000_000_u64.div_ceil(70)) + .unwrap(); + for scale in [1, 4] { + host.config.scale = scale; + let canvas = host.draw().unwrap(); + assert!(canvas.image.pixels.iter().all(|pixel| *pixel == 5)); + let rgba = host.present_rgba(&canvas); + assert_eq!(&rgba[..4], &[0, 255, 255, 255]); + } + assert_eq!(host.hd.validated_count(), 0); + assert_eq!( + host.cinematic.as_ref().unwrap().music_events, + [("APOGEE_MUS", false)] + ); + } + } fn game_input_fixture() -> (tempfile::TempDir, Host) { let (dir, mut host) = ui_fixture(); let pack = serde_json::from_value(serde_json::json!({ diff --git a/RustRaptor/src/hd.rs b/RustRaptor/src/hd.rs index 93e7f058..182a6e7a 100644 --- a/RustRaptor/src/hd.rs +++ b/RustRaptor/src/hd.rs @@ -152,6 +152,11 @@ pub struct CacheStats { pub disk_writes: usize, pub source_label_bytes: usize, } +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum PalettePolicy { + SourceClasses, + Static, +} pub struct HdAssets { root: PathBuf, disk_cache: Option, @@ -159,7 +164,7 @@ pub struct HdAssets { png_info: BTreeMap, atlases: LruCache<[u8; 32], RgbaImage>, rgba: LruCache<[u8; 32], RgbaImage>, - scaled: LruCache<([u8; 32], usize, [u8; 32]), Sprite>, + scaled: LruCache<([u8; 32], usize, [u8; 32], PalettePolicy), Sprite>, png_decodes: usize, disk_hits: usize, disk_writes: usize, @@ -678,6 +683,25 @@ impl HdAssets { id: u32, scale: usize, palette: &[[u8; 3]; 256], + ) -> Result> { + self.sprite_with_policy(id, scale, palette, PalettePolicy::SourceClasses) + } + /// Standalone cards use all 256 colors of their own unfaded static palette. + /// Gameplay cycling/flash classes do not apply; fades happen at presentation. + pub fn sprite_static_palette( + &mut self, + id: u32, + scale: usize, + palette: &[[u8; 3]; 256], + ) -> Result> { + self.sprite_with_policy(id, scale, palette, PalettePolicy::Static) + } + fn sprite_with_policy( + &mut self, + id: u32, + scale: usize, + palette: &[[u8; 3]; 256], + policy: PalettePolicy, ) -> Result> { ensure!( (1..=4).contains(&scale), @@ -690,7 +714,7 @@ impl HdAssets { let requires_alpha = record.requires_alpha; if let Ok((_, info)) = self.png(&frame.file) { let key = Self::frame_key(&frame, &info, requires_alpha); - if let Some(sprite) = self.scaled.get(&(key, scale, palette_key)).cloned() { + if let Some(sprite) = self.scaled.get(&(key, scale, palette_key, policy)).cloned() { self.replacements.get_mut(&id).unwrap().validated = true; return Ok(Some(sprite)); } @@ -699,7 +723,7 @@ impl HdAssets { let Some((key, rgba)) = self.rgba_frame(id)? else { return Ok(None); }; - let scaled_key = (key, scale, palette_key); + let scaled_key = (key, scale, palette_key, policy); let replacement = self.replacements.get(&id).unwrap(); let frame = &replacement.frame; let width = frame.logical_width as usize * scale; @@ -716,13 +740,20 @@ impl HdAssets { let rgb = [pixel[0], pixel[1], pixel[2]]; let logical = (index / width / scale) * frame.logical_width as usize + (index % width / scale); - let source_label = replacement - .source_labels - .as_ref() - .map_or(0, |labels| labels[logical]); + let source_label = if policy == PalettePolicy::SourceClasses { + replacement + .source_labels + .as_ref() + .map_or(0, |labels| labels[logical]) + } else { + 0 + }; *destination = *colors .entry((rgb, source_label)) - .or_insert_with(|| closest(palette, rgb, source_label)); + .or_insert_with(|| match policy { + PalettePolicy::SourceClasses => closest(palette, rgb, source_label), + PalettePolicy::Static => closest_in_range(palette, rgb, 0..256), + }); } self.scaled .insert(scaled_key, sprite.clone(), sprite.pixels.len() * 2); @@ -818,6 +849,9 @@ fn closest(palette: &[[u8; 3]; 256], rgb: [u8; 3], source_label: u8) -> u16 { 250..=255 => return source_label as u16, _ => 0..240, }; + closest_in_range(palette, rgb, range) +} +fn closest_in_range(palette: &[[u8; 3]; 256], rgb: [u8; 3], range: std::ops::Range) -> u16 { palette .iter() .enumerate() @@ -978,6 +1012,67 @@ mod tests { assert!(animated.cache_stats().source_label_bytes <= MAX_SOURCE_LABEL_BYTES); } #[test] + fn static_card_palettes_use_all_colors_without_crossing_gameplay_cache_policy() { + let (dir, catalog, mut manifest) = fixture(); + let rgb = [255, 100, 30]; + RgbaImage::from_pixel(8, 8, image::Rgba([rgb[0], rgb[1], rgb[2], 255])) + .save(dir.path().join("hd/ship.png")) + .unwrap(); + let mut source = fs::read(dir.path().join("source")).unwrap(); + source[20..24].copy_from_slice(&[5, 242, 247, 253]); + fs::write(dir.path().join("source"), &source).unwrap(); + manifest.sequences[0].frames[0].source_sha256 = format!("{:x}", Sha256::digest(&source)); + let mut assets = pack(dir.path(), &catalog, &manifest); + let mut palette = [[0; 3]; 256]; + for index in [7, 243, 247] { + palette[index] = [240, 90, 30]; + } + palette[251] = rgb; + for scale in 1..=4 { + for static_palette in [true, false, true] { + let sprite = if static_palette { + assets.sprite_static_palette(1, scale, &palette) + } else { + assets.sprite(1, scale, &palette) + } + .unwrap() + .unwrap(); + for y in 0..sprite.height { + for x in 0..sprite.width { + assert_eq!( + sprite.pixels[y * sprite.width + x], + if static_palette { + 251 + } else { + [7, 243, 247, 253][(y / scale) * 2 + x / scale] + }, + "palette policy collision: static={static_palette}, scale={scale}" + ); + } + } + } + } + let before = assets.cache_stats(); + assert_eq!(before.indexed_entries, 8); + assert_eq!(before.png_decodes, 1); + palette[251] = [0; 3]; + palette[252] = rgb; + assert!( + assets + .sprite_static_palette(1, 4, &palette) + .unwrap() + .unwrap() + .pixels + .iter() + .all(|pixel| *pixel == 252) + ); + let after = assets.cache_stats(); + assert_eq!(after.indexed_entries, 9); + assert_eq!(after.png_decodes, before.png_decodes); + assert_eq!(after.rgba_bytes, before.rgba_bytes); + assert_eq!(after.disk_writes, before.disk_writes); + } + #[test] fn source_checked_pack_reports_partial_coverage_and_rejects_changed_data() { let (dir, catalog, mut manifest) = fixture(); let assets = pack(dir.path(), &catalog, &manifest); diff --git a/RustRaptor/tests/hd_terrain.rs b/RustRaptor/tests/hd_terrain.rs index 65bb2043..c72737ee 100644 --- a/RustRaptor/tests/hd_terrain.rs +++ b/RustRaptor/tests/hd_terrain.rs @@ -919,9 +919,57 @@ fn canonical_terrain_candidate_preserves_all_declared_contexts_and_neighbor_pair let mut contexts = Vec::new(); let mut pairs = Vec::new(); let mut pair_classes = BTreeMap::<&str, usize>::new(); + let mut native_context_targets = Vec::new(); + let mut gallery_only_unused_targets = Vec::new(); for target in job["targets"].as_array().unwrap() { let target_name = target["asset"].as_str().unwrap(); let id = u32::from_str_radix(target_name, 16).unwrap(); + // Complete catalog coverage also includes unused pictures with no + // native placement. Validate their genuine source-bound art without + // inventing neighbors or presenting a gallery as a mission context. + let mut detailed = None; + for scale in 1..=4 { + let image = hd + .sprite(id, scale, &colors) + .unwrap() + .expect("candidate artwork rejected"); + assert_eq!((image.width, image.height), (32 * scale, 32 * scale)); + assert!(image.pixels.iter().all(|pixel| *pixel < TRANSPARENT)); + if scale == 4 { + detailed = Some(image); + } + } + let unused = target["terrain_context"]["roles"] + .as_array() + .unwrap() + .is_empty(); + if unused { + assert!( + target["terrain_context"]["samples"] + .as_array() + .unwrap() + .is_empty() + ); + assert!( + target["terrain_context"]["neighbors"] + .as_object() + .unwrap() + .values() + .all(|neighbors| neighbors.as_array().unwrap().is_empty()) + ); + let mut gallery = Canvas { + scale: 1, + image: solid(256, 128, 0), + }; + gallery.blit(&upscale(&catalog.sprite(id).unwrap(), 4).unwrap(), 0, 0); + gallery.blit(&detailed.unwrap(), 128, 0); + let file = format!("{target_name}-runtime-unused-gallery.png"); + save(&gallery.image, &output.join(&file)); + gallery_only_unused_targets + .push(json!({"asset":target_name,"file":file,"native_occurrence":false})); + } else { + native_context_targets.push(target_name.to_owned()); + } for (sample_index, sample) in target["terrain_context"]["samples"] .as_array() .unwrap() @@ -984,19 +1032,21 @@ fn canonical_terrain_candidate_preserves_all_declared_contexts_and_neighbor_pair local_pairs.push(json!({"target":target_name,"side":side,"neighbor":neighbor_name,"class":class})); } } - let mut sheet = Canvas { - scale: 1, - image: solid(1024, proofs.len().div_ceil(4).max(1) * 128, 0), - }; - for (index, proof) in proofs.iter().enumerate() { - sheet.blit(proof, (index % 4 * 256) as i32, (index / 4 * 128) as i32); - } - let file = format!("{target_name}-runtime-cardinal-pairs.png"); - save(&sheet.image, &output.join(&file)); - for (index, mut pair) in local_pairs.into_iter().enumerate() { - pair["file"] = json!(file); - pair["sheet_cell"] = json!([index % 4, index / 4]); - pairs.push(pair); + if !proofs.is_empty() { + let mut sheet = Canvas { + scale: 1, + image: solid(1024, proofs.len().div_ceil(4) * 128, 0), + }; + for (index, proof) in proofs.iter().enumerate() { + sheet.blit(proof, (index % 4 * 256) as i32, (index / 4 * 128) as i32); + } + let file = format!("{target_name}-runtime-cardinal-pairs.png"); + save(&sheet.image, &output.join(&file)); + for (index, mut pair) in local_pairs.into_iter().enumerate() { + pair["file"] = json!(file); + pair["sheet_cell"] = json!([index % 4, index / 4]); + pairs.push(pair); + } } } assert!(hd.diagnostics.is_empty(), "{:?}", hd.diagnostics); @@ -1015,6 +1065,7 @@ fn canonical_terrain_candidate_preserves_all_declared_contexts_and_neighbor_pair .collect(); let report = json!({"job_id":job_id,"contexts":contexts,"cardinal_pairs":pairs,"pair_classes":pair_classes, "registered":hd.registered_count(),"decoded":hd.validated_count(),"diagnostics":hd.diagnostics, + "native_context_targets":native_context_targets,"gallery_only_unused_targets":gallery_only_unused_targets, "targets_with_state_partners":partners,"state_partner_note":"Empty means this candidate has no declared damaged/destroyed partner; separate synthetic/source-bound tests verify state selection.", "panels":["Joined original ScaleNx","Exact HdAssets + TerrainHd composition"], "registration":"Isolated test pack only; global manifest and coverage untouched.","review":"Files require visual inspection; test success verifies reachability/canvas/indexing, not artistic acceptance."});