Match full reference demo and validate original missions, transitions and sprite rendering

This commit is contained in:
raptorreplica committed 2026-10-10 12:42:33 +11:00
1 parent 967ba69ee8
commit 0844f6dffa
21 files changed
+3984 -219

No files matched your search

+201
View File
@@ -0,0 +1,201 @@
//! Original-data integration coverage. Run explicitly with --ignored; no game data is tracked.
//! The assisted campaign fixture is described in tools/campaign_check.rs.
#[path = "../tools/campaign_check.rs"]
mod campaign_check;
use raptor_replica::{
assets::{Catalog, decode_demo},
content::ContentId,
modding::ScriptRuntime,
persistence::Pilot,
simulation::{Input, World},
};
use std::{collections::BTreeSet, path::PathBuf};
fn catalog_registry() -> (Catalog, raptor_replica::content::Registry) {
let root = std::env::var_os("RAPTOR_TEST_DATA")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data"));
campaign_check::catalog_registry(&root).unwrap()
}
fn registry() -> raptor_replica::content::Registry {
catalog_registry().1
}
#[test]
#[ignore = "requires original untracked catalog; assisted inspection, not ordinary gameplay wins"]
fn every_original_wave_reaches_its_end_on_all_four_difficulties() {
let registry = registry();
assert_eq!(registry.missions.len(), 27);
let mut failures = vec![];
let mut reports = vec![];
for difficulty in 0..=3 {
for sector in 1..=3 {
for wave in 1..=9 {
let report =
campaign_check::run_wave(&registry, sector, wave, difficulty, 12000).unwrap();
eprintln!("{}", report.csv());
if !report.completed()
|| report.visited_spawns != report.total_spawns
|| report.bosses_seen != report.expected_bosses
{
failures.push(format!("{report:?}"));
}
reports.push(report.csv());
}
}
}
if let Some(path) = std::env::var_os("RAPTOR_CAMPAIGN_REPORT") {
std::fs::write(
path,
format!("{}\n{}\n", campaign_check::csv_header(), reports.join("\n")),
)
.unwrap();
}
assert!(
failures.is_empty(),
"Assisted fixture blockers:\n{}",
failures.join("\n")
);
}
#[test]
#[ignore = "requires original untracked catalog; progression fixture bypasses combat"]
fn all_original_campaign_and_training_progression_paths_are_available() {
let registry = registry();
for difficulty in 0..=3 {
let mut pilot = Pilot {
difficulty,
..Pilot::default()
};
let waves = if difficulty == 0 { 4 } else { 9 };
let sectors = if difficulty == 0 { 1 } else { 3 };
for sector in 1..=sectors {
for wave in 1..=waves {
let expected = ContentId::new(format!("base:sector_{sector}_wave_{wave}")).unwrap();
assert_eq!(pilot.mission, expected);
pilot.complete_mission(&registry).unwrap();
pilot.validate(&registry, &[]).unwrap();
}
}
assert_eq!(pilot.mission, ContentId::from("base:sector_1_wave_1"));
assert_eq!(pilot.difficulty, (difficulty + 1).min(3));
let progress = pilot.legacy_progress.as_ref().unwrap();
assert!(progress.waves.iter().all(|wave| *wave == 0));
if difficulty == 0 {
assert!(progress.training_completed);
assert_eq!(pilot.credits, 10000);
assert_eq!(pilot.inventory[&"base:energy".into()], 75);
} else {
assert_eq!(pilot.completed.len(), 27);
assert_eq!(&progress.difficulties[..3], &[(difficulty + 1).min(3); 3]);
}
}
}
#[test]
#[ignore = "requires original untracked catalog; assisted weapon exercise, no weapon parity claim"]
fn every_original_player_weapon_emits_with_live_mission_targets() {
let registry = registry();
let weapons: Vec<_> = registry
.items
.values()
.filter(|i| i.weapon.is_some() && i.legacy.is_some())
.collect();
assert_eq!(weapons.len(), 15);
let mut exercised = BTreeSet::new();
for item in weapons {
let id = item.weapon.clone().unwrap();
let mut pilot = Pilot {
difficulty: 3,
selected: Some(item.id.clone()),
..Pilot::default()
};
pilot.inventory = [
(item.id.clone(), item.max_count),
("base:energy".into(), 100),
]
.into_iter()
.collect();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
let mut scripts = ScriptRuntime::new();
for tick in 0..1500 {
world.player.shield = 100;
world.player.super_shield = 100;
world
.step(
Input {
fire: true,
absolute: Some([144, 160]),
bomb: tick % 180 == 0,
..Input::default()
},
&registry,
&mut scripts,
)
.unwrap();
if world.projectiles.iter().any(|p| p.weapon == id) {
exercised.insert(id.clone());
break;
}
}
assert!(
exercised.contains(&id),
"original weapon {id} produced no projectile with mission targets"
);
}
assert_eq!(exercised.len(), 15);
}
#[test]
#[ignore = "requires original catalog and rebuilt C++ full-demo reference trace"]
fn complete_demo_1_matches_every_reference_gameplay_snapshot() {
let path = std::env::var_os("RAPTOR_REFERENCE_DEMO1")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".reference/cpp-demo1-full.jsonl")
});
let reference: Vec<serde_json::Value> = std::fs::read_to_string(&path)
.expect("produce the trace using the command in docs/campaign-validation.md")
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect();
assert_eq!(
reference.len(),
1889,
"expected the entire available DEMO1G1 recording"
);
let (catalog, registry) = catalog_registry();
let (sector, wave, frames) = decode_demo(&catalog.named("DEMO1G1_REC").unwrap()).unwrap();
assert_eq!(frames.len(), reference.len());
let mut pilot = Pilot::demo(sector, &registry).unwrap();
pilot.mission = format!("base:sector_{}_wave_{}", sector + 1, wave + 1)
.as_str()
.into();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
let mut scripts = ScriptRuntime::new();
for (frame, expected) in frames.into_iter().zip(reference) {
world
.step(
Input {
fire: frame.buttons[0],
cycle: frame.buttons[1],
bomb: frame.buttons[2],
absolute: Some([frame.x as i32, frame.y as i32]),
demo_bank: Some(frame.bank as i32),
..Input::default()
},
&registry,
&mut scripts,
)
.unwrap();
assert_eq!(
world.reference_state(&registry),
expected,
"reference mismatch at simulation step {}",
world.tick
);
}
assert_eq!(world.tick, 1889);
assert_eq!(world.player.shield, 3);
assert_eq!(world.player.credits, 60825);
}
+379
View File
@@ -0,0 +1,379 @@
//! Opt-in ordinary-input victory check against the installed original catalog.
use raptor_replica::{
assets::{Catalog, decode_demo},
content::Registry,
modding::ScriptRuntime,
persistence::Pilot,
simulation::{Input, Outcome, World},
};
use serde_json::json;
use std::{
collections::BTreeMap,
fs,
io::{BufWriter, Write},
path::PathBuf,
};
const MISSION: &str = "base:sector_1_wave_1";
const ORIGINAL_FLYBY_SOUND: u32 = 129;
#[derive(Debug, serde::Serialize)]
struct DepartureObservation {
tick: u64,
timer: Option<u32>,
x: i32,
y: i32,
draw_player: bool,
control_paused: bool,
sounds: Vec<u32>,
}
fn target_x(world: &World, registry: &Registry) -> i32 {
let center = world.player.x + 16;
world
.enemies
.iter()
.filter(|e| e.health > 0 && e.y + e.height > 0 && e.y < world.player.y)
.min_by_key(|e| {
let def = &registry.enemies[&e.definition];
(
!def.boss,
(e.x + e.width / 2 - center).abs() + (160 - e.y).max(0) / 3,
)
})
.map_or(160, |e| {
// Lead the current observed motion by the forward projectile's travel time.
let travel = ((world.player.y - e.y - e.height / 2) / 8).clamp(0, 24);
(e.x + e.width / 2 + (e.x - e.px).clamp(-4, 4) * travel).clamp(21, 298)
})
}
fn controller(world: &World, registry: &Registry) -> Input {
let aim = target_x(world, registry);
if world.enemies.is_empty() && !world.projectiles.iter().any(|p| p.hostile) {
return Input {
fire: true,
..Input::default()
};
}
let mut best = None;
for dy in [0, -1, 1] {
for dx in [0, -1, 1] {
let input = Input {
dx,
dy,
fire: true,
..Input::default()
};
// These are disposable predictions. The actual world changes only
// through normal keyboard movement and the held fire button.
let mut prediction = world.clone();
let mut scripts = ScriptRuntime::new();
for future in 0..12 {
let projected = if future < 3 {
input
} else {
Input {
fire: true,
..Input::default()
}
};
prediction.step(projected, registry, &mut scripts).unwrap();
}
let lost = (world.player.shield + world.player.super_shield
- prediction.player.shield
- prediction.player.super_shield)
.max(0) as i64;
let position = prediction.player.x + 16 + prediction.player.vx * 2;
let mut cost = lost * 1_000_000
+ (position - aim).abs() as i64 * 80
+ (160 - prediction.player.y).abs() as i64 * 6;
if prediction.outcome == Outcome::Failed {
cost += 1_000_000_000;
}
for p in prediction.projectiles.iter().filter(|p| p.hostile) {
let distance = (p.x - prediction.player.x - 16)
.abs()
.max((p.y - prediction.player.y - 16).abs());
cost += (40 - distance).max(0) as i64 * 30;
}
let gained = prediction
.player
.credits
.saturating_sub(world.player.credits);
cost -= gained as i64;
if best.as_ref().is_none_or(|(previous, _)| cost < *previous) {
best = Some((cost, input));
}
}
}
best.unwrap().1
}
fn demo(frames: &[[u8; 12]]) -> Vec<u8> {
assert!(frames.len() <= i16::MAX as usize);
let mut data = vec![0_u8; 12];
data[8..10].copy_from_slice(&(frames.len() as i16).to_le_bytes());
for frame in frames {
data.extend_from_slice(frame);
}
let (sector, wave, decoded) = decode_demo(&data).unwrap();
assert_eq!((sector, wave, decoded.len()), (0, 0, frames.len()));
data
}
#[test]
#[ignore = "requires locally installed original data; ordinary-input first mission victory"]
fn original_first_mission_is_won_with_a_fresh_pilot() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let path = std::env::var_os("RAPTOR_TEST_DATA")
.map(PathBuf::from)
.unwrap_or_else(|| root.join("data"));
let catalog = Catalog::open(&path).unwrap();
let registry = Registry::base(&catalog).unwrap();
let reference = root.join(".reference");
fs::create_dir_all(&reference).unwrap();
for difficulty in [1, 2] {
let pilot = Pilot {
difficulty,
..Pilot::default()
};
pilot.validate(&registry, &[]).unwrap();
let mut world = World::new(&MISSION.into(), &pilot, &registry).unwrap();
let mut scripts = ScriptRuntime::new();
let mut bosses = BTreeMap::new();
let mut visible_bosses = BTreeMap::new();
let mut killed = BTreeMap::new();
// A removed entity with positive prior health is not credited as a
// kill. This is conservative around same-step collision deaths.
let mut positive_retirements = BTreeMap::new();
let mut frames = Vec::new();
let mut controls = Vec::new();
let mut observations = Vec::new();
let mut snapshots = Vec::new();
let mut departure = Vec::new();
for _ in 0..12_000 {
if world.outcome != Outcome::Playing {
break;
}
let input = controller(&world, &registry);
let prior: Vec<_> = world
.enemies
.iter()
.map(|e| (e.entity, e.definition.clone(), e.health))
.collect();
let before = world.player.shield;
let before_credits = world.player.credits;
let before_timer = world.end_timer;
world.step(input, &registry, &mut scripts).unwrap();
if world.end_timer.is_some() || before_timer.is_some() {
departure.push(DepartureObservation {
tick: world.tick,
timer: world.end_timer,
x: world.player.x,
y: world.player.y,
draw_player: world.draw_player,
control_paused: world.control_paused,
sounds: world.sounds.clone(),
});
}
for e in &world.enemies {
if registry.enemies[&e.definition].boss {
bosses
.entry(e.entity)
.or_insert_with(|| (e.definition.clone(), world.tick));
if e.health > 0
&& e.y + e.height > 0
&& e.y < 200
&& e.x + e.width > 0
&& e.x < 320
{
visible_bosses.entry(e.entity).or_insert(world.tick);
}
}
}
for (entity, definition, health) in prior {
if !world.enemies.iter().any(|e| e.entity == entity) {
if health <= 0 {
let reward = registry.enemies[&definition].reward;
if bosses.contains_key(&entity) {
assert!(
world.player.credits >= before_credits + reward,
"original boss death did not award its reward"
);
}
killed.insert(entity, json!({"definition":definition,"tick":world.tick,"last_health":health,"reward":reward}));
} else {
positive_retirements.insert(entity, (definition, health));
}
}
}
let mut frame = [0_u8; 12];
frame[0] = u8::from(input.fire);
frame[1] = u8::from(input.cycle);
frame[2] = u8::from(input.bomb);
frame[4..6].copy_from_slice(&(world.player.x as i16).to_le_bytes());
frame[6..8].copy_from_slice(&(world.player.y as i16).to_le_bytes());
frame[8..10].copy_from_slice(&(world.player.bank as i16).to_le_bytes());
frames.push(frame);
controls.push(json!({"tick":world.tick,"dx":input.dx,"dy":input.dy,"fire":input.fire}));
snapshots.push(world.reference_state(&registry));
if world.tick % 100 == 0
|| before != world.player.shield
|| world.outcome != Outcome::Playing
{
observations.push(json!({"tick":world.tick,"x":world.player.x,"y":world.player.y,"shield":world.player.shield,"credits":world.player.credits,"enemies":world.enemies.iter().map(|e| json!({"entity":e.entity,"id":e.definition,"health":e.health,"x":e.x,"y":e.y})).collect::<Vec<_>>()}));
}
}
let cue = departure
.iter()
.find(|observation| observation.timer == Some(40))
.expect("successful original mission skipped its departure cue");
let report = json!({"mission":MISSION,"initial_pilot":pilot,"difficulty":difficulty,"ticks":world.tick,"demo_recorded_steps":cue.tick,"outcome":world.outcome,"shield":world.player.shield,"credits":world.player.credits,"spawns":world.spawn_cursor,"total_spawns":registry.missions[&world.mission].spawns.len(),"bosses":bosses,"bosses_first_visible":visible_bosses,"killed":killed,"positive_health_retirements":positive_retirements,"departure":departure,"observations":observations});
let name = format!("first-mission-fresh-difficulty-{difficulty}");
// The original DEMO_Think records before the stats-stage forced exit
// movement. End-of-step coordinates are equivalent until timer40.
// Keep this legacy recording to that boundary; the keyboard log below
// independently reproduces the complete ordinary departure.
let recorded = demo(&frames[..cue.tick as usize]);
fs::write(reference.join(format!("{name}.dem")), &recorded).unwrap();
fs::write(
reference.join(format!("{name}-controls.json")),
serde_json::to_vec(&controls).unwrap(),
)
.unwrap();
fs::write(
reference.join(format!("{name}-report.json")),
serde_json::to_vec_pretty(&report).unwrap(),
)
.unwrap();
let mut trace =
BufWriter::new(fs::File::create(reference.join(format!("{name}.jsonl"))).unwrap());
for snapshot in &snapshots {
writeln!(&mut trace, "{snapshot}").unwrap();
}
trace.flush().unwrap();
eprintln!(
"First original mission difficulty {difficulty}: {:?}, {} steps, shield {}, credits {}, bosses {:?}, confirmed kills {}, positive-health retirements {}",
world.outcome,
world.tick,
world.player.shield,
world.player.credits,
bosses,
killed.len(),
positive_retirements.len()
);
assert_eq!(world.outcome, Outcome::Complete, "{report}");
let mission = &registry.missions[&world.mission];
assert_eq!(world.spawn_cursor, mission.spawns.len());
let expected_bosses = mission
.spawns
.iter()
.filter(|spawn| {
registry.enemies[&spawn.enemy].boss
&& match spawn.difficulty {
3 => true,
4 => difficulty >= 2,
5 => difficulty >= 3,
_ => false,
}
})
.count();
assert!(
expected_bosses > 0,
"catalog did not contain an original boss"
);
assert_eq!(
bosses.len(),
expected_bosses,
"some original bosses never appeared"
);
assert_eq!(
visible_bosses.len(),
bosses.len(),
"some bosses never entered the playfield alive"
);
assert!(
bosses.keys().all(|entity| killed.contains_key(entity)),
"an original boss escaped alive: {report}"
);
assert!(
world.player.credits > pilot.credits,
"victory must award original rewards"
);
assert!(cue.sounds.contains(&ORIGINAL_FLYBY_SOUND));
assert!(cue.control_paused);
let remaining_24 = departure
.iter()
.find(|observation| observation.timer == Some(24))
.expect("successful original mission skipped its departure animation");
assert!(
remaining_24.draw_player,
"a surviving pilot was hidden like a dead pilot"
);
assert!(
world.player.y < 0,
"surviving pilot never flew off the top of the screen"
);
// Legacy playback must preserve every snapshot through the boss kill
// and flyby cue, before automatic departure changes the record phase.
let (_, _, replay) = decode_demo(&recorded).unwrap();
let mut replay_world = World::new(&MISSION.into(), &pilot, &registry).unwrap();
let mut replay_scripts = ScriptRuntime::new();
for (frame, expected) in replay.into_iter().zip(&snapshots) {
replay_world
.step(
Input {
fire: frame.buttons[0],
cycle: frame.buttons[1],
bomb: frame.buttons[2],
absolute: Some([frame.x as i32, frame.y as i32]),
demo_bank: Some(frame.bank as i32),
..Input::default()
},
&registry,
&mut replay_scripts,
)
.unwrap();
assert_eq!(
replay_world.reference_state(&registry),
*expected,
"recorded first-mission input diverged at step {}",
replay_world.tick
);
}
assert_eq!(replay_world.tick, cue.tick);
assert_eq!(replay_world.end_timer, Some(40));
assert!(replay_world.enemies.is_empty());
// The keyboard log is also independently decoded and replayed. Unlike
// the original demo format, it includes ordinary departure movement.
let keyboard: Vec<serde_json::Value> = serde_json::from_slice(
&fs::read(reference.join(format!("{name}-controls.json"))).unwrap(),
)
.unwrap();
let mut keyboard_world = World::new(&MISSION.into(), &pilot, &registry).unwrap();
let mut keyboard_scripts = ScriptRuntime::new();
assert_eq!(keyboard.len(), snapshots.len());
for (control, expected) in keyboard.into_iter().zip(snapshots) {
keyboard_world
.step(
Input {
dx: control["dx"].as_i64().unwrap() as i32,
dy: control["dy"].as_i64().unwrap() as i32,
fire: control["fire"].as_bool().unwrap(),
..Input::default()
},
&registry,
&mut keyboard_scripts,
)
.unwrap();
assert_eq!(
keyboard_world.reference_state(&registry),
expected,
"keyboard recording diverged at step {}",
keyboard_world.tick
);
}
assert_eq!(keyboard_world.outcome, Outcome::Complete);
}
}
+581 -1
View File
@@ -2,7 +2,7 @@ use raptor_replica::{
content::{ContentId, ContentPack, Registry},
modding::{Command, ScriptRuntime},
persistence::Pilot,
simulation::{Input, LineMove, World},
simulation::{Input, LegacyProjectile, LineMove, Projectile, World},
};
use serde_json::json;
@@ -24,6 +24,36 @@ fn fixture() -> Registry {
reg
}
#[test]
fn demo_coordinates_bypass_normal_input_bounds() {
let reg = fixture();
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), &reg).unwrap();
let mut scripts = ScriptRuntime::new();
world
.step(
Input {
absolute: Some([284, 162]),
demo_bank: Some(3),
..Input::default()
},
&reg,
&mut scripts,
)
.unwrap();
assert_eq!((world.player.x, world.player.y), (284, 162));
world
.step(
Input {
absolute: Some([284, 162]),
..Input::default()
},
&reg,
&mut scripts,
)
.unwrap();
assert_eq!((world.player.x, world.player.y), (282, 160));
}
#[test]
fn original_guns_use_banked_muzzles_and_accelerate_after_position_snapshot() {
let reg = fixture();
@@ -184,3 +214,553 @@ fn consumable_use_has_snapshot_and_only_fires_on_press() {
assert_eq!(world.player.credits, 14);
assert_eq!(world.player.selected, None);
}
fn loss_fixture() -> Registry {
let mut registry = fixture();
registry.apply(serde_json::from_value(json!({"items":[
{"id":"base:dumb_missile","name":"Dumb Missile","price":0,"special":true,"legacy":3},
{"id":"base:mini_gun","name":"Mini Gun","price":0,"special":true,"legacy":4},
{"id":"base:death_ray","name":"Death Ray","price":0,"special":true,"legacy":14},
{"id":"base:detect","name":"Detector","price":0,"legacy":17},
{"id":"base:itembuy6","name":"Cash Cargo","price":0,"max_count":50,"legacy":23}
]})).unwrap(), "base").unwrap();
registry
}
fn low_shield_world(registry: &Registry) -> (World, ScriptRuntime) {
let mut pilot = Pilot {
difficulty: 3,
..Pilot::default()
};
for id in [
"base:dumb_missile",
"base:mini_gun",
"base:death_ray",
"base:detect",
] {
pilot.inventory.insert(id.into(), 1);
}
pilot.inventory.insert("base:itembuy6".into(), 50);
let mut world = World::new(&pilot.mission, &pilot, registry).unwrap();
let mut scripts = ScriptRuntime::new();
world
.step(Input::default(), registry, &mut scripts)
.unwrap();
(world, scripts)
}
#[test]
fn low_shield_damage_loses_selected_weapon_then_cycles_in_legacy_order() {
let registry = loss_fixture();
let (mut world, mut scripts) = low_shield_world(&registry);
world.player.selected = Some("base:death_ray".into());
world
.apply_commands(
&[Command::Damage {
entity: 0,
amount: 92,
}],
&registry,
)
.unwrap();
let seed = world.rng.seed;
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert_eq!(world.player.shield, 8);
assert!(
!world
.player
.inventory
.contains_key(&"base:death_ray".into())
);
assert_eq!(world.player.selected, Some("base:dumb_missile".into()));
assert_eq!(
world.player.inventory[&ContentId::from("base:itembuy6")],
50
);
assert!(world.sounds.contains(&119)); // FX_CRASH has no random pitch.
assert_eq!(world.rng.seed, seed);
world
.apply_commands(
&[Command::Damage {
entity: 0,
amount: 1,
}],
&registry,
)
.unwrap();
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert!(
!world
.player
.inventory
.contains_key(&"base:dumb_missile".into())
);
assert_eq!(world.player.selected, Some("base:mini_gun".into()));
}
#[test]
fn low_shield_without_selection_loses_highest_legacy_item_and_protects_essentials() {
let registry = loss_fixture();
let (mut world, mut scripts) = low_shield_world(&registry);
world
.apply_commands(
&[Command::Damage {
entity: 0,
amount: 92,
}],
&registry,
)
.unwrap();
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert!(!world.player.inventory.contains_key(&"base:itembuy6".into()));
for id in [
"base:forward_guns",
"base:energy",
"base:detect",
"base:death_ray",
] {
assert!(
world.player.inventory.contains_key(&id.into()),
"protected or lower-numbered item {id} lost"
);
}
assert_eq!(world.player.selected, None);
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert!(
world
.player
.inventory
.contains_key(&"base:death_ray".into())
);
assert!(!world.sounds.contains(&119)); // Remaining at low shield does not lose another item.
}
#[test]
fn low_energy_with_super_shield_and_damage_above_threshold_preserve_equipment() {
let registry = loss_fixture();
let (mut world, mut scripts) = low_shield_world(&registry);
world.player.selected = Some("base:death_ray".into());
world
.apply_commands(
&[Command::Damage {
entity: 0,
amount: 89,
}],
&registry,
)
.unwrap();
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert_eq!(world.player.shield, 11);
assert!(
world
.player
.inventory
.contains_key(&"base:death_ray".into())
);
world.player.super_shield = 5;
world
.apply_commands(&[Command::Shield { amount: -3 }], &registry)
.unwrap();
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert_eq!(world.player.shield, 8);
assert!(
world
.player
.inventory
.contains_key(&"base:death_ray".into())
);
assert!(!world.sounds.contains(&119));
}
#[test]
fn legacy_kamikaze_arms_chase_when_final_leg_starts_and_crosses_target() {
let mut registry = fixture();
let enemy = registry
.enemies
.get_mut(&ContentId::from("base:drone"))
.unwrap();
enemy.health = 10000;
enemy.speed = 4;
enemy.path = vec![[0, -114], [0, -96]];
let mut fields = vec![0; 26];
fields[7] = 1;
fields[9] = 1;
fields[18] = 4;
fields[19] = 2;
fields[21] = 2; // Original F_KAMI.
enemy.legacy = Some(raptor_replica::assets::LegacyEnemy {
name: "Synthetic Kamikaze".into(),
fields,
shoot_type: vec![0; 24],
engine_x: vec![0; 24],
engine_y: vec![0; 24],
engine_width: vec![0; 24],
gun_x: vec![0; 24],
gun_y: vec![0; 24],
flight_x: vec![0; 32],
flight_y: vec![0; 32],
});
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), &registry).unwrap();
let entity = world
.spawn(&"base:drone".into(), 48, -131, &registry)
.unwrap();
let mut scripts = ScriptRuntime::new();
let mut armed = false;
let mut crossed = false;
for _ in 0..120 {
world
.step(
Input {
absolute: Some([96, 136]),
..Input::default()
},
&registry,
&mut scripts,
)
.unwrap();
let Some(enemy) = world.enemies.iter().find(|e| e.entity == entity) else {
break;
};
if enemy.kami == 1 {
armed = true;
assert_eq!(enemy.movement.ty, -12);
assert!(!enemy.movement.done);
assert_eq!(enemy.path_index, 1);
}
if enemy.kami == 2 {
assert_eq!((enemy.movement.tx, enemy.movement.ty), (112, 152));
crossed |= enemy.movement.y > 152;
}
}
assert!(armed, "last authored leg never armed chase");
assert!(crossed, "terminal chase stopped at its target");
assert!(
!world.enemies.iter().any(|e| e.entity == entity),
"terminal kamikaze failed to retire offscreen"
);
}
#[test]
fn legacy_beam_hits_launch_column_before_following_player_for_display() {
let mut registry = fixture();
registry.apply(serde_json::from_value(json!({
"weapons":[{"id":"base:projectile_death_ray","name":"Death Ray","damage":6,"speed":0,"cooldown":7,"behavior":"beam","target":"all","legacy":14,"art":{"asset":50,"logical_width":2,"logical_height":8,"frames":4}}],
"items":[{"id":"base:death_ray","name":"Death Ray","price":0,"special":true,"legacy":14,"weapon":"base:projectile_death_ray"}]
})).unwrap(), "base").unwrap();
let pilot = Pilot {
selected: Some("base:death_ray".into()),
inventory: [("base:death_ray".into(), 1), ("base:energy".into(), 100)]
.into_iter()
.collect(),
..Pilot::default()
};
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
world
.spawn(&"base:drone".into(), 157, 40, &registry)
.unwrap();
let mut scripts = ScriptRuntime::new();
world
.step(
Input {
fire: true,
..Input::default()
},
&registry,
&mut scripts,
)
.unwrap();
assert_eq!(world.enemies[0].health, 14);
world
.step(
Input {
absolute: Some([141, 160]),
..Input::default()
},
&registry,
&mut scripts,
)
.unwrap();
assert_eq!(world.projectiles[0].x, 156); // Followed artwork is outside the enemy.
assert_eq!(world.enemies[0].health, 8); // Damage still tests launch column159.
}
fn collision_fixture() -> Registry {
let mut registry = fixture();
registry.apply(serde_json::from_value(json!({"weapons":[
{"id":"base:projectile_energy_grab","name":"Energy Grab","damage":3,"speed":4,"cooldown":2,"behavior":"straight","target":"air","legacy":10,"art":{"asset":51,"logical_width":8,"logical_height":8,"frames":6}},
{"id":"base:projectile_ground_bomb","name":"Ground Bomb","damage":50,"speed":1,"cooldown":30,"behavior":"straight","target":"ground","legacy":9,"art":{"asset":52,"logical_width":8,"logical_height":8}},
{"id":"base:projectile_mega_bomb","name":"Mega Bomb","damage":50,"speed":2,"cooldown":60,"behavior":"straight","target":"all","legacy":11,"art":{"asset":53,"logical_width":8,"logical_height":8,"frames":4}},
{"id":"base:hostile","name":"Hostile","damage":2,"speed":2,"cooldown":1,"behavior":"straight","target":"all"}
],"enemies":[
{"id":"base:ground","name":"Ground Target","health":30,"ground":true},
{"id":"base:air","name":"Air Target","health":64,"speed":0,"path":[[0,200]],"weapon":"base:hostile"}
]})).unwrap(), "base").unwrap();
let air = registry
.enemies
.get_mut(&ContentId::from("base:air"))
.unwrap();
let mut fields = vec![0; 26];
fields[5] = 1; // Native suck susceptibility.
fields[7] = 1;
fields[9] = 1;
fields[13] = 64;
fields[16] = 1;
fields[21] = 3;
fields[22] = 1;
air.legacy = Some(raptor_replica::assets::LegacyEnemy {
name: "Synthetic Susceptible Air Target".into(),
fields,
shoot_type: vec![1; 24],
engine_x: vec![0; 24],
engine_y: vec![0; 24],
engine_width: vec![0; 24],
gun_x: vec![16; 24],
gun_y: vec![16; 24],
flight_x: vec![0; 32],
flight_y: vec![0; 32],
});
registry
}
fn inject_legacy_shot(
world: &mut World,
id: &str,
x: i32,
y: i32,
done: bool,
registry: &Registry,
) -> u64 {
let weapon = &registry.weapons[&ContentId::from(id)];
let art = weapon.art.as_ref().unwrap();
let mut movement = LineMove::new(
x + art.logical_width as i32 / 2,
y + art.logical_height as i32 / 2,
x,
0,
);
movement.done = done;
let entity = world.next_entity;
world.next_entity += 1;
world.projectiles.push(Projectile {
entity,
weapon: id.into(),
x,
y,
px: x,
py: y,
vx: 0,
vy: 0,
hostile: false,
age: 0,
legacy_enemy: None,
legacy: Some(LegacyProjectile {
movement,
speed: weapon.speed,
frame: 0,
art_frame: 0,
delay: false,
hit: false,
start: [world.player.x + 16, world.player.y + 16],
}),
});
entity
}
#[test]
fn energy_grab_hits_air_by_one_and_suppresses_fire_after_resistance_is_exhausted() {
let registry = collision_fixture();
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), &registry).unwrap();
world
.spawn(&"base:ground".into(), 64, 64, &registry)
.unwrap();
let entity = world.spawn(&"base:air".into(), 64, 64, &registry).unwrap();
let mut scripts = ScriptRuntime::new();
for (expected_health, expected_resistance, disabled) in
[(63, 1, false), (62, -2, false), (61, -2, true)]
{
inject_legacy_shot(
&mut world,
"base:projectile_energy_grab",
80,
80,
false,
&registry,
);
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
let air = world.enemies.iter().find(|e| e.entity == entity).unwrap();
assert_eq!(air.health, expected_health);
assert_eq!(air.energy_resistance, expected_resistance);
assert_eq!(air.shoot_disabled, disabled);
assert_eq!(world.enemies[0].health, 30); // Ground target overlaps the shot.
}
assert!(world.sounds.contains(&134)); // FX_EGRAB.
let hostile = world.projectiles.iter().filter(|p| p.hostile).count();
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert_eq!(
world.projectiles.iter().filter(|p| p.hostile).count(),
hostile
);
assert!(
world
.enemies
.iter()
.find(|e| e.entity == entity)
.unwrap()
.shoot_disabled
);
}
#[test]
fn energy_grab_does_not_disable_an_immune_air_enemy() {
let mut registry = collision_fixture();
registry
.enemies
.get_mut(&"base:air".into())
.unwrap()
.legacy
.as_mut()
.unwrap()
.fields[5] = 0;
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), &registry).unwrap();
world.spawn(&"base:air".into(), 64, 64, &registry).unwrap();
let mut scripts = ScriptRuntime::new();
for _ in 0..5 {
inject_legacy_shot(
&mut world,
"base:projectile_energy_grab",
80,
80,
false,
&registry,
);
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
}
assert_eq!(world.enemies[0].health, 59);
assert_eq!(world.enemies[0].energy_resistance, 4);
assert!(!world.enemies[0].shoot_disabled);
}
fn terrain_fixture() -> Registry {
let mut registry = collision_fixture();
let pack = serde_json::from_value(json!({"missions":[{
"id":"base:sector_1_wave_1","name":"Terrain Fixture","duration":1000,"auto_complete":false,"legacy_map":123,"columns":3,"layout":vec![0;30],
"tiles":[
{"art":{"asset":60,"logical_width":32,"logical_height":32},"health":200,"destroyed":{"asset":61,"logical_width":32,"logical_height":32}},
{"art":{"asset":62,"logical_width":32,"logical_height":32},"health":200}
]
}],"overrides":["base:sector_1_wave_1"]})).unwrap();
registry.apply(pack, "base").unwrap();
registry
}
#[test]
fn ground_bomb_damages_tile_neighbors_and_overlapping_ground_enemy_together() {
let registry = terrain_fixture();
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), &registry).unwrap();
world
.spawn(&"base:ground".into(), 48, 73, &registry)
.unwrap();
let shot = inject_legacy_shot(
&mut world,
"base:projectile_ground_bomb",
64,
89,
false,
&registry,
);
let mut scripts = ScriptRuntime::new();
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert_eq!(world.enemies[0].health, 25);
assert!(!world.projectiles.iter().any(|p| p.entity == shot));
for position in [19, 18, 16, 20] {
assert_eq!(
world.terrain_health[position], 150,
"primary neighbor {position}"
);
}
for position in [15, 13, 17] {
assert_eq!(
world.terrain_health[position], 175,
"upper neighbor {position}"
);
}
assert_eq!(world.terrain_health[22], 200);
assert_eq!(world.terrain_health[21], 200);
}
#[test]
fn mega_bomb_uses_visible_destructible_tiles_at_fixed_twenty_damage_and_clears_shots() {
let mut registry = terrain_fixture();
registry
.missions
.get_mut(&Pilot::default().mission)
.unwrap()
.layout[8] = 1;
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), &registry).unwrap();
world.terrain_dead[7] = true;
world
.spawn(&"base:ground".into(), 64, 64, &registry)
.unwrap();
let hostile = world.next_entity;
world.next_entity += 1;
world.projectiles.push(Projectile {
entity: hostile,
weapon: "base:hostile".into(),
x: 100,
y: 100,
px: 100,
py: 100,
vx: 0,
vy: 1,
hostile: true,
age: 0,
legacy: None,
legacy_enemy: None,
});
inject_legacy_shot(
&mut world,
"base:projectile_mega_bomb",
100,
100,
true,
&registry,
);
let mut scripts = ScriptRuntime::new();
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert_eq!(world.enemies[0].health, -20); // Original enemy damage50, separate from tiles.
assert!(world.projectiles.is_empty());
for position in 0..30 {
let expected = if position < 6 || position == 7 || position == 8 {
200
} else {
180
};
assert_eq!(
world.terrain_health[position], expected,
"terrain position{position}"
);
}
}
+48 -2
View File
@@ -24,11 +24,15 @@ const MISSION: &str = "iron_coast_mission_1:mission_01";
fn base() -> Registry {
let mut registry = Registry::default();
let pack:ContentPack=serde_json::from_value(json!({
let mut value = json!({
"weapons":[{"id":"base:projectile_forward_guns","name":"Forward Guns","damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all","legacy":0,"art":{"asset":42,"logical_width":4,"logical_height":8,"frames":4}}],
"items":[{"id":"base:forward_guns","name":"Forward Guns","price":0,"max_count":1,"weapon":"base:projectile_forward_guns","legacy":0},{"id":"base:energy","name":"Shield","price":0,"max_count":100,"legacy":16}],
"missions":[{"id":"base:sector_1_wave_1","name":"Base fixture","duration":1000}]
})).unwrap();
});
for n in 0..18 {
value["items"].as_array_mut().unwrap().push(json!({"id":format!("base:fixture_item_{n}"),"name":"Synthetic equipment","price":0,"max_count":1}));
}
let pack: ContentPack = serde_json::from_value(value).unwrap();
registry.apply(pack, "base").unwrap();
registry.validate().unwrap();
registry
@@ -89,6 +93,7 @@ fn fixture_resources(value: &Value, root: &Path) {
}
fn active() -> (tempfile::TempDir, ActiveMods) {
let started = std::time::Instant::now();
let temporary = tempfile::tempdir().unwrap();
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/mods");
for id in PACKAGES {
@@ -114,6 +119,10 @@ fn active() -> (tempfile::TempDir, ActiveMods) {
assert!(discovery.errors.is_empty(), "{:?}", discovery.errors);
let enabled = PACKAGES.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let active = ActiveMods::build(&base(), &discovery, &enabled, None).unwrap();
eprintln!(
"Iron Coast synthetic seven-package activation: {:.3}s",
started.elapsed().as_secs_f64()
);
(temporary, active)
}
@@ -210,9 +219,46 @@ fn play(use_trial: bool) -> (World, Vec<(u64, String)>) {
let recovered: serde_json::Value = serde_json::from_str(&serialized).unwrap();
assert_eq!(recovered["pulse_cannon/scripts/weapon.rhai"], *weapon_state);
}
eprintln!(
"Iron Coast {} run: tick={} outcome={:?} shield={} credits={} repairs_remaining={}",
if use_trial {
"trial Pulse"
} else {
"starter guns"
},
world.tick,
world.outcome,
world.player.shield,
world.player.credits,
world
.player
.inventory
.get(&"iron_coast_mission_1:repair".into())
.copied()
.unwrap_or(0)
);
(world, seen)
}
#[test]
fn full_inventory_can_start_without_deleting_saved_equipment() {
let (_temporary, mut active) = active();
let mut pilot = Pilot::default();
for n in 0..18 {
pilot
.inventory
.insert(format!("base:fixture_item_{n}").as_str().into(), 1);
}
assert_eq!(pilot.inventory.len(), 20);
let mut world = World::new(&MISSION.into(), &pilot, &active.registry).unwrap();
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(world.player.inventory, pilot.inventory);
assert_eq!(world.objectives["iron_coast_mission_1:trial_issued"], 0);
assert_eq!(world.outcome, Outcome::Playing);
}
#[test]
fn restarted_mission_counts_new_turret_kills_independently_of_saved_statistics() {
let (_temporary, mut active) = active();
+31
View File
@@ -501,6 +501,37 @@ fn missing_hook_does_not_advance_rng_or_state() {
assert!(runtime.rng.is_empty());
}
#[test]
fn saves_allow_added_mods_while_preserving_required_versions_and_order() {
let registry = base();
let pin = |id: &str| modding::ModPin {
id: id.into(),
version: "1.0.0".into(),
hash: format!("{id}-content"),
};
let a = pin("alpha");
let b = pin("beta");
let c = pin("extra");
let mut pilot = Pilot::default();
assert!(pilot.validate(&registry, &[a.clone(), b.clone()]).is_ok());
pilot.mods = vec![a.clone(), b.clone()];
pilot
.script_state
.insert("alpha/ai.rhai".into(), json!({"kills": 17}));
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pilot.json");
pilot.save(&path).unwrap();
let before = fs::read(&path).unwrap();
let loaded = Pilot::load(&path, &registry, &[a.clone(), c, b.clone()]).unwrap();
assert_eq!(loaded.script_state, pilot.script_state);
assert!(Pilot::load(&path, &registry, &[b.clone(), a.clone()]).is_err());
assert!(Pilot::load(&path, &registry, &[a.clone()]).is_err());
let mut changed = b;
changed.version = "2.0.0".into();
assert!(Pilot::load(&path, &registry, &[a, changed]).is_err());
assert_eq!(fs::read(path).unwrap(), before);
}
#[test]
fn incompatible_save_pins_and_disabled_equipment_do_not_modify_the_save() {
let temp = tempfile::tempdir().unwrap();
+221
View File
@@ -0,0 +1,221 @@
use raptor_replica::{
content::{ContentPack, Registry},
modding::{Command, ScriptRuntime},
persistence::Pilot,
simulation::{EffectKind, Input, LegacyRng, Outcome, Projectile, World},
};
use serde_json::json;
fn fixture(legacy: bool, automatic: bool) -> (Registry, World, ScriptRuntime) {
let pack: ContentPack = serde_json::from_value(json!({
"weapons":[{"id":"base:shot","name":"Shot","damage":8,"speed":0,"cooldown":1,"behavior":"straight","target":"all"}],
"items":[{"id":"base:forward_guns","name":"Guns","price":0,"weapon":"base:shot","legacy":0},{"id":"base:energy","name":"Shield","price":0,"max_count":100,"legacy":16}],
"enemies":[{"id":"base:target","name":"Final Target","health":1,"ground":true,"reward":10}],
"missions":[{"id":"base:sector_1_wave_1","name":"Transition Fixture","duration":1,"auto_complete":automatic,"legacy_map":if legacy {Some(123)} else {None}}]
})).unwrap();
let mut registry = Registry::default();
registry.apply(pack, "base").unwrap();
let world = World::new(&Pilot::default().mission, &Pilot::default(), &registry).unwrap();
(registry, world, ScriptRuntime::new())
}
fn after_calls(mut rng: LegacyRng, calls: usize) -> i32 {
for _ in 0..calls {
rng.next_value();
}
rng.seed
}
#[test]
fn original_departure_pauses_at_forty_and_flies_off_before_completion() {
let (registry, mut world, mut scripts) = fixture(true, false);
world.end_timer = Some(60);
for step in 1..=20 {
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert_eq!(world.end_timer, Some(60 - step));
assert_eq!(world.outcome, Outcome::Playing);
assert!(world.draw_player);
assert_eq!((world.player.x, world.player.y), (144, 160));
assert_eq!(world.control_paused, step == 20);
assert_eq!(world.sounds.contains(&129), step == 20); // FX_FLYBY.
}
world
.step(
Input {
absolute: Some([280, 0]),
fire: true,
..Input::default()
},
&registry,
&mut scripts,
)
.unwrap();
assert_eq!((world.player.x, world.player.y), (152, 156));
assert!(world.projectiles.is_empty()); // Paused input cannot fire.
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert_eq!((world.player.x, world.player.y), (160, 148)); // Input+stats movement.
while world.outcome == Outcome::Playing {
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
}
assert_eq!(world.tick, 61);
assert_eq!(world.outcome, Outcome::Complete);
assert_eq!((world.player.x, world.player.y), (160, -160));
assert!(world.draw_player);
}
#[test]
fn recorded_departure_positions_are_not_moved_a_second_time() {
let (registry, mut world, mut scripts) = fixture(true, false);
world.end_timer = Some(39);
world.control_paused = true;
world.departure_delta = [8, -4];
world
.step(
Input {
absolute: Some([155, -8]),
demo_bank: Some(2),
..Input::default()
},
&registry,
&mut scripts,
)
.unwrap();
assert_eq!(
(world.player.x, world.player.y, world.player.bank),
(155, -8, 2)
);
assert_eq!(world.end_timer, Some(38));
}
#[test]
fn final_enemy_removal_arms_immunity_before_hostile_shot_updates() {
let (registry, mut world, mut scripts) = fixture(true, true);
let entity = world
.spawn(&"base:target".into(), 20, 20, &registry)
.unwrap();
world
.apply_commands(&[Command::Damage { entity, amount: 1 }], &registry)
.unwrap();
world.projectiles.push(Projectile {
entity: world.next_entity,
weapon: "base:shot".into(),
x: 160,
y: 176,
px: 160,
py: 176,
vx: 0,
vy: 0,
hostile: true,
age: 0,
legacy: None,
legacy_enemy: None,
});
world.next_entity += 1;
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert_eq!(world.end_timer, Some(60));
assert_eq!(world.player.shield, 100);
assert_eq!(world.player.credits, 10);
assert_eq!(world.outcome, Outcome::Playing);
assert!(world.projectiles.is_empty());
let seed = world.rng.seed;
world
.apply_commands(
&[Command::Damage {
entity: 0,
amount: 500,
}],
&registry,
)
.unwrap();
assert_eq!(world.player.shield, 100);
assert_eq!(world.rng.seed, seed);
}
#[test]
fn original_death_delays_failure_hides_ship_at_twenty_four_and_consumes_full_burst_rng() {
let (registry, mut world, mut scripts) = fixture(true, false);
world.player.shield = 0;
let seed = world.rng;
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert_eq!(world.tick, 1);
assert_eq!(world.end_timer, Some(60));
assert_eq!(world.outcome, Outcome::Playing);
assert!(world.draw_player);
assert_eq!(world.rng.seed, after_calls(seed, 4)); // Two x/y positions, no early sound.
assert_eq!(world.effects.len(), 2);
assert_eq!(world.effects[0].kind, EffectKind::MediumAir);
assert_eq!(world.effects[1].kind, EffectKind::SmallAir);
let mut positions = seed;
let y = 160 + positions.next_value() % 32;
let x = 144 + positions.next_value() % 32;
assert_eq!((world.effects[0].x, world.effects[0].y), (x, y));
let seed = world.rng;
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert_eq!(world.rng.seed, after_calls(seed, 6)); // Two positions, sound pan and pitch.
assert!(
world
.sound_events
.iter()
.any(|s| s.asset == 104 && matches!(s.pan, 30 | 225))
);
while world.end_timer != Some(25) {
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert!(world.draw_player);
}
let seed = world.rng;
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert_eq!(world.end_timer, Some(24));
assert!(!world.draw_player);
assert_eq!(world.effects.len(), 100); // Original pool cap, despite512requests.
assert_eq!(world.rng.seed, after_calls(seed, 1032)); // Palette1,positions4,pitches3,burst1024.
assert_eq!(world.sounds.iter().filter(|s| **s == 104).count(), 1);
assert_eq!(world.sounds.iter().filter(|s| **s == 109).count(), 2);
while world.outcome == Outcome::Playing {
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert!(world.tick <= 62);
}
assert_eq!(world.tick, 62);
assert_eq!(world.outcome, Outcome::Failed);
assert!(!world.draw_player);
assert!(!world.control_paused);
let stopped = world.digest();
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert_eq!(world.digest(), stopped);
}
#[test]
fn authored_mod_missions_keep_their_immediate_outcome_contract() {
let (registry, mut world, mut scripts) = fixture(false, true);
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert_eq!(world.outcome, Outcome::Complete);
assert_eq!(world.end_timer, None);
let (registry, mut world, mut scripts) = fixture(false, false);
world.player.shield = 0;
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert_eq!(world.outcome, Outcome::Failed);
assert_eq!(world.end_timer, None);
}
+852
View File
@@ -0,0 +1,852 @@
//! 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 = &registry.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();
}