3.6 Peripherals¶
Everything in src/peripheral/ is a device that hangs off the machine rather
than being part of it — a co-processor, a storage interface, a timer, a serial
port. Some are Next-era additions, some are 1980s add-on hardware the Next
reimplements in its FPGA so that old software still finds them where it expects.
They share a shape. Each is a plain C++ class with no SDL, no Qt and no
knowledge of Emulator; the wiring lives in Emulator::init(), which registers
their ports, and in tick_devices_after_instruction(), which advances the ones
that need to move on their own. Their interrupt outputs are collected by the
IM2 controller in src/cpu/im2.*, and the three that can raise a
non-maskable interrupt go through the arbiter described at the end of this page.
All of them implement save_state and load_state, because rewind snapshots
the whole machine (see
Save state, rewind and determinism).
Copper¶
The Copper is a tiny co-processor that runs in step with the video raster. It holds a short program of its own and executes it as the beam sweeps the screen, so a Next can change hardware registers at an exact point on an exact scanline without the CPU having to sit in a timing loop waiting for it. That is what makes gradient skies, mid-screen palette swaps and split-scroll playfields cheap on this machine. Without it those effects either cost the CPU its whole frame or tear.
copper.{h,cpp} models device/copper.vhd: a 1K × 16-bit instruction RAM
running just two instructions, WAIT hpos,vpos and MOVE nextreg,value,
against the raster counters. Its NextREG writes bypass the CPU path entirely.
Programming it is via NextREG 0x60-0x64; there is no Copper port.
The non-obvious part is which counters it compares against.
Copper::execute(hc, vc, …) takes the VHDL hc_ula / cvc pair, where hc is
the 7 MHz ULA pixel counter, zeroed 11 pixels before the active display — it is
not the 28 MHz master-cycle offset into the line, and not anchored at the
raw line start. jnext passed the raw 28 MHz offset, which put
every WAIT four times too early and one row off (GH #181). WAIT also
compares hc >= (hpos<<3)+12 in wrapping 9-bit arithmetic, so hpos=63 yields
4, not 516.
DMA¶
A DMA controller copies blocks of bytes — memory to memory, or memory to and from an I/O port — without the CPU touching each one. On a Next this is how software moves a Layer 2 screen, decompresses into video RAM or streams sampled audio out to a DAC at a fixed rate; the CPU is stopped for the duration and the controller drives the bus itself.
dma.{h,cpp} models device/dma.vhd: one transfer engine behind two
programming ports, 0x6B (ZXN) and 0x0B (Z80-DMA compatible). The difference
between them is the counter's initial value, not the register protocol.
Memory and I/O access go through four callbacks bound to Mmu and
PortDispatch, so a DMA-driven OUT re-enters the normal dispatcher — which is
why PortDispatch::write() has to tolerate a nested write. execute_burst(16)
runs from the instruction loop, and dma_holds_bus() both stalls the CPU and
silences the DMA ports. In burst mode the CPU is released during the prescaler
wait; in continuous and byte mode it is not.
DivMMC¶
DivMMC is the Next's SD-card interface, inherited from a family of third-party Spectrum add-ons. It is more than a card reader: it carries its own 8 KB ROM and 128 KB of RAM which page in over the bottom of the address space, so that firmware such as esxdos can take over ROM entry points and offer a filing system to software that predates the existence of storage on this machine.
The mechanism that makes that possible is automap. Rather than requiring the
guest to page the DivMMC in explicitly, the hardware watches instruction fetches
and maps itself in when the CPU fetches from one of a set of trigger addresses —
the RST vectors and the ROM's tape routines — then unmaps again when execution
leaves through a designated window. Software that calls RST 8 finds the DivMMC
firmware answering, and never knew it was there.
divmmc.{h,cpp} models device/divmmc.vhd: an 8 KB ROM plus 16 × 8 KB RAM
pages overlaid on slots 0 and 1, controlled both by port 0xE3 (conmem bit 7,
mapram bit 6, bank in bits 3:0) and by the automap path.
It has two independent gates, and conflating them breaks boot.
port_io_enable_ is NR 0x83 bit 0 and defaults to set; nr_0a_4_enable_ is
NR 0x0A bit 4 and defaults to clear. The VHDL output gate is
(conmem OR automap) AND port_io_en, so conmem works with NR 0x0A bit 4
still clear — which is precisely what esxdos's IM1 handler relies on during
early boot, long before firmware sets that bit. Only the automap path needs
both (zxnext.vhd:4112), so is_active() checks port_io_enable_ alone.
The trigger addresses are not hard-coded: NR 0xB8-0xBB select which RST
vectors and tape traps map, whether each is instant or delayed, and whether the
0x3D00 wildcard and the 0x1FF8-0x1FFF unmap window are live.
SPI and the SD card¶
SPI is the bus the SD card sits on, and spi.{h,cpp} (serial/spi_master.vhd)
is the master: port 0xE7 selects a device, port 0xEB exchanges a byte. It is
zero-latency — a write completes the whole byte exchange synchronously — so
spi_wait_n() is constantly asserted. That is faithful at byte granularity but
not at cycle granularity, which matters only to a DMA-over-SPI consumer.
sd_card.{h,cpp} implements SpiDevice over a raw .img file, with enough of
the SPI-mode command set for NextZXOS, esxdos and the firmware boot — including
CMD18 multi-block streaming, which is how tbblue.fw reads /TBBLUE.FW. The
image is opened read-write and guest writes persist. --sdcard-readonly opens
it read-only, and the card then answers CMD24 with the write-error token exactly
as a write-protected card would. This runtime path is entirely separate from the
host-side FAT32 reader in src/core/sd_rom_extractor.{h,cpp}, which pulls the
ROM images out of the same file at startup.
CTC¶
The CTC is the Zilog counter/timer companion to the Z80, and the Next carries one: four independent channels that divide down a clock or count external events, and raise an interrupt when they expire. Software uses it for anything that needs a periodic tick faster or more precise than the 50 Hz frame interrupt — sample playback rates, music drivers, timed I/O.
ctc.{h,cpp} models device/ctc.vhd and ctc_chan.vhd. The four channels sit
at ports 0x183B-0x1B3B, each a transcription of the VHDL five-state machine,
and channel N's ZC/TO output feeds channel N+1 so they can be cascaded.
The aliased range 0x1C3B-0x1F3B (A10 = 1) gets its own handler, which
reads 0x00 and drops writes. That is not defensive padding: with the CTC I/O
enable set, the VHDL asserts a read response and OR-folds zeros, so real
hardware drives 0x00 there rather than letting the bus float.
The UARTs¶
uart.{h,cpp} models serial/uart.vhd. Two channels share ports 0x133B
(Tx/status), 0x143B (Rx/prescaler LSB), 0x153B (select) and 0x163B
(framing), each with a 512-byte RX FIFO and a 64-byte TX FIFO. RX entries are
9 bits wide because the VHDL carries a per-byte overflow OR framing flag.
UART 0 is the ESP, UART 1 the Raspberry Pi (zxnext.vhd:1611) — the obvious
guess is the wrong way round. A channel with nothing attached loops its TX back
into its own RX, which matters more than it sounds; see the replay gate below.
The ESP-01¶
A real ZX Spectrum Next has an ESP-01 WiFi module soldered to it, talking to the
machine over UART 0 with the AT command set that module's firmware speaks:
AT+CIPSTART to open a connection, AT+CIPSEND to write to it, +IPD frames
coming back the other way. It is how a Next gets on the network, and NextZXOS
ships drivers and dot commands built on it, as do third-party programs such as
the NXtel BBS client and nextsync.
jnext emulates it as a real host-side network bridge, not a canned
responder. AT+CIPSTART opens an actual TCP or UDP socket from the host, so
guest software reaches the real internet — which is the only way to be sure the
emulation is right, since the software on the other end is not ours. It is off
by default and enabled with --esp (the GUI can persist that choice, which is
why --no-esp also exists).
The command set is deliberately narrow and every command in it is evidenced in
software that actually runs on a Next. There is no AT+CIPMODE passthrough and
no SSL, because nothing uses either.
Server mode and multiplexing exist (AT+CIPMUX=1, AT+CIPSERVER=1,<port>,
+IPD,<id>,<len>:, AT+CIPCLOSE=<id>), added once a consumer appeared — a debug
stub that has to listen, because the debugger on the PC only ever dials out. The
argument form of AT+CIPCLOSE frees the named connection's slot; the
no-argument form still means "close the outbound connection", in every mode,
because nextsync loops that exact spelling. The constraint that
shaped it is worth knowing before touching that code: the power-on default
stays AT+CIPMUX=0, because nextsync never sends the command and its +IPD
reader does not reject the multiplexed form — it silently mis-parses it. So the
multiplexed framing reaches only a connection whose own session asked for it,
and the listener binds loopback unless the user widens it with
--esp-listen-address.
AT+CIPSTO closes an idle inbound connection (0-7200 s, default 180), which
is the one behaviour here derived from a measurement rather than from a
document or a source: a real Ai-Thinker ESP-01 answers +CIPSTO:180 and drops a
silent server-accepted client at ~182 s. It is also why AtEngine has an
injectable clock — a three-minute default is not provable by waiting, so
set_clock() exists and both of the engine's wall-clock deadlines read through
it. Only bytes arriving from the peer restart the window; whether
server-initiated traffic does is what the matching firmware document does not
say, and is deliberately not modelled.
Why it is a separate component, and what that costs¶
The ESP-01 emulation lives in src/esp01/, outside src/peripheral/, and
that is not filing. It is a deliberate isolation contract, stated in that
directory's CMakeLists.txt and enforced by the build.
The reasoning is simple: an AT-command engine over sockets is not Spectrum-specific. Any other emulator, or any tool that needs to speak to software expecting an ESP-01, wants the same code — and the only thing that makes such a component reusable rather than theoretically-extractable is that nothing in it ever reached for its host. Isolation here is a property that was designed in and is checked, not one that happens to hold today.
Four consequences, all deliberate:
- The
esp01target links nothing from jnext. Notjnext_peripheral, notjnext_core, not even the project's spdlog wrapper. Its interface dependencies are the C++17 standard library and the OS components that library needs — the system threading library on POSIX, and Winsock on Windows for the sockets themselves. Both are OS components rather than third-party packages, so the project's no-new-dependency rule is untouched. include/is the single public include root. Every include of a module header reads#include "esp01/<name>.h"— inside the module, in its own tests, and in jnext alike. A consumer adds one directory and nothing else.- The tests ship with the code.
src/esp01/test/holds two suites: one drives the AT engine against an in-memory fake transport with no socket, no DNS and no listener, and one covers the address policy and the transport against an in-process loopback listener. A consumer gets the proof alongside the implementation instead of taking it on trust, and can run the two binaries directly without adopting jnext's test manifest. - The thread is optional.
esp01/esp_threaded.hwraps the passive core so socket work runs off the guest's thread, but the core is equally drivable inline by a consumer with its own scheduler. It is always built, so no one has to reconfigure to get it, and both modes are exercised by the module's suites — an unexercised alternative mode rots.
Everything that couples the two sides lives on jnext's side of the line:
esp_uart_adapter.{h,cpp}implements jnext'sUartDevicein terms of the module'sesp::EspDevice, installs a sink that pushes bytes into the guest's RX FIFO, mirrors the core's tick gate, and binds the module's logging seam to jnext'sesp01logger so--log-level esp01=debugworks. It is five forwarding methods plus one flag; if it ever grows a state machine, something has been put on the wrong side of the line.esp_host_policy.{h,cpp}holds what a reusable module cannot own, because it is about this program's relationship with its user: the optional--esp-allowhostname allowlist, and a bounded thread-safe connection log the GUI can display, so every attempt, success and refusal is visible. It reaches the engine as a decorator over the module's transport interface, which is what lets jnext add an allowlist without editing a line ofsrc/esp01/.- jnext's own coupling suites live in
test/esp/andtest/gui/, separate from the module's.
The address-based half of the security posture, by contrast, is inside the
module, as esp::AddressPolicy: it denies loopback, link-local,
cloud-metadata, unspecified and multicast addresses while deliberately allowing
RFC1918, so the guest can reach the user's own LAN. That belongs with the
transport because it is a property of the component in any host, not of jnext.
The adapter also carries a replay gate. During rewind fast-forward and RZX
playback the adapter goes inert rather than detaching, because detaching would
re-enable the UART channel's loopback and inject bytes the original run never
saw. Going inert is what "the ESP is silent" actually has to mean, and the gate
is needed because a live network is not re-executable — replaying an
AT+CIPSTART would open a second real connection.
I2C and the RTC¶
i2c.{h,cpp} is a bit-banged bus decoder on ports 0x103B (SCL) and 0x113B
(SDA), with reads ANDing the internal line against the Raspberry Pi bridge
inputs. The only device attached to it is the Next's real-time clock, I2cRtc
at address 0x68 — a DS1307 modelled with the full register map, BCD encoding,
NVRAM, the CH oscillator-halt bit and 12-hour mode. It reads the host clock by
default; --rtc pins it to a fixed instant, which is what makes boot
screenshots reproducible.
Multiface¶
The Multiface was a Romantic Robot cartridge with a physical button on it. Press the button mid-program and it raises an NMI, pages its own ROM in over the bottom of memory and puts up a menu, from which the running program could be saved to tape or patched — the standard way to snapshot or poke a game in the 1980s. The Next reimplements all three historical variants in its FPGA.
multiface.{h,cpp} models device/multiface.vhd as four flip-flops
(nmi_active, invisible, mf_enable, port_io_dly) plus a mode-dependent
port decode selected by NR 0x0A bits 7:6 — MF+3, MF128 in two variants, MF1.
The enable and disable strobes land on different low bytes in each mode, which
is why dispatch reaches it through an I/O observer rather than a handler.
The memory overlay itself is in the MMU, not here: Mmu holds a non-owning
Multiface* and checks is_mem_active() at priority 1, below the boot ROM and
above DivMMC, mapping the ROM at 0x0000 and the RAM at 0x2000. On a Next the
two halves are backed by external SRAM pages 0x0A and 0x0B, because
tbblue.fw loads enNextMf.rom into page 0x0A during boot; standalone machines
use the private buffers instead.
NMI arbitration¶
Three devices can pull the Z80's non-maskable interrupt line — the Multiface,
DivMMC and the expansion bus — and they can want it at the same time, so the
hardware arbitrates. nmi_source.{h,cpp} is not a device but that arbiter, from
zxnext.vhd:2089-2170: the three producers with their NR 0x06 and NR 0x81
enable gates, a priority chain of MF > DivMMC > ExpBus, and a four-state FSM
that advances on the 0x0066 M1 fetch — the address the Z80 jumps to when it
takes an NMI, which is how the hardware knows the request was accepted.
It is fully wired; its own header still calls itself a "Phase-1 scaffold", which
is stale. Stackless NMI (NR 0xC0 bit 3) is out of scope.