853 lines
31 KiB
Rust
853 lines
31 KiB
Rust
//! 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();
|
|
}
|