From 218d7860421eb4cfc4d7b833132f4c476935777a Mon Sep 17 00:00:00 2001 From: Luke Date: Thu, 27 Jun 2024 21:11:57 -0700 Subject: [PATCH 1/2] fix: fix scroll issues --- tetanes/src/nes/action.rs | 52 ++-- tetanes/src/nes/renderer/gui.rs | 6 + tetanes/src/nes/renderer/gui/keybinds.rs | 10 +- tetanes/src/nes/renderer/gui/preferences.rs | 260 ++++++++++---------- tetanes/src/platform.rs | 29 +-- 5 files changed, 186 insertions(+), 171 deletions(-) diff --git a/tetanes/src/nes/action.rs b/tetanes/src/nes/action.rs index 65351b0d..91889af0 100644 --- a/tetanes/src/nes/action.rs +++ b/tetanes/src/nes/action.rs @@ -188,11 +188,11 @@ impl AsRef for Action { Ui::LoadReplay => "Load Replay", }, Action::Menu(menu) => match menu { - Menu::About => "Toggle About Window", - Menu::Keybinds => "Toggle Keybinds Menu", - Menu::PerfStats => "Toggle Performance Stats Window", + Menu::About => "Toggle About", + Menu::Keybinds => "Toggle Keybinds", + Menu::PerfStats => "Toggle Performance Stats", Menu::PpuViewer => "Toggle PPU Viewer", - Menu::Preferences => "Toggle Preferences Menu", + Menu::Preferences => "Toggle Preferences", }, Action::Feature(feature) => match feature { Feature::ToggleReplayRecording => "Toggle Replay Recording", @@ -214,10 +214,10 @@ impl AsRef for Action { Setting::ToggleScreenReader => "Toggle Screen Reader", Setting::ToggleFps => "Toggle FPS", Setting::FastForward => "Fast Forward", - Setting::IncrementScale => "Increment Scale", - Setting::DecrementScale => "Decrement Scale", - Setting::IncrementSpeed => "Increment Speed", - Setting::DecrementSpeed => "Decrement Speed", + Setting::IncrementScale => "Scale Increment", + Setting::DecrementScale => "Scale Decrement", + Setting::IncrementSpeed => "Speed Increment", + Setting::DecrementSpeed => "Speed Increment", }, Action::Deck(deck) => match deck { DeckAction::Reset(kind) => match kind { @@ -236,13 +236,13 @@ impl AsRef for Action { JoypadBtn::Select => "Joypad Select", JoypadBtn::Start => "Joypad Start", }, - DeckAction::ToggleZapperConnected => "Toggle Zapper Connected", + DeckAction::ToggleZapperConnected => "Zapper Gun Toggle", DeckAction::ZapperAim(_) => "Zapper Aim", DeckAction::ZapperAimOffscreen => "Zapper Aim Offscreen (Hold)", DeckAction::ZapperTrigger => "Zapper Trigger", - DeckAction::FourPlayer(FourPlayer::Disabled) => "Disable Four Player Mode", - DeckAction::FourPlayer(FourPlayer::FourScore) => "Enable Four Player (FourScore)", - DeckAction::FourPlayer(FourPlayer::Satellite) => "Enable Four Player (Satellite)", + DeckAction::FourPlayer(FourPlayer::Disabled) => "4-Player Disable", + DeckAction::FourPlayer(FourPlayer::FourScore) => "4-Player Enable (FourScore)", + DeckAction::FourPlayer(FourPlayer::Satellite) => "4-Player Enable (Satellite)", DeckAction::SetSaveSlot(1) => "Set Save Slot 1", DeckAction::SetSaveSlot(2) => "Set Save Slot 2", DeckAction::SetSaveSlot(3) => "Set Save Slot 3", @@ -264,17 +264,17 @@ impl AsRef for Action { }, DeckAction::MapperRevision(rev) => match rev { MapperRevision::Mmc3(mmc3) => match mmc3 { - Mmc3Revision::A => "Set Mapper Rev. to MMC3A", - Mmc3Revision::BC => "Set Mapper Rev. to MMC3B/C", - Mmc3Revision::Acc => "Set Mapper Rev. to MC-ACC", + Mmc3Revision::A => "Set Mapper to MMC3A", + Mmc3Revision::BC => "Set Mapper to MMC3B/C", + Mmc3Revision::Acc => "Set Mapper to MC-ACC", }, MapperRevision::Bf909(bf909) => match bf909 { - Bf909Revision::Bf909x => "Set Mapper Rev. to BF909x", - Bf909Revision::Bf9097 => "Set Mapper Rev. to BF9097", + Bf909Revision::Bf909x => "Set Mapper to BF909x", + Bf909Revision::Bf9097 => "Set Mapper to BF9097", }, }, DeckAction::SetNesRegion(region) => match region { - NesRegion::Auto => "Set Region to Auto-Detect", + NesRegion::Auto => "Set Region to Auto", NesRegion::Ntsc => "Set Region to NTSC", NesRegion::Pal => "Set Region to PAL", NesRegion::Dendy => "Set Region to Dendy", @@ -286,16 +286,16 @@ impl AsRef for Action { }, Action::Debug(debug) => match debug { Debug::Toggle(debugger) => match debugger { - Debugger::Cpu => "Toggle CPU Debugger", - Debugger::Ppu => "Toggle PPU Debugger", - Debugger::Apu => "Toggle APU Debugger", + Debugger::Cpu => "Toggle Debugger", + Debugger::Ppu => "Toggle PPU Viewer", + Debugger::Apu => "Toggle APU Mixer", }, Debug::Step(step) => match step { - DebugStep::Into => "Step Into (CPU Debugger)", - DebugStep::Out => "Step Out (CPU Debugger)", - DebugStep::Over => "Step Over (CPU Debugger)", - DebugStep::Scanline => "Step Scanline (CPU Debugger)", - DebugStep::Frame => "Step Frame (CPU Debugger)", + DebugStep::Into => "Debug Step", + DebugStep::Out => "Debug Step Out", + DebugStep::Over => "Debug Step Over", + DebugStep::Scanline => "Debug Step Scanline", + DebugStep::Frame => "Debug Step Frame", }, }, } diff --git a/tetanes/src/nes/renderer/gui.rs b/tetanes/src/nes/renderer/gui.rs index a2583c0a..891793da 100644 --- a/tetanes/src/nes/renderer/gui.rs +++ b/tetanes/src/nes/renderer/gui.rs @@ -340,6 +340,12 @@ impl Gui { Self::light_theme() }; ctx.set_visuals(theme); + ctx.style_mut(|ctx| { + let scroll = &mut ctx.spacing.scroll; + scroll.floating = false; + scroll.foreground_color = false; + scroll.bar_width = 8.0; + }); const FONT: (&str, &[u8]) = ( "pixeloid-sans", diff --git a/tetanes/src/nes/renderer/gui/keybinds.rs b/tetanes/src/nes/renderer/gui/keybinds.rs index d2c0ad0d..c0e1af06 100644 --- a/tetanes/src/nes/renderer/gui/keybinds.rs +++ b/tetanes/src/nes/renderer/gui/keybinds.rs @@ -246,18 +246,18 @@ impl State { #[cfg(feature = "profiling")] puffin::profile_function!(); + ui.set_min_height(ui.available_height()); + if let Some(player) = player { self.player_gamepad_combo(ui, player, connected_gamepads); ui.separator(); } - ScrollArea::both().show(ui, |ui| { - ui.set_width(ui.available_width()); // Pushes scrollbar to the right of the window - + ScrollArea::both().auto_shrink(false).show(ui, |ui| { let grid = Grid::new("keybind_list") - .num_columns(3) - .spacing([40.0, 6.0]); + .num_columns(4) + .spacing([10.0, 6.0]); grid.show(ui, |ui| { ui.heading("Action"); ui.heading("Binding #1"); diff --git a/tetanes/src/nes/renderer/gui/preferences.rs b/tetanes/src/nes/renderer/gui/preferences.rs index dceec06a..229f589a 100644 --- a/tetanes/src/nes/renderer/gui/preferences.rs +++ b/tetanes/src/nes/renderer/gui/preferences.rs @@ -514,16 +514,18 @@ impl State { puffin::profile_function!(); ui.add_enabled_ui(enabled, |ui| { - ScrollArea::vertical().show(ui, |ui| { - ui.horizontal(|ui| { - ui.selectable_value(&mut self.tab, Tab::Emulation, "Emulation"); - ui.selectable_value(&mut self.tab, Tab::Audio, "Audio"); - ui.selectable_value(&mut self.tab, Tab::Video, "Video"); - ui.selectable_value(&mut self.tab, Tab::Input, "Input"); - }); + ui.set_min_height(ui.available_height()); - ui.separator(); + ui.horizontal(|ui| { + ui.selectable_value(&mut self.tab, Tab::Emulation, "Emulation"); + ui.selectable_value(&mut self.tab, Tab::Audio, "Audio"); + ui.selectable_value(&mut self.tab, Tab::Video, "Video"); + ui.selectable_value(&mut self.tab, Tab::Input, "Input"); + }); + ui.separator(); + + ScrollArea::both().show(ui, |ui| { match self.tab { Tab::Emulation => self.emulation_tab(ui, cfg), Tab::Audio => Self::audio_tab(&self.tx, ui, cfg), @@ -535,70 +537,11 @@ impl State { ui.horizontal(|ui| { if ui.button("Restore Defaults").clicked() { - ui.ctx().memory_mut(|mem| *mem = Default::default()); - - // Inform all cfg updates - let cfg = Config::default(); - let Config { - deck, - emulation, - audio, - renderer, - input, - } = cfg; - let events = [ - ConfigEvent::ActionBindings(input.action_bindings), - ConfigEvent::AlwaysOnTop(renderer.always_on_top), - ConfigEvent::ApuChannelsEnabled(deck.channels_enabled), - ConfigEvent::AudioBuffer(audio.buffer_size), - ConfigEvent::AudioEnabled(audio.enabled), - ConfigEvent::AudioLatency(audio.latency), - ConfigEvent::AutoLoad(emulation.auto_load), - ConfigEvent::AutoSave(emulation.auto_save), - ConfigEvent::AutoSaveInterval(emulation.auto_save_interval), - ConfigEvent::ConcurrentDpad(deck.concurrent_dpad), - ConfigEvent::CycleAccurate(deck.cycle_accurate), - ConfigEvent::DarkTheme(renderer.dark_theme), - ConfigEvent::EmbedViewports(renderer.embed_viewports), - ConfigEvent::FourPlayer(deck.four_player), - ConfigEvent::Fullscreen(renderer.fullscreen), - ConfigEvent::GamepadAssignments(input.gamepad_assignments), - ConfigEvent::GenieCodeClear, - ConfigEvent::HideOverscan(renderer.hide_overscan), - ConfigEvent::MapperRevisions(deck.mapper_revisions), - ConfigEvent::RamState(deck.ram_state), - // Clearing recent roms is handled in a separate button - ConfigEvent::Region(deck.region), - ConfigEvent::RewindEnabled(emulation.rewind), - ConfigEvent::RewindInterval(emulation.rewind_interval), - ConfigEvent::RewindSeconds(emulation.rewind_seconds), - ConfigEvent::RunAhead(emulation.run_ahead), - ConfigEvent::SaveSlot(emulation.save_slot), - ConfigEvent::Shader(renderer.shader), - ConfigEvent::ShowMenubar(renderer.show_menubar), - ConfigEvent::ShowMessages(renderer.show_messages), - ConfigEvent::Speed(emulation.speed), - ConfigEvent::VideoFilter(deck.filter), - ConfigEvent::ZapperConnected(deck.zapper), - ]; - for event in events { - self.tx.event(event); - } + Self::restore_defaults(&self.tx, ui.ctx()); } - if feature!(Storage) { - let data_dir = Config::default_data_dir(); - if ui.button("Clear Save States").clicked() { - match fs::clear_dir(data_dir) { - Ok(_) => self.tx.event(UiEvent::Message(( - MessageType::Info, - "Save States cleared.".to_string(), - ))), - Err(_) => self.tx.event(UiEvent::Message(( - MessageType::Error, - "Failed to clear Save States.".to_string(), - ))), - } - } + + 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); @@ -633,11 +576,10 @@ impl State { .. } = cfg.deck; - ScrollArea::both().show(ui, |ui| { - let grid = Grid::new("emulation_checkboxes") - .num_columns(2) - .spacing([80.0, 6.0]); - grid.show(ui, |ui| { + let grid = Grid::new("emulation_checkboxes") + .num_columns(2) + .spacing([80.0, 6.0]); + grid.show(ui, |ui| { let tx = &self.tx; Preferences::cycle_accurate_checkbox(tx, ui, cycle_accurate, None); @@ -722,66 +664,65 @@ impl State { ui.end_row(); }); - ui.separator(); - - let grid = Grid::new("emulation_preferences") - .num_columns(4) - .spacing([40.0, 6.0]); - grid.show(ui, |ui| { - let tx = &self.tx; + ui.separator(); - ui.strong("Emulation Speed:"); - Preferences::speed_slider(tx, ui, speed); + let grid = Grid::new("emulation_preferences") + .num_columns(4) + .spacing([40.0, 6.0]); + grid.show(ui, |ui| { + let tx = &self.tx; - ui.strong("Run Ahead:") - .on_hover_cursor(CursorIcon::Help) - .on_hover_text("Simulate a number of frames in the future to reduce input lag."); - Preferences::run_ahead_slider(tx, ui, run_ahead); - ui.end_row(); + ui.strong("Emulation Speed:"); + Preferences::speed_slider(tx, ui, speed); - ui.with_layout(Layout::left_to_right(Align::Min), |ui| { - ui.strong("Save Slot:") - .on_hover_cursor(CursorIcon::Help) - .on_hover_text("Select which slot to use when saving or loading game state."); - }); - Grid::new("save_slots") - .num_columns(2) - .spacing([20.0, 6.0]) - .show(ui, |ui| { - Preferences::save_slot_radio(tx, ui, save_slot, cfg, ShowShortcut::No) - }); + ui.strong("Run Ahead:") + .on_hover_cursor(CursorIcon::Help) + .on_hover_text("Simulate a number of frames in the future to reduce input lag."); + Preferences::run_ahead_slider(tx, ui, run_ahead); + ui.end_row(); - ui.with_layout(Layout::left_to_right(Align::Min), |ui| { - ui.strong("Four Player:") - .on_hover_cursor(CursorIcon::Help) - .on_hover_text( - "Some game titles support up to 4 players (requires connected controllers).", - ); + ui.with_layout(Layout::left_to_right(Align::Min), |ui| { + ui.strong("Save Slot:") + .on_hover_cursor(CursorIcon::Help) + .on_hover_text("Select which slot to use when saving or loading game state."); + }); + Grid::new("save_slots") + .num_columns(2) + .spacing([20.0, 6.0]) + .show(ui, |ui| { + Preferences::save_slot_radio(tx, ui, save_slot, cfg, ShowShortcut::No) }); - ui.vertical(|ui| Preferences::four_player_radio(tx, ui, four_player)); - ui.end_row(); - ui.with_layout(Layout::left_to_right(Align::Min), |ui| { - ui.strong("NES Region:") - .on_hover_cursor(CursorIcon::Help) - .on_hover_text("Which regional NES hardware to emulate."); - }); - ui.vertical(|ui| Preferences::nes_region_radio(tx, ui, region)); + ui.with_layout(Layout::left_to_right(Align::Min), |ui| { + ui.strong("Four Player:") + .on_hover_cursor(CursorIcon::Help) + .on_hover_text( + "Some game titles support up to 4 players (requires connected controllers).", + ); + }); + ui.vertical(|ui| Preferences::four_player_radio(tx, ui, four_player)); + ui.end_row(); - ui.with_layout(Layout::left_to_right(Align::Min), |ui| { - ui.strong("RAM State:") - .on_hover_cursor(CursorIcon::Help) - .on_hover_text("What values are read from NES RAM on load."); - }); - ui.vertical(|ui| Preferences::ram_state_radio(tx, ui, ram_state)); - ui.end_row(); + ui.with_layout(Layout::left_to_right(Align::Min), |ui| { + ui.strong("NES Region:") + .on_hover_cursor(CursorIcon::Help) + .on_hover_text("Which regional NES hardware to emulate."); }); + ui.vertical(|ui| Preferences::nes_region_radio(tx, ui, region)); - let grid = Grid::new("genie_codes").num_columns(2).spacing([40.0, 6.0]); - grid.show(ui, |ui| { - self.genie_codes_entry(ui, cfg); - Preferences::genie_codes_list(&self.tx, ui, cfg, false); + ui.with_layout(Layout::left_to_right(Align::Min), |ui| { + ui.strong("RAM State:") + .on_hover_cursor(CursorIcon::Help) + .on_hover_text("What values are read from NES RAM on load."); }); + ui.vertical(|ui| Preferences::ram_state_radio(tx, ui, ram_state)); + ui.end_row(); + }); + + let grid = Grid::new("genie_codes").num_columns(2).spacing([40.0, 6.0]); + grid.show(ui, |ui| { + self.genie_codes_entry(ui, cfg); + Preferences::genie_codes_list(&self.tx, ui, cfg, false); }); } @@ -1028,4 +969,71 @@ impl State { } }); } + + fn restore_defaults(tx: &NesEventProxy, ctx: &Context) { + ctx.memory_mut(|mem| *mem = Default::default()); + + // Inform all cfg updates + let Config { + deck, + emulation, + audio, + renderer, + input, + } = Config::default(); + + let events = [ + ConfigEvent::ActionBindings(input.action_bindings), + ConfigEvent::AlwaysOnTop(renderer.always_on_top), + ConfigEvent::ApuChannelsEnabled(deck.channels_enabled), + ConfigEvent::AudioBuffer(audio.buffer_size), + ConfigEvent::AudioEnabled(audio.enabled), + ConfigEvent::AudioLatency(audio.latency), + ConfigEvent::AutoLoad(emulation.auto_load), + ConfigEvent::AutoSave(emulation.auto_save), + ConfigEvent::AutoSaveInterval(emulation.auto_save_interval), + ConfigEvent::ConcurrentDpad(deck.concurrent_dpad), + ConfigEvent::CycleAccurate(deck.cycle_accurate), + ConfigEvent::DarkTheme(renderer.dark_theme), + ConfigEvent::EmbedViewports(renderer.embed_viewports), + ConfigEvent::FourPlayer(deck.four_player), + ConfigEvent::Fullscreen(renderer.fullscreen), + ConfigEvent::GamepadAssignments(input.gamepad_assignments), + ConfigEvent::GenieCodeClear, + ConfigEvent::HideOverscan(renderer.hide_overscan), + ConfigEvent::MapperRevisions(deck.mapper_revisions), + ConfigEvent::RamState(deck.ram_state), + // Clearing recent roms is handled in a separate button + ConfigEvent::Region(deck.region), + ConfigEvent::RewindEnabled(emulation.rewind), + ConfigEvent::RewindInterval(emulation.rewind_interval), + ConfigEvent::RewindSeconds(emulation.rewind_seconds), + ConfigEvent::RunAhead(emulation.run_ahead), + ConfigEvent::SaveSlot(emulation.save_slot), + ConfigEvent::Shader(renderer.shader), + ConfigEvent::ShowMenubar(renderer.show_menubar), + ConfigEvent::ShowMessages(renderer.show_messages), + ConfigEvent::Speed(emulation.speed), + ConfigEvent::VideoFilter(deck.filter), + ConfigEvent::ZapperConnected(deck.zapper), + ]; + + for event in events { + tx.event(event); + } + } + + fn clear_save_states(tx: &NesEventProxy) { + let data_dir = Config::default_data_dir(); + match fs::clear_dir(data_dir) { + Ok(_) => tx.event(UiEvent::Message(( + MessageType::Info, + "Save States cleared.".to_string(), + ))), + Err(_) => tx.event(UiEvent::Message(( + MessageType::Error, + "Failed to clear Save States.".to_string(), + ))), + } + } } diff --git a/tetanes/src/platform.rs b/tetanes/src/platform.rs index c4278016..59b96c22 100644 --- a/tetanes/src/platform.rs +++ b/tetanes/src/platform.rs @@ -58,17 +58,17 @@ pub mod renderer { #[derive(Debug, Copy, Clone, PartialEq, Eq)] #[must_use] pub enum Feature { - Filesystem, - Storage, - Viewports, - DeferredViewport, - ConstrainedViewport, - Suspend, + AbortOnExit, + AccessKit, Blocking, + ConstrainedViewport, ConsumePaste, - AbortOnExit, + DeferredViewport, + Filesystem, ScreenReader, - AccessKit, + Storage, + Suspend, + Viewports, } /// Checks if the current platform supports a given feature. @@ -77,17 +77,18 @@ macro_rules! feature { ($feature: tt) => {{ use $crate::platform::Feature::*; match $feature { - Suspend => cfg!(target_os = "android"), - Filesystem | Storage | Viewports | Blocking | DeferredViewport => { - cfg!(not(target_arch = "wasm32")) - } - // FIXME: Deadlock thread sleep issue with zbus/async-io on linux when menus are opened - AccessKit => cfg!(any(target_os = "macos", target_os = "windows")), // Wasm should never be able to exit AbortOnExit => cfg!(target_arch = "wasm32"), + // FIXME: Deadlock thread sleep issue with zbus/async-io on linux when menus are opened + AccessKit => cfg!(any(target_os = "macos", target_os = "windows")), + Blocking | DeferredViewport | Filesystem | Viewports => { + cfg!(not(target_arch = "wasm32")) + } ConstrainedViewport | ConsumePaste | ScreenReader => { cfg!(target_arch = "wasm32") } + Storage => true, + Suspend => cfg!(target_os = "android"), } }}; } From 380f9ae96b8f34e925860a3a7bdfb3b9c8bc7081 Mon Sep 17 00:00:00 2001 From: Luke Date: Thu, 27 Jun 2024 21:44:52 -0700 Subject: [PATCH 2/2] feat: added download save states button for web --- Cargo.lock | 70 +++++++++++++++++++++ tetanes-core/src/bus.rs | 12 ++-- tetanes-core/src/control_deck.rs | 12 +++- tetanes-core/src/sys/fs/wasm.rs | 2 +- tetanes/Cargo.toml | 2 + tetanes/src/nes/config.rs | 3 +- tetanes/src/nes/renderer/gui/preferences.rs | 9 +++ tetanes/src/sys/platform/wasm.rs | 58 +++++++++++++++++ 8 files changed, 158 insertions(+), 10 deletions(-) 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 {