Files
RustRaptor/src/persistence.rs
T

592 lines
21 KiB
Rust

use crate::{
content::{ContentId, Registry},
modding::ModPin,
};
use anyhow::{Context, Result, ensure};
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
fs::{self, OpenOptions},
io::Write,
path::{Path, PathBuf},
};
#[derive(Debug, Clone)]
pub struct Paths {
pub data: PathBuf,
pub config: PathBuf,
pub cache: PathBuf,
pub legacy: PathBuf,
}
impl Paths {
pub fn discover() -> Result<Self> {
let home = PathBuf::from(std::env::var_os("HOME").context("HOME is unset")?);
let dir = |name: &str, fallback: &str| {
std::env::var_os(name)
.map(PathBuf::from)
.unwrap_or_else(|| home.join(fallback))
};
Ok(Self {
data: dir("XDG_DATA_HOME", ".local/share").join("RaptorReplica"),
config: dir("XDG_CONFIG_HOME", ".config").join("RaptorReplica"),
cache: dir("XDG_CACHE_HOME", ".cache").join("RaptorReplica"),
legacy: home.join(".local/share/Raptor"),
})
}
pub fn mods(&self) -> PathBuf {
self.data.join("mods")
}
pub fn save_path(&self, slot: usize) -> PathBuf {
self.data.join(format!("pilot-{slot:04}.json"))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub hd_mode: bool,
pub scale: usize,
pub scanlines: bool,
pub fullscreen: bool,
pub borderless: bool,
pub aspect_correct: bool,
pub width: u32,
pub height: u32,
pub music_volume: u8,
pub fx_volume: u8,
pub fx_channels: usize,
pub control: String,
pub difficulty: u32,
pub bindings: BTreeMap<String, String>,
pub enabled_mods: Vec<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
hd_mode: false,
scale: 4,
scanlines: false,
fullscreen: false,
borderless: false,
aspect_correct: true,
width: 1280,
height: 960,
music_volume: 85,
fx_volume: 85,
fx_channels: 2,
control: "keyboard".into(),
difficulty: 2,
bindings: [
("up", "Up"),
("down", "Down"),
("left", "Left"),
("right", "Right"),
("fire", "Left Ctrl"),
("special", "Left Alt"),
("cycle", "Space"),
("bomb", "Right Shift"),
]
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
enabled_mods: vec![],
}
}
}
impl Config {
pub fn load(paths: &Paths) -> Result<Self> {
let path = paths.config.join("config.toml");
let mut c = if path.exists() {
toml::from_str(&fs::read_to_string(path)?)?
} else {
Self::import_ini(&paths.legacy.join("SETUP.INI"))?
};
ensure!(
matches!(c.scale, 1 | 2 | 3 | 4)
&& c.width >= 320
&& c.height >= 200
&& c.width <= 7680
&& c.height <= 4320
&& (1..=8).contains(&c.fx_channels)
&& c.difficulty <= 3,
"invalid video/difficulty settings"
);
c.music_volume = c.music_volume.min(127);
c.fx_volume = c.fx_volume.min(127);
Ok(c)
}
pub fn save(&self, paths: &Paths) -> Result<()> {
atomic_write(
&paths.config.join("config.toml"),
toml::to_string_pretty(self)?.as_bytes(),
)
}
pub fn import_ini(path: &Path) -> Result<Self> {
let mut config = Self::default();
if !path.exists() {
return Ok(config);
}
let mut section = String::new();
for line in fs::read_to_string(path)?.lines() {
let line = line.trim();
if line.starts_with('[') && line.ends_with(']') {
section = line[1..line.len() - 1].to_ascii_lowercase();
continue;
}
let Some((k, v)) = line.split_once('=') else {
continue;
};
let k = k.trim().to_ascii_lowercase();
let value = v.trim().parse::<i32>().unwrap_or(0);
match (section.as_str(), k.as_str()) {
("video", "fullscreen") => config.fullscreen = value != 0,
("video", "borderless") => config.borderless = value != 0,
("video", "upscaler") => {
config.scale = if [2, 3, 4].contains(&value) {
value as usize
} else {
1
}
}
("video", "scanlines") => config.scanlines = value != 0,
("video", "aspect_ratio_correct") => config.aspect_correct = value != 0,
("music", "volume") => config.music_volume = value.clamp(0, 127) as u8,
("soundfx", "volume") => config.fx_volume = value.clamp(0, 127) as u8,
("soundfx", "channels") => config.fx_channels = value.clamp(1, 8) as usize,
("setup", "control") => {
config.control = match value {
1 => "mouse",
2 => "controller",
_ => "keyboard",
}
.into()
}
("keyboard", _) => {
let action = match k.as_str() {
"moveup" => "up",
"movedn" => "down",
"moveleft" => "left",
"moveright" => "right",
"fire" => "fire",
"firesp" => "special",
"changesp" => "cycle",
"megafire" => "bomb",
_ => continue,
};
if let Some(key) = legacy_scancode(value) {
config.bindings.insert(action.into(), key.into());
}
}
_ => {}
}
}
Ok(config)
}
}
fn legacy_scancode(n: i32) -> Option<&'static str> {
Some(match n {
72 => "Up",
80 => "Down",
75 => "Left",
77 => "Right",
29 => "Left Ctrl",
56 => "Left Alt",
57 => "Space",
54 => "Right Shift",
17 => "W",
30 => "A",
31 => "S",
32 => "D",
16 => "Q",
18 => "E",
28 => "Return",
_ => return None,
})
}
pub fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
let parent = path.parent().context("output has no parent")?;
fs::create_dir_all(parent)?;
let mut n = 0u32;
let (temp, mut file) = loop {
let p = parent.join(format!(
".{}.{}.{}.tmp",
path.file_name()
.context("missing output name")?
.to_string_lossy(),
std::process::id(),
n
));
match OpenOptions::new().write(true).create_new(true).open(&p) {
Ok(f) => break (p, f),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => n += 1,
Err(e) => return Err(e.into()),
}
};
let result = (|| -> Result<()> {
file.write_all(data)?;
file.sync_all()?;
fs::rename(&temp, path)?;
fs::File::open(parent)?.sync_all()?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(temp);
}
result
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LegacyProgress {
pub portrait: i32,
pub sector: u32,
pub waves: [u32; 3],
pub difficulties: [u32; 4],
pub training: bool,
pub training_completed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Pilot {
pub version: u32,
pub name: String,
pub callsign: String,
pub credits: u32,
pub difficulty: u32,
pub inventory: BTreeMap<ContentId, u32>,
pub selected: Option<ContentId>,
pub completed: Vec<ContentId>,
pub mission: ContentId,
pub mods: Vec<ModPin>,
pub script_state: BTreeMap<String, serde_json::Value>,
#[serde(default)]
pub script_rng: BTreeMap<String, crate::simulation::LegacyRng>,
#[serde(default)]
pub legacy_progress: Option<LegacyProgress>,
}
impl Default for Pilot {
fn default() -> Self {
Self {
version: 1,
name: "Pilot".into(),
callsign: "RAPTOR".into(),
credits: 0,
difficulty: 2,
inventory: [("base:forward_guns".into(), 1), ("base:energy".into(), 100)]
.into_iter()
.collect(),
selected: None,
completed: vec![],
mission: "base:sector_1_wave_1".into(),
mods: vec![],
script_state: BTreeMap::new(),
script_rng: BTreeMap::new(),
legacy_progress: None,
}
}
}
impl Pilot {
pub fn complete_mission(&mut self, registry: &Registry) -> Result<()> {
let mission = registry
.missions
.get(&self.mission)
.context("completed mission is unavailable")?;
if !self.completed.contains(&self.mission) {
self.completed.push(self.mission.clone());
}
let original = self
.mission
.0
.strip_prefix("base:sector_")
.and_then(|s| s.split_once("_wave_"))
.and_then(|(s, w)| Some((s.parse::<usize>().ok()?, w.parse::<u32>().ok()?)))
.filter(|(s, w)| (1..=3).contains(s) && (1..=9).contains(w));
if let Some((sector, wave)) = original.filter(|_| mission.legacy_map.is_some()) {
let sector = sector - 1;
let mut progress = self.legacy_progress.clone().unwrap_or(LegacyProgress {
portrait: 0,
sector: sector as u32,
waves: [0; 3],
difficulties: [self.difficulty; 4],
training: false,
training_completed: false,
});
progress.difficulties[sector] = self.difficulty;
let limit = if self.difficulty == 0 { 4 } else { 9 };
if wave >= limit {
if self.difficulty == 0 && !progress.training_completed {
// Original graduation resets the training ship and awards
// three 25-unit shields plus the initial 10,000 credits.
self.inventory = [("base:forward_guns".into(), 1), ("base:energy".into(), 75)]
.into_iter()
.collect();
self.selected = None;
self.credits = 10000;
progress.training_completed = true;
for difficulty in &mut progress.difficulties {
if *difficulty == 0 {
*difficulty = 1;
}
}
}
progress.difficulties[sector] = (self.difficulty + 1).min(3);
progress.waves[sector] = 0;
if limit == 4 {
progress.waves = [0; 3];
progress.sector = 0;
} else {
progress.sector = ((sector + 1) % 3) as u32;
}
} else {
progress.sector = sector as u32;
progress.waves[sector] = wave;
}
let next_sector = progress.sector as usize;
self.difficulty = progress.difficulties[next_sector];
self.mission = ContentId(format!(
"base:sector_{}_wave_{}",
next_sector + 1,
progress.waves[next_sector] + 1
));
self.legacy_progress = Some(progress);
} else if let Some(next) = &mission.next {
self.mission = next.clone();
}
self.validate(registry, &self.mods)
}
pub fn demo(sector: usize, registry: &Registry) -> Result<Self> {
let mut p = Self {
difficulty: 3,
credits: 10000,
..Self::default()
};
p.inventory.insert("base:detect".into(), 1);
let equipment: &[(u32, u32)] = match sector {
0 => &[(2, 1), (11, 1), (4, 1), (7, 1), (5, 1), (14, 1)],
1 => &[(11, 4), (15, 100), (1, 1), (2, 1), (5, 1), (8, 1)],
2 => &[(11, 4), (15, 100), (1, 1), (2, 1), (13, 1), (8, 1)],
_ => anyhow::bail!("invalid demo sector"),
};
for (index, count) in equipment {
let item = registry
.items
.values()
.find(|i| i.legacy == Some(*index))
.context("missing demo item")?;
p.inventory.insert(item.id.clone(), *count);
}
if sector == 1 {
p.credits += 0x50003;
} else if sector == 2 {
p.credits += 0xd5fff;
}
// DEMO_MakePlayer advances once after OBJS_Add selects the first
// special weapon. Match that ordering rather than inventory sorting.
p.selected = registry
.items
.values()
.find(|i| i.legacy == Some([5, 8, 8][sector]))
.map(|i| i.id.clone());
Ok(p)
}
pub fn save(&self, path: &Path) -> Result<()> {
atomic_write(path, &serde_json::to_vec_pretty(self)?)
}
pub fn load(path: &Path, registry: &Registry, pins: &[ModPin]) -> Result<Self> {
let p: Self = serde_json::from_slice(&fs::read(path)?)?;
p.validate(registry, pins)?;
Ok(p)
}
pub fn validate(&self, registry: &Registry, pins: &[ModPin]) -> Result<()> {
let p = self;
ensure!(p.version == 1, "unsupported save version {}", p.version);
ensure!(
p.difficulty <= 3 && p.inventory.len() <= 20,
"invalid pilot difficulty/inventory"
);
ensure!(
p.mods == pins,
"save requires these mod versions/content: {:?}; enable the matching mods in the Mods screen",
p.mods
);
ensure!(
registry.missions.contains_key(&p.mission),
"save mission {} is unavailable",
p.mission
);
for (id, count) in &p.inventory {
ensure!(
registry.items.contains_key(id),
"saved item {id} is unavailable"
);
ensure!(
*count <= registry.items[id].max_count,
"saved quantity of {id} exceeds capacity"
);
}
if let Some(id) = &p.selected {
ensure!(
p.inventory.get(id).is_some_and(|n| *n > 0)
&& registry.items.get(id).is_some_and(|i| i.special),
"equipped item {id} is unavailable"
);
}
for id in &p.completed {
ensure!(
registry.missions.contains_key(id),
"completed mission {id} is unavailable"
);
}
if let Some(progress) = &p.legacy_progress {
ensure!(
progress.sector < 3
&& progress.waves.iter().all(|w| *w < 9)
&& progress.difficulties.iter().all(|d| *d < 4),
"invalid saved campaign progression"
);
}
ensure!(
serde_json::to_vec(&p.script_state)?.len() <= 4 * 1024 * 1024,
"saved script state exceeds limit"
);
Ok(())
}
pub fn buy(&mut self, id: &ContentId, registry: &Registry) -> Result<()> {
let item = registry.items.get(id).context("unknown shop item")?;
ensure!(self.credits >= item.price, "not enough credits");
let count = self.inventory.get(id).copied().unwrap_or(0);
ensure!(count < item.max_count, "item capacity reached");
ensure!(
count > 0 || self.inventory.len() < 20,
"ship inventory is full"
);
self.credits -= item.price;
self.inventory.insert(
id.clone(),
count.saturating_add(item.initial_count).min(item.max_count),
);
Ok(())
}
pub fn sell(&mut self, id: &ContentId, registry: &Registry) -> Result<()> {
let item = registry.items.get(id).context("unknown shop item")?;
ensure!(
id.0 != "base:forward_guns" && id.0 != "base:energy",
"essential ship equipment cannot be sold"
);
let count = self.inventory.get_mut(id).context("item is not owned")?;
ensure!(*count > 0, "item is not owned");
*count -= 1;
self.credits = self.credits.saturating_add(item.price / 2);
if *count == 0 {
self.inventory.remove(id);
if self.selected.as_ref() == Some(id) {
self.selected = None;
}
}
Ok(())
}
pub fn import_legacy(path: &Path, registry: &Registry) -> Result<Self> {
let bytes = fs::read(path)?;
ensure!(
(88..=88 + 20 * 24).contains(&bytes.len()) && (bytes.len() - 88) % 24 == 0,
"invalid legacy save length"
);
let head = decrypt(&bytes[..88], b"CASTLE");
let body = decrypt(&bytes[88..], b"CASTLE");
let val = |o| crate::assets::i32_at(&head, o);
let count = val(60)?;
ensure!(
count >= 0 && count as usize <= (body.len() / 24),
"truncated legacy inventory"
);
let sector = val(44)?;
ensure!((0..3).contains(&sector), "invalid legacy sector");
let wave = val(48 + sector as usize * 4)?;
ensure!((0..9).contains(&wave), "invalid legacy wave");
let difficulty = val(64 + sector as usize * 4)?;
ensure!((0..4).contains(&difficulty), "invalid legacy difficulty");
let waves = [val(48)?, val(52)?, val(56)?];
let difficulties = [val(64)?, val(68)?, val(72)?, val(76)?];
ensure!(
waves.iter().all(|w| (0..9).contains(w)),
"invalid legacy campaign progress"
);
ensure!(
difficulties.iter().all(|d| (0..4).contains(d)),
"invalid legacy campaign difficulty"
);
ensure!(
[val(80)?, val(84)?].iter().all(|f| *f == 0 || *f == 1),
"invalid legacy training flags"
);
let text = |r: std::ops::Range<usize>| {
String::from_utf8_lossy(head[r].split(|b| *b == 0).next().unwrap()).into_owned()
};
let mut p = Self {
name: text(0..20),
callsign: text(20..32),
credits: val(36)? as u32,
difficulty: difficulty as u32,
mission: ContentId(format!("base:sector_{}_wave_{}", sector + 1, wave + 1)),
inventory: BTreeMap::new(),
legacy_progress: Some(LegacyProgress {
portrait: val(32)?,
sector: sector as u32,
waves: waves.map(|w| w as u32),
difficulties: difficulties.map(|d| d as u32),
training: val(80)? != 0,
training_completed: val(84)? != 0,
}),
..Self::default()
};
let special = val(40)?;
for r in body.chunks_exact(24).take(count as usize) {
let count = crate::assets::i32_at(r, 8)?;
let typ = crate::assets::i32_at(r, 12)?;
ensure!(count >= 0, "negative legacy item count");
let item = registry
.items
.values()
.find(|i| i.legacy == Some(typ as u32))
.context("unknown legacy item")?;
p.inventory.insert(item.id.clone(), count as u32);
if typ == special {
p.selected = Some(item.id.clone());
}
}
for s in 0..3 {
let w = val(48 + s * 4)?;
for i in 1..=w {
p.completed
.push(ContentId(format!("base:sector_{}_wave_{i}", s + 1)));
}
}
p.validate(registry, &[])?;
Ok(p)
}
}
fn decrypt(bytes: &[u8], key: &[u8]) -> Vec<u8> {
let mut index = 25 % key.len();
let mut previous = key[index];
bytes
.iter()
.map(|x| {
let clear = x.wrapping_sub(key[index]).wrapping_sub(previous);
previous = *x;
index = (index + 1) % key.len();
clear
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writes_replace_atomically() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("save.json");
atomic_write(&p, b"first").unwrap();
atomic_write(&p, b"second").unwrap();
assert_eq!(fs::read(p).unwrap(), b"second");
assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 1);
}
}