Skip to content

Commit

Permalink
feat: Allow exporting save states in web (#311)
Browse files Browse the repository at this point in the history
  • Loading branch information
lukexor authored Jun 28, 2024
1 parent 218d786 commit 627bbec
Show file tree
Hide file tree
Showing 8 changed files with 158 additions and 10 deletions.
70 changes: 70 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions tetanes-core/src/bus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,14 +305,14 @@ impl Reset for Bus {
}

impl Sram for Bus {
fn save(&self, dir: impl AsRef<Path>) -> fs::Result<()> {
fs::save(dir.as_ref().with_extension(".sram"), self.sram())?;
self.ppu.bus.mapper.save(dir)
fn save(&self, path: impl AsRef<Path>) -> fs::Result<()> {
fs::save(path.as_ref(), self.sram())?;
self.ppu.bus.mapper.save(path)
}

fn load(&mut self, dir: impl AsRef<Path>) -> 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<Path>) -> fs::Result<()> {
fs::load(path.as_ref()).map(|data| self.load_sram(data))?;
self.ppu.bus.mapper.load(path)
}
}

Expand Down
12 changes: 10 additions & 2 deletions tetanes-core/src/control_deck.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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(())
}
Expand All @@ -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(())
Expand Down
2 changes: 1 addition & 1 deletion tetanes-core/src/sys/fs/wasm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ pub struct StoreReader {
cursor: io::Cursor<Vec<u8>>,
}

fn local_storage() -> Result<web_sys::Storage> {
pub fn local_storage() -> Result<web_sys::Storage> {
let window = web_sys::window().ok_or_else(|| Error::custom("failed to get js window"))?;
window
.local_storage()
Expand Down
2 changes: 2 additions & 0 deletions tetanes/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
3 changes: 2 additions & 1 deletion tetanes/src/nes/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions tetanes/src/nes/renderer/gui/preferences.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())));
}
}
});
});
});
Expand Down
58 changes: 58 additions & 0 deletions tetanes/src/sys/platform/wasm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<u8>>(&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 {
Expand Down

0 comments on commit 627bbec

Please sign in to comment.