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