diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index fb924762..024ef42b 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -73,6 +73,18 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "Included Windows SDL gameplay failed" } - name: Build and package Windows desktop run: python tools/assemble_windows.py --target x86_64-pc-windows-msvc --sdl2-dir "$env:RUNNER_TEMP\sdl2\SDL2-2.32.10" --zip dist/RustRaptor-windows-x86_64.zip + - name: Check displayed boss phases and SD HD shop artwork + shell: pwsh + run: | + $separator = [char]31 + $sdkLibrary = "$env:RUNNER_TEMP\sdl2\SDL2-2.32.10\lib\x64" + $env:CARGO_ENCODED_RUSTFLAGS = "-L${separator}native=$sdkLibrary${separator}-C${separator}target-feature=+crt-static" + $env:PATH = "$env:GITHUB_WORKSPACE\bin;$env:PATH" + cargo test --release --locked --target x86_64-pc-windows-msvc --target-dir target/windows-runtime --lib scripted_enemy_phase_frames_reach_sd_hd_sprites_and_both_shadow_layers -- --nocapture + if ($LASTEXITCODE -ne 0) { throw "Windows phase sprite and shadow regression failed" } + cargo test --release --locked --target x86_64-pc-windows-msvc --target-dir target/windows-runtime --lib iron_coast_stage2_art_and_shop_preview_captures_preserve_simulation -- --ignored --nocapture + if ($LASTEXITCODE -ne 0) { throw "Windows Stage2 SD HD phase and shop preview failed" } + Remove-Item Env:CARGO_ENCODED_RUSTFLAGS - name: Validate packaged data, HD artwork, mods and SDL gameplay shell: pwsh run: | @@ -126,4 +138,5 @@ jobs: dist/windows-stage2.png dist/windows-stage2-audio.wav dist/windows-stage2-sfx.wav + .reference/iron-coast-stage2/visual/*.png if-no-files-found: error diff --git a/bin/raptor-rust b/bin/raptor-rust index e13def1b..4778ef94 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 0d71ddbe..01d169ad 100755 Binary files a/bin/raptor-rust.exe and b/bin/raptor-rust.exe differ diff --git a/docs/iron-coast-stage2-design.md b/docs/iron-coast-stage2-design.md index 8a3ff730..7be5ef6e 100644 --- a/docs/iron-coast-stage2-design.md +++ b/docs/iron-coast-stage2-design.md @@ -99,8 +99,13 @@ and pointer input complete all eight sorties, saving and loading the pilot, script state and RNG between each, then replay the finale. Three normal Energy purchases are paid from carried credits. The route finishes the first eight with 334,910 credits, and the saved replay with 396,520; equipment stays owned -and rewards are not duplicated. Authors also check every mission at all four -difficulties, starter gear, full inventories and delayed objective completion. +and rewards are not duplicated. Independent review also completes the eight +sorties and a saved finale replay at all four difficulties from the same +registered-pilot baseline, using paid Energy repairs. Authors check full +inventories, purchased weapons and delayed objective completion. The aggregate +README subsequently corrects the mission-selection instructions; its separate +successor receipt verifies that every content, script, image and sound file +remains identical. The final saved route binds the successor package hash. Control policies vary, so these are achievable fixture outcomes rather than predictions of player earnings or difficulty. @@ -112,10 +117,24 @@ review inspected 232 decoded SD/HD outputs at scales 1–4 and checked 75 native sound effects with nonzero output and exact FX mute. The music package records separate complete-score FM and loop-boundary validation. -The ordinary suites pass 310 headless and 374 desktop tests. Final focused +Script-pinned boss phase artwork reaches both sprites and shadows; resetting a +pin restores normal animation. A synthetic Host regression covers ground and +air enemies in SD/HD at scales 1 and 4, and the real artwork previews assert +that consecutive boss phases produce distinct displayed pixels. + +PNG activation still fully decodes artwork and retains the same size, canvas, +HD-resolution and catalog-palette checks. It now avoids converting validation +images to indexed pixels only to discard them. Paired checks with all 45 mods +reduce active content construction from 26.9 to 7.0 seconds; all 6,682 unique +referenced PNGs produce byte-identical indexed output. Actual package startup +is measured separately during relocation tests. + +The ordinary suites pass 315 headless and 380 desktop tests. Final focused checks pass four portable campaign checks plus the real saved route, nine weapon tests, nine shared-enemy tests, six animation/effect API tests and the -ignored Host preview check. All 34 Host captures were visually inspected: +ignored Host preview check. All 34 Host captures were visually inspected; after the phase fix, the six +changed SD/HD phase pictures were inspected again and the other 28 remained +byte-identical: SD/HD boss artwork on each mission's terrain, both shop modes, weapon details, Nightbreak/finale mission cards, dependencies and the disable confirmation. Bosses in these pictures are deliberate art previews; actual combat phase diff --git a/docs/resume-checkpoint.md b/docs/resume-checkpoint.md index 10837758..02bfe778 100644 --- a/docs/resume-checkpoint.md +++ b/docs/resume-checkpoint.md @@ -26,23 +26,31 @@ the relocation. See [repository layout](repository.md). ### Current runtime and Stage 2 work -The `b8b8620` runtime checkpoint includes the reviewed **1,148-frame / 421-sequence** -HD snapshot in both executables' shared catalog. It contains **6,848 data files** -with unchanged original `INDEX`. Linux binary SHA-256 is -`fdfcf03f126279246f3b762987e02fa7b385f07b4d31339732037462f9e52413`; -Windows is `a74cc4ea20ddc17a39fe4cbf2da2e7391a561d1975b114ec56dd35fb5ff38f2d`. -The engine now exposes checked script commands for boss animation frames and -native impact-effect kinds. Original effect serialization remains compatible. +The current Linux and Windows runtimes include the reviewed **1,148-frame / +421-sequence** HD snapshot and **6,848 data files**, with unchanged original +`INDEX`. Linux binary SHA-256 is +`7e62ae1b0e8d18670a7c6c3d4f6419878f696f62ba4c7dd2ee9ebf7545f648de`; +Windows is `63d040f5d5c76bad15a74ab1ef54ca992a0fd9cd8b7a46f8506d8cce4fbec275`. +Checked script commands control nonlegacy boss animation frames and native +impact-effect kinds. The renderer applies phase pins to sprites and both +shadow layers; clearing a pin restores ordinary animation. Original effect +serialization remains compatible. PNG activation fully decodes with the same +limits while avoiding discarded palette conversion; all 6,682 referenced PNG +outputs compare byte-identically and five rejection/output regressions pass. **Iron Coast II: Black Tide** adds eight missions, eight bosses, three shared enemies, three original FM scores and two optional purchased weapons through -23 individual packages. All eight missions pass starter-gun runs at difficulties -0–3. All 23 package freezes and independent long-run checks are complete. The -actual registered-pilot Veteran route saves between eight wins and a finale -replay; all 34 Host artwork/shop/menu captures are inspected. Final relocated -Linux/Windows archives are the remaining release checks. The ordinary source suites pass **310 -headless / 374 desktop** tests. [Stage 2 design and validation](iron-coast-stage2-design.md) -records the new content and final acceptance scope. +23 individual packages. Independent review completes the eight missions plus +saved finale replay at difficulties 0–3 from registered-pilot starting gear, +with paid repairs. The final root Veteran route saves/loads between nine wins +and binds the documentation-only aggregate successor hash. All 34 Host +artwork/shop/menu pictures are reviewed; the six changed phase pictures were +inspected again and the other 28 remain byte-identical. Ordinary release suites +pass **315 headless / 380 desktop** tests, with 26/36 deliberate original-data +and capture tests ignored. Final Linux/Windows archive relocation checks are +in progress against the corrected runtimes; previous archive receipts remain +historical. [Stage 2 design and validation](iron-coast-stage2-design.md) +records content, controls, prices and acceptance scope. Original HD work continues under `hd_coordinator`; later approved candidates remain separate from the immutable packaged 1,148-frame snapshot. The paragraphs diff --git a/docs/windows-build.md b/docs/windows-build.md index 37d2a697..7190842c 100644 --- a/docs/windows-build.md +++ b/docs/windows-build.md @@ -62,18 +62,24 @@ mods, captures SDL gameplay and publishes the zip as a workflow artifact. The included GNU executable uses the reviewed **1,148-frame HD snapshot**; the shared manifest binds **6,848 data files** and the package contains **45 installed mods**. Its SHA-256 is -`a74cc4ea20ddc17a39fe4cbf2da2e7391a561d1975b114ec56dd35fb5ff38f2d`. +`63d040f5d5c76bad15a74ab1ef54ca992a0fd9cd8b7a46f8506d8cce4fbec275`. 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 build and exported-folder catalog, HD, mod and SDL gameplay validation. That run preceded the Stage 2 content commit; it verifies the engine and the previous 22 mod packages. -The expanded workflow additionally completes all eight Black Tide missions -and a saved finale replay with normal firing/input and paid repairs. It then -checks all 45 mods and captures Stage 2 gameplay, FM music and music-muted -sound effects from the MSVC package. Final archive relocation evidence is -recorded after export and identifies the exact ZIP tested. +The [first Stage 2 native run](https://github.com/Quintine/RustRaptor/actions/runs/38127321917) +also completes all eight Black Tide missions and a saved finale replay with +normal firing/input and paid repairs. It validates all 45 mods, the MSVC +package and actual FM music plus music-muted sound effects. That run precedes +the displayed-phase fix and PNG-validation optimization. + +The current workflow additionally checks phase sprites and ground/air shadows +in SD/HD, then asserts distinct consecutive phases in actual Host previews +with shop/menu captures. The corrected included executable and final archive +receive fresh relocation and SDL/audio checks; the receipt identifies the +exact ZIP tested. These checks use isolated profiles and preserve user saves. Earlier Windows checkpoints passed 279 portable tests under Wine 11.19, including save replacement and preservation of a locked existing save. diff --git a/mods/iron_coast_stage2_campaign/README.md b/mods/iron_coast_stage2_campaign/README.md index 8f9f8f11..1ca66811 100644 --- a/mods/iron_coast_stage2_campaign/README.md +++ b/mods/iron_coast_stage2_campaign/README.md @@ -3,8 +3,8 @@ Enable **Iron Coast II — Black Tide** in the game's Mods screen. Dependency selection brings in all eight missions, their individual bosses, the shared coastal enemies, the three-track soundtrack and two optional shop weapons. -Apply the selection at the main menu, choose this campaign in the hangar and -start Nightbreak Inlet. Normal pilot saves retain the campaign and package pins. +Apply the selection at the main menu, then select Nightbreak Inlet in the +Hangar's mission menu. Normal pilot saves retain the campaign and package pins. The individual mission, boss, enemy and weapon mods can also be enabled separately. Removing a required mod shows the dependent packages before the diff --git a/runtime-manifest.json b/runtime-manifest.json index 10c9b9be..c0502fa0 100644 --- a/runtime-manifest.json +++ b/runtime-manifest.json @@ -3,7 +3,7 @@ "platform": "Linux x86_64", "catalog_entries": 6449, "approved_hd_frames": 1148, - "binary_sha256": "fdfcf03f126279246f3b762987e02fa7b385f07b4d31339732037462f9e52413", + "binary_sha256": "7e62ae1b0e8d18670a7c6c3d4f6419878f696f62ba4c7dd2ee9ebf7545f648de", "data_files": 6848, "data_bytes": 663083964, "files": { @@ -6857,11 +6857,11 @@ "data/ui/WINGAME_SWD": "304630a1223419d69723902ffd22c2c4eea0388491e37dfca663fcbe78e99aff" }, "system_dependencies": [ - "\tlinux-vdso.so.1 (0x00007f911cee0000)", - "\tlibSDL2-2.0.so.0 => /home/quintine/.local/lib/libSDL2-2.0.so.0 (0x00007f911c000000)", - "\tlibgcc_s.so.1 => /usr/lib/libgcc_s.so.1 (0x00007f911ce8c000)", - "\tlibm.so.6 => /usr/lib/libm.so.6 (0x00007f911cd55000)", - "\tlibc.so.6 => /usr/lib/libc.so.6 (0x00007f911bc00000)", - "\t/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007f911cee2000)" + "\tlinux-vdso.so.1 (0x00007fb027980000)", + "\tlibSDL2-2.0.so.0 => /home/quintine/.local/lib/libSDL2-2.0.so.0 (0x00007fb026c00000)", + "\tlibgcc_s.so.1 => /usr/lib/libgcc_s.so.1 (0x00007fb02792c000)", + "\tlibm.so.6 => /usr/lib/libm.so.6 (0x00007fb026ec9000)", + "\tlibc.so.6 => /usr/lib/libc.so.6 (0x00007fb026800000)", + "\t/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007fb027982000)" ] } diff --git a/runtime-windows-manifest.json b/runtime-windows-manifest.json index 678e6352..819748ff 100644 --- a/runtime-windows-manifest.json +++ b/runtime-windows-manifest.json @@ -37,7 +37,7 @@ ] }, "files": { - "bin/raptor-rust.exe": "a74cc4ea20ddc17a39fe4cbf2da2e7391a561d1975b114ec56dd35fb5ff38f2d", + "bin/raptor-rust.exe": "63d040f5d5c76bad15a74ab1ef54ca992a0fd9cd8b7a46f8506d8cce4fbec275", "bin/SDL2.dll": "53e8fa7e9ed43c30bd650c39396f0d9d2067d2fff0b926c9433b75202592e4aa", "bin/SDL2-WINDOWS-LICENSE.txt": "97f35b302b361680ec1e891e95d2d52097bb95abff361434916d99dc1305f127", "run.cmd": "b007382d9ed3560191856514a9ebd4e429801fc25428840e304bb297f3553269" diff --git a/src/assets.rs b/src/assets.rs index 0578eeb8..f2d6eadd 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -220,23 +220,7 @@ impl Sprite { Ok(out) } pub fn png(path: &Path, palette: &[[u8; 3]; 256]) -> Result { - let mut reader = image::ImageReader::open(path)?.with_guessed_format()?; - ensure!( - reader.format() == Some(image::ImageFormat::Png), - "mod artwork must be PNG" - ); - let mut limits = image::Limits::default(); - limits.max_image_width = Some(4096); - limits.max_image_height = Some(4096); - limits.max_alloc = Some(64 * 1024 * 1024); - reader.limits(limits); - let image = reader.decode()?.into_rgba8(); - ensure!( - image.width() <= 4096 - && image.height() <= 4096 - && image.width() as u64 * image.height() as u64 <= 4_194_304, - "PNG exceeds 4096px" - ); + let image = checked_png(path)?; let mut out = Self::blank(image.width() as usize, image.height() as usize); // Plain mod PNGs carry no palette-animation metadata. Keep their colors // out of the reserved water/fire/blink entries (240..=255), which change @@ -262,6 +246,29 @@ impl Sprite { Ok(out) } } +/// Fully decode untrusted PNG artwork with the renderer's existing limits. +/// Validation needs only the checked canvas, not indexed color conversion. +pub(crate) fn checked_png(path: &Path) -> Result { + let mut reader = image::ImageReader::open(path)?.with_guessed_format()?; + ensure!( + reader.format() == Some(image::ImageFormat::Png), + "mod artwork must be PNG" + ); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(4096); + limits.max_image_height = Some(4096); + limits.max_alloc = Some(64 * 1024 * 1024); + reader.limits(limits); + let image = reader.decode()?.into_rgba8(); + ensure!( + image.width() <= 4096 + && image.height() <= 4096 + && image.width() as u64 * image.height() as u64 <= 4_194_304, + "PNG exceeds 4096px" + ); + Ok(image) +} + #[derive(Debug, Clone)] pub struct LegacyMap { pub tiles: Vec<(u16, u16)>, diff --git a/src/desktop.rs b/src/desktop.rs index b5ed20c8..df03fe3e 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -3221,7 +3221,7 @@ impl Host { if def.legacy.is_some() { e.art_frame } else { - e.frame + e.pinned_art_frame.unwrap_or(e.frame) }, interp(e.x, e.px), interp(e.y, e.py), @@ -3258,7 +3258,7 @@ impl Host { if def.legacy.is_some() { e.art_frame } else { - e.frame + e.pinned_art_frame.unwrap_or(e.frame) }, interp(e.x, e.px), interp(e.y, e.py), @@ -5531,6 +5531,114 @@ mod tests { host.catalog = Catalog::open(host.catalog.root.clone()).unwrap(); } #[test] + fn scripted_enemy_phase_frames_reach_sd_hd_sprites_and_both_shadow_layers() { + let (_directory, mut host) = ui_fixture(); + install_player_fixture(&mut host); + let root = host.catalog.root.clone(); + let mut palette = [0_u8; 768]; + palette[5 * 3] = 63; + palette[8 * 3 + 1] = 63; + palette[70 * 3 + 2] = 63; + std::fs::write(root.join("PALETTE_DAT"), palette).unwrap(); + let mut index = std::fs::read_to_string(root.join("INDEX")).unwrap(); + for id in 0x10005..=0x1000f { + let name = format!("HUD_{id:x}"); + let bytes = [1_i32, 0, 0, 1, 1] + .into_iter() + .flat_map(i32::to_le_bytes) + .chain([0_u8]) + .collect::>(); + std::fs::write(root.join(&name), &bytes).unwrap(); + index.push_str(&format!("{id:x} {} {name}\n", bytes.len())); + } + std::fs::write(root.join("INDEX"), index).unwrap(); + host.catalog = Catalog::open(&root).unwrap(); + host.palette = host.catalog.palette().unwrap(); + host.shadow_table = [9; 256]; + host.air_shadow_table = [9; 256]; + let artwork = root.join("phases.png"); + let mut strip = image::RgbaImage::new(8, 4); + for y in 0..4 { + for x in 0..2 { + strip.put_pixel(x, y, image::Rgba([255, 0, 0, 255])); + } + } + for y in 0..2 { + for x in 4..8 { + strip.put_pixel(x, y, image::Rgba([0, 255, 0, 255])); + } + } + strip.save(&artwork).unwrap(); + image::imageops::resize(&strip, 32, 16, image::imageops::FilterType::Nearest) + .save(root.join("phases-hd.png")) + .unwrap(); + let pack = serde_json::from_value(serde_json::json!({"enemies":[{ + "id":"fixture:phase","name":"Phase fixture","health":10, + "art":{"png":"phases.png","hd_png":"phases-hd.png", + "logical_width":4,"logical_height":4,"frames":2,"nearest":true} + }]})) + .unwrap(); + host.active.registry.apply(pack, "fixture").unwrap(); + host.active + .registry + .asset_roots + .insert("fixture".into(), root); + for ground in [true, false] { + host.active + .registry + .enemies + .get_mut(&"fixture:phase".into()) + .unwrap() + .ground = ground; + let mut world = + World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap(); + world.draw_player = false; + let entity = world + .spawn(&"fixture:phase".into(), 100, 80, &host.active.registry) + .unwrap(); + host.world = Some(world); + for hd in [false, true] { + host.config.hd_mode = hd; + for scale in [1, 4] { + let mut pictures = Vec::new(); + for frame in [Some(0), Some(1), None] { + host.world + .as_mut() + .unwrap() + .apply_commands( + &[crate::modding::Command::Animation { entity, frame }], + &host.active.registry, + ) + .unwrap(); + let digest = host.world.as_ref().unwrap().digest(); + let mut canvas = Canvas::new(scale); + canvas.clear(70); + host.scene(&mut canvas).unwrap(); + let expected = if frame == Some(1) { 8 } else { 5 }; + assert_eq!( + canvas.image.pixels[80 * scale * canvas.image.width + 100 * scale], + expected + ); + assert_eq!(host.world.as_ref().unwrap().digest(), digest); + pictures.push(canvas.image); + } + assert_eq!(pictures[0], pictures[2], "reset restores normal animation"); + assert_ne!(pictures[0], pictures[1]); + let shadow_changes = pictures[0] + .pixels + .iter() + .zip(&pictures[1].pixels) + .filter(|(a, b)| **a != **b && (**a == 9 || **b == 9)) + .count(); + assert!( + shadow_changes > 0, + "phase mask must reach ground={ground}, HD={hd}, scale={scale} shadow" + ); + } + } + } + } + #[test] fn original_player_mapping_retains_every_bank_and_only_dark_missions_use_flash_frames() { let (_directory, mut host) = ui_fixture(); install_player_fixture(&mut host); @@ -7978,6 +8086,8 @@ mod tests { .spawn(&boss, (320 - width as i32) / 2, 12, &host.active.registry) .unwrap(); host.screen = Screen::Game; + let mut previous_sd = None; + let mut previous_hd = None; for frame in 0..frames { host.world .as_mut() @@ -7995,6 +8105,14 @@ mod tests { host.config.hd_mode = true; let hd = capture(&mut host, &format!("mission-{n:02}-boss-{frame}-hd")); assert_ne!(sd, hd, "Stage2 supplied SD and HD art should differ"); + if let Some(previous) = previous_sd { + assert_ne!(sd, previous, "SD boss {n} must show phase frame {frame}"); + } + if let Some(previous) = previous_hd { + assert_ne!(hd, previous, "HD boss {n} must show phase frame {frame}"); + } + previous_sd = Some(sd); + previous_hd = Some(hd); } host.world = None; } diff --git a/src/modding.rs b/src/modding.rs index b94523c5..837232af 100644 --- a/src/modding.rs +++ b/src/modding.rs @@ -436,24 +436,27 @@ impl ActiveMods { .get(owner) .context("missing PNG root")?; let path = contained(root, Path::new(png))?; - let palette = catalog - .map(|cat| cat.palette()) - .transpose()? - .unwrap_or([[0; 3]; 256]); - let sprite = crate::assets::Sprite::png(&path, &palette)?; + // Retain catalog palette validation even though no indexed output + // is needed here. Rendering still uses Sprite::png unchanged. + if let Some(catalog) = catalog { + catalog.palette()?; + } + let image = crate::assets::checked_png(&path)?; + let width = image.width() as usize; + let height = image.height() as usize; ensure!( - sprite.width % art.frames as usize == 0, + width % art.frames as usize == 0, "{owner}/{png}: PNG strip width must divide evenly into frames" ); ensure!( - sprite.width / art.frames as usize * art.logical_height as usize - == sprite.height * art.logical_width as usize, + width / art.frames as usize * art.logical_height as usize + == height * art.logical_width as usize, "{owner}/{png}: artwork canvas aspect differs from logical canvas" ); if art.hd_png.as_ref() == Some(png) { ensure!( - sprite.width / art.frames as usize >= 2 * art.logical_width as usize - && sprite.height >= 2 * art.logical_height as usize, + width / art.frames as usize >= 2 * art.logical_width as usize + && height >= 2 * art.logical_height as usize, "{owner}/{png}: HD artwork needs at least twice the logical resolution" ); } diff --git a/tests/startup_png_validation.rs b/tests/startup_png_validation.rs new file mode 100644 index 00000000..9d29c54d --- /dev/null +++ b/tests/startup_png_validation.rs @@ -0,0 +1,139 @@ +//! Rejection and renderer output contract for validation's shared PNG decoder. +use raptor_replica::{ + assets::{Catalog, Sprite, TRANSPARENT}, + content::Registry, + modding::{self, ActiveMods}, +}; +use serde_json::json; +use std::{fs, path::Path}; +fn bytes(w: u32, h: u32) -> Vec { + let image = image::RgbaImage::from_pixel(w, h, image::Rgba([45, 71, 93, 255])); + let mut output = std::io::Cursor::new(vec![]); + image + .write_to(&mut output, image::ImageFormat::Png) + .unwrap(); + output.into_inner() +} +fn validate( + sd: &[u8], + hd: Option<&[u8]>, + lw: u32, + lh: u32, + frames: u32, +) -> anyhow::Result { + validate_with_catalog(sd, hd, lw, lh, frames, None) +} +fn validate_with_catalog( + sd: &[u8], + hd: Option<&[u8]>, + lw: u32, + lh: u32, + frames: u32, + catalog: Option<&Catalog>, +) -> anyhow::Result { + let tmp = tempfile::tempdir()?; + let m = tmp.path().join("pngprobe"); + fs::create_dir(&m)?; + fs::write( + m.join("mod.toml"), + "id='pngprobe'\nname='PNG validation fixture'\nversion='1.0.0'\napi_version=1\ncontent='content.json'\n", + )?; + fs::write(m.join("sd.png"), sd)?; + let mut art = json!({"png":"sd.png","logical_width":lw,"logical_height":lh,"frames":frames}); + if let Some(hd) = hd { + fs::write(m.join("hd.png"), hd)?; + art["hd_png"] = "hd.png".into(); + } + fs::write( + m.join("content.json"), + serde_json::to_vec( + &json!({"items":[{"id":"pngprobe:item","name":"PNG fixture","price":0,"art":art}]}), + )?, + )?; + let d = modding::discover(tmp.path()); + assert!(d.errors.is_empty(), "{:?}", d.errors); + ActiveMods::build(&Registry::default(), &d, &["pngprobe".into()], catalog) +} +fn sprite_reject(path: &Path, data: &[u8]) { + fs::write(path, data).unwrap(); + assert!(Sprite::png(path, &[[0; 3]; 256]).is_err()); + assert!(validate(data, None, 1, 1, 1).is_err()); +} +#[test] +fn alpha_threshold_ties_opaque_black_and_reserved_palette_remain_exact() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("pixels.png"); + let mut im = image::RgbaImage::new(5, 1); + for (x, c) in [ + [255, 0, 0, 0], + [0, 0, 0, 128], + [120, 10, 20, 255], + [120, 10, 20, 127], + [0, 0, 255, 255], + ] + .into_iter() + .enumerate() + { + im.put_pixel(x as u32, 0, image::Rgba(c)); + } + im.save(&p).unwrap(); + let mut palette = [[255; 3]; 256]; + palette[0] = [0, 0, 0]; + palette[1] = [120, 10, 20]; + palette[2] = palette[1]; + palette[239] = [0, 0, 254]; + palette[240] = [0, 0, 255]; + let s = Sprite::png(&p, &palette).unwrap(); + assert_eq!(s.pixels, vec![TRANSPARENT, 0, 1, TRANSPARENT, 239]); +} +#[test] +fn corrupt_truncated_and_non_png_are_rejected_by_rendering_and_activation() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("invalid.png"); + let valid = bytes(8, 8); + sprite_reject(&p, &valid[..valid.len() / 2]); + let mut corrupt = valid.clone(); + corrupt[40] ^= 0xff; + sprite_reject(&p, &corrupt); + sprite_reject(&p, b"GIF89a\x01\x00\x01\x00\x00\x00\x00"); +} +#[test] +fn oversized_dimensions_and_pixel_count_stay_rejected() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("large.png"); + sprite_reject(&p, &bytes(4097, 1)); + sprite_reject(&p, &bytes(4096, 1025)); + assert!(validate(&bytes(4096, 1), None, 4096, 1, 1).is_ok()); +} +#[test] +fn strip_canvas_and_hd_resolution_checks_stay_identical() { + let sd = bytes(6, 2); + let hd = bytes(12, 4); + assert!(validate(&sd, Some(&hd), 3, 2, 2).is_ok()); + let err = validate(&bytes(7, 2), None, 3, 2, 2) + .err() + .unwrap() + .to_string(); + assert!(err.contains("divide evenly"), "{err}"); + let err = validate(&bytes(6, 3), None, 3, 2, 2) + .err() + .unwrap() + .to_string(); + assert!(err.contains("aspect differs"), "{err}"); + let err = validate(&sd, Some(&sd), 3, 2, 2).err().unwrap().to_string(); + assert!(err.contains("twice the logical"), "{err}"); + let err = validate(&sd, Some(&bytes(12, 5)), 3, 2, 2) + .err() + .unwrap() + .to_string(); + assert!(err.contains("aspect differs"), "{err}"); +} + +#[test] +fn activation_still_rejects_an_invalid_catalog_palette() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("INDEX"), "1 3 PALETTE_DAT\n").unwrap(); + fs::write(tmp.path().join("PALETTE_DAT"), [0, 0, 0]).unwrap(); + let c = Catalog::open(tmp.path()).unwrap(); + assert!(validate_with_catalog(&bytes(2, 2), None, 2, 2, 1, Some(&c)).is_err()); +} diff --git a/tools/assemble_runtime.py b/tools/assemble_runtime.py index 88c5c208..9a18f53c 100644 --- a/tools/assemble_runtime.py +++ b/tools/assemble_runtime.py @@ -28,6 +28,28 @@ def checked_file(root, relative): return resolved +def iter_archive_files(package, archive, skipped, excluded_files=()): + """Yield the same sorted archive files without walking excluded subtrees.""" + def excluded(relative): + return (skipped.intersection(relative.parts) + or any(part.startswith(".runtime-data-") for part in relative.parts) + or (len(relative.parts) >= 4 and relative.parts[0] == "mods" + and relative.parts[2:4] == ("art", "source"))) + + files = [] + for directory, directories, names in os.walk(package, followlinks=False): + parent = Path(directory) + directories[:] = [name for name in directories + if not excluded((parent / name).relative_to(package))] + for name in names: + path = parent / name + relative = path.relative_to(package) + if (not excluded(relative) and relative not in excluded_files and path.is_file() + and path.resolve() != archive): + files.append(path) + yield from sorted(files) + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--source-data", type=Path) @@ -143,14 +165,9 @@ def main(): archive.parent.mkdir(parents=True, exist_ok=True) skipped = {"target", "dist", ".reference", ".local", "derived", "__pycache__", ".git"} with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as output: - for path in sorted(package.rglob("*")): + for path in iter_archive_files(package, archive, skipped): relative = path.relative_to(package) - if path.is_file() and not skipped.intersection(relative.parts) and path.resolve() != archive: - if any(part.startswith(".runtime-data-") for part in relative.parts): - continue - if len(relative.parts) >= 4 and relative.parts[0] == "mods" and relative.parts[2:4] == ("art", "source"): - continue - output.write(path, str(Path("RustRaptor") / relative)) + output.write(path, str(Path("RustRaptor") / relative)) print(json.dumps({key: value for key, value in report.items() if key not in {"files", "system_dependencies"}}, indent=2)) diff --git a/tools/assemble_windows.py b/tools/assemble_windows.py index 93d6f052..b1bb7c07 100644 --- a/tools/assemble_windows.py +++ b/tools/assemble_windows.py @@ -9,7 +9,7 @@ import struct import subprocess import zipfile -from assemble_runtime import checked_file, digest +from assemble_runtime import checked_file, digest, iter_archive_files SYSTEM_DLLS = { @@ -173,14 +173,9 @@ def main(): archive.parent.mkdir(parents=True, exist_ok=True) skipped = {"target", "dist", ".reference", ".local", "derived", "__pycache__", ".git", "lib"} with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as output: - for path in sorted(package.rglob("*")): + for path in iter_archive_files(package, archive, skipped, + excluded_files=(Path("bin/raptor-rust"),)): relative = path.relative_to(package) - if not path.is_file() or skipped.intersection(relative.parts) or path.resolve() == archive: - continue - if relative == Path("bin/raptor-rust") or any(part.startswith(".runtime-data-") for part in relative.parts): - continue - if len(relative.parts) >= 4 and relative.parts[0] == "mods" and relative.parts[2:4] == ("art", "source"): - continue output.write(path, str(Path("RustRaptor") / relative)) print(json.dumps(report, indent=2))