8567 lines
331 KiB
Rust
8567 lines
331 KiB
Rust
//! SDL host, interpolated scene presentation and local game menus.
|
|
use crate::{
|
|
assets::{Catalog, Sprite},
|
|
audio::{Mixer, MusScore},
|
|
cinematics::{Cinematic, CinematicAudioEvent, Kind as MovieKind},
|
|
content::{Art, ContentId, Registry, art_sprite_variant},
|
|
hd::HdAssets,
|
|
modding::{self, ActiveMods, Discovery},
|
|
persistence::{Config, Paths, Pilot, PilotLoad},
|
|
render::{
|
|
BitmapFont, Canvas, SpriteCache, legacy_light_table, nearest, prefers_nearest,
|
|
shade_air_shadow, shade_ground_shadow, upscale,
|
|
},
|
|
simulation::{Clock, Input, Outcome, World},
|
|
terrain_hd::{
|
|
OverlayResult, Replacement as TerrainReplacement, TerrainHd, logical_sprite, selected_art,
|
|
},
|
|
};
|
|
use anyhow::{Context, Result, anyhow};
|
|
use sdl2::{
|
|
audio::{AudioCallback, AudioDevice, AudioSpecDesired},
|
|
controller::{Axis, Button},
|
|
event::Event,
|
|
keyboard::{Keycode, Scancode},
|
|
pixels::PixelFormatEnum,
|
|
rect::Rect,
|
|
video::FullscreenType,
|
|
};
|
|
use std::{
|
|
collections::BTreeMap,
|
|
path::PathBuf,
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
const MENU_WIDTH: usize = 400;
|
|
const MENU_HEIGHT: usize = 225;
|
|
const MENU_OFFSET: [i32; 2] = [40, 12];
|
|
const MAIN_MENU_X: i32 = 248;
|
|
const MAIN_MENU_Y: i32 = 62;
|
|
|
|
impl AudioCallback for Mixer {
|
|
type Channel = i16;
|
|
fn callback(&mut self, out: &mut [i16]) {
|
|
self.mix(out)
|
|
}
|
|
}
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum Screen {
|
|
Main,
|
|
Hangar,
|
|
Extras,
|
|
PilotForm,
|
|
Missions,
|
|
Shop,
|
|
ShopDetails,
|
|
Inventory,
|
|
Mods,
|
|
Options,
|
|
Game,
|
|
Pause,
|
|
Results,
|
|
Help,
|
|
Credits,
|
|
Pilots,
|
|
LegacyPilots,
|
|
ConfirmDisable,
|
|
ConfirmLoadMods,
|
|
Diagnostics,
|
|
Bindings,
|
|
Cinematic,
|
|
}
|
|
struct PendingPilotLoad {
|
|
loaded: PilotLoad,
|
|
slot: usize,
|
|
return_to: Screen,
|
|
}
|
|
struct PilotForm {
|
|
name: String,
|
|
callsign: String,
|
|
portrait: usize,
|
|
original_portrait: Option<i32>,
|
|
difficulty: u32,
|
|
creating: bool,
|
|
return_to: Screen,
|
|
field: Option<usize>,
|
|
replace_on_input: bool,
|
|
}
|
|
/// Display latches advance with simulation steps, including frames skipped by
|
|
/// the renderer. They never change gameplay state or consume the game's RNG.
|
|
struct HudState {
|
|
tick: u64,
|
|
shield: i32,
|
|
blink: bool,
|
|
damage_flashes: u8,
|
|
sweep: u8,
|
|
}
|
|
impl HudState {
|
|
fn new(world: &World) -> Self {
|
|
Self {
|
|
tick: world.tick,
|
|
shield: world.player.shield,
|
|
blink: true,
|
|
damage_flashes: 0,
|
|
sweep: 0,
|
|
}
|
|
}
|
|
fn observe(&mut self, world: &World, registry: &Registry) {
|
|
if self.tick == world.tick {
|
|
return;
|
|
}
|
|
if world.tick < self.tick {
|
|
*self = Self::new(world);
|
|
return;
|
|
}
|
|
if world.player.shield <= 10 {
|
|
if world.tick % 8 == 0 {
|
|
self.blink = !self.blink;
|
|
if self.blink {
|
|
self.damage_flashes = self.damage_flashes.saturating_sub(1);
|
|
}
|
|
}
|
|
if world.player.shield < self.shield && world.player.super_shield < 1 {
|
|
self.damage_flashes = 2;
|
|
}
|
|
}
|
|
if world
|
|
.player
|
|
.inventory
|
|
.get(&ContentId::from("base:detect"))
|
|
.is_some_and(|n| *n > 0)
|
|
&& boss_shield_percent(world, registry) == 0
|
|
{
|
|
self.sweep = (self.sweep + 1) % 50;
|
|
}
|
|
self.shield = world.player.shield;
|
|
self.tick = world.tick;
|
|
}
|
|
}
|
|
fn boss_shield_percent(world: &World, registry: &Registry) -> i32 {
|
|
let mut total = 0_i64;
|
|
let mut count = 0;
|
|
for enemy in &world.enemies {
|
|
let definition = ®istry.enemies[&enemy.definition];
|
|
// ENEMY_GetBaseDamage checks the boss center reaching the screen.
|
|
if definition.boss && enemy.y + enemy.height / 2 >= 0 {
|
|
total += enemy.health.max(0) as i64 * 100 / definition.health.max(1) as i64;
|
|
count += 1;
|
|
}
|
|
}
|
|
if count == 0 {
|
|
0
|
|
} else {
|
|
(total / count).clamp(0, 100) as i32
|
|
}
|
|
}
|
|
fn hud_shield_bar(canvas: &mut Canvas, x: i32, amount: i32) {
|
|
// RAP_DisplayShieldLevel uses a nine-color run with a 16.16 accumulator.
|
|
let increment = (9_u32 << 16) / 100;
|
|
for level in 0..100_u32 {
|
|
let color = if (level as i32) < amount.clamp(0, 100) {
|
|
74 - ((level * increment) >> 16) as u16
|
|
} else {
|
|
0
|
|
};
|
|
canvas.rect(x, 199 - level as i32 * 2, 4, 1, color);
|
|
}
|
|
}
|
|
struct Host {
|
|
catalog: Catalog,
|
|
base: Registry,
|
|
active: ActiveMods,
|
|
paths: Paths,
|
|
mods_root: PathBuf,
|
|
discovery: Discovery,
|
|
config: Config,
|
|
hd: HdAssets,
|
|
hd_loaded: bool,
|
|
read_only: bool,
|
|
pending: Vec<String>,
|
|
pilot: Pilot,
|
|
slot: usize,
|
|
pilot_error: Option<String>,
|
|
pending_load: Option<PendingPilotLoad>,
|
|
mismatch_confirmed: bool,
|
|
mismatch_backup_done: bool,
|
|
menu_art: crate::menu_art::MenuArt,
|
|
queued_cycle: bool,
|
|
queued_bomb: bool,
|
|
awaiting_binding: Option<String>,
|
|
diagnostic_mod: Option<String>,
|
|
diagnostic_return: Screen,
|
|
diagnostic_selection: usize,
|
|
mods_selection: usize,
|
|
pending_disable: Option<crate::mod_selection::DisablePlan>,
|
|
world: Option<World>,
|
|
cinematic: Option<Cinematic>,
|
|
movie_return: Screen,
|
|
screen: Screen,
|
|
previous: Screen,
|
|
selected: usize,
|
|
shop_selling: bool,
|
|
shop_detail_item: Option<ContentId>,
|
|
pilot_form: Option<PilotForm>,
|
|
pilot_slots: Vec<(usize, String)>,
|
|
message: String,
|
|
font: BitmapFont,
|
|
menu_font: BitmapFont,
|
|
cache: SpriteCache,
|
|
terrain_hd: TerrainHd,
|
|
raw: BTreeMap<u32, Sprite>,
|
|
mod_art: BTreeMap<String, Sprite>,
|
|
palette: [[u8; 3]; 256],
|
|
shadow_table: [u8; 256],
|
|
air_shadow_table: [u8; 256],
|
|
light_table: [u8; 256],
|
|
flame_tables: [[u8; 256]; 8],
|
|
hud_state: HudState,
|
|
audio: Option<AudioDevice<Mixer>>,
|
|
sound_bank: crate::sound::SoundBank,
|
|
desired_music: Option<u32>,
|
|
desired_mod_music: Option<ContentId>,
|
|
desired_music_loop: bool,
|
|
startup_intro_pending: bool,
|
|
clock: Clock,
|
|
}
|
|
impl Host {
|
|
fn new(
|
|
catalog: Catalog,
|
|
base: Registry,
|
|
paths: Paths,
|
|
mods_root: PathBuf,
|
|
read_only: bool,
|
|
) -> Result<Self> {
|
|
let config = Config::load(&paths)?;
|
|
let discovery = modding::discover(&mods_root);
|
|
let mut message = String::new();
|
|
let mut active =
|
|
match ActiveMods::build(&base, &discovery, &config.enabled_mods, Some(&catalog)) {
|
|
Ok(a) => a,
|
|
Err(e) => {
|
|
message = format!("Mod load failed: {e:#}");
|
|
ActiveMods::build(&base, &discovery, &[], Some(&catalog))?
|
|
}
|
|
};
|
|
let mut pilot_error = None;
|
|
let mut pending_load = None;
|
|
let mut pilot = if paths.save_path(0).exists() {
|
|
match Pilot::inspect_load(&paths.save_path(0), &active.registry, &active.pins) {
|
|
Ok(loaded) if loaded.compatibility.is_compatible() => loaded.pilot,
|
|
Ok(loaded) => {
|
|
message = "Saved pilot uses different mods. Review before loading".into();
|
|
pilot_error = Some(message.clone());
|
|
pending_load = Some(PendingPilotLoad {
|
|
loaded,
|
|
slot: 0,
|
|
return_to: Screen::Main,
|
|
});
|
|
Pilot::default()
|
|
}
|
|
Err(e) => {
|
|
message = format!("Saved pilot unavailable: {e:#}");
|
|
pilot_error = Some(message.clone());
|
|
Pilot::default()
|
|
}
|
|
}
|
|
} else {
|
|
let legacy = paths.legacy.join("CHAR0000.FIL");
|
|
if legacy.exists() {
|
|
match Pilot::import_legacy(&legacy, &active.registry) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
message = format!("Legacy import failed: {e:#}");
|
|
Pilot::default()
|
|
}
|
|
}
|
|
} else {
|
|
Pilot::default()
|
|
}
|
|
};
|
|
if !paths.save_path(0).exists() {
|
|
pilot.mods = active.pins.clone();
|
|
}
|
|
restore_scripts(&mut active.scripts, &pilot);
|
|
let hd_loaded = config.hd_mode;
|
|
let hd = if hd_loaded {
|
|
HdAssets::load_lazy(
|
|
&catalog.root.join("hd"),
|
|
&catalog,
|
|
Some(&paths.cache.join("hd-art-v1")),
|
|
)
|
|
} else {
|
|
HdAssets::default()
|
|
};
|
|
let palette = catalog.palette()?;
|
|
let vga_palette = palette.map(|color| color.map(|component| component >> 2));
|
|
let shadow_table = legacy_light_table(&vga_palette, -9);
|
|
let air_shadow_table = legacy_light_table(&vga_palette, -6);
|
|
let light_table = legacy_light_table(&vga_palette, 9);
|
|
let flame_tables =
|
|
std::array::from_fn(|shade| legacy_light_table(&vga_palette, (8 - shade as i32) * 2));
|
|
let font = BitmapFont::decode(&catalog.named("SYSTEM_FNT")?)?;
|
|
let menu_font = BitmapFont::decode(
|
|
&catalog
|
|
.named("FONT2_FNT")
|
|
.or_else(|_| catalog.named("SYSTEM_FNT"))?,
|
|
)?;
|
|
let cache = SpriteCache::new(Some(paths.cache.join("sprites")));
|
|
let pending = config.enabled_mods.clone();
|
|
let initial_screen = if pending_load.is_some() {
|
|
Screen::ConfirmLoadMods
|
|
} else {
|
|
Screen::Main
|
|
};
|
|
Ok(Self {
|
|
catalog,
|
|
base,
|
|
active,
|
|
paths,
|
|
mods_root,
|
|
discovery,
|
|
config,
|
|
hd,
|
|
hd_loaded,
|
|
read_only,
|
|
pending,
|
|
pilot,
|
|
slot: 0,
|
|
pilot_error,
|
|
pending_load,
|
|
mismatch_confirmed: false,
|
|
mismatch_backup_done: false,
|
|
menu_art: crate::menu_art::MenuArt::bundled(),
|
|
queued_cycle: false,
|
|
queued_bomb: false,
|
|
awaiting_binding: None,
|
|
diagnostic_mod: None,
|
|
diagnostic_return: Screen::Main,
|
|
diagnostic_selection: 0,
|
|
mods_selection: 0,
|
|
pending_disable: None,
|
|
world: None,
|
|
cinematic: None,
|
|
movie_return: Screen::Main,
|
|
screen: initial_screen,
|
|
previous: Screen::Main,
|
|
selected: 0,
|
|
shop_selling: false,
|
|
shop_detail_item: None,
|
|
pilot_form: None,
|
|
pilot_slots: vec![],
|
|
message,
|
|
font,
|
|
menu_font,
|
|
cache,
|
|
terrain_hd: TerrainHd::default(),
|
|
raw: BTreeMap::new(),
|
|
mod_art: BTreeMap::new(),
|
|
palette,
|
|
shadow_table,
|
|
air_shadow_table,
|
|
light_table,
|
|
flame_tables,
|
|
hud_state: HudState {
|
|
tick: 0,
|
|
shield: 100,
|
|
blink: true,
|
|
damage_flashes: 0,
|
|
sweep: 0,
|
|
},
|
|
audio: None,
|
|
sound_bank: crate::sound::SoundBank::default(),
|
|
desired_music: None,
|
|
desired_mod_music: None,
|
|
desired_music_loop: true,
|
|
startup_intro_pending: false,
|
|
clock: Clock::default(),
|
|
})
|
|
}
|
|
fn go(&mut self, screen: Screen) {
|
|
if self.screen == Screen::Mods {
|
|
self.mods_selection = self.selected;
|
|
}
|
|
self.screen = screen;
|
|
self.selected = if screen == Screen::Mods {
|
|
self.mods_selection.min(self.rows().len().saturating_sub(1))
|
|
} else {
|
|
0
|
|
};
|
|
if screen == Screen::Shop {
|
|
self.shop_selling = false;
|
|
}
|
|
if screen == Screen::Pilots {
|
|
self.pilot_slots = self
|
|
.slots()
|
|
.into_iter()
|
|
.map(|slot| {
|
|
let path = self.paths.save_path(slot);
|
|
let identity = std::fs::metadata(&path)
|
|
.ok()
|
|
.filter(|meta| meta.len() <= 1_048_576)
|
|
.and_then(|_| std::fs::read(path).ok())
|
|
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
|
|
.and_then(|value| {
|
|
Some(format!(
|
|
"{} / {}",
|
|
value["callsign"].as_str()?,
|
|
value["name"].as_str()?
|
|
))
|
|
})
|
|
.unwrap_or_else(|| "unavailable - select for details".into());
|
|
(slot, format!("{slot:04}: {identity}"))
|
|
})
|
|
.collect();
|
|
}
|
|
}
|
|
fn present_rgba(&mut self, image: &Canvas) -> Vec<u8> {
|
|
if self.world.is_none() && self.cinematic.is_none() {
|
|
self.menu_art
|
|
.compose(image, &self.palette, self.config.scanlines)
|
|
} else {
|
|
let palette = if let Some(movie) = &self.cinematic {
|
|
movie.palette(&self.palette)
|
|
} else {
|
|
mission_palette(&self.palette, self.world.as_ref())
|
|
};
|
|
image.rgba(&palette, self.config.scanlines)
|
|
}
|
|
}
|
|
fn validate_current_pilot(&self, pilot: &Pilot) -> Result<()> {
|
|
if self.mismatch_confirmed {
|
|
pilot.validate_tolerant(&self.active.registry)
|
|
} else {
|
|
pilot.validate(&self.active.registry, &self.active.pins)
|
|
}
|
|
}
|
|
fn write_pilot(&mut self, pilot: &Pilot) -> Result<()> {
|
|
let path = self.paths.save_path(self.slot);
|
|
if self.mismatch_confirmed && !self.mismatch_backup_done && path.exists() {
|
|
use sha2::Digest;
|
|
let original = std::fs::read(&path)?;
|
|
let hash = format!("{:x}", sha2::Sha256::digest(&original));
|
|
let backup = self.paths.data.join(format!(
|
|
"pilot-{:04}-before-mod-change-{}.json",
|
|
self.slot,
|
|
&hash[..12]
|
|
));
|
|
if !backup.exists() {
|
|
crate::persistence::atomic_write(&backup, &original)?;
|
|
}
|
|
self.mismatch_backup_done = true;
|
|
}
|
|
pilot.save(&path)
|
|
}
|
|
fn load_pilot_into_hangar(&mut self, pilot: Pilot, slot: usize, confirmed: bool) {
|
|
restore_scripts(&mut self.active.scripts, &pilot);
|
|
self.pilot = pilot;
|
|
self.config.difficulty = self.pilot.difficulty;
|
|
self.slot = slot;
|
|
self.pilot_error = None;
|
|
self.mismatch_confirmed = confirmed;
|
|
self.mismatch_backup_done = false;
|
|
self.pending_load = None;
|
|
self.message = if confirmed {
|
|
format!("Loaded slot {slot:04}; unavailable content retained")
|
|
} else {
|
|
format!("Loaded pilot slot {slot:04}")
|
|
};
|
|
self.hangar();
|
|
}
|
|
fn request_pilot_load(&mut self, slot: usize, return_to: Screen) -> Result<()> {
|
|
let loaded = Pilot::inspect_load(
|
|
&self.paths.save_path(slot),
|
|
&self.active.registry,
|
|
&self.active.pins,
|
|
)?;
|
|
if loaded.compatibility.is_compatible() {
|
|
self.load_pilot_into_hangar(loaded.pilot, slot, false);
|
|
} else {
|
|
self.pending_load = Some(PendingPilotLoad {
|
|
loaded,
|
|
slot,
|
|
return_to,
|
|
});
|
|
self.message = "Review differences. Loading keeps unavailable content".into();
|
|
self.go(Screen::ConfirmLoadMods);
|
|
}
|
|
Ok(())
|
|
}
|
|
fn cancel_pilot_load(&mut self) {
|
|
let return_to = self
|
|
.pending_load
|
|
.take()
|
|
.map_or(Screen::Main, |pending| pending.return_to);
|
|
self.message = "Pilot load cancelled; save unchanged".into();
|
|
self.go(return_to);
|
|
}
|
|
fn save_config(&self) -> Result<()> {
|
|
if self.read_only {
|
|
return Ok(());
|
|
}
|
|
self.config.save(&self.paths)
|
|
}
|
|
fn save(&mut self) -> Result<()> {
|
|
self.require_pilot()?;
|
|
if self.read_only {
|
|
return Ok(());
|
|
}
|
|
self.edit_pilot(|_, _| Ok(()))?;
|
|
Ok(())
|
|
}
|
|
fn save_pilot_action(&mut self) -> Result<()> {
|
|
anyhow::ensure!(!self.read_only, "Saving is disabled in preview mode");
|
|
self.require_pilot()?;
|
|
self.check_saved_slot()?;
|
|
if self.world.is_some() {
|
|
// Saving from Pause retains the last hangar inventory/progression
|
|
// and its script snapshot, never half-completed mission state.
|
|
let mut candidate = self.pilot.clone();
|
|
self.validate_current_pilot(&candidate)?;
|
|
if !self.mismatch_confirmed {
|
|
candidate.mods = self.active.pins.clone();
|
|
}
|
|
self.write_pilot(&candidate)?;
|
|
self.pilot = candidate;
|
|
} else {
|
|
self.save()?;
|
|
}
|
|
self.message = if self.world.is_some() {
|
|
format!(
|
|
"Saved {0} / {1:04}: hangar state only",
|
|
self.pilot.callsign, self.slot
|
|
)
|
|
} else {
|
|
format!("Saved {0} / slot {1:04}", self.pilot.callsign, self.slot)
|
|
};
|
|
self.play_ui_sound("FX_BONUS");
|
|
Ok(())
|
|
}
|
|
fn hangar(&mut self) {
|
|
self.go(Screen::Hangar);
|
|
self.music("HANGAR_MUS");
|
|
}
|
|
fn music(&mut self, name: &str) {
|
|
self.music_with_loop(name, true);
|
|
}
|
|
fn music_with_loop(&mut self, name: &str, looping: bool) {
|
|
let id = self.catalog.id(name).ok();
|
|
if self.desired_mod_music.is_none()
|
|
&& id.is_some()
|
|
&& self.desired_music == id
|
|
&& self.desired_music_loop == looping
|
|
{
|
|
return;
|
|
}
|
|
self.desired_music_loop = looping;
|
|
self.desired_mod_music = None;
|
|
self.desired_music = id;
|
|
let score = self.catalog.named(name).and_then(|b| MusScore::decode(&b));
|
|
if let (Some(device), Ok(score)) = (&mut self.audio, score) {
|
|
device
|
|
.lock()
|
|
.play_with_fade(score, looping, name != "APOGEE_MUS");
|
|
}
|
|
}
|
|
fn mod_music(&mut self, id: &ContentId) -> Result<()> {
|
|
let definition = self
|
|
.active
|
|
.registry
|
|
.music
|
|
.get(id)
|
|
.context("mission music track is unavailable")?;
|
|
let looping = definition.looping;
|
|
if self.desired_mod_music.as_ref() == Some(id) && self.desired_music_loop == looping {
|
|
return Ok(());
|
|
}
|
|
let score = self.sound_bank.mod_music(&self.active.registry, id)?;
|
|
self.desired_music = None;
|
|
self.desired_mod_music = Some(id.clone());
|
|
self.desired_music_loop = looping;
|
|
if let Some(audio) = &mut self.audio {
|
|
audio.lock().play_with_fade(score, looping, true);
|
|
}
|
|
Ok(())
|
|
}
|
|
/// Reapply pending selection when the audio device is first attached.
|
|
fn resume_desired_music(&mut self) -> Result<()> {
|
|
let score = if let Some(id) = &self.desired_mod_music {
|
|
Some((self.sound_bank.mod_music(&self.active.registry, id)?, true))
|
|
} else if let Some(id) = self.desired_music {
|
|
Some((
|
|
MusScore::decode(&self.catalog.read(id)?)?,
|
|
self.catalog.id("APOGEE_MUS").ok() != Some(id),
|
|
))
|
|
} else {
|
|
None
|
|
};
|
|
if let (Some(audio), Some((score, fade))) = (&mut self.audio, score) {
|
|
audio
|
|
.lock()
|
|
.play_with_fade(score, self.desired_music_loop, fade);
|
|
}
|
|
Ok(())
|
|
}
|
|
fn play_ui_sound(&mut self, name: &str) {
|
|
let Some(device) = &mut self.audio else {
|
|
return;
|
|
};
|
|
let result = crate::sound::ui_event(name).and_then(|event| {
|
|
let sample = self.sound_bank.original(&self.catalog, event.asset)?;
|
|
device
|
|
.lock()
|
|
.sample(sample, event.pan, event.pitch, event.volume, event.priority);
|
|
Ok(())
|
|
});
|
|
if let Err(error) = result {
|
|
self.message = format!("Audio cue unavailable: {error:#}");
|
|
}
|
|
}
|
|
fn start(&mut self, id: ContentId) -> Result<()> {
|
|
self.require_pilot()?;
|
|
anyhow::ensure!(
|
|
self.active.registry.missions.contains_key(&id),
|
|
"Saved mission {id} is unavailable. Choose Select mission in the hangar, or enable its mod."
|
|
);
|
|
let mut pilot = self.pilot.clone();
|
|
pilot.mission = id.clone();
|
|
pilot.difficulty = self.config.difficulty;
|
|
let world = World::new(&id, &pilot, &self.active.registry)?;
|
|
if let Some(track) = self.active.registry.missions[&id].music_track.clone() {
|
|
self.mod_music(&track)?;
|
|
} else if let Some(music) = self.active.registry.missions[&id].music {
|
|
self.desired_mod_music = None;
|
|
self.desired_music = Some(music);
|
|
self.desired_music_loop = true;
|
|
if let Ok(score) = self.catalog.read(music).and_then(|b| MusScore::decode(&b)) {
|
|
if let Some(audio) = &mut self.audio {
|
|
audio.lock().play_with_fade(score, true, true);
|
|
}
|
|
}
|
|
}
|
|
self.pilot = pilot;
|
|
self.active.scripts.states = self.pilot.script_state.clone();
|
|
self.active.scripts.rng = self.pilot.script_rng.clone();
|
|
self.world = Some(world);
|
|
self.hud_state = HudState::new(self.world.as_ref().unwrap());
|
|
self.clock = Clock::default();
|
|
self.go(Screen::Game);
|
|
Ok(())
|
|
}
|
|
fn show_movie(&mut self, kind: MovieKind, return_to: Screen) -> Result<()> {
|
|
let movie = Cinematic::load(&self.catalog, kind)?;
|
|
self.startup_intro_pending = false;
|
|
self.cinematic = Some(movie);
|
|
self.movie_return = return_to;
|
|
match kind {
|
|
MovieKind::Intro => self.music("RINTRO_MUS"),
|
|
MovieKind::Death => self.music("RAP5_MUS"),
|
|
_ => {}
|
|
}
|
|
self.go(Screen::Cinematic);
|
|
Ok(())
|
|
}
|
|
fn start_original_intro(&mut self, return_to: Screen) -> Result<()> {
|
|
match self.show_movie(MovieKind::OpeningCredits, return_to) {
|
|
Ok(()) => {
|
|
self.startup_intro_pending = true;
|
|
Ok(())
|
|
}
|
|
Err(_) => self.show_movie(MovieKind::Intro, return_to),
|
|
}
|
|
}
|
|
fn finish_movie(&mut self) {
|
|
if let Some(audio) = &mut self.audio {
|
|
// The original movie player clears its outgoing patches on skip/end.
|
|
for tag in [104, 129, 144, 149, 179] {
|
|
audio.lock().stop_tagged(tag);
|
|
}
|
|
}
|
|
self.cinematic = None;
|
|
if self.startup_intro_pending {
|
|
self.startup_intro_pending = false;
|
|
if self.show_movie(MovieKind::Intro, self.movie_return).is_ok() {
|
|
return;
|
|
}
|
|
}
|
|
self.go(self.movie_return);
|
|
if matches!(self.screen, Screen::Main | Screen::ConfirmLoadMods) {
|
|
self.music("MAINMENU_MUS");
|
|
}
|
|
}
|
|
fn items(&self) -> Vec<ContentId> {
|
|
let mut ids: Vec<_> = self
|
|
.active
|
|
.registry
|
|
.items
|
|
.values()
|
|
.filter(|i| i.legacy.is_none_or(|n| n < 18))
|
|
.map(|i| i.id.clone())
|
|
.collect();
|
|
ids.sort_by_key(|id| {
|
|
(
|
|
self.active.registry.items[id].legacy.unwrap_or(u32::MAX),
|
|
id.clone(),
|
|
)
|
|
});
|
|
ids
|
|
}
|
|
fn inventory_ids(&self) -> Vec<ContentId> {
|
|
owned_items(&self.pilot, &self.active.registry)
|
|
}
|
|
fn shop_ids(&self) -> Vec<ContentId> {
|
|
if self.shop_selling {
|
|
self.inventory_ids()
|
|
.into_iter()
|
|
.filter(|id| sellable(id))
|
|
.collect()
|
|
} else {
|
|
self.items()
|
|
}
|
|
}
|
|
fn missions(&self) -> Vec<ContentId> {
|
|
let mut ids: Vec<_> = self
|
|
.active
|
|
.registry
|
|
.campaigns
|
|
.values()
|
|
.flat_map(|c| c.missions.clone())
|
|
.collect();
|
|
let mut seen = std::collections::BTreeSet::new();
|
|
ids.retain(|id| seen.insert(id.clone()));
|
|
for id in self.active.registry.missions.keys() {
|
|
if seen.insert(id.clone()) {
|
|
ids.push(id.clone());
|
|
}
|
|
}
|
|
ids
|
|
}
|
|
fn rows(&self) -> Vec<String> {
|
|
match self.screen {
|
|
Screen::Main => vec![
|
|
"Return to hangar",
|
|
"New pilot",
|
|
"Load / import pilot",
|
|
"Options",
|
|
"Mods",
|
|
"Help / credits",
|
|
"Watch intro",
|
|
"Quit",
|
|
]
|
|
.into_iter()
|
|
.map(str::to_string)
|
|
.collect(),
|
|
Screen::Hangar => [
|
|
"Fly mission",
|
|
"Select mission",
|
|
"Supply room / shop",
|
|
"Inventory / equipment",
|
|
"Save pilot",
|
|
"Edit pilot ID",
|
|
"Exit hangar",
|
|
]
|
|
.into_iter()
|
|
.map(str::to_string)
|
|
.collect(),
|
|
Screen::Extras => ["Flight controls", "Credits", "Return to main menu"]
|
|
.into_iter()
|
|
.map(str::to_string)
|
|
.collect(),
|
|
Screen::PilotForm => self
|
|
.pilot_form
|
|
.as_ref()
|
|
.map(|form| {
|
|
let mut rows = vec![
|
|
format!(
|
|
"Name: {}{}",
|
|
form.name,
|
|
if form.field == Some(0) { "_" } else { "" }
|
|
),
|
|
format!(
|
|
"Callsign: {}{}",
|
|
form.callsign,
|
|
if form.field == Some(1) { "_" } else { "" }
|
|
),
|
|
format!("ID picture: {} / 4 (change)", form.portrait + 1),
|
|
];
|
|
if form.creating {
|
|
rows.push(format!(
|
|
"Difficulty: {} (change)",
|
|
["Training", "Rookie", "Veteran", "Elite"][form.difficulty as usize]
|
|
));
|
|
}
|
|
rows.push(if form.creating {
|
|
"Create pilot and enter hangar".into()
|
|
} else {
|
|
"Save pilot ID".into()
|
|
});
|
|
rows.push("Cancel".into());
|
|
rows
|
|
})
|
|
.unwrap_or_default(),
|
|
Screen::Missions => self
|
|
.missions()
|
|
.iter()
|
|
.map(|id| {
|
|
format!(
|
|
"{}{}",
|
|
if self.pilot.completed.contains(id) {
|
|
"+ "
|
|
} else {
|
|
" "
|
|
},
|
|
self.active.registry.missions[id].name
|
|
)
|
|
})
|
|
.collect(),
|
|
Screen::Shop => std::iter::once(if self.shop_selling {
|
|
"Mode: SELL (switch)".into()
|
|
} else {
|
|
"Mode: BUY (switch)".into()
|
|
})
|
|
.chain(self.shop_ids().iter().map(|id| {
|
|
let i = &self.active.registry.items[id];
|
|
format!(
|
|
"{} ${} x{}",
|
|
i.name,
|
|
if self.shop_selling {
|
|
i.resale_price()
|
|
} else {
|
|
i.price
|
|
},
|
|
self.pilot.item_count(id)
|
|
)
|
|
}))
|
|
.collect(),
|
|
Screen::Inventory => self
|
|
.inventory_ids()
|
|
.iter()
|
|
.map(|id| {
|
|
let i = &self.active.registry.items[id];
|
|
let n = self.pilot.item_count(id);
|
|
format!(
|
|
"{} x{}{}",
|
|
i.name,
|
|
n,
|
|
if self.pilot.selected.as_ref() == Some(id) {
|
|
" [EQUIPPED]"
|
|
} else {
|
|
""
|
|
}
|
|
)
|
|
})
|
|
.chain(self.pilot.inventory.iter().filter(|(id, _)| !self.active.registry.items.contains_key(*id))
|
|
.map(|(id, n)| format!("[UNAVAILABLE] {id} x{n}")))
|
|
.collect(),
|
|
Screen::ShopDetails => self
|
|
.shop_detail_item
|
|
.as_ref()
|
|
.and_then(|id| self.shop_info(id).ok())
|
|
.map(|info| {
|
|
info.full_lines()
|
|
.into_iter()
|
|
.flat_map(|line| wrap_text(&self.font, &line, 274))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default(),
|
|
Screen::Pilots => ["Create new pilot".into(), "Import legacy pilot".into()]
|
|
.into_iter()
|
|
.chain(self.pilot_slots.iter().map(|(_, label)| label.clone()))
|
|
.collect(),
|
|
Screen::LegacyPilots => self
|
|
.legacy_slots()
|
|
.iter()
|
|
.map(|path| {
|
|
format!(
|
|
"Import {}",
|
|
path.file_name().unwrap_or_default().to_string_lossy()
|
|
)
|
|
})
|
|
.collect(),
|
|
Screen::ConfirmLoadMods => ["Cancel - keep current pilot".into(), "Load anyway".into()]
|
|
.into_iter()
|
|
.chain(self.pending_load.iter().flat_map(|pending| {
|
|
let mut lines = vec![format!("Slot {:04}: {} / {}", pending.slot, pending.loaded.pilot.callsign, pending.loaded.pilot.name)];
|
|
lines.extend(pending.loaded.compatibility.messages());
|
|
lines.push("Unavailable equipment and progression remain saved. Choose another mission if needed. An original save backup is kept before writing changes.".into());
|
|
lines.into_iter().flat_map(|line| wrap_text(&self.font, &line, 272)).collect::<Vec<_>>()
|
|
})).collect(),
|
|
Screen::ConfirmDisable => [
|
|
"Yes, disable these mods".into(),
|
|
"Cancel - keep enabled".into(),
|
|
]
|
|
.into_iter()
|
|
.chain(
|
|
self.pending_disable
|
|
.iter()
|
|
.flat_map(|plan| plan.removed.iter())
|
|
.map(|id| {
|
|
self.discovery
|
|
.mods
|
|
.get(id)
|
|
.map(|installed| {
|
|
format!(
|
|
"Disable: {} {}",
|
|
installed.manifest.name, installed.manifest.version
|
|
)
|
|
})
|
|
.unwrap_or_else(|| format!("Disable: {id}"))
|
|
}),
|
|
)
|
|
.collect(),
|
|
Screen::Diagnostics => self.diagnostics(),
|
|
Screen::Bindings => ACTIONS
|
|
.iter()
|
|
.map(|action| format!("{}: {}", action, binding_label(&self.config, action)))
|
|
.chain(std::iter::once("Restore default bindings".into()))
|
|
.collect(),
|
|
Screen::Mods => {
|
|
let mut r = vec!["Apply selections".into(), "Rescan installed mods".into()];
|
|
for id in self.mod_ids() {
|
|
if let Some(m) = self.discovery.mods.get(&id) {
|
|
r.push(format!(
|
|
"[{}] {} {}",
|
|
if self.pending.contains(&id) { "X" } else { " " },
|
|
m.manifest.name,
|
|
m.manifest.version
|
|
));
|
|
}
|
|
}
|
|
r
|
|
}
|
|
Screen::Options => vec![
|
|
if self.config.scale == 1 {
|
|
"Sprite scale: original".into()
|
|
} else {
|
|
format!("Sprite scale: {}x crisp", self.config.scale)
|
|
},
|
|
format!("Scanlines: {}", self.config.scanlines),
|
|
format!("Fullscreen: {}", self.config.fullscreen),
|
|
format!("Borderless: {}", self.config.borderless),
|
|
format!("4:3 aspect: {}", self.config.aspect_correct),
|
|
format!("Music volume: {}", self.config.music_volume),
|
|
format!("Effects volume: {}", self.config.fx_volume),
|
|
format!("Controls: {}", self.config.control),
|
|
format!(
|
|
"Difficulty: {}",
|
|
["Training", "Rookie", "Veteran", "Elite"][self.config.difficulty as usize]
|
|
),
|
|
format!("Resolution: {}x{}", self.config.width, self.config.height),
|
|
if self.config.hd_mode {
|
|
format!("HD art: on ({} supplied)", self.hd.registered_count())
|
|
} else {
|
|
"HD art: off".into()
|
|
},
|
|
"Keyboard bindings".into(),
|
|
format!("Effects channels: {}", self.config.fx_channels),
|
|
"Save settings".into(),
|
|
"Return".into(),
|
|
],
|
|
Screen::Pause => vec![
|
|
"Resume",
|
|
"Options",
|
|
"Mods",
|
|
"Save pilot (hangar state)",
|
|
"Return to main menu",
|
|
]
|
|
.into_iter()
|
|
.map(str::to_string)
|
|
.collect(),
|
|
Screen::Results => vec!["Return to hangar".into()],
|
|
_ => vec![],
|
|
}
|
|
}
|
|
fn mod_ids(&self) -> Vec<String> {
|
|
// Installed rows stay in ID order. Activation priority is a separate
|
|
// pending list, so toggles and dependency selection cannot move rows.
|
|
self.discovery.mods.keys().cloned().collect()
|
|
}
|
|
fn require_pilot(&self) -> Result<()> {
|
|
if let Some(error) = &self.pilot_error {
|
|
return Err(anyhow!(
|
|
"{error}. Restore matching mods, load a compatible pilot, or choose New pilot to create a separate pilot."
|
|
));
|
|
}
|
|
self.validate_current_pilot(&self.pilot)
|
|
}
|
|
fn slots(&self) -> Vec<usize> {
|
|
let mut slots = std::fs::read_dir(&self.paths.data)
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(|entry| {
|
|
let entry = entry.ok()?;
|
|
if !entry.file_type().ok()?.is_file() {
|
|
return None;
|
|
}
|
|
let name = entry.file_name();
|
|
let name = name.to_str()?;
|
|
let number = name.strip_prefix("pilot-")?.strip_suffix(".json")?;
|
|
(number.len() == 4 && number.bytes().all(|b| b.is_ascii_digit()))
|
|
.then(|| number.parse::<usize>().ok())
|
|
.flatten()
|
|
.filter(|slot| *slot < 1000)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
slots.sort_unstable();
|
|
slots
|
|
}
|
|
fn legacy_slots(&self) -> Vec<PathBuf> {
|
|
let mut slots = std::fs::read_dir(&self.paths.legacy)
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(|entry| {
|
|
let entry = entry.ok()?;
|
|
if !entry.file_type().ok()?.is_file() {
|
|
return None;
|
|
}
|
|
let name = entry.file_name();
|
|
let name = name.to_str()?;
|
|
let number = name.strip_prefix("CHAR")?.strip_suffix(".FIL")?;
|
|
(number.len() == 4 && number.bytes().all(|b| b.is_ascii_digit()))
|
|
.then(|| entry.path())
|
|
})
|
|
.collect::<Vec<_>>();
|
|
slots.sort();
|
|
slots
|
|
}
|
|
fn unused_slot(&self) -> Result<usize> {
|
|
(0..1000)
|
|
.find(|n| std::fs::symlink_metadata(self.paths.save_path(*n)).is_err())
|
|
.context("all pilot slots are occupied")
|
|
}
|
|
fn new_pilot(&mut self) -> Result<()> {
|
|
self.create_pilot("Pilot".into(), "RAPTOR".into(), 0)
|
|
}
|
|
fn create_pilot(&mut self, name: String, callsign: String, portrait: usize) -> Result<()> {
|
|
let slot = self.unused_slot()?;
|
|
let mut pilot = Pilot {
|
|
name,
|
|
callsign,
|
|
credits: 10000,
|
|
mods: self.active.pins.clone(),
|
|
difficulty: self.config.difficulty,
|
|
legacy_progress: Some(crate::persistence::LegacyProgress {
|
|
portrait: portrait_asset_id(portrait),
|
|
sector: 0,
|
|
waves: [0; 3],
|
|
difficulties: [
|
|
self.config.difficulty,
|
|
self.config.difficulty,
|
|
self.config.difficulty,
|
|
2,
|
|
],
|
|
training: self.config.difficulty == 0,
|
|
training_completed: self.config.difficulty != 0,
|
|
}),
|
|
..Pilot::default()
|
|
};
|
|
pilot.inventory.insert("base:energy".into(), 75);
|
|
pilot.validate(&self.active.registry, &self.active.pins)?;
|
|
if !self.read_only {
|
|
pilot.save(&self.paths.save_path(slot))?;
|
|
}
|
|
self.slot = slot;
|
|
self.pilot = pilot;
|
|
self.pilot_error = None;
|
|
self.active.scripts.states.clear();
|
|
self.active.scripts.rng.clear();
|
|
self.mismatch_confirmed = false;
|
|
self.pending_load = None;
|
|
self.message = format!("New pilot in slot {slot:04}");
|
|
Ok(())
|
|
}
|
|
fn begin_pilot_form(&mut self, creating: bool) -> Result<()> {
|
|
anyhow::ensure!(
|
|
self.world.is_none(),
|
|
"Return to the hangar before editing a pilot"
|
|
);
|
|
if !creating {
|
|
self.require_pilot()?;
|
|
}
|
|
let return_to = self.screen;
|
|
self.pilot_form = Some(PilotForm {
|
|
name: if creating {
|
|
String::new()
|
|
} else {
|
|
self.pilot.name.clone()
|
|
},
|
|
callsign: if creating {
|
|
String::new()
|
|
} else {
|
|
self.pilot.callsign.clone()
|
|
},
|
|
portrait: self
|
|
.pilot
|
|
.legacy_progress
|
|
.as_ref()
|
|
.map_or(0, |p| portrait_index(p.portrait)),
|
|
original_portrait: if creating {
|
|
None
|
|
} else {
|
|
self.pilot
|
|
.legacy_progress
|
|
.as_ref()
|
|
.map(|progress| progress.portrait)
|
|
},
|
|
difficulty: self.config.difficulty,
|
|
creating,
|
|
return_to,
|
|
field: Some(0),
|
|
replace_on_input: true,
|
|
});
|
|
self.go(Screen::PilotForm);
|
|
self.message = "Enter name and callsign; ENTER advances".into();
|
|
Ok(())
|
|
}
|
|
fn type_pilot_text(&mut self, text: &str) {
|
|
let Some(form) = &mut self.pilot_form else {
|
|
return;
|
|
};
|
|
let Some(field) = form.field else {
|
|
return;
|
|
};
|
|
let value = if field == 0 {
|
|
&mut form.name
|
|
} else {
|
|
&mut form.callsign
|
|
};
|
|
let text = text
|
|
.chars()
|
|
.filter(|ch| ch.is_ascii() && !ch.is_ascii_control())
|
|
.collect::<String>();
|
|
if text.is_empty() {
|
|
return;
|
|
}
|
|
if form.replace_on_input {
|
|
value.clear();
|
|
form.replace_on_input = false;
|
|
}
|
|
let available = (if field == 0 { 20_usize } else { 12 }).saturating_sub(value.len());
|
|
value.extend(text.chars().take(available));
|
|
}
|
|
fn pilot_field_key(&mut self, key: Keycode) -> bool {
|
|
let Some(form) = &mut self.pilot_form else {
|
|
return false;
|
|
};
|
|
let Some(field) = form.field else {
|
|
return false;
|
|
};
|
|
match key {
|
|
Keycode::Backspace => {
|
|
let value = if field == 0 {
|
|
&mut form.name
|
|
} else {
|
|
&mut form.callsign
|
|
};
|
|
if form.replace_on_input {
|
|
value.clear();
|
|
} else {
|
|
value.pop();
|
|
}
|
|
form.replace_on_input = false;
|
|
}
|
|
Keycode::Delete => {
|
|
if field == 0 {
|
|
form.name.clear();
|
|
} else {
|
|
form.callsign.clear();
|
|
}
|
|
form.replace_on_input = false;
|
|
}
|
|
Keycode::Return | Keycode::Tab => {
|
|
form.field = (field == 0).then_some(1);
|
|
self.selected = if field == 0 {
|
|
1
|
|
} else if form.creating {
|
|
4
|
|
} else {
|
|
3
|
|
};
|
|
form.replace_on_input = true;
|
|
}
|
|
Keycode::Escape | Keycode::Up | Keycode::Down => return false,
|
|
_ => {}
|
|
}
|
|
true
|
|
}
|
|
fn cancel_pilot_form(&mut self) {
|
|
let return_to = self
|
|
.pilot_form
|
|
.take()
|
|
.map_or(Screen::Main, |form| form.return_to);
|
|
self.go(return_to);
|
|
self.message = "Pilot ID unchanged".into();
|
|
}
|
|
fn commit_pilot_form(&mut self) -> Result<()> {
|
|
let form = self.pilot_form.as_ref().context("pilot form is not open")?;
|
|
let name = form.name.trim().to_string();
|
|
let callsign = form.callsign.trim().to_string();
|
|
let creating = form.creating;
|
|
let portrait = form.portrait;
|
|
let stored_portrait = form
|
|
.original_portrait
|
|
.filter(|original| portrait_index(*original) == portrait)
|
|
.unwrap_or_else(|| portrait_asset_id(portrait));
|
|
let difficulty = form.difficulty;
|
|
anyhow::ensure!(!name.is_empty(), "Enter the pilot's name");
|
|
anyhow::ensure!(!callsign.is_empty(), "Enter a callsign");
|
|
anyhow::ensure!(
|
|
name.len() <= 20 && callsign.len() <= 12,
|
|
"Name permits 20 characters; callsign permits 12"
|
|
);
|
|
if creating {
|
|
let previous_difficulty = self.config.difficulty;
|
|
self.config.difficulty = difficulty;
|
|
if let Err(error) = self.create_pilot(name, callsign, portrait) {
|
|
self.config.difficulty = previous_difficulty;
|
|
return Err(error);
|
|
}
|
|
} else {
|
|
self.edit_pilot(|pilot, _| {
|
|
pilot.name = name;
|
|
pilot.callsign = callsign;
|
|
let progress =
|
|
pilot
|
|
.legacy_progress
|
|
.get_or_insert(crate::persistence::LegacyProgress {
|
|
portrait: 0,
|
|
sector: 0,
|
|
waves: [0; 3],
|
|
difficulties: [pilot.difficulty; 4],
|
|
training: pilot.difficulty == 0,
|
|
training_completed: false,
|
|
});
|
|
progress.portrait = stored_portrait;
|
|
Ok(())
|
|
})?;
|
|
self.message = "Pilot ID saved".into();
|
|
}
|
|
self.pilot_form = None;
|
|
self.hangar();
|
|
Ok(())
|
|
}
|
|
fn import_pilot(&mut self, path: &std::path::Path) -> Result<()> {
|
|
let slot = self.unused_slot()?;
|
|
let mut pilot = Pilot::import_legacy(path, &self.active.registry)?;
|
|
pilot.mods = self.active.pins.clone();
|
|
pilot.validate(&self.active.registry, &self.active.pins)?;
|
|
if !self.read_only {
|
|
pilot.save(&self.paths.save_path(slot))?;
|
|
}
|
|
restore_scripts(&mut self.active.scripts, &pilot);
|
|
self.pilot = pilot;
|
|
self.config.difficulty = self.pilot.difficulty;
|
|
self.slot = slot;
|
|
self.pilot_error = None;
|
|
self.mismatch_confirmed = false;
|
|
self.pending_load = None;
|
|
self.message = format!("Imported {} into slot {slot:04}", self.pilot.callsign);
|
|
self.hangar();
|
|
Ok(())
|
|
}
|
|
fn edit_pilot(&mut self, edit: impl FnOnce(&mut Pilot, &Registry) -> Result<()>) -> Result<()> {
|
|
self.require_pilot()?;
|
|
self.check_saved_slot()?;
|
|
let mut candidate = self.pilot.clone();
|
|
edit(&mut candidate, &self.active.registry)?;
|
|
candidate.merge_runtime_snapshot(
|
|
&self.active.pins,
|
|
&self.active.scripts.states,
|
|
&self.active.scripts.rng,
|
|
);
|
|
self.validate_current_pilot(&candidate)?;
|
|
if !self.read_only {
|
|
self.write_pilot(&candidate)?;
|
|
}
|
|
self.pilot = candidate;
|
|
Ok(())
|
|
}
|
|
fn check_saved_slot(&self) -> Result<()> {
|
|
let path = self.paths.save_path(self.slot);
|
|
if std::fs::symlink_metadata(&path).is_ok() {
|
|
if self.mismatch_confirmed {
|
|
Pilot::inspect_load(&path, &self.active.registry, &self.active.pins)?;
|
|
} else {
|
|
Pilot::load(&path, &self.active.registry, &self.active.pins)
|
|
.with_context(|| format!("Existing pilot slot {:04} is unavailable; load matching mods or create a separate pilot", self.slot))?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
fn diagnostics(&self) -> Vec<String> {
|
|
let mut lines = vec![self.message.clone()];
|
|
if let Some(mod_id) = &self.diagnostic_mod {
|
|
if let Some(installed) = self.discovery.mods.get(mod_id) {
|
|
let manifest = &installed.manifest;
|
|
lines.push(format!(
|
|
"{} {} / API {}",
|
|
manifest.name, manifest.version, manifest.api_version
|
|
));
|
|
lines.push(manifest.description.clone());
|
|
lines.extend(dependency_lines(manifest, &self.discovery, &self.pending));
|
|
lines.push(format!(
|
|
"Conflicts: {}",
|
|
if manifest.conflicts.is_empty() {
|
|
"none".into()
|
|
} else {
|
|
manifest.conflicts.join(", ")
|
|
}
|
|
));
|
|
if let Some(priority) = self.pending.iter().position(|id| id == mod_id) {
|
|
lines.push(format!(
|
|
"Requested load priority: {} / {} (later overrides win)",
|
|
priority + 1,
|
|
self.pending.len()
|
|
));
|
|
}
|
|
}
|
|
}
|
|
if let Some(e) = &self.pilot_error {
|
|
lines.push(e.clone());
|
|
}
|
|
lines.extend(self.discovery.errors.clone());
|
|
lines.extend(self.hd.diagnostics.clone());
|
|
lines.push(if self.hd_loaded {
|
|
format!(
|
|
"HD art: {} supplied frames, {} decoded; absent artwork uses original sprites",
|
|
self.hd.registered_count(),
|
|
self.hd.validated_count()
|
|
)
|
|
} else {
|
|
"HD pack not loaded; enable HD art in Options to inspect it".into()
|
|
});
|
|
lines.push(format!(
|
|
"Active order: {}",
|
|
self.active
|
|
.pins
|
|
.iter()
|
|
.map(|m| m.id.clone())
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
));
|
|
lines.push(format!("Pending order: {}", self.pending.join(", ")));
|
|
lines.extend(self.active.registry.overrides.clone());
|
|
for m in self.discovery.mods.values() {
|
|
lines.push(format!(
|
|
"{} {}: {}",
|
|
m.manifest.id, m.manifest.version, m.manifest.description
|
|
));
|
|
lines.push(format!(
|
|
"Dependencies: {:?}; conflicts: {:?}",
|
|
m.manifest.dependencies, m.manifest.conflicts
|
|
));
|
|
}
|
|
lines
|
|
.into_iter()
|
|
.flat_map(|s| {
|
|
s.chars()
|
|
.collect::<Vec<_>>()
|
|
.chunks(52)
|
|
.map(|c| c.iter().collect::<String>())
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.collect()
|
|
}
|
|
fn open_diagnostics(&mut self) {
|
|
if self.screen == Screen::Diagnostics {
|
|
return;
|
|
}
|
|
self.diagnostic_mod = if self.screen == Screen::Mods && self.selected >= 2 {
|
|
self.mod_ids().get(self.selected - 2).cloned()
|
|
} else {
|
|
None
|
|
};
|
|
self.diagnostic_return = self.screen;
|
|
self.diagnostic_selection = self.selected;
|
|
self.go(Screen::Diagnostics);
|
|
self.play_ui_sound("FX_SWEP");
|
|
}
|
|
fn apply_mods(&mut self) -> Result<()> {
|
|
let mut candidate = ActiveMods::build(
|
|
&self.base,
|
|
&self.discovery,
|
|
&self.pending,
|
|
Some(&self.catalog),
|
|
)?;
|
|
let mut config = self.config.clone();
|
|
config.enabled_mods = self.pending.clone();
|
|
if !self.read_only {
|
|
config.save(&self.paths)?;
|
|
}
|
|
let saved = self.paths.save_path(self.slot);
|
|
let inspected = if saved.exists() {
|
|
Pilot::inspect_load(&saved, &candidate.registry, &candidate.pins)
|
|
} else {
|
|
self.pilot
|
|
.validate_tolerant(&candidate.registry)
|
|
.map(|_| PilotLoad {
|
|
pilot: self.pilot.clone(),
|
|
compatibility: self
|
|
.pilot
|
|
.compatibility_report(&candidate.registry, &candidate.pins),
|
|
})
|
|
};
|
|
match inspected {
|
|
Ok(loaded) if loaded.compatibility.is_compatible() => {
|
|
restore_scripts(&mut candidate.scripts, &loaded.pilot);
|
|
self.pilot = loaded.pilot;
|
|
self.pilot_error = None;
|
|
self.mismatch_confirmed = false;
|
|
self.pending_load = None;
|
|
}
|
|
Ok(loaded) => {
|
|
self.pilot_error = Some("Pilot needs confirmation for the current mods".into());
|
|
self.pending_load = Some(PendingPilotLoad {
|
|
loaded,
|
|
slot: self.slot,
|
|
return_to: Screen::Main,
|
|
});
|
|
}
|
|
Err(e) => self.pilot_error = Some(format!("Pilot slot {:04}: {e:#}", self.slot)),
|
|
}
|
|
self.config = config;
|
|
self.active = candidate;
|
|
self.sound_bank.clear_mods();
|
|
if self.desired_mod_music.is_some() {
|
|
self.music("MAINMENU_MUS");
|
|
}
|
|
self.mod_art.clear();
|
|
if self.pending_load.is_some() {
|
|
self.message = "Mods applied; review pilot differences before loading".into();
|
|
self.go(Screen::ConfirmLoadMods);
|
|
} else if self.pilot_error.is_some() {
|
|
self.message = "Mods applied; choose Load / New pilot".into();
|
|
} else {
|
|
self.message = "Mods applied".into();
|
|
}
|
|
Ok(())
|
|
}
|
|
fn activate(&mut self) -> Result<bool> {
|
|
match self.screen {
|
|
Screen::Main => match self.selected {
|
|
0 => {
|
|
if self.pilot_error.is_some() && self.paths.save_path(self.slot).exists() {
|
|
self.request_pilot_load(self.slot, Screen::Main)?;
|
|
} else {
|
|
self.require_pilot()?;
|
|
self.hangar();
|
|
}
|
|
}
|
|
1 => self.begin_pilot_form(true)?,
|
|
2 => self.go(Screen::Pilots),
|
|
3 => {
|
|
self.previous = Screen::Main;
|
|
self.go(Screen::Options);
|
|
}
|
|
4 => self.go(Screen::Mods),
|
|
5 => self.go(Screen::Extras),
|
|
6 => self.start_original_intro(Screen::Main)?,
|
|
7 => return Ok(false),
|
|
_ => {}
|
|
},
|
|
Screen::Hangar => match self.selected {
|
|
0 => self.start(self.pilot.mission.clone())?,
|
|
1 => self.go(Screen::Missions),
|
|
2 => {
|
|
self.go(Screen::Shop);
|
|
self.play_ui_sound("FX_DOOR");
|
|
}
|
|
3 => self.go(Screen::Inventory),
|
|
4 => self.save_pilot_action()?,
|
|
5 => self.begin_pilot_form(false)?,
|
|
6 => self.return_main()?,
|
|
_ => {}
|
|
},
|
|
Screen::Extras => match self.selected {
|
|
0 => self.go(Screen::Help),
|
|
1 => self.go(Screen::Credits),
|
|
2 => self.return_main()?,
|
|
_ => {}
|
|
},
|
|
Screen::PilotForm => match self.selected {
|
|
0 | 1 => {
|
|
if let Some(form) = &mut self.pilot_form {
|
|
form.field = Some(self.selected);
|
|
form.replace_on_input = true;
|
|
}
|
|
}
|
|
2 => {
|
|
if let Some(form) = &mut self.pilot_form {
|
|
form.portrait = (form.portrait + 1) % 4;
|
|
form.field = None;
|
|
}
|
|
}
|
|
_ => {
|
|
let creating = self.pilot_form.as_ref().is_some_and(|form| form.creating);
|
|
if creating && self.selected == 3 {
|
|
if let Some(form) = &mut self.pilot_form {
|
|
form.difficulty = (form.difficulty + 1) % 4;
|
|
form.field = None;
|
|
}
|
|
} else if self.selected == if creating { 4 } else { 3 } {
|
|
self.commit_pilot_form()?;
|
|
} else if self.selected == if creating { 5 } else { 4 } {
|
|
self.cancel_pilot_form();
|
|
}
|
|
}
|
|
},
|
|
Screen::Missions => {
|
|
if let Some(id) = self.missions().get(self.selected) {
|
|
self.start(id.clone())?;
|
|
}
|
|
}
|
|
Screen::Pilots => {
|
|
if self.selected == 0 {
|
|
self.begin_pilot_form(true)?;
|
|
} else if self.selected == 1 {
|
|
self.go(Screen::LegacyPilots);
|
|
if self.legacy_slots().is_empty() {
|
|
self.message =
|
|
format!("No CHARxxxx.FIL pilots in {}", self.paths.legacy.display());
|
|
}
|
|
} else if let Some(slot) = self
|
|
.pilot_slots
|
|
.get(self.selected - 2)
|
|
.map(|(slot, _)| *slot)
|
|
{
|
|
self.request_pilot_load(slot, Screen::Pilots)?;
|
|
}
|
|
}
|
|
Screen::LegacyPilots => {
|
|
if let Some(path) = self.legacy_slots().get(self.selected).cloned() {
|
|
self.import_pilot(&path)?;
|
|
}
|
|
}
|
|
Screen::Shop => {
|
|
if self.selected == 0 {
|
|
self.toggle_shop_mode();
|
|
} else if let Some(id) = self.shop_ids().get(self.selected - 1).cloned() {
|
|
let selling = self.shop_selling;
|
|
self.edit_pilot(|pilot, registry| {
|
|
if selling {
|
|
pilot.sell(&id, registry)
|
|
} else {
|
|
pilot.buy(&id, registry)
|
|
}
|
|
})?;
|
|
self.selected = self.selected.min(self.rows().len().saturating_sub(1));
|
|
self.message = if selling {
|
|
"Sale complete"
|
|
} else {
|
|
"Purchase complete"
|
|
}
|
|
.into();
|
|
self.play_ui_sound("FX_BONUS");
|
|
}
|
|
}
|
|
Screen::Inventory => {
|
|
if let Some(id) = self.inventory_ids().get(self.selected).cloned() {
|
|
if self
|
|
.active
|
|
.registry
|
|
.items
|
|
.get(&id)
|
|
.is_some_and(|i| i.special)
|
|
{
|
|
self.edit_pilot(|pilot, _| {
|
|
pilot.selected = Some(id);
|
|
Ok(())
|
|
})?;
|
|
self.message = "Weapon equipped".into();
|
|
} else {
|
|
self.message = "Primary equipment is always active".into();
|
|
}
|
|
}
|
|
}
|
|
Screen::Mods => {
|
|
if self.selected == 0 {
|
|
if self.world.is_some() {
|
|
self.config.enabled_mods = self.pending.clone();
|
|
self.save_config()?;
|
|
self.message = "Changes pending until the main menu".into();
|
|
} else {
|
|
self.apply_mods()?;
|
|
}
|
|
} else if self.selected == 1 {
|
|
self.discovery = modding::discover(&self.mods_root);
|
|
self.message = self.discovery.errors.join("; ");
|
|
} else if let Some(id) = self.mod_ids().get(self.selected - 2).cloned() {
|
|
if self.pending.contains(&id) {
|
|
let plan =
|
|
crate::mod_selection::plan_disable(&self.discovery, &self.pending, &id);
|
|
if plan.removed.len() == 1 {
|
|
self.pending = plan.enabled;
|
|
self.message = format!("Deselected {id}. Choose Apply selections");
|
|
self.selected = self
|
|
.mod_ids()
|
|
.iter()
|
|
.position(|selected| selected == &id)
|
|
.unwrap_or(0)
|
|
+ 2;
|
|
} else {
|
|
self.pending_disable = Some(plan);
|
|
self.go(Screen::ConfirmDisable);
|
|
self.selected = 1; // Default to keeping the enabled selection.
|
|
}
|
|
} else {
|
|
let enabled =
|
|
crate::mod_selection::plan_enable(&self.discovery, &self.pending, &id)?;
|
|
let added = enabled
|
|
.iter()
|
|
.filter(|id| !self.pending.contains(id))
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
self.pending = enabled;
|
|
self.selected = self
|
|
.mod_ids()
|
|
.iter()
|
|
.position(|selected| selected == &id)
|
|
.unwrap_or(0)
|
|
+ 2;
|
|
self.message =
|
|
format!("Selected {}. Choose Apply selections", added.join(", "));
|
|
}
|
|
}
|
|
}
|
|
Screen::ConfirmLoadMods => {
|
|
if self.selected == 0 {
|
|
self.cancel_pilot_load();
|
|
} else if self.selected == 1 {
|
|
let pending = self.pending_load.take().context("No pending pilot load")?;
|
|
self.load_pilot_into_hangar(pending.loaded.confirm_load(), pending.slot, true);
|
|
}
|
|
}
|
|
Screen::ConfirmDisable => {
|
|
if self.selected == 0 {
|
|
if let Some(plan) = self.pending_disable.take() {
|
|
self.pending = plan.enabled;
|
|
self.message = format!(
|
|
"Deselected {}. Choose Apply selections",
|
|
plan.removed.join(", ")
|
|
);
|
|
}
|
|
self.go(Screen::Mods);
|
|
} else if self.selected == 1 {
|
|
self.pending_disable = None;
|
|
self.message = "Mod selection unchanged".into();
|
|
self.go(Screen::Mods);
|
|
}
|
|
}
|
|
Screen::Options => {
|
|
if self.selected == 11 {
|
|
self.go(Screen::Bindings);
|
|
} else if self.selected == 13 {
|
|
anyhow::ensure!(!self.read_only, "Saving is disabled in preview mode");
|
|
self.save_config()?;
|
|
self.message = "Settings saved".into();
|
|
} else if self.selected == 14 {
|
|
self.back()?;
|
|
} else {
|
|
adjust_option(&mut self.config, self.selected, 1);
|
|
}
|
|
}
|
|
Screen::Bindings => {
|
|
if let Some(action) = ACTIONS.get(self.selected) {
|
|
self.awaiting_binding = Some(action.to_string());
|
|
self.message = format!("Press a key for {action}; ESC cancels");
|
|
} else {
|
|
self.config.bindings = Config::default().bindings;
|
|
self.save_config()?;
|
|
self.message = "Default bindings restored".into();
|
|
}
|
|
}
|
|
Screen::Pause => match self.selected {
|
|
0 => self.go(Screen::Game),
|
|
1 => {
|
|
self.previous = Screen::Pause;
|
|
self.go(Screen::Options)
|
|
}
|
|
2 => self.go(Screen::Mods),
|
|
3 => self.save_pilot_action()?,
|
|
4 => self.return_main()?,
|
|
_ => {}
|
|
},
|
|
Screen::Results => {
|
|
self.return_main()?;
|
|
if self.screen == Screen::Main {
|
|
self.hangar();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(true)
|
|
}
|
|
fn choose(&mut self) -> Result<bool> {
|
|
let result = self.activate();
|
|
self.play_ui_sound(if result.is_ok() {
|
|
"FX_SWEP"
|
|
} else {
|
|
"FX_WARNING"
|
|
});
|
|
result
|
|
}
|
|
fn move_selection(&mut self, delta: i32) {
|
|
if self.screen == Screen::PilotForm {
|
|
if let Some(form) = &mut self.pilot_form {
|
|
form.field = None;
|
|
}
|
|
}
|
|
let next = (self.selected as i64 + i64::from(delta))
|
|
.clamp(0, self.rows().len().saturating_sub(1) as i64) as usize;
|
|
if next != self.selected {
|
|
self.selected = next;
|
|
self.play_ui_sound("FX_SWEP");
|
|
}
|
|
}
|
|
fn adjust(&mut self, direction: i32) {
|
|
if self.screen == Screen::PilotForm && self.selected == 3 {
|
|
if let Some(form) = &mut self.pilot_form {
|
|
if form.creating {
|
|
form.difficulty =
|
|
(form.difficulty as i32 + direction.signum()).rem_euclid(4) as u32;
|
|
self.play_ui_sound("FX_SWEP");
|
|
}
|
|
}
|
|
} else if self.screen == Screen::Options {
|
|
if adjust_option(&mut self.config, self.selected, direction) {
|
|
self.play_ui_sound("FX_SWEP");
|
|
}
|
|
} else if self.screen == Screen::Mods && self.selected >= 2 {
|
|
if let Some(id) = self.mod_ids().get(self.selected - 2) {
|
|
if let Some(n) = self.pending.iter().position(|m| m == id) {
|
|
let next = if direction < 0 {
|
|
n.saturating_sub(1)
|
|
} else {
|
|
(n + 1).min(self.pending.len() - 1)
|
|
};
|
|
if next != n {
|
|
self.pending.swap(n, next);
|
|
self.message = format!(
|
|
"Load priority {} / {}; later overrides win",
|
|
next + 1,
|
|
self.pending.len()
|
|
);
|
|
self.play_ui_sound("FX_SWEP");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
fn sell_inventory(&mut self) -> Result<()> {
|
|
if let Some(id) = self.inventory_ids().get(self.selected).cloned() {
|
|
self.edit_pilot(|pilot, registry| pilot.sell(&id, registry))?;
|
|
self.selected = self.selected.min(self.rows().len().saturating_sub(1));
|
|
self.message = "Sale complete".into();
|
|
}
|
|
Ok(())
|
|
}
|
|
fn open_shop_details(&mut self) {
|
|
if let Some(id) = self
|
|
.selected
|
|
.checked_sub(1)
|
|
.and_then(|index| self.shop_ids().get(index).cloned())
|
|
{
|
|
self.shop_detail_item = Some(id);
|
|
self.go(Screen::ShopDetails);
|
|
self.play_ui_sound("FX_SWEP");
|
|
}
|
|
}
|
|
fn toggle_shop_mode(&mut self) {
|
|
let item = self
|
|
.selected
|
|
.checked_sub(1)
|
|
.and_then(|index| self.shop_ids().get(index).cloned());
|
|
self.shop_selling = !self.shop_selling;
|
|
self.selected = item
|
|
.and_then(|item| {
|
|
self.shop_ids()
|
|
.iter()
|
|
.position(|candidate| candidate == &item)
|
|
})
|
|
.map_or(0, |index| index + 1);
|
|
}
|
|
fn return_main(&mut self) -> Result<()> {
|
|
self.world = None;
|
|
self.clock = Clock::default();
|
|
if self.pending
|
|
!= self
|
|
.active
|
|
.pins
|
|
.iter()
|
|
.map(|m| m.id.clone())
|
|
.collect::<Vec<_>>()
|
|
{
|
|
if let Err(e) = self.apply_mods() {
|
|
self.message = format!("Mod changes pending: {e:#}");
|
|
}
|
|
}
|
|
self.go(if self.pending_load.is_some() {
|
|
Screen::ConfirmLoadMods
|
|
} else {
|
|
Screen::Main
|
|
});
|
|
self.music("MAINMENU_MUS");
|
|
Ok(())
|
|
}
|
|
fn back(&mut self) -> Result<()> {
|
|
match self.screen {
|
|
Screen::ShopDetails => {
|
|
let selling = self.shop_selling;
|
|
self.go(Screen::Shop);
|
|
self.shop_selling = selling;
|
|
self.selected = self
|
|
.shop_detail_item
|
|
.as_ref()
|
|
.and_then(|id| self.shop_ids().iter().position(|candidate| candidate == id))
|
|
.map_or(0, |index| index + 1);
|
|
}
|
|
Screen::ConfirmLoadMods => self.cancel_pilot_load(),
|
|
Screen::ConfirmDisable => {
|
|
self.pending_disable = None;
|
|
self.message = "Mod selection unchanged".into();
|
|
self.go(Screen::Mods);
|
|
}
|
|
Screen::Cinematic => self.finish_movie(),
|
|
Screen::PilotForm => self.cancel_pilot_form(),
|
|
Screen::Game => self.go(Screen::Pause),
|
|
Screen::Pause => self.go(Screen::Game),
|
|
Screen::Options => {
|
|
self.save_config()?;
|
|
self.go(self.previous)
|
|
}
|
|
Screen::Mods if self.world.is_some() => self.go(Screen::Pause),
|
|
Screen::Diagnostics => {
|
|
self.go(self.diagnostic_return);
|
|
self.selected = self
|
|
.diagnostic_selection
|
|
.min(self.rows().len().saturating_sub(1));
|
|
}
|
|
Screen::Bindings => {
|
|
self.awaiting_binding = None;
|
|
self.save_config()?;
|
|
self.go(Screen::Options);
|
|
}
|
|
Screen::Results => {
|
|
self.return_main()?;
|
|
if self.screen == Screen::Main {
|
|
self.hangar();
|
|
}
|
|
}
|
|
Screen::LegacyPilots => self.go(Screen::Pilots),
|
|
Screen::Missions | Screen::Shop | Screen::Inventory => self.hangar(),
|
|
Screen::Help | Screen::Credits => self.go(Screen::Extras),
|
|
_ => {
|
|
self.go(Screen::Main);
|
|
self.music("MAINMENU_MUS");
|
|
}
|
|
}
|
|
self.play_ui_sound("FX_SWEP");
|
|
Ok(())
|
|
}
|
|
fn update(&mut self, input: Input, nanos: u64) -> Result<()> {
|
|
if self.screen == Screen::Cinematic {
|
|
let movie = self
|
|
.cinematic
|
|
.as_mut()
|
|
.context("missing active cinematic")?;
|
|
movie.advance(nanos)?;
|
|
let sounds = std::mem::take(&mut movie.audio_events);
|
|
let music_events = std::mem::take(&mut movie.music_events);
|
|
let finished = movie.finished();
|
|
for (name, looping) in music_events {
|
|
self.music_with_loop(name, looping);
|
|
}
|
|
if let Some(audio) = &mut self.audio {
|
|
for event in sounds {
|
|
match event {
|
|
CinematicAudioEvent::Stop(tag) => audio.lock().stop_tagged(tag),
|
|
CinematicAudioEvent::Play(event) => {
|
|
match self.sound_bank.original(&self.catalog, event.asset) {
|
|
Ok(sample) => audio.lock().sample_tagged(
|
|
event.asset,
|
|
sample,
|
|
event.pan,
|
|
event.pitch,
|
|
event.volume,
|
|
event.priority,
|
|
),
|
|
Err(error) => {
|
|
self.message = format!("Cinematic sound unavailable: {error:#}")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if finished {
|
|
self.finish_movie();
|
|
}
|
|
return Ok(());
|
|
}
|
|
if self.screen != Screen::Game {
|
|
return Ok(());
|
|
}
|
|
self.queued_cycle |= input.cycle;
|
|
self.queued_bomb |= input.bomb;
|
|
let steps = self.clock.advance(nanos);
|
|
let mut input = input;
|
|
input.cycle = self.queued_cycle;
|
|
input.bomb = self.queued_bomb;
|
|
for _ in 0..steps {
|
|
let w = self.world.as_mut().context("missing active mission")?;
|
|
w.step(input, &self.active.registry, &mut self.active.scripts)?;
|
|
// SDL cycle/bomb inputs are non-repeating button-down pulses. Release
|
|
// their simulation latches after delivery so a second genuine tap
|
|
// on the next step is accepted. World::step still debounces held
|
|
// inputs supplied directly by demos and headless controllers.
|
|
if input.cycle {
|
|
w.cycle_held = false;
|
|
}
|
|
if input.bomb {
|
|
w.bomb_held = false;
|
|
}
|
|
self.hud_state.observe(w, &self.active.registry);
|
|
input.cycle = false;
|
|
input.bomb = false;
|
|
self.queued_cycle = false;
|
|
self.queued_bomb = false;
|
|
if let Some(audio) = &mut self.audio {
|
|
for event in &w.sound_events {
|
|
match self.sound_bank.original(&self.catalog, event.asset) {
|
|
Ok(sample) => audio.lock().sample(
|
|
sample,
|
|
event.pan,
|
|
event.pitch,
|
|
event.volume,
|
|
event.priority,
|
|
),
|
|
Err(error) => {
|
|
self.message = format!("Mission sound unavailable: {error:#}")
|
|
}
|
|
}
|
|
}
|
|
if w.player.shield <= 10 && self.hud_state.blink && w.end_timer.is_none() {
|
|
let event = crate::sound::ui_event("FX_WARNING")?;
|
|
match self.sound_bank.original(&self.catalog, event.asset) {
|
|
Ok(sample) => audio.lock().sample(
|
|
sample,
|
|
event.pan,
|
|
event.pitch,
|
|
event.volume,
|
|
event.priority,
|
|
),
|
|
Err(error) => {
|
|
self.message = format!("Shield warning unavailable: {error:#}")
|
|
}
|
|
}
|
|
}
|
|
for id in &w.mod_sounds {
|
|
match self.sound_bank.mod_sample(&self.active.registry, id) {
|
|
Ok(sample) => {
|
|
let event = &self.active.registry.sounds[id];
|
|
audio.lock().sample(
|
|
sample,
|
|
event.pan,
|
|
event.pitch,
|
|
event.volume,
|
|
event.priority as i32,
|
|
);
|
|
}
|
|
Err(error) => self.message = format!("Mod sound unavailable: {error:#}"),
|
|
}
|
|
}
|
|
}
|
|
if w.outcome != Outcome::Playing {
|
|
let outcome = w.outcome;
|
|
let mission = w.mission.clone();
|
|
let mut pilot = w.pilot(&self.pilot);
|
|
if outcome == Outcome::Complete {
|
|
if self.mismatch_confirmed {
|
|
pilot.complete_mission_tolerant(&self.active.registry)?;
|
|
} else {
|
|
pilot.complete_mission(&self.active.registry)?;
|
|
}
|
|
self.edit_pilot(move |candidate, _| {
|
|
*candidate = pilot;
|
|
Ok(())
|
|
})?;
|
|
self.config.difficulty = self.pilot.difficulty;
|
|
// The pilot is committed. A settings failure must not
|
|
// retry progression against an already advanced pilot.
|
|
self.message = match self.save_config() {
|
|
Ok(()) => "Mission complete - pilot saved".into(),
|
|
Err(error) => format!("Pilot saved; settings save failed: {error:#}"),
|
|
};
|
|
} else {
|
|
self.message = "Ship lost - previous hangar save retained".into();
|
|
}
|
|
let kind = if outcome == Outcome::Failed {
|
|
MovieKind::Death
|
|
} else if mission.0.ends_with("_wave_9") {
|
|
let sector = mission
|
|
.0
|
|
.strip_prefix("base:sector_")
|
|
.and_then(|s| s.split('_').next())
|
|
.and_then(|s| s.parse::<u8>().ok())
|
|
.unwrap_or(1);
|
|
MovieKind::CampaignEnd(sector.saturating_sub(1))
|
|
} else {
|
|
MovieKind::Landing
|
|
};
|
|
if mission.0.starts_with("base:") {
|
|
if let Err(e) = self.show_movie(kind, Screen::Results) {
|
|
self.message = format!("{}; cinematic unavailable: {e:#}", self.message);
|
|
self.go(Screen::Results);
|
|
}
|
|
} else {
|
|
self.go(Screen::Results);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
fn raw(&mut self, id: u32) -> Result<Sprite> {
|
|
if let Some(s) = self.raw.get(&id) {
|
|
return Ok(s.clone());
|
|
}
|
|
let s = self.catalog.sprite(id)?;
|
|
self.raw.insert(id, s.clone());
|
|
Ok(s)
|
|
}
|
|
fn art(
|
|
&mut self,
|
|
canvas: &mut Canvas,
|
|
art: &Art,
|
|
owner: &str,
|
|
frame: u32,
|
|
x: f32,
|
|
y: f32,
|
|
) -> Result<()> {
|
|
let key = if art.png.is_some() || art.hd_png.is_some() {
|
|
Some(format!(
|
|
"{owner}:{}:{frame}:{}:{}",
|
|
serde_json::to_string(art)?,
|
|
canvas.scale,
|
|
self.config.hd_mode
|
|
))
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(sprite) = key.as_ref().and_then(|k| self.mod_art.get(k)) {
|
|
canvas.blit(
|
|
sprite,
|
|
(x * canvas.scale as f32).round() as i32,
|
|
(y * canvas.scale as f32).round() as i32,
|
|
);
|
|
return Ok(());
|
|
}
|
|
if let Some(id) = art.asset {
|
|
if self.config.hd_mode && art.hd_png.is_none() {
|
|
if let Some(sprite) =
|
|
self.hd
|
|
.sprite(id + frame % art.frames, canvas.scale, &self.palette)?
|
|
{
|
|
if sprite.width == art.logical_width as usize * canvas.scale
|
|
&& sprite.height == art.logical_height as usize * canvas.scale
|
|
{
|
|
canvas.blit(
|
|
&sprite,
|
|
(x * canvas.scale as f32).round() as i32,
|
|
(y * canvas.scale as f32).round() as i32,
|
|
);
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let sprite = if let Some(id) = art
|
|
.asset
|
|
.filter(|_| !(self.config.hd_mode && art.hd_png.is_some()))
|
|
{
|
|
self.raw(id + frame % art.frames)?
|
|
} else {
|
|
art_sprite_variant(
|
|
&self.catalog,
|
|
&self.active.registry,
|
|
owner,
|
|
art,
|
|
frame,
|
|
self.config.hd_mode,
|
|
)?
|
|
};
|
|
let scaled = if sprite.width == art.logical_width as usize
|
|
&& sprite.height == art.logical_height as usize
|
|
{
|
|
let pixelated = art.nearest
|
|
|| art
|
|
.asset
|
|
.is_some_and(|id| prefers_nearest(id + frame % art.frames));
|
|
self.cache.get(&sprite, canvas.scale, pixelated)?.clone()
|
|
} else {
|
|
let mut out = Sprite::blank(
|
|
art.logical_width as usize * canvas.scale,
|
|
art.logical_height as usize * canvas.scale,
|
|
);
|
|
for yy in 0..out.height {
|
|
for xx in 0..out.width {
|
|
out.pixels[yy * out.width + xx] =
|
|
sprite.pixels[(yy * sprite.height / out.height) * sprite.width
|
|
+ xx * sprite.width / out.width];
|
|
}
|
|
}
|
|
out
|
|
};
|
|
canvas.blit(
|
|
&scaled,
|
|
(x * canvas.scale as f32).round() as i32,
|
|
(y * canvas.scale as f32).round() as i32,
|
|
);
|
|
if let Some(key) = key {
|
|
if self
|
|
.mod_art
|
|
.values()
|
|
.map(|s| s.pixels.len() * 2)
|
|
.sum::<usize>()
|
|
+ scaled.pixels.len() * 2
|
|
> 128 * 1024 * 1024
|
|
{
|
|
self.mod_art.clear();
|
|
}
|
|
self.mod_art.insert(key, scaled);
|
|
}
|
|
Ok(())
|
|
}
|
|
fn shadow(
|
|
&mut self,
|
|
canvas: &mut Canvas,
|
|
art: &Art,
|
|
owner: &str,
|
|
frame: u32,
|
|
x: f32,
|
|
y: f32,
|
|
ground: bool,
|
|
) -> Result<()> {
|
|
// Original masks retain their native canvas even when HD art is enabled.
|
|
// Mod masks use the supplied SD artwork and explicit logical dimensions.
|
|
let source = if let Some(asset) = art.asset {
|
|
self.raw(asset + frame % art.frames)?
|
|
} else {
|
|
art_sprite_variant(
|
|
&self.catalog,
|
|
&self.active.registry,
|
|
owner,
|
|
art,
|
|
frame,
|
|
false,
|
|
)?
|
|
};
|
|
let mask = if (source.width, source.height)
|
|
== (art.logical_width as usize, art.logical_height as usize)
|
|
{
|
|
source
|
|
} else {
|
|
let mut logical =
|
|
Sprite::blank(art.logical_width as usize, art.logical_height as usize);
|
|
for row in 0..logical.height {
|
|
for column in 0..logical.width {
|
|
logical.pixels[row * logical.width + column] =
|
|
source.pixels[(row * source.height / logical.height) * source.width
|
|
+ column * source.width / logical.width];
|
|
}
|
|
}
|
|
logical
|
|
};
|
|
if ground {
|
|
shade_ground_shadow(canvas, &mask, x, y, &self.shadow_table);
|
|
} else {
|
|
shade_air_shadow(canvas, &mask, x, y, &self.air_shadow_table);
|
|
}
|
|
Ok(())
|
|
}
|
|
fn native_sprite(&mut self, asset: u32, scale: usize) -> Result<Sprite> {
|
|
if self.config.hd_mode {
|
|
if let Some(sprite) = self.hd.sprite(asset, scale, &self.palette)? {
|
|
return Ok(sprite);
|
|
}
|
|
}
|
|
let original = self.raw(asset)?;
|
|
Ok(self
|
|
.cache
|
|
.get(&original, scale, prefers_nearest(asset))?
|
|
.clone())
|
|
}
|
|
fn native_blit(&mut self, canvas: &mut Canvas, asset: u32, x: f32, y: f32) -> Result<()> {
|
|
let sprite = self.native_sprite(asset, canvas.scale)?;
|
|
canvas.blit(
|
|
&sprite,
|
|
(x * canvas.scale as f32).round() as i32,
|
|
(y * canvas.scale as f32).round() as i32,
|
|
);
|
|
Ok(())
|
|
}
|
|
fn projectiles(
|
|
&mut self,
|
|
canvas: &mut Canvas,
|
|
world: &World,
|
|
hostile: bool,
|
|
alpha: f32,
|
|
) -> Result<()> {
|
|
for p in world.projectiles.iter().filter(|p| p.hostile == hostile) {
|
|
let def = self.active.registry.weapons[&p.weapon].clone();
|
|
let x = p.px as f32 + (p.x - p.px) as f32 * alpha;
|
|
let y = p.py as f32 + (p.y - p.py) as f32 * alpha;
|
|
if p.legacy.is_some() || p.legacy_enemy.is_some() {
|
|
if let Some(shot) = &p.legacy {
|
|
let beam = crate::legacy_tables::weapons()
|
|
.into_iter()
|
|
.find(|lib| Some(lib.index) == def.legacy)
|
|
.map_or(0, |lib| lib.beam);
|
|
if beam == 1 {
|
|
let from = [
|
|
world.player.px as f32
|
|
+ (world.player.x - world.player.px) as f32 * alpha
|
|
+ 16.0,
|
|
world.player.py as f32
|
|
+ (world.player.y - world.player.py) as f32 * alpha
|
|
+ 16.0,
|
|
];
|
|
for (offset, color) in [(1.0, 69), (-1.0, 69), (0.0, 64)] {
|
|
tracer_line(
|
|
canvas,
|
|
[from[0] + offset, from[1]],
|
|
[shot.movement.x, shot.movement.y],
|
|
color,
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
if beam == 2 {
|
|
let frame = shot.art_frame;
|
|
let sprite_asset = World::projectile_asset(p, &self.active.registry)
|
|
.context("native beam has no artwork")?
|
|
+ frame;
|
|
let mut row = shot.movement.ty as f32;
|
|
while row < y {
|
|
self.native_blit(canvas, sprite_asset, x, row)?;
|
|
row += 3.0;
|
|
}
|
|
let power_frame = p.age as u32 % 4;
|
|
let death_ray = def.legacy == Some(14);
|
|
self.native_blit(
|
|
canvas,
|
|
if death_ray { 0x10239 } else { 0x1023d } + power_frame,
|
|
x - if death_ray { 4.0 } else { 0.0 },
|
|
y,
|
|
)?;
|
|
let hit = self.raw(0x101f1 + power_frame)?;
|
|
let hit_y = shot.movement.ty - 8;
|
|
if hit_y > 0 {
|
|
self.native_blit(
|
|
canvas,
|
|
0x101f1 + power_frame,
|
|
x - (hit.width / 4) as f32,
|
|
hit_y as f32,
|
|
)?;
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
if let Some(shot) = &p.legacy_enemy {
|
|
if shot.kind == 5 {
|
|
let frame = shot.art_frame % shot.frames.max(1);
|
|
let mut row = y;
|
|
while row < shot.movement.ty as f32 {
|
|
self.native_blit(canvas, shot.asset + frame, x, row)?;
|
|
row += 3.0;
|
|
}
|
|
self.native_blit(canvas, 0x10245 + frame, x, y)?;
|
|
let hit = self.raw(0x101f1 + frame)?;
|
|
let hit_y = shot.movement.ty - 8;
|
|
if hit_y > 0 && hit_y < 200 {
|
|
self.native_blit(
|
|
canvas,
|
|
0x101f1 + frame,
|
|
x - (hit.width / 4) as f32,
|
|
hit_y as f32,
|
|
)?;
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
if let Some(asset) = World::projectile_asset(p, &self.active.registry) {
|
|
let frame = if let Some(shot) = &p.legacy_enemy {
|
|
shot.art_frame % shot.frames.max(1)
|
|
} else {
|
|
p.legacy.as_ref().map_or(0, |shot| shot.art_frame)
|
|
};
|
|
self.native_blit(canvas, asset + frame, x, y)?;
|
|
}
|
|
} else if matches!(def.behavior, crate::content::WeaponBehavior::Beam) {
|
|
canvas.rect(x as i32, 0, 2, world.player.y, 255);
|
|
} else if let Some(art) = def.art {
|
|
let owner = self.active.registry.owners[&p.weapon].clone();
|
|
self.art(
|
|
canvas,
|
|
&art,
|
|
&owner,
|
|
p.age / 2,
|
|
x - art.logical_width as f32 / 2.0,
|
|
y - art.logical_height as f32 / 2.0,
|
|
)?;
|
|
} else {
|
|
canvas.rect(x as i32, y as i32, 2, 4, if p.hostile { 34 } else { 255 });
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
fn secondary_effects(
|
|
&mut self,
|
|
canvas: &mut Canvas,
|
|
world: &World,
|
|
layer: u8,
|
|
alpha: f32,
|
|
) -> Result<()> {
|
|
for effect in world
|
|
.visual_effects
|
|
.iter()
|
|
.filter(|effect| effect.kind.layer() == layer)
|
|
{
|
|
let (asset, frames) = effect.kind.artwork();
|
|
let asset = asset + effect.age.saturating_sub(1).min(frames - 1);
|
|
let original = self.raw(asset)?;
|
|
let x = effect.px as f32 + (effect.x - effect.px) as f32 * alpha
|
|
- if effect.kind.top_left() {
|
|
0.0
|
|
} else {
|
|
original.width as f32 / 2.0
|
|
};
|
|
let y = effect.py as f32 + (effect.y - effect.py) as f32 * alpha
|
|
- if effect.kind.top_left() {
|
|
0.0
|
|
} else {
|
|
original.height as f32 / 2.0
|
|
};
|
|
if effect.kind.lighting_mask() {
|
|
// These native shapes brighten existing palette indices;
|
|
// their stored mask colors are never foreground artwork.
|
|
let hd = if self.config.hd_mode {
|
|
self.hd.sprite(asset, canvas.scale, &self.palette)?
|
|
} else {
|
|
None
|
|
};
|
|
let mask = hd.unwrap_or_else(|| nearest(&original, canvas.scale));
|
|
canvas.shade(
|
|
&mask,
|
|
(x * canvas.scale as f32).round() as i32,
|
|
(y * canvas.scale as f32).round() as i32,
|
|
&self.light_table,
|
|
);
|
|
} else {
|
|
self.native_blit(canvas, asset, x, y)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
fn effects(
|
|
&mut self,
|
|
canvas: &mut Canvas,
|
|
world: &World,
|
|
ground: bool,
|
|
alpha: f32,
|
|
) -> Result<()> {
|
|
for effect in world.effects.iter().filter(|e| e.ground == ground) {
|
|
let (asset, frames) = effect.kind.artwork();
|
|
let frame = effect.age.saturating_sub(1).min(frames - 1);
|
|
let original = self.raw(asset + frame)?;
|
|
let hd = if self.config.hd_mode {
|
|
self.hd.sprite(asset + frame, canvas.scale, &self.palette)?
|
|
} else {
|
|
None
|
|
};
|
|
let sprite = match hd {
|
|
Some(sprite) => sprite,
|
|
None => self
|
|
.cache
|
|
.get(&original, canvas.scale, prefers_nearest(asset + frame))?
|
|
.clone(),
|
|
};
|
|
let x = effect.px as f32 + (effect.x - effect.px) as f32 * alpha
|
|
- original.width as f32 / 2.0;
|
|
let y = effect.py as f32 + (effect.y - effect.py) as f32 * alpha
|
|
- original.height as f32 / 2.0;
|
|
canvas.blit(
|
|
&sprite,
|
|
(x * canvas.scale as f32).round() as i32,
|
|
(y * canvas.scale as f32).round() as i32,
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
fn shop_info(&self, id: &ContentId) -> Result<ShopInfo> {
|
|
shop_info(
|
|
&self.pilot,
|
|
&self.active.registry,
|
|
id,
|
|
self.require_pilot().err().map(|error| format!("{error:#}")),
|
|
)
|
|
}
|
|
fn shop_panel(&mut self, canvas: &mut Canvas) -> Result<()> {
|
|
self.menu_panel(canvas, 164, 43, 138, 126);
|
|
let Some(id) = self
|
|
.selected
|
|
.checked_sub(1)
|
|
.and_then(|index| self.shop_ids().get(index).cloned())
|
|
else {
|
|
for (index, line) in wrap_text(&self.font, "Choose BUY or SELL, then select equipment to view its image, price and flight behavior. Click this panel or press D/X for full details.", 126).iter().enumerate() {
|
|
self.font.text(canvas, 172, 51 + index as i32 * 10, line, 8);
|
|
}
|
|
return Ok(());
|
|
};
|
|
let info = self.shop_info(&id)?;
|
|
for (index, line) in wrap_text(&self.font, &info.name, 126)
|
|
.iter()
|
|
.take(2)
|
|
.enumerate()
|
|
{
|
|
self.font.text(canvas, 172, 45 + index as i32 * 8, line, 48);
|
|
}
|
|
if let Some(art) = self.active.registry.items[&id].art.clone() {
|
|
let owner = self
|
|
.active
|
|
.registry
|
|
.owners
|
|
.get(&id)
|
|
.cloned()
|
|
.unwrap_or_else(|| "base".into());
|
|
// Render with the original logical canvas first so the normal SD/HD
|
|
// asset path and transparency rules remain intact in this preview.
|
|
let mut preview = Canvas {
|
|
image: Sprite::blank(
|
|
art.logical_width as usize * canvas.scale,
|
|
art.logical_height as usize * canvas.scale,
|
|
),
|
|
scale: canvas.scale,
|
|
};
|
|
self.art(&mut preview, &art, &owner, 0, 0.0, 0.0)?;
|
|
let factor = (40.0 / art.logical_width as f32).min(32.0 / art.logical_height as f32);
|
|
let width = (art.logical_width as f32 * factor).round().max(1.0) as usize;
|
|
let height = (art.logical_height as f32 * factor).round().max(1.0) as usize;
|
|
let mut thumbnail = Sprite::blank(width * canvas.scale, height * canvas.scale);
|
|
for y in 0..thumbnail.height {
|
|
for x in 0..thumbnail.width {
|
|
thumbnail.pixels[y * thumbnail.width + x] =
|
|
preview.image.pixels[(y * preview.image.height / thumbnail.height)
|
|
* preview.image.width
|
|
+ x * preview.image.width / thumbnail.width];
|
|
}
|
|
}
|
|
canvas.blit(
|
|
&thumbnail,
|
|
(172 + (40 - width as i32) / 2) * canvas.scale as i32,
|
|
(64 + (32 - height as i32) / 2) * canvas.scale as i32,
|
|
);
|
|
} else {
|
|
self.font.text(canvas, 172, 77, "NO IMAGE", 144);
|
|
}
|
|
for (y, text) in [
|
|
(64, info.kind.to_string()),
|
|
(73, format!("Buy ${}", info.price)),
|
|
(82, format!("Sell ${}", info.resale)),
|
|
(91, format!("Owned {}", info.owned)),
|
|
] {
|
|
self.font.text(
|
|
canvas,
|
|
216,
|
|
y,
|
|
&fit_text(&self.font, &text, 82),
|
|
if y == 64 { 48 } else { 8 },
|
|
);
|
|
}
|
|
for (index, line) in info.stats.iter().take(4).enumerate() {
|
|
self.font.text(
|
|
canvas,
|
|
172,
|
|
104 + index as i32 * 8,
|
|
&fit_text(&self.font, line, 126),
|
|
8,
|
|
);
|
|
}
|
|
for (index, line) in wrap_text(&self.font, &info.description, 126)
|
|
.iter()
|
|
.take(2)
|
|
.enumerate()
|
|
{
|
|
self.font.text(canvas, 172, 136 + index as i32 * 8, line, 8);
|
|
}
|
|
let status = if self.shop_selling {
|
|
format!("SELL: {}", info.sell)
|
|
} else {
|
|
format!("BUY: {}", info.buy)
|
|
};
|
|
for (index, line) in wrap_text(&self.font, &status, 126)
|
|
.iter()
|
|
.take(2)
|
|
.enumerate()
|
|
{
|
|
self.font.text(
|
|
canvas,
|
|
172,
|
|
154 + index as i32 * 8,
|
|
line,
|
|
if status.ends_with("Ready") { 48 } else { 34 },
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
fn mission_panel(&mut self, canvas: &mut Canvas) -> Result<()> {
|
|
let Some(id) = self.missions().get(self.selected).cloned() else {
|
|
return Ok(());
|
|
};
|
|
let mission = self.active.registry.missions[&id].clone();
|
|
let owner = self
|
|
.active
|
|
.registry
|
|
.owners
|
|
.get(&id)
|
|
.cloned()
|
|
.unwrap_or_else(|| "base".into());
|
|
self.menu_panel(canvas, 152, 43, 150, 126);
|
|
for (index, line) in wrap_text(&self.font, &mission.name, 132)
|
|
.iter()
|
|
.take(2)
|
|
.enumerate()
|
|
{
|
|
self.font.text(canvas, 160, 48 + index as i32 * 9, line, 48);
|
|
}
|
|
if let Some(mut art) = mission.art.clone() {
|
|
let height = (132 * art.logical_height / art.logical_width).min(61);
|
|
art.logical_width = 132;
|
|
art.logical_height = height;
|
|
self.art(canvas, &art, &owner, 0, 160.0, 70.0)?;
|
|
} else if mission.columns > 0 {
|
|
let columns = mission.columns as usize;
|
|
let tile_width = (132 / columns.min(12)).max(1);
|
|
for position in 0..(columns * 4).min(mission.layout.len()) {
|
|
if position % columns >= 12 {
|
|
continue;
|
|
}
|
|
if let Some(tile) = mission.tiles.get(mission.layout[position]) {
|
|
let mut art = tile.art.clone();
|
|
art.logical_width = tile_width as u32;
|
|
art.logical_height = 15;
|
|
self.art(
|
|
canvas,
|
|
&art,
|
|
&owner,
|
|
0,
|
|
(160 + position % columns * tile_width) as f32,
|
|
(70 + position / columns * 15) as f32,
|
|
)?;
|
|
}
|
|
}
|
|
}
|
|
self.font.text(
|
|
canvas,
|
|
160,
|
|
138,
|
|
if self.pilot.completed.contains(&id) {
|
|
"STATUS COMPLETE"
|
|
} else if self.pilot.mission == id {
|
|
"STATUS NEXT MISSION"
|
|
} else {
|
|
"STATUS AVAILABLE"
|
|
},
|
|
1,
|
|
);
|
|
self.font.text(
|
|
canvas,
|
|
160,
|
|
149,
|
|
&format!(
|
|
"{} DIFFICULTY",
|
|
["TRAINING", "ROOKIE", "VETERAN", "ELITE"][self.config.difficulty as usize]
|
|
),
|
|
144,
|
|
);
|
|
self.font.text(canvas, 160, 160, "ENTER / A TO LAUNCH", 8);
|
|
Ok(())
|
|
}
|
|
fn menu_portrait(
|
|
&mut self,
|
|
canvas: &mut Canvas,
|
|
portrait: usize,
|
|
x: i32,
|
|
y: i32,
|
|
) -> Result<()> {
|
|
let name = ["WMALEID_PIC", "BMALEID_PIC", "WFMALEID_PIC", "BFMALEID_PIC"][portrait.min(3)];
|
|
if let Ok(id) = self.catalog.id(name) {
|
|
let image = self.raw(id)?;
|
|
let hd = if self.config.hd_mode {
|
|
self.hd.sprite(id, canvas.scale, &self.palette)?
|
|
} else {
|
|
None
|
|
};
|
|
canvas.blit(
|
|
&hd.unwrap_or_else(|| nearest(&image, canvas.scale)),
|
|
x * canvas.scale as i32,
|
|
y * canvas.scale as i32,
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
fn menu_panel(&self, canvas: &mut Canvas, x: i32, y: i32, width: i32, height: i32) {
|
|
// Menus use the full-color illustration beneath a subtle navy tint.
|
|
// In-flight overlays remain indexed so they preserve the mission palette.
|
|
let panel = if self.world.is_none() {
|
|
crate::menu_art::MENU_PANEL
|
|
} else {
|
|
closest(&self.palette, [12, 20, 27])
|
|
};
|
|
let edge = closest(&self.palette, [58, 78, 89]);
|
|
canvas.rect(x, y, width, height, panel);
|
|
canvas.rect(x, y, width, 1, edge);
|
|
canvas.rect(x, y + height - 1, width, 1, edge);
|
|
canvas.rect(x, y, 1, height, edge);
|
|
canvas.rect(x + width - 1, y, 1, height, edge);
|
|
}
|
|
fn classic_menu(&mut self, canvas: &mut Canvas) -> Result<()> {
|
|
self.menu_panel(canvas, 12, 42, 144, 126);
|
|
self.font.text(canvas, 22, 49, "PILOT IDENTIFICATION", 144);
|
|
let portrait = self
|
|
.pilot
|
|
.legacy_progress
|
|
.as_ref()
|
|
.map_or(0, |p| portrait_index(p.portrait));
|
|
self.menu_portrait(canvas, portrait, 22, 62)?;
|
|
canvas.rect(21, 61, 42, 1, 144);
|
|
canvas.rect(21, 102, 42, 1, 144);
|
|
self.menu_font.text(
|
|
canvas,
|
|
70,
|
|
62,
|
|
&fit_text(&self.menu_font, &self.pilot.callsign.to_uppercase(), 78),
|
|
48,
|
|
);
|
|
for (index, line) in wrap_text(&self.font, &self.pilot.name, 78)
|
|
.iter()
|
|
.take(2)
|
|
.enumerate()
|
|
{
|
|
self.font.text(canvas, 70, 76 + index as i32 * 9, line, 8);
|
|
}
|
|
self.font
|
|
.text(canvas, 70, 94, &format!("ID {:04}", self.slot), 144);
|
|
self.font.text(
|
|
canvas,
|
|
22,
|
|
110,
|
|
&fit_text(
|
|
&self.font,
|
|
&format!("CREDITS ${}", self.pilot.credits),
|
|
124,
|
|
),
|
|
1,
|
|
);
|
|
canvas.rect(22, 120, 124, 1, closest(&self.palette, [58, 78, 89]));
|
|
self.font.text(canvas, 22, 125, "NEXT MISSION", 144);
|
|
let mission = self
|
|
.active
|
|
.registry
|
|
.missions
|
|
.get(&self.pilot.mission)
|
|
.map(|m| m.name.clone())
|
|
.unwrap_or_else(|| self.pilot.mission.0.clone());
|
|
for (index, line) in wrap_text(&self.font, &mission, 124)
|
|
.iter()
|
|
.take(2)
|
|
.enumerate()
|
|
{
|
|
self.font.text(canvas, 22, 136 + index as i32 * 9, line, 1);
|
|
}
|
|
self.font.text(
|
|
canvas,
|
|
22,
|
|
157,
|
|
&fit_text(
|
|
&self.font,
|
|
&format!(
|
|
"{} / {} MODS",
|
|
["TRAINING", "ROOKIE", "VETERAN", "ELITE"][self.config.difficulty as usize],
|
|
self.active.pins.len()
|
|
),
|
|
124,
|
|
),
|
|
144,
|
|
);
|
|
let labels: &[&str] = if self.screen == Screen::Main {
|
|
&[
|
|
"RETURN TO HANGAR",
|
|
"NEW PILOT",
|
|
"LOAD / IMPORT",
|
|
"OPTIONS",
|
|
"MODS",
|
|
"HELP / CREDITS",
|
|
"WATCH INTRO",
|
|
"QUIT",
|
|
]
|
|
} else {
|
|
&[
|
|
"FLY MISSION",
|
|
"SELECT MISSION",
|
|
"SUPPLY ROOM",
|
|
"INVENTORY",
|
|
"SAVE PILOT",
|
|
"EDIT PILOT ID",
|
|
"MAIN MENU",
|
|
]
|
|
};
|
|
for (index, label) in labels.iter().enumerate() {
|
|
let y = 42 + index as i32 * 16;
|
|
self.menu_panel(canvas, 164, y, 136, 15);
|
|
if index == self.selected {
|
|
canvas.rect(165, y + 1, 134, 13, closest(&self.palette, [35, 42, 40]));
|
|
canvas.rect(164, y, 136, 1, 48);
|
|
canvas.rect(164, y + 1, 3, 13, 48);
|
|
}
|
|
self.menu_font.text(
|
|
canvas,
|
|
174,
|
|
y + 3,
|
|
&fit_text(&self.menu_font, label, 120),
|
|
if index == self.selected { 48 } else { 1 },
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
fn main_menu(&mut self, canvas: &mut Canvas) -> Result<()> {
|
|
use crate::menu_art::{
|
|
MENU_ACCENT, MENU_CARD, MENU_FOCUS, MENU_MUTED, MENU_RULE, MENU_TEXT,
|
|
};
|
|
|
|
// The illustration leaves the right side open for navigation. Keep the
|
|
// ship and coastal view exposed instead of framing the entire screen.
|
|
let mut title = Canvas {
|
|
scale: canvas.scale * 2,
|
|
image: Sprite::blank(
|
|
self.menu_font.text_width("RAPTOR") * canvas.scale * 2,
|
|
self.menu_font.height() * canvas.scale * 2,
|
|
),
|
|
};
|
|
self.menu_font.text(&mut title, 0, 0, "RAPTOR", MENU_TEXT);
|
|
canvas.blit(
|
|
&title.image,
|
|
(MAIN_MENU_X + 10) * canvas.scale as i32,
|
|
22 * canvas.scale as i32,
|
|
);
|
|
self.font.text(
|
|
canvas,
|
|
MAIN_MENU_X + 10,
|
|
43,
|
|
"CALL OF THE SHADOWS",
|
|
MENU_MUTED,
|
|
);
|
|
canvas.rect(MAIN_MENU_X, MAIN_MENU_Y - 5, 136, 1, MENU_RULE);
|
|
for (index, label) in [
|
|
"RETURN TO HANGAR",
|
|
"NEW PILOT",
|
|
"LOAD / IMPORT",
|
|
"OPTIONS",
|
|
"MODS",
|
|
"HELP / CREDITS",
|
|
"WATCH INTRO",
|
|
"QUIT",
|
|
]
|
|
.iter()
|
|
.enumerate()
|
|
{
|
|
let y = MAIN_MENU_Y + index as i32 * 16;
|
|
if index == self.selected {
|
|
canvas.rect(MAIN_MENU_X, y, 136, 15, MENU_FOCUS);
|
|
canvas.rect(MAIN_MENU_X, y + 1, 2, 13, MENU_ACCENT);
|
|
}
|
|
self.menu_font.text(
|
|
canvas,
|
|
MAIN_MENU_X + 10,
|
|
y + 3,
|
|
&fit_text(&self.menu_font, label, 120),
|
|
if index == self.selected {
|
|
MENU_ACCENT
|
|
} else {
|
|
MENU_TEXT
|
|
},
|
|
);
|
|
}
|
|
|
|
canvas.rect(20, 24, 144, 91, MENU_CARD);
|
|
canvas.rect(20, 24, 32, 1, MENU_ACCENT);
|
|
self.font
|
|
.text(canvas, 30, 31, "PILOT IDENTIFICATION", MENU_MUTED);
|
|
let portrait = self
|
|
.pilot
|
|
.legacy_progress
|
|
.as_ref()
|
|
.map_or(0, |progress| portrait_index(progress.portrait));
|
|
self.menu_portrait(canvas, portrait, 30, 45)?;
|
|
self.menu_font.text(
|
|
canvas,
|
|
78,
|
|
45,
|
|
&fit_text(&self.menu_font, &self.pilot.callsign.to_uppercase(), 72),
|
|
MENU_ACCENT,
|
|
);
|
|
for (index, line) in wrap_text(&self.font, &self.pilot.name, 72)
|
|
.iter()
|
|
.take(2)
|
|
.enumerate()
|
|
{
|
|
self.font
|
|
.text(canvas, 78, 59 + index as i32 * 9, line, MENU_TEXT);
|
|
}
|
|
self.font
|
|
.text(canvas, 78, 79, &format!("ID {:04}", self.slot), MENU_MUTED);
|
|
self.font.text(
|
|
canvas,
|
|
30,
|
|
92,
|
|
&fit_text(
|
|
&self.font,
|
|
&format!("CREDITS ${}", self.pilot.credits),
|
|
120,
|
|
),
|
|
MENU_TEXT,
|
|
);
|
|
let mission = self
|
|
.active
|
|
.registry
|
|
.missions
|
|
.get(&self.pilot.mission)
|
|
.map(|mission| mission.name.as_str())
|
|
.unwrap_or(&self.pilot.mission.0);
|
|
self.font.text(
|
|
canvas,
|
|
30,
|
|
104,
|
|
&fit_text(&self.font, &format!("NEXT {mission}"), 120),
|
|
MENU_MUTED,
|
|
);
|
|
|
|
canvas.rect(20, 204, 364, 18, MENU_CARD);
|
|
self.font.text(
|
|
canvas,
|
|
30,
|
|
207,
|
|
"UP/DOWN choose ENTER/A accept ESC/B back",
|
|
MENU_MUTED,
|
|
);
|
|
let context = if self.message.is_empty() {
|
|
menu_context(Screen::Main, self.selected).unwrap_or("")
|
|
} else {
|
|
&self.message
|
|
};
|
|
self.font.text(
|
|
canvas,
|
|
30,
|
|
216,
|
|
&fit_text(&self.font, context, 344),
|
|
if self.message.to_ascii_lowercase().contains("failed") {
|
|
34
|
|
} else {
|
|
MENU_TEXT
|
|
},
|
|
);
|
|
Ok(())
|
|
}
|
|
fn hud_asset(&mut self, canvas: &mut Canvas, asset: u32, x: i32, y: i32) -> Result<()> {
|
|
self.ensure_hd();
|
|
if self.config.hd_mode {
|
|
if let Some(sprite) = self.hd.sprite(asset, canvas.scale, &self.palette)? {
|
|
canvas.blit(&sprite, x * canvas.scale as i32, y * canvas.scale as i32);
|
|
return Ok(());
|
|
}
|
|
}
|
|
let original = self.raw(asset)?;
|
|
let sprite = self.cache.get(&original, canvas.scale, true)?;
|
|
canvas.blit(sprite, x * canvas.scale as i32, y * canvas.scale as i32);
|
|
Ok(())
|
|
}
|
|
fn hud_weapon(&mut self, canvas: &mut Canvas, item: &ContentId) -> Result<()> {
|
|
self.ensure_hd();
|
|
let Some(definition) = self.active.registry.items.get(item) else {
|
|
return Ok(());
|
|
};
|
|
let Some(art) = definition.art.clone() else {
|
|
return Ok(());
|
|
};
|
|
let owner = self.active.registry.owners[item].clone();
|
|
let source = if let Some(asset) = art
|
|
.asset
|
|
.filter(|_| !(self.config.hd_mode && art.hd_png.is_some()))
|
|
{
|
|
let detailed = if self.config.hd_mode {
|
|
self.hd.sprite(asset, canvas.scale, &self.palette)?
|
|
} else {
|
|
None
|
|
};
|
|
match detailed {
|
|
Some(sprite) => sprite,
|
|
None => self.raw(asset)?,
|
|
}
|
|
} else {
|
|
art_sprite_variant(
|
|
&self.catalog,
|
|
&self.active.registry,
|
|
&owner,
|
|
&art,
|
|
0,
|
|
self.config.hd_mode,
|
|
)?
|
|
};
|
|
// Original equipment icons keep their native canvas. Mod equipment
|
|
// fits the same slot, including its transparent margins and HD detail.
|
|
let logical_w = art.logical_width as usize;
|
|
let logical_h = art.logical_height as usize;
|
|
let shrink = (18.0 / logical_w as f32)
|
|
.min(18.0 / logical_h as f32)
|
|
.min(1.0);
|
|
let width = ((logical_w as f32 * shrink).round() as usize).max(1) * canvas.scale;
|
|
let height = ((logical_h as f32 * shrink).round() as usize).max(1) * canvas.scale;
|
|
let mut icon = Sprite::blank(width, height);
|
|
for y in 0..height {
|
|
for x in 0..width {
|
|
icon.pixels[y * width + x] = source.pixels
|
|
[(y * source.height / height) * source.width + x * source.width / width];
|
|
}
|
|
}
|
|
canvas.blit(&icon, 286 * canvas.scale as i32, 2 * canvas.scale as i32);
|
|
Ok(())
|
|
}
|
|
fn hud(&mut self, canvas: &mut Canvas, world: &World) -> Result<()> {
|
|
canvas.rect(0, 0, 16, 200, 0);
|
|
canvas.rect(304, 0, 16, 200, 0);
|
|
hud_shield_bar(canvas, 8, world.player.super_shield);
|
|
hud_shield_bar(canvas, 308, world.player.shield);
|
|
// RAP_PrintNum's dollar glyph, one-pixel gap and eight-pixel digits.
|
|
self.hud_asset(canvas, 0x1000f, 119, 2)?;
|
|
for (position, digit) in format!("{:08}", world.player.credits).bytes().enumerate() {
|
|
self.hud_asset(
|
|
canvas,
|
|
0x10005 + (digit - b'0') as u32,
|
|
128 + position as i32 * 8,
|
|
2,
|
|
)?;
|
|
}
|
|
if let Some(item) = &world.player.selected {
|
|
if world.player.inventory.get(item).is_some_and(|n| *n > 0) {
|
|
self.hud_weapon(canvas, item)?;
|
|
}
|
|
}
|
|
// Equipped charge and reserve modules are distinct inventory units.
|
|
if world
|
|
.player
|
|
.inventory
|
|
.contains_key(&ContentId::from("base:super_shield"))
|
|
{
|
|
for module in 0..=world.player.super_shield_reserves.len() {
|
|
self.hud_asset(canvas, 0x102e0, 18 + module as i32 * 13, 1)?;
|
|
}
|
|
}
|
|
let bombs = world
|
|
.player
|
|
.inventory
|
|
.get(&ContentId::from("base:mega_bomb"))
|
|
.copied()
|
|
.unwrap_or(0);
|
|
for index in 0..bombs.min(7) {
|
|
self.hud_asset(canvas, 0x102df, 18 + index as i32 * 13, 186)?;
|
|
}
|
|
if bombs > 7 {
|
|
self.font
|
|
.text(canvas, 18 + 7 * 13, 189, &format!("+{}", bombs - 7), 68);
|
|
}
|
|
if world
|
|
.player
|
|
.inventory
|
|
.get(&ContentId::from("base:detect"))
|
|
.is_some_and(|n| *n > 0)
|
|
{
|
|
let level = boss_shield_percent(world, &self.active.registry);
|
|
if level > 0 {
|
|
canvas.rect(109, 191, 102, 8, 74);
|
|
canvas.rect(110, 192, level, 6, 68);
|
|
} else {
|
|
canvas.rect(110 + self.hud_state.sweep as i32, 190, 1, 3, 68);
|
|
canvas.rect(209 - self.hud_state.sweep as i32, 190, 1, 3, 68);
|
|
}
|
|
}
|
|
if world.player.shield <= 10 && self.hud_state.blink {
|
|
if self.hud_state.damage_flashes > 0 {
|
|
let width = self.raw(0x10011)?.width as i32;
|
|
self.hud_asset(canvas, 0x10011, (320 - width) / 2, 173)?;
|
|
}
|
|
let width = self.raw(0x10010)?.width as i32;
|
|
self.hud_asset(canvas, 0x10010, (320 - width) / 2, 182)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
fn ensure_hd(&mut self) {
|
|
if self.config.hd_mode && !self.hd_loaded {
|
|
self.hd = HdAssets::load_lazy(
|
|
&self.catalog.root.join("hd"),
|
|
&self.catalog,
|
|
Some(&self.paths.cache.join("hd-art-v1")),
|
|
);
|
|
self.hd_loaded = true;
|
|
}
|
|
}
|
|
fn player(&mut self, canvas: &mut Canvas, world: &World, alpha: f32) -> Result<()> {
|
|
if !world.draw_player {
|
|
return Ok(());
|
|
}
|
|
let position = [
|
|
world.player.px as f32 + (world.player.x - world.player.px) as f32 * alpha,
|
|
world.player.py as f32 + (world.player.y - world.player.py) as f32 * alpha,
|
|
];
|
|
for [x, y] in player_flame_positions(position, world.player.bank) {
|
|
flame_down(
|
|
canvas,
|
|
x,
|
|
y,
|
|
4,
|
|
(world.tick % 2) as usize,
|
|
&self.flame_tables,
|
|
);
|
|
}
|
|
self.native_blit(
|
|
canvas,
|
|
player_asset_frame(&self.catalog, world)?,
|
|
position[0],
|
|
position[1],
|
|
)
|
|
}
|
|
fn scene(&mut self, canvas: &mut Canvas) -> Result<()> {
|
|
self.ensure_hd();
|
|
let Some(world) = self.world.clone() else {
|
|
return Ok(());
|
|
};
|
|
let mission = self.active.registry.missions[&world.mission].clone();
|
|
let alpha = self.clock.alpha();
|
|
let interp =
|
|
|current: i32, previous: i32| previous as f32 + (current - previous) as f32 * alpha;
|
|
let rows = if mission.columns > 0 {
|
|
mission.layout.len() / mission.columns as usize
|
|
} else {
|
|
0
|
|
};
|
|
let offset = 200 - rows as i32 * 32 + world.scroll;
|
|
let phase = offset.rem_euclid(32);
|
|
let row_origin = offset - phase;
|
|
let owner = self.active.registry.owners[&world.mission].clone();
|
|
let mut visible_tiles = Vec::new();
|
|
for (row, tiles) in mission
|
|
.layout
|
|
.chunks(mission.columns.max(1) as usize)
|
|
.enumerate()
|
|
{
|
|
let y = row_origin + row as i32 * 32;
|
|
if !(-66..234).contains(&y) {
|
|
continue;
|
|
}
|
|
for (col, index) in tiles.iter().enumerate() {
|
|
let n = row * mission.columns as usize + col;
|
|
let art = selected_art(
|
|
&mission.tiles[*index],
|
|
mission.legacy_map.is_some(),
|
|
world.terrain_dead[n],
|
|
world.terrain_settled[n],
|
|
);
|
|
let x = 16 + col as i32 * 32;
|
|
visible_tiles.push((art.clone(), x, y));
|
|
}
|
|
}
|
|
let key = serde_json::to_vec(&(
|
|
&world.mission,
|
|
row_origin,
|
|
canvas.scale,
|
|
self.config.hd_mode,
|
|
&self.palette[..],
|
|
&self.active.pins,
|
|
&visible_tiles,
|
|
))?;
|
|
let shift = if world.tick > 0 { alpha - 1.0 } else { 0.0 };
|
|
let translated_y = ((-34.0 + phase as f32 + shift) * canvas.scale as f32).round() as i32;
|
|
if let Some(bg) = self.terrain_hd.cached_layer(&key) {
|
|
canvas.blit(bg, 0, translated_y);
|
|
} else {
|
|
// Prepare a row-aligned strip with neighboring rows and a two-pixel
|
|
// filtering gutter. The 32 scroll phases only translate this layer.
|
|
let mut terrain = Canvas {
|
|
scale: 1,
|
|
image: Sprite {
|
|
width: 320,
|
|
height: 268,
|
|
pixels: vec![0; 320 * 268],
|
|
},
|
|
};
|
|
let mut prepared_tiles = Vec::new();
|
|
for (art, x, y) in visible_tiles {
|
|
let source = if let Some(id) = art.asset {
|
|
self.raw(id)?
|
|
} else {
|
|
let key = format!("terrain-sd:{owner}:{}", serde_json::to_string(&art)?);
|
|
if let Some(sprite) = self.mod_art.get(&key) {
|
|
sprite.clone()
|
|
} else {
|
|
let sprite = art_sprite_variant(
|
|
&self.catalog,
|
|
&self.active.registry,
|
|
&owner,
|
|
&art,
|
|
0,
|
|
false,
|
|
)?;
|
|
if self
|
|
.mod_art
|
|
.values()
|
|
.map(|s| s.pixels.len() * 2)
|
|
.sum::<usize>()
|
|
+ sprite.pixels.len() * 2
|
|
> 128 * 1024 * 1024
|
|
{
|
|
self.mod_art.clear();
|
|
}
|
|
self.mod_art.insert(key, sprite.clone());
|
|
sprite
|
|
}
|
|
};
|
|
let detailed_original = source.width >= art.logical_width as usize * 2
|
|
&& source.height >= art.logical_height as usize * 2;
|
|
let original = logical_sprite(
|
|
&source,
|
|
art.logical_width as usize,
|
|
art.logical_height as usize,
|
|
)?;
|
|
terrain.blit(&original, x, y + 34);
|
|
prepared_tiles.push((art, x, y, detailed_original));
|
|
}
|
|
let mut bg = upscale(&terrain.image, canvas.scale)?;
|
|
if self.config.hd_mode {
|
|
let mut replacements = Vec::new();
|
|
let mut labels = Vec::new();
|
|
for (art, x, y, detailed_original) in prepared_tiles {
|
|
let replacement =
|
|
if art.hd_png.is_some() || (art.png.is_some() && detailed_original) {
|
|
let mut tile = Canvas {
|
|
scale: canvas.scale,
|
|
image: Sprite::blank(
|
|
art.logical_width as usize * canvas.scale,
|
|
art.logical_height as usize * canvas.scale,
|
|
),
|
|
};
|
|
self.art(&mut tile, &art, &owner, 0, 0.0, 0.0)?;
|
|
Some(tile.image)
|
|
} else if let Some(id) = art.asset {
|
|
self.hd.sprite(id, canvas.scale, &self.palette)?
|
|
} else {
|
|
None
|
|
};
|
|
let Some(replacement) = replacement else {
|
|
continue;
|
|
};
|
|
labels.push(art.asset.map(|id| format!("{id:05x}")).unwrap_or_else(|| {
|
|
art.hd_png.clone().or(art.png.clone()).unwrap_or_default()
|
|
}));
|
|
replacements.push(TerrainReplacement {
|
|
image: replacement,
|
|
position: [x, y + 34],
|
|
logical_size: [art.logical_width as usize, art.logical_height as usize],
|
|
});
|
|
}
|
|
if !replacements.is_empty() {
|
|
let composition =
|
|
self.terrain_hd
|
|
.compose(&bg, &replacements, canvas.scale, &self.palette);
|
|
bg = composition.image;
|
|
for (label, result) in labels.into_iter().zip(composition.results) {
|
|
if result != OverlayResult::Applied {
|
|
let diagnostic = format!(
|
|
"HD terrain {label}: {result:?}; using joined original artwork"
|
|
);
|
|
if !self.hd.diagnostics.contains(&diagnostic) {
|
|
self.hd.diagnostics.push(diagnostic);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
canvas.blit(&bg, 0, translated_y);
|
|
self.terrain_hd.retain_layer(key, bg);
|
|
}
|
|
let player_frame = player_asset_frame(&self.catalog, &world)?;
|
|
for ground in [true, false] {
|
|
let mut shadows = 0;
|
|
for e in &world.enemies {
|
|
let def = self.active.registry.enemies[&e.definition].clone();
|
|
if def.ground != ground
|
|
|| def
|
|
.legacy
|
|
.as_ref()
|
|
.is_some_and(|legacy| legacy.fields[11] == 0)
|
|
{
|
|
continue;
|
|
}
|
|
if shadows >= if ground { 25 } else { 50 } {
|
|
break;
|
|
}
|
|
if let Some(art) = def.art {
|
|
let owner = self.active.registry.owners[&e.definition].clone();
|
|
self.shadow(
|
|
canvas,
|
|
&art,
|
|
&owner,
|
|
if def.legacy.is_some() {
|
|
e.art_frame
|
|
} else {
|
|
e.frame
|
|
},
|
|
interp(e.x, e.px),
|
|
interp(e.y, e.py),
|
|
ground,
|
|
)?;
|
|
shadows += 1;
|
|
}
|
|
}
|
|
if !ground && shadows < 50 && world.draw_player {
|
|
let mask = self.raw(player_frame)?;
|
|
shade_air_shadow(
|
|
canvas,
|
|
&mask,
|
|
interp(world.player.x, world.player.px),
|
|
interp(world.player.y, world.player.py),
|
|
&self.air_shadow_table,
|
|
);
|
|
}
|
|
if !ground {
|
|
self.effects(canvas, &world, true, alpha)?;
|
|
self.secondary_effects(canvas, &world, 0, alpha)?;
|
|
}
|
|
for e in &world.enemies {
|
|
let def = self.active.registry.enemies[&e.definition].clone();
|
|
if def.ground != ground {
|
|
continue;
|
|
}
|
|
if let Some(art) = def.art {
|
|
let owner = self.active.registry.owners[&e.definition].clone();
|
|
self.art(
|
|
canvas,
|
|
&art,
|
|
&owner,
|
|
if def.legacy.is_some() {
|
|
e.art_frame
|
|
} else {
|
|
e.frame
|
|
},
|
|
interp(e.x, e.px),
|
|
interp(e.y, e.py),
|
|
)?;
|
|
}
|
|
}
|
|
}
|
|
self.projectiles(canvas, &world, false, alpha)?;
|
|
for pickup in &world.pickups {
|
|
let previous = pickup
|
|
.legacy
|
|
.as_ref()
|
|
.and_then(|legacy| legacy.previous_art_position)
|
|
.unwrap_or([pickup.x, pickup.y]);
|
|
let pickup_x = interp(pickup.x, previous[0]);
|
|
let pickup_y = interp(pickup.y, previous[1]);
|
|
if pickup
|
|
.legacy
|
|
.as_ref()
|
|
.is_some_and(|legacy| legacy.countdown.is_some())
|
|
{
|
|
if let Ok(asset) = self.catalog.id("N$_PIC") {
|
|
let original = self.raw(asset)?;
|
|
let hd = if self.config.hd_mode {
|
|
self.hd.sprite(asset, canvas.scale, &self.palette)?
|
|
} else {
|
|
None
|
|
};
|
|
let sprite = match hd {
|
|
Some(sprite) => sprite,
|
|
None => self.cache.get(&original, canvas.scale, false)?.clone(),
|
|
};
|
|
canvas.blit(
|
|
&sprite,
|
|
(pickup_x * canvas.scale as f32).round() as i32,
|
|
(pickup_y * canvas.scale as f32).round() as i32,
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
if let Some(art) = self.active.registry.items[&pickup.item].art.clone() {
|
|
let owner = self.active.registry.owners[&pickup.item].clone();
|
|
self.art(
|
|
canvas,
|
|
&art,
|
|
&owner,
|
|
pickup
|
|
.legacy
|
|
.as_ref()
|
|
.map_or((world.tick / 3) as u32, |legacy| legacy.art_frame),
|
|
pickup_x,
|
|
pickup_y,
|
|
)?;
|
|
}
|
|
if let Some(legacy) = &pickup.legacy {
|
|
let asset = 0x10225 + legacy.glow_frame;
|
|
let mask = self.raw(asset)?;
|
|
let x = pickup_x + 8.0 - mask.width as f32 / 2.0;
|
|
let y = pickup_y + 8.0 - mask.height as f32 / 2.0;
|
|
let hd = if self.config.hd_mode {
|
|
self.hd.sprite(asset, canvas.scale, &self.palette)?
|
|
} else {
|
|
None
|
|
};
|
|
let mask = hd.unwrap_or_else(|| nearest(&mask, canvas.scale));
|
|
canvas.shade(
|
|
&mask,
|
|
(x * canvas.scale as f32).round() as i32,
|
|
(y * canvas.scale as f32).round() as i32,
|
|
&self.light_table,
|
|
);
|
|
}
|
|
}
|
|
self.secondary_effects(canvas, &world, 1, alpha)?;
|
|
self.player(canvas, &world, alpha)?;
|
|
self.effects(canvas, &world, false, alpha)?;
|
|
self.secondary_effects(canvas, &world, 2, alpha)?;
|
|
self.projectiles(canvas, &world, true, alpha)?;
|
|
crate::render::draw_world_lightning_arcs(canvas, &world, &self.palette, alpha);
|
|
self.hud(canvas, &world)?;
|
|
Ok(())
|
|
}
|
|
fn draw(&mut self) -> Result<Canvas> {
|
|
let canvas = self.draw_content()?;
|
|
if self.world.is_none() && self.cinematic.is_none() && self.screen != Screen::Main {
|
|
let mut frame = Canvas {
|
|
scale: canvas.scale,
|
|
image: Sprite::blank(MENU_WIDTH * canvas.scale, MENU_HEIGHT * canvas.scale),
|
|
};
|
|
frame.blit(
|
|
&canvas.image,
|
|
MENU_OFFSET[0] * canvas.scale as i32,
|
|
MENU_OFFSET[1] * canvas.scale as i32,
|
|
);
|
|
Ok(frame)
|
|
} else {
|
|
Ok(canvas)
|
|
}
|
|
}
|
|
fn draw_content(&mut self) -> Result<Canvas> {
|
|
self.ensure_hd();
|
|
let mut canvas = if self.screen == Screen::Main && self.cinematic.is_none() {
|
|
Canvas {
|
|
scale: self.config.scale,
|
|
image: Sprite::blank(
|
|
MENU_WIDTH * self.config.scale,
|
|
MENU_HEIGHT * self.config.scale,
|
|
),
|
|
}
|
|
} else {
|
|
Canvas::new(self.config.scale)
|
|
};
|
|
canvas.clear(if self.world.is_some() {
|
|
0
|
|
} else {
|
|
crate::assets::TRANSPARENT
|
|
});
|
|
if let Some(movie) = &self.cinematic {
|
|
let replacement = if self.config.hd_mode {
|
|
if let Some((name, palette)) = movie.still_picture() {
|
|
self.hd
|
|
.sprite_static_palette(self.catalog.id(name)?, canvas.scale, palette)?
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
canvas.blit(
|
|
&replacement.unwrap_or_else(|| nearest(&movie.image, canvas.scale)),
|
|
0,
|
|
0,
|
|
);
|
|
return Ok(canvas);
|
|
}
|
|
if self.world.is_some() {
|
|
self.scene(&mut canvas)?;
|
|
if self.screen == Screen::Game {
|
|
return Ok(canvas);
|
|
}
|
|
}
|
|
if self.screen == Screen::Main {
|
|
self.main_menu(&mut canvas)?;
|
|
return Ok(canvas);
|
|
}
|
|
let classic = matches!(self.screen, Screen::Main | Screen::Hangar);
|
|
self.menu_panel(&mut canvas, 12, 8, 296, if classic { 31 } else { 184 });
|
|
if classic {
|
|
self.menu_panel(&mut canvas, 12, 170, 296, 24);
|
|
}
|
|
let title = match self.screen {
|
|
Screen::Main => "RAPTOR",
|
|
Screen::Hangar => "HANGAR",
|
|
Screen::Extras => "HELP / CREDITS",
|
|
Screen::PilotForm => {
|
|
if self.pilot_form.as_ref().is_some_and(|form| form.creating) {
|
|
"REGISTER NEW PILOT"
|
|
} else {
|
|
"EDIT PILOT ID"
|
|
}
|
|
}
|
|
Screen::Missions => "MISSION SELECT",
|
|
Screen::Shop => {
|
|
if self.shop_selling {
|
|
"HANGAR / SHOP - SELL"
|
|
} else {
|
|
"HANGAR / SHOP - BUY"
|
|
}
|
|
}
|
|
Screen::ShopDetails => "EQUIPMENT DETAILS",
|
|
Screen::Inventory => "INVENTORY / EQUIPMENT",
|
|
Screen::Mods => "MOD MANAGER",
|
|
Screen::Options => "OPTIONS",
|
|
Screen::Pause => "PAUSED",
|
|
Screen::Results => "FLIGHT REPORT",
|
|
Screen::Help => "FLIGHT CONTROLS",
|
|
Screen::Credits => "CREDITS",
|
|
Screen::Pilots => "LOAD PILOT",
|
|
Screen::LegacyPilots => "IMPORT LEGACY PILOT",
|
|
Screen::ConfirmDisable => "ARE YOU SURE?",
|
|
Screen::ConfirmLoadMods => "LOAD WITH DIFFERENT MODS?",
|
|
Screen::Diagnostics => "MOD / SAVE DIAGNOSTICS",
|
|
Screen::Bindings => "KEYBOARD BINDINGS",
|
|
Screen::Cinematic => "",
|
|
Screen::Game => "",
|
|
};
|
|
self.menu_font.text(
|
|
&mut canvas,
|
|
22,
|
|
15,
|
|
&fit_text(&self.menu_font, title, 276),
|
|
48,
|
|
);
|
|
self.font.text(
|
|
&mut canvas,
|
|
22,
|
|
29,
|
|
&fit_text(
|
|
&self.font,
|
|
&format!("PILOT {} ${}", self.pilot.callsign, self.pilot.credits),
|
|
276,
|
|
),
|
|
8,
|
|
);
|
|
let rows = self.rows();
|
|
let mission_card = self.screen == Screen::Missions;
|
|
let shop = self.screen == Screen::Shop;
|
|
if self.screen == Screen::LegacyPilots && rows.is_empty() {
|
|
for (index, line) in [
|
|
"No original pilot saves found.",
|
|
"Original CASTLE saves can be imported here.",
|
|
"Press ESC / B to return to Load Pilot.",
|
|
]
|
|
.iter()
|
|
.enumerate()
|
|
{
|
|
self.font
|
|
.text(&mut canvas, 24, 51 + index as i32 * 14, line, 8);
|
|
}
|
|
}
|
|
let first = self.selected.saturating_sub(8);
|
|
if !classic && rows.len() > 10 {
|
|
let visible = rows.len().saturating_sub(first).min(10);
|
|
canvas.rect(303, 44, 2, 110, closest(&self.palette, [42, 57, 65]));
|
|
canvas.rect(
|
|
303,
|
|
44 + (first * 110 / rows.len()) as i32,
|
|
2,
|
|
(visible * 110 / rows.len()).max(5) as i32,
|
|
144,
|
|
);
|
|
}
|
|
for (index, row) in rows
|
|
.iter()
|
|
.enumerate()
|
|
.skip(first)
|
|
.take(if classic { 0 } else { 10 })
|
|
{
|
|
let y = 46 + (index - first) as i32 * 11;
|
|
if index == self.selected {
|
|
canvas.rect(
|
|
20,
|
|
y - 2,
|
|
if shop {
|
|
140
|
|
} else if mission_card {
|
|
126
|
|
} else if self.screen == Screen::PilotForm {
|
|
214
|
|
} else {
|
|
280
|
|
},
|
|
10,
|
|
closest(&self.palette, [35, 42, 40]),
|
|
);
|
|
canvas.rect(20, y - 2, 2, 10, 48);
|
|
}
|
|
self.font.text(
|
|
&mut canvas,
|
|
24,
|
|
y,
|
|
&if shop {
|
|
fit_text(&self.font, row, 132)
|
|
} else if mission_card {
|
|
fit_text(&self.font, row, 114)
|
|
} else {
|
|
fit_text(
|
|
&self.font,
|
|
row,
|
|
if self.screen == Screen::PilotForm {
|
|
204
|
|
} else {
|
|
272
|
|
},
|
|
)
|
|
},
|
|
if index == self.selected { 48 } else { 1 },
|
|
);
|
|
}
|
|
if classic {
|
|
self.classic_menu(&mut canvas)?;
|
|
}
|
|
if self.screen == Screen::PilotForm {
|
|
if let Some(form) = &self.pilot_form {
|
|
let portrait = form.portrait;
|
|
self.menu_portrait(&mut canvas, portrait, 252, 46)?;
|
|
}
|
|
for (index, line) in [
|
|
"Name: up to 20 characters",
|
|
"Callsign: up to 12 characters",
|
|
"ENTER advances to the next field",
|
|
"Choose Create / Save to finish",
|
|
"Cancel or ESC preserves the current pilot",
|
|
]
|
|
.iter()
|
|
.enumerate()
|
|
{
|
|
self.font
|
|
.text(&mut canvas, 24, 112 + index as i32 * 10, line, 8);
|
|
}
|
|
}
|
|
if mission_card {
|
|
self.mission_panel(&mut canvas)?;
|
|
}
|
|
if shop {
|
|
self.shop_panel(&mut canvas)?;
|
|
}
|
|
if self.screen == Screen::Mods {
|
|
if self.selected >= 2 {
|
|
if let Some(id) = self.mod_ids().get(self.selected - 2) {
|
|
let m = &self.discovery.mods[id];
|
|
let dependencies =
|
|
dependency_lines(&m.manifest, &self.discovery, &self.pending);
|
|
let summary = dependencies
|
|
.first()
|
|
.cloned()
|
|
.unwrap_or_else(|| "Dependencies: none".into());
|
|
self.font.text(
|
|
&mut canvas,
|
|
22,
|
|
160,
|
|
&fit_text(&self.font, &summary, 276),
|
|
144,
|
|
);
|
|
}
|
|
}
|
|
self.font.text(
|
|
&mut canvas,
|
|
22,
|
|
171,
|
|
"ENTER toggle/apply <> priority D/X details",
|
|
144,
|
|
);
|
|
} else if self.screen == Screen::Help {
|
|
let key = |action: &str| binding_label(&self.config, action);
|
|
for (n, line) in [
|
|
format!(
|
|
"Move: {} / {} / {} / {}",
|
|
key("left"),
|
|
key("right"),
|
|
key("up"),
|
|
key("down")
|
|
),
|
|
format!("Fire: {} Special: {}", key("fire"), key("special")),
|
|
format!("Cycle: {} Mega bomb: {}", key("cycle"), key("bomb")),
|
|
"ESC: pause O: options".into(),
|
|
"Mouse / controller selectable in Options".into(),
|
|
"ENTER: choose ESC: back".into(),
|
|
"Shop: choose BUY / SELL, then an item".into(),
|
|
]
|
|
.iter()
|
|
.enumerate()
|
|
{
|
|
self.font.text(
|
|
&mut canvas,
|
|
24,
|
|
49 + n as i32 * 14,
|
|
&fit_text(&self.font, line, 274),
|
|
1,
|
|
);
|
|
}
|
|
} else if self.screen == Screen::Credits {
|
|
for (n, line) in [
|
|
"Raptor: Cygnus Studios / Apogee",
|
|
"Reconstructed engine: nukeykt / skynettx",
|
|
"Rust replica: GPL v2 or later",
|
|
"FM core: Nuked-OPL3 / Tony Gies",
|
|
"Game assets: your original purchased copy",
|
|
]
|
|
.iter()
|
|
.enumerate()
|
|
{
|
|
self.font.text(&mut canvas, 24, 52 + n as i32 * 16, line, 1);
|
|
}
|
|
} else {
|
|
let hint = match self.screen {
|
|
Screen::Options => "LEFT/RIGHT adjust ENTER change ESC back",
|
|
Screen::Main | Screen::Hangar => "UP/DOWN choose ENTER/A accept ESC/B back",
|
|
Screen::PilotForm => "Type ID ENTER next ESC cancel",
|
|
Screen::Pause => "Save Pilot stores hangar state, not the flight",
|
|
Screen::Shop if self.shop_selling => {
|
|
"ENTER sell TAB/Y mode D/X details ESC back"
|
|
}
|
|
Screen::Shop => "ENTER buy TAB/Y mode D/X details ESC back",
|
|
Screen::ShopDetails => "UP/DOWN scroll ESC back to shop",
|
|
Screen::Inventory => "ENTER equip S sell ESC back",
|
|
Screen::LegacyPilots => "ENTER import to a new Rust slot ESC back",
|
|
Screen::ConfirmDisable => "Dependents shown above. ESC cancels",
|
|
Screen::ConfirmLoadMods => "ENTER/A choose ESC/B cancels loading",
|
|
_ => "ENTER/A choose ESC/B back",
|
|
};
|
|
self.font.text(
|
|
&mut canvas,
|
|
22,
|
|
if classic { 173 } else { 171 },
|
|
&fit_text(&self.font, hint, 276),
|
|
144,
|
|
);
|
|
}
|
|
if !self.message.is_empty() {
|
|
self.font.text(
|
|
&mut canvas,
|
|
22,
|
|
if classic { 183 } else { 181 },
|
|
&fit_text(&self.font, &self.message, 276),
|
|
menu_message_color(&self.message),
|
|
);
|
|
} else if let Some(context) = menu_context(self.screen, self.selected) {
|
|
self.font
|
|
.text(&mut canvas, 22, 183, &fit_text(&self.font, context, 276), 8);
|
|
}
|
|
Ok(canvas)
|
|
}
|
|
}
|
|
const ACTIONS: [&str; 8] = [
|
|
"up", "down", "left", "right", "fire", "special", "cycle", "bomb",
|
|
];
|
|
fn player_asset_frame(catalog: &Catalog, world: &World) -> Result<u32> {
|
|
let dark = matches!(
|
|
world.mission.0.as_str(),
|
|
"base:sector_2_wave_8" | "base:sector_3_wave_3"
|
|
);
|
|
let family = if dark {
|
|
if world.player_flash {
|
|
"FPLAYER_PIC"
|
|
} else {
|
|
"DPLAYER_PIC"
|
|
}
|
|
} else {
|
|
"LPLAYER_PIC"
|
|
};
|
|
Ok(catalog.id(family)? + world.player.bank.clamp(0, 6) as u32)
|
|
}
|
|
fn player_flame_positions(position: [f32; 2], bank: i32) -> [[f32; 2]; 2] {
|
|
const ENGINE: [i32; 7] = [0, 1, 2, 3, 2, 1, 0];
|
|
let offset = ENGINE[bank.clamp(0, 6) as usize] as f32;
|
|
let [x, y] = [position[0] + 16.0, position[1] + 16.0];
|
|
[[x - offset - 3.0, y + 15.0], [x + offset - 2.0, y + 15.0]]
|
|
}
|
|
/// FLAME_Down shades the existing indexed canvas before the ship is composed.
|
|
/// Native clipping changes the ramp's height; fixed-point division remains
|
|
/// important for its 12-row phase, whose third row still selects shade one.
|
|
fn flame_down(
|
|
canvas: &mut Canvas,
|
|
x: f32,
|
|
y: f32,
|
|
width: i32,
|
|
frame: usize,
|
|
tables: &[[u8; 256]; 8],
|
|
) {
|
|
let base_x = x.floor() as i32;
|
|
let base_y = y.floor() as i32;
|
|
let left = base_x.max(0);
|
|
let right = (base_x + width).min(320);
|
|
let top = base_y.max(0);
|
|
let bottom = (base_y + [8, 12][frame % 2]).min(200);
|
|
if right <= left || bottom <= top {
|
|
return;
|
|
}
|
|
let scale = canvas.scale as i32;
|
|
let scaled_x = ((left as f32 + x - x.floor()) * scale as f32).round() as i32;
|
|
let scaled_y = ((top as f32 + y - y.floor()) * scale as f32).round() as i32;
|
|
let increment = (8_u32 << 16) / (bottom - top) as u32;
|
|
for row in 0..bottom - top {
|
|
let table = &tables[(row as u32 * increment >> 16) as usize];
|
|
let row_y = scaled_y + row * scale;
|
|
for yy in row_y.max(0)..(row_y + scale).min(canvas.image.height as i32) {
|
|
for xx in
|
|
scaled_x.max(0)..(scaled_x + (right - left) * scale).min(canvas.image.width as i32)
|
|
{
|
|
let pixel =
|
|
&mut canvas.image.pixels[yy as usize * canvas.image.width + xx as usize];
|
|
if *pixel < 256 {
|
|
*pixel = table[*pixel as usize] as u16;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/// Native S_LINE tracers use three indexed lines rather than a projectile icon.
|
|
fn tracer_line(canvas: &mut Canvas, from: [f32; 2], to: [i32; 2], color: u16) {
|
|
let scale = canvas.scale as i32;
|
|
let [mut x, mut y] = from.map(|v| (v * scale as f32).round() as i32);
|
|
let [end_x, end_y] = to.map(|v| v * scale);
|
|
let dx = (end_x - x).abs();
|
|
let dy = -(end_y - y).abs();
|
|
let sx = if x < end_x { 1 } else { -1 };
|
|
let sy = if y < end_y { 1 } else { -1 };
|
|
let mut error = dx + dy;
|
|
loop {
|
|
for row in y.max(0)..(y + scale).min(canvas.image.height as i32) {
|
|
for column in x.max(0)..(x + scale).min(canvas.image.width as i32) {
|
|
canvas.image.pixels[row as usize * canvas.image.width + column as usize] = color;
|
|
}
|
|
}
|
|
if x == end_x && y == end_y {
|
|
break;
|
|
}
|
|
let doubled = error * 2;
|
|
if doubled >= dy {
|
|
error += dy;
|
|
x += sx;
|
|
}
|
|
if doubled <= dx {
|
|
error += dx;
|
|
y += sy;
|
|
}
|
|
}
|
|
}
|
|
fn portrait_asset_id(index: usize) -> i32 {
|
|
[0x10116, 0x10119, 0x10117, 0x10118][index.min(3)]
|
|
}
|
|
fn menu_context(screen: Screen, selected: usize) -> Option<&'static str> {
|
|
let lines: &[&str] = match screen {
|
|
Screen::Main => &[
|
|
"Continue with the current pilot",
|
|
"Register a new pilot and choose difficulty",
|
|
"Load a pilot or import an original save",
|
|
"Graphics, sound and flight controls",
|
|
"Enable mods and review their dependencies",
|
|
"Flight controls and game credits",
|
|
"Play the original introduction",
|
|
"Exit Raptor",
|
|
],
|
|
Screen::Hangar => &[
|
|
"Launch the next mission",
|
|
"Choose a campaign mission",
|
|
"Buy or sell equipment in the supply room",
|
|
"Select the equipment carried into flight",
|
|
"Save equipment and campaign progress",
|
|
"Change name, callsign or ID picture",
|
|
"Return to the main menu",
|
|
],
|
|
_ => return None,
|
|
};
|
|
lines.get(selected).copied()
|
|
}
|
|
fn menu_message_color(message: &str) -> u16 {
|
|
if message.to_ascii_lowercase().contains("failed") {
|
|
return 34;
|
|
}
|
|
let informational = [
|
|
"Saved ",
|
|
"Loaded ",
|
|
"New pilot ",
|
|
"Imported ",
|
|
"Mods applied",
|
|
"Deselected ",
|
|
"Enabled ",
|
|
"Selected ",
|
|
"Sale complete",
|
|
"Purchase complete",
|
|
"Weapon equipped",
|
|
"Primary equipment",
|
|
"Settings saved",
|
|
"Default bindings",
|
|
"Binding change cancelled",
|
|
"Pilot ID",
|
|
"Mod selection unchanged",
|
|
"Pilot load cancelled",
|
|
"Mission complete",
|
|
"Enter ",
|
|
"Press ",
|
|
"Review differences",
|
|
"Changes pending",
|
|
];
|
|
if informational
|
|
.iter()
|
|
.any(|prefix| message.starts_with(prefix))
|
|
{
|
|
8
|
|
} else {
|
|
34
|
|
}
|
|
}
|
|
fn portrait_index(value: i32) -> usize {
|
|
match value {
|
|
0..=3 => value as usize,
|
|
0x10116 => 0,
|
|
0x10119 => 1,
|
|
0x10117 => 2,
|
|
0x10118 => 3,
|
|
_ => 0,
|
|
}
|
|
}
|
|
fn owned_items(pilot: &Pilot, registry: &Registry) -> Vec<ContentId> {
|
|
let mut ids: Vec<_> = pilot
|
|
.inventory
|
|
.iter()
|
|
.filter(|(id, _)| pilot.item_count(id) > 0 && registry.items.contains_key(*id))
|
|
.map(|(id, _)| id.clone())
|
|
.collect();
|
|
ids.sort_by_key(|id| (registry.items[id].legacy.unwrap_or(u32::MAX), id.clone()));
|
|
ids
|
|
}
|
|
fn sellable(id: &ContentId) -> bool {
|
|
id.0 != "base:forward_guns"
|
|
}
|
|
struct ShopInfo {
|
|
name: String,
|
|
description: String,
|
|
kind: &'static str,
|
|
price: u32,
|
|
resale: u32,
|
|
owned: u32,
|
|
slots: usize,
|
|
stats: Vec<String>,
|
|
buy: String,
|
|
sell: String,
|
|
usage: &'static str,
|
|
}
|
|
impl ShopInfo {
|
|
fn full_lines(&self) -> Vec<String> {
|
|
let mut lines = vec![
|
|
self.name.clone(),
|
|
self.kind.into(),
|
|
format!("Buy ${} / resale ${}", self.price, self.resale),
|
|
format!(
|
|
"Owned {} / inventory {} of 20 slots",
|
|
self.owned, self.slots
|
|
),
|
|
];
|
|
lines.extend(self.stats.clone());
|
|
lines.push(self.description.clone());
|
|
lines.push(self.usage.into());
|
|
lines.push(format!("BUY: {}", self.buy));
|
|
lines.push(format!("SELL: {}", self.sell));
|
|
lines
|
|
}
|
|
}
|
|
fn shop_info(
|
|
pilot: &Pilot,
|
|
registry: &Registry,
|
|
id: &ContentId,
|
|
blocked: Option<String>,
|
|
) -> Result<ShopInfo> {
|
|
use crate::content::{Target, WeaponBehavior};
|
|
let item = registry.items.get(id).context("unknown shop item")?;
|
|
let (kind, usage) = if item.legacy == Some(11) {
|
|
(
|
|
"BOMB",
|
|
"Press the bound Mega bomb control. Each blast consumes one bomb.",
|
|
)
|
|
} else if item.consumable {
|
|
(
|
|
"CONSUMABLE",
|
|
if item.special {
|
|
"Select this item in Inventory and use the Special control; effects may be scripted."
|
|
} else {
|
|
"Used by its pickup or scripted behavior; see the item description."
|
|
},
|
|
)
|
|
} else if item.special {
|
|
(
|
|
"SELECTABLE",
|
|
if item.weapon.is_some() {
|
|
"Equip in Inventory or cycle specials in flight; fire using Fire or Special."
|
|
} else {
|
|
"Equip in Inventory or cycle specials in flight; activate using Special."
|
|
},
|
|
)
|
|
} else if item.weapon.is_some() {
|
|
(
|
|
"ALWAYS EQUIPPED",
|
|
"Fires together with the forward guns while owned; no selection needed.",
|
|
)
|
|
} else {
|
|
("PASSIVE", "Active while owned; no weapon selection needed.")
|
|
};
|
|
let mut stats = Vec::new();
|
|
if let Some(weapon) = item
|
|
.weapon
|
|
.as_ref()
|
|
.and_then(|weapon| registry.weapons.get(weapon))
|
|
{
|
|
stats.push(format!(
|
|
"Targets: {}",
|
|
match weapon.target {
|
|
Target::Air => "air",
|
|
Target::Ground => "ground",
|
|
Target::All => "air + ground",
|
|
}
|
|
));
|
|
stats.push(if weapon.legacy == Some(10) {
|
|
"Damage 1; drains guns".into()
|
|
} else {
|
|
format!("Damage {} / hit", weapon.damage)
|
|
});
|
|
stats.push(format!(
|
|
"Cycle {} ticks ({:.2}/s)",
|
|
weapon.cooldown,
|
|
70.0 / (3.0 * f64::from(weapon.cooldown.max(1)))
|
|
));
|
|
let behavior = match weapon.behavior {
|
|
WeaponBehavior::Straight => "Straight",
|
|
WeaponBehavior::Homing => "Homing",
|
|
WeaponBehavior::Beam => "Beam",
|
|
WeaponBehavior::Spread => "Spread",
|
|
WeaponBehavior::Bomb => "Blast",
|
|
WeaponBehavior::EnergyGrab => "Disables guns",
|
|
WeaponBehavior::Scripted => "Custom discharge",
|
|
};
|
|
stats.push(format!(
|
|
"{}{}",
|
|
behavior,
|
|
if weapon.script.is_some() && !matches!(weapon.behavior, WeaponBehavior::Scripted) {
|
|
" / scripted"
|
|
} else {
|
|
""
|
|
}
|
|
));
|
|
} else if id.0 == "base:super_shield" {
|
|
stats.push(format!("Modules {} / 5", pilot.item_count(id)));
|
|
stats.push(format!(
|
|
"Active charge {}",
|
|
pilot.inventory.get(id).copied().unwrap_or(0)
|
|
));
|
|
stats.push(format!("Fresh charge {}", item.initial_count));
|
|
stats.push("Reserves equip in order".into());
|
|
} else {
|
|
stats.push(format!("Capacity {}", item.max_count));
|
|
stats.push(format!("Purchase adds {}", item.initial_count));
|
|
if item.shield != 0 {
|
|
stats.push(format!("Energy effect {:+}", item.shield));
|
|
}
|
|
if item.credits != 0 {
|
|
stats.push(format!("Credit effect {:+}", item.credits));
|
|
}
|
|
if item.script.is_some() {
|
|
stats.push("Scripted item effects".into());
|
|
}
|
|
}
|
|
let eligibility = |selling: bool| {
|
|
if let Some(error) = &blocked {
|
|
return error.clone();
|
|
}
|
|
let mut candidate = pilot.clone();
|
|
let result = if selling {
|
|
candidate.sell(id, registry)
|
|
} else {
|
|
candidate.buy(id, registry)
|
|
};
|
|
result
|
|
.err()
|
|
.map_or_else(|| "Ready".into(), |error| format!("{error:#}"))
|
|
};
|
|
Ok(ShopInfo {
|
|
name: item.name.clone(),
|
|
description: if item.description.trim().is_empty() {
|
|
usage.into()
|
|
} else {
|
|
item.description.clone()
|
|
},
|
|
kind,
|
|
price: item.price,
|
|
resale: item.resale_price(),
|
|
owned: pilot.item_count(id),
|
|
slots: pilot.inventory_slots(),
|
|
stats,
|
|
buy: eligibility(false),
|
|
sell: eligibility(true),
|
|
usage,
|
|
})
|
|
}
|
|
fn fit_text(font: &BitmapFont, text: &str, width: usize) -> String {
|
|
if font.text_width(text) <= width {
|
|
return text.into();
|
|
}
|
|
let mut clipped = String::new();
|
|
for ch in text.chars() {
|
|
let next = format!("{clipped}{ch}...");
|
|
if font.text_width(&next) > width {
|
|
break;
|
|
}
|
|
clipped.push(ch);
|
|
}
|
|
clipped.push_str("...");
|
|
clipped
|
|
}
|
|
fn wrap_text(font: &BitmapFont, text: &str, width: usize) -> Vec<String> {
|
|
let mut lines = Vec::new();
|
|
let mut line = String::new();
|
|
for word in text.split_whitespace() {
|
|
let joined = if line.is_empty() {
|
|
word.to_string()
|
|
} else {
|
|
format!("{line} {word}")
|
|
};
|
|
if font.text_width(&joined) <= width {
|
|
line = joined;
|
|
continue;
|
|
}
|
|
if !line.is_empty() {
|
|
lines.push(std::mem::take(&mut line));
|
|
}
|
|
for ch in word.chars() {
|
|
if font.text_width(&format!("{line}{ch}")) > width && !line.is_empty() {
|
|
lines.push(std::mem::take(&mut line));
|
|
}
|
|
line.push(ch);
|
|
}
|
|
}
|
|
if !line.is_empty() {
|
|
lines.push(line);
|
|
}
|
|
lines
|
|
}
|
|
fn menu_row_at(screen: Screen, selected: usize, rows: usize, x: i32, y: i32) -> Option<usize> {
|
|
if screen == Screen::Main {
|
|
if !(MAIN_MENU_X..MAIN_MENU_X + 136).contains(&x)
|
|
|| !(MAIN_MENU_Y..MAIN_MENU_Y + 128).contains(&y)
|
|
{
|
|
return None;
|
|
}
|
|
let selection = ((y - MAIN_MENU_Y) / 16) as usize;
|
|
return (selection < rows).then_some(selection);
|
|
}
|
|
if screen == Screen::Hangar {
|
|
if !(164..300).contains(&x) || !(42..170).contains(&y) {
|
|
return None;
|
|
}
|
|
let selection = ((y - 42) / 16) as usize;
|
|
return (selection < rows).then_some(selection);
|
|
}
|
|
let right = if screen == Screen::Shop {
|
|
160
|
|
} else if screen == Screen::Missions {
|
|
146
|
|
} else {
|
|
300
|
|
};
|
|
if !(20..right).contains(&x) || !(44..154).contains(&y) {
|
|
return None;
|
|
}
|
|
let selection = selected.saturating_sub(8) + ((y - 44) / 11) as usize;
|
|
(selection < rows).then_some(selection)
|
|
}
|
|
fn dependency_lines(
|
|
manifest: &modding::Manifest,
|
|
discovery: &Discovery,
|
|
selected: &[String],
|
|
) -> Vec<String> {
|
|
if manifest.dependencies.is_empty() {
|
|
return vec!["Dependencies: none".into()];
|
|
}
|
|
manifest
|
|
.dependencies
|
|
.iter()
|
|
.map(|(id, requirement)| {
|
|
let status = if let Some(installed) = discovery.mods.get(id) {
|
|
let matches = semver::VersionReq::parse(requirement)
|
|
.ok()
|
|
.zip(semver::Version::parse(&installed.manifest.version).ok())
|
|
.is_some_and(|(requirement, version)| requirement.matches(&version));
|
|
format!(
|
|
"installed {} {}",
|
|
installed.manifest.version,
|
|
if !matches {
|
|
"VERSION MISMATCH"
|
|
} else if selected.contains(id) {
|
|
"enabled"
|
|
} else {
|
|
"DISABLED"
|
|
}
|
|
)
|
|
} else {
|
|
"MISSING".into()
|
|
};
|
|
format!("Requires {id} {requirement}: {status}")
|
|
})
|
|
.collect()
|
|
}
|
|
fn step_volume(value: u8, direction: i32) -> u8 {
|
|
if direction < 0 {
|
|
if value == 0 {
|
|
127
|
|
} else {
|
|
value.saturating_sub(16)
|
|
}
|
|
} else if value >= 127 {
|
|
0
|
|
} else {
|
|
value.saturating_add(16).min(127)
|
|
}
|
|
}
|
|
fn adjust_option(config: &mut Config, selected: usize, direction: i32) -> bool {
|
|
if direction == 0 {
|
|
return false;
|
|
}
|
|
let direction = direction.signum();
|
|
let cycle = |value: i32, length: i32| (value + direction).rem_euclid(length);
|
|
match selected {
|
|
0 => config.scale = (cycle(config.scale as i32 - 1, 4) + 1) as usize,
|
|
1 => config.scanlines = !config.scanlines,
|
|
2 => config.fullscreen = !config.fullscreen,
|
|
3 => config.borderless = !config.borderless,
|
|
4 => config.aspect_correct = !config.aspect_correct,
|
|
5 => config.music_volume = step_volume(config.music_volume, direction),
|
|
6 => config.fx_volume = step_volume(config.fx_volume, direction),
|
|
7 => {
|
|
let controls = ["keyboard", "mouse", "controller"];
|
|
let current = controls
|
|
.iter()
|
|
.position(|control| *control == config.control)
|
|
.unwrap_or(0);
|
|
config.control = controls[cycle(current as i32, 3) as usize].into();
|
|
}
|
|
8 => config.difficulty = cycle(config.difficulty as i32, 4) as u32,
|
|
9 => {
|
|
(config.width, config.height) = if (config.width, config.height) == (1280, 720) {
|
|
(1920, 1080)
|
|
} else {
|
|
(1280, 720)
|
|
};
|
|
}
|
|
10 => config.hd_mode = !config.hd_mode,
|
|
12 => config.fx_channels = (cycle(config.fx_channels as i32 - 1, 8) + 1) as usize,
|
|
_ => return false,
|
|
}
|
|
true
|
|
}
|
|
fn binding_matches(config: &Config, action: &str, key: Option<Scancode>) -> bool {
|
|
key.is_some_and(|key| binding_keys(config, action).contains(&key))
|
|
}
|
|
fn binding_keys(config: &Config, action: &str) -> Vec<Scancode> {
|
|
let primary = config
|
|
.bindings
|
|
.get(action)
|
|
.and_then(|value| Scancode::from_name(value));
|
|
let mut keys = primary.into_iter().collect::<Vec<_>>();
|
|
if action == "cycle" && primary == Some(Scancode::LAlt) {
|
|
// The default cycle control accepts either Alt or Shift, while a
|
|
// customized action keeps ownership of any explicitly assigned key.
|
|
for alias in [Scancode::RAlt, Scancode::LShift, Scancode::RShift] {
|
|
if !config
|
|
.bindings
|
|
.iter()
|
|
.any(|(other, value)| other != "cycle" && Scancode::from_name(value) == Some(alias))
|
|
{
|
|
keys.push(alias);
|
|
}
|
|
}
|
|
}
|
|
keys
|
|
}
|
|
fn binding_label(config: &Config, action: &str) -> String {
|
|
let keys = binding_keys(config, action);
|
|
if keys
|
|
== [
|
|
Scancode::LAlt,
|
|
Scancode::RAlt,
|
|
Scancode::LShift,
|
|
Scancode::RShift,
|
|
]
|
|
{
|
|
"Alt / Shift".into()
|
|
} else if keys.is_empty() {
|
|
config
|
|
.bindings
|
|
.get(action)
|
|
.cloned()
|
|
.unwrap_or_else(|| "unbound".into())
|
|
} else {
|
|
keys.into_iter()
|
|
.map(Scancode::name)
|
|
.collect::<Vec<_>>()
|
|
.join(" / ")
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
mod binding_tests {
|
|
use super::*;
|
|
#[test]
|
|
fn both_alt_and_shift_cycle_without_using_bombs_or_consumables() {
|
|
let config = Config::default();
|
|
assert_eq!(binding_label(&config, "cycle"), "Alt / Shift");
|
|
for key in [
|
|
Scancode::LAlt,
|
|
Scancode::RAlt,
|
|
Scancode::LShift,
|
|
Scancode::RShift,
|
|
] {
|
|
assert!(binding_matches(&config, "cycle", Some(key)));
|
|
assert!(!binding_matches(&config, "bomb", Some(key)));
|
|
assert!(!binding_matches(&config, "special", Some(key)));
|
|
}
|
|
assert!(binding_matches(&config, "bomb", Some(Scancode::Space)));
|
|
assert!(!binding_matches(&config, "cycle", Some(Scancode::Space)));
|
|
assert!(binding_matches(&config, "special", Some(Scancode::Z)));
|
|
assert!(!binding_matches(&config, "cycle", None));
|
|
}
|
|
#[test]
|
|
fn cycling_aliases_respect_custom_shift_bindings_and_custom_cycle_keys() {
|
|
let mut config = Config::default();
|
|
config.bindings.insert("fire".into(), "Right Shift".into());
|
|
config.bindings.insert("bomb".into(), "Left Shift".into());
|
|
assert!(binding_matches(&config, "fire", Some(Scancode::RShift)));
|
|
assert!(binding_matches(&config, "bomb", Some(Scancode::LShift)));
|
|
assert!(!binding_matches(&config, "cycle", Some(Scancode::RShift)));
|
|
assert!(!binding_matches(&config, "cycle", Some(Scancode::LShift)));
|
|
assert!(binding_matches(&config, "cycle", Some(Scancode::RAlt)));
|
|
config.bindings.insert("cycle".into(), "Q".into());
|
|
assert!(binding_matches(&config, "cycle", Some(Scancode::Q)));
|
|
for key in [
|
|
Scancode::LAlt,
|
|
Scancode::RAlt,
|
|
Scancode::LShift,
|
|
Scancode::RShift,
|
|
] {
|
|
assert!(!binding_matches(&config, "cycle", Some(key)));
|
|
}
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
// A failed load uses a display placeholder; never validate that placeholder
|
|
// in place of the existing file when mods are changed.
|
|
fn pilot_for_mods(
|
|
path: &std::path::Path,
|
|
current: &Pilot,
|
|
registry: &Registry,
|
|
pins: &[modding::ModPin],
|
|
) -> Result<Pilot> {
|
|
if path.exists() {
|
|
Pilot::load(path, registry, pins)
|
|
} else {
|
|
current.validate(registry, pins)?;
|
|
Ok(current.clone())
|
|
}
|
|
}
|
|
fn restore_scripts(runtime: &mut modding::ScriptRuntime, pilot: &Pilot) {
|
|
runtime.states = pilot.script_state.clone();
|
|
runtime.rng = pilot.script_rng.clone();
|
|
}
|
|
fn mission_palette(base: &[[u8; 3]; 256], world: Option<&World>) -> [[u8; 3]; 256] {
|
|
let mut palette = *base;
|
|
let Some(world) = world else {
|
|
return palette;
|
|
};
|
|
if world.tick < 3 {
|
|
return palette;
|
|
}
|
|
let updates = (world.tick - 1) / 2;
|
|
let water = (updates - (updates + 3) / 4) % 5;
|
|
let fire = (updates / 2) % 5;
|
|
for index in 0..5 {
|
|
palette[240 + index] = base[240 + (index + water as usize) % 5];
|
|
palette[245 + index] = base[245 + (index + fire as usize) % 5];
|
|
}
|
|
const GLOW: [u8; 20] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
|
|
for (index, phase) in [(250, 0_u64), (251, 8_u64)] {
|
|
let amount = GLOW[((phase + updates - 1) % 20) as usize];
|
|
palette[index] = base[index].map(|value| {
|
|
let value = (value >> 2).saturating_sub(amount);
|
|
(value << 2) | (value >> 4)
|
|
});
|
|
}
|
|
if updates >= 2 {
|
|
palette[if (updates / 2 - 1) % 2 == 0 { 252 } else { 253 }] = [0; 3];
|
|
}
|
|
if !world.palette_blue {
|
|
palette[254] = [0; 3];
|
|
}
|
|
if (updates - 1) % 6 >= 3 {
|
|
palette[255] = [0; 3];
|
|
}
|
|
palette
|
|
}
|
|
fn closest(palette: &[[u8; 3]; 256], rgb: [u8; 3]) -> u16 {
|
|
palette
|
|
.iter()
|
|
.enumerate()
|
|
.min_by_key(|(_, c)| {
|
|
(0..3)
|
|
.map(|i| (c[i] as i32 - rgb[i] as i32).pow(2))
|
|
.sum::<i32>()
|
|
})
|
|
.unwrap()
|
|
.0 as u16
|
|
}
|
|
fn viewport(w: u32, h: u32, aspect: bool) -> Rect {
|
|
if aspect {
|
|
let width = w.min(h * 4 / 3);
|
|
let height = h.min(w * 3 / 4);
|
|
Rect::new(
|
|
((w - width) / 2) as i32,
|
|
((h - height) / 2) as i32,
|
|
width,
|
|
height,
|
|
)
|
|
} else {
|
|
Rect::new(0, 0, w, h)
|
|
}
|
|
}
|
|
fn presentation_viewport(host: &Host, w: u32, h: u32) -> Rect {
|
|
if host.cinematic.is_some() {
|
|
// The original movies use non-square pixels. Always present them at
|
|
// 4:3, independently of the optional gameplay stretching setting.
|
|
viewport(w, h, true)
|
|
} else if host.world.is_none() {
|
|
let width = w.min(h * 16 / 9);
|
|
let height = h.min(w * 9 / 16);
|
|
Rect::new(
|
|
((w - width) / 2) as i32,
|
|
((h - height) / 2) as i32,
|
|
width,
|
|
height,
|
|
)
|
|
} else {
|
|
viewport(w, h, host.config.aspect_correct)
|
|
}
|
|
}
|
|
fn menu_point(host: &Host, w: u32, h: u32, x: i32, y: i32) -> Option<[i32; 2]> {
|
|
let rect = presentation_viewport(host, w, h);
|
|
if !rect.contains_point((x, y)) || rect.width() == 0 || rect.height() == 0 {
|
|
return None;
|
|
}
|
|
if host.world.is_none() && host.cinematic.is_none() {
|
|
let point = [
|
|
(x - rect.x()) * MENU_WIDTH as i32 / rect.width() as i32,
|
|
(y - rect.y()) * MENU_HEIGHT as i32 / rect.height() as i32,
|
|
];
|
|
Some(if host.screen == Screen::Main {
|
|
point
|
|
} else {
|
|
[point[0] - MENU_OFFSET[0], point[1] - MENU_OFFSET[1]]
|
|
})
|
|
} else {
|
|
Some([
|
|
(x - rect.x()) * 320 / rect.width() as i32,
|
|
(y - rect.y()) * 200 / rect.height() as i32,
|
|
])
|
|
}
|
|
}
|
|
pub fn run(
|
|
catalog: Catalog,
|
|
base: Registry,
|
|
paths: Paths,
|
|
mods_root: PathBuf,
|
|
frames: Option<u32>,
|
|
screenshot: Option<PathBuf>,
|
|
preview: Option<ContentId>,
|
|
movie_preview: Option<MovieKind>,
|
|
benchmark: bool,
|
|
) -> Result<()> {
|
|
let setup_start = Instant::now();
|
|
let mut host = Host::new(catalog, base, paths, mods_root, frames.is_some())?;
|
|
let mission_preview = preview.is_some();
|
|
if let Some(id) = preview {
|
|
if frames.is_some() {
|
|
host.new_pilot()?;
|
|
}
|
|
host.start(id)?;
|
|
}
|
|
if let Some(kind) = movie_preview {
|
|
host.show_movie(kind, Screen::Main)?;
|
|
} else if !mission_preview && frames.is_none() {
|
|
let return_to = host.screen;
|
|
if let Err(error) = host.start_original_intro(return_to) {
|
|
host.message = format!("Original intro unavailable: {error:#}");
|
|
}
|
|
}
|
|
let sdl = sdl2::init().map_err(|e| anyhow!(e))?;
|
|
let video = sdl.video().map_err(|e| anyhow!(e))?;
|
|
let window = video
|
|
.window(
|
|
"RaptorReplica - Rust",
|
|
host.config.width,
|
|
host.config.height,
|
|
)
|
|
.position_centered()
|
|
.resizable()
|
|
.allow_highdpi()
|
|
.build()?;
|
|
let mut canvas = match window.into_canvas().accelerated().present_vsync().build() {
|
|
Ok(c) => c,
|
|
Err(_) => video
|
|
.window(
|
|
"RaptorReplica - Rust",
|
|
host.config.width,
|
|
host.config.height,
|
|
)
|
|
.resizable()
|
|
.build()?
|
|
.into_canvas()
|
|
.software()
|
|
.build()?,
|
|
};
|
|
let creator = canvas.texture_creator();
|
|
let sidebar_image = host.menu_art.sidebars();
|
|
let mut sidebar_texture = creator.create_texture_static(
|
|
PixelFormatEnum::RGBA32,
|
|
sidebar_image.width(),
|
|
sidebar_image.height(),
|
|
)?;
|
|
sidebar_texture.update(
|
|
None,
|
|
sidebar_image.as_raw(),
|
|
sidebar_image.width() as usize * 4,
|
|
)?;
|
|
let mut texture = creator.create_texture_streaming(
|
|
PixelFormatEnum::RGBA32,
|
|
(320 * host.config.scale) as u32,
|
|
(200 * host.config.scale) as u32,
|
|
)?;
|
|
let mut applied = (0usize, false, false, 0u32, 0u32);
|
|
let mut events = sdl.event_pump().map_err(|e| anyhow!(e))?;
|
|
let controllers = sdl.game_controller().map_err(|e| anyhow!(e))?;
|
|
let mut controller = (0..controllers.num_joysticks().unwrap_or(0))
|
|
.filter(|index| controllers.is_game_controller(*index))
|
|
.find_map(|index| controllers.open(index).ok());
|
|
let audio_capture = std::env::var_os("RAPTOR_AUDIO_CAPTURE").map(PathBuf::from);
|
|
match sdl.audio() {
|
|
Ok(audio) => {
|
|
let genmidi = host.catalog.named("GENMIDI_OP2")?;
|
|
let desired = AudioSpecDesired {
|
|
freq: Some(44100),
|
|
channels: Some(2),
|
|
samples: Some(512),
|
|
};
|
|
match audio.open_playback(None, &desired, |spec| {
|
|
Mixer::new(spec.freq as u32, &genmidi).expect("validated instrument table")
|
|
}) {
|
|
Ok(device) => {
|
|
let mut device = device;
|
|
if let Err(error) = host.sound_bank.preload_originals(&host.catalog) {
|
|
host.message = format!("Original sound cache unavailable: {error:#}");
|
|
}
|
|
{
|
|
let mut mixer = device.lock();
|
|
mixer.set_fx_channels(host.config.fx_channels);
|
|
mixer.music_volume = host.config.music_volume;
|
|
mixer.fx_volume = host.config.fx_volume;
|
|
if audio_capture.is_some() {
|
|
let seconds = std::env::var("RAPTOR_AUDIO_CAPTURE_SECONDS")
|
|
.ok()
|
|
.map(|s| s.parse::<u32>())
|
|
.transpose()?
|
|
.unwrap_or(15);
|
|
mixer.start_capture(seconds)?;
|
|
}
|
|
}
|
|
device.resume();
|
|
host.audio = Some(device)
|
|
}
|
|
Err(e) => host.message = format!("Audio unavailable: {e}"),
|
|
}
|
|
}
|
|
Err(error) => host.message = format!("Audio unavailable: {error}"),
|
|
}
|
|
if host.desired_music.is_some() || host.desired_mod_music.is_some() {
|
|
if let Err(error) = host.resume_desired_music() {
|
|
host.message = format!("Music unavailable: {error:#}");
|
|
}
|
|
} else if host.cinematic.is_none() {
|
|
host.music("MAINMENU_MUS");
|
|
}
|
|
let mut previous = Instant::now();
|
|
let setup_ms = setup_start.elapsed().as_secs_f64() * 1000.0;
|
|
let mut work_times = Vec::new();
|
|
let mut frame_times = Vec::new();
|
|
let mut count = 0u32;
|
|
let mut cycle = false;
|
|
let mut bomb = false;
|
|
let mut quit = false;
|
|
let mut text_input_active = false;
|
|
while !quit {
|
|
let frame_start = Instant::now();
|
|
for event in events.poll_iter() {
|
|
match event {
|
|
Event::Quit { .. } => quit = true,
|
|
Event::ControllerDeviceAdded { which, .. } => {
|
|
controller = controllers.open(which).ok();
|
|
}
|
|
Event::ControllerDeviceRemoved { which, .. } => {
|
|
if controller
|
|
.as_ref()
|
|
.is_some_and(|c| c.instance_id() == which)
|
|
{
|
|
controller = (0..controllers.num_joysticks().unwrap_or(0))
|
|
.filter(|index| controllers.is_game_controller(*index))
|
|
.find_map(|index| controllers.open(index).ok());
|
|
}
|
|
}
|
|
Event::ControllerButtonDown { button, .. } => {
|
|
if host.screen == Screen::Cinematic {
|
|
host.finish_movie();
|
|
continue;
|
|
}
|
|
if button == Button::Start {
|
|
if let Err(e) = host.back() {
|
|
host.message = e.to_string()
|
|
}
|
|
} else if host.screen != Screen::Game {
|
|
match button {
|
|
Button::DPadUp => host.move_selection(-1),
|
|
Button::DPadDown => host.move_selection(1),
|
|
Button::DPadLeft => host.adjust(-1),
|
|
Button::DPadRight => host.adjust(1),
|
|
Button::X if host.screen == Screen::Shop => host.open_shop_details(),
|
|
Button::Y if host.screen == Screen::Shop => {
|
|
host.toggle_shop_mode();
|
|
host.play_ui_sound("FX_SWEP");
|
|
}
|
|
Button::X if host.screen == Screen::Mods => host.open_diagnostics(),
|
|
Button::A => match host.choose() {
|
|
Ok(r) => quit = !r,
|
|
Err(e) => host.message = format!("{e:#}"),
|
|
},
|
|
Button::B => {
|
|
if let Err(e) = host.back() {
|
|
host.message = e.to_string();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
} else if button == Button::X {
|
|
cycle = true
|
|
} else if button == Button::Y {
|
|
bomb = true
|
|
}
|
|
}
|
|
Event::MouseWheel { y, .. } if host.screen != Screen::Game => {
|
|
host.move_selection(y.saturating_neg());
|
|
}
|
|
Event::MouseButtonDown { x, y, .. } if host.screen != Screen::Game => {
|
|
if host.screen == Screen::Cinematic {
|
|
host.finish_movie();
|
|
continue;
|
|
}
|
|
let (w, h) = canvas.window().size();
|
|
let Some([xx, yy]) = menu_point(&host, w, h, x, y) else {
|
|
continue;
|
|
};
|
|
if host.screen == Screen::PilotForm
|
|
&& (252..292).contains(&xx)
|
|
&& (46..86).contains(&yy)
|
|
{
|
|
host.selected = 2;
|
|
if let Err(error) = host.choose() {
|
|
host.message = error.to_string();
|
|
}
|
|
continue;
|
|
}
|
|
if host.screen == Screen::Shop
|
|
&& (164..302).contains(&xx)
|
|
&& (43..169).contains(&yy)
|
|
{
|
|
host.open_shop_details();
|
|
continue;
|
|
}
|
|
if host.screen == Screen::Mods
|
|
&& (20..300).contains(&xx)
|
|
&& (158..178).contains(&yy)
|
|
{
|
|
host.open_diagnostics();
|
|
continue;
|
|
}
|
|
if let Some(selection) =
|
|
menu_row_at(host.screen, host.selected, host.rows().len(), xx, yy)
|
|
{
|
|
host.selected = selection;
|
|
match host.choose() {
|
|
Ok(r) => quit = !r,
|
|
Err(e) => host.message = format!("{e:#}"),
|
|
}
|
|
}
|
|
}
|
|
Event::TextInput { text, .. } if host.screen == Screen::PilotForm => {
|
|
host.type_pilot_text(&text);
|
|
}
|
|
Event::KeyDown {
|
|
keycode: Some(key),
|
|
scancode: physical_key,
|
|
keymod,
|
|
repeat: false,
|
|
..
|
|
} => {
|
|
if host.screen == Screen::Cinematic {
|
|
host.finish_movie();
|
|
continue;
|
|
}
|
|
if host.screen == Screen::PilotForm
|
|
&& host
|
|
.pilot_form
|
|
.as_ref()
|
|
.is_some_and(|form| form.field.is_some())
|
|
{
|
|
if key == Keycode::Escape {
|
|
if let Err(error) = host.back() {
|
|
host.message = error.to_string();
|
|
}
|
|
continue;
|
|
}
|
|
if host.pilot_field_key(key) {
|
|
continue;
|
|
}
|
|
}
|
|
if let Some(action) = host.awaiting_binding.take() {
|
|
if key == Keycode::Escape {
|
|
host.message = "Binding change cancelled".into();
|
|
} else if let Some(scancode) = physical_key {
|
|
host.config
|
|
.bindings
|
|
.insert(action.clone(), scancode.name().to_string());
|
|
host.message = format!("{action}: {}", scancode.name());
|
|
if let Err(e) = host.save_config() {
|
|
host.message = e.to_string();
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
if host.screen == Screen::Game
|
|
&& ACTIONS
|
|
.iter()
|
|
.any(|action| binding_matches(&host.config, action, physical_key))
|
|
{
|
|
cycle |= binding_matches(&host.config, "cycle", physical_key);
|
|
bomb |= binding_matches(&host.config, "bomb", physical_key);
|
|
continue;
|
|
}
|
|
if key == Keycode::Return
|
|
&& keymod
|
|
.intersects(sdl2::keyboard::Mod::LALTMOD | sdl2::keyboard::Mod::RALTMOD)
|
|
{
|
|
host.config.fullscreen = !host.config.fullscreen;
|
|
continue;
|
|
}
|
|
if key == Keycode::Escape || key == Keycode::P && host.screen == Screen::Game {
|
|
if let Err(e) = host.back() {
|
|
host.message = e.to_string()
|
|
}
|
|
continue;
|
|
}
|
|
if host.screen == Screen::Game {
|
|
match key {
|
|
Keycode::O => {
|
|
host.previous = Screen::Pause;
|
|
host.go(Screen::Options)
|
|
}
|
|
_ if binding_matches(&host.config, "cycle", physical_key) => {
|
|
cycle = true
|
|
}
|
|
_ if binding_matches(&host.config, "bomb", physical_key) => bomb = true,
|
|
_ => {}
|
|
}
|
|
continue;
|
|
}
|
|
match key {
|
|
Keycode::Up => host.move_selection(-1),
|
|
Keycode::Down => host.move_selection(1),
|
|
Keycode::Return => match host.choose() {
|
|
Ok(r) => quit = !r,
|
|
Err(e) => host.message = format!("{e:#}"),
|
|
},
|
|
Keycode::D if host.screen == Screen::Shop => host.open_shop_details(),
|
|
Keycode::Tab if host.screen == Screen::Shop => {
|
|
host.toggle_shop_mode();
|
|
host.play_ui_sound("FX_SWEP");
|
|
}
|
|
Keycode::D
|
|
if !matches!(
|
|
host.screen,
|
|
Screen::ConfirmDisable
|
|
| Screen::ConfirmLoadMods
|
|
| Screen::PilotForm
|
|
) =>
|
|
{
|
|
host.open_diagnostics()
|
|
}
|
|
Keycode::N
|
|
if host.world.is_none()
|
|
&& !matches!(
|
|
host.screen,
|
|
Screen::ConfirmDisable
|
|
| Screen::ConfirmLoadMods
|
|
| Screen::PilotForm
|
|
) =>
|
|
{
|
|
if let Err(e) = host.begin_pilot_form(true) {
|
|
host.play_ui_sound("FX_WARNING");
|
|
host.message = e.to_string();
|
|
} else {
|
|
host.play_ui_sound("FX_SWEP");
|
|
}
|
|
}
|
|
Keycode::S if host.screen == Screen::Inventory => {
|
|
if let Err(e) = host.sell_inventory() {
|
|
host.play_ui_sound("FX_WARNING");
|
|
host.message = e.to_string();
|
|
} else {
|
|
host.play_ui_sound("FX_SWEP");
|
|
}
|
|
}
|
|
Keycode::Left => host.adjust(-1),
|
|
Keycode::Right => host.adjust(1),
|
|
_ => {}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
let editing = host.screen == Screen::PilotForm
|
|
&& host
|
|
.pilot_form
|
|
.as_ref()
|
|
.is_some_and(|form| form.field.is_some());
|
|
if editing != text_input_active {
|
|
let input = canvas.window().subsystem().text_input();
|
|
if editing {
|
|
input.start();
|
|
} else {
|
|
input.stop();
|
|
}
|
|
text_input_active = editing;
|
|
}
|
|
let desired = (
|
|
host.config.scale,
|
|
host.config.fullscreen,
|
|
host.config.borderless,
|
|
host.config.width,
|
|
host.config.height,
|
|
);
|
|
if desired != applied {
|
|
if host.config.scale != applied.0 {
|
|
texture = creator.create_texture_streaming(
|
|
PixelFormatEnum::RGBA32,
|
|
(320 * host.config.scale) as u32,
|
|
(200 * host.config.scale) as u32,
|
|
)?;
|
|
}
|
|
if let Err(e) = canvas
|
|
.window_mut()
|
|
.set_fullscreen(if host.config.fullscreen {
|
|
FullscreenType::Desktop
|
|
} else {
|
|
FullscreenType::Off
|
|
})
|
|
{
|
|
host.message = e;
|
|
}
|
|
canvas.window_mut().set_bordered(!host.config.borderless);
|
|
if !host.config.fullscreen {
|
|
let _ = canvas
|
|
.window_mut()
|
|
.set_size(host.config.width, host.config.height);
|
|
}
|
|
applied = desired;
|
|
}
|
|
let now = Instant::now();
|
|
let elapsed = now.duration_since(previous).as_nanos().min(250_000_000) as u64;
|
|
let elapsed = if frames.is_some() {
|
|
16_666_667
|
|
} else {
|
|
elapsed
|
|
};
|
|
previous = now;
|
|
let keyboard = events.keyboard_state();
|
|
let pressed = |action: &str| {
|
|
binding_keys(&host.config, action)
|
|
.into_iter()
|
|
.any(|key| keyboard.is_scancode_pressed(key))
|
|
};
|
|
let mut input = Input {
|
|
dx: pressed("right") as i32 - pressed("left") as i32,
|
|
dy: pressed("down") as i32 - pressed("up") as i32,
|
|
fire: pressed("fire") || frames.is_some() && host.screen == Screen::Game,
|
|
special: pressed("special"),
|
|
cycle,
|
|
bomb,
|
|
..Input::default()
|
|
};
|
|
if host.config.control == "mouse" {
|
|
let mouse = events.mouse_state();
|
|
let (w, h) = canvas.window().size();
|
|
let r = viewport(w, h, host.config.aspect_correct);
|
|
input.absolute = Some([
|
|
(mouse.x() - r.x()) * 320 / r.width() as i32 - 16,
|
|
(mouse.y() - r.y()) * 200 / r.height() as i32 - 16,
|
|
]);
|
|
input.fire = mouse.left();
|
|
input.special = mouse.right();
|
|
}
|
|
if host.config.control == "controller" {
|
|
if let Some(c) = &controller {
|
|
input.dx = if i32::from(c.axis(Axis::LeftX)).abs() > 8000 {
|
|
c.axis(Axis::LeftX).signum() as i32
|
|
} else {
|
|
0
|
|
};
|
|
input.dy = if i32::from(c.axis(Axis::LeftY)).abs() > 8000 {
|
|
c.axis(Axis::LeftY).signum() as i32
|
|
} else {
|
|
0
|
|
};
|
|
if input.dx == 0 {
|
|
input.dx =
|
|
c.button(Button::DPadRight) as i32 - c.button(Button::DPadLeft) as i32;
|
|
}
|
|
if input.dy == 0 {
|
|
input.dy = c.button(Button::DPadDown) as i32 - c.button(Button::DPadUp) as i32;
|
|
}
|
|
input.fire = c.button(Button::A);
|
|
input.special = c.button(Button::B);
|
|
}
|
|
}
|
|
if let Err(e) = host.update(input, elapsed) {
|
|
host.message = format!("Mission paused: {e:#}");
|
|
host.go(Screen::Pause);
|
|
}
|
|
cycle = false;
|
|
bomb = false;
|
|
if let Some(audio) = &mut host.audio {
|
|
let mut mixer = audio.lock();
|
|
mixer.set_fx_channels(host.config.fx_channels);
|
|
mixer.music_volume = host.config.music_volume;
|
|
mixer.fx_volume = host.config.fx_volume;
|
|
}
|
|
let image = host.draw()?;
|
|
let rgba = host.present_rgba(&image);
|
|
if (texture.query().width, texture.query().height)
|
|
!= (image.image.width as u32, image.image.height as u32)
|
|
{
|
|
texture = creator.create_texture_streaming(
|
|
PixelFormatEnum::RGBA32,
|
|
image.image.width as u32,
|
|
image.image.height as u32,
|
|
)?;
|
|
}
|
|
texture.update(None, &rgba, image.image.width * 4)?;
|
|
let (w, h) = canvas.output_size().map_err(|e| anyhow!(e))?;
|
|
canvas.set_draw_color(sdl2::pixels::Color::BLACK);
|
|
canvas.clear();
|
|
let destination = presentation_viewport(&host, w, h);
|
|
if host.world.is_some()
|
|
&& host.cinematic.is_none()
|
|
&& (destination.width() < w || destination.height() < h)
|
|
{
|
|
canvas
|
|
.copy(&sidebar_texture, None, None)
|
|
.map_err(|e| anyhow!(e))?;
|
|
canvas.set_draw_color(sdl2::pixels::Color::RGB(65, 54, 39));
|
|
if destination.x() > 0 {
|
|
canvas
|
|
.draw_line((destination.x() - 1, 0), (destination.x() - 1, h as i32))
|
|
.map_err(|e| anyhow!(e))?;
|
|
let right = destination.x() + destination.width() as i32;
|
|
canvas
|
|
.draw_line((right, 0), (right, h as i32))
|
|
.map_err(|e| anyhow!(e))?;
|
|
}
|
|
}
|
|
canvas
|
|
.copy(&texture, None, destination)
|
|
.map_err(|e| anyhow!(e))?;
|
|
if frames.is_some_and(|n| count + 1 >= n) {
|
|
if let Some(path) = &screenshot {
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
let presented = canvas
|
|
.read_pixels(None, PixelFormatEnum::RGBA32)
|
|
.map_err(|e| anyhow!(e))?;
|
|
image::save_buffer(path, &presented, w, h, image::ColorType::Rgba8)?;
|
|
}
|
|
}
|
|
let work_ms = frame_start.elapsed().as_secs_f64() * 1000.0;
|
|
canvas.present();
|
|
if benchmark && count >= 5 && !frames.is_some_and(|n| count + 1 >= n) {
|
|
work_times.push(work_ms);
|
|
frame_times.push(frame_start.elapsed().as_secs_f64() * 1000.0);
|
|
}
|
|
count += 1;
|
|
if frames.is_some_and(|n| count >= n) {
|
|
quit = true;
|
|
}
|
|
if frames.is_some() {
|
|
std::thread::sleep(Duration::from_millis(1));
|
|
}
|
|
}
|
|
if benchmark && !work_times.is_empty() {
|
|
let (output_width, output_height) = canvas.output_size().map_err(|e| anyhow!(e))?;
|
|
work_times.sort_by(f64::total_cmp);
|
|
frame_times.sort_by(f64::total_cmp);
|
|
let percentile = |values: &[f64], p: usize| values[(values.len() - 1) * p / 100];
|
|
println!(
|
|
"benchmark renderer={} size={}x{} scale={} hd={} startup_ms={setup_ms:.2} samples={} work_p50_ms={:.2} work_p95_ms={:.2} frame_p50_ms={:.2} frame_p95_ms={:.2}",
|
|
canvas.info().name,
|
|
output_width,
|
|
output_height,
|
|
host.config.scale,
|
|
host.config.hd_mode,
|
|
work_times.len(),
|
|
percentile(&work_times, 50),
|
|
percentile(&work_times, 95),
|
|
percentile(&frame_times, 50),
|
|
percentile(&frame_times, 95)
|
|
);
|
|
}
|
|
if frames.is_none() {
|
|
host.config.save(&host.paths)?;
|
|
}
|
|
if let Some(path) = audio_capture {
|
|
let audio = host
|
|
.audio
|
|
.as_mut()
|
|
.context("audio capture requires an available SDL output device")?;
|
|
audio.pause();
|
|
let capture = audio
|
|
.lock()
|
|
.take_capture()
|
|
.context("audio capture was not started")?;
|
|
let nonzero = capture.samples.iter().filter(|&&s| s != 0).count();
|
|
println!(
|
|
"Audio capture: rate={} frames={} nonzero_samples={} file={}",
|
|
capture.rate,
|
|
capture.samples.len() / 2,
|
|
nonzero,
|
|
path.display()
|
|
);
|
|
std::fs::write(path, crate::sound::encode_capture(&capture)?)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
fn ui_fixture() -> (tempfile::TempDir, Host) {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let catalog_root = dir.path().join("catalog");
|
|
std::fs::create_dir(&catalog_root).unwrap();
|
|
std::fs::write(catalog_root.join("PALETTE_DAT"), [0; 768]).unwrap();
|
|
let mut font = vec![0; 772];
|
|
font[..4].copy_from_slice(&1i32.to_le_bytes());
|
|
std::fs::write(catalog_root.join("SYSTEM_FNT"), font).unwrap();
|
|
std::fs::write(
|
|
catalog_root.join("INDEX"),
|
|
"1 768 PALETTE_DAT\n2 772 SYSTEM_FNT\n",
|
|
)
|
|
.unwrap();
|
|
let paths = Paths {
|
|
data: dir.path().join("saves"),
|
|
config: dir.path().join("config"),
|
|
cache: dir.path().join("cache"),
|
|
legacy: dir.path().join("legacy"),
|
|
};
|
|
let pack = serde_json::from_value(serde_json::json!({
|
|
"items": [
|
|
{"id":"base:forward_guns","name":"Guns","price":0,"legacy":0},
|
|
{"id":"base:empty","name":"Depleted","price":10,"special":true,"legacy":1},
|
|
{"id":"base:fixture","name":"Fixture special","price":40,"special":true,"legacy":2},
|
|
{"id":"base:energy","name":"Energy","price":0,"max_count":100,"legacy":16}
|
|
],
|
|
"missions":[{"id":"base:sector_1_wave_1","name":"UI fixture","duration":100}]
|
|
}))
|
|
.unwrap();
|
|
let mut registry = Registry::default();
|
|
registry.apply(pack, "base").unwrap();
|
|
let host = Host::new(
|
|
Catalog::open(catalog_root).unwrap(),
|
|
registry,
|
|
paths,
|
|
dir.path().join("mods"),
|
|
false,
|
|
)
|
|
.unwrap();
|
|
(dir, host)
|
|
}
|
|
#[test]
|
|
fn catalog_equipment_uses_hd_in_the_hud_and_loads_only_when_requested() {
|
|
use sha2::{Digest, Sha256};
|
|
let (_dir, mut host) = ui_fixture();
|
|
let root = host.catalog.root.clone();
|
|
let source = [1_i32, 0, 0, 4, 4]
|
|
.into_iter()
|
|
.flat_map(i32::to_le_bytes)
|
|
.chain([5; 16])
|
|
.collect::<Vec<_>>();
|
|
std::fs::write(root.join("EQUIPMENT"), &source).unwrap();
|
|
let index = std::fs::read_to_string(root.join("INDEX")).unwrap();
|
|
std::fs::write(
|
|
root.join("INDEX"),
|
|
format!("{index}3 {} EQUIPMENT\n", source.len()),
|
|
)
|
|
.unwrap();
|
|
host.catalog = Catalog::open(&root).unwrap();
|
|
let hd_root = root.join("hd");
|
|
std::fs::create_dir(&hd_root).unwrap();
|
|
image::RgbaImage::from_pixel(32, 32, image::Rgba([255, 0, 0, 255]))
|
|
.save(hd_root.join("equipment.png"))
|
|
.unwrap();
|
|
let manifest = serde_json::json!({"format_version":1,"name":"HUD fixture","sequences":[{
|
|
"name":"Equipment","base_asset":3,"frame_count":1,"frames":[{
|
|
"frame":0,"file":"equipment.png","logical_width":4,"logical_height":4,
|
|
"origin":[0,0],"source_sha256":format!("{:x}",Sha256::digest(&source))
|
|
}]}]});
|
|
std::fs::write(
|
|
hd_root.join("hd.json"),
|
|
serde_json::to_vec(&manifest).unwrap(),
|
|
)
|
|
.unwrap();
|
|
host.active
|
|
.registry
|
|
.items
|
|
.get_mut(&"base:forward_guns".into())
|
|
.unwrap()
|
|
.art = Some(
|
|
serde_json::from_value(
|
|
serde_json::json!({"asset":3,"logical_width":4,"logical_height":4}),
|
|
)
|
|
.unwrap(),
|
|
);
|
|
host.palette[5] = [0, 255, 0];
|
|
host.palette[8] = [255, 0, 0];
|
|
assert!(!host.hd_loaded);
|
|
let mut sd = Canvas::new(4);
|
|
host.hud_weapon(&mut sd, &"base:forward_guns".into())
|
|
.unwrap();
|
|
assert_eq!(sd.image.pixels[8 * sd.image.width + 286 * 4], 5);
|
|
assert!(!host.hd_loaded);
|
|
let before = serde_json::to_value(&host.pilot).unwrap();
|
|
host.config.hd_mode = true;
|
|
for scale in [1, 4] {
|
|
let mut hd = Canvas::new(scale);
|
|
host.hud_weapon(&mut hd, &"base:forward_guns".into())
|
|
.unwrap();
|
|
assert_eq!(hd.image.pixels[2 * scale * hd.image.width + 286 * scale], 8);
|
|
assert_eq!(
|
|
hd.image.pixels.iter().filter(|&&p| p == 8).count(),
|
|
16 * scale * scale
|
|
);
|
|
let mut icon = Canvas::new(scale);
|
|
host.hud_asset(&mut icon, 3, 10, 20).unwrap();
|
|
assert_eq!(
|
|
icon.image.pixels[20 * scale * icon.image.width + 10 * scale],
|
|
8
|
|
);
|
|
assert_eq!(
|
|
icon.image.pixels.iter().filter(|&&p| p == 8).count(),
|
|
16 * scale * scale
|
|
);
|
|
}
|
|
assert!(host.hd_loaded);
|
|
assert_eq!(host.hd.coverage(), 1);
|
|
assert_eq!(serde_json::to_value(&host.pilot).unwrap(), before);
|
|
}
|
|
#[test]
|
|
fn timed_currency_pickup_uses_hd_pixels_at_interpolated_native_position_without_mutation() {
|
|
use crate::simulation::{LegacyPickup, Pickup};
|
|
use sha2::{Digest, Sha256};
|
|
|
|
let (_dir, mut host) = ui_fixture();
|
|
install_player_fixture(&mut host);
|
|
let root = host.catalog.root.clone();
|
|
let source = [1_i32, 0, 0, 4, 4]
|
|
.into_iter()
|
|
.flat_map(i32::to_le_bytes)
|
|
.chain([5; 16])
|
|
.collect::<Vec<_>>();
|
|
std::fs::write(root.join("N$_PIC"), &source).unwrap();
|
|
let index = std::fs::read_to_string(root.join("INDEX")).unwrap();
|
|
std::fs::write(
|
|
root.join("INDEX"),
|
|
format!("{index}1000f {} N$_PIC\n", source.len()),
|
|
)
|
|
.unwrap();
|
|
host.catalog = Catalog::open(&root).unwrap();
|
|
let hd_root = root.join("hd");
|
|
std::fs::create_dir(&hd_root).unwrap();
|
|
image::RgbaImage::from_fn(16, 16, |x, _| {
|
|
image::Rgba(if x < 8 {
|
|
[255, 0, 0, 255]
|
|
} else {
|
|
[0, 0, 255, 255]
|
|
})
|
|
})
|
|
.save(hd_root.join("currency.png"))
|
|
.unwrap();
|
|
let manifest = serde_json::json!({"format_version":1,"name":"Timed currency fixture","sequences":[{
|
|
"name":"Currency","base_asset":0x1000f,"frame_count":1,"frames":[{
|
|
"frame":0,"file":"currency.png","logical_width":4,"logical_height":4,
|
|
"origin":[0,0],"source_sha256":format!("{:x}",Sha256::digest(&source))
|
|
}]}]});
|
|
std::fs::write(
|
|
hd_root.join("hd.json"),
|
|
serde_json::to_vec(&manifest).unwrap(),
|
|
)
|
|
.unwrap();
|
|
host.palette[5] = [0, 255, 0];
|
|
host.palette[8] = [255, 0, 0];
|
|
host.palette[9] = [0, 0, 255];
|
|
// The full scene also renders numeric HUD pictures; keep those separate
|
|
// from the currency asset used by both the HUD and this timed pickup.
|
|
for asset in 0x10005..=0x1000e {
|
|
host.raw.insert(asset, Sprite::blank(1, 1));
|
|
}
|
|
let mut world =
|
|
World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap();
|
|
world.draw_player = false;
|
|
world.player.selected = None;
|
|
world.tick = 17;
|
|
world.rng.seed = 0x1234567;
|
|
world.pickups.push(Pickup {
|
|
entity: 300,
|
|
item: "base:energy".into(),
|
|
x: 63,
|
|
y: 105,
|
|
legacy: Some(LegacyPickup {
|
|
x: 71,
|
|
y: 113,
|
|
position: 3,
|
|
frame: 1,
|
|
art_frame: 0,
|
|
countdown: Some(13),
|
|
glow_frame: 1,
|
|
previous_art_position: Some([60, 100]),
|
|
}),
|
|
});
|
|
let before = world.digest();
|
|
let rng_before = world.rng;
|
|
let pilot_before = serde_json::to_value(&host.pilot).unwrap();
|
|
host.world = Some(world);
|
|
for scale in [1, 4] {
|
|
for halfway in [false, true] {
|
|
host.clock = Clock::default();
|
|
assert_eq!(host.clock.advance(if halfway { 21_428_571 } else { 0 }), 0);
|
|
let alpha_before = host.clock.alpha();
|
|
assert_eq!(alpha_before, if halfway { 0.5 } else { 0.0 });
|
|
let [left, top] = match (scale, halfway) {
|
|
(1, false) => [60, 100],
|
|
(1, true) => [62, 103],
|
|
(4, false) => [240, 400],
|
|
(4, true) => [246, 410],
|
|
_ => unreachable!(),
|
|
};
|
|
for hd in [false, true] {
|
|
host.config.hd_mode = hd;
|
|
let mut canvas = Canvas::new(scale);
|
|
host.scene(&mut canvas).unwrap();
|
|
// Inspect the pickup's complete footprint and one-pixel
|
|
// border, far from the HUD's separate dollar glyph. This
|
|
// fails if the timed-pickup branch falls back to source art,
|
|
// uses collision-center coordinates or rounds before scaling.
|
|
for y in top - 1..=top + 4 * scale {
|
|
for x in left - 1..=left + 4 * scale {
|
|
let expected = if (left..left + 4 * scale).contains(&x)
|
|
&& (top..top + 4 * scale).contains(&y)
|
|
{
|
|
if !hd {
|
|
5
|
|
} else if x < left + 2 * scale {
|
|
8
|
|
} else {
|
|
9
|
|
}
|
|
} else {
|
|
0
|
|
};
|
|
assert_eq!(
|
|
canvas.image.pixels[y * canvas.image.width + x],
|
|
expected,
|
|
"currency at ({x},{y}), scale={scale}, halfway={halfway}, hd={hd}"
|
|
);
|
|
}
|
|
}
|
|
let after = host.world.as_ref().unwrap();
|
|
assert_eq!(after.digest(), before);
|
|
assert_eq!(after.rng, rng_before);
|
|
let pickup = after.pickups[0].legacy.as_ref().unwrap();
|
|
// These presentation fields are intentionally not serialized
|
|
// by World::digest, so verify them separately as well.
|
|
assert_eq!(pickup.previous_art_position, Some([60, 100]));
|
|
assert_eq!(pickup.glow_frame, 1);
|
|
assert_eq!(host.clock.alpha(), alpha_before);
|
|
assert_eq!(serde_json::to_value(&host.pilot).unwrap(), pilot_before);
|
|
}
|
|
}
|
|
}
|
|
assert_eq!(host.hd.validated_count(), 1);
|
|
assert!(host.hd.diagnostics.is_empty());
|
|
}
|
|
fn opening_card_fixture() -> (tempfile::TempDir, Host) {
|
|
use sha2::{Digest, Sha256};
|
|
let (dir, mut host) = ui_fixture();
|
|
let root = host.catalog.root.clone();
|
|
let hd_root = root.join("hd");
|
|
std::fs::create_dir(&hd_root).unwrap();
|
|
let mut index = std::fs::read_to_string(root.join("INDEX")).unwrap();
|
|
let mut sequences = Vec::new();
|
|
for (asset, name, palette_name, source_index, colors) in [
|
|
(
|
|
0x1002b,
|
|
"APOGEE_PIC",
|
|
"POGPAL_DAT",
|
|
5,
|
|
[[63, 0, 0], [0, 63, 0]],
|
|
),
|
|
(
|
|
0x1002d,
|
|
"CYGNUS_PIC",
|
|
"CYGPAL_DAT",
|
|
7,
|
|
[[0, 0, 63], [63, 0, 0]],
|
|
),
|
|
] {
|
|
let source = [1_i32, 0, 0, 320, 200]
|
|
.into_iter()
|
|
.flat_map(i32::to_le_bytes)
|
|
.chain(vec![source_index; 320 * 200])
|
|
.collect::<Vec<_>>();
|
|
std::fs::write(root.join(name), &source).unwrap();
|
|
index.push_str(&format!("{asset:x} {} {name}\n", source.len()));
|
|
let mut palette = [0; 768];
|
|
palette[source_index as usize * 3..source_index as usize * 3 + 3]
|
|
.copy_from_slice(&[0, 63, 63]);
|
|
for (entry, color) in [(42, colors[0]), (250, colors[1])] {
|
|
palette[entry * 3..entry * 3 + 3].copy_from_slice(&color);
|
|
}
|
|
std::fs::write(root.join(palette_name), palette).unwrap();
|
|
index.push_str(&format!("{:x} 768 {palette_name}\n", asset + 1));
|
|
let file = format!("{name}.png");
|
|
image::RgbaImage::from_fn(640, 400, |x, _| {
|
|
let color = colors[usize::from(x >= 320)].map(|v| v * 4 + v / 16);
|
|
image::Rgba([color[0], color[1], color[2], 255])
|
|
})
|
|
.save(hd_root.join(&file))
|
|
.unwrap();
|
|
sequences.push(serde_json::json!({
|
|
"name":name,"base_asset":asset,"frame_count":1,"frames":[{
|
|
"frame":0,"file":file,"logical_width":320,"logical_height":200,
|
|
"origin":[0,0],"source_sha256":format!("{:x}",Sha256::digest(&source))
|
|
}]
|
|
}));
|
|
}
|
|
let mut score = b"MUS\x1a".to_vec();
|
|
score.extend(1_u16.to_le_bytes());
|
|
score.extend(16_u16.to_le_bytes());
|
|
score.extend([0; 8]);
|
|
score.push(0x60);
|
|
std::fs::write(root.join("APOGEE_MUS"), &score).unwrap();
|
|
index.push_str(&format!("9999 {} APOGEE_MUS\n", score.len()));
|
|
std::fs::write(root.join("INDEX"), index).unwrap();
|
|
std::fs::write(
|
|
hd_root.join("hd.json"),
|
|
serde_json::to_vec(&serde_json::json!({
|
|
"format_version":1,"name":"Opening card fixture","sequences":sequences
|
|
}))
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
host.catalog = Catalog::open(&root).unwrap();
|
|
// Wrong-for-cards gameplay palette makes accidental global quantization
|
|
// visibly different from either source-specific static palette.
|
|
host.palette[8] = [255, 0, 0];
|
|
host.palette[9] = [0, 255, 0];
|
|
host.palette[10] = [0, 0, 255];
|
|
host.world =
|
|
Some(World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap());
|
|
host.show_movie(MovieKind::OpeningCredits, Screen::Main)
|
|
.unwrap();
|
|
(dir, host)
|
|
}
|
|
fn cinematic_audio_snapshot(movie: &Cinematic) -> Vec<serde_json::Value> {
|
|
movie
|
|
.audio_events
|
|
.iter()
|
|
.map(|event| match event {
|
|
CinematicAudioEvent::Play(sound) => serde_json::json!({"play":sound}),
|
|
CinematicAudioEvent::Stop(asset) => serde_json::json!({"stop":asset}),
|
|
})
|
|
.collect()
|
|
}
|
|
#[test]
|
|
fn cinematics_keep_four_three_when_gameplay_stretching_is_enabled() {
|
|
let (_directory, mut host) = opening_card_fixture();
|
|
host.config.aspect_correct = false;
|
|
for (width, height) in [(1280, 720), (1280, 800), (1920, 1080)] {
|
|
let rect = presentation_viewport(&host, width, height);
|
|
assert!((rect.width() * 3).abs_diff(rect.height() * 4) <= 3);
|
|
}
|
|
assert_eq!(
|
|
presentation_viewport(&host, 1920, 1080),
|
|
Rect::new(240, 0, 1440, 1080)
|
|
);
|
|
host.cinematic = None;
|
|
assert_eq!(
|
|
presentation_viewport(&host, 1920, 1080),
|
|
Rect::new(0, 0, 1920, 1080)
|
|
);
|
|
}
|
|
#[test]
|
|
fn supplied_hd_opening_cards_use_unfaded_source_palettes_without_advancing_movies_or_world() {
|
|
let (_dir, mut host) = opening_card_fixture();
|
|
assert!(!host.config.hd_mode);
|
|
host.config.scanlines = false;
|
|
let mut reference = Cinematic::load(&host.catalog, MovieKind::OpeningCredits).unwrap();
|
|
let pilot_before = serde_json::to_value(&host.pilot).unwrap();
|
|
let world_before = host.world.as_ref().unwrap().digest();
|
|
let rng_before = host.world.as_ref().unwrap().rng;
|
|
let mut drawn_cards = std::collections::BTreeSet::new();
|
|
let sample_ticks = [0, 32, 64, 184, 215, 247, 279, 311, 506, 537, 568];
|
|
for tick in 0..=569 {
|
|
let movie = host.cinematic.as_ref().unwrap();
|
|
assert_eq!(
|
|
movie.finished(),
|
|
reference.finished(),
|
|
"timing at tick {tick}"
|
|
);
|
|
assert_eq!(movie.image, reference.image);
|
|
assert_eq!(
|
|
movie.palette(&host.palette),
|
|
reference.palette(&host.palette)
|
|
);
|
|
assert_eq!(movie.music_events, reference.music_events);
|
|
assert_eq!(
|
|
cinematic_audio_snapshot(movie),
|
|
cinematic_audio_snapshot(&reference)
|
|
);
|
|
if sample_ticks.contains(&tick) {
|
|
let (name, _) = reference.still_picture().unwrap();
|
|
drawn_cards.insert(name);
|
|
let original = if name == "APOGEE_PIC" { 5 } else { 7 };
|
|
for scale in [1, 4] {
|
|
host.config.scale = scale;
|
|
for hd in [false, true] {
|
|
host.config.hd_mode = hd;
|
|
let canvas = host.draw().unwrap();
|
|
let rgba = host.present_rgba(&canvas);
|
|
assert_eq!(
|
|
(canvas.image.width, canvas.image.height),
|
|
(320 * scale, 200 * scale)
|
|
);
|
|
assert!(!canvas.image.pixels.contains(&crate::assets::TRANSPARENT));
|
|
for (x, y) in [(0, 0), (80, 100), (240, 100), (319, 199)] {
|
|
let pixel = y * scale * canvas.image.width + x * scale;
|
|
let expected = if hd {
|
|
if x < 160 { 42 } else { 250 }
|
|
} else {
|
|
original
|
|
};
|
|
assert_eq!(
|
|
canvas.image.pixels[pixel], expected,
|
|
"{name}, tick={tick}, scale={scale}, hd={hd}"
|
|
);
|
|
let color = reference.palette(&host.palette)[expected as usize];
|
|
assert_eq!(
|
|
&rgba[pixel * 4..pixel * 4 + 4],
|
|
&[color[0], color[1], color[2], 255]
|
|
);
|
|
}
|
|
assert_eq!(host.world.as_ref().unwrap().digest(), world_before);
|
|
assert_eq!(host.world.as_ref().unwrap().rng, rng_before);
|
|
assert_eq!(serde_json::to_value(&host.pilot).unwrap(), pilot_before);
|
|
let movie = host.cinematic.as_ref().unwrap();
|
|
assert_eq!(movie.image, reference.image);
|
|
assert_eq!(
|
|
movie.palette(&host.palette),
|
|
reference.palette(&host.palette)
|
|
);
|
|
assert_eq!(movie.music_events, reference.music_events);
|
|
assert_eq!(
|
|
cinematic_audio_snapshot(movie),
|
|
cinematic_audio_snapshot(&reference)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if reference.finished() {
|
|
assert_eq!(tick, 569);
|
|
break;
|
|
}
|
|
let nanos = 1_000_000_000_u64.div_ceil(70);
|
|
host.cinematic.as_mut().unwrap().advance(nanos).unwrap();
|
|
reference.advance(nanos).unwrap();
|
|
}
|
|
assert_eq!(drawn_cards, ["APOGEE_PIC", "CYGNUS_PIC"].into());
|
|
assert_eq!(
|
|
reference.music_events,
|
|
[("APOGEE_MUS", false), ("RINTRO_MUS", true)]
|
|
);
|
|
assert_eq!(reference.audio_events.len(), 2);
|
|
assert_eq!(host.hd.validated_count(), 2);
|
|
assert!(host.hd.diagnostics.is_empty());
|
|
// Fade phases reuse the same palette-bound indexed sprites rather than
|
|
// repeatedly quantizing against changing or completely black colors.
|
|
assert_eq!(host.hd.cache_stats().indexed_entries, 4);
|
|
assert_eq!(host.hd.cache_stats().png_decodes, 2);
|
|
}
|
|
#[test]
|
|
fn missing_or_source_mismatched_hd_opening_card_keeps_native_picture_and_palette() {
|
|
for mismatched in [false, true] {
|
|
let (_dir, mut host) = opening_card_fixture();
|
|
let manifest_path = host.catalog.root.join("hd/hd.json");
|
|
let mut manifest: serde_json::Value =
|
|
serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap();
|
|
if mismatched {
|
|
manifest["sequences"][0]["frames"][0]["source_sha256"] = "changed".into();
|
|
} else {
|
|
manifest["sequences"][0]["frames"] = serde_json::json!([]);
|
|
}
|
|
std::fs::write(manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap();
|
|
host.config.hd_mode = true;
|
|
host.config.scanlines = false;
|
|
host.cinematic
|
|
.as_mut()
|
|
.unwrap()
|
|
.advance(64 * 1_000_000_000_u64.div_ceil(70))
|
|
.unwrap();
|
|
for scale in [1, 4] {
|
|
host.config.scale = scale;
|
|
let canvas = host.draw().unwrap();
|
|
assert!(canvas.image.pixels.iter().all(|pixel| *pixel == 5));
|
|
let rgba = host.present_rgba(&canvas);
|
|
assert_eq!(&rgba[..4], &[0, 255, 255, 255]);
|
|
}
|
|
assert_eq!(host.hd.validated_count(), 0);
|
|
assert_eq!(
|
|
host.cinematic.as_ref().unwrap().music_events,
|
|
[("APOGEE_MUS", false)]
|
|
);
|
|
}
|
|
}
|
|
fn game_input_fixture() -> (tempfile::TempDir, Host) {
|
|
let (dir, mut host) = ui_fixture();
|
|
let pack = serde_json::from_value(serde_json::json!({
|
|
"weapons": [{"id":"base:input_bomb","name":"Input bomb","damage":1,"speed":2,"cooldown":1,"behavior":"straight","target":"all"}],
|
|
"items": [
|
|
{"id":"base:cycle_a","name":"Special A","price":1,"special":true},
|
|
{"id":"base:cycle_b","name":"Special B","price":1,"special":true},
|
|
{"id":"base:mega_bomb","name":"Bomb","price":1,"max_count":5,"consumable":true,"weapon":"base:input_bomb"}
|
|
]
|
|
})).unwrap();
|
|
host.active.registry.apply(pack, "base").unwrap();
|
|
host.pilot.inventory = [
|
|
("base:energy".into(), 100),
|
|
("base:cycle_a".into(), 1),
|
|
("base:cycle_b".into(), 1),
|
|
("base:mega_bomb".into(), 3),
|
|
]
|
|
.into();
|
|
host.pilot.selected = Some("base:cycle_a".into());
|
|
host.world =
|
|
Some(World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap());
|
|
host.screen = Screen::Game;
|
|
(dir, host)
|
|
}
|
|
fn install_player_fixture(host: &mut Host) {
|
|
let mut index = std::fs::read_to_string(host.catalog.root.join("INDEX")).unwrap();
|
|
for (name, base, color) in [
|
|
("LPLAYER_PIC", 0x1011a, 60),
|
|
("DPLAYER_PIC", 0x20045, 70),
|
|
("FPLAYER_PIC", 0x2004c, 80),
|
|
] {
|
|
for bank in 0..7 {
|
|
let path = if bank == 0 {
|
|
name.to_owned()
|
|
} else {
|
|
format!("{name}.{bank}")
|
|
};
|
|
let mut bytes = Vec::new();
|
|
for value in [1_i32, 0, 0, 32, 32] {
|
|
bytes.extend(value.to_le_bytes());
|
|
}
|
|
bytes.extend(vec![(color + bank) as u8; 1024]);
|
|
std::fs::write(host.catalog.root.join(&path), &bytes).unwrap();
|
|
index.push_str(&format!("{:x} {} {path}\n", base + bank, bytes.len()));
|
|
}
|
|
}
|
|
std::fs::write(host.catalog.root.join("INDEX"), index).unwrap();
|
|
host.catalog = Catalog::open(host.catalog.root.clone()).unwrap();
|
|
}
|
|
#[test]
|
|
fn original_player_mapping_retains_every_bank_and_only_dark_missions_use_flash_frames() {
|
|
let (_directory, mut host) = ui_fixture();
|
|
install_player_fixture(&mut host);
|
|
let mut world =
|
|
World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap();
|
|
for sector in 1..=3 {
|
|
for wave in 1..=9 {
|
|
world.mission = format!("base:sector_{sector}_wave_{wave}").as_str().into();
|
|
let dark = matches!((sector, wave), (2, 8) | (3, 3));
|
|
for flash in [false, true] {
|
|
world.player_flash = flash;
|
|
for bank in 0..7 {
|
|
world.player.bank = bank;
|
|
assert_eq!(
|
|
player_asset_frame(&host.catalog, &world).unwrap(),
|
|
if dark {
|
|
if flash { 0x2004c } else { 0x20045 }
|
|
} else {
|
|
0x1011a
|
|
} + bank as u32
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
world.player.bank = -3;
|
|
assert_eq!(player_asset_frame(&host.catalog, &world).unwrap(), 0x1011a);
|
|
world.player.bank = 20;
|
|
assert_eq!(player_asset_frame(&host.catalog, &world).unwrap(), 0x10120);
|
|
}
|
|
#[test]
|
|
fn original_engine_offsets_follow_all_banks_and_interpolated_positions() {
|
|
for (bank, left, right) in [
|
|
(0, 157.0, 158.0),
|
|
(1, 156.0, 159.0),
|
|
(2, 155.0, 160.0),
|
|
(3, 154.0, 161.0),
|
|
(4, 155.0, 160.0),
|
|
(5, 156.0, 159.0),
|
|
(6, 157.0, 158.0),
|
|
] {
|
|
assert_eq!(
|
|
player_flame_positions([144.0, 120.0], bank),
|
|
[[left, 151.0], [right, 151.0]]
|
|
);
|
|
}
|
|
assert_eq!(
|
|
player_flame_positions([144.5, 120.25], 3),
|
|
[[154.5, 151.25], [161.5, 151.25]]
|
|
);
|
|
}
|
|
#[test]
|
|
fn original_flame_ramps_retain_fixed_point_phases_clipping_and_indexed_background() {
|
|
let tables = std::array::from_fn(|shade| [shade as u8 + 1; 256]);
|
|
for scale in 1..=4 {
|
|
for (x, y, frame, left, top, width, expected) in [
|
|
(10.0, 20.0, 0, 10, 20, 4, vec![0, 1, 2, 3, 4, 5, 6, 7]),
|
|
(
|
|
10.0,
|
|
20.0,
|
|
1,
|
|
10,
|
|
20,
|
|
4,
|
|
vec![0, 0, 1, 1, 2, 3, 3, 4, 5, 5, 6, 7],
|
|
),
|
|
(319.0, 194.0, 1, 319, 194, 1, vec![0, 1, 2, 3, 5, 6]),
|
|
(-2.0, -4.0, 1, 0, 0, 2, vec![0, 1, 2, 3, 4, 5, 6, 7]),
|
|
] {
|
|
let mut canvas = Canvas::new(scale);
|
|
canvas.clear(37);
|
|
flame_down(&mut canvas, x, y, 4, frame, &tables);
|
|
let mut oracle = Canvas::new(scale);
|
|
oracle.clear(37);
|
|
for (row, shade) in expected.into_iter().enumerate() {
|
|
oracle.rect(left, top + row as i32, width, 1, shade + 1);
|
|
}
|
|
assert_eq!(canvas.image, oracle.image);
|
|
}
|
|
let mut canvas = Canvas::new(scale);
|
|
canvas.clear(crate::assets::TRANSPARENT);
|
|
flame_down(&mut canvas, 10.0, 20.0, 4, 0, &tables);
|
|
assert!(
|
|
canvas
|
|
.image
|
|
.pixels
|
|
.iter()
|
|
.all(|pixel| *pixel == crate::assets::TRANSPARENT)
|
|
);
|
|
}
|
|
}
|
|
#[test]
|
|
fn original_player_draws_over_engine_shading_without_advancing_world_and_hides_both_on_death() {
|
|
let (_directory, mut host) = ui_fixture();
|
|
install_player_fixture(&mut host);
|
|
host.flame_tables =
|
|
std::array::from_fn(|_| std::array::from_fn(|pixel| (pixel + 1).min(255) as u8));
|
|
let mut world =
|
|
World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap();
|
|
world.player.bank = 0;
|
|
for scale in [1, 4] {
|
|
for (mission, flash, color) in [
|
|
("base:sector_1_wave_1", false, 60),
|
|
("base:sector_1_wave_1", true, 60),
|
|
("base:sector_2_wave_8", false, 70),
|
|
("base:sector_2_wave_8", true, 80),
|
|
] {
|
|
world.mission = mission.into();
|
|
world.player_flash = flash;
|
|
host.config.hd_mode = true; // Original fallback until each HD family is accepted.
|
|
let before = world.digest();
|
|
let mut canvas = Canvas::new(scale);
|
|
canvas.clear(37);
|
|
host.player(&mut canvas, &world, 1.0).unwrap();
|
|
let pixel = |x: usize, y: usize| {
|
|
canvas.image.pixels[y * scale * canvas.image.width + x * scale]
|
|
};
|
|
assert_eq!(pixel(160, 176), color);
|
|
assert_eq!(
|
|
pixel(157, 191),
|
|
color,
|
|
"ship artwork must cover the first flame row"
|
|
);
|
|
assert_eq!(pixel(157, 192), 38);
|
|
assert_eq!(
|
|
pixel(158, 192),
|
|
39,
|
|
"banked overlapping shade passes remain sequential"
|
|
);
|
|
assert_eq!(pixel(162, 192), 37);
|
|
assert_eq!(world.digest(), before);
|
|
}
|
|
}
|
|
world.draw_player = false;
|
|
let mut canvas = Canvas::new(4);
|
|
canvas.clear(37);
|
|
let before = world.digest();
|
|
host.player(&mut canvas, &world, 0.5).unwrap();
|
|
assert!(canvas.image.pixels.iter().all(|pixel| *pixel == 37));
|
|
assert_eq!(world.digest(), before);
|
|
}
|
|
#[test]
|
|
#[ignore = "requires original local catalog; writes indexed player flame/banking contacts"]
|
|
fn original_player_flames_and_flash_capture_complete_banking_families_in_sd_and_hd() {
|
|
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
let catalog = Catalog::open(root.parent().unwrap().join("data")).unwrap();
|
|
let registry = Registry::base(&catalog).unwrap();
|
|
let temporary = tempfile::tempdir().unwrap();
|
|
let paths = Paths {
|
|
data: temporary.path().join("saves"),
|
|
config: temporary.path().join("config"),
|
|
cache: temporary.path().join("cache"),
|
|
legacy: temporary.path().join("legacy"),
|
|
};
|
|
let mut host = Host::new(
|
|
catalog,
|
|
registry,
|
|
paths,
|
|
temporary.path().join("mods"),
|
|
true,
|
|
)
|
|
.unwrap();
|
|
host.config.scale = 4;
|
|
let output = root.parent().unwrap().join(".reference/secondary-effects");
|
|
std::fs::create_dir_all(&output).unwrap();
|
|
for hd in [false, true] {
|
|
host.config.hd_mode = hd;
|
|
host.ensure_hd();
|
|
let mut contact = Sprite::blank(7 * 64 * 4, 6 * 64 * 4);
|
|
for (family, (mission, flash)) in [
|
|
("base:sector_1_wave_1", false),
|
|
("base:sector_2_wave_8", false),
|
|
("base:sector_2_wave_8", true),
|
|
]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
for phase in 0..2 {
|
|
for bank in 0..7 {
|
|
let mut world =
|
|
World::new(&mission.into(), &host.pilot, &host.active.registry)
|
|
.unwrap();
|
|
world.player.y = 100;
|
|
world.player.py = 100;
|
|
world.player.bank = bank;
|
|
world.player_flash = flash;
|
|
world.tick = 64 + phase as u64;
|
|
let before = world.digest();
|
|
let mut canvas = Canvas::new(4);
|
|
// Original native water tile provides a textured indexed
|
|
// background on which the light-table flames are visible.
|
|
let tile = host.active.registry.missions
|
|
[&ContentId::from("base:sector_1_wave_1")]
|
|
.tiles[0]
|
|
.art
|
|
.asset
|
|
.unwrap();
|
|
let tile = nearest(&host.raw(tile).unwrap(), 4);
|
|
for y in (0..800).step_by(tile.height) {
|
|
for x in (0..1280).step_by(tile.width) {
|
|
canvas.blit(&tile, x as i32, y as i32);
|
|
}
|
|
}
|
|
host.player(&mut canvas, &world, 1.0).unwrap();
|
|
for y in 0..64 * 4 {
|
|
let source = (84 * 4 + y) * canvas.image.width + 128 * 4;
|
|
let target = ((family * 2 + phase) * 64 * 4 + y) * contact.width
|
|
+ bank as usize * 64 * 4;
|
|
contact.pixels[target..target + 64 * 4]
|
|
.copy_from_slice(&canvas.image.pixels[source..source + 64 * 4]);
|
|
}
|
|
assert_eq!(world.digest(), before);
|
|
if bank == 3 && phase == 1 {
|
|
host.hud_state = HudState::new(&world);
|
|
host.world = Some(world);
|
|
host.screen = Screen::Game;
|
|
let before = host.world.as_ref().unwrap().digest();
|
|
let scene = host.draw().unwrap();
|
|
image::save_buffer(
|
|
output.join(format!(
|
|
"native-player-family-{family}-{}.png",
|
|
if hd { "hd" } else { "sd" }
|
|
)),
|
|
&host.present_rgba(&scene),
|
|
scene.image.width as u32,
|
|
scene.image.height as u32,
|
|
image::ColorType::Rgba8,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(host.world.as_ref().unwrap().digest(), before);
|
|
host.world = None;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let canvas = Canvas {
|
|
image: contact,
|
|
scale: 4,
|
|
};
|
|
image::save_buffer(
|
|
output.join(format!(
|
|
"native-player-banking-flames-{}.png",
|
|
if hd { "hd" } else { "sd" }
|
|
)),
|
|
&canvas.rgba(&host.palette, false),
|
|
canvas.image.width as u32,
|
|
canvas.image.height as u32,
|
|
image::ColorType::Rgba8,
|
|
)
|
|
.unwrap();
|
|
}
|
|
}
|
|
fn native_beam_fixture(index: u32) -> (tempfile::TempDir, Host, World) {
|
|
let (dir, mut host) = ui_fixture();
|
|
let lib = crate::legacy_tables::weapons()
|
|
.into_iter()
|
|
.find(|lib| lib.index == index)
|
|
.unwrap();
|
|
let id = ContentId::from("base:visual_beam");
|
|
let pack = serde_json::from_value(serde_json::json!({
|
|
"weapons":[{"id":id,"name":"Visual beam","legacy":index,"damage":1,"speed":0,"cooldown":2,"behavior":"beam","target":"all",
|
|
"art":{"asset":lib.asset,"logical_width":2,"logical_height":3,"frames":lib.frames}}]
|
|
})).unwrap();
|
|
host.active.registry.apply(pack, "base").unwrap();
|
|
for frame in 0..lib.frames {
|
|
host.raw.insert(
|
|
lib.asset + frame,
|
|
Sprite {
|
|
width: 2,
|
|
height: 3,
|
|
pixels: vec![9; 6],
|
|
},
|
|
);
|
|
}
|
|
for base in [0x10239, 0x1023d, 0x10245] {
|
|
for frame in 0..4 {
|
|
host.raw.insert(
|
|
base + frame,
|
|
Sprite {
|
|
width: 4,
|
|
height: 4,
|
|
pixels: vec![20 + frame as u16; 16],
|
|
},
|
|
);
|
|
}
|
|
}
|
|
for frame in 0..4 {
|
|
host.raw.insert(
|
|
0x101f1 + frame,
|
|
Sprite {
|
|
width: 16,
|
|
height: 4,
|
|
pixels: vec![30 + frame as u16; 64],
|
|
},
|
|
);
|
|
}
|
|
let mut world =
|
|
World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap();
|
|
world.projectiles.push(crate::simulation::Projectile {
|
|
entity: 1,
|
|
weapon: id,
|
|
x: 160,
|
|
y: 140,
|
|
px: 160,
|
|
py: 140,
|
|
vx: 0,
|
|
vy: 0,
|
|
hostile: false,
|
|
age: 1,
|
|
legacy: Some(crate::simulation::LegacyProjectile {
|
|
movement: crate::simulation::LineMove::new(160, 140, 160, 40),
|
|
speed: 0,
|
|
frame: 1,
|
|
art_frame: 0,
|
|
delay: false,
|
|
hit: false,
|
|
start: [160, 176],
|
|
}),
|
|
legacy_enemy: None,
|
|
});
|
|
(dir, host, world)
|
|
}
|
|
#[test]
|
|
fn original_beams_draw_the_full_shaft_power_and_impact_at_native_offsets() {
|
|
for index in [13, 14] {
|
|
let (_dir, mut host, world) = native_beam_fixture(index);
|
|
host.config.hd_mode = true; // No optional HD pack: exact original fallback.
|
|
let before = world.digest();
|
|
for scale in [1, 4] {
|
|
let mut actual = Canvas::new(scale);
|
|
host.projectiles(&mut actual, &world, false, 1.0).unwrap();
|
|
let pixel = |x: usize, y: usize| {
|
|
actual.image.pixels[y * scale * actual.image.width + x * scale]
|
|
};
|
|
assert_eq!(
|
|
pixel(160, 100),
|
|
9,
|
|
"beam shaft was reduced to one projectile icon"
|
|
);
|
|
assert_eq!(
|
|
pixel(if index == 14 { 156 } else { 160 }, 142),
|
|
21,
|
|
"native power frame is cnt=1 on the first step"
|
|
);
|
|
assert_eq!(
|
|
pixel(156, 32),
|
|
31,
|
|
"impact caps use width>>2 and endpoint-8"
|
|
);
|
|
assert_eq!(pixel(155, 32), 0);
|
|
assert_eq!(pixel(160, 24), 0);
|
|
}
|
|
assert_eq!(
|
|
world.digest(),
|
|
before,
|
|
"rendering must not advance beam state"
|
|
);
|
|
}
|
|
}
|
|
#[test]
|
|
fn original_enemy_lasers_repeat_downward_and_use_their_own_muzzle_frames() {
|
|
let (_dir, mut host, mut world) = native_beam_fixture(14);
|
|
for frame in 0..4 {
|
|
host.raw.insert(
|
|
0x10241 + frame,
|
|
Sprite {
|
|
width: 2,
|
|
height: 3,
|
|
pixels: vec![9; 6],
|
|
},
|
|
);
|
|
}
|
|
let projectile = &mut world.projectiles[0];
|
|
projectile.hostile = true;
|
|
projectile.y = 40;
|
|
projectile.py = 40;
|
|
projectile.legacy = None;
|
|
projectile.legacy_enemy = Some(crate::simulation::EnemyProjectile {
|
|
movement: crate::simulation::LineMove::new(160, 40, 160, 140),
|
|
kind: 5,
|
|
asset: 0x10241,
|
|
frames: 4,
|
|
frame: 3,
|
|
art_frame: 2,
|
|
speed: 0,
|
|
maximum_speed: 6,
|
|
damage: 12,
|
|
position: 0,
|
|
enemy: 2,
|
|
gun: 0,
|
|
});
|
|
let before = world.digest();
|
|
let mut canvas = Canvas::new(1);
|
|
host.projectiles(&mut canvas, &world, true, 1.0).unwrap();
|
|
let pixel = |x: usize, y: usize| canvas.image.pixels[y * canvas.image.width + x];
|
|
assert_eq!(pixel(160, 41), 22);
|
|
assert_eq!(pixel(160, 100), 9);
|
|
assert_eq!(pixel(156, 132), 32);
|
|
assert_eq!(pixel(155, 132), 0);
|
|
assert_eq!(world.digest(), before);
|
|
}
|
|
#[test]
|
|
fn original_turret_tracer_has_three_palette_lines_and_uses_player_interpolation() {
|
|
let (_dir, mut host, world) = native_beam_fixture(5);
|
|
let before = world.digest();
|
|
let mut canvas = Canvas::new(1);
|
|
host.projectiles(&mut canvas, &world, false, 1.0).unwrap();
|
|
for (x, color) in [(159, 69), (160, 64), (161, 69)] {
|
|
assert_eq!(canvas.image.pixels[176 * 320 + x], color);
|
|
}
|
|
assert_eq!(canvas.image.pixels[150 * 320 + 160], 64);
|
|
assert_eq!(world.digest(), before);
|
|
}
|
|
#[test]
|
|
fn original_light_effects_transform_background_indices_and_preserve_mask_holes() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
let mut world =
|
|
World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap();
|
|
let mut effect = crate::simulation::VisualEffect::new(
|
|
crate::simulation::VisualEffectKind::Shield,
|
|
100,
|
|
100,
|
|
);
|
|
effect.age = 1;
|
|
world.visual_effects.push(effect);
|
|
host.raw.insert(
|
|
0x10249,
|
|
Sprite {
|
|
width: 2,
|
|
height: 2,
|
|
pixels: vec![0, 99, crate::assets::TRANSPARENT, 5],
|
|
},
|
|
);
|
|
host.light_table = std::array::from_fn(|index| (index + 7).min(255) as u8);
|
|
host.config.hd_mode = true;
|
|
let before = world.digest();
|
|
for scale in [1, 4] {
|
|
let mut canvas = Canvas::new(scale);
|
|
canvas.clear(37);
|
|
host.secondary_effects(&mut canvas, &world, 2, 1.0).unwrap();
|
|
let pixel = |x: usize, y: usize| {
|
|
canvas.image.pixels[y * scale * canvas.image.width + x * scale]
|
|
};
|
|
assert_eq!(pixel(99, 99), 44);
|
|
assert_eq!(pixel(100, 99), 44);
|
|
assert_eq!(pixel(99, 100), 37);
|
|
assert_eq!(pixel(100, 100), 44);
|
|
}
|
|
// A supplied HD mask must use its own alpha, the original logical
|
|
// anchor and the indexed light table, regardless of foreground color.
|
|
use sha2::{Digest, Sha256};
|
|
let root = host.catalog.root.clone();
|
|
let mut source = [0_i32, 0, 0, 2, 2]
|
|
.into_iter()
|
|
.flat_map(i32::to_le_bytes)
|
|
.collect::<Vec<_>>();
|
|
source.extend([0_i32, 0, 0, 2].into_iter().flat_map(i32::to_le_bytes));
|
|
source.extend([0, 99]);
|
|
source.extend([1_i32, 1, 3, 1].into_iter().flat_map(i32::to_le_bytes));
|
|
source.push(5);
|
|
source.extend([0_i32, 0, -1, 0].into_iter().flat_map(i32::to_le_bytes));
|
|
std::fs::write(root.join("SHIELD"), &source).unwrap();
|
|
let index = std::fs::read_to_string(root.join("INDEX")).unwrap();
|
|
std::fs::write(
|
|
root.join("INDEX"),
|
|
format!("{index}10249 {} SHIELD\n", source.len()),
|
|
)
|
|
.unwrap();
|
|
host.catalog = Catalog::open(&root).unwrap();
|
|
let hd_root = root.join("hd");
|
|
std::fs::create_dir(&hd_root).unwrap();
|
|
let mask = image::RgbaImage::from_fn(8, 8, |x, y| {
|
|
image::Rgba([255, 0, 0, if x >= 4 && y < 4 { 255 } else { 0 }])
|
|
});
|
|
mask.save(hd_root.join("mask.png")).unwrap();
|
|
let manifest = serde_json::json!({"format_version":1,"name":"Light-mask fixture","sequences":[{
|
|
"name":"Shield","base_asset":0x10249,"frame_count":1,"frames":[{
|
|
"frame":0,"file":"mask.png","logical_width":2,"logical_height":2,
|
|
"origin":[0,0],"source_sha256":format!("{:x}",Sha256::digest(&source))
|
|
}]}]});
|
|
std::fs::write(
|
|
hd_root.join("hd.json"),
|
|
serde_json::to_vec(&manifest).unwrap(),
|
|
)
|
|
.unwrap();
|
|
host.hd = HdAssets::load_lazy(&hd_root, &host.catalog, None);
|
|
for scale in [1, 4] {
|
|
let mut canvas = Canvas::new(scale);
|
|
canvas.clear(37);
|
|
host.secondary_effects(&mut canvas, &world, 2, 1.0).unwrap();
|
|
let pixel = |x: usize, y: usize| {
|
|
canvas.image.pixels[y * scale * canvas.image.width + x * scale]
|
|
};
|
|
assert_eq!(pixel(99, 99), 37);
|
|
assert_eq!(pixel(100, 99), 44);
|
|
assert_eq!(pixel(99, 100), 37);
|
|
assert_eq!(pixel(100, 100), 37);
|
|
assert!(
|
|
!canvas.image.pixels.contains(&0),
|
|
"HD mask colors must not paint the background"
|
|
);
|
|
}
|
|
assert_eq!(host.hd.validated_count(), 1);
|
|
assert_eq!(world.digest(), before);
|
|
}
|
|
#[test]
|
|
#[ignore = "requires local original catalog; writes ignored native beam/effect captures"]
|
|
fn original_secondary_effects_capture_sd_and_hd_without_gameplay_mutation() {
|
|
use crate::simulation::{
|
|
EnemyProjectile, LegacyPickup, LegacyProjectile, LineMove, Pickup, Projectile,
|
|
VisualEffect, VisualEffectKind,
|
|
};
|
|
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
let catalog = Catalog::open(root.parent().unwrap().join("data")).unwrap();
|
|
let registry = Registry::base(&catalog).unwrap();
|
|
let temporary = tempfile::tempdir().unwrap();
|
|
let paths = Paths {
|
|
data: temporary.path().join("saves"),
|
|
config: temporary.path().join("config"),
|
|
cache: temporary.path().join("cache"),
|
|
legacy: temporary.path().join("legacy"),
|
|
};
|
|
let mut host = Host::new(
|
|
catalog,
|
|
registry,
|
|
paths,
|
|
temporary.path().join("mods"),
|
|
true,
|
|
)
|
|
.unwrap();
|
|
host.config.scale = 4;
|
|
let mut world =
|
|
World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap();
|
|
world.tick = 200;
|
|
for (index, x, y, target_y) in [(13, 100, 150, 48), (14, 210, 140, 72), (5, 160, 76, 76)] {
|
|
let weapon = host
|
|
.active
|
|
.registry
|
|
.weapons
|
|
.values()
|
|
.find(|w| w.legacy == Some(index))
|
|
.unwrap();
|
|
world.projectiles.push(Projectile {
|
|
entity: index as u64,
|
|
weapon: weapon.id.clone(),
|
|
x,
|
|
y,
|
|
px: x,
|
|
py: y,
|
|
vx: 0,
|
|
vy: 0,
|
|
hostile: false,
|
|
age: 1,
|
|
legacy: Some(LegacyProjectile {
|
|
movement: LineMove::new(x, y, x, target_y),
|
|
speed: 0,
|
|
frame: 1,
|
|
art_frame: 0,
|
|
delay: false,
|
|
hit: false,
|
|
start: [160, 176],
|
|
}),
|
|
legacy_enemy: None,
|
|
});
|
|
}
|
|
let enemy_weapon = host
|
|
.active
|
|
.registry
|
|
.weapons
|
|
.values()
|
|
.find(|w| w.legacy == Some(0))
|
|
.unwrap()
|
|
.id
|
|
.clone();
|
|
world.projectiles.push(Projectile {
|
|
entity: 100,
|
|
weapon: enemy_weapon,
|
|
x: 260,
|
|
y: 32,
|
|
px: 260,
|
|
py: 32,
|
|
vx: 0,
|
|
vy: 0,
|
|
hostile: true,
|
|
age: 1,
|
|
legacy: None,
|
|
legacy_enemy: Some(EnemyProjectile {
|
|
movement: LineMove::new(260, 32, 260, 156),
|
|
kind: 5,
|
|
asset: 0x10241,
|
|
frames: 4,
|
|
frame: 1,
|
|
art_frame: 0,
|
|
speed: 0,
|
|
maximum_speed: 6,
|
|
damage: 12,
|
|
position: 0,
|
|
enemy: 200,
|
|
gun: 0,
|
|
}),
|
|
});
|
|
for (kind, x, y, age) in [
|
|
(VisualEffectKind::SmokeDown, 40, 130, 3),
|
|
(VisualEffectKind::SmokeTrail, 288, 96, 2),
|
|
(VisualEffectKind::BlueSpark, 96, 48, 3),
|
|
(VisualEffectKind::OrangeSpark, 206, 72, 4),
|
|
(VisualEffectKind::EnergyGrab, 50, 52, 7),
|
|
(VisualEffectKind::Shield, 160, 176, 2),
|
|
(VisualEffectKind::Casing, 166, 176, 2),
|
|
(VisualEffectKind::LaserImpact, 160, 76, 2),
|
|
] {
|
|
let mut effect = VisualEffect::new(kind, x, y);
|
|
effect.age = age;
|
|
world.visual_effects.push(effect);
|
|
}
|
|
world.pickups.push(Pickup {
|
|
entity: 300,
|
|
item: "base:energy".into(),
|
|
x: 60,
|
|
y: 106,
|
|
legacy: Some(LegacyPickup {
|
|
x: 68,
|
|
y: 114,
|
|
position: 0,
|
|
frame: 0,
|
|
art_frame: 0,
|
|
countdown: None,
|
|
glow_frame: 2,
|
|
previous_art_position: Some([60, 106]),
|
|
}),
|
|
});
|
|
host.hud_state = HudState::new(&world);
|
|
host.world = Some(world);
|
|
host.screen = Screen::Game;
|
|
let before = host.world.as_ref().unwrap().digest();
|
|
let output = root.parent().unwrap().join(".reference/secondary-effects");
|
|
std::fs::create_dir_all(&output).unwrap();
|
|
for hd in [false, true] {
|
|
host.config.hd_mode = hd;
|
|
for age in 1..=3 {
|
|
for projectile in &mut host.world.as_mut().unwrap().projectiles {
|
|
if projectile.legacy.is_some() {
|
|
projectile.age = age;
|
|
}
|
|
if let Some(enemy) = &mut projectile.legacy_enemy {
|
|
enemy.art_frame = age - 1;
|
|
}
|
|
}
|
|
let snapshot = host.world.as_ref().unwrap().digest();
|
|
let canvas = host.draw().unwrap();
|
|
image::save_buffer(
|
|
output.join(format!(
|
|
"native-effects-{}-{age}.png",
|
|
if hd { "hd" } else { "sd" }
|
|
)),
|
|
&host.present_rgba(&canvas),
|
|
canvas.image.width as u32,
|
|
canvas.image.height as u32,
|
|
image::ColorType::Rgba8,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(host.world.as_ref().unwrap().digest(), snapshot);
|
|
}
|
|
}
|
|
// Rendering changed neither gameplay nor the initial frame state; the
|
|
// only fixture changes above intentionally selected animation frames.
|
|
let world = host.world.as_mut().unwrap();
|
|
for projectile in &mut world.projectiles {
|
|
if projectile.legacy.is_some() {
|
|
projectile.age = 1;
|
|
}
|
|
if let Some(enemy) = &mut projectile.legacy_enemy {
|
|
enemy.art_frame = 0;
|
|
}
|
|
}
|
|
assert_eq!(world.digest(), before);
|
|
}
|
|
fn music_fixture() -> (tempfile::TempDir, Host, ContentId) {
|
|
let (dir, mut host) = ui_fixture();
|
|
let root = host.mods_root.join("music_fixture");
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
let body = [0x40, 0, 0, 0x90, 188, 100, 4, 0x80, 60, 4, 0x60];
|
|
let mut score = b"MUS\x1a".to_vec();
|
|
score.extend((body.len() as u16).to_le_bytes());
|
|
score.extend(16_u16.to_le_bytes());
|
|
score.extend(1_u16.to_le_bytes());
|
|
score.extend([0; 6]);
|
|
score.extend(body);
|
|
std::fs::write(root.join("theme.mus"), score).unwrap();
|
|
let id = ContentId::from("music_fixture:theme");
|
|
let pack = serde_json::from_value(
|
|
serde_json::json!({"music":[{"id":id,"name":"Fixture theme","file":"theme.mus"}]}),
|
|
)
|
|
.unwrap();
|
|
host.active.registry.apply(pack, "music_fixture").unwrap();
|
|
host.active
|
|
.registry
|
|
.asset_roots
|
|
.insert("music_fixture".into(), root);
|
|
host.active
|
|
.registry
|
|
.missions
|
|
.get_mut(&host.pilot.mission)
|
|
.unwrap()
|
|
.music_track = Some(id.clone());
|
|
(dir, host, id)
|
|
}
|
|
#[test]
|
|
fn mission_mod_music_identity_is_pending_before_audio_and_original_menus_clear_it() {
|
|
let (_dir, mut host, track) = music_fixture();
|
|
let mission = host.pilot.mission.clone();
|
|
host.start(mission).unwrap();
|
|
assert_eq!(host.desired_mod_music, Some(track.clone()));
|
|
assert_eq!(host.desired_music, None);
|
|
assert!(host.desired_music_loop);
|
|
assert!(host.audio.is_none());
|
|
std::fs::remove_file(host.mods_root.join("music_fixture/theme.mus")).unwrap();
|
|
host.mod_music(&track).unwrap();
|
|
host.resume_desired_music().unwrap();
|
|
assert_eq!(host.desired_mod_music, Some(track));
|
|
host.music("MAINMENU_MUS");
|
|
assert!(host.desired_mod_music.is_none());
|
|
}
|
|
#[test]
|
|
fn failed_mission_music_load_keeps_pilot_and_menu_state_unchanged() {
|
|
let (_dir, mut host, _track) = music_fixture();
|
|
std::fs::write(
|
|
host.mods_root.join("music_fixture/theme.mus"),
|
|
"broken music",
|
|
)
|
|
.unwrap();
|
|
let prior = serde_json::to_value(&host.pilot).unwrap();
|
|
assert!(host.start(host.pilot.mission.clone()).is_err());
|
|
assert_eq!(serde_json::to_value(&host.pilot).unwrap(), prior);
|
|
assert!(host.world.is_none());
|
|
assert!(host.screen == Screen::Main);
|
|
assert!(host.desired_mod_music.is_none());
|
|
}
|
|
#[test]
|
|
#[ignore = "opens a paused SDL audio device; run with SDL_AUDIODRIVER=dummy for deterministic host music validation"]
|
|
fn pending_mod_music_resumes_on_device_attach_without_restart_for_the_same_track() {
|
|
let (_dir, mut host, track) = music_fixture();
|
|
host.start(host.pilot.mission.clone()).unwrap();
|
|
let score = host
|
|
.sound_bank
|
|
.mod_music(&host.active.registry, &track)
|
|
.unwrap();
|
|
let mut instruments = b"#OPL_II#".to_vec();
|
|
for _ in 0..175 {
|
|
let mut instrument = [0; 36];
|
|
instrument[2] = 128;
|
|
for part in 0..2 {
|
|
let start = 4 + part * 16;
|
|
instrument[start..start + 6].copy_from_slice(&[0x21, 0xf2, 0x0f, 0, 0, 32]);
|
|
instrument[start + 7..start + 13].copy_from_slice(&[0x21, 0xf2, 0x0f, 0, 0, 0]);
|
|
}
|
|
instruments.extend(instrument);
|
|
}
|
|
let sdl = sdl2::init().unwrap();
|
|
let audio = sdl.audio().unwrap();
|
|
let desired = AudioSpecDesired {
|
|
freq: Some(44100),
|
|
channels: Some(2),
|
|
samples: Some(512),
|
|
};
|
|
let mut device = audio
|
|
.open_playback(None, &desired, |spec| {
|
|
Mixer::new(spec.freq as u32, &instruments).unwrap()
|
|
})
|
|
.unwrap();
|
|
device.lock().music_volume = host.config.music_volume;
|
|
host.audio = Some(device);
|
|
host.resume_desired_music().unwrap();
|
|
let mut reference = Mixer::new(44100, &instruments).unwrap();
|
|
reference.music_volume = host.config.music_volume;
|
|
reference.play_with_fade(score, true, true);
|
|
let mut actual = vec![0; 4410 * 2];
|
|
let mut expected = vec![0; actual.len()];
|
|
host.audio.as_mut().unwrap().lock().mix(&mut actual);
|
|
reference.mix(&mut expected);
|
|
assert_eq!(actual, expected);
|
|
assert!(actual.iter().any(|sample| *sample != 0));
|
|
host.mod_music(&track).unwrap();
|
|
host.audio.as_mut().unwrap().lock().mix(&mut actual);
|
|
reference.mix(&mut expected);
|
|
assert_eq!(
|
|
actual, expected,
|
|
"selecting the same namespaced track must preserve playback position"
|
|
);
|
|
}
|
|
#[test]
|
|
fn desktop_action_pulses_accept_consecutive_real_taps() {
|
|
let (_dir, mut host) = game_input_fixture();
|
|
let tap = Input {
|
|
cycle: true,
|
|
bomb: true,
|
|
..Input::default()
|
|
};
|
|
host.update(tap, 43_000_000).unwrap();
|
|
let world = host.world.as_ref().unwrap();
|
|
assert_eq!(world.tick, 1);
|
|
assert_eq!(world.player.selected, Some("base:cycle_b".into()));
|
|
assert_eq!(world.player.inventory[&"base:mega_bomb".into()], 2);
|
|
host.update(tap, 43_000_000).unwrap();
|
|
let world = host.world.as_ref().unwrap();
|
|
assert_eq!(world.tick, 2);
|
|
assert_eq!(world.player.selected, Some("base:cycle_a".into()));
|
|
assert_eq!(world.player.inventory[&"base:mega_bomb".into()], 1);
|
|
assert_eq!(world.projectiles.len(), 2);
|
|
}
|
|
#[test]
|
|
fn desktop_action_pulses_wait_for_a_step_and_are_delivered_once() {
|
|
let (_dir, mut host) = game_input_fixture();
|
|
host.update(
|
|
Input {
|
|
cycle: true,
|
|
bomb: true,
|
|
..Input::default()
|
|
},
|
|
0,
|
|
)
|
|
.unwrap();
|
|
let world = host.world.as_ref().unwrap();
|
|
assert_eq!(world.tick, 0);
|
|
assert_eq!(world.player.selected, Some("base:cycle_a".into()));
|
|
assert_eq!(world.player.inventory[&"base:mega_bomb".into()], 3);
|
|
assert!(host.queued_cycle && host.queued_bomb);
|
|
host.update(Input::default(), 129_000_000).unwrap();
|
|
let world = host.world.as_ref().unwrap();
|
|
assert_eq!(world.tick, 3);
|
|
assert_eq!(world.player.selected, Some("base:cycle_b".into()));
|
|
assert_eq!(world.player.inventory[&"base:mega_bomb".into()], 2);
|
|
assert_eq!(world.projectiles.len(), 1);
|
|
assert!(!host.queued_cycle && !host.queued_bomb);
|
|
host.update(Input::default(), 43_000_000).unwrap();
|
|
assert_eq!(
|
|
host.world.as_ref().unwrap().player.inventory[&"base:mega_bomb".into()],
|
|
2
|
|
);
|
|
}
|
|
#[test]
|
|
fn direct_simulation_still_debounces_held_cycle_and_bomb_buttons() {
|
|
let (_dir, mut host) = game_input_fixture();
|
|
let world = host.world.as_mut().unwrap();
|
|
let held = Input {
|
|
cycle: true,
|
|
bomb: true,
|
|
..Input::default()
|
|
};
|
|
for _ in 0..2 {
|
|
world
|
|
.step(held, &host.active.registry, &mut host.active.scripts)
|
|
.unwrap();
|
|
}
|
|
assert_eq!(world.player.selected, Some("base:cycle_b".into()));
|
|
assert_eq!(world.player.inventory[&"base:mega_bomb".into()], 2);
|
|
world
|
|
.step(
|
|
Input::default(),
|
|
&host.active.registry,
|
|
&mut host.active.scripts,
|
|
)
|
|
.unwrap();
|
|
world
|
|
.step(held, &host.active.registry, &mut host.active.scripts)
|
|
.unwrap();
|
|
assert_eq!(world.player.selected, Some("base:cycle_a".into()));
|
|
assert_eq!(world.player.inventory[&"base:mega_bomb".into()], 1);
|
|
}
|
|
fn install_dependency_fixture(host: &mut Host) {
|
|
for (id, dependencies) in [
|
|
("dependency", ""),
|
|
("dependent", "[dependencies]\ndependency = \"^1.0\"\n"),
|
|
] {
|
|
let root = host.mods_root.join(id);
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
std::fs::write(root.join("mod.toml"), format!("id = \"{id}\"\nname = \"{id}\"\nversion = \"1.0.0\"\napi_version = 1\n{dependencies}")).unwrap();
|
|
std::fs::write(root.join("content.json"), "{}").unwrap();
|
|
}
|
|
host.discovery = modding::discover(&host.mods_root);
|
|
assert!(host.discovery.errors.is_empty());
|
|
}
|
|
#[test]
|
|
fn main_and_hangar_expose_all_actions_without_scrolling_and_share_mouse_mapping() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
assert_eq!(
|
|
host.rows(),
|
|
[
|
|
"Return to hangar",
|
|
"New pilot",
|
|
"Load / import pilot",
|
|
"Options",
|
|
"Mods",
|
|
"Help / credits",
|
|
"Watch intro",
|
|
"Quit"
|
|
]
|
|
);
|
|
for row in 0..host.rows().len() {
|
|
assert_eq!(
|
|
menu_row_at(
|
|
Screen::Main,
|
|
7,
|
|
host.rows().len(),
|
|
MAIN_MENU_X + 20,
|
|
MAIN_MENU_Y + 5 + row as i32 * 16
|
|
),
|
|
Some(row)
|
|
);
|
|
}
|
|
assert_eq!(menu_row_at(Screen::Main, 0, 8, 40, 50), None);
|
|
host.activate().unwrap();
|
|
assert!(host.screen == Screen::Hangar);
|
|
assert_eq!(host.rows()[4], "Save pilot");
|
|
assert_eq!(host.rows().len(), 7);
|
|
host.selected = 2;
|
|
host.activate().unwrap();
|
|
assert!(host.screen == Screen::Shop);
|
|
host.back().unwrap();
|
|
assert!(host.screen == Screen::Hangar);
|
|
host.selected = 1;
|
|
host.activate().unwrap();
|
|
assert!(host.screen == Screen::Missions);
|
|
host.back().unwrap();
|
|
assert!(host.screen == Screen::Hangar);
|
|
host.selected = 6;
|
|
host.activate().unwrap();
|
|
assert!(host.screen == Screen::Main);
|
|
}
|
|
#[test]
|
|
fn registration_requires_identity_preserves_existing_slots_and_cancels_without_writes() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
host.pilot.save(&host.paths.save_path(0)).unwrap();
|
|
let original = std::fs::read(host.paths.save_path(0)).unwrap();
|
|
let pilot_before = serde_json::to_value(&host.pilot).unwrap();
|
|
host.selected = 1;
|
|
host.activate().unwrap();
|
|
assert!(host.screen == Screen::PilotForm);
|
|
assert!(
|
|
host.commit_pilot_form()
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("name")
|
|
);
|
|
host.type_pilot_text("Aria");
|
|
assert!(host.pilot_field_key(Keycode::Return));
|
|
host.type_pilot_text("ACE");
|
|
host.back().unwrap();
|
|
assert!(host.screen == Screen::Main);
|
|
assert_eq!(serde_json::to_value(&host.pilot).unwrap(), pilot_before);
|
|
assert_eq!(host.slots(), vec![0]);
|
|
host.begin_pilot_form(true).unwrap();
|
|
host.type_pilot_text("Aria");
|
|
host.pilot_field_key(Keycode::Return);
|
|
host.type_pilot_text("ACE");
|
|
host.pilot_field_key(Keycode::Return);
|
|
assert_eq!(host.selected, 4);
|
|
host.selected = 3;
|
|
host.adjust(-1);
|
|
assert_eq!(host.pilot_form.as_ref().unwrap().difficulty, 1);
|
|
assert_eq!(host.config.difficulty, 2);
|
|
host.selected = 4;
|
|
host.activate().unwrap();
|
|
assert!(host.screen == Screen::Hangar);
|
|
assert_eq!(host.slot, 1);
|
|
assert_eq!(host.pilot.name, "Aria");
|
|
assert_eq!(host.pilot.callsign, "ACE");
|
|
assert_eq!(host.pilot.credits, 10_000);
|
|
assert_eq!(host.pilot.inventory[&ContentId::from("base:energy")], 75);
|
|
assert_eq!(host.config.difficulty, 1);
|
|
assert_eq!(
|
|
host.pilot.legacy_progress.as_ref().unwrap().difficulties,
|
|
[1, 1, 1, 2]
|
|
);
|
|
let saved = Pilot::load(&host.paths.save_path(1), &host.active.registry, &[]).unwrap();
|
|
assert_eq!(saved.callsign, "ACE");
|
|
assert_eq!(std::fs::read(host.paths.save_path(0)).unwrap(), original);
|
|
host.go(Screen::Pilots);
|
|
assert!(host.rows()[3].contains("ACE / Aria"));
|
|
}
|
|
#[test]
|
|
fn pilot_id_edit_has_bounded_text_and_saves_only_after_accepting() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
host.pilot.save(&host.paths.save_path(0)).unwrap();
|
|
let before = std::fs::read(host.paths.save_path(0)).unwrap();
|
|
host.hangar();
|
|
host.selected = 5;
|
|
host.activate().unwrap();
|
|
host.type_pilot_text("ABCDEFGHIJKLMNOPQRSTUVWXYZ\n");
|
|
assert_eq!(host.pilot_form.as_ref().unwrap().name.len(), 20);
|
|
host.pilot_field_key(Keycode::Backspace);
|
|
assert_eq!(host.pilot_form.as_ref().unwrap().name.len(), 19);
|
|
host.pilot_field_key(Keycode::Return);
|
|
host.type_pilot_text("abcdefghijklmnop");
|
|
assert_eq!(host.pilot_form.as_ref().unwrap().callsign.len(), 12);
|
|
host.pilot_field_key(Keycode::Return);
|
|
host.selected = 2;
|
|
host.activate().unwrap();
|
|
assert_eq!(std::fs::read(host.paths.save_path(0)).unwrap(), before);
|
|
host.selected = 3;
|
|
host.activate().unwrap();
|
|
assert!(host.screen == Screen::Hangar);
|
|
assert_eq!(
|
|
host.pilot.legacy_progress.as_ref().unwrap().portrait,
|
|
0x10119
|
|
);
|
|
assert_eq!(host.pilot.name, "ABCDEFGHIJKLMNOPQRS");
|
|
assert_eq!(host.pilot.callsign, "abcdefghijkl");
|
|
assert_ne!(std::fs::read(host.paths.save_path(0)).unwrap(), before);
|
|
assert_eq!(host.slots(), vec![0]);
|
|
}
|
|
#[test]
|
|
fn pilot_id_editor_maps_native_portraits_and_preserves_unchanged_representations() {
|
|
for (index, native) in [0x10116, 0x10119, 0x10117, 0x10118].into_iter().enumerate() {
|
|
for representation in [native, index as i32] {
|
|
let (_dir, mut host) = ui_fixture();
|
|
host.pilot.legacy_progress = Some(crate::persistence::LegacyProgress {
|
|
portrait: representation,
|
|
sector: 0,
|
|
waves: [0; 3],
|
|
difficulties: [2; 4],
|
|
training: false,
|
|
training_completed: true,
|
|
});
|
|
host.pilot.save(&host.paths.save_path(0)).unwrap();
|
|
assert_eq!(portrait_index(representation), index);
|
|
assert_eq!(portrait_asset_id(index), native);
|
|
host.hangar();
|
|
host.begin_pilot_form(false).unwrap();
|
|
assert_eq!(host.pilot_form.as_ref().unwrap().portrait, index);
|
|
host.commit_pilot_form().unwrap();
|
|
assert_eq!(
|
|
host.pilot.legacy_progress.as_ref().unwrap().portrait,
|
|
representation
|
|
);
|
|
let saved =
|
|
Pilot::load(&host.paths.save_path(0), &host.active.registry, &[]).unwrap();
|
|
assert_eq!(saved.legacy_progress.unwrap().portrait, representation);
|
|
}
|
|
}
|
|
}
|
|
#[test]
|
|
fn intentional_save_creates_current_slot_and_blocks_incompatible_existing_save() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
host.hangar();
|
|
host.pilot.callsign = "ACE".into();
|
|
host.pilot.credits = 4567;
|
|
host.active.scripts.states.insert(
|
|
"fixture:behavior.rhai".into(),
|
|
serde_json::json!({"count":5}),
|
|
);
|
|
host.selected = 4;
|
|
host.activate().unwrap();
|
|
assert!(host.screen == Screen::Hangar);
|
|
assert!(host.message.contains("Saved ACE"));
|
|
let saved = Pilot::load(&host.paths.save_path(0), &host.active.registry, &[]).unwrap();
|
|
assert_eq!(saved.credits, 4567);
|
|
assert_eq!(saved.script_state["fixture:behavior.rhai"]["count"], 5);
|
|
let mut incompatible = saved;
|
|
incompatible.mods.push(modding::ModPin {
|
|
id: "missing".into(),
|
|
version: "1.0.0".into(),
|
|
hash: "required".into(),
|
|
});
|
|
incompatible.save(&host.paths.save_path(0)).unwrap();
|
|
let before = std::fs::read(host.paths.save_path(0)).unwrap();
|
|
let pilot_before = serde_json::to_value(&host.pilot).unwrap();
|
|
assert!(
|
|
host.activate()
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("Existing pilot slot")
|
|
);
|
|
assert_eq!(std::fs::read(host.paths.save_path(0)).unwrap(), before);
|
|
assert_eq!(serde_json::to_value(&host.pilot).unwrap(), pilot_before);
|
|
}
|
|
#[test]
|
|
fn failed_save_keeps_pilot_and_pause_save_retains_hangar_script_snapshot() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
host.pilot.script_state.insert(
|
|
"fixture:behavior.rhai".into(),
|
|
serde_json::json!({"count":2}),
|
|
);
|
|
host.world =
|
|
Some(World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap());
|
|
host.world.as_mut().unwrap().player.credits = 999_999;
|
|
host.active.scripts.states.insert(
|
|
"fixture:behavior.rhai".into(),
|
|
serde_json::json!({"count":999}),
|
|
);
|
|
host.go(Screen::Pause);
|
|
host.selected = 3;
|
|
host.activate().unwrap();
|
|
let saved = Pilot::load(&host.paths.save_path(0), &host.active.registry, &[]).unwrap();
|
|
assert_eq!(saved.credits, host.pilot.credits);
|
|
assert_ne!(saved.credits, host.world.as_ref().unwrap().player.credits);
|
|
assert_eq!(saved.script_state["fixture:behavior.rhai"]["count"], 2);
|
|
assert!(host.message.contains("hangar state only"));
|
|
host.world = None;
|
|
host.paths.data = host.paths.data.join("blocked");
|
|
std::fs::write(&host.paths.data, "not a directory").unwrap();
|
|
let before = serde_json::to_value(&host.pilot).unwrap();
|
|
assert!(host.save().is_err());
|
|
assert_eq!(serde_json::to_value(&host.pilot).unwrap(), before);
|
|
}
|
|
#[test]
|
|
fn settings_have_a_visible_save_action_and_preserve_return_context() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
host.previous = Screen::Main;
|
|
host.go(Screen::Options);
|
|
assert_eq!(host.rows()[13], "Save settings");
|
|
host.config.fx_volume = 127;
|
|
host.selected = 13;
|
|
host.activate().unwrap();
|
|
assert!(host.screen == Screen::Options);
|
|
assert_eq!(Config::load(&host.paths).unwrap().fx_volume, 127);
|
|
assert_eq!(host.message, "Settings saved");
|
|
host.selected = 14;
|
|
host.activate().unwrap();
|
|
assert!(host.screen == Screen::Main);
|
|
}
|
|
#[test]
|
|
fn diagnostics_preserve_options_return_target_and_do_not_create_binding_loops() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
for origin in [Screen::Main, Screen::Pause] {
|
|
host.previous = origin;
|
|
host.go(Screen::Options);
|
|
host.open_diagnostics();
|
|
host.back().unwrap();
|
|
assert!(host.screen == Screen::Options);
|
|
host.back().unwrap();
|
|
assert!(host.screen == origin);
|
|
host.go(Screen::Options);
|
|
host.selected = 11;
|
|
host.activate().unwrap();
|
|
host.open_diagnostics();
|
|
host.back().unwrap();
|
|
assert!(host.screen == Screen::Bindings);
|
|
host.back().unwrap();
|
|
assert!(host.screen == Screen::Options);
|
|
host.back().unwrap();
|
|
assert!(host.screen == origin);
|
|
}
|
|
}
|
|
#[test]
|
|
fn inventory_actions_follow_visible_owned_rows() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
host.pilot.inventory.insert("base:empty".into(), 0);
|
|
host.pilot.inventory.insert("base:fixture".into(), 1);
|
|
host.pilot.inventory.insert("base:missing".into(), 1);
|
|
host.go(Screen::Inventory);
|
|
assert_eq!(
|
|
host.inventory_ids(),
|
|
vec![
|
|
ContentId::from("base:forward_guns"),
|
|
"base:fixture".into(),
|
|
"base:energy".into()
|
|
]
|
|
);
|
|
assert!(host.rows()[1].starts_with("Fixture special"));
|
|
host.selected = 1;
|
|
let before = host.pilot.inventory.clone();
|
|
assert!(host.activate().is_err()); // Unavailable saved equipment still blocks edits.
|
|
assert_eq!(host.pilot.inventory, before);
|
|
host.pilot
|
|
.inventory
|
|
.remove(&ContentId::from("base:missing"));
|
|
host.activate().unwrap();
|
|
assert_eq!(host.pilot.selected, Some("base:fixture".into()));
|
|
host.sell_inventory().unwrap();
|
|
assert_eq!(host.pilot.credits, 20);
|
|
assert_eq!(host.pilot.selected, None);
|
|
assert!(host.rows()[1].starts_with("Energy"));
|
|
}
|
|
#[test]
|
|
fn volume_options_reach_mute_and_maximum_in_both_directions() {
|
|
let mut config = Config::default();
|
|
for expected in [101, 117, 127, 0] {
|
|
assert!(adjust_option(&mut config, 5, 1));
|
|
assert_eq!(config.music_volume, expected);
|
|
}
|
|
for expected in [69, 53, 37, 21, 5, 0, 127] {
|
|
assert!(adjust_option(&mut config, 6, -1));
|
|
assert_eq!(config.fx_volume, expected);
|
|
}
|
|
config.scale = 1;
|
|
adjust_option(&mut config, 0, -1);
|
|
assert_eq!(config.scale, 4);
|
|
config.fx_channels = 1;
|
|
adjust_option(&mut config, 12, -1);
|
|
assert_eq!(config.fx_channels, 8);
|
|
assert!(!adjust_option(&mut config, 11, 1)); // Bindings opens a separate screen.
|
|
}
|
|
#[test]
|
|
fn shop_sale_is_accessible_and_failed_save_keeps_pilot_unchanged() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
host.pilot.inventory.insert("base:fixture".into(), 1);
|
|
host.go(Screen::Shop);
|
|
host.activate().unwrap(); // Visible buy/sell mode entry.
|
|
assert!(host.shop_selling);
|
|
assert_eq!(
|
|
host.shop_ids(),
|
|
vec![ContentId::from("base:fixture"), "base:energy".into()]
|
|
);
|
|
host.selected = 1;
|
|
std::fs::write(&host.paths.data, "blocked save directory").unwrap();
|
|
let before = serde_json::to_value(&host.pilot).unwrap();
|
|
assert!(host.activate().is_err());
|
|
assert_eq!(serde_json::to_value(&host.pilot).unwrap(), before);
|
|
std::fs::remove_file(&host.paths.data).unwrap();
|
|
host.activate().unwrap();
|
|
assert_eq!(host.pilot.credits, 20);
|
|
assert_eq!(host.shop_ids(), vec![ContentId::from("base:energy")]);
|
|
}
|
|
#[test]
|
|
fn legacy_import_uses_a_separate_free_slot_and_retains_original_files() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
host.pilot.callsign = "EXISTING".into();
|
|
host.pilot.save(&host.paths.save_path(0)).unwrap();
|
|
let existing = std::fs::read(host.paths.save_path(0)).unwrap();
|
|
let mut header = vec![0u8; 88];
|
|
header[20..28].copy_from_slice(b"IMPORTED");
|
|
header[36..40].copy_from_slice(&1234i32.to_le_bytes());
|
|
header[40..44].copy_from_slice(&(-1i32).to_le_bytes());
|
|
let key = b"CASTLE";
|
|
let mut index = 25 % key.len();
|
|
let mut previous = key[index];
|
|
let source = header
|
|
.into_iter()
|
|
.map(|clear| {
|
|
let byte = clear.wrapping_add(key[index]).wrapping_add(previous);
|
|
previous = byte;
|
|
index = (index + 1) % key.len();
|
|
byte
|
|
})
|
|
.collect::<Vec<_>>();
|
|
std::fs::create_dir(&host.paths.legacy).unwrap();
|
|
let path = host.paths.legacy.join("CHAR0007.FIL");
|
|
std::fs::write(&path, &source).unwrap();
|
|
host.import_pilot(&path).unwrap();
|
|
assert_eq!(host.slot, 1);
|
|
assert_eq!(host.pilot.callsign, "IMPORTED");
|
|
assert_eq!(host.pilot.credits, 1234);
|
|
assert_eq!(std::fs::read(path).unwrap(), source);
|
|
assert_eq!(std::fs::read(host.paths.save_path(0)).unwrap(), existing);
|
|
assert_eq!(
|
|
Pilot::load(&host.paths.save_path(1), &host.active.registry, &[])
|
|
.unwrap()
|
|
.callsign,
|
|
"IMPORTED"
|
|
);
|
|
assert_eq!(host.slots(), vec![0, 1]);
|
|
}
|
|
#[test]
|
|
fn mod_diagnostics_reopening_preserves_parent_and_selected_row() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
install_dependency_fixture(&mut host);
|
|
host.go(Screen::Mods);
|
|
host.selected = 3;
|
|
host.open_diagnostics();
|
|
assert!(host.screen == Screen::Diagnostics);
|
|
assert_eq!(host.diagnostic_mod.as_deref(), Some("dependent"));
|
|
host.move_selection(3);
|
|
host.open_diagnostics(); // A second D press must not replace its parent.
|
|
assert!(host.diagnostic_return == Screen::Mods);
|
|
assert_eq!(host.diagnostic_mod.as_deref(), Some("dependent"));
|
|
host.back().unwrap();
|
|
assert!(host.screen == Screen::Mods);
|
|
assert_eq!(host.selected, 3);
|
|
host.back().unwrap();
|
|
assert!(host.screen == Screen::Main);
|
|
|
|
host.go(Screen::Options);
|
|
host.selected = 8;
|
|
host.open_diagnostics();
|
|
host.open_diagnostics();
|
|
host.back().unwrap();
|
|
assert!(host.screen == Screen::Options);
|
|
assert_eq!(host.selected, 8);
|
|
}
|
|
#[test]
|
|
fn mod_rows_and_cursor_stay_fixed_through_dependency_enable_disable_and_priority() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
install_dependency_fixture(&mut host);
|
|
host.go(Screen::Mods);
|
|
let installed = host.mod_ids();
|
|
let row = installed.iter().position(|id| id == "dependent").unwrap() + 2;
|
|
host.selected = row;
|
|
host.activate().unwrap();
|
|
assert_eq!(host.mod_ids(), installed);
|
|
assert_eq!(host.selected, row);
|
|
assert_eq!(host.pending, vec!["dependency", "dependent"]);
|
|
host.adjust(-1);
|
|
assert_eq!(host.pending, vec!["dependent", "dependency"]);
|
|
assert_eq!(host.mod_ids(), installed);
|
|
assert_eq!(host.selected, row);
|
|
host.activate().unwrap();
|
|
assert!(host.screen == Screen::ConfirmDisable);
|
|
host.back().unwrap();
|
|
assert!(host.screen == Screen::Mods);
|
|
assert_eq!(host.selected, row);
|
|
host.activate().unwrap();
|
|
host.selected = 0;
|
|
host.activate().unwrap();
|
|
assert!(host.screen == Screen::Mods);
|
|
assert!(host.pending.is_empty());
|
|
assert_eq!(host.mod_ids(), installed);
|
|
assert_eq!(host.selected, row);
|
|
|
|
host.selected = 2;
|
|
host.activate().unwrap();
|
|
host.activate().unwrap(); // Standalone dependency disables immediately.
|
|
assert!(host.screen == Screen::Mods);
|
|
assert!(host.pending.is_empty());
|
|
assert_eq!(host.selected, 2);
|
|
assert_eq!(host.mod_ids(), installed);
|
|
}
|
|
#[test]
|
|
fn selecting_a_mod_selects_its_installed_dependency_without_applying() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
install_dependency_fixture(&mut host);
|
|
host.go(Screen::Mods);
|
|
host.selected = host
|
|
.mod_ids()
|
|
.iter()
|
|
.position(|id| id == "dependent")
|
|
.unwrap()
|
|
+ 2;
|
|
host.activate().unwrap();
|
|
assert_eq!(host.pending, vec!["dependency", "dependent"]);
|
|
assert!(host.active.pins.is_empty());
|
|
assert!(host.config.enabled_mods.is_empty());
|
|
assert!(
|
|
dependency_lines(
|
|
&host.discovery.mods["dependent"].manifest,
|
|
&host.discovery,
|
|
&host.pending
|
|
)[0]
|
|
.contains("installed 1.0.0 enabled")
|
|
);
|
|
}
|
|
#[test]
|
|
fn disabling_a_required_mod_needs_confirmation_and_back_preserves_selection() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
install_dependency_fixture(&mut host);
|
|
host.pending = vec!["dependency".into(), "dependent".into()];
|
|
host.active = ActiveMods::build(
|
|
&host.base,
|
|
&host.discovery,
|
|
&host.pending,
|
|
Some(&host.catalog),
|
|
)
|
|
.unwrap();
|
|
host.pilot.mods = host.active.pins.clone();
|
|
host.config.enabled_mods = host.pending.clone();
|
|
host.world =
|
|
Some(World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap());
|
|
let active_pins = host.active.pins.clone();
|
|
host.go(Screen::Mods);
|
|
host.selected = 2;
|
|
host.activate().unwrap();
|
|
assert!(host.screen == Screen::ConfirmDisable);
|
|
assert_eq!(host.selected, 1); // Cancel is initially selected.
|
|
assert_eq!(host.pending, vec!["dependency", "dependent"]);
|
|
assert_eq!(host.pending_disable.as_ref().unwrap().removed.len(), 2);
|
|
host.back().unwrap();
|
|
assert_eq!(host.pending, vec!["dependency", "dependent"]);
|
|
host.selected = 2;
|
|
host.activate().unwrap();
|
|
host.activate().unwrap(); // The selected Cancel action also preserves both packages.
|
|
assert_eq!(host.pending, vec!["dependency", "dependent"]);
|
|
host.selected = 2;
|
|
host.activate().unwrap();
|
|
host.selected = 0;
|
|
host.activate().unwrap();
|
|
assert!(host.pending.is_empty());
|
|
assert_eq!(host.active.pins, active_pins); // Mission content stays active until main menu.
|
|
assert_eq!(host.config.enabled_mods, vec!["dependency", "dependent"]);
|
|
assert!(host.world.is_some());
|
|
assert!(host.pending_disable.is_none());
|
|
assert!(host.screen == Screen::Mods);
|
|
}
|
|
#[test]
|
|
fn missing_dependency_selection_fails_without_changing_pending_mods() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
install_dependency_fixture(&mut host);
|
|
host.discovery.mods.remove("dependency");
|
|
host.go(Screen::Mods);
|
|
host.selected = 2;
|
|
let error = host.activate().unwrap_err().to_string();
|
|
assert!(error.contains("dependency"));
|
|
assert!(host.pending.is_empty());
|
|
assert!(host.active.pins.is_empty());
|
|
assert!(host.screen == Screen::Mods);
|
|
}
|
|
#[test]
|
|
fn shop_information_uses_registry_roles_targets_prices_and_trigger_cadence() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
let weapon: crate::content::WeaponDef = serde_json::from_value(serde_json::json!({
|
|
"id":"base:fixture_shot","name":"Fixture projectile","damage":4,"speed":8,"cooldown":9,"behavior":"homing","target":"ground","script":"behavior.rhai"
|
|
})).unwrap();
|
|
host.active
|
|
.registry
|
|
.weapons
|
|
.insert(weapon.id.clone(), weapon);
|
|
let item = host
|
|
.active
|
|
.registry
|
|
.items
|
|
.get_mut(&ContentId::from("base:fixture"))
|
|
.unwrap();
|
|
item.weapon = Some("base:fixture_shot".into());
|
|
item.description = "A controlled test weapon.".into();
|
|
host.pilot.credits = 40;
|
|
let before = serde_json::to_value(&host.pilot).unwrap();
|
|
let info = host.shop_info(&"base:fixture".into()).unwrap();
|
|
assert_eq!(info.kind, "SELECTABLE");
|
|
assert_eq!((info.price, info.resale, info.owned), (40, 20, 0));
|
|
assert_eq!(info.description, "A controlled test weapon.");
|
|
assert_eq!(
|
|
info.stats,
|
|
vec![
|
|
"Targets: ground",
|
|
"Damage 4 / hit",
|
|
"Cycle 9 ticks (2.59/s)",
|
|
"Homing / scripted"
|
|
]
|
|
);
|
|
assert_eq!(info.buy, "Ready");
|
|
assert!(info.sell.contains("not owned"));
|
|
assert_eq!(serde_json::to_value(&host.pilot).unwrap(), before); // Eligibility only previews an edit.
|
|
host.active
|
|
.registry
|
|
.items
|
|
.get_mut(&ContentId::from("base:fixture"))
|
|
.unwrap()
|
|
.special = false;
|
|
assert_eq!(
|
|
host.shop_info(&"base:fixture".into()).unwrap().kind,
|
|
"ALWAYS EQUIPPED"
|
|
);
|
|
host.active
|
|
.registry
|
|
.items
|
|
.get_mut(&ContentId::from("base:fixture"))
|
|
.unwrap()
|
|
.consumable = true;
|
|
assert_eq!(
|
|
host.shop_info(&"base:fixture".into()).unwrap().kind,
|
|
"CONSUMABLE"
|
|
);
|
|
host.active
|
|
.registry
|
|
.weapons
|
|
.get_mut(&ContentId::from("base:fixture_shot"))
|
|
.unwrap()
|
|
.legacy = Some(10);
|
|
assert_eq!(
|
|
host.shop_info(&"base:fixture".into()).unwrap().stats[1],
|
|
"Damage 1; drains guns"
|
|
);
|
|
}
|
|
#[test]
|
|
fn shop_reports_super_shield_modules_separately_from_active_charge() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
let item: crate::content::ItemDef = serde_json::from_value(serde_json::json!({
|
|
"id":"base:super_shield","name":"Super Shield","price":1000,"initial_count":100,"max_count":100,"legacy":15
|
|
})).unwrap();
|
|
host.active.registry.items.insert(item.id.clone(), item);
|
|
host.pilot.inventory.insert("base:super_shield".into(), 40);
|
|
host.pilot.super_shield_reserves = vec![100, 75];
|
|
host.pilot.credits = 1000;
|
|
let info = host.shop_info(&"base:super_shield".into()).unwrap();
|
|
assert_eq!(info.owned, 3);
|
|
assert_eq!(info.slots, 5);
|
|
assert_eq!(info.stats[0], "Modules 3 / 5");
|
|
assert_eq!(info.stats[1], "Active charge 40");
|
|
assert_eq!(info.buy, "Ready");
|
|
assert!(info.sell.contains("fully charged"));
|
|
host.pilot.inventory.insert("base:super_shield".into(), 0);
|
|
assert!(
|
|
host.inventory_ids()
|
|
.contains(&ContentId::from("base:super_shield"))
|
|
);
|
|
assert_eq!(
|
|
host.shop_info(&"base:super_shield".into()).unwrap().owned,
|
|
3
|
|
);
|
|
}
|
|
#[test]
|
|
fn shop_detail_navigation_preserves_item_mode_and_blocks_preview_purchase_clicks() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
host.pilot.inventory.insert("base:fixture".into(), 1);
|
|
host.go(Screen::Shop);
|
|
host.activate().unwrap();
|
|
host.selected = 1;
|
|
let before = serde_json::to_value(&host.pilot).unwrap();
|
|
host.open_shop_details();
|
|
assert!(host.screen == Screen::ShopDetails);
|
|
assert!(host.rows().iter().any(|line| line.contains("resale")));
|
|
host.back().unwrap();
|
|
assert!(host.screen == Screen::Shop);
|
|
assert!(host.shop_selling);
|
|
assert_eq!(host.selected, 1);
|
|
assert_eq!(serde_json::to_value(&host.pilot).unwrap(), before);
|
|
assert_eq!(menu_row_at(Screen::Shop, 1, 20, 170, 60), None);
|
|
assert_eq!(menu_row_at(Screen::Shop, 1, 20, 159, 60), Some(1));
|
|
assert_eq!(menu_row_at(Screen::Shop, 1, 20, 40, 154), None);
|
|
assert_eq!(
|
|
menu_row_at(Screen::Main, 1, 8, MAIN_MENU_X + 20, MAIN_MENU_Y + 18),
|
|
Some(1)
|
|
);
|
|
}
|
|
#[test]
|
|
fn mission_preview_is_not_an_action_hitbox() {
|
|
assert_eq!(menu_row_at(Screen::Missions, 0, 27, 160, 60), None);
|
|
assert_eq!(menu_row_at(Screen::Missions, 0, 27, 145, 60), Some(1));
|
|
assert_eq!(menu_row_at(Screen::Missions, 17, 27, 40, 60), Some(10));
|
|
}
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
struct NativeTerrainSample {
|
|
target_index: usize,
|
|
asset: u32,
|
|
mission: ContentId,
|
|
row: usize,
|
|
column: usize,
|
|
placement: usize,
|
|
settled: bool,
|
|
}
|
|
|
|
// A catalog shard can begin with tiles that never occur in a native map.
|
|
// Check the real layout before treating a job's sample as gameplay evidence.
|
|
fn first_native_terrain_sample(
|
|
job: &serde_json::Value,
|
|
registry: &Registry,
|
|
) -> anyhow::Result<Option<NativeTerrainSample>> {
|
|
use anyhow::{Context, ensure};
|
|
let targets = job["targets"]
|
|
.as_array()
|
|
.context("terrain job has no targets")?;
|
|
for (target_index, target) in targets.iter().enumerate() {
|
|
let context = &target["terrain_context"];
|
|
let samples = context["samples"]
|
|
.as_array()
|
|
.context("terrain target has no sample list")?;
|
|
if samples.is_empty() {
|
|
ensure!(
|
|
context["registry_or_layout_referenced"].as_bool() == Some(false),
|
|
"terrain target {} has no occurrence samples but is not explicitly unused",
|
|
target["asset"]
|
|
);
|
|
continue;
|
|
}
|
|
let asset = u32::from_str_radix(
|
|
target["asset"]
|
|
.as_str()
|
|
.context("terrain target has no asset ID")?,
|
|
16,
|
|
)?;
|
|
let sample = &samples[0];
|
|
let mission = ContentId::from(
|
|
sample["mission"]
|
|
.as_str()
|
|
.context("sample has no mission")?,
|
|
);
|
|
let definition = registry
|
|
.missions
|
|
.get(&mission)
|
|
.context("sample mission is absent from the registry")?;
|
|
let row = usize::try_from(sample["row"].as_u64().context("sample has invalid row")?)?;
|
|
let column = usize::try_from(
|
|
sample["column"]
|
|
.as_u64()
|
|
.context("sample has invalid column")?,
|
|
)?;
|
|
let columns = definition.columns as usize;
|
|
ensure!(
|
|
columns > 0 && column < columns,
|
|
"sample column is outside the mission"
|
|
);
|
|
let placement = row
|
|
.checked_mul(columns)
|
|
.and_then(|value| value.checked_add(column))
|
|
.context("sample placement overflows")?;
|
|
let tile_index = *definition
|
|
.layout
|
|
.get(placement)
|
|
.context("sample row is outside the mission")?;
|
|
let tile = definition
|
|
.tiles
|
|
.get(tile_index)
|
|
.context("sample references an invalid mission tile")?;
|
|
let settled = if tile.art.asset == Some(asset) {
|
|
false
|
|
} else {
|
|
ensure!(
|
|
tile.destroyed.as_ref().and_then(|art| art.asset) == Some(asset),
|
|
"sample target {asset:x} does not occur at {mission} row {row}, column {column}"
|
|
);
|
|
true
|
|
};
|
|
return Ok(Some(NativeTerrainSample {
|
|
target_index,
|
|
asset,
|
|
mission,
|
|
row,
|
|
column,
|
|
placement,
|
|
settled,
|
|
}));
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
fn native_terrain_damage_placement(
|
|
definition: &crate::content::MissionDef,
|
|
row_origin: i32,
|
|
) -> Option<usize> {
|
|
let changes_art = |tile: usize| {
|
|
definition.tiles[tile]
|
|
.destroyed
|
|
.as_ref()
|
|
.is_some_and(|destroyed| {
|
|
serde_json::to_value(destroyed).unwrap()
|
|
!= serde_json::to_value(&definition.tiles[tile].art).unwrap()
|
|
})
|
|
};
|
|
let distinct = || {
|
|
definition
|
|
.layout
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, tile)| changes_art(**tile))
|
|
};
|
|
distinct()
|
|
.find(|(index, _)| {
|
|
let y = row_origin + (*index / definition.columns as usize) as i32 * 32;
|
|
(0..160).contains(&y)
|
|
})
|
|
.or_else(|| distinct().next())
|
|
.map(|(index, _)| index)
|
|
}
|
|
|
|
fn terrain_sample_registry() -> Registry {
|
|
let mut registry = Registry::default();
|
|
let mission: crate::content::MissionDef = serde_json::from_value(serde_json::json!({
|
|
"id":"base:sample_fixture", "name":"Sample fixture", "duration":10,
|
|
"columns":2, "layout":[0, 1], "tiles":[
|
|
{"art":{"asset":16,"logical_width":32,"logical_height":32},
|
|
"destroyed":{"asset":17,"logical_width":32,"logical_height":32}},
|
|
{"art":{"asset":18,"logical_width":32,"logical_height":32}}
|
|
]
|
|
}))
|
|
.unwrap();
|
|
registry.missions.insert(mission.id.clone(), mission);
|
|
registry
|
|
}
|
|
|
|
#[test]
|
|
fn native_terrain_damage_fixture_skips_same_art_and_prefers_visible_changes() {
|
|
let registry = terrain_sample_registry();
|
|
let mut mission = registry.missions[&ContentId::from("base:sample_fixture")].clone();
|
|
let mut same_art = mission.tiles[0].clone();
|
|
same_art.destroyed = Some(same_art.art.clone());
|
|
mission.tiles.push(same_art);
|
|
mission.layout = vec![2, 1, 0];
|
|
assert_eq!(native_terrain_damage_placement(&mission, 64), Some(2));
|
|
assert_eq!(native_terrain_damage_placement(&mission, 1000), Some(2));
|
|
mission.columns = 1;
|
|
mission.layout = vec![0, 2, 0];
|
|
assert_eq!(native_terrain_damage_placement(&mission, -64), Some(2));
|
|
mission.layout = vec![2, 1];
|
|
assert_eq!(native_terrain_damage_placement(&mission, 64), None);
|
|
}
|
|
|
|
#[test]
|
|
fn native_terrain_sample_skips_unused_targets_and_verifies_the_live_layout() {
|
|
let registry = terrain_sample_registry();
|
|
let mut job = serde_json::json!({"targets":[
|
|
{"asset":"0f", "terrain_context":{"samples":[],"registry_or_layout_referenced":false}},
|
|
{"asset":"10", "terrain_context":{"samples":[
|
|
{"mission":"base:sample_fixture","row":0,"column":0}
|
|
]}},
|
|
{"asset":"12", "terrain_context":{"samples":[
|
|
{"mission":"base:sample_fixture","row":0,"column":1}
|
|
]}}
|
|
]});
|
|
assert_eq!(
|
|
first_native_terrain_sample(&job, ®istry).unwrap(),
|
|
Some(NativeTerrainSample {
|
|
target_index: 1,
|
|
asset: 16,
|
|
mission: "base:sample_fixture".into(),
|
|
row: 0,
|
|
column: 0,
|
|
placement: 0,
|
|
settled: false,
|
|
})
|
|
);
|
|
job["targets"][1]["asset"] = "11".into();
|
|
assert!(
|
|
first_native_terrain_sample(&job, ®istry)
|
|
.unwrap()
|
|
.unwrap()
|
|
.settled
|
|
);
|
|
job["targets"][1]["asset"] = "12".into();
|
|
assert!(
|
|
first_native_terrain_sample(&job, ®istry)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("does not occur")
|
|
);
|
|
job["targets"][1]["terrain_context"]["samples"][0]["column"] = 2.into();
|
|
assert!(
|
|
first_native_terrain_sample(&job, ®istry)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("column")
|
|
);
|
|
job["targets"][1]["terrain_context"]["samples"][0]["column"] = 0.into();
|
|
job["targets"][1]["terrain_context"]["samples"][0]["row"] = 1.into();
|
|
assert!(
|
|
first_native_terrain_sample(&job, ®istry)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("row")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn native_terrain_sample_reports_an_entirely_unused_shard_without_a_fake_occurrence() {
|
|
let registry = terrain_sample_registry();
|
|
let mut job = serde_json::json!({"targets":[
|
|
{"asset":"0f", "terrain_context":{"samples":[],"registry_or_layout_referenced":false}},
|
|
{"asset":"ff", "terrain_context":{"samples":[],"registry_or_layout_referenced":false}}
|
|
]});
|
|
assert_eq!(first_native_terrain_sample(&job, ®istry).unwrap(), None);
|
|
job["targets"][1]["terrain_context"]["registry_or_layout_referenced"] = true.into();
|
|
assert!(
|
|
first_native_terrain_sample(&job, ®istry)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("not explicitly unused")
|
|
);
|
|
}
|
|
#[test]
|
|
#[ignore = "requires original catalog and canonical terrain candidate; captures cached native mission terrain"]
|
|
fn canonical_terrain_live_scene_matches_fresh_composition_after_scroll_damage_and_mod_changes()
|
|
{
|
|
let project = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.parent()
|
|
.unwrap();
|
|
let catalog = Catalog::open(project.join("data")).unwrap();
|
|
let base = Registry::base(&catalog).unwrap();
|
|
let temporary = tempfile::tempdir().unwrap();
|
|
let paths = Paths {
|
|
data: temporary.path().join("saves"),
|
|
config: temporary.path().join("config"),
|
|
cache: temporary.path().join("cache"),
|
|
legacy: temporary.path().join("legacy"),
|
|
};
|
|
let mut host =
|
|
Host::new(catalog, base, paths, temporary.path().join("mods"), true).unwrap();
|
|
let job_id =
|
|
std::env::var("RAPTOR_HD_TERRAIN_JOB").unwrap_or_else(|_| "terrain-10360-1036f".into());
|
|
assert!(
|
|
job_id
|
|
.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || c == '-')
|
|
);
|
|
let hd_root = project.join("data/hd");
|
|
let job_root = hd_root.join("jobs").join(&job_id);
|
|
let job: serde_json::Value =
|
|
serde_json::from_slice(&std::fs::read(job_root.join("job.json")).unwrap()).unwrap();
|
|
let output = project.join(".reference/terrain-hd").join(&job_id);
|
|
std::fs::create_dir_all(&output).unwrap();
|
|
let Some(sample) = first_native_terrain_sample(&job, &host.active.registry).unwrap() else {
|
|
std::fs::write(
|
|
output.join("native-scene-cache-proof.json"),
|
|
serde_json::to_vec_pretty(&serde_json::json!({
|
|
"job":job_id,"status":"no_native_occurrence",
|
|
"native_scene_validated":false,"all_32_scroll_phases_equal_fresh":false,
|
|
"native_destroyed_placement_tested":null,"mod_order_and_hash_invalidate":false,
|
|
"limits":"Every target is explicitly unused in native maps. Gallery or synthetic review remains necessary; no native gameplay validation was run."
|
|
})).unwrap(),
|
|
).unwrap();
|
|
println!("{job_id}: no native occurrence; native scene validation was not run");
|
|
return;
|
|
};
|
|
let candidate: serde_json::Value =
|
|
serde_json::from_slice(&std::fs::read(job_root.join("candidate.json")).unwrap())
|
|
.unwrap();
|
|
let pack_root = temporary.path().join("candidate");
|
|
for target in candidate["targets"].as_array().unwrap() {
|
|
let file = std::path::Path::new(target["file"].as_str().unwrap());
|
|
let destination = pack_root.join(file);
|
|
std::fs::create_dir_all(destination.parent().unwrap()).unwrap();
|
|
if !destination.exists() {
|
|
std::fs::copy(hd_root.join(file), destination).unwrap();
|
|
}
|
|
}
|
|
std::fs::write(
|
|
pack_root.join("hd.json"),
|
|
serde_json::to_vec(&serde_json::json!({
|
|
"format_version":1,"name":job_id,"sequences":candidate["sequences"]
|
|
}))
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
host.hd = HdAssets::load_lazy(&pack_root, &host.catalog, None);
|
|
host.hd_loaded = true;
|
|
host.config.scale = 4;
|
|
host.config.hd_mode = true;
|
|
let mission = sample.mission.clone();
|
|
let definition = &host.active.registry.missions[&mission];
|
|
let row_origin = 64 - sample.row as i32 * 32;
|
|
let scroll =
|
|
row_origin - 200 + (definition.layout.len() / definition.columns as usize) as i32 * 32;
|
|
let mut world = World::new(&mission, &host.pilot, &host.active.registry).unwrap();
|
|
world.tick = 64;
|
|
world.scroll = scroll;
|
|
if sample.settled {
|
|
world.terrain_dead[sample.placement] = true;
|
|
world.terrain_settled[sample.placement] = true;
|
|
}
|
|
host.world = Some(world);
|
|
host.screen = Screen::Game;
|
|
let capture = |host: &mut Host, name: &str| {
|
|
let before = serde_json::json!({"world":host.world,"pilot":host.pilot,"states":host.active.scripts.states,"rng":host.active.scripts.rng});
|
|
let frame = host.draw().unwrap();
|
|
let rgba = host.present_rgba(&frame);
|
|
image::save_buffer(
|
|
output.join(format!("{name}.png")),
|
|
&rgba,
|
|
frame.image.width as u32,
|
|
frame.image.height as u32,
|
|
image::ColorType::Rgba8,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
serde_json::json!({"world":host.world,"pilot":host.pilot,"states":host.active.scripts.states,"rng":host.active.scripts.rng}),
|
|
before
|
|
);
|
|
frame.image
|
|
};
|
|
capture(&mut host, "native-candidate-phase-00");
|
|
let before = std::time::Instant::now();
|
|
for _ in 0..60 {
|
|
std::hint::black_box(host.draw().unwrap());
|
|
}
|
|
let cached_scene_mean_ms = before.elapsed().as_secs_f64() * 1000.0 / 60.0;
|
|
for phase in 0..32 {
|
|
host.world.as_mut().unwrap().scroll = scroll + phase;
|
|
let cached = host.draw().unwrap().image;
|
|
host.terrain_hd = TerrainHd::default();
|
|
let fresh = host.draw().unwrap().image;
|
|
assert_eq!(cached, fresh, "scroll phase {phase}");
|
|
}
|
|
capture(&mut host, "native-candidate-phase-31");
|
|
host.world.as_mut().unwrap().scroll = scroll;
|
|
let definition = &host.active.registry.missions[&mission];
|
|
let damaged = native_terrain_damage_placement(definition, row_origin);
|
|
assert!(
|
|
damaged.is_some(),
|
|
"native fixture mission must include destroyed terrain"
|
|
);
|
|
if let Some(index) = damaged {
|
|
let damaged_row_origin = 64 - (index / definition.columns as usize) as i32 * 32;
|
|
host.world.as_mut().unwrap().scroll = damaged_row_origin - 200
|
|
+ (definition.layout.len() / definition.columns as usize) as i32 * 32;
|
|
// A destroyed-art sample can already have settled this placement.
|
|
// Start the independent damage check from a known intact state.
|
|
host.world.as_mut().unwrap().terrain_dead[index] = false;
|
|
host.world.as_mut().unwrap().terrain_settled[index] = false;
|
|
let intact = capture(&mut host, "native-candidate-destruction-intact");
|
|
host.world.as_mut().unwrap().terrain_dead[index] = true;
|
|
assert_eq!(
|
|
host.draw().unwrap().image,
|
|
intact,
|
|
"native tile keeps intact art until settled"
|
|
);
|
|
host.world.as_mut().unwrap().terrain_settled[index] = true;
|
|
let settled = capture(&mut host, "native-candidate-destroyed-settled");
|
|
assert_ne!(
|
|
settled, intact,
|
|
"destroyed tile must not reuse cached intact terrain"
|
|
);
|
|
host.terrain_hd = TerrainHd::default();
|
|
assert_eq!(
|
|
host.draw().unwrap().image,
|
|
settled,
|
|
"damaged cache matches fresh scene"
|
|
);
|
|
}
|
|
host.world.as_mut().unwrap().scroll = scroll;
|
|
// The prepared key includes mod pins in their active priority order.
|
|
// Verify it is not reused under a changed hash or reversed load order.
|
|
host.active.pins = vec![
|
|
modding::ModPin {
|
|
id: "test-a".into(),
|
|
version: "1".into(),
|
|
hash: "a".into(),
|
|
},
|
|
modding::ModPin {
|
|
id: "test-b".into(),
|
|
version: "1".into(),
|
|
hash: "b".into(),
|
|
},
|
|
];
|
|
host.draw().unwrap();
|
|
let make_key = |host: &Host| {
|
|
let definition = &host.active.registry.missions[&mission];
|
|
let world = host.world.as_ref().unwrap();
|
|
let mut visible = Vec::new();
|
|
for (index, tile) in definition.layout.iter().enumerate() {
|
|
let y = row_origin + (index / definition.columns as usize) as i32 * 32;
|
|
if (-66..234).contains(&y) {
|
|
visible.push((
|
|
selected_art(
|
|
&definition.tiles[*tile],
|
|
true,
|
|
world.terrain_dead[index],
|
|
world.terrain_settled[index],
|
|
)
|
|
.clone(),
|
|
16 + (index % definition.columns as usize) as i32 * 32,
|
|
y,
|
|
));
|
|
}
|
|
}
|
|
serde_json::to_vec(&(
|
|
&mission,
|
|
row_origin,
|
|
host.config.scale,
|
|
host.config.hd_mode,
|
|
&host.palette[..],
|
|
&host.active.pins,
|
|
&visible,
|
|
))
|
|
.unwrap()
|
|
};
|
|
assert!(host.terrain_hd.cached_layer(&make_key(&host)).is_some());
|
|
host.active.pins.reverse();
|
|
assert!(host.terrain_hd.cached_layer(&make_key(&host)).is_none());
|
|
host.draw().unwrap();
|
|
host.active.pins[0].hash.push_str("changed");
|
|
assert!(host.terrain_hd.cached_layer(&make_key(&host)).is_none());
|
|
host.draw().unwrap();
|
|
assert!(host.terrain_hd.prepared_bytes() <= 320 * 268 * 16 * 2 + 65536);
|
|
std::fs::write(output.join("native-scene-cache-proof.json"),serde_json::to_vec_pretty(&serde_json::json!({
|
|
"job":job_id,"status":"native_scene_validated","native_scene_validated":true,
|
|
"asset":format!("{:x}",sample.asset),"target_index":sample.target_index,
|
|
"mission":mission,"row":sample.row,"column":sample.column,"sample_settled":sample.settled,
|
|
"all_32_scroll_phases_equal_fresh":true,
|
|
"native_destroyed_placement_tested":damaged,"mod_order_and_hash_invalidate":true,
|
|
"cached_scene_mean_ms":cached_scene_mean_ms,"retained_strip_bytes":host.terrain_hd.prepared_bytes(),
|
|
"transition_lookup_bytes":host.terrain_hd.cache_bytes(),"registration":"Isolated candidate pack; global manifest unchanged"
|
|
})).unwrap()).unwrap();
|
|
println!(
|
|
"{job_id}: target {:x} at {mission} row {}, column {}; all 32 phases matched; cached native scene mean {cached_scene_mean_ms:.3} ms; damaged placement {damaged:?}",
|
|
sample.asset, sample.row, sample.column,
|
|
);
|
|
}
|
|
#[test]
|
|
#[ignore = "requires original catalog and authored mods; writes full menu/intro screenshot audit"]
|
|
fn every_menu_screen_and_original_intro_visual_audit() {
|
|
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
let catalog = Catalog::open(root.parent().unwrap().join("data")).unwrap();
|
|
let base = Registry::base(&catalog).unwrap();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = Paths {
|
|
data: temp.path().join("saves"),
|
|
config: temp.path().join("config"),
|
|
cache: temp.path().join("cache"),
|
|
legacy: temp.path().join("legacy"),
|
|
};
|
|
let mut host = Host::new(catalog, base, paths, root.join("mods"), false).unwrap();
|
|
host.config.scale = 4;
|
|
host.create_pilot("Aria".into(), "ACE".into(), 2).unwrap();
|
|
let output = root.parent().unwrap().join(".reference/ui-all");
|
|
std::fs::create_dir_all(&output).unwrap();
|
|
for entry in std::fs::read_dir(&output).unwrap() {
|
|
let path = entry.unwrap().path();
|
|
if path
|
|
.file_name()
|
|
.is_some_and(|name| name.to_string_lossy().starts_with("intro-"))
|
|
&& path.extension().is_some_and(|extension| extension == "png")
|
|
{
|
|
std::fs::remove_file(path).unwrap();
|
|
}
|
|
}
|
|
let mut manifest = Vec::<serde_json::Value>::new();
|
|
let mut capture = |host: &mut Host, name: &str| {
|
|
let before = serde_json::json!({"pilot":host.pilot,"world":host.world,"states":host.active.scripts.states,"rng":host.active.scripts.rng});
|
|
let image = host.draw().unwrap();
|
|
let rgba = host.present_rgba(&image);
|
|
image::save_buffer(
|
|
output.join(format!("{name}.png")),
|
|
&rgba,
|
|
image.image.width as u32,
|
|
image.image.height as u32,
|
|
image::ColorType::Rgba8,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
serde_json::json!({"pilot":host.pilot,"world":host.world,"states":host.active.scripts.states,"rng":host.active.scripts.rng}),
|
|
before
|
|
);
|
|
manifest.push(serde_json::json!({"file":format!("{name}.png"),"selected":host.selected,"rows":host.rows()}));
|
|
};
|
|
for (screen, name, selected) in [
|
|
(Screen::Main, "01-main", 0),
|
|
(Screen::Hangar, "02-hangar", 4),
|
|
(Screen::Missions, "03-missions-original", 0),
|
|
(Screen::Missions, "04-missions-scroll", 20),
|
|
(Screen::Extras, "05-help-credits", 0),
|
|
(Screen::Help, "06-controls", 0),
|
|
(Screen::Credits, "07-credits", 0),
|
|
(Screen::Options, "08-options-top", 0),
|
|
(Screen::Options, "09-options-save", 13),
|
|
(Screen::Bindings, "10-keyboard-bindings", 0),
|
|
(Screen::Pilots, "11-pilot-archive", 2),
|
|
(Screen::LegacyPilots, "12-legacy-import-empty", 0),
|
|
(Screen::Inventory, "13-inventory", 0),
|
|
] {
|
|
host.go(screen);
|
|
host.selected = selected;
|
|
host.message.clear();
|
|
capture(&mut host, name);
|
|
}
|
|
host.go(Screen::Main);
|
|
host.begin_pilot_form(true).unwrap();
|
|
host.type_pilot_text("Morgan");
|
|
host.pilot_field_key(Keycode::Return);
|
|
host.type_pilot_text("FALCON");
|
|
capture(&mut host, "14-new-pilot");
|
|
host.cancel_pilot_form();
|
|
host.hangar();
|
|
host.begin_pilot_form(false).unwrap();
|
|
capture(&mut host, "15-edit-pilot");
|
|
host.cancel_pilot_form();
|
|
let enabled =
|
|
crate::mod_selection::plan_enable(&host.discovery, &[], "iron_coast_mission_1")
|
|
.unwrap();
|
|
host.active =
|
|
ActiveMods::build(&host.base, &host.discovery, &enabled, Some(&host.catalog)).unwrap();
|
|
host.pending = enabled.clone();
|
|
host.pilot.mods = host.active.pins.clone();
|
|
host.pilot.credits = 500_000;
|
|
host.pilot
|
|
.inventory
|
|
.insert("pulse_cannon:equipment".into(), 1);
|
|
host.pilot
|
|
.inventory
|
|
.insert("seeker_swarm:equipment".into(), 1);
|
|
host.pilot.selected = Some("seeker_swarm:equipment".into());
|
|
host.hangar();
|
|
capture(&mut host, "16-hangar-with-mods");
|
|
host.go(Screen::Mods);
|
|
host.selected = host
|
|
.mod_ids()
|
|
.iter()
|
|
.position(|id| id == "iron_coast_mission_1")
|
|
.unwrap()
|
|
+ 2;
|
|
capture(&mut host, "17-mod-manager-dependencies");
|
|
let mod_rows = host.mod_ids();
|
|
let mod_selection = host.selected;
|
|
host.open_diagnostics();
|
|
capture(&mut host, "18-mod-details");
|
|
host.open_diagnostics();
|
|
host.back().unwrap();
|
|
assert!(host.screen == Screen::Mods);
|
|
assert_eq!(host.mod_ids(), mod_rows);
|
|
assert_eq!(host.selected, mod_selection);
|
|
host.selected = host
|
|
.mod_ids()
|
|
.iter()
|
|
.position(|id| id == "iron_coast_mission_1")
|
|
.unwrap()
|
|
+ 2;
|
|
host.activate().unwrap();
|
|
assert!(host.screen == Screen::ConfirmDisable);
|
|
capture(&mut host, "19-mod-disable-confirmation");
|
|
host.back().unwrap();
|
|
assert_eq!(host.selected, mod_selection);
|
|
host.go(Screen::Missions);
|
|
host.selected = host
|
|
.missions()
|
|
.iter()
|
|
.position(|id| id.0 == "iron_coast_mission_1:mission_1")
|
|
.unwrap_or(host.missions().len() - 1);
|
|
capture(&mut host, "20-mod-mission");
|
|
for (name, id, hd, selling) in [
|
|
("21-shop-original", "base:plasma_guns", false, false),
|
|
("22-shop-mod-sd", "pulse_cannon:equipment", false, false),
|
|
("23-shop-mod-hd", "pulse_cannon:equipment", true, false),
|
|
("24-shop-sell", "pulse_cannon:equipment", true, true),
|
|
] {
|
|
host.go(Screen::Shop);
|
|
host.shop_selling = selling;
|
|
host.config.hd_mode = hd;
|
|
let id = ContentId::from(id);
|
|
host.selected = host
|
|
.shop_ids()
|
|
.iter()
|
|
.position(|candidate| candidate == &id)
|
|
.unwrap()
|
|
+ 1;
|
|
capture(&mut host, name);
|
|
}
|
|
host.open_shop_details();
|
|
capture(&mut host, "25-equipment-details-top");
|
|
host.selected = host.rows().len() - 1;
|
|
capture(&mut host, "26-equipment-details-scroll");
|
|
host.back().unwrap();
|
|
host.go(Screen::Inventory);
|
|
host.selected = host
|
|
.inventory_ids()
|
|
.iter()
|
|
.position(|id| id.0 == "pulse_cannon:equipment")
|
|
.unwrap();
|
|
capture(&mut host, "27-inventory-mod-equipped");
|
|
let mut changed = host.pilot.clone();
|
|
changed.mods[0].hash = "changed-source-content".into();
|
|
changed.save(&host.paths.save_path(9)).unwrap();
|
|
host.request_pilot_load(9, Screen::Pilots).unwrap();
|
|
assert!(host.screen == Screen::ConfirmLoadMods);
|
|
capture(&mut host, "28-pilot-mod-mismatch-confirmation");
|
|
host.cancel_pilot_load();
|
|
host.start(host.pilot.mission.clone()).unwrap();
|
|
host.go(Screen::Pause);
|
|
host.selected = 3;
|
|
host.message.clear();
|
|
capture(&mut host, "29-pause-save");
|
|
host.go(Screen::Results);
|
|
host.message = "Mission complete - pilot saved".into();
|
|
capture(&mut host, "30-flight-report");
|
|
host.world = None;
|
|
host.go(Screen::Main);
|
|
host.selected = 6;
|
|
host.activate().unwrap();
|
|
assert!(host.screen == Screen::Cinematic);
|
|
let mut checkpoints = [64, 260, 708, 810, 1142, 1394, 1524, 1640, 1691, 1776, 1861]
|
|
.into_iter()
|
|
.peekable();
|
|
let mut completed_at = 0;
|
|
for tick in 1..10000 {
|
|
host.update(Input::default(), 14_285_715).unwrap();
|
|
if checkpoints.peek() == Some(&tick) {
|
|
capture(&mut host, &format!("intro-{tick:04}"));
|
|
checkpoints.next();
|
|
}
|
|
if host.screen != Screen::Cinematic {
|
|
completed_at = tick;
|
|
break;
|
|
}
|
|
}
|
|
assert_eq!(completed_at, 1891);
|
|
assert!(host.screen == Screen::Main);
|
|
capture(&mut host, "31-after-intro");
|
|
assert_eq!(
|
|
host.desired_music,
|
|
Some(host.catalog.id("MAINMENU_MUS").unwrap())
|
|
);
|
|
std::fs::write(output.join("screens.json"),serde_json::to_vec_pretty(&serde_json::json!({"screens":manifest,"intro_complete_tick":completed_at,"fixture":"isolated profile; native assets, seven Iron Coast packages"})).unwrap()).unwrap();
|
|
println!(
|
|
"Full menu and original intro captures: {} (intro {} ticks)",
|
|
output.display(),
|
|
completed_at
|
|
);
|
|
}
|
|
#[test]
|
|
#[ignore = "requires original catalog; writes ignored menu usability captures"]
|
|
fn classic_main_hangar_registration_and_explicit_save_visual_capture() {
|
|
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
let catalog = Catalog::open(root.parent().unwrap().join("data")).unwrap();
|
|
let registry = Registry::base(&catalog).unwrap();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = Paths {
|
|
data: temp.path().join("saves"),
|
|
config: temp.path().join("config"),
|
|
cache: temp.path().join("cache"),
|
|
legacy: temp.path().join("legacy"),
|
|
};
|
|
let mut host =
|
|
Host::new(catalog, registry, paths, temp.path().join("mods"), false).unwrap();
|
|
host.config.scale = 4;
|
|
let output = root.parent().unwrap().join(".reference/ui-menu");
|
|
std::fs::create_dir_all(&output).unwrap();
|
|
let capture = |host: &mut Host, name: &str| {
|
|
let before = serde_json::to_value(&host.pilot).unwrap();
|
|
let canvas = host.draw().unwrap();
|
|
image::save_buffer(
|
|
output.join(format!("{name}.png")),
|
|
&host.present_rgba(&canvas),
|
|
canvas.image.width as u32,
|
|
canvas.image.height as u32,
|
|
image::ColorType::Rgba8,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(serde_json::to_value(&host.pilot).unwrap(), before);
|
|
};
|
|
for (selected, name) in [
|
|
(0, "main-classic"),
|
|
(3, "main-options-selected"),
|
|
(7, "main-quit-selected"),
|
|
] {
|
|
host.selected = selected;
|
|
let canvas = host.draw().unwrap();
|
|
assert_eq!(
|
|
canvas.image.pixels[(MAIN_MENU_Y as usize + 1 + selected * 16)
|
|
* 4
|
|
* canvas.image.width
|
|
+ MAIN_MENU_X as usize * 4],
|
|
crate::menu_art::MENU_ACCENT
|
|
);
|
|
capture(&mut host, name);
|
|
}
|
|
host.selected = 1;
|
|
host.activate().unwrap();
|
|
host.type_pilot_text("Aria");
|
|
host.pilot_field_key(Keycode::Return);
|
|
host.type_pilot_text("ACE");
|
|
capture(&mut host, "register-pilot");
|
|
host.pilot_field_key(Keycode::Return);
|
|
host.activate().unwrap();
|
|
host.message.clear();
|
|
host.selected = 4;
|
|
capture(&mut host, "hangar-save-visible");
|
|
host.activate().unwrap();
|
|
assert!(host.paths.save_path(host.slot).exists());
|
|
capture(&mut host, "save-confirmation");
|
|
host.selected = 5;
|
|
host.activate().unwrap();
|
|
capture(&mut host, "edit-pilot-id");
|
|
host.back().unwrap();
|
|
host.go(Screen::Pilots);
|
|
host.message.clear();
|
|
assert!(host.rows()[2].contains("ACE / Aria"));
|
|
capture(&mut host, "pilot-load-import");
|
|
host.previous = Screen::Main;
|
|
host.go(Screen::Options);
|
|
host.message.clear();
|
|
host.selected = 13;
|
|
capture(&mut host, "settings-save");
|
|
host.start(host.pilot.mission.clone()).unwrap();
|
|
host.go(Screen::Pause);
|
|
host.message.clear();
|
|
host.selected = 3;
|
|
capture(&mut host, "pause-save-hangar-state");
|
|
}
|
|
#[test]
|
|
#[ignore = "requires local original catalog and supplied mod PNGs; writes ignored shop captures"]
|
|
fn shop_original_and_mod_art_details_capture_without_editing_pilots() {
|
|
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
let catalog_root = std::env::var_os("RAPTOR_TEST_DATA")
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| root.parent().unwrap().join("data"));
|
|
let catalog = Catalog::open(catalog_root).unwrap();
|
|
let registry = Registry::base(&catalog).unwrap();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = Paths {
|
|
data: temp.path().join("saves"),
|
|
config: temp.path().join("config"),
|
|
cache: temp.path().join("cache"),
|
|
legacy: temp.path().join("legacy"),
|
|
};
|
|
let mut host = Host::new(catalog, registry, paths, root.join("mods"), true).unwrap();
|
|
let enabled = [
|
|
"pulse_cannon".into(),
|
|
"seeker_swarm".into(),
|
|
"ion_lance".into(),
|
|
];
|
|
host.active =
|
|
ActiveMods::build(&host.base, &host.discovery, &enabled, Some(&host.catalog)).unwrap();
|
|
host.pilot.credits = 4_000_000;
|
|
host.config.scale = 4;
|
|
let output = root.parent().unwrap().join(".reference/ui-shop");
|
|
std::fs::create_dir_all(&output).unwrap();
|
|
let capture = |host: &mut Host, name: &str| {
|
|
host.message.clear();
|
|
let before = serde_json::to_value(&host.pilot).unwrap();
|
|
let canvas = host.draw().unwrap();
|
|
let rgba = host.present_rgba(&canvas);
|
|
image::save_buffer(
|
|
output.join(format!("{name}.png")),
|
|
&rgba,
|
|
canvas.image.width as u32,
|
|
canvas.image.height as u32,
|
|
image::ColorType::Rgba8,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(serde_json::to_value(&host.pilot).unwrap(), before);
|
|
assert!(!host.paths.data.exists());
|
|
rgba
|
|
};
|
|
for (legacy, name) in [
|
|
(1, "original-plasma"),
|
|
(5, "original-air-missile"),
|
|
(9, "original-ground-bomb"),
|
|
] {
|
|
let id = host
|
|
.active
|
|
.registry
|
|
.items
|
|
.values()
|
|
.find(|item| item.legacy == Some(legacy))
|
|
.unwrap()
|
|
.id
|
|
.clone();
|
|
host.go(Screen::Shop);
|
|
host.selected = host
|
|
.shop_ids()
|
|
.iter()
|
|
.position(|candidate| candidate == &id)
|
|
.unwrap()
|
|
+ 1;
|
|
let info = host.shop_info(&id).unwrap();
|
|
assert!(!info.description.is_empty());
|
|
assert_eq!(info.buy, "Ready");
|
|
capture(&mut host, name);
|
|
}
|
|
for namespace in ["pulse_cannon", "seeker_swarm", "ion_lance"] {
|
|
let id = ContentId::from(format!("{namespace}:equipment").as_str());
|
|
host.go(Screen::Shop);
|
|
host.selected = host
|
|
.shop_ids()
|
|
.iter()
|
|
.position(|candidate| candidate == &id)
|
|
.unwrap()
|
|
+ 1;
|
|
assert!(
|
|
host.active.registry.items[&id]
|
|
.art
|
|
.as_ref()
|
|
.unwrap()
|
|
.hd_png
|
|
.is_some()
|
|
);
|
|
assert!(!host.shop_info(&id).unwrap().description.is_empty());
|
|
host.config.hd_mode = false;
|
|
let sd = capture(&mut host, &format!("{namespace}-sd"));
|
|
host.config.hd_mode = true;
|
|
let hd = capture(&mut host, &format!("{namespace}-hd"));
|
|
assert_ne!(
|
|
sd, hd,
|
|
"supplied SD and HD equipment previews should differ"
|
|
);
|
|
host.open_shop_details();
|
|
capture(&mut host, &format!("{namespace}-full-details"));
|
|
}
|
|
let id = ContentId::from("base:super_shield");
|
|
host.pilot.inventory.insert(id.clone(), 40);
|
|
host.pilot.super_shield_reserves = vec![100, 100];
|
|
host.go(Screen::Shop);
|
|
host.shop_selling = true;
|
|
host.selected = host
|
|
.shop_ids()
|
|
.iter()
|
|
.position(|candidate| candidate == &id)
|
|
.unwrap()
|
|
+ 1;
|
|
assert_eq!(host.shop_info(&id).unwrap().owned, 3);
|
|
assert!(host.shop_info(&id).unwrap().sell.contains("fully charged"));
|
|
capture(&mut host, "super-shield-partial-resale");
|
|
println!("Shop captures: {}", output.display());
|
|
}
|
|
#[test]
|
|
fn disabling_a_single_unneeded_mod_toggles_without_confirmation() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
install_dependency_fixture(&mut host);
|
|
host.pending = vec!["dependency".into()];
|
|
host.go(Screen::Mods);
|
|
host.selected = 2;
|
|
host.activate().unwrap();
|
|
assert!(host.pending.is_empty());
|
|
assert!(host.pending_disable.is_none());
|
|
assert!(host.screen == Screen::Mods);
|
|
assert!(host.active.pins.is_empty());
|
|
}
|
|
#[test]
|
|
fn disabling_a_dependent_lists_its_unused_dependency_before_removal() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
install_dependency_fixture(&mut host);
|
|
host.pending = vec!["dependency".into(), "dependent".into()];
|
|
host.go(Screen::Mods);
|
|
host.selected = host
|
|
.mod_ids()
|
|
.iter()
|
|
.position(|id| id == "dependent")
|
|
.unwrap()
|
|
+ 2;
|
|
host.activate().unwrap();
|
|
assert!(host.screen == Screen::ConfirmDisable);
|
|
let removed = &host.pending_disable.as_ref().unwrap().removed;
|
|
assert!(removed.iter().any(|id| id == "dependency"));
|
|
assert!(removed.iter().any(|id| id == "dependent"));
|
|
assert_eq!(host.pending, vec!["dependency", "dependent"]);
|
|
host.selected = 0;
|
|
host.activate().unwrap();
|
|
assert!(host.pending.is_empty());
|
|
}
|
|
#[test]
|
|
fn hud_shield_ramp_matches_native_fixed_point_colors_and_clear_rows() {
|
|
for scale in [1, 2, 3, 4] {
|
|
for amount in [-1, 0, 10, 50, 100, 120] {
|
|
let mut canvas = Canvas::new(scale);
|
|
canvas.clear(37);
|
|
hud_shield_bar(&mut canvas, 308, amount);
|
|
let mut accumulator = 0_u32;
|
|
for level in 0..100 {
|
|
let expected = if level < amount.clamp(0, 100) {
|
|
74 - (accumulator >> 16) as u16
|
|
} else {
|
|
0
|
|
};
|
|
for y in (199 - level * 2) as usize * scale..(200 - level * 2) as usize * scale
|
|
{
|
|
for x in 308 * scale..312 * scale {
|
|
assert_eq!(canvas.image.pixels[y * canvas.image.width + x], expected);
|
|
}
|
|
}
|
|
accumulator += (9 << 16) / 100;
|
|
}
|
|
assert_eq!(
|
|
canvas.image.pixels[199 * scale * canvas.image.width + 307 * scale],
|
|
37
|
|
);
|
|
assert_eq!(
|
|
canvas.image.pixels[198 * scale * canvas.image.width + 308 * scale],
|
|
37
|
|
);
|
|
assert_eq!(
|
|
canvas
|
|
.image
|
|
.pixels
|
|
.iter()
|
|
.filter(|&&p| (66..=74).contains(&p))
|
|
.count(),
|
|
amount.clamp(0, 100) as usize * 4 * scale * scale
|
|
);
|
|
}
|
|
}
|
|
}
|
|
#[test]
|
|
fn hud_warning_latches_follow_steps_and_ignore_repeated_render_observations() {
|
|
let (registry, pilot) = fixture();
|
|
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
|
let mut state = HudState::new(&world);
|
|
let before = world.rng.seed;
|
|
world.player.shield = 9;
|
|
for tick in 1..=32 {
|
|
world.tick = tick;
|
|
state.observe(&world, ®istry);
|
|
let snapshot = (state.blink, state.damage_flashes, state.sweep);
|
|
for _ in 0..5 {
|
|
state.observe(&world, ®istry);
|
|
}
|
|
assert_eq!((state.blink, state.damage_flashes, state.sweep), snapshot);
|
|
assert_eq!(state.blink, (tick / 8) % 2 == 0);
|
|
assert_eq!(
|
|
state.damage_flashes,
|
|
if tick < 16 {
|
|
2
|
|
} else if tick < 32 {
|
|
1
|
|
} else {
|
|
0
|
|
}
|
|
);
|
|
}
|
|
assert_eq!(world.rng.seed, before);
|
|
world.player.shield = 50;
|
|
for tick in 33..=48 {
|
|
world.tick = tick;
|
|
state.observe(&world, ®istry);
|
|
}
|
|
assert!(
|
|
state.blink,
|
|
"healthy shields leave the low-energy blink latch alone"
|
|
);
|
|
}
|
|
#[test]
|
|
fn hud_detector_averages_visible_boss_health_and_respects_owned_equipment() {
|
|
let (mut registry, pilot) = fixture();
|
|
for (id, health) in [("test:boss_a", 100), ("test:boss_b", 200)] {
|
|
let enemy: crate::content::EnemyDef = serde_json::from_value(serde_json::json!({
|
|
"id":id,"name":id,"health":health,"boss":true
|
|
}))
|
|
.unwrap();
|
|
registry.enemies.insert(enemy.id.clone(), enemy);
|
|
}
|
|
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
|
world
|
|
.spawn(&"test:boss_a".into(), 100, -17, ®istry)
|
|
.unwrap();
|
|
world.enemies[0].health = 50;
|
|
assert_eq!(boss_shield_percent(&world, ®istry), 0);
|
|
world.enemies[0].y = -16;
|
|
assert_eq!(boss_shield_percent(&world, ®istry), 50);
|
|
world
|
|
.spawn(&"test:boss_b".into(), 100, 40, ®istry)
|
|
.unwrap();
|
|
world.enemies[1].health = 50;
|
|
assert_eq!(boss_shield_percent(&world, ®istry), 37);
|
|
let mut state = HudState::new(&world);
|
|
world.tick = 1;
|
|
state.observe(&world, ®istry);
|
|
assert_eq!(state.sweep, 0, "detector is unavailable without equipment");
|
|
world.player.inventory.insert("base:detect".into(), 1);
|
|
world.enemies.clear();
|
|
world.tick = 2;
|
|
state.observe(&world, ®istry);
|
|
assert_eq!(state.sweep, 1);
|
|
state.observe(&world, ®istry);
|
|
assert_eq!(state.sweep, 1);
|
|
for tick in 3..=51 {
|
|
world.tick = tick;
|
|
state.observe(&world, ®istry);
|
|
}
|
|
assert_eq!(state.sweep, 0);
|
|
}
|
|
#[test]
|
|
#[ignore = "requires local original catalog; writes ignored gameplay HUD captures"]
|
|
fn hud_original_art_and_mod_icons_keep_native_slots_and_simulation_unchanged() {
|
|
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
let catalog = Catalog::open(root.parent().unwrap().join("data")).unwrap();
|
|
let registry = Registry::base(&catalog).unwrap();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let paths = Paths {
|
|
data: temp.path().join("saves"),
|
|
config: temp.path().join("config"),
|
|
cache: temp.path().join("cache"),
|
|
legacy: temp.path().join("legacy"),
|
|
};
|
|
let mut host = Host::new(catalog, registry, paths, temp.path().join("mods"), true).unwrap();
|
|
host.config.scale = 4;
|
|
host.pilot = Pilot::demo(1, &host.active.registry).unwrap();
|
|
let mission = host.pilot.mission.clone();
|
|
host.start(mission).unwrap();
|
|
let mut world = host.world.take().unwrap();
|
|
world.player.credits = 12345678;
|
|
world.player.shield = 50;
|
|
let selected = host
|
|
.active
|
|
.registry
|
|
.items
|
|
.values()
|
|
.find(|item| item.legacy == Some(5))
|
|
.unwrap()
|
|
.id
|
|
.clone();
|
|
world.player.selected = Some(selected.clone());
|
|
world.player.super_shield_reserves = vec![31, 100];
|
|
host.hud_state = HudState::new(&world);
|
|
let snapshot = serde_json::to_value(&world).unwrap();
|
|
let mut canvas = Canvas::new(4);
|
|
canvas.clear(37);
|
|
host.hud(&mut canvas, &world).unwrap();
|
|
let digits: Vec<_> = (0..11)
|
|
.map(|digit| host.catalog.sprite(0x10005 + digit).unwrap())
|
|
.collect();
|
|
let mut expected = Canvas::new(4);
|
|
expected.clear(37);
|
|
expected.blit(&nearest(&digits[10], 4), 119 * 4, 2 * 4);
|
|
for (position, digit) in "12345678".bytes().enumerate() {
|
|
expected.blit(
|
|
&nearest(&digits[(digit - b'0') as usize], 4),
|
|
(128 + position as i32 * 8) * 4,
|
|
2 * 4,
|
|
);
|
|
}
|
|
for y in 2 * 4..10 * 4 {
|
|
for x in 119 * 4..192 * 4 {
|
|
assert_eq!(
|
|
canvas.image.pixels[y * canvas.image.width + x],
|
|
expected.image.pixels[y * expected.image.width + x]
|
|
);
|
|
}
|
|
}
|
|
assert_eq!(serde_json::to_value(&world).unwrap(), snapshot);
|
|
let original_icon = host.active.registry.items[&selected]
|
|
.art
|
|
.as_ref()
|
|
.unwrap()
|
|
.asset
|
|
.unwrap();
|
|
let native = host.catalog.sprite(original_icon).unwrap();
|
|
for y in 0..native.height * 4 {
|
|
for x in 0..native.width * 4 {
|
|
let pixel = native.pixels[y / 4 * native.width + x / 4];
|
|
assert_eq!(
|
|
canvas.image.pixels[(2 * 4 + y) * canvas.image.width + 286 * 4 + x],
|
|
if pixel == crate::assets::TRANSPARENT {
|
|
37
|
|
} else {
|
|
pixel
|
|
}
|
|
);
|
|
}
|
|
}
|
|
// Supplied SD/HD artwork uses its registry owner and fits the native
|
|
// 18px equipment slot, rather than painting over the right shield bar.
|
|
let mut supplied = image::RgbaImage::new(24, 24);
|
|
let rgb = host.palette[245];
|
|
for y in 4..20 {
|
|
for x in 4..20 {
|
|
supplied.put_pixel(x, y, image::Rgba([rgb[0], rgb[1], rgb[2], 255]));
|
|
}
|
|
}
|
|
supplied.save(temp.path().join("equipment.png")).unwrap();
|
|
image::imageops::resize(&supplied, 192, 192, image::imageops::FilterType::Nearest)
|
|
.save(temp.path().join("equipment-hd.png"))
|
|
.unwrap();
|
|
let pack:crate::content::ContentPack = serde_json::from_value(serde_json::json!({
|
|
"items":[{"id":"hud:equipment","name":"Supplied HUD icon","price":0,
|
|
"art":{"png":"equipment.png","hd_png":"equipment-hd.png","logical_width":24,"logical_height":24}}]
|
|
})).unwrap();
|
|
host.active.registry.apply(pack, "hud").unwrap();
|
|
host.active
|
|
.registry
|
|
.asset_roots
|
|
.insert("hud".into(), temp.path().to_owned());
|
|
for hd in [false, true] {
|
|
host.config.hd_mode = hd;
|
|
let mut mod_icon = Canvas::new(4);
|
|
mod_icon.clear(37);
|
|
host.hud_weapon(&mut mod_icon, &"hud:equipment".into())
|
|
.unwrap();
|
|
assert!(mod_icon.image.pixels.iter().any(|&pixel| pixel != 37));
|
|
for (index, &pixel) in mod_icon.image.pixels.iter().enumerate() {
|
|
if pixel != 37 {
|
|
assert!((286 * 4..304 * 4).contains(&(index % mod_icon.image.width)));
|
|
assert!((2 * 4..20 * 4).contains(&(index / mod_icon.image.width)));
|
|
}
|
|
}
|
|
}
|
|
host.config.hd_mode = false;
|
|
host.world = Some(world.clone());
|
|
let output = root.parent().unwrap().join(".reference/ui");
|
|
std::fs::create_dir_all(&output).unwrap();
|
|
let mut normal = Canvas::new(4);
|
|
host.scene(&mut normal).unwrap();
|
|
image::save_buffer(
|
|
output.join("hud-full-original.png"),
|
|
&normal.rgba(&host.palette, false),
|
|
normal.image.width as u32,
|
|
normal.image.height as u32,
|
|
image::ColorType::Rgba8,
|
|
)
|
|
.unwrap();
|
|
world.tick = 1;
|
|
world.player.shield = 9;
|
|
world.player.super_shield = 0;
|
|
world.player.super_shield_reserves.clear();
|
|
world
|
|
.player
|
|
.inventory
|
|
.remove(&ContentId::from("base:super_shield"));
|
|
host.hud_state.observe(&world, &host.active.registry);
|
|
assert_eq!(host.hud_state.damage_flashes, 2);
|
|
host.world = Some(world);
|
|
let mut critical = Canvas::new(4);
|
|
host.scene(&mut critical).unwrap();
|
|
image::save_buffer(
|
|
output.join("hud-critical-original.png"),
|
|
&critical.rgba(&host.palette, false),
|
|
critical.image.width as u32,
|
|
critical.image.height as u32,
|
|
image::ColorType::Rgba8,
|
|
)
|
|
.unwrap();
|
|
assert!(!host.paths.save_path(0).exists());
|
|
}
|
|
#[test]
|
|
fn failed_victory_save_can_retry_without_advancing_training_twice() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
let mission: crate::content::MissionDef = serde_json::from_value(serde_json::json!({
|
|
"id":"base:sector_1_wave_4", "name":"Training graduation", "duration":100,
|
|
"legacy_map":42
|
|
}))
|
|
.unwrap();
|
|
host.active
|
|
.registry
|
|
.missions
|
|
.insert(mission.id.clone(), mission);
|
|
host.pilot.mission = "base:sector_1_wave_4".into();
|
|
host.pilot.difficulty = 0;
|
|
host.pilot.credits = 6_000;
|
|
host.config.difficulty = 0;
|
|
let before = serde_json::to_value(&host.pilot).unwrap();
|
|
let mut world =
|
|
World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap();
|
|
world.player.credits = 12_345;
|
|
world.outcome = Outcome::Complete;
|
|
host.world = Some(world);
|
|
host.screen = Screen::Game;
|
|
std::fs::write(&host.paths.data, "blocked save directory").unwrap();
|
|
assert!(host.update(Input::default(), 43_000_000).is_err());
|
|
assert_eq!(serde_json::to_value(&host.pilot).unwrap(), before);
|
|
assert_eq!(host.config.difficulty, 0);
|
|
assert_eq!(host.world.as_ref().unwrap().outcome, Outcome::Complete);
|
|
std::fs::remove_file(&host.paths.data).unwrap();
|
|
host.update(Input::default(), 43_000_000).unwrap();
|
|
let saved =
|
|
Pilot::load(&host.paths.save_path(host.slot), &host.active.registry, &[]).unwrap();
|
|
assert_eq!(saved.mission.0, "base:sector_1_wave_1");
|
|
assert_eq!(saved.difficulty, 1);
|
|
assert_eq!(saved.credits, 10_000);
|
|
assert_eq!(saved.inventory[&"base:energy".into()], 75);
|
|
assert!(saved.legacy_progress.as_ref().unwrap().training_completed);
|
|
assert_eq!(
|
|
saved.completed,
|
|
vec![ContentId::from("base:sector_1_wave_4")]
|
|
);
|
|
assert_eq!(host.config.difficulty, 1);
|
|
assert!(host.screen == Screen::Results);
|
|
}
|
|
#[test]
|
|
fn committed_victory_is_not_reprocessed_when_only_settings_save_fails() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
let mut world =
|
|
World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap();
|
|
world.player.credits = 1_234;
|
|
world.outcome = Outcome::Complete;
|
|
host.world = Some(world);
|
|
host.screen = Screen::Game;
|
|
std::fs::write(&host.paths.config, "blocked settings directory").unwrap();
|
|
host.update(Input::default(), 43_000_000).unwrap();
|
|
assert!(host.screen == Screen::Results);
|
|
assert!(host.message.contains("settings save failed"));
|
|
assert_eq!(host.pilot.credits, 1_234);
|
|
assert_eq!(host.pilot.completed.len(), 1);
|
|
let saved =
|
|
Pilot::load(&host.paths.save_path(host.slot), &host.active.registry, &[]).unwrap();
|
|
assert_eq!(saved.credits, 1_234);
|
|
let before = serde_json::to_value(&host.pilot).unwrap();
|
|
host.update(Input::default(), 43_000_000).unwrap();
|
|
assert_eq!(serde_json::to_value(&host.pilot).unwrap(), before);
|
|
}
|
|
#[test]
|
|
fn loading_a_pilot_restores_scripts_before_hangar_saves() {
|
|
let (_, mut pilot) = fixture();
|
|
pilot.script_state.insert(
|
|
"weapon:behavior.rhai".into(),
|
|
serde_json::json!({"shots": 42}),
|
|
);
|
|
pilot.script_rng.insert(
|
|
"weapon:behavior.rhai".into(),
|
|
crate::simulation::LegacyRng::new(731),
|
|
);
|
|
let mut runtime = modding::ScriptRuntime::new();
|
|
restore_scripts(&mut runtime, &pilot);
|
|
let mut resaved = pilot.clone();
|
|
resaved.script_state = runtime.states.clone();
|
|
resaved.script_rng = runtime.rng.clone();
|
|
assert_eq!(resaved.script_state, pilot.script_state);
|
|
assert_eq!(resaved.script_rng["weapon:behavior.rhai"].seed, 731);
|
|
restore_scripts(&mut runtime, &Pilot::default());
|
|
assert!(runtime.states.is_empty());
|
|
assert!(runtime.rng.is_empty());
|
|
}
|
|
fn fixture() -> (Registry, Pilot) {
|
|
let mut registry = Registry::default();
|
|
let mut pilot = Pilot::default();
|
|
pilot.inventory.clear();
|
|
let mission: crate::content::MissionDef = serde_json::from_value(serde_json::json!({
|
|
"id": pilot.mission, "name": "Save safety fixture", "duration": 100
|
|
}))
|
|
.unwrap();
|
|
registry.missions.insert(mission.id.clone(), mission);
|
|
(registry, pilot)
|
|
}
|
|
#[test]
|
|
fn applying_mods_checks_saved_pilot_instead_of_display_placeholder() {
|
|
let (registry, placeholder) = fixture();
|
|
assert!(placeholder.validate(®istry, &[]).is_ok());
|
|
let pin = modding::ModPin {
|
|
id: "example".into(),
|
|
version: "1.0.0".into(),
|
|
hash: "required".into(),
|
|
};
|
|
let mut saved = placeholder.clone();
|
|
saved.mods = vec![pin.clone()];
|
|
saved.callsign = "SAVED PILOT".into();
|
|
saved.credits = 4242;
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("pilot.json");
|
|
saved.save(&path).unwrap();
|
|
let before = std::fs::read(&path).unwrap();
|
|
assert!(pilot_for_mods(&path, &placeholder, ®istry, &[]).is_err());
|
|
assert_eq!(std::fs::read(&path).unwrap(), before);
|
|
let restored = pilot_for_mods(&path, &placeholder, ®istry, &[pin]).unwrap();
|
|
assert_eq!(restored.callsign, "SAVED PILOT");
|
|
assert_eq!(restored.credits, 4242);
|
|
}
|
|
#[test]
|
|
fn corrupt_existing_slot_cannot_be_replaced_by_valid_placeholder() {
|
|
let (registry, placeholder) = fixture();
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("pilot.json");
|
|
std::fs::write(&path, b"corrupt existing save").unwrap();
|
|
assert!(pilot_for_mods(&path, &placeholder, ®istry, &[]).is_err());
|
|
assert_eq!(std::fs::read(&path).unwrap(), b"corrupt existing save");
|
|
let unused = dir.path().join("unused.json");
|
|
assert_eq!(
|
|
pilot_for_mods(&unused, &placeholder, ®istry, &[])
|
|
.unwrap()
|
|
.callsign,
|
|
placeholder.callsign
|
|
);
|
|
}
|
|
#[test]
|
|
fn full_hd_window_retains_centered_four_three_viewport() {
|
|
assert_eq!(viewport(1920, 1080, true), Rect::new(240, 0, 1440, 1080));
|
|
assert_eq!(viewport(1920, 1080, false), Rect::new(0, 0, 1920, 1080));
|
|
}
|
|
#[test]
|
|
fn widescreen_menus_map_every_action_and_keep_gameplay_in_four_three() {
|
|
let (_directory, mut host) = ui_fixture();
|
|
for (width, height) in [(1280, 720), (1280, 800), (1920, 1080)] {
|
|
let rect = presentation_viewport(&host, width, height);
|
|
assert_eq!(rect.width() * 9, rect.height() * 16);
|
|
for row in 0..8 {
|
|
let logical = [MAIN_MENU_X + 20, MAIN_MENU_Y + row * 16 + 5];
|
|
let point = menu_point(
|
|
&host,
|
|
width,
|
|
height,
|
|
rect.x() + logical[0] * rect.width() as i32 / MENU_WIDTH as i32,
|
|
rect.y() + logical[1] * rect.height() as i32 / MENU_HEIGHT as i32,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
menu_row_at(Screen::Main, 7, 8, point[0], point[1]),
|
|
Some(row as usize)
|
|
);
|
|
}
|
|
assert!(menu_point(&host, width, height, -1, -1).is_none());
|
|
}
|
|
host.go(Screen::Hangar);
|
|
let point = menu_point(
|
|
&host,
|
|
1920,
|
|
1080,
|
|
(MENU_OFFSET[0] + 200) * 1920 / MENU_WIDTH as i32,
|
|
(MENU_OFFSET[1] + 111) * 1080 / MENU_HEIGHT as i32,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
menu_row_at(Screen::Hangar, 0, 7, point[0], point[1]),
|
|
Some(4)
|
|
);
|
|
let menu = host.draw().unwrap();
|
|
assert_eq!(
|
|
(menu.image.width, menu.image.height),
|
|
(
|
|
MENU_WIDTH * host.config.scale,
|
|
MENU_HEIGHT * host.config.scale
|
|
)
|
|
);
|
|
host.world =
|
|
Some(World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap());
|
|
assert_eq!(
|
|
presentation_viewport(&host, 1920, 1080),
|
|
Rect::new(240, 0, 1440, 1080)
|
|
);
|
|
}
|
|
#[test]
|
|
fn palette_effects_do_not_consume_simulation_rng() {
|
|
let (registry, pilot) = fixture();
|
|
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
|
world.tick = 7;
|
|
world.palette_blue = false;
|
|
let before = world.rng.seed;
|
|
let base = [[255, 128, 64]; 256];
|
|
let first = mission_palette(&base, Some(&world));
|
|
assert_eq!(first, mission_palette(&base, Some(&world)));
|
|
assert_eq!(world.rng.seed, before);
|
|
assert_eq!(first[254], [0; 3]);
|
|
world.palette_blue = true;
|
|
assert_eq!(mission_palette(&base, Some(&world))[254], base[254]);
|
|
}
|
|
#[test]
|
|
fn mismatched_pilot_load_requires_confirmation_cancel_preserves_file_and_accept_backs_up() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
let mut saved = host.pilot.clone();
|
|
saved.name = "Original".into();
|
|
saved.credits = 100;
|
|
saved.mods = vec![modding::ModPin {
|
|
id: "absent_pack".into(),
|
|
version: "1.0.0".into(),
|
|
hash: "original_hash".into(),
|
|
}];
|
|
saved.inventory.insert("absent_pack:weapon".into(), 1);
|
|
saved.selected = Some("absent_pack:weapon".into());
|
|
saved.completed.push("absent_pack:mission".into());
|
|
saved
|
|
.script_state
|
|
.insert("absent_pack".into(), serde_json::json!({"stage":3}));
|
|
saved.save(&host.paths.save_path(1)).unwrap();
|
|
let original = std::fs::read(host.paths.save_path(1)).unwrap();
|
|
let before = serde_json::to_value(&host.pilot).unwrap();
|
|
host.request_pilot_load(1, Screen::Pilots).unwrap();
|
|
assert!(host.screen == Screen::ConfirmLoadMods);
|
|
assert_eq!(host.selected, 0);
|
|
assert!(host.rows().iter().any(|line| line.contains("absent_pack")));
|
|
host.back().unwrap();
|
|
assert!(host.screen == Screen::Pilots);
|
|
assert_eq!(serde_json::to_value(&host.pilot).unwrap(), before);
|
|
assert_eq!(std::fs::read(host.paths.save_path(1)).unwrap(), original);
|
|
host.request_pilot_load(1, Screen::Pilots).unwrap();
|
|
host.selected = 1;
|
|
host.choose().unwrap();
|
|
assert!(host.screen == Screen::Hangar);
|
|
assert!(host.mismatch_confirmed);
|
|
host.require_pilot().unwrap();
|
|
assert_eq!(std::fs::read(host.paths.save_path(1)).unwrap(), original);
|
|
host.edit_pilot(|pilot, registry| pilot.buy(&"base:fixture".into(), registry))
|
|
.unwrap();
|
|
let updated = Pilot::inspect_load(&host.paths.save_path(1), &host.active.registry, &[])
|
|
.unwrap()
|
|
.pilot;
|
|
assert_eq!(updated.inventory[&ContentId::from("absent_pack:weapon")], 1);
|
|
assert!(updated.completed.contains(&"absent_pack:mission".into()));
|
|
assert_eq!(updated.mods, saved.mods);
|
|
assert_eq!(
|
|
updated.script_state["absent_pack"],
|
|
saved.script_state["absent_pack"]
|
|
);
|
|
let backups = std::fs::read_dir(&host.paths.data)
|
|
.unwrap()
|
|
.filter_map(|entry| {
|
|
let path = entry.ok()?.path();
|
|
path.file_name()?
|
|
.to_str()?
|
|
.contains("before-mod-change")
|
|
.then_some(path)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(backups.len(), 1);
|
|
assert_eq!(std::fs::read(&backups[0]).unwrap(), original);
|
|
host.save_pilot_action().unwrap();
|
|
assert_eq!(
|
|
std::fs::read_dir(&host.paths.data)
|
|
.unwrap()
|
|
.filter_map(Result::ok)
|
|
.filter(|entry| entry
|
|
.file_name()
|
|
.to_string_lossy()
|
|
.contains("before-mod-change"))
|
|
.count(),
|
|
1
|
|
);
|
|
host.go(Screen::Inventory);
|
|
assert!(host.rows().iter().any(|row| row.contains("[UNAVAILABLE]")));
|
|
}
|
|
#[test]
|
|
fn startup_mismatch_prompts_again_after_cancel_and_missing_mission_can_be_changed() {
|
|
let (_dir, host) = ui_fixture();
|
|
let mut saved = host.pilot.clone();
|
|
saved.mission = "absent_pack:mission".into();
|
|
saved.save(&host.paths.save_path(0)).unwrap();
|
|
let mut host =
|
|
Host::new(host.catalog, host.base, host.paths, host.mods_root, false).unwrap();
|
|
assert!(host.screen == Screen::ConfirmLoadMods);
|
|
host.back().unwrap();
|
|
assert!(host.screen == Screen::Main);
|
|
host.selected = 0;
|
|
host.choose().unwrap();
|
|
assert!(host.screen == Screen::ConfirmLoadMods);
|
|
host.selected = 1;
|
|
host.choose().unwrap();
|
|
assert!(
|
|
host.start("absent_pack:mission".into())
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("Select mission")
|
|
);
|
|
assert!(host.world.is_none());
|
|
host.start("base:sector_1_wave_1".into()).unwrap();
|
|
assert!(host.screen == Screen::Game);
|
|
}
|
|
#[test]
|
|
fn result_back_preserves_confirmation_when_pending_mods_change_saved_requirements() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
let mut saved = host.pilot.clone();
|
|
saved.mods.push(modding::ModPin {
|
|
id: "missing".into(),
|
|
version: "1.0.0".into(),
|
|
hash: "old".into(),
|
|
});
|
|
saved.save(&host.paths.save_path(0)).unwrap();
|
|
host.pending.push("not_installed".into());
|
|
// Invalid mod application retains its error, then an independently pending load
|
|
// must survive the Results back-to-main navigation.
|
|
host.request_pilot_load(0, Screen::Main).unwrap();
|
|
host.go(Screen::Results);
|
|
host.back().unwrap();
|
|
assert!(host.screen == Screen::ConfirmLoadMods);
|
|
assert!(host.pending_load.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn retained_missing_weapon_has_no_hud_art_and_oversized_merged_state_does_not_save() {
|
|
let (_dir, mut host) = ui_fixture();
|
|
host.mismatch_confirmed = true;
|
|
host.pilot.inventory.insert("absent:weapon".into(), 1);
|
|
host.pilot.selected = Some("absent:weapon".into());
|
|
host.hud_weapon(&mut Canvas::new(1), &"absent:weapon".into())
|
|
.unwrap();
|
|
host.pilot.script_state.insert(
|
|
"inactive".into(),
|
|
serde_json::json!("x".repeat(4 * 1024 * 1024 - 1000)),
|
|
);
|
|
host.active
|
|
.scripts
|
|
.states
|
|
.insert("active".into(), serde_json::json!("x".repeat(2000)));
|
|
let before = serde_json::to_value(&host.pilot).unwrap();
|
|
assert!(
|
|
host.edit_pilot(|pilot, _| {
|
|
pilot.credits += 1;
|
|
Ok(())
|
|
})
|
|
.is_err()
|
|
);
|
|
assert_eq!(serde_json::to_value(&host.pilot).unwrap(), before);
|
|
assert!(!host.paths.save_path(0).exists());
|
|
}
|
|
}
|