146 lines
6.7 KiB
Rust
146 lines
6.7 KiB
Rust
//! Read-only public engine asset/audio review for both independent Stage 2 mods.
|
|
//! Writes captures only to the caller's evidence directory, never to game data.
|
|
use image::{RgbaImage, imageops};
|
|
use raptor_replica::{
|
|
assets::{Catalog, Sprite, TRANSPARENT},
|
|
audio::Mixer,
|
|
content::{ContentId, Registry, art_sprite_variant},
|
|
modding::{self, ActiveMods},
|
|
render::{Canvas, nearest},
|
|
sound::{SoundBank, encode_capture},
|
|
};
|
|
use serde_json::json;
|
|
use std::{env, fs, path::Path};
|
|
fn normalized(sprite: &Sprite, w: usize, h: usize) -> Sprite {
|
|
let mut out = Sprite::blank(w, h);
|
|
for y in 0..h {
|
|
for x in 0..w {
|
|
out.pixels[y * w + x] =
|
|
sprite.pixels[(y * sprite.height / h) * sprite.width + x * sprite.width / w];
|
|
}
|
|
}
|
|
out
|
|
}
|
|
fn main() {
|
|
let args: Vec<_> = env::args().collect();
|
|
assert_eq!(args.len(), 4, "usage: native_review DATA MODS EVIDENCE");
|
|
let catalog = Catalog::open(&args[1]).unwrap();
|
|
let base = Registry::base(&catalog).unwrap();
|
|
let ids = ["iron_coast_railburst", "iron_coast_depth_charge"].map(String::from);
|
|
let discovery = modding::discover(Path::new(&args[2]));
|
|
let active = ActiveMods::build(&base, &discovery, &ids, Some(&catalog)).unwrap();
|
|
let out = Path::new(&args[3]);
|
|
fs::create_dir_all(out).unwrap();
|
|
let palette = catalog.palette().unwrap();
|
|
let mut rows = Vec::new();
|
|
let mut gallery = RgbaImage::new(1024, 1024);
|
|
let mut row = 0;
|
|
for owner in &ids {
|
|
for kind in ["equipment", "projectile"] {
|
|
let id: ContentId = format!(
|
|
"{owner}:{}",
|
|
if kind == "equipment" {
|
|
"equipment"
|
|
} else {
|
|
"projectile"
|
|
}
|
|
)
|
|
.as_str()
|
|
.into();
|
|
let art = active.registry.art(&id).unwrap();
|
|
for hd in [false, true] {
|
|
let source =
|
|
art_sprite_variant(&catalog, &active.registry, owner, art, 0, hd).unwrap();
|
|
assert!(
|
|
source
|
|
.pixels
|
|
.iter()
|
|
.filter(|c| **c != TRANSPARENT)
|
|
.all(|c| *c < 240)
|
|
);
|
|
assert!(
|
|
source.pixels[0] == TRANSPARENT
|
|
&& source.pixels[source.width - 1] == TRANSPARENT
|
|
);
|
|
let mut counts = Vec::new();
|
|
for scale in 1..=4 {
|
|
// The same canvas sizing and nearest resampling used by Host::art.
|
|
let display = if source.width == art.logical_width as usize
|
|
&& source.height == art.logical_height as usize
|
|
{
|
|
nearest(&source, scale)
|
|
} else {
|
|
normalized(
|
|
&source,
|
|
art.logical_width as usize * scale,
|
|
art.logical_height as usize * scale,
|
|
)
|
|
};
|
|
counts.push(json!({"scale":scale,"width":display.width,"height":display.height,"painted":display.pixels.iter().filter(|c|**c!=TRANSPARENT).count()}));
|
|
let mut canvas = Canvas::new(scale);
|
|
canvas.clear(12);
|
|
canvas.rect(160, 0, 160, 200, 93);
|
|
for (x, y) in [(70, 90), (230, 90)] {
|
|
canvas.blit(&display, x * scale as i32, y * scale as i32);
|
|
}
|
|
let rgba = RgbaImage::from_raw(
|
|
canvas.image.width as u32,
|
|
canvas.image.height as u32,
|
|
canvas.rgba(&palette, false),
|
|
)
|
|
.unwrap();
|
|
rgba.save(out.join(format!(
|
|
"{owner}-{kind}-{}-{scale}x.png",
|
|
if hd { "hd" } else { "sd" }
|
|
)))
|
|
.unwrap();
|
|
}
|
|
let display = normalized(
|
|
&source,
|
|
art.logical_width as usize * 8,
|
|
art.logical_height as usize * 8,
|
|
);
|
|
let mut cell = Canvas::new(1);
|
|
cell.clear(12);
|
|
cell.rect(160, 0, 160, 200, 93);
|
|
cell.blit(&display, 20, 20);
|
|
cell.blit(&display, 180, 20);
|
|
let rgba = RgbaImage::from_raw(320, 200, cell.rgba(&palette, false)).unwrap();
|
|
imageops::overlay(
|
|
&mut gallery,
|
|
&rgba,
|
|
if hd { 512 } else { 0 },
|
|
(row * 256) as i64,
|
|
);
|
|
rows.push(json!({"owner":owner,"kind":kind,"hd":hd,"physical_canvas":[source.width,source.height],"logical_canvas":[art.logical_width,art.logical_height],"frame":0,"frames":art.frames,"ordinary_palette_only":true,"clear_corner_alpha":true,"all_scales":counts}));
|
|
}
|
|
row += 1;
|
|
}
|
|
}
|
|
gallery.save(out.join("indexed-sd-hd-gallery.png")).unwrap();
|
|
let instruments = catalog.named("GENMIDI_OP2").unwrap();
|
|
let mut audio = Vec::new();
|
|
for owner in &ids {
|
|
for kind in ["fire", "impact"] {
|
|
let id: ContentId = format!("{owner}:{kind}").as_str().into();
|
|
let mut bank = SoundBank::default();
|
|
let sample = bank.mod_sample(&active.registry, &id).unwrap();
|
|
assert!(!sample.data.is_empty());
|
|
let mut mixer = Mixer::new(44100, &instruments).unwrap();
|
|
mixer.start_capture(1).unwrap();
|
|
bank.queue_mod(&active.registry, &mut mixer, &id).unwrap();
|
|
let mut samples = vec![0; 44100 * 2];
|
|
mixer.mix(&mut samples);
|
|
assert!(samples.iter().any(|x| *x != 0));
|
|
let file = format!("{owner}-{kind}-mixed.wav");
|
|
fs::write(
|
|
out.join(&file),
|
|
encode_capture(&mixer.take_capture().unwrap()).unwrap(),
|
|
)
|
|
.unwrap();
|
|
audio.push(json!({"id":id,"decoder":"SoundBank::mod_sample","mixer":"Mixer::sample through SoundBank::queue_mod","source_rate":sample.rate,"decoded_samples":sample.data.len(),"mixed_rate":44100,"mixed_channels":2,"nonzero_output_samples":samples.iter().filter(|x|**x!=0).count(),"peak":samples.iter().map(|x|(*x as i32).abs()).max().unwrap(),"capture":file}));
|
|
}
|
|
}
|
|
fs::write(out.join("native-review.json"),serde_json::to_vec_pretty(&json!({"scope":"Public PNG decoder, same Host art normalization, indexed Canvas blitter and actual SoundBank/Mixer. Synthetic backgrounds; no live desktop/gameplay claim.","assets":rows,"audio":audio})).unwrap()).unwrap();
|
|
}
|