diff --git a/Cargo.lock b/Cargo.lock index dce8a08e..e3c77f7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -218,6 +218,15 @@ version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" +[[package]] +name = "arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arboard" version = "3.4.0" @@ -1279,6 +1288,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.67", +] + [[package]] name = "digest" version = "0.10.7" @@ -1316,6 +1336,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.67", +] + [[package]] name = "dlib" version = "0.5.2" @@ -2457,6 +2488,12 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lockfree-object-pool" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e" + [[package]] name = "log" version = "0.4.21" @@ -4055,6 +4092,7 @@ name = "tetanes" version = "0.11.0" dependencies = [ "anyhow", + "base64 0.22.1", "bincode", "bytemuck", "cfg-if", @@ -4098,6 +4136,7 @@ dependencies = [ "webbrowser 1.0.1", "wgpu", "winit", + "zip", ] [[package]] @@ -5612,6 +5651,37 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +[[package]] +name = "zip" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775a2b471036342aa69bc5a602bc889cb0a06cda00477d0c69566757d5553d39" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", + "zopfli", +] + +[[package]] +name = "zopfli" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5019f391bac5cf252e93bbcc53d039ffd62c7bfb7c150414d61369afe57e946" +dependencies = [ + "bumpalo", + "crc32fast", + "lockfree-object-pool", + "log", + "once_cell", + "simd-adler32", +] + [[package]] name = "zvariant" version = "3.15.2" diff --git a/tetanes-core/src/bus.rs b/tetanes-core/src/bus.rs index 5dcf7f7d..fce41579 100644 --- a/tetanes-core/src/bus.rs +++ b/tetanes-core/src/bus.rs @@ -305,14 +305,14 @@ impl Reset for Bus { } impl Sram for Bus { - fn save(&self, dir: impl AsRef) -> fs::Result<()> { - fs::save(dir.as_ref().with_extension(".sram"), self.sram())?; - self.ppu.bus.mapper.save(dir) + fn save(&self, path: impl AsRef) -> fs::Result<()> { + fs::save(path.as_ref(), self.sram())?; + self.ppu.bus.mapper.save(path) } - fn load(&mut self, dir: impl AsRef) -> fs::Result<()> { - fs::load(dir.as_ref().with_extension(".sram")).map(|data| self.load_sram(data))?; - self.ppu.bus.mapper.load(dir) + fn load(&mut self, path: impl AsRef) -> fs::Result<()> { + fs::load(path.as_ref()).map(|data| self.load_sram(data))?; + self.ppu.bus.mapper.load(path) } } diff --git a/tetanes-core/src/control_deck.rs b/tetanes-core/src/control_deck.rs index b52ca38d..cb5b6c33 100644 --- a/tetanes-core/src/control_deck.rs +++ b/tetanes-core/src/control_deck.rs @@ -151,6 +151,8 @@ impl Config { pub const BASE_DIR: &'static str = "tetanes"; /// Directory for storing battery-backed Cart RAM. pub const SRAM_DIR: &'static str = "sram"; + /// File extension for battery-backed Cart RAM. + pub const SRAM_EXTENSION: &'static str = "sram"; /// Returns the default directory where TetaNES data is stored. #[inline] @@ -470,7 +472,10 @@ impl ControlDeck { } info!("saving SRAM..."); - self.cpu.bus.save(path).map_err(Error::Sram)?; + self.cpu + .bus + .save(path.with_extension(Config::SRAM_EXTENSION)) + .map_err(Error::Sram)?; } Ok(()) } @@ -488,7 +493,10 @@ impl ControlDeck { } if path.is_file() { info!("loading SRAM..."); - self.cpu.bus.load(path).map_err(Error::Sram)?; + self.cpu + .bus + .load(path.with_extension(Config::SRAM_EXTENSION)) + .map_err(Error::Sram)?; } } Ok(()) diff --git a/tetanes-core/src/sys/fs/wasm.rs b/tetanes-core/src/sys/fs/wasm.rs index 72908ffd..515614ef 100644 --- a/tetanes-core/src/sys/fs/wasm.rs +++ b/tetanes-core/src/sys/fs/wasm.rs @@ -19,7 +19,7 @@ pub struct StoreReader { cursor: io::Cursor>, } -fn local_storage() -> Result { +pub fn local_storage() -> Result { let window = web_sys::window().ok_or_else(|| Error::custom("failed to get js window"))?; window .local_storage() diff --git a/tetanes/Cargo.toml b/tetanes/Cargo.toml index 7cf0ebac..1a72b048 100644 --- a/tetanes/Cargo.toml +++ b/tetanes/Cargo.toml @@ -126,6 +126,8 @@ web-sys = { workspace = true, features = [ ] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" +zip = { version = "2.1", default-features = false, features = ["deflate"] } +base64 = "0.22" [package.metadata.docs.rs] rustc-args = ["--cfg=web_sys_unstable_apis"] diff --git a/tetanes/src/nes/config.rs b/tetanes/src/nes/config.rs index 9916e491..b657b7e7 100644 --- a/tetanes/src/nes/config.rs +++ b/tetanes/src/nes/config.rs @@ -287,6 +287,7 @@ pub struct Config { impl Config { pub const SAVE_DIR: &'static str = "save"; + pub const SAVE_EXTENSION: &'static str = "sav"; pub const WINDOW_TITLE: &'static str = "TetaNES"; pub const FILENAME: &'static str = "config.json"; @@ -333,7 +334,7 @@ impl Config { .join(Self::SAVE_DIR) .join(name) .join(format!("slot-{}", slot)) - .with_extension("sav") + .with_extension(Self::SAVE_EXTENSION) } pub fn reset(&mut self) { diff --git a/tetanes/src/nes/renderer/gui/preferences.rs b/tetanes/src/nes/renderer/gui/preferences.rs index 229f589a..b6d3371d 100644 --- a/tetanes/src/nes/renderer/gui/preferences.rs +++ b/tetanes/src/nes/renderer/gui/preferences.rs @@ -543,9 +543,18 @@ impl State { if feature!(Storage) && ui.button("Clear Save States").clicked() { Self::clear_save_states(&self.tx); } + if feature!(Filesystem) && ui.button("Clear Recent ROMs").clicked() { self.tx.event(ConfigEvent::RecentRomsClear); } + + #[cfg(target_arch = "wasm32")] + if ui.button("Download Save States").clicked() { + if let Err(err) = crate::platform::download_save_states() { + self.tx + .event(UiEvent::Message((MessageType::Error, err.to_string()))); + } + } }); }); }); diff --git a/tetanes/src/sys/platform/wasm.rs b/tetanes/src/sys/platform/wasm.rs index fa34eb8b..3a2121b8 100644 --- a/tetanes/src/sys/platform/wasm.rs +++ b/tetanes/src/sys/platform/wasm.rs @@ -761,6 +761,64 @@ impl Initialize for Renderer { } } +pub fn download_save_states() -> anyhow::Result<()> { + use crate::nes::config::Config; + use anyhow::{anyhow, Context}; + use base64::Engine; + use std::io::{Cursor, Write}; + use tetanes_core::{control_deck::Config as DeckConfig, sys::fs::local_storage}; + use wasm_bindgen::JsCast; + use web_sys::{self, js_sys}; + use zip::write::{SimpleFileOptions, ZipWriter}; + + let local_storage = local_storage()?; + let mut zip = ZipWriter::new(Cursor::new(Vec::with_capacity(30 * 1024))); + + for key in js_sys::Object::keys(&local_storage) + .iter() + .filter_map(|key| key.as_string()) + .filter(|key| { + key.ends_with(Config::SAVE_EXTENSION) || key.ends_with(DeckConfig::SRAM_EXTENSION) + }) + { + zip.start_file(&*key, SimpleFileOptions::default())?; + let Some(data) = local_storage + .get_item(&key) + .map_err(|_| anyhow!("failed to find data for {key}"))? + .and_then(|value| serde_json::from_str::>(&value).ok()) + else { + continue; + }; + zip.write_all(&data)?; + } + + let res = zip.finish()?; + + let document = web_sys::window() + .and_then(|window| window.document()) + .context("failed to get document")?; + + let link = document + .create_element("a") + .map_err(|err| anyhow!("failed to create link element: {err:?}"))?; + link.set_attribute( + "href", + &format!( + "data:text/plain;base64,{}", + base64::prelude::BASE64_STANDARD.encode(res.into_inner()) + ), + ) + .map_err(|err| anyhow!("failed to set href attribute: {err:?}"))?; + link.set_attribute("download", "tetanes-save-states.zip") + .map_err(|err| anyhow!("failed to set download attribute: {err:?}"))?; + + let link: web_sys::HtmlAnchorElement = + web_sys::HtmlAnchorElement::unchecked_from_js(link.into()); + link.click(); + + Ok(()) +} + impl BuilderExt for WindowBuilder { /// Sets platform-specific window options. fn with_platform(self, _title: &str) -> Self {