Spectrum Next FPGA emulation

The ZX Spectrum Next is not a chip: it is a machine implemented as an FPGA core (TBBlue). zxplay_go therefore emulates the Next by treating the FPGA source itself as the schematic. zxnext.vhd plus the per-device VHDL modules under the TBBlue core are the oracle for every hardware question; a reference emulator serves only as a behavioural oracle for whole-boot comparisons. This document describes how that plays out in code.

Diagrams: memory-decode.drawio, next-video-pipeline.drawio, next-boot-chain.drawio.

The conformance method

Per-feature "validated against the VHDL" claims are spot checks, so the project replaces spot-checking with enumeration:

  1. VHDL_CONFORMANCE.md lists the FPGA surface in nine axes (reset defaults, write semantics, read-back mux, port decode, interrupts, CPU ops, paging, divMMC/SD, video) and maps every row to an implementation and a test.
  2. Each subsystem carries a fpga_golden_test.go replaying GHDL simulations of the real VHDL (testbenches in _tools/<subsystem>-vhdl-test/).
  3. The cold boot of real NextZXOS is the integration test that the rows are complete.
  4. Divergence hunting against a reference emulator uses the dev modes in cmd/zxplay_go (--next-lockstep, --next-nrdiff, --next-memdiff, --next-bisect) keyed on a shared guest clock (FRAMES sysvar).

Source comments cite exact VHDL lines. When you change any of this code, carry the citation with it.

External validation material (not yet integrated; tracked on the ZX Play roadmap initiative for this documentation):

The NextReg file (pkg/next/nextregs)

Dispatcher models the register file behind ports $243B (select) and $253B (data) and the Z80N NEXTREG opcodes:

Wiring (pkg/next/wire.go)

Every register-to-subsystem connection is a WireXxx function, applied together by Wire(opts). Centralising the wiring means the production bus and the test harness are configured by the same code and cannot drift. Highlights:

Memory: MMU and the overlay mux (pkg/memory)

Diagram: memory-decode.drawio.

The Next view of memory is 8 slots of 8K over a 2 MB RAM array (banks 0..111 allocated for ModelNext), with classic 16K paging still live underneath. Read dispatch mirrors the FPGA's final memory mux, highest priority first: FPGA bootrom, divMMC overlay, Multiface overlay, Alt-ROM redirect, config-mode RAM window, Layer 2 read paging, 8K MMU override, then the classic path (Beta ROM override, 16K page maps). Writes mirror the order with documented differences (the bootrom only masks reads while config mode is active; Alt-ROM bit 6 splits read/write redirect).

Coexistence rule: classic port writes ($7FFD/$1FFD/$DFFD) re-sync the 8K slot table for the pages they touch and clear those slots' MMU override; SetMMU sets the override. Last writer wins, exactly as on hardware. Cold RAM zero-fills (matching SDRAM and the reference), with an optional pseudo-random fill behind an env var for the uninitialised-read detector.

Contention: the classic pattern applies at 3.5 MHz only. At any turbo speed the FPGA does not assert contention, and neither does the emulator. NR$08 bit 1 disables RAM contention outright.

Hot-path caches (#187, wasm exec cost): Read/Write first consult per-8K-slot fast tables (readFast/writeFast in pkg/memory) that point straight into the backing RAM half-bank whenever a slot's dispatch provably reduces to a plain RAM access — no hooks/observers, no uninit tracking, Layer 2 $123B paging off, and (bottom 16K) none of the overlay cascade active. The bottom two slots are included because games run whole engines from MMU RAM there (Atic Atac, banks 16/17): the divMMC pager reports its effective decode through the memory's bottomOverlayProbe and calls InvalidateBottomFast (O(1)) from every transition choke point — automap page-in (all variants incl. the delayed next-M1 conversion), $1FFx off-area page-out, RETN unmap, port $E3 (CONMEM/bank/MAPRAM), enable, NR$09 MAPRAM escape — at the pacer's NMI cadence. Multiface/bootrom/config/Alt-ROM/EFF7/beta and the classic peripheral enables invalidate from their setters; paging/MMU/model/hook mutations invalidate the whole table. Timing is untouched: waits and contention are charged in the CPU cycle helpers, not in Read/Write. Value identity is pinned by pkg/memory/fastpath_xcheck_test.go (randomized churn vs a slow-path reference, plus the divMMC transition shapes).

Boot chain

Diagram: next-boot-chain.drawio.

The faithful path (default, and the only desktop path):

  1. Power-on arms the FPGA bootrom (tbblue_loader.rom, GPLv3, embedded) mirrored over $0000-$3FFF, and sets config mode.
  2. The bootrom reads TBBLUE.FW from the SD card over SPI, copies it to $6000, and jumps. SPACE during the splash opens the firmware config menu, which can boot any machine personality.
  3. The firmware streams personality ROMs through the config-mode RAM window (NR$04 selects the target page; bootrom masks reads but writes pass through), seeds NR$05-$09/$80-$83, then writes NR$03. The first NR$03 write clears the bootrom mask and sets the machine type.
  4. A soft reset (NR$02 bit 0) lands in NextZXOS. NR$02's reset_type is a 3-bit shift-history register, so reads return $00 → $02 → $01 across the boot; NextZXOS uses that to distinguish its staging pass. Soft reset preserves NR$03, resets paging and the MMU, re-arms the divMMC entry points, and re-arms the bootrom only when config mode is active (that is what makes the config-menu machine selection work).

Licensing shape: only the GPLv3 loader is embedded. enNextZX.rom, enNxtmmc.rom and the SD content are user-installed (pkg/next/install, with an official-distro downloader) or injected in the browser (InjectROM via zxRegisterROM).

Browser accelerators (opt-in via go.env in GoEmulator.js, never core defaults):

Video pipeline

Diagram: next-video-pipeline.drawio.

Per frame the ULA renders its classic base image, then for each active scanline re-renders the ULA row through the live Next ULA palette with the Copper interleaved per 14 MHz HALF-pixel and composes it — on rows whose state can change mid-row, through the FUSED per-half-pixel pass (#183, r59: every layer's palette lookup and the mixer state read live inside the interleave); event-free rows take a provably-identical pair-coalescing stride. The live Next output frame is always 640×256 (two output pixels per frame pixel — the FPGA's own 14 MHz pixel bus shape, zxnext.vhd:6543-6552). The pieces:

$57/$5B and the auto-increment NextRegs $75-$79. The engine keeps the FPGA's TWO sprite indexes (sprites.vhd:591-655): the NR$34 mirror (target of NextReg attribute writes; NR$34 reads it live) and the IO-port cursor (ports $303B/$57), tied together by NR$09 bit 4 ("lockstep"). Transparency compares each pixel's RAW pattern value against the NR$4B colour (full byte in 8bpp, low nibble in 4bpp, sprites.vhd:971) — palette index 0 is drawable, so the compositor reads per-pixel coverage (LineCoverage) rather than sentinel values. Rendering models the FPGA's per-line budget (one 448-count raster line of 28MHz FSM cycles: 1 per sprite qualified + 1 per pixel column) — sprites past the budget drop off the line and $303B bit 1 latches — and the 9-bit X wrap that shows high-X sprites on the left edge (sprites.vhd:855).

Raster feedback: ULA.BeamPosition() derives (line, hpos) on the 3.5 MHz-REFERENCE timeline measured from the CPU's per-frame origin (z80.FrameOriginRefTstates, re-recorded at every frame boundary and shared with the frame-INT assert offset), wired to NR$1E/$1F, so DI'd raster-polling code (NextGuide) works and raster reads can never drift from interrupt placement. The FPGA's cvc counter runs on the VIDEO clock (zxnext.vhd:5982-5986), so the beam must NOT advance with raw CPU T-states: at 28 MHz that swept the frame 8× per real frame — the r49 TX-1696 finding (work item #166), where the game's NR$1F ≥ 192 raster gate read garbage and its SP push-fill collided with the frame INT. Pinned by TestBeamPositionTurbo.

Interrupts

DMA (pkg/next/dma)

The zxnDMA on ports $6B (zxnDMA mode) and $0B (Z80-DMA compatibility, the legacy MB-02/Datagear decode) speaks the Z80-DMA WR-group protocol: variable length register groups decoded by a pending-follow-byte state machine, WR6 commands (RESET/LOAD/CONTINUE/ENABLE/DISABLE/READ MASK/INITIATE READ SEQUENCE/READ STATUS/REINIT STATUS), the read-back state machine mirroring dma.vhd's reg_rd_seq_s (each read returns the aimed register and advances to the next masked one; $A7/$BB aim at the first masked register, $BF at status), and the status byte "00"&endofblock_n&"1101"&atleastone. Both ports reach the one controller (ULA.dmaClaims); each access latches dma_mode from the port used (zxnext.vhd:1811-1819), and the mode seeds the byte counter 0 / -1 at LOAD/CONTINUE/auto-restart — so a Zilog-mode block of length N moves N+1 bytes, the genuine Zilog convention. LOAD latches the source/destination pointers by the direction in force at LOAD (dma.vhd:646-663) and a later direction flip transfers with the stale roles (per-byte stepping, memory-vs-IO cycle type and port A/B read-back follow the live direction bit) — the Misc/ZilogDMA border-text behaviour. The prescaler delay is turbo-scaled per dma.vhd's timer (prescaler*4^turbo/2 CPU T-states per byte). Continuous mode stalls the CPU by charging cycles; burst+ prescaler mode interleaves with CPU execution via a per-instruction Step paced on the MONOTONIC reference clock (the raw per-frame T-state counter wraps), and an auto-restart block reloads and repeats until DISABLE. Interrupts (2026-08-01): the zxnDMA generates NONE — the FPGA's dma.vhd carries the Zilog interrupt-control machinery commented out, so the no-op interrupt commands are conformant. What NR$CC-$CE gate is the DMA-DELAY condition (zxnext.vhd:2005-2008): an im2-chain device outside idle with its enable bit set — or an outstanding NMI with NR$CC bit 7 — holds a transfer off the bus between bytes until the RETI/RETN release (IM2Block.DMAPause + dma.SetPauseFunc, composed by next.WireDMAPause; continuous blocks split at the pause instant and charge only the bytes moved, burst schedules restart at the unpause instant; in pulse mode the chain is held reset so only the NMI arm applies). Not modelled: match logic and DMA-vs-CPU bus contention; read/write cycle lengths are charged in CPU T-states as a model convention, and a continuous transfer's port writes all land at one raster instant (known-gaps.md).

Storage: divMMC, SD, esxDOS, .NEX

Peripheral blocks

Working on this area