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 MHI decoder board: mailbox register protocol

This chapter specifies the mailbox protocol shared by the host board (src/mhi.rs) and mhi_copperline.library (guest/mhi/). Protocol changes must follow the versioning rules.

Offsets are relative to the board’s autoconfigured window. The protocol can be implemented on another bus or emulator without changing register semantics; see porting.

MHI itself -- MHIAllocDecoder/MHIQueueBuffer/MHIGetStatus/etc., the Amiga-side API AmigaAMP and other players call -- is not this board’s wire format. MHI is a library API, implemented by mhi_copperline.library (the guest side of this project); this board never sees an MHIP_* or MHIQ_* constant, a decoder handle, or a signal mask. See The MHI-API/board split for exactly what stays guest-side and why.

Zorro identity

Register map

All registers are 16-bit and word-aligned; addresses are offsets within the 64 KiB window. RO = guest may only read; WO = guest may only write (reads of a WO register return 0); RW = both. Offsets not listed are reserved: they read as 0x0000 and silently discard writes, in every protocol version, so a guest built against a newer spec than the board implements degrades safely on old fields rather than reading garbage. See Access size and alignment for the rules move.b/move.w accesses follow.

OffsetNameWidthAccessReset valueGroup
0x00VERSIONwordRO0x0002Capability/version
0x02CAPSwordROboard-fixedCapability/version
0x04STATUSwordRO0x0000 (STOPPED)Status/control
0x06CONTROLwordWO--Status/control
0x08INTREQwordRW0x0000Interrupts
0x0AINTENAwordRW0x0000Interrupts
0x0CQUEUE_DEPTHwordROboard-fixed (16)Descriptor queue
0x0EQUEUE_COUNTwordRO0x0000Descriptor queue
0x10DESC_ADDR_HIwordWO--Descriptor queue
0x12DESC_ADDR_LOwordWO--Descriptor queue
0x14DESC_LEN_HIwordWO--Descriptor queue
0x16DESC_LEN_LOwordWO--Descriptor queue
0x18DOORBELLwordWO--Descriptor queue
0x1ACOMPLETED_COUNTwordRO0x0000Completion/reclaim
0x1CPARAM_SELECTwordRW0x0000Param latches
0x1EPARAM_VALUEwordRWper-param defaultParam latches
0x20-0xFFFFreserved----0x0000--

The window is 64 KiB (the smallest legal Zorro II size) even though only 32 bytes contain registers. The remaining space is reserved for future protocol versions without moving the board to a bigger window, which would change its autoconfig identity.

Capability/version registers

Status and control

Interrupts

INT2, level-sensitive: the line is asserted whenever (INTREQ & INTENA) != 0. Both registers share one bit layout:

BitMeaningRaised when
0BUFFER_DONEA descriptor finished playing out and was reclaimed (COMPLETED_COUNT advanced)
1OUT_OF_DATASTATUS transitioned into OUT_OF_DATA
2QUEUE_OVERFLOWA DOORBELL write was dropped because the queue was full (diagnostic; not part of MHI’s own semantics, but useful to a guest that mis-tracks QUEUE_COUNT)
3-15reservednever set in this version

Descriptor queue and doorbell

The board holds a FIFO queue of up to QUEUE_DEPTH descriptors, each an (Amiga address, length) pair identifying a buffer of encoded MPEG bitstream in Amiga memory that the guest handed over via MHIQueueBuffer. 16 descriptors deep (QUEUE_DEPTH reads back the constant 16) -- generous enough that AmigaAMP-style double/triple buffering never back-pressures on the board even with small buffers, without pinning an unreasonable amount of encoded audio in flight (at typical 32 KiB MP3 buffers, 16 deep is 512 KiB of staged bitstream, well within a stock machine’s chip+fast memory).

To enqueue a descriptor:

  1. Write the Amiga source address, high word then low word (either order is accepted; both are independent latches -- see Access size and alignment), to DESC_ADDR_HI/DESC_ADDR_LO.

  2. Write the buffer length, high word then low word, to DESC_LEN_HI/DESC_LEN_LO. Length is a full 32-bit byte count (Zorro II’s 24-bit address space bounds it further in practice, but the register pair itself does not truncate).

  3. Write any value to DOORBELL. This is what actually commits the staged address+length as a new descriptor at the tail of the queue -- steps 1-2 only load latches that DOORBELL reads at the moment it is written; nothing is queued until the doorbell write happens.

If the queue is full (QUEUE_COUNT == QUEUE_DEPTH) when DOORBELL is written, the descriptor is dropped (the staged address/length are left as-is, so the guest may simply retry once space frees) and INTREQ.QUEUE_OVERFLOW is set. This mirrors MHIQueueBuffer’s own contract: it returns FALSE when a buffer cannot be queued, and the guest library is expected to poll room before calling it via QUEUE_COUNT < QUEUE_DEPTH, exactly as MHIGetEmpty polls for reclaimed buffers. QUEUE_OVERFLOW exists as a diagnostic for a guest bug (racing the check), not as a code path production drivers should hit.

A zero-length descriptor (DESC_LEN_HI:DESC_LEN_LO == 0 at the DOORBELL write) is accepted, not dropped, but completes immediately: it has no bytes to decode and no audio to play out, so it is never appended to the queue -- QUEUE_COUNT does not move -- and instead COMPLETED_COUNT advances and INTREQ.BUFFER_DONE is set on the spot. Because it never touches QUEUE_COUNT, it specifically does not count as the 0-to-1 transition Out-of-data semantics describes: a zero-length DOORBELL write while STATUS == OUT_OF_DATA completes (and raises BUFFER_DONE) without moving STATUS back to PLAYING.

Completion and reclaim

The board does not hand back buffer pointers or a per-descriptor ring index -- descriptors complete strictly in FIFO order (the guest library already knows, in its own client-side queue mirroring MHIQueueBuffer’s call order, which buffer is next), so a single monotonic counter is sufficient and simpler than a ring of completion records:

Param latches

MHI’s tone/volume/panning controls (MHISetParam) are modeled as a two-register select/value mailbox rather than one register per parameter -- MHI defines a long tail of them (MHIP_BAND1..MHIP_BAND10 for a 10-band EQ, on top of volume/panning/bass/mid/treble/crossmixing/ prefactor), and a fixed one-register-per-param layout would either waste window space up front or need a protocol bump the day a client asks for one more band. The mailbox pattern keeps the register count fixed regardless of how many parameters the guest library ends up exposing.

DSP chain

Every latch is applied, every produced sample, in one fixed order -- order is audible, so it is as much a part of the contract as the latch ranges themselves:

decoded PCM -> prefactor -> bass -> mid -> treble -> volume -> pan -> crossmix -> (FIFO -> resampler)

This runs entirely in the causal native-rate producer, before the FIFO a non-causal resampler pulls from (see Determinism and timing) -- so a latch write’s effect lands at the decoded stream’s own sample rate, and the resampler never needs to know params exist. A latch change takes effect at the next sample this chain produces; there is no ramping or click/zipper suppression in this version (matching a cheap hardware decoder with no smoothing of its own -- a later VERSION could add it without moving any existing field). Filter state (see “Tone filters” below) is genuine machine state and round-trips through savestates exactly like the decoder’s own reservoir does.

Access size and alignment

The window behaves like a 16-bit peripheral: every register above is a plain word (16-bit) register at an even offset, and the board only ever decodes even addresses.

Out-of-data semantics

STATUS transitions to OUT_OF_DATA (3) exactly when playback drains the queue: the board is in PLAYING, the last outstanding descriptor finishes playing out, and QUEUE_COUNT reaches 0 with nothing new enqueued in the same instant. That transition raises both INTREQ.BUFFER_DONE (for the descriptor that just completed) and INTREQ.OUT_OF_DATA together -- a guest servicing only BUFFER_DONE and checking STATUS afterward, or one servicing both bits, both see a consistent picture.

While OUT_OF_DATA, the board is not stopped -- it matches MHI’s own description of the state (“run out of data but still waiting for more”): decoder cross-frame state is preserved exactly as PAUSE preserves it, and audio output is silence in the meantime (no repeat-last-sample holdover the way Toccata’s FIFO underrun behaves -- MPEG frames are not individually meaningful to hold on to). The moment a DOORBELL write successfully enqueues a new descriptor while STATUS == OUT_OF_DATA (QUEUE_COUNT goes from 0 to 1), the board resumes playback automatically and STATUS returns to PLAYING with no CONTROL=PLAY required -- a guest need not notice OUT_OF_DATA at all if it always keeps the queue fed; it exists for the client that briefly runs dry (the common case an MHI-aware application like AmigaAMP polls for, e.g. to know a track has finished once no more data is coming).

CONTROL=STOP from OUT_OF_DATA behaves exactly as from any other state: transition to STOPPED (a no-op on the already-empty queue).

Undecodable bitstream content (bytes that are not a valid Layer III frame at all, or that carry a sync-valid-looking header but fail to decode -- corrupt encodes, or a hostile/buggy guest handing the board arbitrary bytes) is skipped exactly as any real decoder resyncs across junk: the board hunts forward for the next decodable frame, consuming and completing descriptors as their bytes are skipped past, the same as if those bytes had decoded into audio. That resync work is budgeted per tick rather than run to completion in one step, so a descriptor consisting entirely of undecodable bytes does not stall the emulation; it still completes and reaches OUT_OF_DATA once the queue genuinely empties out -- the resync merely spreads across a handful of ticks instead of resolving within one. (Skipped bytes are not paced at the decoded audio’s sample rate the way played-out bytes are -- there is no audio to pace them by -- so an all-garbage descriptor drains in far less emulated time than the same bytes of genuine audio would take to play out.) Nothing about the guest-visible contract changes -- COMPLETED_COUNT/QUEUE_COUNT/INTREQ still only ever advance in whole-descriptor, whole-frame steps -- only the emulated wall-clock-adjacent pacing of how many ticks that takes.

Seek-entry hardening

MHI itself has no seek call -- the ABI is exactly MHIAllocDecoder/ MHIFreeDecoder/MHIQueueBuffer/MHIGetEmpty/MHIGetStatus/MHIPlay/ MHIStop/MHIPause/MHIQuery/MHISetParam, nothing more. Seeking is entirely the player’s own responsibility: it calls MHIStop, repositions its own file read to wherever it wants to resume, and MHIQueueBuffers buffers starting at that new position. From the board’s side, a seek is indistinguishable from any other STOP followed by fresh descriptors -- there is no seek-specific register or command, and none is needed.

The board must handle both parts of a seek:

An incomplete trailing frame (the queued bytes end mid-frame -- too few bytes for the decoder to tell whether they are even a valid sync, let alone decode them) is different from undecodable content: it is not junk to skip, it is a real frame waiting on the rest of its bytes, which a subsequent DOORBELL may yet supply (the guest’s next descriptor can complete a frame split across a buffer boundary, and this is expected to happen routinely -- see “Determinism and timing” below). The board therefore holds those bytes and does not touch QUEUE_COUNT/STATUS while it waits. If no further DOORBELL ever completes the frame, though, an implementation must not wait forever: QUEUE_COUNT/STATUS need to recover in bounded time so a guest polling for completion is not wedged by a stream that simply ended mid-frame. The exact bound is an implementation choice, not part of this protocol’s guest-visible contract -- Copperline’s is documented in its own implementation notes below.

Determinism and timing

The board consumes a descriptor’s bitstream at the decoded audio’s own emulated-time rate, the same principle as every other in-tree audio device (see Audio sink service (src/audio/)'s determinism section and Toccata and the AD1848 model's “mixer cadence” for the worked example): a decoded MPEG frame (1152 PCM samples at the stream’s sample rate) is not considered “played out” -- and its bytes are not considered consumed from the descriptor, and COMPLETED_COUNT/INTREQ do not advance -- until that many emulated sample-clock ticks have elapsed, exactly as if the samples were being produced for playback in real time. A descriptor’s completion event and any INT2 assertion it causes are therefore also emulated-time events, not host-wall-clock ones: they fire the tick a frame’s worth of emulated sample time has elapsed, identically whether the host machine runs in real time, is throttled, or is warped as fast as the host CPU allows. This is what makes a scripted scenario against this board reproducible byte-for-byte and makes --audio-wav/stem captures of its output deterministic across runs, the same guarantee every other Copperline audio path already gives.

The MHI-API/board split

This board’s registers are deliberately innocent of MHI’s own numbering -- no MHIF_*, MHIP_*, or MHIQ_* constant appears in this protocol, and the split is intentional, not an oversight:

ConcernLives in
Decoder identity strings (MHIQ_DECODER_NAME/_VERSION, MHIQ_AUTHOR, the MHIQ_CAPABILITIES MIME-type string)Guest library (guest/mhi/) -- compile-time constants; they describe the library, not the board
MHIQ_IS_HARDWARE/_IS_68K/_IS_PPCGuest library -- static answers (this is a real register-mailbox device the library talks to over the Zorro bus, so MHIQ_IS_HARDWARE answers true; it runs no 68k/PPC code of its own, so both processor queries answer false)
MPEG version/layer/bitrate-mode support (MHIQ_MPEG1/_MPEG2/_MPEG25, MHIQ_LAYER3, MHIQ_VARIABLE_BITRATE)CAPS register (0x02) -- genuinely board-reported, since a future board revision’s decoder could differ
MHIQ_JOINT_STEREOGuest library -- fixed MHIF_SUPPORTED; decoding joint-stereo Layer III is inherent to any conforming decoder, not a distinct board capability worth its own CAPS bit
Tone/volume/output query flags (MHIQ_VOLUME_CONTROL, MHIQ_PANNING_CONTROL, MHIQ_BASS_CONTROL, MHIQ_TREBLE_CONTROL, MHIQ_MID_CONTROL, MHIQ_PREFACTOR_CONTROL, MHIQ_CROSSMIXING, MHIQ_5_BAND_EQ, MHIQ_10_BAND_EQ)Guest library -- keyed off CAPS bit 6 for the seven params this board’s param latch table defines (indices 0-6: volume, panning, bass, mid, treble, crossmixing, prefactor): MHIF_UNSUPPORTED against a version-1 board (bit 6 clear -- the latches exist and round-trip, but nothing applies them to decoded PCM, so answering MHIF_SUPPORTED would tell a client its MHISetParam calls are audible when they are not), MHIF_SUPPORTED when bit 6 is set (bit 6 set). One guest library binary answers correctly either way -- see CAPS’s own bit-6 note above. The 5/10-band EQ stays MHIF_UNSUPPORTED regardless of bit 6, until a later VERSION adds MHIP_MIDBASS/MHIP_MIDHIGH/MHIP_BAND1-MHIP_BAND10 equivalents at reserved indices 7+
Decoder handle, client task pointer, signal mask (MHIAllocDecoder/MHIFreeDecoder)Guest library only -- entirely a host-side (Amiga-side) bookkeeping concept; the board has no notion of “a handle” and serves exactly one client at a time
Transport (MHIPlay/MHIStop/MHIPause), status (MHIGetStatus), queueing (MHIQueueBuffer/MHIGetEmpty), params (MHISetParam)Guest library translates 1:1 to/from this board’s CONTROL/STATUS/descriptor-queue/PARAM_* registers

Keeping MHI’s own vocabulary entirely out of the wire protocol is what lets this spec describe a board that could serve any MHI-shaped guest front-end (or, in principle, a non-MHI player that just wants a hardware MPEG decoder) without the register file encoding one particular API version’s constants -- and it is what makes the split in Porting to another emulator below possible without also porting MHI-specific glue.

Versioning

VERSION (0x00) is the register-protocol version; the current value is 2. Changes to offsets, widths, access rules, bit meanings, or documented semantics require a version bump. Preserve existing register meanings and use reserved offsets for additions.

The guest library requires at least version 1. It accepts newer versions and checks CAPS for optional features. A newer board must therefore remain compatible with the register operations older drivers use.

Version 2 applies parameter latches 0-6 to decoded PCM and sets CAPS bit 6. Version 1 stores and reads back the same values but leaves the audio unchanged. Register locations and widths are identical in both versions.

Porting to another emulator

Everything above is expressed purely in terms of the autoconfigured window’s own offsets and the Amiga’s 24-bit address space -- nothing references Copperline’s internal types, its ZorroDevice/DeviceHost Rust traits, or its savestate format. An unrelated emulator wanting to support the same guest library and the same MHI test assets needs only to:

  1. Autoconfig a Zorro II board at manufacturer 0x1448, product 7, 64 KiB, no autoboot ROM.

  2. Implement the register map above over its own bus/register dispatch.

  3. On a successful DOORBELL write, copy DESC_LEN_HI:DESC_LEN_LO bytes from DESC_ADDR_HI:DESC_ADDR_LO in the emulated Amiga’s address space into wherever its own MPEG decoder wants the bytes -- by whatever internal mechanism that emulator already uses to read guest memory from a device model (a literal DMA engine, a direct memory-array read, anything at all; this spec does not constrain it).

  4. Pace descriptor consumption and COMPLETED_COUNT/INTREQ updates to the decoded audio’s own emulated-time rate, per Determinism and timing, so that scripted scenarios and captures built against one implementation reproduce on the other.

Copperline’s own implementation notes -- the Symphonia-based decoder choice, how push_source("mhi", ...) joins the mixer, BoardDevice wiring, and savestate serialization of in-flight decoder/queue state -- are Copperline-internal and out of scope for this document; they belong in src/mhi.rs’s own doc comments and this page’s future host-board implementation notes once WP3 lands, not in the protocol spec itself.

Copperline implementation notes

This section summarizes src/mhi.rs ([mhi], feature-gated behind the default-on mhi build feature); it does not change any of the protocol content above.