diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ef7bde..3b38068 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 0000000..920fccb --- /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 bee1d40..eed1e18 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 bb7df70..e4d1d3f 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 2dd67ba..6dad212 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 0000000..54954ce --- /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 0000000..846123e --- /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 0000000..a8d81e7 --- /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 0000000..debfd2f --- /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 0000000..9d6de15 --- /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 0000000..f2de21e --- /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 0000000..281b4c6 --- /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 54e4d80..ec83340 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 e01e205..04852c3 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 0000000..9ab211a --- /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 2eabb81..7b23917 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 9996a83..0e10514 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 2b44455..0664619 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 6901270..7175c36 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 d1e2c00..f6834ca 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 ef584ca..b82a52a 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 63986f5..23a3226 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 471e6a4..12ddeda 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 2f3fb6a..7e6e592 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 7b480f9..183e2ab 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 0000000..d6c0e31 --- /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 8da646b..4fb5906 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 9c6cabc..4139d3d 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 51e9fed..ff1e3ae 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 0000000..5a685dd --- /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 80eacb0..e0c7a05 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 7577db6..a916869 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 0000000..84823ee --- /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 5022e7e..a0b0b82 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 ea6735b..30c1d37 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 c6a938e..5cbdbd0 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 0000000..d31ca4d --- /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);