diff --git a/tdkpin-rs/.cargo/config.toml b/tdkpin-rs/.cargo/config.toml new file mode 100644 index 0000000..7096228 --- /dev/null +++ b/tdkpin-rs/.cargo/config.toml @@ -0,0 +1,21 @@ +[target.wasm32-unknown-unknown] +rustflags = [ + "-C", + "link-arg=--import-undefined", + "-C", + "link-arg=--export=tdkpin_storage_crate_version", + "-C", + "link-arg=--export=tdkpin_browser_storage_clear", + "-C", + "link-arg=--export=tdkpin_browser_storage_push", + "-C", + "link-arg=--export=tdkpin_browser_storage_finish", + "-C", + "link-arg=--export=tdkpin_browser_storage_save_revision", + "-C", + "link-arg=--export=tdkpin_browser_storage_save_length", + "-C", + "link-arg=--export=tdkpin_browser_storage_save_byte", + "-C", + "link-arg=--export=tdkpin_browser_storage_save_ack", +] diff --git a/tdkpin-rs/Cargo.lock b/tdkpin-rs/Cargo.lock index 00f5437..05f1093 100644 --- a/tdkpin-rs/Cargo.lock +++ b/tdkpin-rs/Cargo.lock @@ -164,6 +164,42 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -362,6 +398,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "png" version = "0.17.16" @@ -481,6 +523,12 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "0.6.14" @@ -506,11 +554,17 @@ name = "tdkpin-rs" version = "1.0.0" dependencies = [ "directories", + "futures-util", "macroquad", "serde", "serde_json", + "tdkpin-web-storage", ] +[[package]] +name = "tdkpin-web-storage" +version = "1.0.0" + [[package]] name = "thiserror" version = "2.0.20" diff --git a/tdkpin-rs/Cargo.toml b/tdkpin-rs/Cargo.toml index 119c468..0a374b5 100644 --- a/tdkpin-rs/Cargo.toml +++ b/tdkpin-rs/Cargo.toml @@ -4,11 +4,17 @@ version = "1.0.0" edition = "2024" [dependencies] -directories = "6" macroquad = { version = "0.4", features = ["audio"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +directories = "6" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +futures-util = "0.3" +tdkpin-web-storage = { path = "web_storage" } + [lints.clippy] pedantic = { level = "warn", priority = -1 } todo = "warn" diff --git a/tdkpin-rs/README.md b/tdkpin-rs/README.md index 76fa01c..8eb5a1e 100644 --- a/tdkpin-rs/README.md +++ b/tdkpin-rs/README.md @@ -31,6 +31,19 @@ development packages are also required (X11, OpenGL, and ALSA). Windows needs no extra runtime installation; macOS builds with the normal Apple developer command-line tools. +## Browser version + +Build and serve the WASM website locally with: + +```sh +just web-serve +``` + +Then open . The browser build keeps the original +640x460 presentation centered on a black page and stores settings and high +scores in browser storage. See [web/README.md](web/README.md) for the static +bundle details. + ## Deterministic mechanics validation Named scenarios can be advanced without waiting in real time. The simulator diff --git a/tdkpin-rs/justfile b/tdkpin-rs/justfile index 8d41d09..9bfbaf4 100644 --- a/tdkpin-rs/justfile +++ b/tdkpin-rs/justfile @@ -12,6 +12,13 @@ build-release: build-production: cargo build --profile production +web-build: + cargo build --profile production --target wasm32-unknown-unknown + cp target/wasm32-unknown-unknown/production/tdkpin-rs.wasm web/tdkpin-rs.wasm + +web-serve: web-build + python3 -m http.server 8000 --directory web + fmt: cargo +nightly fmt tombi format diff --git a/tdkpin-rs/src/app.rs b/tdkpin-rs/src/app.rs index 22b640d..562d7ed 100644 --- a/tdkpin-rs/src/app.rs +++ b/tdkpin-rs/src/app.rs @@ -1,3 +1,4 @@ +#[cfg(not(target_arch = "wasm32"))] use std::path::Path; use macroquad::prelude::*; @@ -247,6 +248,8 @@ impl App { } pub fn frame(&mut self) { + #[cfg(target_arch = "wasm32")] + self.assets.update(); self.handle_global_input(); match self.screen { Screen::Loading => { @@ -266,11 +269,13 @@ impl App { self.present(); } + #[cfg(not(target_arch = "wasm32"))] pub fn set_simulation_game(&mut self, game: Game) { self.game = Some(game); self.screen = Screen::Playing; } + #[cfg(not(target_arch = "wasm32"))] pub fn set_simulation_attract(&mut self, steps: u64) { self.game = None; self.screen = Screen::Attract; @@ -280,6 +285,7 @@ impl App { } } + #[cfg(not(target_arch = "wasm32"))] pub fn set_simulation_loading(&mut self, steps: u64) { self.game = None; self.screen = Screen::Loading; @@ -288,12 +294,14 @@ impl App { self.loading_until = get_time() + 60.0; } + #[cfg(not(target_arch = "wasm32"))] pub fn set_simulation_highscores(&mut self) { self.game = None; self.return_screen = Screen::Attract; self.screen = Screen::HighScores; } + #[cfg(not(target_arch = "wasm32"))] pub fn set_simulation_name_entry(&mut self) { self.game = Some(Game::new(1)); self.return_screen = Screen::Playing; @@ -302,10 +310,12 @@ impl App { self.screen = Screen::NameEntry; } + #[cfg(not(target_arch = "wasm32"))] pub fn render_simulation(&self) { self.draw_logical(); } + #[cfg(not(target_arch = "wasm32"))] pub fn export_simulation_png(&self, path: &Path) -> Result<(), String> { let path = path .to_str() @@ -451,6 +461,7 @@ impl App { let now = get_time(); if now - self.last_help_click <= 0.40 { self.save(); + #[cfg(not(target_arch = "wasm32"))] macroquad::miniquad::window::request_quit(); } self.last_help_click = now; diff --git a/tdkpin-rs/src/assets.rs b/tdkpin-rs/src/assets.rs index e20bc4e..8699d10 100644 --- a/tdkpin-rs/src/assets.rs +++ b/tdkpin-rs/src/assets.rs @@ -1,3 +1,7 @@ +#[cfg(target_arch = "wasm32")] +use futures_util::future::join_all; +#[cfg(target_arch = "wasm32")] +use macroquad::experimental::coroutines::{Coroutine, start_coroutine}; use macroquad::{ audio::{PlaySoundParams, Sound, load_sound_from_bytes, play_sound, stop_sound}, prelude::{FilterMode, Image, Texture2D}, @@ -24,6 +28,8 @@ pub struct Assets { pub ball: Texture2D, pub digits: Texture2D, sounds: Vec<(u16, Sound)>, + #[cfg(target_arch = "wasm32")] + sound_loader: Option>>, } impl Assets { @@ -85,78 +91,18 @@ impl Assets { let digits = monochrome_texture(include_bytes!("../assets/original/images/bitmap_00500.png")); - let sound_bytes: [(u16, &[u8]); 16] = [ - ( - 2001, - include_bytes!("../assets/original/audio/wav_02001.wav"), - ), - ( - 2002, - include_bytes!("../assets/original/audio/wav_02002.wav"), - ), - ( - 2004, - include_bytes!("../assets/original/audio/wav_02004.wav"), - ), - ( - 2006, - include_bytes!("../assets/original/audio/wav_02006.wav"), - ), - ( - 2007, - include_bytes!("../assets/original/audio/wav_02007.wav"), - ), - ( - 2008, - include_bytes!("../assets/original/audio/wav_02008.wav"), - ), - ( - 2011, - include_bytes!("../assets/original/audio/wav_02011.wav"), - ), - ( - 2012, - include_bytes!("../assets/original/audio/wav_02012.wav"), - ), - ( - 2013, - include_bytes!("../assets/original/audio/wav_02013.wav"), - ), - ( - 2015, - include_bytes!("../assets/original/audio/wav_02015.wav"), - ), - ( - 2016, - include_bytes!("../assets/original/audio/wav_02016.wav"), - ), - ( - 2017, - include_bytes!("../assets/original/audio/wav_02017.wav"), - ), - ( - 2019, - include_bytes!("../assets/original/audio/wav_02019.wav"), - ), - ( - 2020, - include_bytes!("../assets/original/audio/wav_02020.wav"), - ), - ( - 2021, - include_bytes!("../assets/original/audio/wav_02021.wav"), - ), - ( - 2022, - include_bytes!("../assets/original/audio/wav_02022.wav"), - ), - ]; - let mut sounds = Vec::with_capacity(sound_bytes.len()); - for (id, bytes) in sound_bytes { - if let Ok(sound) = load_sound_from_bytes(bytes).await { - sounds.push((id, sound)); - } - } + let sound_bytes = sound_bytes(); + #[cfg(target_arch = "wasm32")] + let (sounds, sound_loader) = ( + Vec::new(), + Some(start_coroutine( + async move { load_sounds(&sound_bytes).await }, + )), + ); + #[cfg(not(target_arch = "wasm32"))] + let sounds = load_sounds(&sound_bytes).await; + #[cfg(target_arch = "wasm32")] + macroquad::window::next_frame().await; Self { active_table, @@ -179,9 +125,23 @@ impl Assets { ball, digits, sounds, + #[cfg(target_arch = "wasm32")] + sound_loader, } } + #[cfg(target_arch = "wasm32")] + pub fn update(&mut self) { + let Some(loader) = self.sound_loader.as_ref() else { + return; + }; + if !loader.is_done() { + return; + } + let loader = self.sound_loader.take().expect("sound loader must exist"); + self.sounds = loader.retrieve().unwrap_or_default(); + } + pub fn play(&self, id: u16, enabled: bool) { if !enabled { return; @@ -205,6 +165,102 @@ impl Assets { } } +fn sound_bytes() -> [(u16, &'static [u8]); 16] { + [ + ( + 2001, + include_bytes!("../assets/original/audio/wav_02001.wav"), + ), + ( + 2002, + include_bytes!("../assets/original/audio/wav_02002.wav"), + ), + ( + 2004, + include_bytes!("../assets/original/audio/wav_02004.wav"), + ), + ( + 2006, + include_bytes!("../assets/original/audio/wav_02006.wav"), + ), + ( + 2007, + include_bytes!("../assets/original/audio/wav_02007.wav"), + ), + ( + 2008, + include_bytes!("../assets/original/audio/wav_02008.wav"), + ), + ( + 2011, + include_bytes!("../assets/original/audio/wav_02011.wav"), + ), + ( + 2012, + include_bytes!("../assets/original/audio/wav_02012.wav"), + ), + ( + 2013, + include_bytes!("../assets/original/audio/wav_02013.wav"), + ), + ( + 2015, + include_bytes!("../assets/original/audio/wav_02015.wav"), + ), + ( + 2016, + include_bytes!("../assets/original/audio/wav_02016.wav"), + ), + ( + 2017, + include_bytes!("../assets/original/audio/wav_02017.wav"), + ), + ( + 2019, + include_bytes!("../assets/original/audio/wav_02019.wav"), + ), + ( + 2020, + include_bytes!("../assets/original/audio/wav_02020.wav"), + ), + ( + 2021, + include_bytes!("../assets/original/audio/wav_02021.wav"), + ), + ( + 2022, + include_bytes!("../assets/original/audio/wav_02022.wav"), + ), + ] +} + +async fn load_sounds(sound_bytes: &[(u16, &[u8])]) -> Vec<(u16, Sound)> { + #[cfg(target_arch = "wasm32")] + { + join_all(sound_bytes.iter().map(|(id, bytes)| async move { + load_sound_from_bytes(bytes) + .await + .ok() + .map(|sound| (*id, sound)) + })) + .await + .into_iter() + .flatten() + .collect() + } + + #[cfg(not(target_arch = "wasm32"))] + { + let mut sounds = Vec::with_capacity(sound_bytes.len()); + for &(id, bytes) in sound_bytes { + if let Ok(sound) = load_sound_from_bytes(bytes).await { + sounds.push((id, sound)); + } + } + sounds + } +} + fn texture(bytes: &[u8]) -> Texture2D { let texture = Texture2D::from_file_with_format(bytes, None); texture.set_filter(FilterMode::Nearest); diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index 1c2d1a4..cc628c8 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -382,6 +382,7 @@ pub struct Game { } impl Game { + #[cfg(not(target_arch = "wasm32"))] pub fn new(player_count: usize) -> Self { Self::new_with_seed(player_count, macroquad::rand::rand()) } @@ -460,6 +461,7 @@ impl Game { true } + #[cfg(not(target_arch = "wasm32"))] pub(crate) fn begin_claw_scenario(&mut self, terminal_frame: u8) -> Vec { self.ball.in_launcher = false; self.ball.position = CLAW_TRIGGER_CENTER; @@ -469,6 +471,7 @@ impl Game { events } + #[cfg(not(target_arch = "wasm32"))] pub(crate) fn begin_effect_scenario(&mut self, effect: u8) { self.target_effect = effect.min(7); self.object_active[usize::from(EFFECT_SENSOR.id)] = self.target_effect != 0; @@ -478,6 +481,7 @@ impl Game { } } + #[cfg(not(target_arch = "wasm32"))] pub(crate) fn begin_target_scenario(&mut self) { self.ball.in_launcher = false; self.ball.position = vec2(170.0, 230.0); @@ -2024,6 +2028,7 @@ impl Game { } } + #[cfg(not(target_arch = "wasm32"))] fn begin_claw_capture(&mut self, terminal_frame: u8, events: &mut Vec) { debug_assert!(CLAW_TERMINAL_FRAMES.contains(&terminal_frame)); if self.claw.active { diff --git a/tdkpin-rs/src/main.rs b/tdkpin-rs/src/main.rs index 1f26f6a..0b68c63 100644 --- a/tdkpin-rs/src/main.rs +++ b/tdkpin-rs/src/main.rs @@ -7,11 +7,13 @@ mod geometry; mod original_physics; mod persistence; mod real48; +#[cfg(not(target_arch = "wasm32"))] mod simulation; mod table; use app::App; use macroquad::prelude::*; +#[cfg(not(target_arch = "wasm32"))] use simulation::{Request, Scenario, Simulation, usage}; const WINDOW_WIDTH: i32 = 640; @@ -30,18 +32,21 @@ fn window_conf() -> Conf { #[macroquad::main(window_conf)] async fn main() { - let request = match Request::parse(std::env::args()) { - Ok(request) => request, - Err(error) => { - eprintln!("{error}"); + #[cfg(not(target_arch = "wasm32"))] + { + let request = match Request::parse(std::env::args()) { + Ok(request) => request, + Err(error) => { + eprintln!("{error}"); + return; + } + }; + if let Some(request) = request { + if let Err(error) = run_simulation(request).await { + eprintln!("{error}\n\n{}", usage()); + } return; } - }; - if let Some(request) = request { - if let Err(error) = run_simulation(request).await { - eprintln!("{error}\n\n{}", usage()); - } - return; } let mut app = App::load().await; @@ -51,6 +56,7 @@ async fn main() { } } +#[cfg(not(target_arch = "wasm32"))] async fn run_simulation(request: Request) -> Result<(), String> { let mut simulation = Simulation::new(request.scenario, request.seed); simulation.advance_to(request.target_step); diff --git a/tdkpin-rs/src/persistence.rs b/tdkpin-rs/src/persistence.rs index 82810f5..0bc6d50 100644 --- a/tdkpin-rs/src/persistence.rs +++ b/tdkpin-rs/src/persistence.rs @@ -1,7 +1,12 @@ -use std::{fs, io, path::PathBuf}; +use std::io; +#[cfg(not(target_arch = "wasm32"))] +use std::{fs, path::PathBuf}; +#[cfg(not(target_arch = "wasm32"))] use directories::ProjectDirs; use serde::{Deserialize, Serialize}; +#[cfg(target_arch = "wasm32")] +use tdkpin_web_storage::{queue_save, take_loaded}; const ORIGINAL_HIGHSCORES: &[u8] = include_bytes!("../assets/original/HISCORES.DAT"); const ORIGINAL_INI: &str = include_str!("../../original/TDKPIN.INI"); @@ -75,10 +80,15 @@ impl Default for SavedData { } } +#[cfg(not(target_arch = "wasm32"))] pub struct Persistence { path: PathBuf, } +#[cfg(target_arch = "wasm32")] +pub struct Persistence; + +#[cfg(not(target_arch = "wasm32"))] impl Persistence { pub fn new() -> Self { let path = ProjectDirs::from("com", "kiwi-hamburg", "TDK Pinball Machine").map_or_else( @@ -92,11 +102,7 @@ impl Persistence { fs::read_to_string(&self.path) .ok() .and_then(|text| serde_json::from_str(&text).ok()) - .unwrap_or_else(|| SavedData { - settings: load_adjacent_original_settings() - .unwrap_or_else(|| parse_original_settings(ORIGINAL_INI)), - high_scores: parse_original_high_scores(ORIGINAL_HIGHSCORES), - }) + .unwrap_or_else(native_default_saved_data) } pub fn save(&self, data: &SavedData) -> io::Result<()> { @@ -118,6 +124,37 @@ impl Persistence { } } +#[cfg(target_arch = "wasm32")] +impl Persistence { + pub fn new() -> Self { + Self + } + + #[allow(clippy::unused_self)] + pub fn load(&self) -> SavedData { + take_loaded() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default() + } + + #[allow(clippy::unused_self)] + pub fn save(&self, data: &SavedData) -> io::Result<()> { + let encoded = serde_json::to_vec(data).map_err(io::Error::other)?; + queue_save(encoded); + Ok(()) + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn native_default_saved_data() -> SavedData { + SavedData { + settings: load_adjacent_original_settings() + .unwrap_or_else(|| parse_original_settings(ORIGINAL_INI)), + high_scores: parse_original_high_scores(ORIGINAL_HIGHSCORES), + } +} + +#[cfg(not(target_arch = "wasm32"))] fn load_adjacent_original_settings() -> Option { let executable = std::env::current_exe().ok()?; let exact_name = executable.with_extension("INI"); diff --git a/tdkpin-rs/web/README.md b/tdkpin-rs/web/README.md new file mode 100644 index 0000000..bd5f68a --- /dev/null +++ b/tdkpin-rs/web/README.md @@ -0,0 +1,15 @@ +# TDK Pinball Machine web build + +Build and serve the browser version from this directory with: + +```sh +just web-serve +``` + +The game is compiled for `wasm32-unknown-unknown` and loaded into the centered +black canvas by `index.html`. Browser settings and high scores are saved in +`localStorage`; native builds continue to use their normal per-user save file. + +The page uses Macroquad's official browser loader from the miniquad samples +site. The web page must be served over HTTP rather than opened directly from a +`file:` URL. diff --git a/tdkpin-rs/web/index.html b/tdkpin-rs/web/index.html new file mode 100644 index 0000000..66153c5 --- /dev/null +++ b/tdkpin-rs/web/index.html @@ -0,0 +1,53 @@ + + + + + + + TDK Pinball Machine 1.00 + + + + + + + + + + diff --git a/tdkpin-rs/web/storage.js b/tdkpin-rs/web/storage.js new file mode 100644 index 0000000..d2255f7 --- /dev/null +++ b/tdkpin-rs/web/storage.js @@ -0,0 +1,69 @@ +"use strict"; + +(function registerStoragePlugin() { + const storageKey = "tdkpin.save.v1"; + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + let lastRevision = 0; + + function browserStorage() { + try { + return window.localStorage; + } catch (_error) { + return null; + } + } + + function sendSavedDataToRust() { + wasm_exports.tdkpin_browser_storage_clear(); + const storage = browserStorage(); + let saved = null; + try { + saved = storage?.getItem(storageKey); + } catch (_error) { + saved = null; + } + if (saved !== null && saved !== undefined) { + for (const byte of encoder.encode(saved)) { + wasm_exports.tdkpin_browser_storage_push(byte); + } + } + wasm_exports.tdkpin_browser_storage_finish(); + } + + function flushRustSave() { + const revision = wasm_exports.tdkpin_browser_storage_save_revision(); + if (revision === lastRevision) { + return; + } + + const bytes = new Uint8Array( + wasm_exports.tdkpin_browser_storage_save_length(), + ); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = wasm_exports.tdkpin_browser_storage_save_byte(index); + } + + const storage = browserStorage(); + try { + storage?.setItem(storageKey, decoder.decode(bytes)); + } catch (_error) { + // Private browsing or a full quota should not stop the game loop. + } + wasm_exports.tdkpin_browser_storage_save_ack(); + lastRevision = revision; + } + + function onInit() { + sendSavedDataToRust(); + lastRevision = wasm_exports.tdkpin_browser_storage_save_revision(); + window.setInterval(flushRustSave, 50); + window.addEventListener("beforeunload", flushRustSave); + } + + miniquad_add_plugin({ + name: "tdkpin_storage", + on_init: onInit, + version: 1, + }); +})(); diff --git a/tdkpin-rs/web/tdkpin-rs.wasm b/tdkpin-rs/web/tdkpin-rs.wasm new file mode 100755 index 0000000..ec9d459 Binary files /dev/null and b/tdkpin-rs/web/tdkpin-rs.wasm differ diff --git a/tdkpin-rs/web_storage/Cargo.toml b/tdkpin-rs/web_storage/Cargo.toml new file mode 100644 index 0000000..d560ba4 --- /dev/null +++ b/tdkpin-rs/web_storage/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "tdkpin-web-storage" +version = "1.0.0" +edition = "2024" +publish = false + +[lib] +path = "src/lib.rs" diff --git a/tdkpin-rs/web_storage/src/lib.rs b/tdkpin-rs/web_storage/src/lib.rs new file mode 100644 index 0000000..6ed3335 --- /dev/null +++ b/tdkpin-rs/web_storage/src/lib.rs @@ -0,0 +1,73 @@ +#![allow(unsafe_code)] + +use std::{ + cell::{Cell, RefCell}, + mem, +}; + +thread_local! { + static LOADED_BYTES: RefCell> = const { RefCell::new(Vec::new()) }; + static PENDING_SAVE: RefCell>> = const { RefCell::new(None) }; + static SAVE_REVISION: Cell = const { Cell::new(0) }; +} + +pub fn take_loaded() -> Option> { + let bytes = LOADED_BYTES.with(|loaded| mem::take(&mut *loaded.borrow_mut())); + (!bytes.is_empty()).then_some(bytes) +} + +pub fn queue_save(bytes: Vec) { + PENDING_SAVE.with(|pending| *pending.borrow_mut() = Some(bytes)); + SAVE_REVISION.with(|revision| revision.set(revision.get().wrapping_add(1))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn tdkpin_storage_crate_version() -> u32 { + 1 +} + +#[unsafe(no_mangle)] +pub extern "C" fn tdkpin_browser_storage_clear() { + LOADED_BYTES.with(|loaded| loaded.borrow_mut().clear()); +} + +#[unsafe(no_mangle)] +pub extern "C" fn tdkpin_browser_storage_push(byte: u32) { + if let Ok(byte) = u8::try_from(byte) { + LOADED_BYTES.with(|loaded| loaded.borrow_mut().push(byte)); + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn tdkpin_browser_storage_finish() {} + +#[unsafe(no_mangle)] +pub extern "C" fn tdkpin_browser_storage_save_revision() -> u32 { + SAVE_REVISION.with(Cell::get) +} + +#[unsafe(no_mangle)] +pub extern "C" fn tdkpin_browser_storage_save_length() -> u32 { + PENDING_SAVE.with(|pending| { + pending + .borrow() + .as_ref() + .map_or(0, |bytes| u32::try_from(bytes.len()).unwrap_or(u32::MAX)) + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn tdkpin_browser_storage_save_byte(index: u32) -> u32 { + PENDING_SAVE.with(|pending| { + pending + .borrow() + .as_ref() + .and_then(|bytes| bytes.get(usize::try_from(index).ok()?)) + .map_or(0, |byte| u32::from(*byte)) + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn tdkpin_browser_storage_save_ack() { + PENDING_SAVE.with(|pending| *pending.borrow_mut() = None); +}