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.

Writing Zorro board plugins

Copperline’s expansion bus models Zorro II/III autoconfig (src/zorro.rs). Boards are data-driven: a board is described by a BoardSpec, and additional boards are added from TOML metadata files without writing any Rust. The built-in [memory] fast and z3 options are themselves just boards built from the same specs, and the [scsi] option can add the A2091 (Zorro II, src/a2091.rs) or A4091 (Zorro III, src/a4091.rs) SCSI controller as a device-backed board (see the device-board notes below and the [scsi] section of Configuration reference; the third [scsi] choice, the A3000’s motherboard SDMAC, is silicon at $DD0000 rather than a Zorro board).

There are two board kinds:

Functional boards (the A2091, the A4091, the A2065, the CDTV DMAC, the Toccata, the MHI decoder board, and WASM plugins) all implement the ZorroDevice trait (src/zorro_device.rs): the bus drives every board through that one boundary for register access, ticking, interrupts, and DMA.

Describing a board in TOML

Reference a board metadata file from the main configuration:

# copperline.toml
[[zorro]]
metadata = "boards/megaram.toml"

Multiple [[zorro]] entries are allowed; boards join the autoconfig chain in file order, after the built-in fast/z3 RAM boards.

The metadata file:

# boards/megaram.toml
name = "MegaRAM"        # human-readable, appears in logs
zorro = 3               # 2 or 3
type = "ram"            # "ram" or "wasm"
size = "64M"
manufacturer = 0x07DB   # 16-bit autoconfig manufacturer ID
product = 0x20          # 8-bit product code, unique per manufacturer
serial = 0              # optional, defaults to 0
memlist = true          # optional; defaults true for type = "ram"

Field notes:

The spec is validated on load (BoardSpec::validate, src/zorro.rs): bad sizes, unknown zorro versions, and unknown backing types are reported with the metadata file’s path.

WASM plugin boards

A type = "wasm" board is a functional board whose behaviour comes from an external WebAssembly module (src/wasmboard.rs), so you can ship a working board -- registers, interrupts, DMA -- as a .wasm file plus a TOML manifest, with no changes to Copperline.

# boards/example.toml
name = "Example Board"
zorro = 2
type = "wasm"
size = "64K"            # the board's autoconfig window size
manufacturer = 0x1448
product = 0x10
wasm = "example.wasm"   # module path, relative to this metadata file
dma  = true             # capabilities, all default false:
int2 = true             #   dma  -> the dma_read/dma_write host imports
int6 = false            #   int2 -> may assert INT2 (PORTS)
                        #   int6 -> may assert INT6 (EXTER)
# A NIC plugin may also request the shared host networking capability:
# net = "bridge"         # none / loopback / nat / bridge
# net_interface = "en0" # required for bridge
# resolve = true         # host-OS-resolver DNS lookups (resolve_start/resolve_poll)

WASM is chosen because a module’s entire mutable state lives in its linear memory -- a flat byte array that Copperline’s save states snapshot and restore exactly like Amiga RAM, preserving deterministic replay. The engine is run with NaN canonicalization and without SIMD or threads for determinism; a plugin’s persistent state must live in linear memory (WebAssembly globals are not captured). A save state stores the module’s path and replays its memory image on load, so the .wasm file must remain where the manifest points.

Module ABI

The host calls these exports (all optional except memory):

ExportSignaturePurpose
memory(linear memory)required; the board’s state lives here
init() -> ()called once after instantiation
read(off i32, size i32) -> i32register read at a window offset
write(off i32, size i32, value i32)register write
tick(cck i32)advance by cck colour clocks
int2() -> i32INT2 (PORTS) line state, non-zero = asserted
int6() -> i32INT6 (EXTER) line state

The plugin may import these host functions from module env (gated by the manifest capabilities; importing one that was not granted fails to load):

ImportSignatureCapability
log(ptr i32, len i32)always available
config_get / resource_len / resource_readsee belowalways available
dma_read(addr i32, ptr i32, len i32)dma: Amiga addr -> plugin memory ptr
dma_write(addr i32, ptr i32, len i32)dma: plugin memory ptr -> Amiga addr
net_send(ptr i32, len i32)net: transmit the Ethernet frame at plugin memory ptr
net_recv(ptr i32, cap i32) -> i32net: copy the next inbound frame into ptr (truncated to cap), or 0
resolve_start(name_ptr i32, name_len i32) -> i32resolve: start a host-OS-resolver lookup, returns a request id or -1
resolve_poll(id i32, out_ptr i32) -> i32resolve: poll it -- -2 pending, -1 failed, or 0 with the address at out_ptr

DMA transfers (dma_read/dma_write) are transactional and permitted only during active host transactions (read, write, tick). Calling DMA functions during module initialization (init) traps immediately and causes plugin instantiation to fail. Calling DMA functions during passive interrupt queries (int2, int6) traps immediately and transitions the board into the faulted offline state. Within an active host callback, dma_write buffers transfers into a host-side journal (bounded to 4,096 transfers and 16 MiB cumulative size per callback to prevent host resource exhaustion); pending writes are committed to Amiga memory only upon successful return. If the plugin traps (e.g. out of fuel, panic, or unhandled exception), uncommitted writes are rolled back, leaving Amiga memory untouched. dma_read provides read-your-writes coherency by overlaying pending uncommitted writes, including across 32-bit address wrap boundaries (0xFFFF_FFFF -> 0x0000_0000).

Fault isolation and lifecycle

When a plugin traps during runtime execution (read, write, tick, int2, or int6 -- e.g. from fuel exhaustion, unreachable code, out-of-bounds access, or calling DMA outside active transactions):

Interrupt lines are level-sensitive and polled, exactly like the in-tree boards: a plugin holds int2/int6 non-zero while the line is asserted, and the bus applies the interrupt-delivery pipeline automatically -- the plugin never pulses INTREQ.

resolve_start/resolve_poll ask Copperline’s own process to resolve a hostname via its OS resolver (getaddrinfo) on a short-lived background thread, rather than the plugin having to speak DNS wire format itself over its own net traffic -- the only way a plugin can get “whatever the host’s resolver is configured for” name resolution under a backend (like a direct LAN bridge) with no virtual DNS forwarder of its own. resolve_start reads the name from the plugin’s own linear memory and returns a request id; resolve_poll is a non-blocking poll of that id, writing the resolved IPv4 address (4 bytes, big-endian) into the plugin’s own linear memory at out_ptr on success (0). Like net, using it makes a board non-deterministic -- see Configuration reference's [hostsocket] section for the concrete example (its resolver key, which defaults to using this capability under net = "nat"/"bridge").

Plugins can be written in any language that targets wasm32 (Rust, C, Zig, ...). An inert example module and its manifest can be generated with the ignored test emit_example_plugin_wasm (see src/wasmboard.rs).

Plugin settings, files, and the config panel

A plugin can take settings and files. The manifest declares defaults in a [config] table and a schema in [[option]] entries:

[config]                 # defaults
mode = "bridged"
mtu = 1500
[[option]]               # schema (drives the launcher's config panel)
key = "mode"
label = "Mode"
type = "enum"            # string | bool | int | file | enum
choices = ["bridged", "nat"]
[[option]]
key = "rom"
label = "Boot ROM"
type = "file"            # the host loads the file and exposes it as a resource

At runtime the module reads a setting via the config_get host import, and a file-typed option’s bytes via resource_len / resource_read (keyed by the option’s key). For an autoboot ROM, the plugin copies the rom resource into its linear memory at init and serves those bytes from read(), with diag_vec set in the manifest -- just like the in-tree A2091.

The user overrides settings per board in the main config, layered over the manifest defaults:

[[zorro]]
metadata = "boards/nic.toml"
config = { mode = "nat", rom = "boot.rom" }

The machine-configuration launcher renders the [[option]] schema as an editable field per option (enum/int steppers, a bool toggle, a file picker, and a text box for strings), writing changes back as these per-board overrides.

Networking: the A2065 Ethernet board

Copperline includes an in-tree Commodore A2065 Ethernet board (src/a2065.rs), an Am7990 LANCE NIC the AmigaOS SANA-II a2065.device drives. Fit it from the config:

[a2065]
net = "nat"   # "bridge", "loopback", or "none" for isolation
# interface = "en0"  # required for "bridge"

(--a2065-net BACKEND is the matching per-run flag, and the launcher’s I/O Ports tab’s Networking page has the same picker under its Ethernet: heading. Bridged mode adds a live host-adapter picker. --list-net-interfaces prints the stable names accepted by [a2065] interface and --a2065-interface.)

Unlike the DMAC boards, the LANCE does not master the Amiga bus: its init block, descriptor rings, and packet buffers live in the board’s own 32 KiB RAM (which the CPU reaches through the board window), so the board is self-contained and owns its host network backend directly.

Host network backends live in src/net/ behind the NetBackend trait. Three are built in:

Bridge open failures are fatal to startup and save-state restoration; Copperline never silently substitutes NAT or isolation. If an adapter disappears while running, the worker logs link loss and disconnects the guest.

Networking is non-deterministic. Inbound frames arrive on the host’s schedule, not the emulated clock, so a fitted A2065 (or any net-capable WASM plugin) breaks Copperline’s byte-identical replay and save-state reproducibility while traffic flows -- the emulator logs this when the board is attached. Save states record only the chosen backend and bring up a fresh one on load (in-flight frames are dropped; the guest’s TCP retransmits).

Audio: the Toccata sound board

[toccata] fits an in-tree MacroSystem Toccata (src/toccata.rs), a Zorro II AD1848-based sound board with a mature, open-source AHI driver (toccata.audio), so AHI-aware guest software gets 16-bit sound with no Copperline-specific driver work:

[toccata]
enabled = true

No other options exist yet. Unlike the A2065/HostSocket boards above, Toccata’s guest interface is purely register-and-FIFO, not bus-mastering DMA, and its output joins Copperline’s own mixer as a named source (the toccata stem in --audio-stems) rather than talking to a host device directly -- see toccata.md for the register model and audio.md for the mixer/stem-capture integration.

Audio: the MHI decoder board

[mhi] fits an in-tree virtual MPEG-1/2/2.5 Layer III audio decoder board (src/mhi.rs) that serves the Amiga MHI API through the ported mhi_copperline.library (guest/mhi/), rather than modelling any real physical hardware -- MHI-aware players such as AmigaAMP decode MP3 through it exactly as they would through a real MHI decoder board or software MHI driver:

[mhi]
enabled = true

No other options exist yet. Omit the section (or enabled = false) for no board. Like Toccata, its guest interface is register-and-descriptor-queue, not bus-mastering DMA, and its decoded output joins Copperline’s own mixer as a named source (the mhi stem in --audio-stems) rather than talking to a host device directly -- see mhi.md for the register model and audio.md for the mixer/stem-capture integration.

Networking: the bundled HostSocket board

[hostsocket] fits the bundled HostSocket board: guest-facing bsdsocket.library backed by a host-side smoltcp TCP/IP stack, so socket-using applications run with no guest network stack to boot -- see the configuration guide for the user-facing knobs and caveats. Where the A2065 answers “does this driver/stack work,” HostSocket answers “does this application use sockets correctly,” and serves it as the real, everyday bsdsocket.library for software running under Copperline.

Architecturally it is not a native board like the A2065 but a WASM plugin board (previous section) whose module and guest autoboot ROM ship inside the copperline binary:

The board autoconfigs under the Copperline manufacturer ID with product 6 (see below). Its conformance record against the external bsdsocktest suite is crates/hostsocket-plugin/docs/bsdsocktest-status.md.

Crypto: the bundled zz9k board

[zz9k] fits the bundled ZZ9000 SDK crypto board: a register-compatible subset of the MNT ZZ9000’s “SDK v2” service platform (the CORE + MEMORY + CRYPTO services) whose crypto runs host-side, so the SDK’s unmodified Amiga-side software -- its transport library, the zz9k-* tools, and the accelerated AmiSSL build -- gets modern-speed hashing, AEAD, key exchange, and signature verification from an emulated 68k. See the configuration guide for the user-facing knobs and the protocol contract for the register/opcode surface and the exact zz9000-sdk revision it tracks.

Like HostSocket it is a WASM plugin board whose module ships inside the copperline binary: crates/zz9k-plugin/ is the source, the committed artifact is assets/zz9k/zz9k_plugin.wasm (refresh with make in the crate), and config resolution (src/zz9k.rs) expands [zz9k] into a plugin-board entry with the module-path sentinel <bundled-zz9k>. Unlike HostSocket it is pure compute -- no DMA, no network, no host sockets -- so fitting it keeps the machine fully deterministic and replay-safe, and it carries no autoboot ROM or guest driver of its own: the SDK software finds the board via FindConfigDev and speaks to it directly.

It is also the one bundled board that does not autoconfig under the Copperline manufacturer ID: it presents the ZZ9000’s own identity (manufacturer 0x6D6E, product 4 on Zorro III / 3 on Zorro II), because that identity is what the SDK’s board probe looks for. The RTG/USB/Ethernet faces of the real ZZ9000 are absent -- their registers read zero and their services report unsupported -- so installing the real board’s P96 zz9000.card driver against it is unsupported (harmless, but no display).

Graphics: RTG boards

Copperline fits at most one RTG board through [rtg]. Both are functional device-backed boards whose guest drivers program real hardware interfaces; there is no software-aware virtual framebuffer.

Z3660

[rtg] card = "z3660" fits an in-tree RTG (retargetable graphics) board (src/z3660.rs) modelled on the Z3660 accelerator’s FPGA graphics core, driven by the open-source Z3660.card Picasso96 driver in the guest (see the configuration guide for setup). Although the physical board sits in the A3000/A4000 CPU slot, its RTG core autoconfigs as an ordinary Zorro III board -- manufacturer 0x144B, product 1, the real board’s identity rather than Copperline’s own manufacturer ID below -- with one 128 MB window. The driver finds it with FindConfigDev and talks to a 32-bit register file in the first 2 KB of the window; the rest is board RAM, with P96 VRAM from window offset +0x200000 and the GFXData blit-parameter mailbox the driver fills before ringing a blit at +0x3200000. Only the first 64 MB is backed -- the driver never touches the window beyond ~52 MB, so the allocation stays honest without carrying all 128 MB.

Like the A2065 it is a device-backed board, not a [[zorro]] metadata board; the scanout and blitter model live in z3660.rs and the presentation path is described in The video pipeline.

Village Tronic Picasso II and II+

[rtg] card = "picasso2" fits the 1993 Zorro II card with its CL-GD5426; card = "picasso2plus" selects the CL-GD5428-based Picasso II+. Both take 1 or 2 MB of VRAM ([rtg] vram). One physical board enumerates as two consecutive autoconfig identities under Village Tronic manufacturer 2167 ($0877). The original reports serial $00020000; the II+ reports $00100000 on both identities:

productsizeautoconfig spacepurpose
111 or 2 MBZorro II memorylinear VRAM aperture
1264 KBZorro II I/OVGA registers and monitor switch

Product 11 has ERTF_CHAINEDCONFIG set and is not added to the system free memory list; product 12 follows it. Both windows route to one Picasso2 device. Copperline tags the VRAM mapping internally so the shared device can distinguish it without changing the common Zorro device interface. Product 13, the physical board’s jumper-selected segmented mode, is intentionally not implemented because Picasso96 does not support it.

The product-12 register window decodes as follows:

offsetfunction
$0000-$0FFFVGA I/O ports at the same numeric port address
$1000-$1FFFthe same ports with address + 1, for odd ISA byte lanes
$2000-$7FFFunused, reads as open bus and drops writes
$8xxx, $Axxxeven-address write selects the RTG output
$9xxx, $Bxxxeven-address write selects native Amiga pass-through

A 68k word write to $3C4 therefore supplies the sequencer index in its high byte and the $3C5 data in its low byte. The linear memory aperture preserves byte order exactly. Both Cirrus revisions interpret 15/16-bit pixels as little-endian words and 24-bit pixels as B, G, R bytes, matching the *PC and BGR formats advertised by the Picasso96 driver. CRTC part ID $27 reads $90 on the CL-GD5426 and $98 on the CL-GD5428.

Only the II+ drives an interrupt line. A write to register-window offset $1001 enables its INT2 output and $1000 disables it. An enabled VGA vertical interrupt latches at the CRTC-programmed retrace edge in emulated time; writing CRTC $11 with bit 4 clear acknowledges it. The original card stores the board-enable bit for register compatibility but never asserts INT2.

Ateo Concepts Graffity [Zorro II] and [Zorro III]

[rtg] card = "graffityz2"/"graffityz3" fit Graffity, a lesser-known board that reuses Picasso II+‘s CL-GD5428 core under Ateo Concepts’ own registered manufacturer ID 2092 ($082C). Both take 1 or 2 MB of VRAM ([rtg] vram); see graffity.md for the chip-level detail. Graffity ships a first-class Picasso96 board driver (Graffity.card in the classic Aminet Picasso96Install package), so no CyberGraphX or custom driver is needed.

The Zorro II variant enumerates the same way Picasso II does -- a chained VRAM aperture (product 34) and a register aperture (product 33), except the register aperture is 128 KB rather than 64 KB, and its VGA ports sit directly at the window offset (no odd-lane +0x1000 mirror):

productsizeautoconfig spacepurpose
341 or 2 MBZorro II memorylinear VRAM aperture
33128 KBZorro II I/OVGA registers and monitor switch

The Zorro III variant is a single 16 MB window (product 33, no chained identity) with three fixed sub-apertures instead of one shared register window:

offsetsizepurpose
+$40000064 KBmonitor-switch strobe trap only; never reaches VGA registers
+$80000064 KBVGA registers, same direct port addressing as the Zorro II variant
+$C000001 or 2 MBlinear VRAM

Both variants decode the monitor switch the same way Picasso II does ($60 selects RTG, $40 selects native Amiga pass-through), but neither has a board-level interrupt-enable latch: INT2 follows the CL-GD5428 core’s own vertical-blank state directly.

How autoconfig works in Copperline

Everything below happens automatically; it is documented so you can debug a board that the guest OS does not pick up, and so the model is clear when adding new backing types.

At reset every board is unconfigured and the first board in the chain appears in the autoconfig window at $E80000-$E8FFFF (AUTOCONFIG_BASE/AUTOCONFIG_SIZE, src/zorro.rs:22). Kickstart’s expansion library then walks the chain:

  1. Discovery. The board exposes a 16-byte autoconfig ROM, nibble-encoded at even addresses of the window. Byte 0 (er_Type: Zorro generation, memlist flag, size code) is presented as-is; all other bytes are presented inverted, per the hardware convention. The ROM carries the product, manufacturer, and serial from the spec, plus the size code (zorro_ii_size_code / zorro_iii_size_bits).

  2. Base assignment. For a Zorro II board, Kickstart writes the base address high byte to $E80048; for Zorro III it writes a word of the base’s high 16 bits to $E80044. The write configures the board at that base and maps its space.

  3. Chain advance. The configured board disappears from the config window and the next unconfigured board appears. Kickstart can also write $E8004C to “shut up” a board it cannot place, removing it without mapping.

Successful configuration is logged:

zorro II board "fast RAM" autoconfigured at 0x00200000
zorro II board "Copperline" autoconfigured at 0x00E90000

Once configured, accesses inside a board’s window are routed by ZorroChain::region_at into the board’s backing storage. RAM-backed board space is external-bus memory: it runs at the CPU clock and does not contend on the chip bus (see The timing model). The standalone ZorroChain::power_on_reset API returns every board to the unconfigured state and zeroes its RAM by default. A machine-level cold boot returns it to the same unconfigured state but uses the selected [memory] init fill policy for the RAM.

Device-backed boards (BoardBacking other than Ram) differ in three ways:

On CDTV machines the DMAC occupies the config window first; the Zorro chain follows once it is configured, matching real-machine autoconfig order.

On a CD32 with fmv_rom, the Commodore Full Motion Video cartridge instead occupies the first Zorro II slot, as its module ROM expects: manufacturer 514, product $6A, serial $0028001E, 1 MiB memory-space board with DiagArea vector $80 and the no-shut-up flag. Its hardware model and address map are documented in Peripherals and expansion.

The Copperline manufacturer ID

Copperline’s built-in virtual boards autoconfig under manufacturer ID 5192 (0x1448) -- the registered ID of dec0de Consulting, which also makes the real ROMulus flash-ROM board. The product numbers under it are:

ProductBoard
1ROMulus (physical hardware; not emulated)
2Copperline identification board
3Built-in fast RAM ([memory] fast)
4Built-in Zorro III RAM ([memory] z3)
5Copperline services board (host [[filesys]] mounts; filesys.rs)
6HostSocket bsdsocket.library board ([hostsocket]; hostsocket.rs)
7MHI virtual MPEG audio decoder board ([mhi]; mhi.rs)

(The bundled zz9k crypto board is the one exception: it autoconfigs under MNT’s manufacturer ID 0x6D6E with the ZZ9000’s own product numbers, because the ZZ9000 SDK detects the board by that identity.)

The identification board (BoardSpec::copperline_id) is always added to the chain (unless disabled, below) so guest software can detect that it is running under Copperline rather than on real hardware or another emulator -- for example identify.library calling FindConfigDev(5192, 2). It is the smallest legal Zorro II board (64K), is kept out of the Exec free-memory list, and never autoboots, so it sits inertly on the chain without changing the machine’s usable memory map. Its autoconfig serial number carries the running Copperline version packed as major << 16 | minor << 8 | patch, so a tool can report the exact version and not just the emulator name.

The board is added last, after the RAM and [[zorro]] boards, so those keep the base addresses they would get without it. Set identify = false in the configuration to drop it entirely (for a chain with no emulator-identifying board); see the identify option in Configuration reference.

Adding a board in Rust

Most functional boards should be WASM plugins (above). Add an in-tree board in Rust only when it needs host integration or performance that a plugin cannot give (the A2091 SCSI controller, src/a2091.rs, is the worked example). In-tree functional boards implement the ZorroDevice trait (src/zorro_device.rs) and are stored as a BoardDevice enum variant in Bus::devices; the chain maps each board’s window to a BoardBacking::Device(slot) index into that vector.

  1. Implement ZorroDevice for the board (register read/write, tick, int2_line/int6_line, reset); DMA goes through the DeviceHost passed to each call. Add a BoardDevice variant wrapping it (src/zorro_device.rs) -- append it at the end of the enum: bincode encodes variants by index, so inserting one anywhere else renumbers every saved state. Extend all of BoardDevice’s forwarding match arms (read, write, peek_word, tick, int2_line, int6_line, is_idle, next_event_cck, take_activity, reset, kind) for the new variant.

  2. Provide a BoardSpec constructor with backing: BoardBacking::Device(slot), mirroring the existing ones -- note the full field set (a stale example here previously omitted three of them):

    pub fn fast_ram(size_bytes: usize) -> Self {
        Self {
            name: "fast RAM".into(),
            version: ZorroVersion::II,
            manufacturer: COPPERLINE_MANUFACTURER_ID,
            product: PRODUCT_FAST_RAM,
            serial: 0,
            size_bytes,
            backing: BoardBacking::Ram,
            memlist: true,
            memory_space: true,
            chained: false,
            window: 0,
            diag_vec: None,
        }
    }
  3. Instantiate the device in build_machine (src/emulator.rs, not src/main.rs): assign it a slot, add its BoardSpec to the chain, and push the BoardDevice onto Bus::devices (the A2091 block, and the lide-compatible IDE board’s block right after it, are worked templates). Bump savestate::STATE_VERSION (with a comment explaining why) whenever the serialized layout changes -- adding a BoardDevice variant always counts, since the enum itself is part of every save state.

  4. Add unit tests next to the existing ones in src/zorro.rs, which cover ROM nibble encoding, Zorro II/III base assignment, chain advance, shut-up, and power-on reset -- they are the best worked examples of the protocol.

Keep the hardware-first rule in mind: boards model autoconfig hardware behaviour, and anything guest-visible (IDs, sizes, ROM bytes) should match what a real board of that class would expose.