From caf4d5e558427fd756ae2056f8c1da54f712e9f0 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Sun, 26 Jul 2026 22:38:34 +1000 Subject: [PATCH] web: savegames through the page, since there is no saves/ directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mrpci_request_save / mrpci_save_out / mrpci_save_in carry a SaveData across the wasm boundary, so the browser build can save and restore even though Command::SaveGame's std::fs call goes nowhere there. The moment being stored is exactly the one the native build writes to saves/.json — the same GameState::save_data — so a slot means the same thing in both places. F5 now asks the page instead of the filesystem; F7 points at the SAVES menu, because restoring needs to know *which* slot and a function key can't say. Co-Authored-By: Claude Opus 5 --- .cargo/config.toml | 2 ++ mrpci/src/main.rs | 84 ++++++++++++++++++++++++++++++++++++++++++---- web/index.html | 46 ++++++++++++++++++++++--- 3 files changed, 121 insertions(+), 11 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index ebe7fc9..7af80a2 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -13,4 +13,6 @@ rustflags = [ "-C", "link-arg=--allow-undefined", "-C", "link-arg=--export=mrpci_alloc", "-C", "link-arg=--export=mrpci_world_in", + "-C", "link-arg=--export=mrpci_save_in", + "-C", "link-arg=--export=mrpci_request_save", ] diff --git a/mrpci/src/main.rs b/mrpci/src/main.rs index a3e8454..ac9adf9 100644 --- a/mrpci/src/main.rs +++ b/mrpci/src/main.rs @@ -126,9 +126,20 @@ pub fn world_from_bundle(json: &str) -> Option { /// on the next frame. No threads, no async. #[cfg(target_arch = "wasm32")] mod web_bridge { + use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; pub static PENDING_LOAD: Mutex> = Mutex::new(None); + pub static PENDING_RESTORE: Mutex> = Mutex::new(None); + // Atomic, not Mutex: with no threads in the picture LLVM is free to + // const-fold a plain static whose only writer is JS, which it cannot see. + // MRPGI learned this the hard way; don't relearn it. + pub static SAVE_REQUESTED: AtomicBool = AtomicBool::new(false); + + extern "C" { + /// The engine hands a savegame back to the page. + pub fn mrpci_save_out(ptr: *const u8, len: usize); + } /// JS asks for a buffer to write into. #[no_mangle] @@ -139,11 +150,26 @@ mod web_bridge { /// JS hands over a bundle JSON it wrote into that buffer. #[no_mangle] pub extern "C" fn mrpci_world_in(ptr: *mut u8, len: usize) { - let s = unsafe { + *PENDING_LOAD.lock().unwrap() = Some(take(ptr, len)); + } + + /// JS hands over a savegame to restore. + #[no_mangle] + pub extern "C" fn mrpci_save_in(ptr: *mut u8, len: usize) { + *PENDING_RESTORE.lock().unwrap() = Some(take(ptr, len)); + } + + /// JS asks the engine to emit its current progress on the next frame. + #[no_mangle] + pub extern "C" fn mrpci_request_save() { + SAVE_REQUESTED.store(true, Ordering::SeqCst); + } + + fn take(ptr: *mut u8, len: usize) -> String { + unsafe { let boxed = Box::from_raw(std::slice::from_raw_parts_mut(ptr, len) as *mut [u8]); String::from_utf8_lossy(&boxed).into_owned() - }; - *PENDING_LOAD.lock().unwrap() = Some(s); + } } } @@ -227,6 +253,34 @@ async fn amain(game_dir: String) { // --- a game handed over by the page ---------------------------------- // The browser build is a *player*: the picker (and drag-and-drop) // swap the whole world underneath it, and the session restarts. + // --- savegames, via the page -------------------------------------- + // There is no `saves/` directory in a browser, so F5/F7 and the + // arcade's slot menu both go out through the page instead. The + // moment being saved is exactly the one the native build writes to + // disk — GameState::save_data — so a slot means the same thing in + // both places. + #[cfg(target_arch = "wasm32")] + { + use std::sync::atomic::Ordering; + if web_bridge::SAVE_REQUESTED.swap(false, Ordering::SeqCst) { + match serde_json::to_string(&gs.save_data()) { + Ok(j) => unsafe { web_bridge::mrpci_save_out(j.as_ptr(), j.len()) }, + Err(e) => ui.log.push(format!("(couldn't pack the save: {})", e)), + } + } + let restore = web_bridge::PENDING_RESTORE.lock().unwrap().take(); + if let Some(json) = restore { + match serde_json::from_str::(&json) { + Ok(d) => { + let evs = gs.restore_data(d); + handle_events(&evs, &mut ui, &mut audio); + ui.log.push("(restored.)".into()); + } + Err(_) => ui.log.push("(that isn't an MRPCI savegame.)".into()), + } + } + } + #[cfg(target_arch = "wasm32")] { let incoming = web_bridge::PENDING_LOAD.lock().unwrap().take(); @@ -321,13 +375,29 @@ async fn amain(game_dir: String) { ui.typing = true; ui.input.clear(); } + // Native writes saves/quick.json. The browser has no saves/ dir, + // so the same two keys ask the page to put the blob wherever the + // player's savegames live — their account, or this browser. if is_key_pressed(KeyCode::F5) { - let evs = gs.apply(Command::SaveGame { slot: "quick".into() }); - handle_events(&evs, &mut ui, &mut audio); + #[cfg(not(target_arch = "wasm32"))] + { + let evs = gs.apply(Command::SaveGame { slot: "quick".into() }); + handle_events(&evs, &mut ui, &mut audio); + } + #[cfg(target_arch = "wasm32")] + { + web_bridge::SAVE_REQUESTED.store(true, std::sync::atomic::Ordering::SeqCst); + ui.log.push("(saving\u{2026})".into()); + } } if is_key_pressed(KeyCode::F7) { - let evs = gs.apply(Command::RestoreGame { slot: "quick".into() }); - handle_events(&evs, &mut ui, &mut audio); + #[cfg(not(target_arch = "wasm32"))] + { + let evs = gs.apply(Command::RestoreGame { slot: "quick".into() }); + handle_events(&evs, &mut ui, &mut audio); + } + #[cfg(target_arch = "wasm32")] + ui.log.push("(use the SAVES menu up top to restore.)".into()); } if is_key_pressed(KeyCode::F9) { let evs = gs.apply(Command::NewGame { room: None }); diff --git a/web/index.html b/web/index.html index e855c9d..0b18f18 100644 --- a/web/index.html +++ b/web/index.html @@ -45,14 +45,48 @@