Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Browser build and WebAssembly integration

Copperline compiles to WebAssembly with a canvas and Web Audio frontend. A hosted instance is available at copperline.dev/try.

This chapter covers using the browser build, its architecture, building it locally, and embedding the emulator in your own web pages.

Using the hosted web emulator

The web version runs at copperline.dev/try:

Input methods

Save states in the browser

The web build uses the same .clstate file format as the desktop version:

Rollback netplay

Controls -> Netplay connects two browsers using WebRTC. The host shares an invitation link or QR code; the other player opens it and clicks Join game. Advanced keeps manual offer/answer codes for pages without a room service. The host sends ROMs, floppy images and machine settings over the encrypted peer connection. The guest verifies them before startup and uses them only for the session; its remembered ROM and local choices are preserved. Both pages start fresh machines with matching ROMs, disks and hardware settings. During play the host can use Netplay -> Swap disk or Eject selected drive; both peers pause and apply the disk change together. The guest receives each replacement automatically, including repeated swaps in DF0. Up to eight spectators can watch through a separate spectator invitation. Each player controls one Amiga port; late input is predicted and corrected by rollback. See browser netplay setup for the steps, controller mapping, relay troubleshooting and restrictions.

Netplay requires WebRTC data channels as well as WebAssembly. It works on a static site; room invitations use a separate signaling service, while manual codes need no server. A desktop peer cannot join a browser session. Save states, media changes, serial connections and pause are unavailable until disconnect.

Architecture

The browser implementation consists of the following components:

Building the WebAssembly package locally

Prerequisites

Ensure the wasm32-unknown-unknown Rust target and the matching wasm-bindgen-cli version are installed:

rustup target add wasm32-unknown-unknown
# Run from the repository root; the CLI must match the crate exactly.
bindgen_version=$(sed -n 's/^wasm-bindgen = "=\(.*\)"$/\1/p' crates/copperline-web/Cargo.toml)
cargo install wasm-bindgen-cli --version "$bindgen_version" --locked

Compilation

cd crates/copperline-web
cargo build --release --target wasm32-unknown-unknown --locked
wasm-bindgen --target web --out-dir pkg \
  target/wasm32-unknown-unknown/release/copperline_web.wasm

The compiled JavaScript loader (copperline_web.js) and WebAssembly binary (copperline_web_bg.wasm) are output to the pkg/ directory.

From the repository root, node tools/check-web-netplay.mjs exercises this release bundle with two emulators, packet loss/reordering, input changes and different presentation settings, and node tools/check-web-netplay-swaps.mjs adds the reliable disk-change channel. Run npm test --prefix crates/copperline-web/www for the page controller tests. These checks need no display, network or external ROMs. tools/check-web-netplay-browser.mjs and tools/check-web-netplay-rooms.mjs drive the real page in a browser through Playwright; the second also needs a room service.

Embedding with the WebEmu API

The example below assumes that the page has loaded the ROM and floppy bytes, created a 2D canvas context named ctx, and connected an audioWorkletNode whose processor accepts interleaved stereo samples. Resize the canvas to the presentation dimensions when they change. The complete page and audio processor are in crates/copperline-web/www/.

import init, { WebEmu } from './pkg/copperline_web.js';

const wasm = await init();

// Initialize emulator (Profile, Video standard, Floppy drive count)
const emu = new WebEmu('A1200', 'PAL', 2);

// Load Kickstart ROM and insert disks
emu.load_rom(romUint8Array, extRomUint8Array);
emu.insert_floppy(0, gameDiskBytes, 'game.adf');
emu.insert_floppy_writable(1, saveDiskBytes, 'save.adf');

// Main animation and audio loop
function renderLoop(timestampMs) {
  emu.run(timestampMs, 5); // Step emulator up to current time (max 5 frames)

  const rows = emu.present_rows();
  if (rows > 0) {
    const width = emu.present_width();
    const pixelView = new Uint8ClampedArray(
      wasm.memory.buffer,
      emu.present_ptr(),
      width * rows * 4
    );
    ctx.putImageData(new ImageData(pixelView, width, rows), 0, 0);
  }

  const audioSamples = emu.take_audio(); // Interleaved stereo Float32Array (44.1 kHz)
  if (audioSamples.length > 0) {
    audioWorkletNode.port.postMessage(audioSamples, [audioSamples.buffer]);
  }

  requestAnimationFrame(renderLoop);
}
requestAnimationFrame(renderLoop);

Key WebEmu API methods

Other methods cover the rest of the page:

Embedding netplay

Create and load a fresh WebEmu for each connection. After WebRTC opens, call start_netplay(player, session, delay, window, controller) before the first run or run_hidden call. player is 1 or 2; session is a shared 32-digit hex ID; delay is an integer from 0 to 6, window from 1 to 12, and controller is "joystick", "cd32" or "mouse". Both ports use that controller. A machine that has run or loaded a save state is ineligible. A fitted RTC is seeded to 2000-01-01 UTC; this does not add a clock to models without one. Failed startup restores the machine and leaves it available for local use or another startup attempt.

RtcLink in www/netplay.js provides offer(settings), answer(offerCode) and accept(answerCode), with configureIce(iceServers, relayOnly) for temporary TURN credentials obtained from a trusted service. report() returns sanitized connection diagnostics. Its onOpen callback is the point to construct the machine and call start_netplay. The page sets settings.media = "host-v1" to add a reliable setup channel; its onOpen awaits transferMedia(hostSnapshot, progress) on the host, or transferMedia(null, progress) on the guest, which returns the verified host snapshot. Embedders that omit this setting must supply matching media themselves. Its onClose callback must stop the page’s loops and free the machine. Immediately after startup, call run_hidden(now, 0) and link.send(emu) once to send the initial fingerprint. From then on, call link.receive(emu) before each run/run_hidden and link.send(emu) after it, including polls that advance zero frames: those still process handshakes, corrections and retransmissions. The ordinary render and audio-drain APIs still apply. On close, drain queued packets and poll once more before freeing the machine, to surface a pending mismatch error.

For another transport, pass each complete received packet to netplay_receive(Uint8Array), and drain netplay_take_packet() until it returns an empty array. Preserve packet boundaries; do not interpret packets as text. The supported browser transport uses an unordered data channel with maxRetransmits: 0; Copperline performs input retransmission itself.

Set settings.swaps = "disk-v1" and supply swapCallbacks.machine (returning the current WebEmu) to enable the separate reliable disk channel. Optional status and changed callbacks update the page. The host calls link.swaps.swap(drive, {bytes, name, writable}), or passes null to eject. Keep polling and running the emulator during this operation: Rust stops forward execution at the negotiated boundary but must still process input and acknowledgements. Do not call the underlying hold/stage/apply/resume methods independently; both peers must complete the coordination protocol before either resumes.

netplay_status() returns [connected, frame, confirmed, acknowledged, rollbacks, replayed, checked], with connected represented by 0 or 1. It returns an empty array outside netplay. netplay_release_input() clears this peer’s held input; call it when the page loses focus. Existing key methods collect local input. During netplay, the port-2 joystick/CD32 methods collect the local player’s controller even when that player owns Amiga port 1; port-1 calls are ignored. This preserves the page’s first-gamepad mapping.

WebEmu.netplay_packet_layout() returns [protocol, maxBytes, headerBytes, inputBytes] for glue compatibility checks. Floppy sound enablement and level must match before startup; their setters fail during a session. Output volume and mono/stereo presentation remain local.

Machine/media/state operations, including controller fitting and floppy speed, fail while a session exists. Serial input is ignored, and so is mouse input unless the session’s controller is "mouse". A protocol error stays latched. Free the instance and start fresh after any disconnect or error; there is no operation to resume it locally.

Spectating uses the same shape. A host that will admit spectators calls netplay_enable_spectators() straight after start_netplay, before the first run, so the confirmed history is kept from frame zero; netplay_identity() returns the fingerprint a spectator must reproduce, and each spectator reads the history through spectator_feed_open(), spectator_feed_take(id, maxBytes) and spectator_feed_close(id). A browser host refuses new spectators once it retains more than 64 MiB of history. A spectator page loads the host’s media into a fresh WebEmu, calls start_spectating(controller), sends spectate_identity() to the host, and feeds the host’s stream to spectate_receive(bytes); spectate_status() returns [spectating, frame, behind, checked, diskChanges]. www/netplay-watch.js implements both sides over a WebRTC data channel.

HTML element hooks in try.js

When using the bundled try.js harness, standard UI elements can be connected by ID:

Page configuration file (copperline.json)

You can provide default settings via a copperline.json file next to the page (the glue fetches ./copperline.json):

{
  "machine": "A1200",
  "video": "PAL",
  "df0": "adf/game.adf",
  "autoboot": true,
  "floppy_speed": 400,
  "monitor": "1084",
  "scaling": "integer",
  "autocrop": true,
  "background_run": true
}

Every key is optional. The file accepts machine, video, kick (a same-origin ROM path), df0, df1, floppy_speed (100/200/400/800, or 0 for turbo), floppy_sounds, mono_audio, overscan (tv/smart/full), tint (none/bw/green/amber/sepia), monitor (1084/classic/crt/cabinet/bezel/plain), scaling, autocrop, deinterlace, phosphor (0 to 0.95), joy (off/keys/cd32/touch), background_run, serial_url, serial_raw and autoboot (power on once everything has loaded). The link parameters (?df0=, ?df1=, ?kick=, ?machine=, ?video=, ?joy=, ?fdspeed=) override the file. Display choices (overscan, tint, monitor, scaling, autocrop, deinterlace, phosphor) and background_run are starting points for first-time visitors: a visitor’s own remembered choice wins.

Serial port over WebSockets

The hosted page can connect the Amiga serial port to a ws:// or wss:// gateway, such as a telnet BBS behind a WebSocket bridge. Enter the gateway URL and click Connect; serial_url and serial_raw in copperline.json preset the URL box and the raw checkbox.

Headless WebAssembly benchmarking

From the repository root, build the frontend-free WASI benchmark:

rustup target add wasm32-wasip1
cargo build --release --locked --target wasm32-wasip1 \
  --no-default-features --features bench-bin --bin copperline-bench
wasmtime run --dir . target/wasm32-wasip1/release/copperline-bench.wasm \
  --config test.toml --seconds 30

Keep the config and its media under the directory exposed by --dir. copperline-bench has its own small parser, separate from the desktop binary’s: --config PATH, --rom PATH, --ext PATH, --df0 PATH and --seconds N (default 30). Add --render to include framebuffer rendering and post-processing.