Package RustRaptor and refine controls, menus, saves, sound and mod selection
This commit is contained in:
1 parent
0844f6dffa
commit
e17bc1003e
649 files changed
+8652
-2677
No files matched your search
@@ -1,852 +0,0 @@
|
||||
//! Indexed visual contracts and independent ScaleNx specification oracle.
|
||||
//! Original-data contact sheets are opt-in and remain under ignored .reference/.
|
||||
use raptor_replica::{
|
||||
assets::{Catalog, Sprite, TRANSPARENT},
|
||||
content::Registry,
|
||||
render::{
|
||||
BitmapFont, Canvas, SpriteCache, legacy_light_table, nearest, prefers_nearest,
|
||||
shade_air_shadow, shade_ground_shadow, upscale,
|
||||
},
|
||||
};
|
||||
use std::{collections::BTreeSet, fs, path::Path};
|
||||
|
||||
// Uses independent per-corner consensus rules rather than the renderer's shared
|
||||
// optimized guard. Source specification: https://www.scale2x.it/algorithm
|
||||
fn oracle(source: &Sprite, factor: usize) -> Sprite {
|
||||
if factor == 1 {
|
||||
return source.clone();
|
||||
}
|
||||
if factor == 4 {
|
||||
return oracle(&oracle(source, 2), 2);
|
||||
}
|
||||
let mut result = Sprite::blank(source.width * factor, source.height * factor);
|
||||
for y in 0..source.height {
|
||||
for x in 0..source.width {
|
||||
let read = |dx: i32, dy: i32| {
|
||||
let column = (x as i32 + dx).max(0).min(source.width as i32 - 1) as usize;
|
||||
let row = (y as i32 + dy).max(0).min(source.height as i32 - 1) as usize;
|
||||
source.pixels[row * source.width + column]
|
||||
};
|
||||
let [a, b, c, d, e, f, g, h, i] = [
|
||||
read(-1, -1),
|
||||
read(0, -1),
|
||||
read(1, -1),
|
||||
read(-1, 0),
|
||||
read(0, 0),
|
||||
read(1, 0),
|
||||
read(-1, 1),
|
||||
read(0, 1),
|
||||
read(1, 1),
|
||||
];
|
||||
let northwest = d == b && b != f && d != h;
|
||||
let northeast = b == f && b != d && f != h;
|
||||
let southwest = d == h && d != b && h != f;
|
||||
let southeast = h == f && d != h && b != f;
|
||||
let select = |condition, replacement| if condition { replacement } else { e };
|
||||
let values = if factor == 2 {
|
||||
vec![
|
||||
select(northwest, d),
|
||||
select(northeast, f),
|
||||
select(southwest, d),
|
||||
select(southeast, f),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
select(northwest, d),
|
||||
select((northwest && e != c) || (northeast && e != a), b),
|
||||
select(northeast, f),
|
||||
select((northwest && e != g) || (southwest && e != a), d),
|
||||
e,
|
||||
select((northeast && e != i) || (southeast && e != c), f),
|
||||
select(southwest, d),
|
||||
select((southwest && e != i) || (southeast && e != g), h),
|
||||
select(southeast, f),
|
||||
]
|
||||
};
|
||||
for yy in 0..factor {
|
||||
for xx in 0..factor {
|
||||
result.pixels[(y * factor + yy) * result.width + x * factor + xx] =
|
||||
values[yy * factor + xx];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn crop(source: &Sprite, x: usize, y: usize, width: usize, height: usize) -> Sprite {
|
||||
let mut out = Sprite::blank(width, height);
|
||||
for row in 0..height {
|
||||
out.pixels[row * width..(row + 1) * width].copy_from_slice(
|
||||
&source.pixels[(y + row) * source.width + x..(y + row) * source.width + x + width],
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn pattern(width: usize, height: usize, mut seed: u32) -> Sprite {
|
||||
let pixels = (0..width * height)
|
||||
.map(|_| {
|
||||
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
[0, 1, 245, 255, TRANSPARENT][(seed >> 16) as usize % 5]
|
||||
})
|
||||
.collect();
|
||||
Sprite {
|
||||
width,
|
||||
height,
|
||||
pixels,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_three_symbol_neighborhoods_match_unoptimized_published_oracle() {
|
||||
// 3^9 arrangements cover every local equality class for two colors plus
|
||||
// transparency; additional multi-color/border patterns are tested below.
|
||||
for mut code in 0..3_usize.pow(9) {
|
||||
let pixels = (0..9)
|
||||
.map(|_| {
|
||||
let value = [0, 245, TRANSPARENT][code % 3];
|
||||
code /= 3;
|
||||
value
|
||||
})
|
||||
.collect();
|
||||
let source = Sprite {
|
||||
width: 3,
|
||||
height: 3,
|
||||
pixels,
|
||||
};
|
||||
for factor in [2, 3, 4] {
|
||||
assert_eq!(upscale(&source, factor).unwrap(), oracle(&source, factor));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamped_borders_and_single_pixel_dimensions_match_oracle() {
|
||||
for (width, height) in [(1, 1), (1, 9), (9, 1), (2, 2), (7, 11)] {
|
||||
for seed in 0..20 {
|
||||
let source = pattern(width, height, seed);
|
||||
for factor in [1, 2, 3, 4] {
|
||||
assert_eq!(upscale(&source, factor).unwrap(), oracle(&source, factor));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_copies_palette_labels_and_keeps_opaque_black_and_white() {
|
||||
let source = Sprite {
|
||||
width: 3,
|
||||
height: 2,
|
||||
pixels: vec![0, 255, TRANSPARENT, 245, 0, 255],
|
||||
};
|
||||
let colors: BTreeSet<_> = source.pixels.iter().copied().collect();
|
||||
for factor in [1, 2, 3, 4] {
|
||||
let scaled = upscale(&source, factor).unwrap();
|
||||
assert!(scaled.pixels.iter().all(|value| colors.contains(value)));
|
||||
assert!(scaled.pixels.contains(&0));
|
||||
assert!(scaled.pixels.contains(&255));
|
||||
assert!(scaled.pixels.contains(&TRANSPARENT));
|
||||
assert_eq!(source.width, 3);
|
||||
assert_eq!(source.height, 2);
|
||||
}
|
||||
let mut canvas = Canvas::new(1);
|
||||
canvas.clear(37);
|
||||
canvas.blit(&source, 4, 5);
|
||||
let mut first = [[0; 3]; 256];
|
||||
first[0] = [1, 2, 3];
|
||||
first[255] = [250, 251, 252];
|
||||
first[245] = [200, 90, 10];
|
||||
first[37] = [8, 9, 10];
|
||||
let pixels_before = canvas.image.pixels.clone();
|
||||
let rgba_first = canvas.rgba(&first, false);
|
||||
first[245] = [10, 90, 200];
|
||||
let rgba_second = canvas.rgba(&first, false);
|
||||
assert_eq!(canvas.image.pixels, pixels_before);
|
||||
assert_ne!(rgba_first, rgba_second);
|
||||
assert_eq!(
|
||||
&rgba_second[((5 * 320 + 4) * 4)..((5 * 320 + 4) * 4 + 3)],
|
||||
&[1, 2, 3]
|
||||
);
|
||||
assert_eq!(canvas.image.pixels[5 * 320 + 6], 37);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_transparent_sprites_clip_cleanly_at_every_viewport_edge() {
|
||||
let source = pattern(7, 9, 19);
|
||||
for factor in [1, 2, 3, 4] {
|
||||
let sprite = upscale(&source, factor).unwrap();
|
||||
let width = 320 * factor;
|
||||
let height = 200 * factor;
|
||||
for (x, y) in [
|
||||
(-(sprite.width as i32) + 1, -(sprite.height as i32) + 1),
|
||||
(-3, 20),
|
||||
(30, -4),
|
||||
(width as i32 - 1, height as i32 - 1),
|
||||
(width as i32 - 4, 30),
|
||||
(40, height as i32 - 4),
|
||||
(-100, -100),
|
||||
] {
|
||||
let mut canvas = Canvas::new(factor);
|
||||
canvas.clear(37);
|
||||
canvas.blit(&sprite, x, y);
|
||||
for yy in 0..height {
|
||||
for xx in 0..width {
|
||||
let sx = xx as i32 - x;
|
||||
let sy = yy as i32 - y;
|
||||
let expected = if sx >= 0
|
||||
&& sy >= 0
|
||||
&& sx < sprite.width as i32
|
||||
&& sy < sprite.height as i32
|
||||
{
|
||||
let pixel = sprite.pixels[sy as usize * sprite.width + sx as usize];
|
||||
if pixel == TRANSPARENT { 37 } else { pixel }
|
||||
} else {
|
||||
37
|
||||
};
|
||||
assert_eq!(canvas.image.pixels[yy * width + xx], expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_pixel_terrain_context_matches_full_joined_filter_at_all_scales() {
|
||||
let source = pattern(18, 18, 28);
|
||||
let with_gutter = crop(&source, 4, 4, 10, 10);
|
||||
for factor in [2, 3, 4] {
|
||||
let full = upscale(&source, factor).unwrap();
|
||||
let gutter = upscale(&with_gutter, factor).unwrap();
|
||||
assert_eq!(
|
||||
crop(&full, 6 * factor, 6 * factor, 6 * factor, 6 * factor),
|
||||
crop(&gutter, 2 * factor, 2 * factor, 6 * factor, 6 * factor)
|
||||
);
|
||||
assert_eq!(gutter, oracle(&with_gutter, factor));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_tile_filtering_is_detected_by_joined_seam_regression() {
|
||||
let source = pattern(8, 6, 1);
|
||||
let left = crop(&source, 0, 0, 4, 6);
|
||||
let right = crop(&source, 4, 0, 4, 6);
|
||||
for factor in [2, 3, 4] {
|
||||
let joined = upscale(&source, factor).unwrap();
|
||||
let a = upscale(&left, factor).unwrap();
|
||||
let b = upscale(&right, factor).unwrap();
|
||||
let mut tiled = Sprite::blank(8 * factor, 6 * factor);
|
||||
for row in 0..tiled.height {
|
||||
tiled.pixels[row * tiled.width..row * tiled.width + a.width]
|
||||
.copy_from_slice(&a.pixels[row * a.width..(row + 1) * a.width]);
|
||||
tiled.pixels[row * tiled.width + a.width..(row + 1) * tiled.width]
|
||||
.copy_from_slice(&b.pixels[row * b.width..(row + 1) * b.width]);
|
||||
}
|
||||
assert_ne!(joined, tiled, "fixture must expose a seam at scale{factor}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_text_keeps_exact_square_pixel_blocks_at_every_scale() {
|
||||
let mut bytes = vec![0; 772];
|
||||
bytes[..4].copy_from_slice(&2_i32.to_le_bytes());
|
||||
bytes[516 + b'A' as usize] = 3;
|
||||
bytes.extend([1, 0, 1, 0, 1, 0]);
|
||||
let font = BitmapFont::decode(&bytes).unwrap();
|
||||
assert_eq!(font.height(), 2);
|
||||
for scale in [1, 2, 3, 4] {
|
||||
let mut canvas = Canvas::new(scale);
|
||||
canvas.clear(37);
|
||||
font.text(&mut canvas, 10, 20, "A", 100);
|
||||
for y in 0..2 * scale {
|
||||
for x in 0..3 * scale {
|
||||
let expected = if [1, 0, 1, 0, 1, 0][y / scale * 3 + x / scale] == 0 {
|
||||
37
|
||||
} else {
|
||||
100
|
||||
};
|
||||
assert_eq!(
|
||||
canvas.image.pixels[(20 * scale + y) * canvas.image.width + 10 * scale + x],
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
canvas
|
||||
.image
|
||||
.pixels
|
||||
.iter()
|
||||
.filter(|pixel| **pixel == 100)
|
||||
.count(),
|
||||
3 * scale * scale
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_shadow_masks_clip_without_shading_transparent_pixels() {
|
||||
let mask = Sprite {
|
||||
width: 3,
|
||||
height: 3,
|
||||
pixels: vec![
|
||||
TRANSPARENT,
|
||||
1,
|
||||
TRANSPARENT,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
TRANSPARENT,
|
||||
1,
|
||||
TRANSPARENT,
|
||||
],
|
||||
};
|
||||
let mut canvas = Canvas::new(2);
|
||||
canvas.clear(10);
|
||||
let mut lookup = std::array::from_fn(|index| index as u8);
|
||||
lookup[10] = 11;
|
||||
let y = canvas.image.height as i32 - 2;
|
||||
canvas.shade(&mask, -1, y, &lookup);
|
||||
for yy in 0..canvas.image.height {
|
||||
for x in 0..canvas.image.width {
|
||||
let source_x = x + 1;
|
||||
let source_y = yy as i32 - y;
|
||||
let affected = source_x < 3
|
||||
&& (0..3).contains(&source_y)
|
||||
&& mask.pixels[source_y as usize * 3 + source_x] != TRANSPARENT;
|
||||
assert_eq!(
|
||||
canvas.image.pixels[yy * canvas.image.width + x],
|
||||
if affected { 11 } else { 10 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vga_light_tables_clamp_six_bit_channels_and_choose_last_equal_match() {
|
||||
let palette = std::array::from_fn(|index| [(index % 64) as u8; 3]);
|
||||
let identity = legacy_light_table(&palette, 0);
|
||||
let dark = legacy_light_table(&palette, -9);
|
||||
let light = legacy_light_table(&palette, 9);
|
||||
for index in 0..256 {
|
||||
assert_eq!(identity[index], 192 + (index % 64) as u8);
|
||||
assert_eq!(dark[index], 192 + (index % 64).saturating_sub(9) as u8);
|
||||
assert_eq!(light[index], 192 + ((index % 64) + 9).min(63) as u8);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vga_remap_uses_manhattan_rgb_distance_instead_of_squared_distance() {
|
||||
let mut palette = [[63; 3]; 256];
|
||||
palette[0] = [20; 3];
|
||||
palette[1] = [14, 10, 10]; // Manhattan 4, squared distance 16.
|
||||
palette[2] = [12, 12, 11]; // Manhattan 5, squared distance 9.
|
||||
assert_eq!(legacy_light_table(&palette, -10)[0], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ground_shadows_keep_native_mask_offsets_and_clip_at_every_scale() {
|
||||
let mask = pattern(7, 9, 31);
|
||||
let lookup = std::array::from_fn(|index| index.saturating_add(1).min(255) as u8);
|
||||
for (x, y) in [(3, -4), (-2, 40), (320, 190), (100, 197), (-20, -30)] {
|
||||
let mut native = Canvas::new(1);
|
||||
native.clear(10);
|
||||
// Reference inverse mask coordinates include SHADOW_GAdd's (-3,+4).
|
||||
for yy in 0..200 {
|
||||
for xx in 0..320 {
|
||||
let sx = xx as i32 - x + 3;
|
||||
let sy = yy as i32 - y - 4;
|
||||
if (0..mask.width as i32).contains(&sx)
|
||||
&& (0..mask.height as i32).contains(&sy)
|
||||
&& mask.pixels[sy as usize * mask.width + sx as usize] != TRANSPARENT
|
||||
{
|
||||
native.image.pixels[yy * 320 + xx] = 11;
|
||||
}
|
||||
}
|
||||
}
|
||||
for scale in [1, 2, 3, 4] {
|
||||
let mut actual = Canvas::new(scale);
|
||||
actual.clear(10);
|
||||
shade_ground_shadow(&mut actual, &mask, x as f32, y as f32, &lookup);
|
||||
assert_eq!(actual.image, nearest(&native.image, scale));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reference SHADOW_Draw at native integer coordinates. Find mask runs from
|
||||
// occupied coordinates, project their endpoints, then expand the final native
|
||||
// raster. This keeps the oracle independent of high-resolution span drawing.
|
||||
fn native_air_shadow(mask: &Sprite, x: i32, y: i32, lookup: &[u8; 256]) -> Sprite {
|
||||
let project = |value: i32, center: i32| {
|
||||
let fixed = ((value - center) as i64 * 409_600) / 280;
|
||||
(fixed >> 11) as i32 + center
|
||||
};
|
||||
let mut result = Sprite {
|
||||
width: 320,
|
||||
height: 200,
|
||||
pixels: vec![10; 320 * 200],
|
||||
};
|
||||
let mut seen_rows = BTreeSet::new();
|
||||
for row in 0..mask.height {
|
||||
let occupied: Vec<_> = (0..mask.width)
|
||||
.filter(|&column| mask.pixels[row * mask.width + column] != TRANSPARENT)
|
||||
.collect();
|
||||
if occupied.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let yy = project(y + 20 + row as i32, 100);
|
||||
if !seen_rows.insert(yy) || !(0..200).contains(&yy) {
|
||||
continue;
|
||||
}
|
||||
let mut starts = vec![0];
|
||||
for index in 1..occupied.len() {
|
||||
if occupied[index] != occupied[index - 1] + 1 {
|
||||
starts.push(index);
|
||||
}
|
||||
}
|
||||
starts.push(occupied.len());
|
||||
for endpoints in starts.windows(2) {
|
||||
let start = project(x - 10 + occupied[endpoints[0]] as i32, 160);
|
||||
let end = project(x - 10 + occupied[endpoints[1] - 1] as i32, 160);
|
||||
for xx in start.max(0)..=end.min(319) {
|
||||
let pixel = &mut result.pixels[yy as usize * 320 + xx as usize];
|
||||
*pixel = lookup[*pixel as usize] as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn air_shadows_project_signed_spans_and_suppress_collapsed_rows() {
|
||||
let mask = Sprite {
|
||||
width: 7,
|
||||
height: 7,
|
||||
pixels: vec![1; 49],
|
||||
};
|
||||
let lookup = std::array::from_fn(|index| index.saturating_add(1).min(255) as u8);
|
||||
for scale in [1, 2, 3, 4] {
|
||||
let mut actual = Canvas::new(scale);
|
||||
actual.clear(10);
|
||||
shade_air_shadow(&mut actual, &mask, 170.0, 80.0, &lookup);
|
||||
assert_eq!(
|
||||
actual.image,
|
||||
nearest(&native_air_shadow(&mask, 170, 80, &lookup), scale)
|
||||
);
|
||||
assert_eq!(
|
||||
actual.image.pixels.iter().filter(|&&p| p == 11).count(),
|
||||
25 * scale * scale
|
||||
);
|
||||
assert!(
|
||||
!actual.image.pixels.contains(&12),
|
||||
"collapsed rows must not shade twice"
|
||||
);
|
||||
assert_eq!(
|
||||
actual.image.pixels[100 * scale * actual.image.width + 160 * scale],
|
||||
11
|
||||
);
|
||||
assert_eq!(
|
||||
actual.image.pixels[105 * scale * actual.image.width + 160 * scale],
|
||||
10
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn air_shadows_preserve_sparse_mask_spans_and_clip_projected_edges() {
|
||||
let mask = pattern(21, 17, 76);
|
||||
let lookup = std::array::from_fn(|index| index.saturating_add(1).min(255) as u8);
|
||||
for (x, y) in [
|
||||
(12, -48),
|
||||
(-55, 30),
|
||||
(389, 198),
|
||||
(165, 64),
|
||||
(303, 223),
|
||||
(-80, -70),
|
||||
] {
|
||||
let expected = native_air_shadow(&mask, x, y, &lookup);
|
||||
for scale in [1, 2, 3, 4] {
|
||||
let mut actual = Canvas::new(scale);
|
||||
actual.clear(10);
|
||||
shade_air_shadow(&mut actual, &mask, x as f32, y as f32, &lookup);
|
||||
assert_eq!(
|
||||
actual.image,
|
||||
nearest(&expected, scale),
|
||||
"air position ({x},{y}) at{scale}x"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolated_shadows_translate_masks_without_changing_coverage_or_transparency() {
|
||||
let mask = Sprite {
|
||||
width: 7,
|
||||
height: 7,
|
||||
pixels: vec![1; 49],
|
||||
};
|
||||
let lookup = std::array::from_fn(|index| index.saturating_add(1).min(255) as u8);
|
||||
let mut integer = Canvas::new(4);
|
||||
integer.clear(10);
|
||||
shade_air_shadow(&mut integer, &mask, 170.0, 80.0, &lookup);
|
||||
let mut half = Canvas::new(4);
|
||||
half.clear(10);
|
||||
shade_air_shadow(&mut half, &mask, 170.5, 80.5, &lookup);
|
||||
assert_eq!(half.image.pixels.iter().filter(|&&p| p == 11).count(), 400);
|
||||
for y in 100 * 4..105 * 4 {
|
||||
for x in 160 * 4..165 * 4 {
|
||||
assert_eq!(
|
||||
integer.image.pixels[y * integer.image.width + x],
|
||||
half.image.pixels[(y + 1) * half.image.width + x + 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut empty = Canvas::new(4);
|
||||
empty.clear(TRANSPARENT);
|
||||
shade_air_shadow(&mut empty, &mask, 170.0, 80.0, &lookup);
|
||||
assert!(empty.image.pixels.iter().all(|&p| p == TRANSPARENT));
|
||||
shade_ground_shadow(&mut empty, &mask, 10.0, 10.0, &lookup);
|
||||
assert!(empty.image.pixels.iter().all(|&p| p == TRANSPARENT));
|
||||
}
|
||||
|
||||
fn png(sprite: &Sprite, palette: &[[u8; 3]; 256], path: &Path) {
|
||||
let mut image = image::RgbaImage::new(sprite.width as u32, sprite.height as u32);
|
||||
for (index, &value) in sprite.pixels.iter().enumerate() {
|
||||
let color = if value == TRANSPARENT {
|
||||
[0, 0, 0, 0]
|
||||
} else {
|
||||
let [r, g, b] = palette[value as usize];
|
||||
[r, g, b, 255]
|
||||
};
|
||||
image.put_pixel(
|
||||
(index % sprite.width) as u32,
|
||||
(index / sprite.width) as u32,
|
||||
image::Rgba(color),
|
||||
);
|
||||
}
|
||||
image.save(path).unwrap();
|
||||
}
|
||||
|
||||
fn sheet(sprites: &[Sprite], columns: usize, palette: &[[u8; 3]; 256], path: &Path) {
|
||||
let width = sprites.iter().map(|s| s.width).max().unwrap() + 12;
|
||||
let height = sprites.iter().map(|s| s.height).max().unwrap() + 12;
|
||||
let rows = sprites.len().div_ceil(columns);
|
||||
let mut canvas = Canvas::new(1);
|
||||
canvas.image = Sprite {
|
||||
width: columns * width,
|
||||
height: rows * height,
|
||||
pixels: vec![0; columns * width * rows * height],
|
||||
};
|
||||
for (index, sprite) in sprites.iter().enumerate() {
|
||||
canvas.blit(
|
||||
sprite,
|
||||
((index % columns) * width + 6) as i32,
|
||||
((index / columns) * height + 6) as i32,
|
||||
);
|
||||
}
|
||||
png(&canvas.image, palette, path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires local original palette and sprites; emits private shadow contacts"]
|
||||
fn original_shadow_tables_and_banking_masks_match_indexed_projection() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let catalog = Catalog::open(root.join("data")).unwrap();
|
||||
let palette = catalog.palette().unwrap();
|
||||
let bytes = catalog.named("PALETTE_DAT").unwrap();
|
||||
let vga: [[u8; 3]; 256] = std::array::from_fn(|i| bytes[i * 3..i * 3 + 3].try_into().unwrap());
|
||||
assert_eq!(palette.map(|rgb| rgb.map(|component| component >> 2)), vga);
|
||||
let output = root.join(".reference/visuals");
|
||||
fs::create_dir_all(&output).unwrap();
|
||||
// Independent argmin over (distance, reverse index) preserves GFX_Remap's
|
||||
// last-equal tie rule without the renderer's imperative running minimum.
|
||||
for level in [-9, -6, 9] {
|
||||
let expected: [u8; 256] = std::array::from_fn(|index| {
|
||||
let target = vga[index].map(|component| (component as i32 + level).clamp(0, 63));
|
||||
(0..256)
|
||||
.min_by_key(|&candidate| {
|
||||
let distance: i32 = vga[candidate]
|
||||
.iter()
|
||||
.zip(target)
|
||||
.map(|(&a, b)| (a as i32 - b).abs())
|
||||
.sum();
|
||||
(distance, std::cmp::Reverse(candidate))
|
||||
})
|
||||
.unwrap() as u8
|
||||
});
|
||||
assert_eq!(legacy_light_table(&vga, level), expected);
|
||||
}
|
||||
let air = legacy_light_table(&vga, -6);
|
||||
let ground = legacy_light_table(&vga, -9);
|
||||
let mut banking = vec![];
|
||||
let base = catalog.id("LPLAYER_PIC").unwrap();
|
||||
let terrain_id = Registry::base(&catalog).unwrap().missions[&"base:sector_1_wave_1".into()]
|
||||
.tiles[0]
|
||||
.art
|
||||
.asset
|
||||
.unwrap();
|
||||
let tile = catalog.sprite(terrain_id).unwrap();
|
||||
for frame in 0..7 {
|
||||
let mask = catalog.sprite(base + frame).unwrap();
|
||||
let lookup = std::array::from_fn(|index| index.saturating_add(1).min(255) as u8);
|
||||
for scale in [1, 2, 3, 4] {
|
||||
let mut actual = Canvas::new(scale);
|
||||
actual.clear(10);
|
||||
shade_air_shadow(&mut actual, &mask, 144.0, 160.0, &lookup);
|
||||
assert_eq!(
|
||||
actual.image,
|
||||
nearest(&native_air_shadow(&mask, 144, 160, &lookup), scale)
|
||||
);
|
||||
}
|
||||
let mut preview = Canvas::new(4);
|
||||
for y in (0..200).step_by(tile.height) {
|
||||
for x in (0..320).step_by(tile.width) {
|
||||
preview.blit(&upscale(&tile, 4).unwrap(), (x * 4) as i32, (y * 4) as i32);
|
||||
}
|
||||
}
|
||||
shade_air_shadow(&mut preview, &mask, 144.0, 160.0, &air);
|
||||
preview.blit(&upscale(&mask, 4).unwrap(), 144 * 4, 160 * 4);
|
||||
banking.push(crop(&preview.image, 112 * 4, 132 * 4, 80 * 4, 68 * 4));
|
||||
}
|
||||
sheet(
|
||||
&banking,
|
||||
7,
|
||||
&palette,
|
||||
&output.join("player-shadow-banking-4x.png"),
|
||||
);
|
||||
let mask = catalog.sprite(catalog.id("SHIP37G1_PIC").unwrap()).unwrap();
|
||||
let mut preview = Canvas::new(4);
|
||||
for y in (0..200).step_by(tile.height) {
|
||||
for x in (0..320).step_by(tile.width) {
|
||||
preview.blit(&upscale(&tile, 4).unwrap(), (x * 4) as i32, (y * 4) as i32);
|
||||
}
|
||||
}
|
||||
shade_ground_shadow(&mut preview, &mask, 144.0, 80.0, &ground);
|
||||
preview.blit(&upscale(&mask, 4).unwrap(), 144 * 4, 80 * 4);
|
||||
png(
|
||||
&crop(&preview.image, 128 * 4, 64 * 4, 64 * 4, 64 * 4),
|
||||
&palette,
|
||||
&output.join("ground-shadow-4x.png"),
|
||||
);
|
||||
fs::write(
|
||||
output.join("shadow-tables.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"ground_offset":[-3,4],"ground_level":-9,"air_offset":[-10,20],"air_level":-6,
|
||||
"air_view":[160,100,1000],"air_depth":1280,"distance":200,
|
||||
"ground_table":ground.to_vec(),"air_table":air.to_vec()
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires local original catalog; emits private .reference/visuals contact sheets"]
|
||||
fn original_sequences_preserve_canvases_palette_origins_and_terrain_context() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let catalog = Catalog::open(root.join("data")).unwrap();
|
||||
let palette = catalog.palette().unwrap();
|
||||
let output = root.join(".reference/visuals");
|
||||
fs::create_dir_all(&output).unwrap();
|
||||
let sequences = [
|
||||
("LPLAYER_PIC", 7),
|
||||
("DPLAYER_PIC", 7),
|
||||
("FPLAYER_PIC", 7),
|
||||
("GEXPLO_BLK", 42),
|
||||
("AIRBOOM_PIC", 16),
|
||||
("EXPLO2_BLK", 13),
|
||||
("NMSHOT_BLK", 4),
|
||||
("GUNSTR_BLK", 4),
|
||||
("SHIP22G1_PIC", 6),
|
||||
("SHIP08G1_PIC", 4),
|
||||
];
|
||||
let mut report = vec![];
|
||||
let mut runtime_cache = SpriteCache::new(None);
|
||||
for (name, count) in sequences {
|
||||
let base = catalog.id(name).unwrap();
|
||||
let first = catalog.sprite(base).unwrap();
|
||||
let mut original = vec![];
|
||||
let mut filtered = vec![];
|
||||
let mut runtime = vec![];
|
||||
let mut metadata = vec![];
|
||||
for frame in 0..count {
|
||||
let sprite = catalog.sprite(base + frame).unwrap();
|
||||
assert_eq!(
|
||||
(sprite.width, sprite.height),
|
||||
(first.width, first.height),
|
||||
"{name} frame{frame} shifts animation canvas"
|
||||
);
|
||||
let labels: BTreeSet<_> = sprite.pixels.iter().copied().collect();
|
||||
let mut bounds = vec![];
|
||||
for factor in [1, 2, 3, 4] {
|
||||
let scaled = upscale(&sprite, factor).unwrap();
|
||||
assert_eq!(
|
||||
scaled,
|
||||
oracle(&sprite, factor),
|
||||
"{name} frame{frame} scale{factor}"
|
||||
);
|
||||
assert!(scaled.pixels.iter().all(|value| labels.contains(value)));
|
||||
assert_eq!(
|
||||
(scaled.width, scaled.height),
|
||||
(sprite.width * factor, sprite.height * factor)
|
||||
);
|
||||
let use_nearest = prefers_nearest(base + frame);
|
||||
let presented = runtime_cache.get(&sprite, factor, use_nearest).unwrap();
|
||||
if use_nearest {
|
||||
assert_eq!(*presented, nearest(&sprite, factor));
|
||||
assert_eq!(
|
||||
presented
|
||||
.pixels
|
||||
.iter()
|
||||
.filter(|pixel| **pixel != TRANSPARENT)
|
||||
.count(),
|
||||
sprite
|
||||
.pixels
|
||||
.iter()
|
||||
.filter(|pixel| **pixel != TRANSPARENT)
|
||||
.count()
|
||||
* factor
|
||||
* factor,
|
||||
"{name} frame{frame} loses opacity under nearest fallback"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(*presented, scaled);
|
||||
}
|
||||
if factor == 4 {
|
||||
runtime.push(presented.clone());
|
||||
}
|
||||
// C++ centers registered animations using the full frame canvas,
|
||||
// never its changing opaque bounds. Keep that anchor at every scale.
|
||||
let anchor = (sprite.width as i32 / 2, sprite.height as i32 / 2);
|
||||
let mut canvas = Canvas::new(factor);
|
||||
canvas.clear(37);
|
||||
canvas.blit(
|
||||
&scaled,
|
||||
(160 - anchor.0) * factor as i32,
|
||||
(100 - anchor.1) * factor as i32,
|
||||
);
|
||||
assert_eq!(
|
||||
(160 - anchor.0) * factor as i32 + anchor.0 * factor as i32,
|
||||
160 * factor as i32
|
||||
);
|
||||
let mut minx = scaled.width;
|
||||
let mut miny = scaled.height;
|
||||
let mut maxx = 0;
|
||||
let mut maxy = 0;
|
||||
for (index, &pixel) in scaled.pixels.iter().enumerate() {
|
||||
if pixel != TRANSPARENT {
|
||||
minx = minx.min(index % scaled.width);
|
||||
miny = miny.min(index / scaled.width);
|
||||
maxx = maxx.max(index % scaled.width + 1);
|
||||
maxy = maxy.max(index / scaled.width + 1);
|
||||
}
|
||||
}
|
||||
bounds.push(serde_json::json!({"factor":factor,"bounds":[minx,miny,maxx,maxy],
|
||||
"opaque_pixels":scaled.pixels.iter().filter(|pixel|**pixel!=TRANSPARENT).count()}));
|
||||
if factor == 4 {
|
||||
filtered.push(scaled);
|
||||
}
|
||||
}
|
||||
original.push(nearest(&sprite, 4));
|
||||
metadata.push(serde_json::json!({"frame":frame,"asset":format!("{:05x}",base+frame),"logical_canvas":[sprite.width,sprite.height],"center_anchor":[sprite.width/2,sprite.height/2],"nearest_fallback":prefers_nearest(base+frame),"bounds":bounds}));
|
||||
}
|
||||
sheet(
|
||||
&original,
|
||||
7.min(count as usize),
|
||||
&palette,
|
||||
&output.join(format!("{name}-nearest4.png")),
|
||||
);
|
||||
sheet(
|
||||
&filtered,
|
||||
7.min(count as usize),
|
||||
&palette,
|
||||
&output.join(format!("{name}-scale4.png")),
|
||||
);
|
||||
sheet(
|
||||
&runtime,
|
||||
7.min(count as usize),
|
||||
&palette,
|
||||
&output.join(format!("{name}-runtime4.png")),
|
||||
);
|
||||
report.push(serde_json::json!({"name":name,"frames":metadata}));
|
||||
}
|
||||
let registry = Registry::base(&catalog).unwrap();
|
||||
let mission = ®istry.missions[&"base:sector_1_wave_1".into()];
|
||||
let columns = mission.columns as usize;
|
||||
let rows = mission.layout.len() / columns;
|
||||
let selected = (0..rows - 2)
|
||||
.find(|row| {
|
||||
let labels: BTreeSet<_> = (0..3)
|
||||
.flat_map(|y| (0..3).map(move |x| mission.layout[(row + y) * columns + x + 3]))
|
||||
.collect();
|
||||
labels.len() > 2
|
||||
})
|
||||
.unwrap();
|
||||
let mut joined = Canvas::new(1);
|
||||
joined.image = Sprite {
|
||||
width: 96,
|
||||
height: 96,
|
||||
pixels: vec![0; 96 * 96],
|
||||
};
|
||||
let mut tiles = vec![];
|
||||
for row in 0..3 {
|
||||
for column in 0..3 {
|
||||
let index = mission.layout[(selected + row) * columns + column + 3];
|
||||
let sprite = catalog
|
||||
.sprite(mission.tiles[index].art.asset.unwrap())
|
||||
.unwrap();
|
||||
assert_eq!((sprite.width, sprite.height), (32, 32));
|
||||
joined.blit(&sprite, (column * 32) as i32, (row * 32) as i32);
|
||||
tiles.push(sprite);
|
||||
}
|
||||
}
|
||||
let mut seam_counts = vec![];
|
||||
for factor in [2, 3, 4] {
|
||||
let filtered = upscale(&joined.image, factor).unwrap();
|
||||
assert_eq!(filtered, oracle(&joined.image, factor));
|
||||
let gutter = crop(&joined.image, 30, 30, 36, 36);
|
||||
assert_eq!(
|
||||
crop(
|
||||
&filtered,
|
||||
32 * factor,
|
||||
32 * factor,
|
||||
32 * factor,
|
||||
32 * factor
|
||||
),
|
||||
crop(
|
||||
&upscale(&gutter, factor).unwrap(),
|
||||
2 * factor,
|
||||
2 * factor,
|
||||
32 * factor,
|
||||
32 * factor
|
||||
)
|
||||
);
|
||||
let mut tiled = Canvas::new(1);
|
||||
tiled.image = Sprite::blank(96 * factor, 96 * factor);
|
||||
for (index, tile) in tiles.iter().enumerate() {
|
||||
tiled.blit(
|
||||
&upscale(tile, factor).unwrap(),
|
||||
((index % 3) * 32 * factor) as i32,
|
||||
((index / 3) * 32 * factor) as i32,
|
||||
);
|
||||
}
|
||||
let differences = filtered
|
||||
.pixels
|
||||
.iter()
|
||||
.zip(&tiled.image.pixels)
|
||||
.filter(|(a, b)| a != b)
|
||||
.count();
|
||||
seam_counts
|
||||
.push(serde_json::json!({"factor":factor,"independent_tile_differences":differences}));
|
||||
png(
|
||||
&filtered,
|
||||
&palette,
|
||||
&output.join(format!("terrain-joined-{factor}x.png")),
|
||||
);
|
||||
png(
|
||||
&tiled.image,
|
||||
&palette,
|
||||
&output.join(format!("terrain-independent-{factor}x.png")),
|
||||
);
|
||||
}
|
||||
fs::write(output.join("report.json"),serde_json::to_vec_pretty(&serde_json::json!({"sequences":report,"terrain_source_row":selected,"terrain_differences":seam_counts})).unwrap()).unwrap();
|
||||
}
|
||||
Reference in new issue
Block a user