web: savegames through the page, since there is no saves/ directory

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/<slot>.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 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-26 22:38:34 +10:00
parent 50ede0febf
commit caf4d5e558
3 changed files with 121 additions and 11 deletions

View File

@ -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",
]

View File

@ -126,9 +126,20 @@ pub fn world_from_bundle(json: &str) -> Option<World> {
/// 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<Option<String>> = Mutex::new(None);
pub static PENDING_RESTORE: Mutex<Option<String>> = Mutex::new(None);
// Atomic, not Mutex<bool>: 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::<mrpci_core::SaveData>(&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 });

View File

@ -45,14 +45,48 @@
<script>
// miniquad resolves the engine's undefined imports at runtime; register
// before the wasm loads or the instantiation fails.
miniquad_add_plugin({ register_plugin: () => {}, version: '1', name: 'mrpci_bridge' })
let onSaveOut = null
miniquad_add_plugin({
register_plugin: (imports) => {
imports.env.mrpci_save_out = (ptr, len) => {
const json = new TextDecoder().decode(new Uint8Array(wasm_memory.buffer, ptr, len))
if (onSaveOut) onSaveOut(json)
}
},
version: '1',
name: 'mrpci_bridge',
})
/** Hand a WorldBundle JSON to the running engine. */
function sendBundleToEngine(json) {
/** Copy a string into the engine and call an exported entry point. */
function handOver(fn, json) {
const bytes = new TextEncoder().encode(json)
const ptr = wasm_exports.mrpci_alloc(bytes.length)
new Uint8Array(wasm_memory.buffer, ptr, bytes.length).set(bytes)
wasm_exports.mrpci_world_in(ptr, bytes.length)
fn(ptr, bytes.length)
}
/** Hand a WorldBundle JSON to the running engine. */
function sendBundleToEngine(json) {
handOver(wasm_exports.mrpci_world_in, json)
}
/**
* The engine emits its progress a frame after being asked, so a save
* is a round trip, not a return value — hence the promise. The timeout
* matters: if the engine is wedged, the menu should say so rather than
* hang on a button press forever.
*/
function captureSave() {
return new Promise((resolve) => {
if (!wasm_exports?.mrpci_request_save) return resolve(null)
const timer = setTimeout(() => { onSaveOut = null; resolve(null) }, 2000)
onSaveOut = (json) => {
clearTimeout(timer)
onSaveOut = null
resolve(json)
}
wasm_exports.mrpci_request_save()
})
}
load('mrpci.wasm')
@ -70,6 +104,10 @@
engine: 'mrpci',
ready: () => typeof wasm_exports !== 'undefined' && !!wasm_exports,
load: (json) => sendBundleToEngine(json),
saves: {
capture: () => captureSave(),
restore: (json) => handOver(wasm_exports.mrpci_save_in, json),
},
})
</script>
</body>