Render scripted boss phases and accelerate validated mod loading

This commit is contained in:
raptorreplica committed 2026-10-11 20:49:37 +11:00
1 parent 9226a13433
commit 0e8a27c728
15 files changed
+403 -78

No files matched your search

+13
View File
@@ -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
BIN
View File
Binary file not shown.
Binary file not shown.
+23 -4
View File
@@ -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
+22 -14
View File
@@ -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
+12 -6
View File
@@ -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.
+2 -2
View File
@@ -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
+7 -7
View File
@@ -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)"
]
}
+1 -1
View File
@@ -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"
+24 -17
View File
@@ -220,23 +220,7 @@ impl Sprite {
Ok(out)
}
pub fn png(path: &Path, palette: &[[u8; 3]; 256]) -> Result<Self> {
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<image::RgbaImage> {
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)>,
+120 -2
View File
@@ -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::<Vec<_>>();
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;
}
+13 -10
View File
@@ -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"
);
}
+139
View File
@@ -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<u8> {
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<ActiveMods> {
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<ActiveMods> {
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());
}
+24 -7
View File
@@ -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))
+3 -8
View File
@@ -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))