Render scripted boss phases and accelerate validated mod loading
This commit is contained in:
1 parent
9226a13433
commit
0e8a27c728
15 files changed
+403
-78
No files matched your search
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
Reference in new issue
Block a user