From 34219b9d54778a4ce328e2fddb3d1593654f6a27 Mon Sep 17 00:00:00 2001 From: grimsace Date: Wed, 2 Sep 2026 12:11:45 -0500 Subject: [PATCH] redid save file so it's compressed and in xml (which is used for a lot of stuff like this) --- Cargo.toml | 2 + src/layout/types.rs | 1 + src/saveandload.rs | 138 +++++++++++++++++++++++++++++++++++++++----- src/ui/types.rs | 2 + 4 files changed, 128 insertions(+), 15 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 12e3123..6f0ce3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,8 @@ edition = "2024" eframe = "0.31" serde = { version = "1", features = ["derive"] } serde_json = "1" +quick-xml = { version = "0.37", features = ["serialize"] } +zip = { version = "2", default-features = false, features = ["deflate"] } dirs = "6" rfd = "0.15" tiny-skia = "0.11" diff --git a/src/layout/types.rs b/src/layout/types.rs index 750f91c..99ba5d0 100644 --- a/src/layout/types.rs +++ b/src/layout/types.rs @@ -212,6 +212,7 @@ pub struct WindowSettings { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default)] pub struct DungeonLayout { pub name: String, pub rooms: Vec, diff --git a/src/saveandload.rs b/src/saveandload.rs index d99e6fd..859168e 100644 --- a/src/saveandload.rs +++ b/src/saveandload.rs @@ -1,13 +1,16 @@ /* * Persistence logic for dungeon state. * Handles saving and loading the complete dungeon state - * (settings and layout data) to and from disk using JSON. + * (settings, layout data, and SVGs) to and from compressed XML archives. */ use rfd::FileDialog; use serde::{Deserialize, Serialize}; use std::fs; +use std::io::{Cursor, Read, Write}; use std::path::PathBuf; +use zip::write::SimpleFileOptions; +use zip::{CompressionMethod, ZipArchive, ZipWriter}; use crate::layout::DungeonLayout; use crate::ui::UiSettings; @@ -19,7 +22,66 @@ pub struct DungeonSave { pub svgs: Vec, } -// Saves dungeon state to a user-selected JSON file. +const XML_ENTRY_NAME: &str = "dungeon.xml"; + +#[derive(Serialize, Deserialize)] +struct DungeonXml { + #[serde(rename = "$text")] + payload: String, +} + +fn serialize_dungeon(save_data: &DungeonSave) -> Result, String> { + let payload = serde_json::to_string(save_data) + .map_err(|e| format!("Failed to serialize dungeon: {e}"))?; + quick_xml::se::to_string(&DungeonXml { payload }) + .map(|xml| { + let mut document = String::from("\n"); + document.push_str(&xml); + document.into_bytes() + }) + .map_err(|e| format!("Failed to serialize dungeon: {e}")) +} + +fn deserialize_dungeon(xml: &[u8]) -> Result { + let document: DungeonXml = quick_xml::de::from_reader(Cursor::new(xml)) + .map_err(|e| format!("Failed to parse dungeon XML: {e}"))?; + serde_json::from_str(&document.payload) + .map_err(|e| format!("Failed to parse dungeon XML payload: {e}")) +} + +fn create_archive(save_data: &DungeonSave) -> Result, String> { + let xml = serialize_dungeon(save_data)?; + let mut archive = Cursor::new(Vec::new()); + { + let mut writer = ZipWriter::new(&mut archive); + let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + writer + .start_file(XML_ENTRY_NAME, options) + .map_err(|e| format!("Failed to create dungeon archive: {e}"))?; + writer + .write_all(&xml) + .map_err(|e| format!("Failed to write dungeon XML: {e}"))?; + writer + .finish() + .map_err(|e| format!("Failed to finish dungeon archive: {e}"))?; + } + Ok(archive.into_inner()) +} + +fn read_archive(bytes: &[u8]) -> Result { + let mut archive = + ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("Invalid dungeon archive: {e}"))?; + let mut xml_file = archive + .by_name(XML_ENTRY_NAME) + .map_err(|e| format!("Dungeon archive is missing {XML_ENTRY_NAME}: {e}"))?; + let mut xml = Vec::new(); + xml_file + .read_to_end(&mut xml) + .map_err(|e| format!("Failed to read dungeon XML: {e}"))?; + deserialize_dungeon(&xml) +} + +// Saves dungeon state to a compressed XML archive. pub fn save_dungeon( settings: &UiSettings, layouts: Vec, @@ -27,8 +89,8 @@ pub fn save_dungeon( ) -> Result { let dialog = FileDialog::new() .set_title("Save Dungeon State") - .add_filter("Dungeon File", &["dungeon"]) - .set_file_name("my_dungeon.dungeon"); + .add_filter("Dungeon File", &["dun"]) + .set_file_name("my_dungeon.dun"); #[cfg(target_os = "windows")] let path = std::thread::spawn(move || dialog.save_file()) @@ -37,7 +99,10 @@ pub fn save_dungeon( #[cfg(not(target_os = "windows"))] let path = dialog.save_file(); - let path = path.ok_or_else(|| "Save canceled".to_string())?; + let mut path = path.ok_or_else(|| "Save canceled".to_string())?; + if path.extension().is_none() { + path.set_extension("dun"); + } let save_data = DungeonSave { settings: settings.clone(), @@ -45,19 +110,17 @@ pub fn save_dungeon( svgs, }; - let json = serde_json::to_string_pretty(&save_data) - .map_err(|e| format!("Failed to serialize dungeon: {e}"))?; - - fs::write(&path, json).map_err(|e| format!("Failed to write file: {e}"))?; + let archive = create_archive(&save_data)?; + fs::write(&path, archive).map_err(|e| format!("Failed to write file: {e}"))?; Ok(path) } -// Loads dungeon state from a user-selected JSON file. +// Loads dungeon state from a compressed XML archive. pub fn load_dungeon() -> Result { let dialog = FileDialog::new() .set_title("Load Dungeon State") - .add_filter("Dungeon File", &["dungeon"]); + .add_filter("Dungeon File", &["dun"]); #[cfg(target_os = "windows")] let path = std::thread::spawn(move || dialog.pick_file()) @@ -68,10 +131,55 @@ pub fn load_dungeon() -> Result { let path = path.ok_or_else(|| "Load canceled".to_string())?; - let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {e}"))?; + let content = fs::read(path).map_err(|e| format!("Failed to read file: {e}"))?; + read_archive(&content) +} - let save_data: DungeonSave = - serde_json::from_str(&content).map_err(|e| format!("Failed to parse dungeon file: {e}"))?; +#[cfg(test)] +mod tests { + use super::*; - Ok(save_data) + #[test] + fn archive_round_trip_preserves_saved_data() { + let save = DungeonSave { + settings: UiSettings::default(), + layouts: vec![DungeonLayout { + name: "Level 1".to_string(), + rooms: vec![crate::layout::types::Room { + x: 2, + y: 3, + width: 5, + height: 4, + }], + text_labels: vec![crate::layout::types::TextLabel { + cell: (4, 5), + text: "Keep & escape".to_string(), + font_size: 18, + }], + ..DungeonLayout::default() + }], + svgs: vec!["test &".to_string()], + }; + + let archive = create_archive(&save).expect("archive should serialize"); + let restored = read_archive(&archive).expect("archive should deserialize"); + + assert_eq!(restored.settings, save.settings); + assert_eq!(restored.layouts, save.layouts); + assert_eq!(restored.svgs, save.svgs); + } + + #[test] + fn archive_uses_deflate_compression() { + let save = DungeonSave { + settings: UiSettings::default(), + layouts: Vec::new(), + svgs: vec!["".repeat(100)], + }; + let archive = create_archive(&save).expect("archive should serialize"); + let mut zip = ZipArchive::new(Cursor::new(archive)).expect("archive should be valid"); + let entry = zip.by_name(XML_ENTRY_NAME).expect("XML entry should exist"); + + assert_eq!(entry.compression(), CompressionMethod::Deflated); + } } diff --git a/src/ui/types.rs b/src/ui/types.rs index 13b9e19..e949d9a 100644 --- a/src/ui/types.rs +++ b/src/ui/types.rs @@ -135,6 +135,7 @@ pub struct UiSettings { pub export_height: u32, pub allow_export_aspect_change: bool, pub export_show_grid: bool, + #[serde(skip_serializing_if = "Option::is_none")] pub last_export_path: Option, pub add_tool: AddTool, pub add_text_value: String, @@ -156,6 +157,7 @@ pub struct UiSettings { pub min_levels: usize, pub max_levels: usize, pub active_level_index: usize, + #[serde(skip_serializing_if = "Option::is_none")] pub overlay_level_index: Option, pub export_level_index: usize, pub trap_frequency_percent: usize,