Match full reference demo and validate original missions, transitions and sprite rendering
This commit is contained in:
1 parent
967ba69ee8
commit
0844f6dffa
21 files changed
+3984
-219
No files matched your search
@@ -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(®istry, 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(®istry).unwrap();
|
||||
pilot.validate(®istry, &[]).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, ®istry).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()
|
||||
},
|
||||
®istry,
|
||||
&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, ®istry).unwrap();
|
||||
pilot.mission = format!("base:sector_{}_wave_{}", sector + 1, wave + 1)
|
||||
.as_str()
|
||||
.into();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).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()
|
||||
},
|
||||
®istry,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
world.reference_state(®istry),
|
||||
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);
|
||||
}
|
||||
@@ -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 = ®istry.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(®istry, &[]).unwrap();
|
||||
let mut world = World::new(&MISSION.into(), &pilot, ®istry).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, ®istry);
|
||||
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, ®istry, &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(®istry));
|
||||
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 = ®istry.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, ®istry).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()
|
||||
},
|
||||
®istry,
|
||||
&mut replay_scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
replay_world.reference_state(®istry),
|
||||
*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, ®istry).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()
|
||||
},
|
||||
®istry,
|
||||
&mut keyboard_scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
keyboard_world.reference_state(®istry),
|
||||
expected,
|
||||
"keyboard recording diverged at step {}",
|
||||
keyboard_world.tick
|
||||
);
|
||||
}
|
||||
assert_eq!(keyboard_world.outcome, Outcome::Complete);
|
||||
}
|
||||
}
|
||||
+581
-1
@@ -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(), ®).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
absolute: Some([284, 162]),
|
||||
demo_bank: Some(3),
|
||||
..Input::default()
|
||||
},
|
||||
®,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((world.player.x, world.player.y), (284, 162));
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
absolute: Some([284, 162]),
|
||||
..Input::default()
|
||||
},
|
||||
®,
|
||||
&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(®istry);
|
||||
world.player.selected = Some("base:death_ray".into());
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity: 0,
|
||||
amount: 92,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
let seed = world.rng.seed;
|
||||
world
|
||||
.step(Input::default(), ®istry, &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,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &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(®istry);
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity: 0,
|
||||
amount: 92,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &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(), ®istry, &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(®istry);
|
||||
world.player.selected = Some("base:death_ray".into());
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity: 0,
|
||||
amount: 89,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &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 }], ®istry)
|
||||
.unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &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(), ®istry).unwrap();
|
||||
let entity = world
|
||||
.spawn(&"base:drone".into(), 48, -131, ®istry)
|
||||
.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()
|
||||
},
|
||||
®istry,
|
||||
&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, ®istry).unwrap();
|
||||
world
|
||||
.spawn(&"base:drone".into(), 157, 40, ®istry)
|
||||
.unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.enemies[0].health, 14);
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
absolute: Some([141, 160]),
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&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 = ®istry.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(), ®istry).unwrap();
|
||||
world
|
||||
.spawn(&"base:ground".into(), 64, 64, ®istry)
|
||||
.unwrap();
|
||||
let entity = world.spawn(&"base:air".into(), 64, 64, ®istry).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,
|
||||
®istry,
|
||||
);
|
||||
world
|
||||
.step(Input::default(), ®istry, &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(), ®istry, &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(), ®istry).unwrap();
|
||||
world.spawn(&"base:air".into(), 64, 64, ®istry).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
for _ in 0..5 {
|
||||
inject_legacy_shot(
|
||||
&mut world,
|
||||
"base:projectile_energy_grab",
|
||||
80,
|
||||
80,
|
||||
false,
|
||||
®istry,
|
||||
);
|
||||
world
|
||||
.step(Input::default(), ®istry, &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(), ®istry).unwrap();
|
||||
world
|
||||
.spawn(&"base:ground".into(), 48, 73, ®istry)
|
||||
.unwrap();
|
||||
let shot = inject_legacy_shot(
|
||||
&mut world,
|
||||
"base:projectile_ground_bomb",
|
||||
64,
|
||||
89,
|
||||
false,
|
||||
®istry,
|
||||
);
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
world
|
||||
.step(Input::default(), ®istry, &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(), ®istry).unwrap();
|
||||
world.terrain_dead[7] = true;
|
||||
world
|
||||
.spawn(&"base:ground".into(), 64, 64, ®istry)
|
||||
.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,
|
||||
®istry,
|
||||
);
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
world
|
||||
.step(Input::default(), ®istry, &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
@@ -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();
|
||||
|
||||
@@ -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(®istry, &[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, ®istry, &[a.clone(), c, b.clone()]).unwrap();
|
||||
assert_eq!(loaded.script_state, pilot.script_state);
|
||||
assert!(Pilot::load(&path, ®istry, &[b.clone(), a.clone()]).is_err());
|
||||
assert!(Pilot::load(&path, ®istry, &[a.clone()]).is_err());
|
||||
let mut changed = b;
|
||||
changed.version = "2.0.0".into();
|
||||
assert!(Pilot::load(&path, ®istry, &[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();
|
||||
|
||||
@@ -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(), ®istry).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(), ®istry, &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()
|
||||
},
|
||||
®istry,
|
||||
&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(), ®istry, &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(), ®istry, &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()
|
||||
},
|
||||
®istry,
|
||||
&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, ®istry)
|
||||
.unwrap();
|
||||
world
|
||||
.apply_commands(&[Command::Damage { entity, amount: 1 }], ®istry)
|
||||
.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(), ®istry, &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,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.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(), ®istry, &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(), ®istry, &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(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert!(world.draw_player);
|
||||
}
|
||||
let seed = world.rng;
|
||||
world
|
||||
.step(Input::default(), ®istry, &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(), ®istry, &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(), ®istry, &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(), ®istry, &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(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.outcome, Outcome::Failed);
|
||||
assert_eq!(world.end_timer, None);
|
||||
}
|
||||
@@ -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 = ®istry.missions[&"base:sector_1_wave_1".into()];
|
||||
let columns = mission.columns as usize;
|
||||
let rows = mission.layout.len() / columns;
|
||||
let selected = (0..rows - 2)
|
||||
.find(|row| {
|
||||
let labels: BTreeSet<_> = (0..3)
|
||||
.flat_map(|y| (0..3).map(move |x| mission.layout[(row + y) * columns + x + 3]))
|
||||
.collect();
|
||||
labels.len() > 2
|
||||
})
|
||||
.unwrap();
|
||||
let mut joined = Canvas::new(1);
|
||||
joined.image = Sprite {
|
||||
width: 96,
|
||||
height: 96,
|
||||
pixels: vec![0; 96 * 96],
|
||||
};
|
||||
let mut tiles = vec![];
|
||||
for row in 0..3 {
|
||||
for column in 0..3 {
|
||||
let index = mission.layout[(selected + row) * columns + column + 3];
|
||||
let sprite = catalog
|
||||
.sprite(mission.tiles[index].art.asset.unwrap())
|
||||
.unwrap();
|
||||
assert_eq!((sprite.width, sprite.height), (32, 32));
|
||||
joined.blit(&sprite, (column * 32) as i32, (row * 32) as i32);
|
||||
tiles.push(sprite);
|
||||
}
|
||||
}
|
||||
let mut seam_counts = vec![];
|
||||
for factor in [2, 3, 4] {
|
||||
let filtered = upscale(&joined.image, factor).unwrap();
|
||||
assert_eq!(filtered, oracle(&joined.image, factor));
|
||||
let gutter = crop(&joined.image, 30, 30, 36, 36);
|
||||
assert_eq!(
|
||||
crop(
|
||||
&filtered,
|
||||
32 * factor,
|
||||
32 * factor,
|
||||
32 * factor,
|
||||
32 * factor
|
||||
),
|
||||
crop(
|
||||
&upscale(&gutter, factor).unwrap(),
|
||||
2 * factor,
|
||||
2 * factor,
|
||||
32 * factor,
|
||||
32 * factor
|
||||
)
|
||||
);
|
||||
let mut tiled = Canvas::new(1);
|
||||
tiled.image = Sprite::blank(96 * factor, 96 * factor);
|
||||
for (index, tile) in tiles.iter().enumerate() {
|
||||
tiled.blit(
|
||||
&upscale(tile, factor).unwrap(),
|
||||
((index % 3) * 32 * factor) as i32,
|
||||
((index / 3) * 32 * factor) as i32,
|
||||
);
|
||||
}
|
||||
let differences = filtered
|
||||
.pixels
|
||||
.iter()
|
||||
.zip(&tiled.image.pixels)
|
||||
.filter(|(a, b)| a != b)
|
||||
.count();
|
||||
seam_counts
|
||||
.push(serde_json::json!({"factor":factor,"independent_tile_differences":differences}));
|
||||
png(
|
||||
&filtered,
|
||||
&palette,
|
||||
&output.join(format!("terrain-joined-{factor}x.png")),
|
||||
);
|
||||
png(
|
||||
&tiled.image,
|
||||
&palette,
|
||||
&output.join(format!("terrain-independent-{factor}x.png")),
|
||||
);
|
||||
}
|
||||
fs::write(output.join("report.json"),serde_json::to_vec_pretty(&serde_json::json!({"sequences":report,"terrain_source_row":selected,"terrain_differences":seam_counts})).unwrap()).unwrap();
|
||||
}
|
||||
Reference in new issue
Block a user