Skip to content

Commit

Permalink
feat: added config and save/sram state persistence to web (#274)
Browse files Browse the repository at this point in the history
  • Loading branch information
lukexor authored Jun 2, 2024
1 parent 1e920fd commit 8c7f6df
Show file tree
Hide file tree
Showing 17 changed files with 350 additions and 223 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ tracing = { version = "0.1", default-features = false, features = [
tracing-subscriber = "0.3"
serde_json = "1.0"
web-time = "0.2" # FIXME: winit is using an old version
web-sys = "0.3"

# Playable framerates in development
[profile.dev]
Expand Down
2 changes: 2 additions & 0 deletions tetanes-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ enum_dispatch = "0.3"
flate2 = "1.0"
rand = "0.8"
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true

Expand All @@ -52,6 +53,7 @@ puffin = { workspace = true, optional = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
puffin = { workspace = true, features = ["web"], optional = true }
web-time.workspace = true
web-sys = { workspace = true, features = ["Storage", "Window"] }

[dev-dependencies]
anyhow.workspace = true
Expand Down
37 changes: 19 additions & 18 deletions tetanes-core/src/control_deck.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ pub enum Error {
/// Save state error.
#[error("save state error: {0:?}")]
SaveState(fs::Error),
/// When trying to load a save state that doesn't exist.
#[error("no save state found")]
NoSaveStateFound,
/// Operational error indicating a ROM must be loaded first.
#[error("no rom is loaded")]
RomNotLoaded,
Expand Down Expand Up @@ -133,7 +136,7 @@ pub struct Config {
/// Headless mode.
pub headless_mode: HeadlessMode,
/// Data directory for storing battery-backed RAM.
pub data_dir: Option<PathBuf>,
pub data_dir: PathBuf,
/// Which mapper revisions to emulate for any ROM loaded that uses this mapper.
pub mapper_revisions: MapperRevisionsConfig,
/// Whether to emulate PPU warmup where writes to certain registers are ignored. Can result in
Expand All @@ -152,15 +155,15 @@ impl Config {
/// Returns the default directory where TetaNES data is stored.
#[inline]
#[must_use]
pub fn default_data_dir() -> Option<PathBuf> {
dirs::data_local_dir().map(|dir| dir.join(Self::BASE_DIR))
pub fn default_data_dir() -> PathBuf {
dirs::data_local_dir().map_or_else(|| PathBuf::from("data"), |dir| dir.join(Self::BASE_DIR))
}

/// Returns the directory used to store battery-backed Cart RAM.
#[inline]
#[must_use]
pub fn sram_dir(&self) -> Option<PathBuf> {
self.data_dir.as_ref().map(|dir| dir.join(Self::SRAM_DIR))
pub fn sram_dir(&self) -> PathBuf {
self.data_dir.join(Self::SRAM_DIR)
}
}

Expand Down Expand Up @@ -208,7 +211,7 @@ pub struct ControlDeck {
/// The currently loaded ROM [`Cart`], if any.
loaded_rom: Option<LoadedRom>,
/// Directory for storing battery-backed Cart RAM if a ROM is loaded.
sram_dir: Option<PathBuf>,
sram_dir: PathBuf,
/// Mapper revisions to emulate for any ROM loaded that matches the given mappers.
mapper_revisions: MapperRevisionsConfig,
/// Whether to auto-detect the region based on the loaded Cart.
Expand Down Expand Up @@ -276,8 +279,8 @@ impl ControlDeck {
/// Returns the path to the SRAM save file for a given ROM name which is used to store
/// battery-backed Cart RAM. Returns `None` when the current platform doesn't have a
/// `data` directory and no custom `data_dir` was configured.
pub fn sram_dir(&self, name: &str) -> Option<PathBuf> {
self.sram_dir.as_ref().map(|dir| dir.join(name))
pub fn sram_dir(&self, name: &str) -> PathBuf {
self.sram_dir.join(name)
}

/// Loads a ROM cartridge into memory
Expand All @@ -304,10 +307,9 @@ impl ControlDeck {
self.update_mapper_revisions();
self.reset(ResetKind::Hard);
self.running = true;
if let Some(dir) = self.sram_dir(&name) {
if let Err(err) = self.load_sram(dir) {
error!("failed to load SRAM: {err:?}");
}
let sram_dir = self.sram_dir(&name);
if let Err(err) = self.load_sram(sram_dir) {
error!("failed to load SRAM: {err:?}");
}
self.loaded_rom = Some(loaded_rom.clone());
Ok(loaded_rom)
Expand Down Expand Up @@ -336,10 +338,9 @@ impl ControlDeck {
/// If the loaded [`Cart`] is battery-backed and saving fails, then an error is returned.
pub fn unload_rom(&mut self) -> Result<()> {
if let Some(rom) = &self.loaded_rom {
if let Some(dir) = self.sram_dir(&rom.name) {
if let Err(err) = self.save_sram(dir) {
error!("failed to save SRAM: {err:?}");
}
let sram_dir = self.sram_dir(&rom.name);
if let Err(err) = self.save_sram(sram_dir) {
error!("failed to save SRAM: {err:?}");
}
}
self.loaded_rom = None;
Expand Down Expand Up @@ -515,15 +516,15 @@ impl ControlDeck {
return Err(Error::RomNotLoaded);
};
let path = path.as_ref();
if path.exists() {
if fs::exists(path) {
fs::load::<Cpu>(path)
.map_err(Error::SaveState)
.map(|mut cpu| {
cpu.bus.input.clear();
self.load_cpu(cpu)
})
} else {
Ok(())
Err(Error::NoSaveStateFound)
}
}

Expand Down
10 changes: 10 additions & 0 deletions tetanes-core/src/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ where
let mut writer = fs::writer_impl(path)?;
write_header(&mut writer).map_err(Error::WriteHeaderFailed)?;
encode(&mut writer, &data).map_err(Error::EncodingFailed)?;
writer
.flush()
.map_err(|err| Error::io(err, "failed to save data"))?;
Ok(())
}

Expand All @@ -124,6 +127,9 @@ pub fn save_raw(path: impl AsRef<Path>, value: &[u8]) -> Result<()> {
writer
.write_all(value)
.map_err(|err| Error::io(err, "failed to save data"))?;
writer
.flush()
.map_err(|err| Error::io(err, "failed to save data"))?;
Ok(())
}

Expand Down Expand Up @@ -160,6 +166,10 @@ pub fn clear_dir(path: impl AsRef<Path>) -> Result<()> {
fs::clear_dir_impl(path)
}

pub fn exists(path: &Path) -> bool {
fs::exists_impl(path)
}

pub fn filename(path: &Path) -> &str {
path.file_name()
.and_then(std::ffi::OsStr::to_str)
Expand Down
5 changes: 5 additions & 0 deletions tetanes-core/src/sys/fs/os.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,8 @@ pub fn clear_dir_impl(path: impl AsRef<Path>) -> Result<()> {
remove_dir_all(path)
.map_err(|source| Error::io(source, format!("failed to remove directory {path:?}")))
}

pub fn exists_impl(path: impl AsRef<Path>) -> bool {
let path = path.as_ref();
path.exists()
}
121 changes: 110 additions & 11 deletions tetanes-core/src/sys/fs/wasm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,120 @@
use crate::fs::{Error, Result};
use std::{
io::{Empty, Read, Write},
path::Path,
io::{self, Read, Write},
mem,
path::{Path, PathBuf},
};
use web_sys::js_sys;

pub fn writer_impl(_path: impl AsRef<Path>) -> Result<impl Write> {
// TODO: provide file download
Err::<Empty, _>(Error::custom("not implemented: wasm write"))
#[derive(Debug)]
#[must_use]
pub struct StoreWriter {
path: PathBuf,
data: Vec<u8>,
}

pub fn reader_impl(_path: impl AsRef<Path>) -> Result<impl Read> {
// TODO: provide file upload?
Err::<Empty, _>(Error::custom("not implemented: wasm read"))
pub struct StoreReader {
cursor: io::Cursor<Vec<u8>>,
}

pub fn clear_dir_impl(_path: impl AsRef<Path>) -> Result<()> {
// TODO: clear storage
Err::<(), _>(Error::custom("not implemented: wasm clear dir"))
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()
.map_err(|err| {
tracing::error!("failed to get local storage: {err:?}");
Error::custom(format!("failed to get storage"))
})?
.ok_or_else(|| Error::custom("no storage available"))
}

impl Write for StoreWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.data.extend_from_slice(buf);
Ok(buf.len())
}

fn flush(&mut self) -> io::Result<()> {
let local_storage = local_storage().map_err(io::Error::other)?;

let key = self.path.to_string_lossy();
let data = mem::take(&mut self.data);
let value = match serde_json::to_string(&data) {
Ok(value) => value,
Err(err) => {
self.data = data;
tracing::error!("failed to serialize data: {err:?}");
return Err(io::Error::other("failed to serialize data"));
}
};

if let Err(err) = local_storage.set_item(&key, &value) {
self.data = data;
tracing::error!("failed to store data in local storage: {err:?}");
return Err(io::Error::other("failed to write data"));
}

Ok(())
}
}

impl Read for StoreReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.cursor.read(buf)
}
}

pub fn writer_impl(path: impl AsRef<Path>) -> Result<impl Write> {
let path = path.as_ref();
Ok(StoreWriter {
path: path.to_path_buf(),
data: Vec::new(),
})
}

pub fn reader_impl(path: impl AsRef<Path>) -> Result<impl Read> {
let path = path.as_ref();
let local_storage = local_storage()?;

let key = path.to_string_lossy().into_owned();
let data = local_storage
.get_item(&key)
.map_err(|_| Error::custom("failed to find data for {key}"))?
.map(|value| {
serde_json::from_str(&value).map_err(|err| {
tracing::error!("failed to deserialize data: {err:?}");
Error::custom("failed to deserialize data")
})
})
.unwrap_or_else(|| Ok(Vec::new()))?;

Ok(StoreReader {
cursor: io::Cursor::new(data),
})
}

pub fn clear_dir_impl(path: impl AsRef<Path>) -> Result<()> {
let path = path.as_ref().to_string_lossy();
let local_storage = local_storage()?;

for key in js_sys::Object::keys(&local_storage)
.iter()
.filter_map(|key| key.as_string())
.filter(|key| key.starts_with(&*path))
{
let _ = local_storage.remove_item(&key);
}

Ok(())
}

pub fn exists_impl(path: impl AsRef<Path>) -> bool {
let path = path.as_ref();
let Ok(local_storage) = local_storage() else {
return false;
};

let key = path.to_string_lossy();
matches!(local_storage.get_item(&key), Ok(Some(_)))
}
3 changes: 1 addition & 2 deletions tetanes/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,7 @@ getrandom = { version = "0.2", features = ["js"] }
puffin = { workspace = true, features = ["web"], optional = true }
tracing-web = "0.1"
wgpu = { version = "0.19", features = ["webgl"] }
web-sys = { version = "0.3", features = [
"Blob",
web-sys = { workspace = true, features = [
"Document",
"DomTokenList",
"Element",
Expand Down
50 changes: 24 additions & 26 deletions tetanes/src/nes/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -499,34 +499,32 @@ impl Mixer {

fn start_recording(&mut self) -> anyhow::Result<()> {
let _ = self.stop_recording();
if let Some(dir) = Config::default_audio_dir() {
let path = dir
.join(
chrono::Local::now()
.format("recording_%Y-%m-%d_at_%H_%M_%S")
.to_string(),
)
.with_extension("wav");
if let Some(parent) = path.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent).with_context(|| {
format!(
"failed to create audio recording directory: {}",
parent.display()
)
})?;
}
let path = Config::default_audio_dir()
.join(
chrono::Local::now()
.format("recording_%Y-%m-%d_at_%H_%M_%S")
.to_string(),
)
.with_extension("wav");
if let Some(parent) = path.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent).with_context(|| {
format!(
"failed to create audio recording directory: {}",
parent.display()
)
})?;
}
let spec = hound::WavSpec {
channels: self.channels,
sample_rate: self.sample_rate,
bits_per_sample: 32,
sample_format: hound::SampleFormat::Float,
};
let writer = hound::WavWriter::create(&path, spec)
.context("failed to create audio recording")?;
self.recording = Some((path, writer));
}
let spec = hound::WavSpec {
channels: self.channels,
sample_rate: self.sample_rate,
bits_per_sample: 32,
sample_format: hound::SampleFormat::Float,
};
let writer =
hound::WavWriter::create(&path, spec).context("failed to create audio recording")?;
self.recording = Some((path, writer));
Ok(())
}

Expand Down
Loading

0 comments on commit 8c7f6df

Please sign in to comment.