From 1191f1b8f110b163119f1dc458ce6af4833aa47e Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Mon, 13 Jul 2026 12:54:52 +0100 Subject: [PATCH] Add a browser (WebAssembly) build of the emulator Split the crate by cargo features so the deterministic core compiles without the desktop stack, and add a thin wasm frontend that runs the same core in a browser at real speed, published to copperline.dev/try. Feature split (defaults unchanged; native builds are byte-for-byte behaviourally identical, verified by the full test suite and matching --benchmark-until numbers): - `frontend`: the winit/pixels window, launcher/UI, cpal output, gilrs gamepads, rfd dialogs, and the console's arboard paste. - `wasm-boards`: the wasmtime plugin-board host. Cranelift cannot be compiled *to* wasm32, so browser builds turn it off; the pure-data manifest structs move to `wasm_manifest.rs` so config/zorro still compile without it. - `bench-bin`: a headless benchmark binary (`copperline-bench`) that also builds for wasm32-wasip1 and runs under Node's WASI (tools/wasi-bench.mjs), measuring wasm performance on the same V8 engine Chrome uses. With --render it includes the presentation pipeline; render checksums match native exactly. Portability work in the core: - `timebase.rs` re-exports std::time natively and web-time on wasm32-unknown-unknown (std Instant/SystemTime abort there); the hot-path probe sampling in bus.rs and the emulator stats go through it. envcfg snapshots an empty environment on that target instead of panicking in std::env::vars_os(). - The pure presentation helpers (post-process, recentring, TV mask, aperture predicates) move from window/present.rs to `video/present_common.rs` with re-exports, so frontend-less builds (cpu.rs debug screenshots, the web crate) can present frames. - Byte-based ingestion: `FloppyImage::from_bytes` and `FloppyController::insert_disk_image_bytes` mirror the path-based loaders for hosts with no filesystem. The browser frontend (`crates/copperline-web`, standalone cdylib, no wgpu, ~1.4 MiB wasm) exports a small WebEmu API: rAF-driven pacing with the audio queue as master clock, canvas presentation via ImageData, AudioWorklet audio, the desktop key table transliterated to W3C codes, and Pointer Lock mouse. The page glue (www/try.js, www/audio-worklet.js) lives here so it cannot drift from the API; the wasm-demo.yml workflow publishes it with the bundle and the AROS ROMs to the website repository on release tags, mirroring docs-site.yml. A new CI job guards the portability invariant (core check without default features, web crate check for wasm32-unknown-unknown). Documented in docs/guide/browser.md: architecture, local build steps, the embedding API, and the wasip1 benchmarking workflow. --- .github/workflows/ci.yml | 22 + .github/workflows/wasm-demo.yml | 81 +++ Cargo.lock | 1 + Cargo.toml | 66 +- README.md | 6 + crates/copperline-web/.gitignore | 2 + crates/copperline-web/Cargo.lock | 705 +++++++++++++++++++++ crates/copperline-web/Cargo.toml | 31 + crates/copperline-web/src/lib.rs | 402 ++++++++++++ crates/copperline-web/www/audio-worklet.js | 78 +++ crates/copperline-web/www/try.js | 267 ++++++++ docs/guide/browser.md | 156 +++++ docs/myst.yml | 2 + src/audio.rs | 44 +- src/bin/bench.rs | 212 +++++++ src/bus.rs | 2 +- src/config.rs | 4 +- src/cpu.rs | 5 +- src/emulator.rs | 13 +- src/envcfg.rs | 11 +- src/floppy.rs | 47 +- src/lib.rs | 4 + src/priority.rs | 9 +- src/rtc.rs | 2 +- src/serial.rs | 2 +- src/timebase.rs | 17 + src/timestamp.rs | 2 +- src/video/bitplane.rs | 2 +- src/video/mod.rs | 4 + src/video/present_common.rs | 171 +++++ src/video/window.rs | 22 +- src/video/window/present.rs | 153 +---- src/wasm_manifest.rs | 42 ++ src/wasmboard.rs | 35 +- src/zorro.rs | 2 +- src/zorro_device.rs | 12 + tools/wasi-bench.mjs | 25 + 37 files changed, 2431 insertions(+), 230 deletions(-) create mode 100644 .github/workflows/wasm-demo.yml create mode 100644 crates/copperline-web/.gitignore create mode 100644 crates/copperline-web/Cargo.lock create mode 100644 crates/copperline-web/Cargo.toml create mode 100644 crates/copperline-web/src/lib.rs create mode 100644 crates/copperline-web/www/audio-worklet.js create mode 100644 crates/copperline-web/www/try.js create mode 100644 docs/guide/browser.md create mode 100644 src/bin/bench.rs create mode 100644 src/timebase.rs create mode 100644 src/video/present_common.rs create mode 100644 src/wasm_manifest.rs create mode 100644 tools/wasi-bench.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ef7bde0..3b38068c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,6 +79,28 @@ jobs: | sed 's/^/--test /' \ | xargs cargo test --release --lib + wasm: + name: Headless core and browser build + # Guards the portability split behind the browser build (see + # docs/guide/browser.md): the core must keep compiling without the + # desktop frontend, and the web crate must keep compiling for + # wasm32-unknown-unknown. Cheap check-only job, so plain ubuntu is fine + # (no GPU or audio stack is reached without the `frontend` feature). + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + - name: Core without the desktop frontend + run: cargo check --no-default-features --locked + - name: Headless benchmark binary + run: cargo check --no-default-features --features bench-bin --locked + - name: Web crate (wasm32-unknown-unknown) + working-directory: crates/copperline-web + run: cargo check --target wasm32-unknown-unknown --locked + m68k-singlestep: name: m68k SingleStepTests (68000 fixtures) # Runs the per-instruction 68000 fixture suite against the real diff --git a/.github/workflows/wasm-demo.yml b/.github/workflows/wasm-demo.yml new file mode 100644 index 00000000..920fccbd --- /dev/null +++ b/.github/workflows/wasm-demo.yml @@ -0,0 +1,81 @@ +name: Browser demo + +# Builds the wasm browser frontend (crates/copperline-web) and publishes it +# to the copperline.dev website repository (LinuxJedi/copperline.github.io) +# under /try when a v* tag is pushed, so the in-browser emulator always +# matches the latest release. A workflow_dispatch trigger allows +# re-publishing from whatever ref it is run on without cutting a release. +# +# The page shell (try/index.html) is hand-written in the website repository +# and left alone. This workflow replaces the parts that must change together +# with the emulator: the wasm-bindgen bundle (try/pkg), the JS glue that +# drives the WebEmu API (try/try.js, try/audio-worklet.js, sourced from +# crates/copperline-web/www), and the AROS ROMs (try/aros). +# +# Pushing to the website repository needs the SITE_DEPLOY_KEY secret: the +# private half of an SSH deploy key whose public half is installed with +# write access on LinuxJedi/copperline.github.io (shared with docs-site.yml). +on: + push: + tags: ["v*"] + workflow_dispatch: + +concurrency: + group: wasm-demo-${{ github.ref }} + cancel-in-progress: true + +jobs: + publish: + name: Build and publish the browser demo + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install the wasm target + run: rustup target add wasm32-unknown-unknown + - name: Install wasm-bindgen-cli + # The CLI must match the crate dependency exactly; the version is + # pinned in crates/copperline-web/Cargo.toml and parsed from there so + # they cannot drift apart. + run: | + V=$(sed -n 's/^wasm-bindgen = "=\(.*\)"$/\1/p' crates/copperline-web/Cargo.toml) + test -n "$V" + cargo install wasm-bindgen-cli --version "$V" --locked + - name: Build the web crate + working-directory: crates/copperline-web + run: | + cargo build --release --target wasm32-unknown-unknown --locked + wasm-bindgen --target web --out-dir pkg \ + target/wasm32-unknown-unknown/release/copperline_web.wasm + test -s pkg/copperline_web_bg.wasm + - name: Check out the website repository + uses: actions/checkout@v4 + with: + repository: LinuxJedi/copperline.github.io + ssh-key: ${{ secrets.SITE_DEPLOY_KEY }} + path: site + - name: Replace the generated parts of /try + # The hand-written page shell must already exist in the site repo; + # refuse to publish a bundle onto a site that has no page for it. + run: | + test -f site/try/index.html + rm -rf site/try/pkg site/try/aros + mkdir -p site/try/pkg site/try/aros + cp crates/copperline-web/pkg/copperline_web.js \ + crates/copperline-web/pkg/copperline_web_bg.wasm site/try/pkg/ + cp crates/copperline-web/www/try.js \ + crates/copperline-web/www/audio-worklet.js site/try/ + cp assets/aros/aros-amiga-m68k-rom.bin \ + assets/aros/aros-amiga-m68k-ext.bin \ + assets/aros/LICENSE assets/aros/ACKNOWLEDGEMENTS site/try/aros/ + - name: Commit and push + working-directory: site + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A try + if git diff --cached --quiet; then + echo "Browser demo is unchanged; nothing to publish." + exit 0 + fi + git commit -m "Publish the browser demo for ${{ github.ref_name }}" + git push diff --git a/Cargo.lock b/Cargo.lock index bee1d400..eed1e185 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -498,6 +498,7 @@ dependencies = [ "toml", "wasmtime", "wat", + "web-time", "winit", "zerocopy", ] diff --git a/Cargo.toml b/Cargo.toml index bb7df70e..e4d1d3f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,9 +10,12 @@ repository = "https://github.com/LinuxJedi/Copperline" keywords = ["amiga", "emulator", "m68k"] categories = ["emulators"] publish = false +# `cargo run` should keep launching the emulator now that the headless +# `copperline-bench` bin target exists. +default-run = "copperline" [features] -default = ["midi"] +default = ["midi", "frontend", "wasm-boards"] display-plan-trace = [] # Local investigation switches that deliberately alter timing or rendering. # Normal builds keep these environment-variable overrides inert so release @@ -24,6 +27,28 @@ internal-diagnostics = [] # CoreMIDI (macOS), the ALSA sequencer (Linux), WinMM (Windows); other targets # get a stub. See src/midi/. midi = [] +# The interactive desktop frontend: the winit/pixels window and launcher, +# host audio output, gamepads, file dialogs, and the debugger console's +# clipboard paste. Off, the crate is the portable headless core (the surface +# a wasm32 browser frontend builds against); the `copperline` binary +# requires it. +frontend = [ + "dep:winit", + "dep:pixels", + "dep:cpal", + "dep:gilrs", + "dep:rfd", + "dep:arboard", + "dep:env_logger", +] +# The wasmtime host for functional Zorro boards (src/wasmboard.rs). Separate +# from `frontend` because it is a core-machine feature, but Cranelift cannot +# be compiled FOR wasm32, so browser builds turn it off. +wasm-boards = ["dep:wasmtime"] +# The headless `copperline-bench` benchmark binary (core-only; also builds +# for wasm32-wasip1). Off by default so native release artifacts are +# unchanged. +bench-bin = [] [dependencies] # Path-only dependency on the in-tree fork (crates/m68k). No version requirement @@ -31,32 +56,34 @@ midi = [] # always used and the upstream `m68k` crate can never be pulled in; aliased back # to `m68k` so source keeps `use m68k::...`. See crates/m68k/Cargo.toml. m68k = { package = "copperline-m68k", path = "crates/m68k" } -pixels = "0.17" -winit = "0.30" +pixels = { version = "0.17", optional = true } +winit = { version = "0.30", optional = true } anyhow = "1" log = "0.4" -env_logger = "0.11" +env_logger = { version = "0.11", optional = true } serde = { version = "1", features = ["derive"] } serde-big-array = "0.5" bincode = "1" toml = "0.8" png = "0.17" flate2 = "1" -cpal = "0.15" +cpal = { version = "0.15", optional = true } +# Kept unconditional (not `frontend`-gated) because the Windows MIDI backend +# (src/midi/winmm.rs) also uses it; it is pure Rust and wasm-safe. ringbuf = "0.4" hound = "3.5" -rfd = "0.17.2" -gilrs = "0.11" +rfd = { version = "0.17.2", optional = true } +gilrs = { version = "0.11", optional = true } # Host clipboard for the debugger console's paste (text only; the default # image-data feature is left off). wayland-data-control covers Wayland # hosts; X11 support is built in on Linux. -arboard = { version = "3", default-features = false, features = ["wayland-data-control"] } +arboard = { version = "3", default-features = false, features = ["wayland-data-control"], optional = true } # WASM plugin host for functional Zorro boards (src/wasmboard.rs). Lean feature # set: the Cranelift JIT + runtime only -- no WASI, component model, GC, threads, # or wat parsing in the shipped binary (kept deterministic; see the determinism # Config in wasmboard.rs). Pin the version: save-state replay of a plugin's # linear-memory snapshot is only guaranteed within one wasmtime build. -wasmtime = { version = "=27.0.0", default-features = false, features = ["cranelift", "runtime"] } +wasmtime = { version = "=27.0.0", default-features = false, features = ["cranelift", "runtime"], optional = true } # Already pulled in transitively; used directly only for localtime_r (local # time-zone filename stamps) and, on macOS, the pthread QoS class used for the # optional realtime-priority feature (see src/priority.rs). @@ -74,10 +101,27 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["NSD # Optional realtime-priority feature: cross-platform thread scheduling control. # Only used off macOS -- the macOS path uses the libc pthread QoS API directly # (Core Audio already runs the audio callback real-time), so this crate (and -# the windows-sys it pulls in) never enters the macOS build. -[target.'cfg(not(target_os = "macos"))'.dependencies] +# the windows-sys it pulls in) never enters the macOS build. Excluded on +# wasm32, where there are no host threads to schedule. +[target.'cfg(not(any(target_os = "macos", target_arch = "wasm32")))'.dependencies] thread-priority = "3.1" +# Browser builds only: std::time::Instant/SystemTime panic on +# wasm32-unknown-unknown; web-time backs them with performance.now()/Date.now(). +# See src/timebase.rs -- native targets re-export std::time unchanged. +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +web-time = "1" + +[[bin]] +name = "copperline" +path = "src/main.rs" +required-features = ["frontend"] + +[[bin]] +name = "copperline-bench" +path = "src/bin/bench.rs" +required-features = ["bench-bin"] + [dev-dependencies] # Compile WAT to wasm bytes in the WASM-plugin-host tests, so golden test # plugins live as readable text in the tests rather than checked-in binaries. diff --git a/README.md b/README.md index 2dd67ba0..6dad212a 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,12 @@ against real hardware. interactive chip-bus frame analyzer, remote GDB support, deterministic save states, input recording/replay, and headless screenshot/frame-dump capture -- the deterministic core makes every replay byte-identical. +- **A browser build**: the same core compiled to WebAssembly with a + canvas/Web Audio frontend, hosted at + [copperline.dev/try](https://copperline.dev/try/) -- boots the bundled + AROS ROM, takes your own Kickstart and disk images, and runs entirely + client-side. See `docs/guide/browser.md` for how it works and how to + embed it. ## Requirements diff --git a/crates/copperline-web/.gitignore b/crates/copperline-web/.gitignore new file mode 100644 index 00000000..54954ced --- /dev/null +++ b/crates/copperline-web/.gitignore @@ -0,0 +1,2 @@ +/target +/pkg diff --git a/crates/copperline-web/Cargo.lock b/crates/copperline-web/Cargo.lock new file mode 100644 index 00000000..846123ee --- /dev/null +++ b/crates/copperline-web/Cargo.lock @@ -0,0 +1,705 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "console_log" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8aed40e4edbf4d3b4431ab260b63fdc40f5780a4766824329ea0f1eefe3c0f" +dependencies = [ + "log", + "web-sys", +] + +[[package]] +name = "copperline" +version = "0.11.0" +dependencies = [ + "anyhow", + "bincode", + "copperline-m68k", + "flate2", + "hound", + "libc", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "png", + "ringbuf", + "serde", + "serde-big-array", + "thread-priority", + "toml", + "web-time", + "zerocopy", +] + +[[package]] +name = "copperline-m68k" +version = "0.1.5" +dependencies = [ + "serde", +] + +[[package]] +name = "copperline-web" +version = "0.1.0" +dependencies = [ + "anyhow", + "console_error_panic_hook", + "console_log", + "copperline", + "log", + "wasm-bindgen", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "objc2", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ringbuf" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe47b720588c8702e34b5979cb3271a8b1842c7cb6f57408efa70c779363488c" +dependencies = [ + "crossbeam-utils", + "portable-atomic", + "portable-atomic-util", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thread-priority" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d2e834949be5111506bb252643498af1514f600d9e1dceedaa42afae155b67f" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "libc", + "log", + "rustversion", + "windows", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/crates/copperline-web/Cargo.toml b/crates/copperline-web/Cargo.toml new file mode 100644 index 00000000..a8d81e76 --- /dev/null +++ b/crates/copperline-web/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "copperline-web" +version = "0.1.0" +edition = "2021" +rust-version = "1.87" +license = "GPL-3.0-or-later" +description = "Browser (wasm32) frontend for the Copperline Amiga emulator" +publish = false + +# Standalone: deliberately not a member of any workspace, so building this +# crate never changes the root package's lockfile or native artifacts. +[workspace] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +# The headless core only: no winit/pixels/cpal/wasmtime. +copperline = { path = "../..", default-features = false } +# Pinned exactly: the wasm-bindgen CLI that post-processes the build must be +# the same version (installed via homebrew). +wasm-bindgen = "=0.2.126" +anyhow = "1" +log = "0.4" +console_log = "1" +console_error_panic_hook = "0.1" + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 diff --git a/crates/copperline-web/src/lib.rs b/crates/copperline-web/src/lib.rs new file mode 100644 index 00000000..debfd2f8 --- /dev/null +++ b/crates/copperline-web/src/lib.rs @@ -0,0 +1,402 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Browser frontend for Copperline: a thin wasm-bindgen wrapper around the +//! headless core. The page's JS drives everything: it fetches ROM bytes, +//! constructs a [`WebEmu`], calls [`WebEmu::run`] from requestAnimationFrame, +//! blits the presentation buffer to a canvas via ImageData, forwards +//! keyboard/mouse events, and ships each frame's mixed audio to an +//! AudioWorklet. No winit, wgpu, or cpal: the canvas is the display and the +//! Web Audio API is the sound device, so the wasm stays small and +//! single-threaded (GitHub Pages cannot serve the COOP/COEP headers that +//! SharedArrayBuffer builds need). + +use std::cell::RefCell; +use std::path::PathBuf; +use std::rc::Rc; + +use copperline::audio::AudioSink; +use copperline::config::{Config, Overscan}; +use copperline::emulator::{build_machine, Emulator}; +use copperline::video::deinterlace::Deinterlacer; +use copperline::video::{bitplane, present_common, FB_WIDTH, MAX_FB_PIXELS}; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(start)] +pub fn start() { + console_error_panic_hook::set_once(); + let _ = console_log::init_with_level(log::Level::Info); +} + +/// Collects Paula's mixed 44.1 kHz stereo output as interleaved f32 frames; +/// the page drains it once per animation frame with [`WebEmu::take_audio`] +/// and posts the chunk to the AudioWorklet. +struct WebAudioSink { + buf: Rc>>, +} + +impl AudioSink for WebAudioSink { + fn push(&mut self, left: f32, right: f32) { + let mut buf = self.buf.borrow_mut(); + buf.push(left); + buf.push(right); + } + fn flush(&mut self) {} +} + +fn js_err(e: anyhow::Error) -> JsValue { + JsValue::from_str(&format!("{e:#}")) +} + +/// Translate a W3C `KeyboardEvent.code` string to an Amiga raw scan code. +/// The table mirrors the desktop frontend's winit mapping +/// (`video/window/host_input.rs`); winit's `KeyCode` variant names are the +/// W3C code strings, so the two stay in lockstep by construction. +fn w3c_code_to_amiga_rawkey(code: &str) -> Option { + Some(match code { + // Letters (row-by-row, Amiga's funny layout) + "KeyA" => 0x20, + "KeyB" => 0x35, + "KeyC" => 0x33, + "KeyD" => 0x22, + "KeyE" => 0x12, + "KeyF" => 0x23, + "KeyG" => 0x24, + "KeyH" => 0x25, + "KeyI" => 0x17, + "KeyJ" => 0x26, + "KeyK" => 0x27, + "KeyL" => 0x28, + "KeyM" => 0x37, + "KeyN" => 0x36, + "KeyO" => 0x18, + "KeyP" => 0x19, + "KeyQ" => 0x10, + "KeyR" => 0x13, + "KeyS" => 0x21, + "KeyT" => 0x14, + "KeyU" => 0x16, + "KeyV" => 0x34, + "KeyW" => 0x11, + "KeyX" => 0x32, + "KeyY" => 0x15, + "KeyZ" => 0x31, + // Top-row digits + "Digit1" => 0x01, + "Digit2" => 0x02, + "Digit3" => 0x03, + "Digit4" => 0x04, + "Digit5" => 0x05, + "Digit6" => 0x06, + "Digit7" => 0x07, + "Digit8" => 0x08, + "Digit9" => 0x09, + "Digit0" => 0x0A, + // Punctuation + "Backquote" => 0x00, + "Minus" => 0x0B, + "Equal" => 0x0C, + "Backslash" => 0x0D, + "BracketLeft" => 0x1A, + "BracketRight" => 0x1B, + "Semicolon" => 0x29, + "Quote" => 0x2A, + "Comma" => 0x38, + "Period" => 0x39, + "Slash" => 0x3A, + // International keys: the ISO 102nd key between left Shift and Z is + // Amiga rawkey $30; the Japanese Ro key sits in the same matrix + // position on layouts that have it. + "IntlBackslash" | "IntlRo" => 0x30, + // Control + "Space" => 0x40, + "Enter" => 0x44, + "Backspace" => 0x41, + "Tab" => 0x42, + "Escape" => 0x45, + "Delete" => 0x46, + // Amiga Help: F11 host-side (no dedicated host key exists). + "F11" => 0x5F, + "ShiftLeft" => 0x60, + "ShiftRight" => 0x61, + "CapsLock" => 0x62, + // Single Ctrl key on the Amiga; right Ctrl doubles as Right Amiga + // alongside the right Super/Meta key (see host_input.rs). + "ControlLeft" => 0x63, + "AltLeft" => 0x64, + "AltRight" => 0x65, + "MetaLeft" | "OSLeft" => 0x66, + "MetaRight" | "OSRight" | "ControlRight" => 0x67, + // Arrows + "ArrowUp" => 0x4C, + "ArrowDown" => 0x4D, + "ArrowRight" => 0x4E, + "ArrowLeft" => 0x4F, + // Function keys + "F1" => 0x50, + "F2" => 0x51, + "F3" => 0x52, + "F4" => 0x53, + "F5" => 0x54, + "F6" => 0x55, + "F7" => 0x56, + "F8" => 0x57, + "F9" => 0x58, + "F10" => 0x59, + // Numpad + "Numpad0" => 0x0F, + "Numpad1" => 0x1D, + "Numpad2" => 0x1E, + "Numpad3" => 0x1F, + "Numpad4" => 0x2D, + "Numpad5" => 0x2E, + "Numpad6" => 0x2F, + "Numpad7" => 0x3D, + "Numpad8" => 0x3E, + "Numpad9" => 0x3F, + "NumpadDecimal" => 0x3C, + "NumpadEnter" => 0x43, + "NumpadSubtract" => 0x4A, + "NumpadAdd" => 0x5E, + "NumpadMultiply" => 0x5D, + "NumpadDivide" => 0x5C, + "NumpadParenLeft" => 0x5A, + "NumpadParenRight" => 0x5B, + _ => return None, + }) +} + +/// Mirrors the desktop frontend's fractional mouse-delta accumulator +/// (`take_integral_mouse_delta` in window/present.rs): whole pixels go to the +/// emulated mouse, the fraction carries to the next event. +fn take_integral_delta(value: &mut f64) -> i32 { + let whole = value.trunc(); + if whole > i32::MAX as f64 { + *value = 0.0; + i32::MAX + } else if whole < i32::MIN as f64 { + *value = 0.0; + i32::MIN + } else { + *value -= whole; + whole as i32 + } +} + +/// How far the emulated clock may fall behind the wall clock before `run` +/// gives up catching up and re-anchors instead (tab was backgrounded, a GC +/// pause, ...). Mirrors the native pacer's `MAX_REALTIME_CATCHUP`. +const MAX_CATCHUP_SECONDS: f64 = 0.1; + +#[wasm_bindgen] +pub struct WebEmu { + emu: Emulator, + audio: Rc>>, + fb: Vec, + deinterlacer: Deinterlacer, + present: Vec, + present_rows: usize, + last_rendered_frame: Option, + /// Wall-clock/emulated-time pair the pacer chases from; None until the + /// first `run` call after (re)boot. + anchor: Option<(f64, f64)>, + mouse_remainder: (f64, f64), +} + +#[wasm_bindgen] +impl WebEmu { + /// Build the default machine (the A500 AROS profile of the desktop + /// launcher) with a placeholder ROM; `load_rom` supplies the real one. + #[wasm_bindgen(constructor)] + pub fn new() -> Result { + let cfg = Config::default(); + let audio = Rc::new(RefCell::new(Vec::new())); + let sink = WebAudioSink { buf: audio.clone() }; + // rom_optional: the default rom_path names the bundled AROS file, + // which does not exist in the browser; build with a placeholder. + let emu = build_machine(&cfg, Box::new(sink), false, true).map_err(js_err)?; + Ok(WebEmu { + emu, + audio, + fb: vec![0u32; MAX_FB_PIXELS], + deinterlacer: Deinterlacer::new(), + present: Vec::new(), + present_rows: 0, + last_rendered_frame: None, + anchor: None, + mouse_remainder: (0.0, 0.0), + }) + } + + /// Fit a Kickstart/AROS ROM (and optional extended ROM) from bytes and + /// cold-reset, as if the chips had been swapped and the machine power + /// cycled. 256 KiB Kickstart 1.x images are mirrored up automatically. + pub fn load_rom(&mut self, rom: Vec, ext: Option>) -> Result<(), JsValue> { + self.emu.reload_rom(rom, ext).map_err(js_err)?; + self.anchor = None; + Ok(()) + } + + /// Step emulated time up to the wall clock (`now_ms` is + /// `performance.now()`), at most `max_frames` PAL frames per call, then + /// render the latest completed frame into the presentation buffer. + /// Returns the number of frames stepped. Deficits past 100 ms are + /// forgiven by re-anchoring, so a backgrounded tab resumes at real time + /// instead of fast-forwarding. + pub fn run(&mut self, now_ms: f64, max_frames: u32) -> Result { + let (anchor_wall, anchor_emu) = *self + .anchor + .get_or_insert((now_ms, self.emu.bus().emulated_seconds())); + let target = anchor_emu + (now_ms - anchor_wall) / 1000.0; + let mut stepped = 0u32; + while self.emu.bus().emulated_seconds() < target && stepped < max_frames { + self.emu.step_frame().map_err(js_err)?; + stepped += 1; + } + if target - self.emu.bus().emulated_seconds() > MAX_CATCHUP_SECONDS { + self.anchor = Some((now_ms, self.emu.bus().emulated_seconds())); + } + if stepped > 0 { + self.render_completed_frame(); + } + Ok(stepped) + } + + /// The desktop sync render path (`render_emulated_frame_sync`) against + /// the shared present_common helpers: render the completed hardware + /// frame, post-process, deinterlace, and copy out the woven rows. + fn render_completed_frame(&mut self) { + if !self.emu.bus().frame_render_available() { + return; + } + let emulated_frame = self.emu.bus().emulated_frames(); + if !present_common::should_render_emulated_frame(self.last_rendered_frame, emulated_frame) { + return; + } + let visible_start_vpos = self.emu.bus().frame_visible_start_vpos(); + bitplane::render(self.emu.bus_mut(), &mut self.fb); + let geometry = self.emu.bus().frame_geometry(); + let field_rows = present_common::post_process_rendered_field( + &mut self.fb, + geometry, + visible_start_vpos, + 0, + Overscan::Tv, + ); + let base = self.emu.bus().frame_render_base(); + self.deinterlacer.push_field( + &self.fb, + field_rows, + base.bplcon0 & 0x0004 != 0, + base.long_field, + !geometry.programmable, + ); + self.present_rows = self.deinterlacer.output_rows(); + let active = self.present_rows * FB_WIDTH; + self.present.resize(active, 0); + self.present + .copy_from_slice(&self.deinterlacer.output()[..active]); + self.last_rendered_frame = Some(emulated_frame); + } + + /// Presentation buffer: RGBA bytes in memory order, `present_width() x + /// present_rows()` pixels, directly viewable as canvas ImageData. The + /// pointer is only valid until the next `run` call (the buffer may + /// reallocate and wasm memory may grow), so JS must re-create its view + /// every frame. + pub fn present_ptr(&self) -> *const u32 { + self.present.as_ptr() + } + + pub fn present_rows(&self) -> u32 { + self.present_rows as u32 + } + + pub fn present_width(&self) -> u32 { + FB_WIDTH as u32 + } + + /// Drain the mixed audio: interleaved stereo f32 at 44.1 kHz, one PAL + /// frame is 882 stereo frames. The page transfers the returned buffer to + /// the AudioWorklet. + pub fn take_audio(&mut self) -> Vec { + std::mem::take(&mut *self.audio.borrow_mut()) + } + + /// Queued audio frames not yet drained (diagnostics). + pub fn audio_pending(&self) -> u32 { + (self.audio.borrow().len() / 2) as u32 + } + + /// Forward a keyboard event; `code` is `KeyboardEvent.code`. Returns + /// true when the key maps to an Amiga key (the page then calls + /// preventDefault). + pub fn key_event(&mut self, code: &str, pressed: bool) -> bool { + match w3c_code_to_amiga_rawkey(code) { + Some(rawkey) => { + self.emu.bus_mut().enqueue_key_event(rawkey, pressed); + true + } + None => false, + } + } + + /// Relative mouse motion in emulated hi-res pixels (pointer-lock + /// movementX/Y, or scaled cursor deltas when unlocked). + pub fn mouse_delta(&mut self, dx: f64, dy: f64) { + if !dx.is_finite() || !dy.is_finite() { + return; + } + self.mouse_remainder.0 += dx; + self.mouse_remainder.1 += dy; + let ix = take_integral_delta(&mut self.mouse_remainder.0); + let iy = take_integral_delta(&mut self.mouse_remainder.1); + if ix != 0 || iy != 0 { + self.emu.bus_mut().input.add_mouse_delta_port1(ix, iy); + } + } + + /// Mouse buttons: 0 = left, 1 = middle, 2 = right (MouseEvent.button). + pub fn mouse_button(&mut self, button: u8, pressed: bool) { + let input = &mut self.emu.bus_mut().input; + match button { + 0 => input.lmb_port1 = pressed, + 1 => input.mmb_port1 = pressed, + 2 => input.rmb_port1 = pressed, + _ => {} + } + } + + /// Insert a floppy image (ADF/ADZ/DMS/extended ADF, optionally + /// gzip/zip-packed) from bytes. Always write-protected: the browser has + /// nowhere to write changes back to. + pub fn insert_floppy(&mut self, drive: u8, bytes: Vec, name: &str) -> Result<(), JsValue> { + self.emu + .bus_mut() + .floppy + .insert_disk_image_bytes(drive as usize, bytes, PathBuf::from(name), true) + .map_err(js_err) + } + + pub fn eject_floppy(&mut self, drive: u8) -> Result<(), JsValue> { + self.emu + .bus_mut() + .floppy + .eject_disk_image(drive as usize) + .map_err(js_err) + } + + /// Cold reset (power cycle), keeping the fitted ROM and inserted disks. + pub fn reset(&mut self) -> Result<(), JsValue> { + self.emu.power_on_reset().map_err(js_err)?; + self.anchor = None; + Ok(()) + } + + pub fn set_volume_percent(&mut self, percent: u8) { + self.emu.bus_mut().set_output_volume_percent(percent); + } + + pub fn emulated_seconds(&self) -> f64 { + self.emu.bus().emulated_seconds() + } +} diff --git a/crates/copperline-web/www/audio-worklet.js b/crates/copperline-web/www/audio-worklet.js new file mode 100644 index 00000000..9d6de15c --- /dev/null +++ b/crates/copperline-web/www/audio-worklet.js @@ -0,0 +1,78 @@ +// Source of truth for the copperline.dev/try page glue: published to the +// website repository by .github/workflows/wasm-demo.yml alongside the wasm +// bundle, so this JS and the WebEmu API always change together. +// Copperline audio worklet: receives interleaved stereo f32 chunks (44.1 kHz) +// from the main thread via postMessage and plays them back in 128-frame +// quanta. Single-threaded pipeline: no SharedArrayBuffer, just transferred +// buffers. Underruns emit silence; a queue past MAX_QUEUE_FRAMES drops the +// oldest chunks (timeline jump or a backgrounded tab). Queue depth is +// reported back every ~10 quanta so the pacer can trim drift. + +const MAX_QUEUE_FRAMES = 11025; // ~250 ms at 44.1 kHz +const PREBUFFER_FRAMES = 2646; // ~60 ms: gate playback until this much queued + +class CopperlineAudio extends AudioWorkletProcessor { + constructor() { + super(); + this.chunks = []; + this.offset = 0; // read offset (in floats) into chunks[0] + this.queuedFrames = 0; + this.underruns = 0; + this.quanta = 0; + this.prebuffering = true; + this.port.onmessage = (e) => { + const chunk = e.data; + if (!(chunk instanceof Float32Array) || chunk.length < 2) return; + this.chunks.push(chunk); + this.queuedFrames += chunk.length >> 1; + while (this.queuedFrames > MAX_QUEUE_FRAMES && this.chunks.length > 1) { + const dropped = this.chunks.shift(); + this.queuedFrames -= (dropped.length >> 1) - (this.offset >> 1); + this.offset = 0; + } + }; + } + + process(inputs, outputs) { + const left = outputs[0][0]; + const right = outputs[0].length > 1 ? outputs[0][1] : outputs[0][0]; + // After a start or a hard drain, hold silence until a small cushion is + // queued so playback does not stutter through the refill. + if (this.prebuffering) { + if (this.queuedFrames < PREBUFFER_FRAMES) return true; + this.prebuffering = false; + } + let i = 0; + while (i < left.length && this.chunks.length > 0) { + const chunk = this.chunks[0]; + while (i < left.length && this.offset < chunk.length) { + left[i] = chunk[this.offset]; + right[i] = chunk[this.offset + 1]; + this.offset += 2; + this.queuedFrames--; + i++; + } + if (this.offset >= chunk.length) { + this.chunks.shift(); + this.offset = 0; + } + } + if (i < left.length) { + this.underruns++; + this.prebuffering = true; + for (; i < left.length; i++) { + left[i] = 0; + right[i] = 0; + } + } + if (++this.quanta % 10 === 0) { + this.port.postMessage({ + queuedMs: (this.queuedFrames / sampleRate) * 1000, + underruns: this.underruns, + }); + } + return true; + } +} + +registerProcessor('copperline-audio', CopperlineAudio); diff --git a/crates/copperline-web/www/try.js b/crates/copperline-web/www/try.js new file mode 100644 index 00000000..f2de21ee --- /dev/null +++ b/crates/copperline-web/www/try.js @@ -0,0 +1,267 @@ +// Source of truth for the copperline.dev/try page glue: published to the +// website repository by .github/workflows/wasm-demo.yml alongside the wasm +// bundle, so this JS and the WebEmu API always change together. +// Copperline in the browser: page glue around the wasm build. +// Loads the emulator module and the AROS ROMs in parallel, boots on click +// (the click also unlocks the AudioContext), then runs one +// requestAnimationFrame loop: step the core to the wall clock, blit the +// presentation buffer to the canvas, and post the frame's audio to the +// worklet. Everything is served from this site - no external requests. + +import init, { WebEmu } from './pkg/copperline_web.js'; + +const $ = (id) => document.getElementById(id); +const canvas = $('screen'); +const ctx2d = canvas.getContext('2d'); +const overlay = $('overlay'); +const bootBtn = $('boot'); +const loadStatus = $('load-status'); +const statLine = $('stat'); + +const FB_W = 716; + +let wasm = null; +let emu = null; +let audioCtx = null; +let audioNode = null; +let queuedMs = 0; +let running = false; +let framesThisSecond = 0; +let lastStatUpdate = 0; + +function setLoadStatus(text) { + loadStatus.textContent = text; +} + +async function fetchBytes(url, label) { + const resp = await fetch(url); + if (!resp.ok) throw new Error(`${label}: HTTP ${resp.status}`); + return new Uint8Array(await resp.arrayBuffer()); +} + +// --- loading ------------------------------------------------------------- + +let romBytes = null; +let extBytes = null; + +async function load() { + try { + setLoadStatus('loading emulator + AROS ROMs...'); + const [wasmExports, rom, ext] = await Promise.all([ + init(), + fetchBytes('./aros/aros-amiga-m68k-rom.bin', 'AROS ROM'), + fetchBytes('./aros/aros-amiga-m68k-ext.bin', 'AROS extended ROM'), + ]); + wasm = wasmExports; + romBytes = rom; + extBytes = ext; + setLoadStatus('ready - boots the open-source AROS ROM'); + bootBtn.disabled = false; + bootBtn.focus(); + } catch (e) { + setLoadStatus(`failed to load: ${e.message ?? e}`); + console.error(e); + } +} + +// --- boot ---------------------------------------------------------------- + +async function boot() { + bootBtn.disabled = true; + try { + audioCtx = new AudioContext({ sampleRate: 44100 }); + await audioCtx.audioWorklet.addModule('./audio-worklet.js'); + audioNode = new AudioWorkletNode(audioCtx, 'copperline-audio', { + outputChannelCount: [2], + }); + audioNode.port.onmessage = (e) => { + if (typeof e.data?.queuedMs === 'number') queuedMs = e.data.queuedMs; + }; + audioNode.connect(audioCtx.destination); + await audioCtx.resume(); + + emu = new WebEmu(); + emu.load_rom(romBytes, extBytes); + emu.set_volume_percent(Number($('vol').value)); + window.__emu = emu; // for debugging/automation + + overlay.style.display = 'none'; + running = true; + requestAnimationFrame(tick); + } catch (e) { + setLoadStatus(`boot failed: ${e.message ?? e}`); + bootBtn.disabled = false; + console.error(e); + } +} + +// --- main loop ----------------------------------------------------------- + +function maxFramesForQueue() { + // The audio clock is the master: when the worklet has plenty queued, skip + // stepping this tick (the pacer forgives deficits past 100 ms, so this + // locks production to the audio device's consumption rate). Otherwise step + // freely to the wall clock - the burst cap only bounds a single tick's + // catch-up work after rAF throttling. + return queuedMs > 150 ? 0 : 5; +} + +function tick(nowMs) { + if (!running) return; + try { + framesThisSecond += emu.run(nowMs, maxFramesForQueue()); + } catch (e) { + running = false; + setLoadStatus(`emulator error: ${e.message ?? e}`); + overlay.style.display = ''; + console.error(e); + return; + } + + const rows = emu.present_rows(); + if (rows > 0) { + if (canvas.height !== rows) { + canvas.width = FB_W; + canvas.height = rows; + } + // The view must be rebuilt every frame: wasm memory may grow and the + // present buffer may reallocate. + const view = new Uint8ClampedArray( + wasm.memory.buffer, + emu.present_ptr(), + FB_W * rows * 4, + ); + ctx2d.putImageData(new ImageData(view, FB_W, rows), 0, 0); + } + + const audio = emu.take_audio(); + if (audio.length > 0 && audioNode) { + audioNode.port.postMessage(audio, [audio.buffer]); + } + + if (nowMs - lastStatUpdate >= 1000) { + statLine.textContent = + `${framesThisSecond} fps | ` + + `${emu.emulated_seconds().toFixed(1)}s emulated | ` + + `audio ${queuedMs.toFixed(0)} ms`; + framesThisSecond = 0; + lastStatUpdate = nowMs; + } + requestAnimationFrame(tick); +} + +document.addEventListener('visibilitychange', () => { + if (!audioCtx) return; + if (document.hidden) audioCtx.suspend(); + else audioCtx.resume(); +}); + +// --- keyboard ------------------------------------------------------------ + +window.addEventListener('keydown', (e) => { + if (!emu || !running || e.repeat) return; + if (emu.key_event(e.code, true)) e.preventDefault(); +}); +window.addEventListener('keyup', (e) => { + if (!emu || !running) return; + if (emu.key_event(e.code, false)) e.preventDefault(); +}); + +// --- mouse --------------------------------------------------------------- +// Unlocked: the cursor drives the Amiga pointer through position deltas +// (Workbench-friendly). Click to pointer-lock for relative motion (games); +// Esc releases the lock, as the browser enforces. + +let lastPos = null; +const cssToEmu = () => FB_W / canvas.clientWidth; + +canvas.addEventListener('mousedown', (e) => { + if (!emu || !running) return; + e.preventDefault(); + if (document.pointerLockElement !== canvas && e.button === 0) { + canvas.requestPointerLock(); + } + emu.mouse_button(e.button, true); +}); +window.addEventListener('mouseup', (e) => { + if (!emu || !running) return; + emu.mouse_button(e.button, false); +}); +canvas.addEventListener('contextmenu', (e) => e.preventDefault()); +window.addEventListener('mousemove', (e) => { + if (!emu || !running) return; + const scale = cssToEmu(); + if (document.pointerLockElement === canvas) { + emu.mouse_delta(e.movementX * scale, e.movementY * scale); + lastPos = null; + } else if (e.target === canvas) { + if (lastPos) { + emu.mouse_delta((e.clientX - lastPos.x) * scale, (e.clientY - lastPos.y) * scale); + } + lastPos = { x: e.clientX, y: e.clientY }; + } else { + lastPos = null; + } +}); +document.addEventListener('pointerlockchange', () => { + lastPos = null; +}); + +// --- controls ------------------------------------------------------------ + +$('df0').addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file || !emu) return; + try { + const bytes = new Uint8Array(await file.arrayBuffer()); + emu.insert_floppy(0, bytes, file.name); + setLoadStatus(`DF0: ${file.name} (write-protected)`); + } catch (err) { + setLoadStatus(`insert failed: ${err.message ?? err}`); + } + e.target.value = ''; +}); + +$('kick').addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file || !emu) return; + try { + const bytes = new Uint8Array(await file.arrayBuffer()); + emu.load_rom(bytes, undefined); + setLoadStatus(`Kickstart loaded: ${file.name} - machine power-cycled`); + } catch (err) { + setLoadStatus(`ROM load failed: ${err.message ?? err}`); + } + e.target.value = ''; +}); + +$('eject').addEventListener('click', () => { + if (!emu) return; + try { + emu.eject_floppy(0); + setLoadStatus('DF0 ejected'); + } catch (err) { + setLoadStatus(`${err.message ?? err}`); + } +}); + +$('reset').addEventListener('click', () => { + if (!emu) return; + try { + emu.reset(); + setLoadStatus('machine reset'); + } catch (err) { + setLoadStatus(`reset failed: ${err.message ?? err}`); + } +}); + +$('fullscreen').addEventListener('click', () => { + $('shell').requestFullscreen?.(); +}); + +$('vol').addEventListener('input', (e) => { + if (emu) emu.set_volume_percent(Number(e.target.value)); +}); + +bootBtn.addEventListener('click', boot); +load(); diff --git a/docs/guide/browser.md b/docs/guide/browser.md new file mode 100644 index 00000000..281b4c65 --- /dev/null +++ b/docs/guide/browser.md @@ -0,0 +1,156 @@ +# The browser build + +Copperline runs in a browser: the same deterministic core, compiled to +WebAssembly with a thin canvas/Web Audio frontend instead of the desktop +window. A hosted build lives on the website at +[copperline.dev](https://copperline.dev/) under `/try`; this page explains +how it is put together, how to build and run it locally, and how to embed +the emulator in your own page. + +## How it is put together + +The crate is split by cargo features so the core carries no desktop +dependencies: + +- **`frontend`** (default) -- the winit/pixels window, launcher and UI, cpal + audio output, gamepads, file dialogs, and clipboard. With the feature off, + the library is the portable headless core plus the pure presentation + helpers (`video::present_common`), which is the surface every alternative + frontend builds against. +- **`wasm-boards`** (default) -- the wasmtime host for + [functional Zorro board plugins](../zorro.md). Wasmtime's JIT cannot be + compiled *to* wasm32, so browser builds turn it off; plugin boards are a + desktop-only feature. +- **`bench-bin`** -- the headless `copperline-bench` benchmark binary (see + [](#benchmarking-the-core-as-wasm)). + +`cargo check --no-default-features` is the portability invariant: the core +must always compile without the desktop stack (CI enforces this, along with +a `wasm32-unknown-unknown` check of the web crate). + +The browser frontend itself is `crates/copperline-web`, a small standalone +`cdylib` crate (deliberately not a workspace member, so building it never +touches the root lockfile). It wraps the core in a `WebEmu` class exported +through wasm-bindgen; the page's JavaScript drives everything from +`requestAnimationFrame`: + +- **Video**: the core's rendered frame is post-processed and deinterlaced by + the same code the desktop uses, then blitted to a `` with + `putImageData` -- the internal framebuffer is RGBA in memory order, so no + conversion happens. There is no wgpu in the build, which keeps the wasm + around 1.4 MiB (about 0.6 MiB over the wire). +- **Audio**: Paula's 44.1 kHz stereo mix is drained once per animation frame + and posted to an `AudioWorklet` as transferred `Float32Array` chunks. The + build is single threaded -- no SharedArrayBuffer, so no COOP/COEP headers + are needed and any static host (GitHub Pages included) can serve it. +- **Pacing**: each animation frame steps the core up to the wall clock, with + the audio queue as the master clock -- when the worklet reports more than + ~150 ms buffered, stepping pauses for a tick. Deficits past 100 ms (a + backgrounded tab, a GC pause) are forgiven rather than fast-forwarded, + mirroring the native pacer's re-anchor behaviour. +- **Input**: `KeyboardEvent.code` strings map to Amiga raw keycodes with the + same table as the desktop frontend (winit's `KeyCode` names *are* the W3C + code strings); the mouse uses Pointer Lock for relative motion, with a + cursor-following fallback when unlocked. + +The guest sees a stock machine: ROMs arrive as bytes +(`Emulator::reload_rom`), floppies as bytes +(`FloppyController::insert_disk_image_bytes`), and disks are always +write-protected because the browser has no filesystem to write changes back +to. + +## Building it locally + +Requirements: the `wasm32-unknown-unknown` target and a `wasm-bindgen` CLI +that exactly matches the version pinned in `crates/copperline-web/Cargo.toml` +(the CLI and the crate must never drift apart): + +```sh +rustup target add wasm32-unknown-unknown +cargo install wasm-bindgen-cli --version 0.2.126 --locked + +cd crates/copperline-web +cargo build --release --target wasm32-unknown-unknown +wasm-bindgen --target web --out-dir pkg \ + target/wasm32-unknown-unknown/release/copperline_web.wasm +``` + +`pkg/` then holds `copperline_web.js` (the ES module loader) and +`copperline_web_bg.wasm`. To run the hosted page against a local build, copy +those two files into the website's `try/pkg/` directory and serve the site +with any static server (`python3 -m http.server`); the page fetches the AROS +ROMs from `try/aros/` (copies of `assets/aros/`). AudioWorklet requires a +secure context, which `localhost` satisfies. + +Releases publish automatically: the `wasm-demo.yml` workflow rebuilds the +bundle on every `v*` tag and pushes it to the website repository, together +with `crates/copperline-web/www/try.js` and `www/audio-worklet.js` -- the +page glue lives in this repository precisely so it can never drift from the +`WebEmu` API it drives. + +## Embedding: the WebEmu API + +The exported surface is small; a minimal page is a canvas plus this: + +```js +import init, { WebEmu } from './pkg/copperline_web.js'; + +const wasm = await init(); +const emu = new WebEmu(); // default A500 machine, placeholder ROM +emu.load_rom(romBytes, extBytes); // Kickstart or AROS bytes; cold reset +emu.insert_floppy(0, adfBytes, 'game.adf'); + +function tick(nowMs) { + emu.run(nowMs, 5); // step to the wall clock, max 5 frames + const rows = emu.present_rows(); + if (rows > 0) { + const view = new Uint8ClampedArray( + wasm.memory.buffer, emu.present_ptr(), emu.present_width() * rows * 4); + ctx.putImageData(new ImageData(view, emu.present_width(), rows), 0, 0); + } + const audio = emu.take_audio(); // interleaved stereo f32 at 44.1 kHz + if (audio.length) worklet.port.postMessage(audio, [audio.buffer]); + requestAnimationFrame(tick); +} +``` + +Input goes through `key_event(event.code, pressed)` (returns whether the key +mapped, for `preventDefault`), `mouse_delta(dx, dy)` and +`mouse_button(button, pressed)`. `reset()` power-cycles, `eject_floppy(n)` +and `set_volume_percent(p)` do what they say, and `emulated_seconds()` +exposes the guest clock for diagnostics. The presentation pointer is only +valid until the next `run` call -- rebuild the typed-array view every frame, +because wasm memory can grow. + +`www/try.js` and `www/audio-worklet.js` are the reference implementation of +all of the above, including the audio drift control. + +(benchmarking-the-core-as-wasm)= +## Benchmarking the core as wasm + +Whether a machine holds real speed in a browser is a measurable question. +The `copperline-bench` binary builds for `wasm32-wasip1` (where `std` time +and file I/O work natively) and runs under Node's WASI, whose V8 is the same +engine Chrome uses: + +```sh +rustup target add wasm32-wasip1 +cargo build --release --target wasm32-wasip1 \ + --no-default-features --features bench-bin --bin copperline-bench + +node tools/wasi-bench.mjs \ + target/wasm32-wasip1/release/copperline-bench.wasm \ + --rom /work/assets/aros/aros-amiga-m68k-rom.bin \ + --ext /work/assets/aros/aros-amiga-m68k-ext.bin \ + --seconds 30 --render +``` + +`--render` includes the full per-frame presentation pipeline (render, +post-process, deinterlace), which is what an interactive frontend pays; the +report shows the realtime factor and the frame-time distribution against the +20 ms PAL budget. The same binary builds natively for a +direct wasm-versus-native comparison on identical workloads -- the render +checksums match between the two, which is the determinism contract doing its +job. As a reference point, on an Apple-Silicon laptop the wasm build ran the +default AROS machine at 6.4x realtime and a Copper/blitter-heavy OCS demo at +2.7x, roughly 1.3-1.5x slower than native. diff --git a/docs/myst.yml b/docs/myst.yml index 54e4d800..ec833406 100644 --- a/docs/myst.yml +++ b/docs/myst.yml @@ -19,6 +19,7 @@ project: - file: guide/configuration.md - file: guide/ui.md - file: guide/headless.md + - file: guide/browser.md - title: Extending children: - file: zorro.md @@ -50,6 +51,7 @@ project: - guide/configuration.md - guide/ui.md - guide/headless.md + - guide/browser.md - zorro.md - debugger/window.md - debugger/console.md diff --git a/src/audio.rs b/src/audio.rs index e01e2054..04852c39 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -5,24 +5,32 @@ //! plus several concrete implementations chosen at startup time based //! on CLI flags. +#[cfg(feature = "frontend")] +use crate::timebase::Instant; use std::fs::File; use std::io::BufWriter; use std::path::Path; +#[cfg(feature = "frontend")] use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +#[cfg(feature = "frontend")] use std::sync::Arc; -use std::time::Instant; use anyhow::{anyhow, Result}; +#[cfg(feature = "frontend")] use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +#[cfg(feature = "frontend")] use ringbuf::traits::{Consumer, Observer, Producer, Split}; +#[cfg(feature = "frontend")] use ringbuf::HeapRb; /// Sample rate the mixer feeds the sink at. This is the rate the /// emulator-side stereo mixer (Paula::tick_audio) runs at; live CPAL /// output resamples these frames to the selected device rate. pub const MIX_SAMPLE_RATE: u32 = 44_100; +#[cfg(feature = "frontend")] const PAL_PAULA_CLOCK_HZ: u32 = 3_546_895; const AUDIO_PROFILE_ENV: &str = "COPPERLINE_AUDIO_PROFILE"; +#[cfg(feature = "frontend")] const CPAL_BUFFER_FRAMES: usize = 131072; // Live-output latency budget. The steady-state target is deliberately fixed: // the emulator's real-time pacer runs the core ahead of the wall clock by @@ -31,8 +39,11 @@ const CPAL_BUFFER_FRAMES: usize = 131072; // drains an already-started queue below target, the sink reports the shortfall // as extra temporary lead so the pacer refills the fixed cushion instead of // settling into a fragile low-latency state. +#[cfg(feature = "frontend")] const CPAL_TARGET_BUFFER_FRAMES: usize = 6615; // ~150 ms steady lead +#[cfg(feature = "frontend")] const CPAL_PREBUFFER_FRAMES: usize = CPAL_TARGET_BUFFER_FRAMES; +#[cfg(feature = "frontend")] const CPAL_STALE_DROP_THRESHOLD_FRAMES: usize = 13230; // trim only past ~300 ms #[derive(Clone, Copy, Debug, Default, PartialEq)] @@ -112,6 +123,7 @@ impl AudioSink for NullSink { // current Paula state instead of playing a stale backlog. // ----------------------------------------------------------------- +#[cfg(feature = "frontend")] pub struct CpalSink { producer: ringbuf::HeapProd<(f32, f32)>, // Keep the stream alive for the lifetime of the sink. @@ -142,7 +154,7 @@ pub struct CpalSink { // ...`). A handler that ignores the trailing varargs is safe to install under // the C calling convention (the caller cleans the stack), so it is declared // without them. -#[cfg(target_os = "linux")] +#[cfg(all(feature = "frontend", target_os = "linux"))] type AlsaErrorHandler = extern "C" fn( *const std::ffi::c_char, std::ffi::c_int, @@ -151,13 +163,13 @@ type AlsaErrorHandler = extern "C" fn( *const std::ffi::c_char, ); -#[cfg(target_os = "linux")] +#[cfg(all(feature = "frontend", target_os = "linux"))] #[link(name = "asound")] extern "C" { fn snd_lib_error_set_handler(handler: Option) -> std::ffi::c_int; } -#[cfg(target_os = "linux")] +#[cfg(all(feature = "frontend", target_os = "linux"))] extern "C" fn alsa_ignore_error( _file: *const std::ffi::c_char, _line: std::ffi::c_int, @@ -173,7 +185,7 @@ extern "C" fn alsa_ignore_error( /// errors through its `Result` API, so nothing user-facing is hidden. Installs /// a no-op error handler once, process-wide, keeping `--list-audio-devices` and /// the picker readable. No-op off Linux, where the handler does not exist. -#[cfg(target_os = "linux")] +#[cfg(all(feature = "frontend", target_os = "linux"))] fn quiet_alsa_probe_logging() { use std::sync::Once; static ONCE: Once = Once::new(); @@ -182,7 +194,7 @@ fn quiet_alsa_probe_logging() { }); } -#[cfg(not(target_os = "linux"))] +#[cfg(all(feature = "frontend", not(target_os = "linux")))] fn quiet_alsa_probe_logging() {} /// Whether an ALSA device name is a low-level *plugin* handle rather than a @@ -196,6 +208,7 @@ fn quiet_alsa_probe_logging() {} /// off any card or device name -- and hidden handles are still selectable by /// name in the config/CLI, since only the displayed list is filtered. A no-op on /// macOS/Windows, whose device names never take this form. +#[cfg(feature = "frontend")] fn is_alsa_plugin_variant(name: &str) -> bool { let plugin = name.split(':').next().unwrap_or(name); matches!( @@ -299,6 +312,7 @@ impl AudioOutput { /// Open the audio sink for a picker selection: a [`NullSink`] when disabled, /// otherwise a [`CpalSink`] on the chosen (or default) device. Device-open /// errors propagate so callers can report them; Disabled never fails. +#[cfg(feature = "frontend")] pub fn open_output_sink( realtime_priority: bool, output: &AudioOutput, @@ -326,6 +340,7 @@ pub fn open_output_sink( /// mixer). Naming individual sinks would need cpal's `jack` backend against /// pipewire-jack, which adds a libjack build dependency; not worth it for a niche /// control when macOS/Windows enumerate every device directly. +#[cfg(feature = "frontend")] pub fn list_output_devices() -> Vec { quiet_alsa_probe_logging(); cpal::default_host() @@ -343,6 +358,7 @@ pub fn list_output_devices() -> Vec { /// to, so selecting it and selecting "Default" (the `None` option) do the same /// thing. `default_name` is the host's default output device name. When the /// default is some other device, `default` is a distinct choice and kept. +#[cfg(feature = "frontend")] fn is_redundant_default(name: &str, default_name: Option<&str>) -> bool { name.eq_ignore_ascii_case("default") && default_name.is_some_and(|d| d.eq_ignore_ascii_case("default")) @@ -352,6 +368,7 @@ fn is_redundant_default(name: &str, default_name: Option<&str>) -> bool { /// as [`list_output_devices`], but drops ALSA's "default" when it is the system /// default, since the picker already offers a synthetic "Default" (the `None` /// selection) for that. Still selectable by name in the config/CLI. +#[cfg(feature = "frontend")] pub fn picker_output_devices() -> Vec { let host = cpal::default_host(); let default_name = host.default_output_device().and_then(|d| d.name().ok()); @@ -365,6 +382,7 @@ pub fn picker_output_devices() -> Vec { /// (case-insensitive), otherwise the system default. A named-but-missing /// device warns and falls back to the default rather than leaving the machine /// silent. +#[cfg(feature = "frontend")] fn select_output_device(host: &cpal::Host, want: Option<&str>) -> Result { if let Some(name) = want { let needle = name.to_lowercase(); @@ -384,6 +402,7 @@ fn select_output_device(host: &cpal::Host, want: Option<&str>) -> Result, @@ -564,6 +584,7 @@ fn next_live_audio_output_frame( } } +#[cfg(feature = "frontend")] struct CpalResampler { step: f64, phase: f64, @@ -572,6 +593,7 @@ struct CpalResampler { primed: bool, } +#[cfg(feature = "frontend")] impl CpalResampler { fn new(output_sample_rate: u32) -> Self { Self { @@ -634,6 +656,7 @@ impl CpalResampler { } } +#[cfg(feature = "frontend")] fn pop_live_audio_frame( consumer: &mut ringbuf::HeapCons<(f32, f32)>, underruns: &AtomicU64, @@ -646,6 +669,7 @@ fn pop_live_audio_frame( }) } +#[cfg(feature = "frontend")] impl AudioSink for CpalSink { fn push(&mut self, left: f32, right: f32) { self.generated_frames = self.generated_frames.saturating_add(1); @@ -772,6 +796,7 @@ impl AudioSink for CpalSink { } } +#[cfg(feature = "frontend")] impl CpalSink { fn request_stale_frame_drop(&self) { let stale = stale_live_audio_frames_to_skip( @@ -825,6 +850,7 @@ impl AudioSink for WavSink { } } +#[cfg(feature = "frontend")] fn stale_live_audio_frames_to_skip( occupied_len: usize, target_len: usize, @@ -837,10 +863,12 @@ fn stale_live_audio_frames_to_skip( } } +#[cfg(feature = "frontend")] fn sample_is_audible(left: f32, right: f32) -> bool { left != 0.0 || right != 0.0 } +#[cfg(feature = "frontend")] fn live_output_lead_seconds_for_state( playback_started: bool, occupied_frames: usize, @@ -856,6 +884,7 @@ fn live_output_lead_seconds_for_state( } } +#[cfg(feature = "frontend")] fn live_output_prebuffering( playback_started: bool, occupied_frames: usize, @@ -864,6 +893,7 @@ fn live_output_prebuffering( !playback_started && occupied_frames > 0 && occupied_frames < target_frames } +#[cfg(feature = "frontend")] fn callback_device_cck(output_frames: usize, output_sample_rate: u32) -> u64 { let rate = u64::from(output_sample_rate.max(1)); (output_frames as u64) @@ -871,7 +901,7 @@ fn callback_device_cck(output_frames: usize, output_sample_rate: u32) -> u64 { .div_ceil(rate) } -#[cfg(test)] +#[cfg(all(test, feature = "frontend"))] mod tests { use super::{ callback_device_cck, is_alsa_plugin_variant, is_redundant_default, diff --git a/src/bin/bench.rs b/src/bin/bench.rs new file mode 100644 index 00000000..9ab211ab --- /dev/null +++ b/src/bin/bench.rs @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Headless core benchmark (`copperline-bench`, behind the `bench-bin` +//! feature): step the deterministic core unpaced for N emulated seconds and +//! report wall-clock frame times. Builds for native targets and for +//! wasm32-wasip1, where it measures what a browser build can sustain; with +//! `--render` each completed frame also runs the full presentation pipeline +//! (bitplane render, post-process, deinterlace) the way an interactive +//! frontend would, so the numbers include the per-frame render cost. +//! +//! Deliberately frontend-free: no winit/cpal/env_logger. Log output from the +//! core goes to stdout through a minimal logger so ROM/config banners stay +//! visible under WASI runtimes. + +use anyhow::{anyhow, Context, Result}; +use copperline::audio::NullSink; +use copperline::config::{Config, ConfigOverrides, Overscan}; +use copperline::emulator::build_machine; +use copperline::timebase::Instant; +use copperline::video::deinterlace::Deinterlacer; +use copperline::video::{bitplane, present_common, FB_WIDTH, MAX_FB_PIXELS}; +use std::path::PathBuf; + +struct StdoutLogger; + +impl log::Log for StdoutLogger { + fn enabled(&self, metadata: &log::Metadata) -> bool { + metadata.level() <= log::Level::Info + } + fn log(&self, record: &log::Record) { + if self.enabled(record.metadata()) { + println!("[{}] {}", record.level(), record.args()); + } + } + fn flush(&self) {} +} + +static LOGGER: StdoutLogger = StdoutLogger; + +struct Args { + rom: Option, + ext: Option, + df0: Option, + config: Option, + seconds: f64, + render: bool, +} + +fn parse_args() -> Result { + let mut args = Args { + rom: None, + ext: None, + df0: None, + config: None, + seconds: 30.0, + render: false, + }; + let mut it = std::env::args().skip(1); + while let Some(arg) = it.next() { + let path_arg = |it: &mut dyn Iterator| -> Result { + it.next() + .map(PathBuf::from) + .ok_or_else(|| anyhow!("{arg} needs a value")) + }; + match arg.as_str() { + "--rom" => args.rom = Some(path_arg(&mut it)?), + "--ext" => args.ext = Some(path_arg(&mut it)?), + "--df0" => args.df0 = Some(path_arg(&mut it)?), + "--config" => args.config = Some(path_arg(&mut it)?), + "--seconds" => { + args.seconds = it + .next() + .and_then(|s| s.parse().ok()) + .ok_or_else(|| anyhow!("--seconds needs a number"))?; + } + "--render" => args.render = true, + other => { + return Err(anyhow!( + "unknown argument {other}; usage: copperline-bench [--config P] [--rom P] \ + [--ext P] [--df0 P] [--seconds F] [--render]" + )); + } + } + } + Ok(args) +} + +fn percentile(sorted: &[f64], p: f64) -> f64 { + if sorted.is_empty() { + return 0.0; + } + let idx = ((sorted.len() as f64 - 1.0) * p).round() as usize; + sorted[idx.min(sorted.len() - 1)] +} + +fn main() -> Result<()> { + let _ = log::set_logger(&LOGGER); + log::set_max_level(log::LevelFilter::Info); + + let args = parse_args()?; + let raw = Config::load_raw(args.config.as_deref(), &ConfigOverrides::default())?; + let mut cfg: Config = raw.try_into()?; + if let Some(rom) = &args.rom { + cfg.rom_path = rom.clone(); + // A ROM given explicitly replaces the whole ROM setup; keep whatever + // extended ROM was requested on the command line only. + cfg.extended_rom_path = None; + } + if let Some(ext) = &args.ext { + cfg.extended_rom_path = Some(ext.clone()); + } + + let mut emu = build_machine(&cfg, Box::new(NullSink), false, false)?; + if let Some(df0) = &args.df0 { + emu.bus_mut() + .floppy + .insert_disk_image(0, df0.clone(), true) + .with_context(|| format!("inserting {}", df0.display()))?; + } + + emu.set_paced(false); + emu.reset_stats(); + + let mut fb = vec![0u32; MAX_FB_PIXELS]; + let mut deinterlacer = Deinterlacer::new(); + let mut last_rendered: Option = None; + let mut rendered_frames: u64 = 0; + + let start_emulated = emu.bus().emulated_seconds(); + let target = start_emulated + args.seconds; + let start_frames = emu.bus().emulated_frames(); + let started = Instant::now(); + let mut frame_times: Vec = Vec::new(); + + while emu.bus().emulated_seconds() < target { + let frame_started = Instant::now(); + emu.step_frame()?; + if args.render && emu.bus().frame_render_available() { + let emulated_frame = emu.bus().emulated_frames(); + if present_common::should_render_emulated_frame(last_rendered, emulated_frame) { + let visible_start_vpos = emu.bus().frame_visible_start_vpos(); + bitplane::render(emu.bus_mut(), &mut fb); + let geometry = emu.bus().frame_geometry(); + let field_rows = present_common::post_process_rendered_field( + &mut fb, + geometry, + visible_start_vpos, + 0, + Overscan::Tv, + ); + let base = emu.bus().frame_render_base(); + deinterlacer.push_field( + &fb, + field_rows, + base.bplcon0 & 0x0004 != 0, + base.long_field, + !geometry.programmable, + ); + last_rendered = Some(emulated_frame); + rendered_frames += 1; + } + } + frame_times.push(frame_started.elapsed().as_secs_f64() * 1_000.0); + } + + let elapsed = started.elapsed().as_secs_f64(); + let frames = emu.bus().emulated_frames().saturating_sub(start_frames); + let emulated = emu.bus().emulated_seconds() - start_emulated; + + let mut sorted = frame_times.clone(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let mean = sorted.iter().sum::() / sorted.len().max(1) as f64; + let over_budget = sorted.iter().filter(|&&t| t > 20.0).count(); + + println!( + "bench: {:.3}s emulated in {:.3}s wall, {} frames ({:.1}/s), realtime x{:.2}{}", + emulated, + elapsed, + frames, + frames as f64 / elapsed.max(f64::EPSILON), + emulated / elapsed.max(f64::EPSILON), + if args.render { + format!(", {rendered_frames} rendered") + } else { + String::new() + } + ); + println!( + "bench frame ms: mean={:.2} p50={:.2} p90={:.2} p95={:.2} p99={:.2} max={:.2}", + mean, + percentile(&sorted, 0.50), + percentile(&sorted, 0.90), + percentile(&sorted, 0.95), + percentile(&sorted, 0.99), + sorted.last().copied().unwrap_or(0.0), + ); + println!( + "bench budget: {} of {} frames over the 20ms PAL budget", + over_budget, + sorted.len() + ); + // Keep the deinterlacer's output observable so the render path cannot be + // optimized out entirely. + if args.render { + let checksum: u64 = deinterlacer.output()[..FB_WIDTH] + .iter() + .map(|&px| px as u64) + .sum(); + println!("bench render checksum: {checksum:#x}"); + } + Ok(()) +} diff --git a/src/bus.rs b/src/bus.rs index 2eabb814..7b239177 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -26,10 +26,10 @@ use crate::floppy::FloppyController; use crate::gayle::Gayle; use crate::memory::Memory; use crate::rtc::Msm6242Rtc; +use crate::timebase::{Duration, Instant}; use crate::video::{beam::BeamEventIndex, FrameGeometry, FB_HEIGHT, FB_WIDTH, MAX_VISIBLE_LINES}; use log::trace; use std::io::Write; -use std::time::{Duration, Instant}; const CHIP_BUS_SLOT_CCK: u32 = 1; const BLITTER_DEADLINE_SLOT_SCAN_LIMIT: u32 = 64; diff --git a/src/config.rs b/src/config.rs index 9996a83c..0e10514b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -33,7 +33,7 @@ pub const BUNDLED_AROS_ROM: &str = ""; pub struct WasmBoardConfig { pub spec: BoardSpec, pub wasm_path: PathBuf, - pub manifest: crate::wasmboard::WasmManifest, + pub manifest: crate::wasm_manifest::WasmManifest, } #[derive(Debug, Clone)] @@ -1232,6 +1232,7 @@ impl RawConfig { /// screen's Save. Only non-default fields are written (see the /// `skip_serializing_if` attributes), so the result reads like the /// hand-written example configs. + #[cfg_attr(not(feature = "frontend"), allow(dead_code))] pub(crate) fn to_toml_string(&self) -> Result { toml::to_string_pretty(self).context("serializing configuration to TOML") } @@ -2348,6 +2349,7 @@ fn parse_video_standard(s: &str) -> Result { /// produces): exact GiB/MiB/KiB get a `G`/`M`/`K` suffix, anything else falls /// back to a raw byte count. Always emits a 4 KiB-aligned value the parser /// round-trips. +#[cfg_attr(not(feature = "frontend"), allow(dead_code))] pub(crate) fn format_size(bytes: usize) -> String { const K: usize = 1024; const M: usize = 1024 * 1024; diff --git a/src/cpu.rs b/src/cpu.rs index 2b444553..06646199 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -1253,7 +1253,10 @@ impl M68kMachine { let geometry = self.bus.bus.frame_geometry(); if !geometry.programmable { let visible_start = self.bus.bus.frame_visible_start_vpos(); - crate::video::window::center_present_frame_for_visible_start(&mut fb, visible_start); + crate::video::present_common::center_present_frame_for_visible_start( + &mut fb, + visible_start, + ); } else if geometry.line_cck != 227 { crate::screenshot::stretch_rows_x( &mut fb, diff --git a/src/emulator.rs b/src/emulator.rs index 6901270c..7175c363 100644 --- a/src/emulator.rs +++ b/src/emulator.rs @@ -12,9 +12,9 @@ use crate::cpu; use crate::floppy::FloppyController; use crate::memory::Memory; use crate::serial::StdoutSink; +use crate::timebase::{Duration, Instant}; use anyhow::{anyhow, Result}; use log::{info, warn}; -use std::time::{Duration, Instant}; const INSTRUCTIONS_PER_SLICE: usize = 32_000; const INSTRUCTIONS_PER_REALTIME_SLICE: usize = 8_192; @@ -369,7 +369,7 @@ pub struct EmuStats { pub frames: u64, pub slices: u64, pub instructions: u64, - pub started_at: Option, + pub started_at: Option, } impl Emulator { @@ -1208,7 +1208,7 @@ impl Emulator { /// without spinning on zero-instruction slices. pub fn debug_step_for_gdb(&mut self, cpu_idle: &mut bool) -> Result<()> { if self.stats.started_at.is_none() { - self.stats.started_at = Some(std::time::Instant::now()); + self.stats.started_at = Some(crate::timebase::Instant::now()); } let frame_before = self.bus().emulated_frames(); self.run_one_step(cpu_idle, INSTRUCTIONS_PER_REALTIME_SLICE)?; @@ -1359,7 +1359,7 @@ impl Emulator { pub fn step_frame(&mut self) -> Result<()> { if self.stats.started_at.is_none() { - self.stats.started_at = Some(std::time::Instant::now()); + self.stats.started_at = Some(crate::timebase::Instant::now()); } self.step_real()?; self.stats.frames += 1; @@ -1840,6 +1840,7 @@ pub fn build_machine( } // WASM plugin boards: assign each a device slot, put its autoconfig // identity on the chain, and instantiate the module. + #[cfg(feature = "wasm-boards")] for wb in &cfg.wasm_boards { let slot = devices.len(); let mut spec = wb.spec.clone(); @@ -1853,6 +1854,10 @@ pub fn build_machine( ); devices.push(crate::zorro_device::BoardDevice::Wasm(board)); } + #[cfg(not(feature = "wasm-boards"))] + if !cfg.wasm_boards.is_empty() { + anyhow::bail!("[[zorro]] wasm boards require a build with the wasm-boards feature"); + } // A2065 Ethernet board (in-tree LANCE NIC): networking is non-deterministic. if let Some(net_config) = cfg.a2065_net { let slot = devices.len(); diff --git a/src/envcfg.rs b/src/envcfg.rs index d1e2c00f..f6834ca9 100644 --- a/src/envcfg.rs +++ b/src/envcfg.rs @@ -20,7 +20,16 @@ use std::sync::OnceLock; fn snapshot() -> &'static HashMap { static SNAPSHOT: OnceLock> = OnceLock::new(); - SNAPSHOT.get_or_init(|| std::env::vars_os().collect()) + // The browser has no process environment and std::env::vars_os() panics + // there ("not supported on this platform"); every knob reads as unset. + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + SNAPSHOT.get_or_init(HashMap::new) + } + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + SNAPSHOT.get_or_init(|| std::env::vars_os().collect()) + } } /// Whether any `COPPERLINE_*` variable is present at all. Every knob this diff --git a/src/floppy.rs b/src/floppy.rs index ef584ca2..b82a52a2 100644 --- a/src/floppy.rs +++ b/src/floppy.rs @@ -366,6 +366,32 @@ impl FloppyController { Ok(()) } + /// Insert a disk from in-memory image bytes (any format + /// [`FloppyImage::from_bytes`] accepts). `label` stands in for the file + /// path in logs and the UI; hosts without a filesystem should pass + /// `write_protected = true` (see `from_bytes`). + pub fn insert_disk_image_bytes( + &mut self, + drive_idx: usize, + bytes: Vec, + label: PathBuf, + write_protected: bool, + ) -> Result<()> { + ensure!( + drive_idx < self.drives.len(), + "invalid floppy drive df{}", + drive_idx + ); + let image = FloppyImage::from_bytes(bytes, label, write_protected) + .with_context(|| format!("loading floppy.df{} image", drive_idx))?; + self.idle_cache = false; + self.drives[drive_idx].insert_image(image); + if self.selected_drive() == Some(drive_idx) { + self.ensure_track(drive_idx, self.track_for_drive(drive_idx)); + } + Ok(()) + } + pub fn eject_disk_image(&mut self, drive_idx: usize) -> Result<()> { ensure!( drive_idx < self.drives.len(), @@ -1891,18 +1917,31 @@ impl FloppyImage { fn load(config: &FloppyDriveConfig) -> Result { let packed = std::fs::read(&config.path) .with_context(|| format!("reading floppy image {}", config.path.display()))?; + Self::from_bytes(packed, config.path.clone(), config.write_protected) + } + + /// Decode an already-loaded image (the byte-for-byte file contents: + /// ADF/extended ADF/DMS/SCP/IPF, optionally gzip- or zip-packed) without + /// touching the filesystem. `path` is the display/write-back label; hosts + /// with no filesystem (the browser build) must insert write-protected so + /// the extended-ADF write-back path never fires. + pub(crate) fn from_bytes( + packed: Vec, + path: PathBuf, + write_protected: bool, + ) -> Result { let (data, write_protected, legacy_extended_adf) = if packed.starts_with(GZIP_SIGNATURE) { let unpacked = decode_gzip_floppy_image(&packed)?; - decode_floppy_payload(unpacked, true, &config.path)? + decode_floppy_payload(unpacked, true, &path)? } else if packed.starts_with(ZIP_SIGNATURE) { let unpacked = decode_zip_floppy_image(&packed)?; - decode_floppy_payload(unpacked, true, &config.path)? + decode_floppy_payload(unpacked, true, &path)? } else { - decode_floppy_payload(packed, config.write_protected, &config.path)? + decode_floppy_payload(packed, write_protected, &path)? }; Ok(Self { - path: config.path.clone(), + path, data, write_protected, legacy_extended_adf, diff --git a/src/lib.rs b/src/lib.rs index 63986f5f..23a3226c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,6 +32,7 @@ pub mod emulator; pub mod envcfg; pub mod filesys; pub mod floppy; +#[cfg(feature = "frontend")] pub mod gamepad; pub mod gayle; pub mod gdbstub; @@ -50,9 +51,12 @@ pub mod savestate; pub mod screenshot; pub mod scsi; pub mod serial; +pub mod timebase; pub mod timestamp; pub mod timetravel; pub mod video; +pub mod wasm_manifest; +#[cfg(feature = "wasm-boards")] pub mod wasmboard; pub mod zorro; pub mod zorro_device; diff --git a/src/priority.rs b/src/priority.rs index 471e6a43..12ddeda0 100644 --- a/src/priority.rs +++ b/src/priority.rs @@ -95,7 +95,14 @@ fn elevate_current_thread(label: &str) { } } -#[cfg(not(target_os = "macos"))] +// No host threads to schedule on wasm32; the whole feature is a no-op there +// (the thread-priority crate is excluded from the wasm32 dependency graph). +#[cfg(target_arch = "wasm32")] +fn elevate_current_thread(label: &str) { + log::info!("priority: {label} thread left as-is (no thread scheduling on wasm)"); +} + +#[cfg(not(any(target_os = "macos", target_arch = "wasm32")))] fn elevate_current_thread(label: &str) { use thread_priority::{set_current_thread_priority, ThreadPriority}; // On Windows this maps to THREAD_PRIORITY_HIGHEST (no privilege needed). diff --git a/src/rtc.rs b/src/rtc.rs index 2f3fb6af..7e6e5922 100644 --- a/src/rtc.rs +++ b/src/rtc.rs @@ -14,7 +14,7 @@ //! deterministic `COPPERLINE_RTC_FIXED_SECS` override stays UTC so it //! remains host-independent. -use std::time::{SystemTime, UNIX_EPOCH}; +use crate::timebase::{SystemTime, UNIX_EPOCH}; #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct Msm6242Rtc { diff --git a/src/serial.rs b/src/serial.rs index 7b480f92..183e2ab8 100644 --- a/src/serial.rs +++ b/src/serial.rs @@ -2,8 +2,8 @@ //! Serial output sink. Paula's SERDAT writes are funneled through here. +use crate::timebase::Instant; use std::io::{self, Write}; -use std::time::Instant; /// Maps the emulated serial timeline onto the host clock so a timing-sensitive /// sink can schedule its output. `host_epoch` is the host instant of emulated diff --git a/src/timebase.rs b/src/timebase.rs new file mode 100644 index 00000000..d6c0e319 --- /dev/null +++ b/src/timebase.rs @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Host clock imports for code shared with the browser build. +//! +//! On every native target (and on wasm32-wasip1, whose WASI clocks work) +//! this is a pure re-export of `std::time`, so it changes nothing. On +//! wasm32-unknown-unknown `std::time::Instant::now()` and +//! `SystemTime::now()` abort at runtime, so the `web-time` crate backs +//! them with `performance.now()` / `Date.now()` instead. Modules that can +//! be part of the headless core import their clocks from here rather than +//! from `std::time`. + +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +pub use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +pub use web_time::{Duration, Instant, SystemTime, UNIX_EPOCH}; diff --git a/src/timestamp.rs b/src/timestamp.rs index 8da646b1..4fb59064 100644 --- a/src/timestamp.rs +++ b/src/timestamp.rs @@ -9,7 +9,7 @@ //! is in the host's local time zone where the platform exposes one (via //! `localtime_r`), falling back to UTC otherwise. -use std::time::{SystemTime, UNIX_EPOCH}; +use crate::timebase::{SystemTime, UNIX_EPOCH}; /// Current host time formatted as `YYYYMMDDHHmmSS`, in local time where /// available. diff --git a/src/video/bitplane.rs b/src/video/bitplane.rs index 9c6cabc2..4139d3db 100644 --- a/src/video/bitplane.rs +++ b/src/video/bitplane.rs @@ -24,10 +24,10 @@ use crate::chipset::denise::{ color_register_value, rgb12_to_rgb24, rgb12_to_rgba8, rgb24_to_rgba8, BitplaneMode, DiwHigh, Palette, COLOR_RGB_MASK, COLOR_TRANSPARENCY_BIT, }; +use crate::timebase::Instant; use std::borrow::Cow; use std::collections::HashMap; use std::sync::OnceLock; -use std::time::Instant; // Beam-to-framebuffer conversion anchors for the pragmatic renderer. // They are derived from the OCS PAL display window/fetch positions diff --git a/src/video/mod.rs b/src/video/mod.rs index 51e9fed5..ff1e3ae7 100644 --- a/src/video/mod.rs +++ b/src/video/mod.rs @@ -4,8 +4,12 @@ pub mod beam; pub mod bitplane; pub mod deinterlace; pub mod font; +#[cfg(feature = "frontend")] pub mod launcher; +pub mod present_common; +#[cfg(feature = "frontend")] pub mod ui; +#[cfg(feature = "frontend")] pub mod window; #[cfg(target_os = "macos")] diff --git a/src/video/present_common.rs b/src/video/present_common.rs new file mode 100644 index 00000000..5a685dd2 --- /dev/null +++ b/src/video/present_common.rs @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Frontend-independent presentation helpers: the post-render pass that +//! turns a rendered field into a presentable frame (vertical/horizontal +//! recentring, the TV overscan bezel mask, programmable-scan stretch) and +//! the aperture/geometry predicates around it. Moved out of +//! `video/window/present.rs` so headless consumers (`cpu.rs`'s debug +//! screenshots, the wasm browser frontend) can present frames without the +//! winit frontend; `window/present.rs` re-exports everything here, so the +//! desktop path is unchanged. + +use super::{bitplane, deinterlace::OUT_HEIGHT, FrameGeometry, FB_HEIGHT, FB_PIXELS, FB_WIDTH}; +use crate::bus::RenderRegisterSnapshot; +use crate::config::Overscan; +use crate::screenshot; + +pub const fn rgba(r: u32, g: u32, b: u32) -> u32 { + 0xFF00_0000 | (b << 16) | (g << 8) | r +} + +pub const STANDARD_PAL_VISIBLE_WIDTH: usize = 320 * 2; +pub const STANDARD_PAL_VISIBLE_LINES: usize = 256; +pub const STANDARD_PAL_VISIBLE_START_VPOS: u32 = 0x2C; +// Default TV presentation keeps a small consumer-visible overscan margin while +// still hiding the deep edge columns that often contain unfinished effects. +pub const TV_HORIZONTAL_OVERSCAN_MARGIN: usize = 24 * 2; + +pub fn post_process_rendered_field( + fb: &mut [u32], + geometry: FrameGeometry, + visible_start_vpos: u32, + h_shift: usize, + overscan: Overscan, +) -> usize { + let field_rows = geometry.visible_lines.min(fb.len() / FB_WIDTH); + // Vertical centring, optional full-overscan horizontal recentring, and the + // TV bezel mask are 15 kHz CRT concepts anchored to the standard PAL/NTSC + // window; a programmable scan defines its own window and presents in full, + // like a multisync monitor. + if !geometry.programmable { + center_present_frame_for_visible_start(fb, visible_start_vpos); + center_present_frame_horizontally(fb, h_shift); + if overscan == Overscan::Tv { + mask_present_frame_to_tv(fb, h_shift, standard_window_top_row(visible_start_vpos)); + } + } else if geometry.line_cck != 227 { + // A multisync monitor's horizontal deflection is time-linear: + // each colour clock of this scan's shorter/longer line covers + // 227/line_cck of the glass a standard line's clock would. + screenshot::stretch_rows_x(fb, FB_WIDTH, field_rows, geometry.line_cck, 227); + } + field_rows +} + +/// `[display] overscan = "tv"`: black out the deep-overscan margins like the +/// bezel of a CRT. Demos routinely leave junk in the deep overscan (e.g. HAM +/// streams that converge off-screen, as on the 9 Fingers title); a TV hides +/// it and so does this mask. The emulated framebuffer itself always carries +/// the full field; this runs on the presentation copy only. +/// +/// The window is a realistic PAL TV visible area rather than the bare standard +/// window: real sets show a margin of overscan, which intentional overscan +/// displays rely on, while the deep-overscan junk the mask exists to hide sits +/// further out. Default TV presentation keeps 24 lo-res pixels of horizontal +/// overscan beside the standard PAL window; full overscan remains available +/// through `Overscan::Full`. The mask is horizontal only: vertical border +/// colour changes are part of the Denise output and can be intentional +/// effects, so rows above or below the standard display remain as rendered. +/// `h_shift` is any horizontal presentation shift already applied to the +/// frame, so the bezel tracks the shifted picture instead of clipping its left +/// edge. +pub fn mask_present_frame_to_tv(fb: &mut [u32], h_shift: usize, _standard_top_row: usize) { + debug_assert!(fb.len() >= FB_PIXELS); + let black = rgba(0, 0, 0); + let (source_left, source_right) = tv_source_h_bounds(); + let left = source_left.saturating_sub(h_shift); + let right = source_right.saturating_sub(h_shift).min(FB_WIDTH).max(left); + for row in fb.chunks_mut(FB_WIDTH) { + row[..left].fill(black); + if right < FB_WIDTH { + row[right..].fill(black); + } + } +} + +/// The framebuffer row carrying the standard window's first line after +/// `center_present_frame_for_visible_start` has run: the centring shift +/// plus however many overscan rows were already visible above it. +pub fn standard_window_top_row(visible_start_vpos: u32) -> usize { + let overscan_rows_already_visible = + STANDARD_PAL_VISIBLE_START_VPOS.saturating_sub(visible_start_vpos) as usize; + presentation_source_y_offset(visible_start_vpos) + overscan_rows_already_visible +} + +/// Shift the rendered frame left by `shift` framebuffer pixels, filling the +/// vacated right columns with black. Used to recentre a standard display +/// whose deep left overscan would otherwise push the picture right of +/// centre. A no-op when `shift` is 0 (overscan frames). +pub fn center_present_frame_horizontally(fb: &mut [u32], shift: usize) { + debug_assert!(fb.len() >= FB_PIXELS); + if shift == 0 { + return; + } + let shift = shift.min(FB_WIDTH); + let black = rgba(0, 0, 0); + for y in 0..FB_HEIGHT { + let row = &mut fb[y * FB_WIDTH..(y + 1) * FB_WIDTH]; + row.copy_within(shift.., 0); + row[FB_WIDTH - shift..].fill(black); + } +} + +pub fn center_present_frame_for_visible_start(fb: &mut [u32], visible_start_vpos: u32) { + debug_assert!(fb.len() >= FB_PIXELS); + let offset = presentation_source_y_offset(visible_start_vpos); + if offset == 0 { + return; + } + + for y in (0..FB_HEIGHT - offset).rev() { + let src = y * FB_WIDTH; + let dst = (y + offset) * FB_WIDTH; + fb.copy_within(src..src + FB_WIDTH, dst); + } + fb[..offset * FB_WIDTH].fill(rgba(0, 0, 0)); +} + +pub fn presentation_source_y_offset(visible_start_vpos: u32) -> usize { + let standard_offset = FB_HEIGHT.saturating_sub(STANDARD_PAL_VISIBLE_LINES) / 2; + let overscan_rows_already_visible = + STANDARD_PAL_VISIBLE_START_VPOS.saturating_sub(visible_start_vpos) as usize; + standard_offset.saturating_sub(overscan_rows_already_visible) +} + +pub fn presentation_h_shift_for(snapshot: &RenderRegisterSnapshot, overscan: Overscan) -> usize { + match overscan { + // TV mode is an aperture over the emulated framebuffer, matching the + // fixed source cutout used by reference emulators. Do not copy pixels + // sideways here: a standard hi-res screen already occupies the right + // edge of Copperline's 716-pixel cutout. + Overscan::Tv => 0, + Overscan::Full => bitplane::present_h_shift_for(snapshot), + } +} + +pub fn tv_source_h_bounds() -> (usize, usize) { + let left = bitplane::STANDARD_VISIBLE_X0.saturating_sub(TV_HORIZONTAL_OVERSCAN_MARGIN); + let right = bitplane::STANDARD_VISIBLE_X0 + .saturating_add(STANDARD_PAL_VISIBLE_WIDTH) + .saturating_add(TV_HORIZONTAL_OVERSCAN_MARGIN) + .min(FB_WIDTH) + .max(left); + (left, right) +} + +pub fn should_render_emulated_frame(last_rendered: Option, current: u64) -> bool { + last_rendered != Some(current) +} + +pub fn is_standard_pal_presentation(geometry: FrameGeometry, src_rows: usize) -> bool { + !geometry.programmable && geometry.frame_lines >= 312 && src_rows == OUT_HEIGHT +} + +pub fn uses_standard_pal_tv_aperture( + geometry: FrameGeometry, + src_rows: usize, + snapshot: &RenderRegisterSnapshot, +) -> bool { + is_standard_pal_presentation(geometry, src_rows) + && bitplane::uses_standard_horizontal_content(snapshot) +} diff --git a/src/video/window.rs b/src/video/window.rs index 80eacb00..e0c7a052 100644 --- a/src/video/window.rs +++ b/src/video/window.rs @@ -9,13 +9,11 @@ use super::deinterlace::{Deinterlacer, OUT_HEIGHT}; use super::launcher::{LauncherField, LauncherState, MachineSetup, StatusMessage}; use super::ui::{self, Panel, UiControl, UiState}; use super::{ - bitplane, font, present_height, FrameGeometry, FB_HEIGHT, FB_PIXELS, FB_WIDTH, - HOST_SHORTCUT_MODIFIER_LABEL, MAX_FB_PIXELS, MAX_VISIBLE_LINES, + bitplane, font, present_height, FB_HEIGHT, FB_PIXELS, FB_WIDTH, HOST_SHORTCUT_MODIFIER_LABEL, + MAX_FB_PIXELS, MAX_VISIBLE_LINES, }; use crate::audio::{AudioSink, CpalSink}; -use crate::bus::{ - BeamWriteSource, FrontPanelStatus, RenderRegisterSnapshot, VideoRenderFrameTiming, -}; +use crate::bus::{BeamWriteSource, FrontPanelStatus, VideoRenderFrameTiming}; use crate::config::{Config, Overscan, PixelAspect, RawConfig, WarpSpeed}; use crate::emulator::Emulator; use crate::screenshot; @@ -285,12 +283,9 @@ const VOLUME_GLYPH_X: usize = VOLUME_SLIDER_X - 16; // `joystick_toggle_clears_worst_case_media`. const JOY_TOGGLE_W: usize = 22; const JOY_TOGGLE_X: usize = VOLUME_GLYPH_X - 2 - JOY_TOGGLE_W; -const STANDARD_PAL_VISIBLE_WIDTH: usize = 320 * 2; -const STANDARD_PAL_VISIBLE_LINES: usize = 256; -const STANDARD_PAL_VISIBLE_START_VPOS: u32 = 0x2C; -// Default TV presentation keeps a small consumer-visible overscan margin while -// still hiding the deep edge columns that often contain unfinished effects. -const TV_HORIZONTAL_OVERSCAN_MARGIN: usize = 24 * 2; +// The standard-window constants (and the TV overscan margin) live in +// `video/present_common.rs` with the presentation helpers they anchor. +use crate::video::present_common::STANDARD_PAL_VISIBLE_WIDTH; const TV_PAL_PRESENT_WIDTH: usize = STANDARD_PAL_VISIBLE_WIDTH + 2 * 26; const TV_PAL_PRESENT_HEIGHT: usize = 540; const TV_PAL_PRESENT_SOURCE_X: usize = bitplane::STANDARD_VISIBLE_X0 - 26; @@ -2092,9 +2087,7 @@ impl Rect { } } -pub(super) const fn rgba(r: u32, g: u32, b: u32) -> u32 { - 0xFF00_0000 | (b << 16) | (g << 8) | r -} +pub(super) use crate::video::present_common::rgba; struct EmbeddedRgbaImage { width: usize, @@ -6560,7 +6553,6 @@ pub(super) use statusbar::{draw_rect_bevel, fill_rect, fill_rect_blend}; pub use host_input::parse_amiga_key; use host_input::*; -pub(crate) use present::center_present_frame_for_visible_start; use present::*; use statusbar::*; diff --git a/src/video/window/present.rs b/src/video/window/present.rs index 7577db66..a9168692 100644 --- a/src/video/window/present.rs +++ b/src/video/window/present.rs @@ -8,6 +8,11 @@ use super::*; +// The pure post-render helpers live in `video/present_common.rs` so headless +// consumers can present frames without the winit frontend; re-exported here so +// the rest of the window module family keeps its unqualified names. +pub(super) use crate::video::present_common::*; + pub(super) fn render_job_to_presentation( job: RenderJob, fb: &mut [u32], @@ -48,33 +53,6 @@ pub(super) fn render_job_to_presentation( } } -pub(super) fn post_process_rendered_field( - fb: &mut [u32], - geometry: FrameGeometry, - visible_start_vpos: u32, - h_shift: usize, - overscan: Overscan, -) -> usize { - let field_rows = geometry.visible_lines.min(fb.len() / FB_WIDTH); - // Vertical centring, optional full-overscan horizontal recentring, and the - // TV bezel mask are 15 kHz CRT concepts anchored to the standard PAL/NTSC - // window; a programmable scan defines its own window and presents in full, - // like a multisync monitor. - if !geometry.programmable { - center_present_frame_for_visible_start(fb, visible_start_vpos); - center_present_frame_horizontally(fb, h_shift); - if overscan == Overscan::Tv { - mask_present_frame_to_tv(fb, h_shift, standard_window_top_row(visible_start_vpos)); - } - } else if geometry.line_cck != 227 { - // A multisync monitor's horizontal deflection is time-linear: - // each colour clock of this scan's shorter/longer line covers - // 227/line_cck of the glass a standard line's clock would. - screenshot::stretch_rows_x(fb, FB_WIDTH, field_rows, geometry.line_cck, 227); - } - field_rows -} - pub(super) fn log_frame_dump_metadata(index: u32, emu: &Emulator) { let bus = emu.bus(); let base = bus.frame_render_base(); @@ -639,46 +617,6 @@ pub(super) fn blend_rgba_over_opaque(dst: u32, sr: u32, sg: u32, sb: u32, sa: u3 rgba(r, g, b) } -/// `[display] overscan = "tv"`: black out the deep-overscan margins like the -/// bezel of a CRT. Demos routinely leave junk in the deep overscan (e.g. HAM -/// streams that converge off-screen, as on the 9 Fingers title); a TV hides -/// it and so does this mask. The emulated framebuffer itself always carries -/// the full field; this runs on the presentation copy only. -/// -/// The window is a realistic PAL TV visible area rather than the bare standard -/// window: real sets show a margin of overscan, which intentional overscan -/// displays rely on, while the deep-overscan junk the mask exists to hide sits -/// further out. Default TV presentation keeps 24 lo-res pixels of horizontal -/// overscan beside the standard PAL window; full overscan remains available -/// through `Overscan::Full`. The mask is horizontal only: vertical border -/// colour changes are part of the Denise output and can be intentional -/// effects, so rows above or below the standard display remain as rendered. -/// `h_shift` is any horizontal presentation shift already applied to the -/// frame, so the bezel tracks the shifted picture instead of clipping its left -/// edge. -pub(super) fn mask_present_frame_to_tv(fb: &mut [u32], h_shift: usize, _standard_top_row: usize) { - debug_assert!(fb.len() >= FB_PIXELS); - let black = rgba(0, 0, 0); - let (source_left, source_right) = tv_source_h_bounds(); - let left = source_left.saturating_sub(h_shift); - let right = source_right.saturating_sub(h_shift).min(FB_WIDTH).max(left); - for row in fb.chunks_mut(FB_WIDTH) { - row[..left].fill(black); - if right < FB_WIDTH { - row[right..].fill(black); - } - } -} - -/// The framebuffer row carrying the standard window's first line after -/// `center_present_frame_for_visible_start` has run: the centring shift -/// plus however many overscan rows were already visible above it. -pub(super) fn standard_window_top_row(visible_start_vpos: u32) -> usize { - let overscan_rows_already_visible = - STANDARD_PAL_VISIBLE_START_VPOS.saturating_sub(visible_start_vpos) as usize; - presentation_source_y_offset(visible_start_vpos) + overscan_rows_already_visible -} - /// Whether horizontal recentring is enabled. On unless COPPERLINE_HCENTER is /// set to a falsey value (0/false/off/no), so full-overscan presentation can /// show the standard display exactly as rendered when debugging alignment. @@ -702,74 +640,6 @@ pub(super) fn threaded_render_enabled() -> bool { } } -/// Shift the rendered frame left by `shift` framebuffer pixels, filling the -/// vacated right columns with black. Used to recentre a standard display -/// whose deep left overscan would otherwise push the picture right of -/// centre. A no-op when `shift` is 0 (overscan frames). -pub(crate) fn center_present_frame_horizontally(fb: &mut [u32], shift: usize) { - debug_assert!(fb.len() >= FB_PIXELS); - if shift == 0 { - return; - } - let shift = shift.min(FB_WIDTH); - let black = rgba(0, 0, 0); - for y in 0..FB_HEIGHT { - let row = &mut fb[y * FB_WIDTH..(y + 1) * FB_WIDTH]; - row.copy_within(shift.., 0); - row[FB_WIDTH - shift..].fill(black); - } -} - -pub(crate) fn center_present_frame_for_visible_start(fb: &mut [u32], visible_start_vpos: u32) { - debug_assert!(fb.len() >= FB_PIXELS); - let offset = presentation_source_y_offset(visible_start_vpos); - if offset == 0 { - return; - } - - for y in (0..FB_HEIGHT - offset).rev() { - let src = y * FB_WIDTH; - let dst = (y + offset) * FB_WIDTH; - fb.copy_within(src..src + FB_WIDTH, dst); - } - fb[..offset * FB_WIDTH].fill(rgba(0, 0, 0)); -} - -pub(super) fn presentation_source_y_offset(visible_start_vpos: u32) -> usize { - let standard_offset = FB_HEIGHT.saturating_sub(STANDARD_PAL_VISIBLE_LINES) / 2; - let overscan_rows_already_visible = - STANDARD_PAL_VISIBLE_START_VPOS.saturating_sub(visible_start_vpos) as usize; - standard_offset.saturating_sub(overscan_rows_already_visible) -} - -pub(super) fn presentation_h_shift_for( - snapshot: &RenderRegisterSnapshot, - overscan: Overscan, -) -> usize { - match overscan { - // TV mode is an aperture over the emulated framebuffer, matching the - // fixed source cutout used by reference emulators. Do not copy pixels - // sideways here: a standard hi-res screen already occupies the right - // edge of Copperline's 716-pixel cutout. - Overscan::Tv => 0, - Overscan::Full => bitplane::present_h_shift_for(snapshot), - } -} - -pub(super) fn tv_source_h_bounds() -> (usize, usize) { - let left = bitplane::STANDARD_VISIBLE_X0.saturating_sub(TV_HORIZONTAL_OVERSCAN_MARGIN); - let right = bitplane::STANDARD_VISIBLE_X0 - .saturating_add(STANDARD_PAL_VISIBLE_WIDTH) - .saturating_add(TV_HORIZONTAL_OVERSCAN_MARGIN) - .min(FB_WIDTH) - .max(left); - (left, right) -} - -pub(super) fn should_render_emulated_frame(last_rendered: Option, current: u64) -> bool { - last_rendered != Some(current) -} - pub(super) fn status_with_latched_fdd_track( status: FrontPanelStatus, last_fdd_track: &mut Option, @@ -838,16 +708,3 @@ pub(super) fn save_present_frame( present_height() as u32, ) } - -pub(super) fn is_standard_pal_presentation(geometry: FrameGeometry, src_rows: usize) -> bool { - !geometry.programmable && geometry.frame_lines >= 312 && src_rows == OUT_HEIGHT -} - -pub(super) fn uses_standard_pal_tv_aperture( - geometry: FrameGeometry, - src_rows: usize, - snapshot: &RenderRegisterSnapshot, -) -> bool { - is_standard_pal_presentation(geometry, src_rows) - && bitplane::uses_standard_horizontal_content(snapshot) -} diff --git a/src/wasm_manifest.rs b/src/wasm_manifest.rs new file mode 100644 index 00000000..84823ee1 --- /dev/null +++ b/src/wasm_manifest.rs @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Plugin-board manifest data shared with builds that exclude the wasmtime +//! host. `WasmCaps`/`WasmManifest` are pure configuration: `config.rs` and +//! `zorro.rs` consume them even when the `wasm-boards` feature (and with it +//! `src/wasmboard.rs`, the runtime that executes plugins) is compiled out. + +use crate::net::NetConfig; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// Capabilities a plugin declares in its manifest; ungranted host imports are +/// not linked, so a module that needs more than it declared fails to +/// instantiate (loudly) rather than silently misbehaving. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct WasmCaps { + /// Bus-master DMA into Amiga memory (`dma_read`/`dma_write` imports). + pub dma: bool, + /// Asserts the INT2 (PORTS) line (advisory; the `int2` export is polled). + pub int2: bool, + /// Asserts the INT6 (EXTER) line (advisory; the `int6` export is polled). + pub int6: bool, + /// Host networking (`net_send`/`net_recv` imports). A net board is + /// non-deterministic; see [`crate::net`]. + pub net: bool, +} + +/// A plugin's non-autoconfig metadata: its display name, capabilities, and (for +/// a NIC board) which host network backend to bring up. The autoconfig identity +/// lives in the board's [`crate::zorro::BoardSpec`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WasmManifest { + pub name: String, + pub caps: WasmCaps, + pub net: NetConfig, + /// Effective plugin settings (manifest defaults merged with the user's + /// per-board overrides), exposed to the module via the `config_get` import. + pub config: BTreeMap, + /// Config keys whose values are host file paths; the host loads each file + /// and exposes it to the module via `resource_read` under the same key. + pub file_keys: Vec, +} diff --git a/src/wasmboard.rs b/src/wasmboard.rs index 5022e7eb..a0b0b82e 100644 --- a/src/wasmboard.rs +++ b/src/wasmboard.rs @@ -50,37 +50,10 @@ use wasmtime::{ Caller, Config, Engine, Extern, Linker, Memory as WasmMemory, Module, Store, TypedFunc, }; -/// Capabilities a plugin declares in its manifest; ungranted host imports are -/// not linked, so a module that needs more than it declared fails to -/// instantiate (loudly) rather than silently misbehaving. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct WasmCaps { - /// Bus-master DMA into Amiga memory (`dma_read`/`dma_write` imports). - pub dma: bool, - /// Asserts the INT2 (PORTS) line (advisory; the `int2` export is polled). - pub int2: bool, - /// Asserts the INT6 (EXTER) line (advisory; the `int6` export is polled). - pub int6: bool, - /// Host networking (`net_send`/`net_recv` imports). A net board is - /// non-deterministic; see [`crate::net`]. - pub net: bool, -} - -/// A plugin's non-autoconfig metadata: its display name, capabilities, and (for -/// a NIC board) which host network backend to bring up. The autoconfig identity -/// lives in the board's [`crate::zorro::BoardSpec`]. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WasmManifest { - pub name: String, - pub caps: WasmCaps, - pub net: NetConfig, - /// Effective plugin settings (manifest defaults merged with the user's - /// per-board overrides), exposed to the module via the `config_get` import. - pub config: BTreeMap, - /// Config keys whose values are host file paths; the host loads each file - /// and exposes it to the module via `resource_read` under the same key. - pub file_keys: Vec, -} +// The manifest structs live in `crate::wasm_manifest` so config/zorro can +// consume them without the wasmtime host; re-exported here so existing +// `wasmboard::WasmManifest` paths keep working. +pub use crate::wasm_manifest::{WasmCaps, WasmManifest}; /// Store data for host imports. The Amiga-memory pointer is stored as a /// `usize` (0 = none) so the store stays `Send`; it is set to the live diff --git a/src/zorro.rs b/src/zorro.rs index ea6735ba..30c1d37f 100644 --- a/src/zorro.rs +++ b/src/zorro.rs @@ -18,7 +18,7 @@ //! [`BoardBacking`]. use crate::net::NetConfig; -use crate::wasmboard::{WasmCaps, WasmManifest}; +use crate::wasm_manifest::{WasmCaps, WasmManifest}; use anyhow::{bail, Context, Result}; use serde::Deserialize; use std::collections::BTreeMap; diff --git a/src/zorro_device.rs b/src/zorro_device.rs index c6a938eb..5cbdbd0b 100644 --- a/src/zorro_device.rs +++ b/src/zorro_device.rs @@ -290,6 +290,7 @@ pub enum BoardDevice { A2091(crate::a2091::A2091), A4091(crate::a4091::A4091), A2065(crate::a2065::A2065), + #[cfg(feature = "wasm-boards")] Wasm(crate::wasmboard::WasmBoard), } @@ -299,6 +300,7 @@ impl ZorroDevice for BoardDevice { BoardDevice::A2091(d) => ZorroDevice::read(d, off, size, host), BoardDevice::A4091(d) => ZorroDevice::read(d, off, size, host), BoardDevice::A2065(d) => ZorroDevice::read(d, off, size, host), + #[cfg(feature = "wasm-boards")] BoardDevice::Wasm(d) => ZorroDevice::read(d, off, size, host), } } @@ -308,6 +310,7 @@ impl ZorroDevice for BoardDevice { BoardDevice::A2091(d) => ZorroDevice::write(d, off, size, value, host), BoardDevice::A4091(d) => ZorroDevice::write(d, off, size, value, host), BoardDevice::A2065(d) => ZorroDevice::write(d, off, size, value, host), + #[cfg(feature = "wasm-boards")] BoardDevice::Wasm(d) => ZorroDevice::write(d, off, size, value, host), } } @@ -317,6 +320,7 @@ impl ZorroDevice for BoardDevice { BoardDevice::A2091(d) => ZorroDevice::peek_word(d, off), BoardDevice::A4091(d) => ZorroDevice::peek_word(d, off), BoardDevice::A2065(d) => ZorroDevice::peek_word(d, off), + #[cfg(feature = "wasm-boards")] BoardDevice::Wasm(d) => ZorroDevice::peek_word(d, off), } } @@ -326,6 +330,7 @@ impl ZorroDevice for BoardDevice { BoardDevice::A2091(d) => ZorroDevice::tick(d, cck, host), BoardDevice::A4091(d) => ZorroDevice::tick(d, cck, host), BoardDevice::A2065(d) => ZorroDevice::tick(d, cck, host), + #[cfg(feature = "wasm-boards")] BoardDevice::Wasm(d) => ZorroDevice::tick(d, cck, host), } } @@ -335,6 +340,7 @@ impl ZorroDevice for BoardDevice { BoardDevice::A2091(d) => ZorroDevice::int2_line(d), BoardDevice::A4091(d) => ZorroDevice::int2_line(d), BoardDevice::A2065(d) => ZorroDevice::int2_line(d), + #[cfg(feature = "wasm-boards")] BoardDevice::Wasm(d) => ZorroDevice::int2_line(d), } } @@ -344,6 +350,7 @@ impl ZorroDevice for BoardDevice { BoardDevice::A2091(d) => ZorroDevice::int6_line(d), BoardDevice::A4091(d) => ZorroDevice::int6_line(d), BoardDevice::A2065(d) => ZorroDevice::int6_line(d), + #[cfg(feature = "wasm-boards")] BoardDevice::Wasm(d) => ZorroDevice::int6_line(d), } } @@ -353,6 +360,7 @@ impl ZorroDevice for BoardDevice { BoardDevice::A2091(d) => ZorroDevice::is_idle(d), BoardDevice::A4091(d) => ZorroDevice::is_idle(d), BoardDevice::A2065(d) => ZorroDevice::is_idle(d), + #[cfg(feature = "wasm-boards")] BoardDevice::Wasm(d) => ZorroDevice::is_idle(d), } } @@ -362,6 +370,7 @@ impl ZorroDevice for BoardDevice { BoardDevice::A2091(d) => ZorroDevice::next_event_cck(d), BoardDevice::A4091(d) => ZorroDevice::next_event_cck(d), BoardDevice::A2065(d) => ZorroDevice::next_event_cck(d), + #[cfg(feature = "wasm-boards")] BoardDevice::Wasm(d) => ZorroDevice::next_event_cck(d), } } @@ -371,6 +380,7 @@ impl ZorroDevice for BoardDevice { BoardDevice::A2091(d) => ZorroDevice::take_activity(d), BoardDevice::A4091(d) => ZorroDevice::take_activity(d), BoardDevice::A2065(d) => ZorroDevice::take_activity(d), + #[cfg(feature = "wasm-boards")] BoardDevice::Wasm(d) => ZorroDevice::take_activity(d), } } @@ -380,6 +390,7 @@ impl ZorroDevice for BoardDevice { BoardDevice::A2091(d) => ZorroDevice::reset(d), BoardDevice::A4091(d) => ZorroDevice::reset(d), BoardDevice::A2065(d) => ZorroDevice::reset(d), + #[cfg(feature = "wasm-boards")] BoardDevice::Wasm(d) => ZorroDevice::reset(d), } } @@ -389,6 +400,7 @@ impl ZorroDevice for BoardDevice { BoardDevice::A2091(d) => ZorroDevice::kind(d), BoardDevice::A4091(d) => ZorroDevice::kind(d), BoardDevice::A2065(d) => ZorroDevice::kind(d), + #[cfg(feature = "wasm-boards")] BoardDevice::Wasm(d) => ZorroDevice::kind(d), } } diff --git a/tools/wasi-bench.mjs b/tools/wasi-bench.mjs new file mode 100644 index 00000000..d31ca4da --- /dev/null +++ b/tools/wasi-bench.mjs @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Run the wasm32-wasip1 copperline-bench build under Node's WASI: +// node tools/wasi-bench.mjs target/wasm32-wasip1/release/copperline-bench.wasm \ +// --rom /work/assets/aros/aros-amiga-m68k-rom.bin --seconds 30 +// The current directory is preopened as /work, so pass /work-prefixed paths. +// Node's V8 wasm tiers are what Chrome uses, so these numbers are the closest +// host-side proxy for browser performance. +import { readFile } from 'node:fs/promises'; +import { WASI } from 'node:wasi'; + +const [wasmPath, ...rest] = process.argv.slice(2); +if (!wasmPath) { + console.error('usage: node tools/wasi-bench.mjs [bench args...]'); + process.exit(2); +} +const wasi = new WASI({ + version: 'preview1', + args: [wasmPath, ...rest], + env: {}, + preopens: { '/work': process.cwd() }, + returnOnExit: true, +}); +const mod = await WebAssembly.compile(await readFile(wasmPath)); +const inst = await WebAssembly.instantiate(mod, wasi.getImportObject()); +process.exitCode = wasi.start(inst);