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.

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 under /try; this page explains how to use that page, how it is put together, how to build and run it locally, and how to embed the emulator in your own page.

Using the hosted page

copperline.dev/try boots a default A500. The Machine select below the screen switches to an AGA A1200 (68EC020, 2 MiB chip RAM, like the desktop’s --model A1200); both models boot the AROS ROM or a loaded Kickstart. Changing it before boot just changes what the boot button builds, and changing it while a machine runs rebuilds the machine and powers it up again -- the model is the board itself, not a knob on it -- keeping the chosen ROM and the inserted disk. A link can preset the model with ?machine=A1200, and a save state carries its own machine, so loading one switches the select to whatever the state brings back. The Video select is the same idea for the standard: PAL (the default) or NTSC (the desktop’s [chipset] video key) -- the standard is the Agnus crystal, so changing it rebuilds a running machine exactly like the model select, and ?video=NTSC presets it per link. The page fetches the open-source AROS ROM while it loads, so the boot button works with no files of your own; the Kickstart ROM and DF0 disk pickers load local images instead. Both work before or after boot: a pre-boot choice is stashed and applied when the machine starts (the boot button relabels to show which ROM it will use), and a post-boot pick swaps the disk live. Disk images are recognised by content -- ADF, ADZ, DMS, IPF, and SCP, plain or gzip/zip packed -- and are always write-protected, since the browser has no filesystem to write changes back to. A file picker cannot sniff content, though: it filters by extension and hides whatever the filter leaves out, which is how an .ipf stayed greyed out in a bundle that decodes IPF perfectly well. So the glue rewrites the disk picker’s filter from the running build’s own format list instead of leaving it as the page shell’s hand-written HTML spelled it. A shell that ships no filter at all still offers every file, and on iOS the pickers filter nothing either, because the system document picker greys out extensions it does not recognise, which would lock out .adf and friends.

A Kickstart that fits is also remembered: the image goes into the browser’s own storage (IndexedDB, never uploaded anywhere), and the next visit boots it with no picker round trip -- the boot button simply reads “Boot Kickstart” again. An explicit choice always wins over the memory (the picker, a drop, ?kick=), and the saved-states panel shows what is remembered with a Forget button that puts the boot button back on AROS.

Five more controls shape what the glass shows without touching the machine. Monitor is the desktop window’s monitor presentation, on by default: the CRT shader preset (bowed tube face, scanlines, aperture grille, corner vignette -- the desktop’s [display] shader = "crt") with the picture seated in one of the desktop’s two bezel styles ([display] bezel), rendered through WebGL2 at display resolution. 1084 pairs the preset with the two-tone 1084 cabinet -- moulding, model badge, logotype and power lamp -- and Classic with the plain rounded frame Copperline drew before the 1084 arrived. CRT filter, 1084 cabinet and Classic bezel select each half alone, and Plain is the undecorated blit the page always had -- also what a browser without WebGL2 falls back to (the select hides there). As on the desktop, a drawn frame also widens what its tube shows: the whole captured raster, border colour to the glass edges, so the opening’s rounded corners crop into overscan border the way a real tube’s do, not into the picture. And as on the desktop, the CRT pass suspends itself on programmable scans, which have no 15 kHz line structure to draw; the bezel stays. The selected monitor fronts the page before anything boots too -- the powered-off tube, dark glass in the moulded frame, rather than a bare black rectangle -- and while a bezel mode is up the page shell’s own thin border around the canvas hides, since the moulded case is the frame. View is the desktop’s [display] overscan knob: TV (the default) masks the deep horizontal overscan like a CRT bezel and crops standard screens (PAL and NTSC alike) to a TV aperture, Full overscan presents the whole field Denise produced -- junk pixels, border tricks and all. Screen tints the picture like a monochrome monitor’s phosphor -- black & white, green, amber, or sepia. Under the monitor path the tint is applied in the shader to the picture alone, so the bezel’s plastic never turns phosphor-green (the desktop tints its buffer before the presentation passes too); on the 2D fallback it is a CSS filter on the canvas. Deinterlace opts interlaced (LACE) displays into the desktop’s motion-adaptive field merging; off, those fields use cheaper line doubling. Progressive displays are identical either way. Phosphor persistence opts into a 40% previous-frame decay trail, useful for field-rate flicker effects but more expensive because every presented pixel is blended with retained history. Both history effects default off in the browser for maximum throughput.

All five are viewing preferences rather than machine state, so the page remembers them in the browser and restores them on the next visit; the machine and video selects deliberately reset instead, because a shared ?df0= link should boot the same machine for everyone.

Screenshots capture the presentation buffer, not the canvas: the CRT pass and the bezel never appear in them, exactly as on the desktop, whose captures skip its presentation passes so they stay comparable whatever the monitor setting. The screen tint is the one exception, baked in as it always was.

While a machine runs (and is not paused), the page holds a screen wake lock where the browser supports one, the way a video player does: a demo or a long loading sequence is exactly the hands-off viewing that trips a host’s idle timeout. Pausing or stopping releases it.

Files can also be dragged onto the page: a .rom file loads (or, before boot, queues) a Kickstart exactly like the ROM picker, a .clstate file restores a save state, and anything else inserts into DF0 like the disk picker -- dropped before boot it queues and inserts when the machine starts. The same 64 MiB cap as URL fetches applies.

A disk can also come from a link: /try/?df0=<url> fetches the image while the emulator loads and inserts it at boot, so a bootable demo is one shareable URL, and the DF0 from URL button does the same for a pasted address, inserting live when the machine is already running. The fetch happens in the visitor’s browser and nothing is proxied, so the image’s host must allow cross-origin GETs (same-origin always works; archive.org does too). Only http(s) URLs are accepted, capped at 64 MiB (SCP flux dumps run tens of MB).

A Kickstart can come from a link too, but only from the page’s own origin: ?kick=<path> fetches the ROM and queues or fits it exactly like the picker (the boot button relabels; a running machine is power-cycled). The same-origin restriction is the copyright gate: Kickstart images are copyrighted, and a cross-origin ?kick= would only exist to share them. A same-origin path can never load a ROM the serving site does not already host, so the hosted page stays exactly as ROM-free as its server -- while a self-hosted copy that serves its owner’s ROM files next to the page (a Docker deployment with a mounted volume, an intranet install) can boot them by URL: ?kick=files/kick13.rom. ROM fetches are capped at 4 MiB and the image is validated like a picked file. A page shell may also offer a Kickstart from URL button (id kickurl), which prompts for a same-origin address, or a Kickstart list select (id kicklist) that fills itself with the ROMs the site serves next to the page (see the page-shell hooks below); the hosted page has no ROMs to point them at and omits both.

Controls:

The Fullscreen button takes over the whole monitor, keeping the display’s 4:3 shape: the picture becomes the largest 4:3 box that fits and is letterboxed against the monitor’s own aspect ratio, so an ultrawide gets pillarbox bars instead of a stretched screen. The letterbox is applied by the page glue itself, not the page’s stylesheet, so it holds on any shell that embeds the emulator. While fullscreen, small Joystick, Keys, Type, Pause and Exit buttons sit in the top-right corner (Type, the device keyboard, on touch screens only). Raising either keyboard there does not cover the picture: the letterbox recomputes into the space above the keys, so the display shrinks and stays whole. The drawn keyboard reports its own measured height for that; the device keyboard is measured from how much of the viewport it covers, which is what a browser offers instead of leaving room. On iPhones, where Safari has no element fullscreen, the button pins the shell over the page instead -- Safari’s chrome stays, the page furniture goes, and the same letterbox applies.

Esc carries two browser defaults -- leaving fullscreen and releasing the captured mouse -- and the guest wants it as the Amiga Esc key besides. In browsers with the Keyboard Lock API (Chromium), the page locks Escape while fullscreen, so a press releases the captured mouse without ending fullscreen, or types Esc into the guest when the pointer is free; leaving fullscreen moves to press-and-hold Esc (the browser announces this on entry) or the Exit button. Browsers without the API keep the default, where a single Esc leaves fullscreen.

Once a machine boots, a status strip appears below the screen with the same front-panel readouts as the desktop status bar: the PWR and FDD LEDs (plus HDD/CD on machines fitted with those drives), the floppy track counter, and the name of the disk in each connected drive. The PWR LED shows the desktop bar’s two lit levels -- bright with the guest’s /LED line engaged, dimmed when released -- and is never dark, as a running machine is always powered.

What the page has to say -- a screenshot copied, a state saved, a disk inserted, something refused -- appears as a caption across the bottom of the screen for a few seconds, the browser’s version of the desktop’s on-screen display. It is over the screen rather than under it so it reads in fullscreen too. Before boot the same messages go to the status line in the middle of the boot overlay, which is where a shell’s own #load-status element lives; the caption takes over when that line is hidden, which is the whole time a machine is running.

Audio starts with the boot click, but a browser autoplay policy can keep the AudioContext suspended anyway; the boot never waits for it. The emulator runs silent and the next click or key press unlocks the sound.

On an iPhone or iPad, leaving the browser for another app deactivates the page’s audio output at the OS level, and coming back does not reliably reactivate it -- the pipeline can keep consuming samples with nothing reaching the speaker. The page rebuilds its audio pipeline whenever it returns to the foreground there, so the sound carries on; if iOS demands a fresh gesture first, the next tap brings it back.

Hiding the tab normally puts the machine to sleep with the page, exactly where it was until the tab returns. Ticking Run in background keeps it running -- and audible -- the way a video tab keeps playing, so a long render or a BBS transfer carries on while you read something else. The choice is remembered per browser. Running hidden needs the sound to have been unlocked (any click or key press does it): the audio pipeline is what clocks the machine while the page cannot see it, so with audio still suspended the machine sleeps as before.

A page that stays visible but is starved of animation frames -- an unfocused window on a host bent on saving power (Windows “efficiency mode” is the classic) -- keeps real time the same way, and needs no option: the audio pipeline steps the machine between whatever animation frames still arrive, so emulation and sound never slow down and only the displayed frame rate drops to what the browser delivers. This too needs unlocked audio; with the AudioContext still suspended there is no fallback clock, and a starved page slows down as the browser dictates.

Save states

The page carries the desktop’s save states, which snapshot the whole emulated machine -- RAM, ROM, chipset, CPU, and the inserted floppy images themselves -- in the same .clstate format, so a state moves between a browser and a desktop build in either direction. Four buttons, which insert themselves below the canvas on a shell that does not place them:

Loading works from a cold page: with no machine booted, a load boots one and restores over it, so a visitor returning to a game lands straight back in it. A state carries its own ROM and disks and replaces the whole machine, so nothing needs to be re-picked first -- and no boot ROM is needed at all, not even AROS. A page whose ROM download failed, or a self-hosted shell that serves none, can still restore a state; the machine that comes out of it is complete. A blob that is not a readable state of this build’s format version is refused with the running machine untouched -- including a state from an older Copperline whose format version has moved on. If that refusal follows a boot the load itself asked for, the page returns to its pre-boot screen rather than leaving a machine running that nothing was restored into.

There are no keyboard shortcuts for these (the desktop’s Cmd/Alt+Shift+S and +L): every key on the page belongs to the guest, so a host shortcut would shadow an Amiga key.

Host-side settings are not part of a machine and do not travel in a state: the page re-applies its own volume, drive-sound, floppy-speed and controller choices over whatever is restored.

How it is put together

The crate is split by cargo features so the core carries no desktop dependencies:

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:

The guest sees a stock machine: ROMs arrive as bytes (Emulator::reload_rom), floppies as bytes (FloppyController::insert_disk_image_bytes, which sniffs the same image formats by content as the desktop file paths), 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):

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, www/render-stride.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:

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

const wasm = await init();
const emu = new WebEmu();          // default A500 machine, placeholder ROM
// ...or pick a machine model: new WebEmu('A1200')
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);
}

The constructor’s first optional argument picks the machine profile by name, exactly as the desktop’s --model flag does (“A500”, “A1200”, ...); omitted, it builds the default A500, so pages written against the model-less constructor keep booting what they always did. The static WebEmu.models() lists the vetted profiles a page can offer unconditionally (currently A500 and A1200 -- both boot AROS or a plain Kickstart with nothing but a floppy; other names the desktop flag takes are accepted too, but CDTV/CD32 want pieces a browser page cannot supply), and its absence on an older bundle is the feature test. The second optional argument picks the video standard on top of the profile (new WebEmu('A500', 'NTSC')), the desktop’s [chipset] video key; omitted, the profile keeps its own (PAL for every offered profile). WebEmu.video_standards() lists the accepted names and is the matching feature test. machine_model() returns the running machine’s profile name (undefined for a shape no profile describes, such as a state saved from a custom desktop config) and follows load_state, so a page can re-point its machine select at what a state brought back; video_standard() does the same for the standard (“PAL”/“NTSC” -- the fitted Agnus crystal, not the live BEAMCON0 bit ECS software can flip); machine_summary() is a one-line description of the machine -- profile, CPU, chipset, video standard, RAM, ROM fingerprint -- for bug reports and diagnostics.

insert_floppy(drive, bytes, name) takes any format the core reads -- ADF/ADZ, extended ADF, DMS, IPF, SCP, plain or gzip/zip packed -- decided by signature, so the name it is given is only a label. The static WebEmu.floppy_formats() lists the extensions those formats conventionally carry (["adf", "adz", ...], no dots) for the one thing a page cannot decide by content: a file input’s accept filter, and any list it scrapes by name. Building the filter from it is what stops a picker hiding an image the build would happily read; its absence is the feature test on an older bundle.

Input goes through key_event(event.code, pressed) (returns whether the key mapped, for preventDefault), mouse_delta(dx, dy) and mouse_button(button, pressed). key_raw(rawkey, pressed) is the same path one step lower down, taking an Amiga raw keycode instead of a KeyboardEvent.code: it is what an on-screen keyboard wants, whose keys already are Amiga keys, and it reaches the codes no positional code expresses on every host layout. Mouse motion is pooled and fed to the hardware counters at a physically plausible rate (at most 100 counts per emulated frame): browsers coalesce pointer events, and a fast flick delivered as one huge delta would wrap the 8-bit JOYxDAT counters and read back as motion in the wrong direction. set_joystick_port(port, ...) and set_cd32_buttons_port(port, ...) drive a joystick or CD32 pad in either port (1 or 2) -- two ports is two players, and the hosted page feeds them from the keyboard mapping and from the Gamepad API. set_port_device(port, name) plugs a device into a port ("mouse", "joystick", "cd32", "analogue", "none"); a page whose gamepad disappears restores the mouse with set_port_device(1, "mouse") rather than leaving a stuck stick where the pointer used to be. The older set_joystick_port2(...) / set_cd32_buttons_port2(...) still work and now forward to the port-taking calls. reset() power-cycles, resync_clock() forgets the pacer’s wall-clock anchor so a page resuming from a pause does not sprint through the frames the pause “owed”, eject_floppy(n) and set_volume_percent(p) do what they say, and emulated_seconds() exposes the guest clock for diagnostics. presentation_revision() identifies the current presentation-buffer generation without hashing it, while last_run_core_ms() and last_run_render_ms() split the host cost of the most recent paced call for page-side diagnostics. set_deinterlace(on) switches motion-adaptive LACE field merging live (deinterlace_enabled() reads it); the browser default is off, so LACE fields are line-doubled and the weave history stays unallocated. set_phosphor(fraction) selects CRT persistence from 0.0 (off, the browser default) through 0.95, with phosphor() returning the quantised value. When rendering is deferred, the next presentation ages the persistence by all elapsed emulated fields, so returning from a hidden tab cannot revive an old one-field trail. Both setters re-present the held frame immediately and leave progressive, zero-persistence output on the direct copy path.

save_state() returns the whole emulated machine as a Uint8Array in the desktop’s .clstate format, and load_state(bytes) restores one -- the browser side of save states. Where the bytes live is the page’s choice (a download, IndexedDB, a fetch); the core only deals in the blob. Both are frame-boundary operations, which any JS-facing call is by construction, and a blob that does not parse throws with the running machine untouched. A load re-anchors the pacer and repaints the restored screen immediately, so a paused page shows where it resumes; host-side settings (volume, drive sounds, floppy speed, port devices) are not part of the machine, so a page that keeps its own should re-apply them afterwards. set_floppy_sounds(on) and set_floppy_sounds_volume(p) control the synthesized drive sounds (on and 100 by default, like the desktop’s [audio] floppy_sounds knobs). set_mono_audio(on) averages the left and right channels into both outputs, like the desktop’s [audio] channel_mode = "mono"; off by default, leaving Paula’s hardware stereo panning. set_floppy_speed(percent) / floppy_speed() set and read the emulated drive speed -- 100/200/400/800 percent, or 0 for turbo -- like the desktop’s [floppy] speed option (see Configuration); changes apply to the live machine. set_overscan(mode) picks the presentation overscan, the desktop’s [display] overscan knob: "tv" (the default) masks the deep horizontal overscan like a CRT bezel and presents standard screens as the captured TV aperture, "full" presents the whole overscan field; unknown names are ignored. The last completed frame is re-presented under the new aperture immediately, so a paused page only has to blit. set_monitor_bezel(drawn) tells the emulator a monitor front is drawn around the picture, which widens the standard-scan crop from the TV aperture to the tube aperture (the whole rendered field, the desktop’s tube view); full overscan and programmable scans are unaffected, and the last completed frame is re-presented like set_overscan. set_tv_centre(h, v) centres the TV picture on the glass, the desktop’s [display] tv_h_centre / tv_v_centre knobs (a monitor’s front-panel H-CENTER/V-CENTER controls): h in lo-res pixels (-16..16, positive right), v in scan lines (-8..8, positive down), clamped to those ranges. Glass the nudge exposes past the captured raster shows black; a TV-aperture control, so full overscan ignores it. The last completed frame is re-presented like set_overscan. Front-panel status getters mirror the desktop status bar’s LED block and are cheap enough to poll every frame: power_led() and fdd_led() return booleans, caps_lock_led() returns the keyboard MCU’s own Caps Lock lamp (read it rather than tracking key presses, or a state load leaves the two disagreeing), hdd_led() and cd_led() return undefined on machines without the drive (hide the LED), fdd_track() returns the cylinder under the selected drive’s head or undefined when no drive is selected (latch the last value so a counter does not flicker), and drive_connected(n) / disk_name(n) describe DF0-DF3 -- a disk_name of undefined means the drive is empty. serial_send(bytes), serial_take(), serial_input_backlog() and serial_dtr() bridge Paula’s serial port to whatever byte stream the page likes (see the serial bridge section). The presentation pointer is only valid until the next run call -- rebuild the typed-array view every frame, because wasm memory can grow. The presentation size is dynamic too: present_width() and present_rows() change when the guest switches between a standard screen (presented as the captured TV aperture crop) and anything else (presented as the full framebuffer), so size the canvas from both every frame rather than assuming fixed dimensions. present_crt_lines() describes the same presentation for a page-side CRT shader pass: the emulated field lines it shows (270 on the standard 50 Hz TV aperture, 214 on a 60 Hz scan, 285 and 235 under the tube aperture of a drawn bezel, half the presented rows in full overscan), and 0 when a scanline effect has nothing honest to draw -- no frame yet, or a programmable scan, where the desktop suspends its CRT preset too.

www/try.js, www/render-stride.js, and www/audio-worklet.js are the reference implementation of all of the above, including the audio drift control.

Optional page-shell hooks

try.js drives any page shell that provides its element ids; beyond the required canvas and control bar, a shell can opt into extras by adding elements, and pages without them are untouched:

The page configuration file

A site can set its defaults in one hand-editable file instead of editing the shell: copperline.json, served next to the page. Every key is optional, a missing or invalid file means no defaults, link parameters (?df0=, ?kick=, ?machine=, ?joy=, ?fdspeed=) override the file per URL, and anything the visitor changes by hand wins as usual:

{
  "machine": "A1200",
  "video": "NTSC",
  "kick": "roms/kick31.rom",
  "df0": "adf/demo.adf",
  "floppy_sounds": false,
  "mono_audio": true,
  "floppy_speed": 800,
  "overscan": "full",
  "tint": "green",
  "monitor": "plain",
  "joy": "keys",
  "background_run": true,
  "serial_url": "wss://bbs.example.com:8443/",
  "serial_raw": false,
  "autoboot": true
}

machine picks the machine model, like ?machine=; video the PAL/NTSC standard, like ?video=; kick follows the same-origin rule as ?kick= (the file can only name a ROM the site already serves); df0 is any URL the visitor’s browser may fetch, like ?df0=. floppy_sounds, mono_audio, and floppy_speed reach the machine whether or not the shell has their controls -- the speed select inserts itself, and a configured floppy_sounds or mono_audio is applied at boot even with no checkbox to show it. overscan, tint, and monitor (the CRT + bezel presentation: 1084, classic, crt, cabinet, bezel, or plain) are starting points for first-time visitors only: all three are per-browser viewing preferences the glue remembers, and a visitor’s own remembered choice wins over the file. serial_url and serial_raw preset the serial bridge’s inputs and therefore need those elements: a shell without them has no connect button to dial with either. joy picks the starting joystick mode. background_run starts first-time visitors with the run-in-background box ticked (a per-browser preference the glue remembers, so as with the viewing choices a visitor’s own remembered choice wins over the file). autoboot: true powers the machine on by itself once the emulator, the ROM, and any configured disk have loaded -- the whole recipe for a page dedicated to one demo or a BBS: name the disk, set autoboot, and a visitor lands in the running machine. Browsers keep audio suspended until the first real click or keypress; the page unlocks it on that gesture.

The serial port: dialling a BBS from a browser

The wasm build exposes Paula’s serial port as a byte channel, so a page can bridge the emulated Amiga to a network service -- the classic use being a telnet BBS, with a terminal program running on the guest. Three calls:

Browsers cannot open raw TCP, so the page’s transport is a WebSocket to a gateway that forwards to the real service -- websockify in front of a telnet port is the standard shape, and the page must use wss:// when it is served over HTTPS. Telnet servers also negotiate options in-band (IAC sequences) that a guest terminal program knows nothing about; www/serial-telnet.js is a small NVT layer that answers the negotiation (ECHO, suppress-go-ahead, binary mode for ZModem, and a terminal-type reply of “ANSI”), unescapes inbound data, and escapes outbound data.

try.js wires all of this up when the page shell provides the elements: an input #serial-url for the gateway URL, a button #serial-connect, and optionally a status span #serial-status and a checkbox #serial-raw that bypasses the telnet layer (for gateways to non-telnet byte services). The hosted /try page omits them; a page embedding the emulator next to a BBS adds four elements and inherits the whole flow. The guest side needs a terminal program on a bootable disk (set to serial.device, 8N1 -- the bridge carries whatever baud the guest picks), inserted like any other floppy.

In telnet mode the connection follows the guest’s DTR line the way a modem follows its terminal. Clicking Connect before the terminal is up would scroll the BBS greeting into a UART nobody is reading and forward boot-ROM chatter to the BBS as phantom keypresses (a stray newline at a login prompt walks straight into the new-user flow), so Connect defers the dial until the guest’s line has settled: DTR asserted and no guest transmit, both held for a three-second guard period measured in emulated time (so a throttled background tab cannot shrink it). The guard matters because AROS raises DTR for a couple of seconds during early boot while its kernel debug output streams to the serial port; that burst fails both conditions, while a real terminal holds DTR silently and passes. While deferred, the status line shows “waiting for the terminal” and the button cancels. A connected session hangs up when the guest drops DTR (terminal exit, reboot, power cycle) and re-arms the deferred dial, so rebooting the terminal disk reconnects by itself. Visitors can therefore click Connect at any point -- before booting, after booting, mid-session before a reboot -- and the dial always lands on a listening terminal. Raw mode is ungated for byte services and guest programs that never drive the CIA-B DTR bit.

On the desktop build the equivalent is [serial] mode = "tcp-connect" plus connect = "host:port" (or --serial-connect host:port), which dials the service directly with no gateway in between; see the configuration chapter.

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:

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, the frame-time distribution against the 20 ms PAL budget, how many final presentation buffers were unchanged, and how many conservative input matches skipped rendering entirely. 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.