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. WAIT also
compares hc >= (hpos<<3)+12 in wrapping 9-bit arithmetic, so hpos=63 yields
4, not 516. A threshold above the line's last hc_ula (455, or 447 on 48K and Pentagon
timing) is never reached, so such a WAIT stalls until the next restart.
Mode 11 restarts the program at cvc 0 / hc_ula 0 and nowhere else
(copper.vhd:80); that is c_min_vactive lines into the raw frame, not its
top. jnext also rewound it at the raw frame start, cutting every whole-frame
program off c_min_vactive lines early: 64 at 50 Hz, 80 on Pentagon, 40 at
60 Hz. An NR 0x62 write that leaves the mode bits unchanged does
nothing to the Copper: only a mode change resets its PC. A stopped Copper is
not stepped, except to latch a pending mode write, so that a stop and a
restart (00 then 01/11) is still seen as a mode change.
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, and the
mode is latched by every access to either port, reads included
(zxnext.vhd:1816-1817).
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. A DMA port read goes
through PortDispatch::guest_read(), so an RZX records and replays it like the
CPU's IN. 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. The wait follows every byte, the
last one of a block included, so a looping sample keeps its spacing across the
auto-restart. The prescaler timer runs on the CPU clock at the CPU speed's
increment (zxnext.vhd:1776-1777, dma.vhd:250-254), which is one count per
28 MHz master cycle at any speed: the emulator hands tick_burst_wait() each
step's CPU clocks and sets set_turbo() from the clock's divisor, so a
prescaler of N spaces bytes N×32 master cycles apart. The status byte follows
the VHDL's two flags exactly: the end of a block sets end of block and an
auto-restart does not clear it, and the return to idle clears at least one.
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.
A program loaded directly, without NextZXOS, has no such firmware behind
$0008; jnext answers its RST 8 calls on the host instead. See
the esxDOS stand-in.
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.
is_active() is the overlay for the opcode fetch: held OR instant_on.
is_active_after_fetch() is the overlay for every later cycle of the same
instruction: the hold the fetch loaded (divmmc.vhd:128-143). They differ
exactly at a delayed-on entry (the operands are already the DivMMC's) and in the
unmap window (the operands are already RAM's). is_nmi_hold() follows the second:
it is sampled after the instruction (automap_hold_ OR button_nmi).
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.
The card can be changed while the machine runs (Emulator::change_sd_card()):
mount() is a full reset() of the protocol state, so the guest has to
initialise the new card from CMD0, which is what NextZXOS's REMOUNT does after
its "press Y". There is no card-detect line to model — the Next's SD port is five
pins (zxnext_top_issue2.vhd:60-64). The ROMs are not re-extracted on a change;
the next power-on reads them from the new card.
The card serves both SD capacity classes, and which one it is is decided by the host, not by the image. ACMD41's HCS bit is latched during init, CMD58 reports it straight back as the OCR's CCS bit, and everything downstream follows from that one bit: CMD17/18/24 take a 512-byte block address when CCS=1 and a byte address when CCS=0, CMD9 answers with a CSD Version 2.0 or Version 1.0 register (the two encode capacity through entirely different fields), and CMD16 SET_BLOCKLEN changes the transfer length only on the standard-capacity card. TBBlue, NextZXOS and FatFs all negotiate HCS=1, so the boot path only ever takes the block-addressed leg — the standard-capacity leg exists so a legacy image mounted by a host that asks for SDSC is addressed correctly instead of silently 512× off.
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 model is exact to the CLK_28 edge, and the edges are where the surprises are:
- A time constant written from S_RESET_TC passes through S_TRIGGER for one edge with the prescaler held at 0, so a timer's first count lands 17 edges after the write at /16 (257 at /256), then every 16 (256).
- A constant written while the channel runs does not touch the count: it
is loaded at the next ZC/TO. A stopped channel reads back its constant,
because
reset_softreloadst_counton every edge. - A ZC/TO reaches the next channel of the ring through that channel's edge detector: it counts (or, waiting for a trigger, starts) two edges later with a falling-edge trigger (control word D4 = 0), one with a rising one.
Ctc::tick() is an event-horizon loop over those edges, and set_time() gives
it the absolute edge so each ZC/TO it reports is stamped for the IM2 fabric.
Port reads and writes bring the CTC up to their own bus timing first — a read
takes port_ctc_dat as reloaded in the third clock of the IN's I/O cycle,
after its contention stretches (zxnext.vhd:4095-4100), a write is taken on
the CLK_28 edge after IORQ and WR assert — and the rest of the instruction is ticked after the port access.
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. As with the CTC, a port access brings the UART up
to its bus timing first (port_uart_dat is a CLK_CPU falling-edge register,
zxnext.vhd:3418-3423), and a TX-empty or RX request is stamped with the edge
the byte engine reports it on. 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 joystick-port serial cable¶
joy_uart_source.{h,cpp} and joy_uart_link.{h,cpp} are the emulator's two
stand-ins for the far end of a home-made DB9 serial cable in a joystick socket —
the rig dezogif and DeZog use to debug a running Next. The bytes do not go
through either UART header; they go through the NR 0x0B I/O-mode multiplexer
(zxnext.vhd:3340-3341, zxnext.vhd:3526-3538), which the guest controls at run time.
That is why neither of them is a UartDevice. A UartDevice is soldered to one
channel's wire, and this cable has no channel of its own: NR 0x0B bit 0 chooses
which channel it lands on, bit 4 chooses which socket is listened to, and bits
7+5 decide whether the mux is on at all. Worse, a channel device is muted
exactly when the joystick connector owns the channel — that is what
UartChannel::device_isolated() means. So the seam is at the mux, and it has
two halves:
- receive —
Emulator::inject_joy_uart_rxapplies the enable and the channel choice, and the owner's sink applies the connector; - transmit —
UartChannel::deliver_tx_byte's isolated branch hands the byte to a mux-level sink installed byUart::set_joy_uart_tx_sink. That branch still returnstrue, which is load-bearing: falling through would loop the byte into the very RX FIFO the cable is feeding.
The two directions are gated differently, and that is the VHDL's doing.
Receive is connector-selected (zxnext.vhd:3538). Transmit is not: the transmit
bit leaves the core as the single output o_JOY_IO_MODE_PIN_7
(zxnext.vhd:1593), and the board drives it onto whichever socket joysel
currently points at — which, in io mode, free-runs at 7 MHz
(md6_joystick_connector_x2.vhd:109, md6_joystick_connector_x2.vhd:117) while one bit at 115200 lasts 243
CLK_28 ticks. Both sockets therefore see it. Copying the receive gate onto the
transmit path is the obvious-looking symmetry and it is wrong.
JoyUartSource (--joy-uart-rx) is a recording: the file is read whole at
startup, clocked out on a schedule, and its cursor is serialised, so a
rewind replays the same bytes in the same frames. JoyUartLink
(--joy-uart-fifo, --joy-uart-pty) is a live host endpoint and the opposite
on both counts — nothing about it is serialised, and it is held INERT during
replay, the EspUartAdapter::set_inert posture. A byte handed to a peer cannot
be unsent, and a replayed frame that re-read the descriptor would consume host
bytes the resumed timeline still needs.
Its descriptors are touched once per frame (JoyUartLink::poll), never from
the per-instruction tick, for the reason UartDevice::poll's header gives: a
read() per Z80 instruction is a syscall per instruction. The per-instruction
half only paces already-buffered bytes out at byte_transfer_ticks(). Receive
is paced because uart_rx.vhd clocks one frame per prescaler * frame_bits and
the RX FIFO is 512 bytes with drop-newest overflow; transmit is not, because
the byte-level TX engine already held the byte for a whole byte time.
Everything about the endpoint is non-blocking, including its opens. The
host→Next FIFO is O_RDONLY | O_NONBLOCK, which succeeds with no writer; the
Next→host FIFO cannot be opened until something is reading it
(O_WRONLY | O_NONBLOCK gives ENXIO), so it is opened lazily and retried on
every flush. SIGPIPE is ignored when the first endpoint is created, without
which a debugger closing its end would kill the emulator outright.
NextPi on the Raspberry Pi¶
--nextpi (or the [nextpi] preference) runs a real NextPi under QEMU on
UART 1, the Pi GPIO header's UART. main() starts it once, at launch: the
preference only when the startup machine is the ZX Next (nextpi::start_request;
the startup machine is emulator_startup_machine over the CLI/saved merge, the
same value that becomes cfg.type), --nextpi on any machine, since NR 0x83
bit 4 keeps UART 1 decoded in the standalone personalities. A machine change
while jnext runs neither starts nor stops it. Three pieces:
peripheral/pi_uart_device.{h,cpp}is the far end of UART 1. Unlike the joystick cable it is aUartDevice: the Pi has a channel of its own, so it attaches to UART 1 the way the ESP attaches to UART 0 and inherits that seam whole — transmit at byte boundaries, paced receive throughtick(), no loopback, and the joystick mux's isolation of the channel. The host half is aJoyUartLinkit owns, so the endpoints, queues, pacing and replay gate are the cable's. One gate is its own: the Pi's pins reach UART 1 only through the NR 0xA0 GPIO mux (zxnext.vhd:2278-2281). The device asksEmulator::pi_uart_connected()— NR 0xA0 bits 5 and 4 both set, the value NextPi's.pisendwrites — and drops traffic in both directions while it is false, counting it, and logging the first loss once.core/pi_qemu.{h,cpp}(POSIX launch) andcore/pi_qemu_win.cpp(Windows launch) spawnqemu-system-arm -M raspi0. The transport is per platform (PiQemu::Spec::transport): on POSIX the Pi's console is a QEMUpipechardev, whose two FIFOs the Emulator opens as the link (JoyUartEndpoint::open_fifo_paths); on Windows, where QEMU'spipechardev is a named-pipe server that blocks QEMU's startup until a client connects and never listens again, it is asocketchardev (host=127.0.0.1, a port jnext picks,server=on,wait=off) that QEMU listens on and re-listens on, and the Emulator is the TCP client (JoyUartEndpoint::open_tcp_client, built on the ESP-01'sesp::netshims;EmulatorConfig::pi_uart_tcp_port). The socket transport can be selected on POSIX too, which is hownextpi_testexercises it on Linux and macOS.PiQemu::link_configis the one place the transport becomesEmulatorConfig. The Windows launch isCreateProcessWwith an explicit handle list (the counterpart ofaddclosefrom_np), a job object with kill-on-close in place of the shell watchdog,LANG=C/LC_ALL=Cin an explicit Unicode environment block (names matched case-insensitively), and a graceful stop byquiton QEMU's stdin (-monitor stdio) before the job is terminated.PiQemubelongs tomain(), not to theEmulator: a hard reset rebuilds the Emulator, which re-opens the same FIFOs while QEMU — holding both ends read-write — keeps running, so the Pi neither reboots nor sees an EOF. A reaper thread logs a QEMU that exits on its own; the destructor stops it with SIGTERM (SIGKILL after three seconds; on Windowsquit, then the job is terminated).Spec::extra_args(the[nextpi] qemu_argspreference, split byPiQemu::split_extra_args: spaces,'...'and"..."literal, backslash ordinary, no expansion) goes after jnext's own arguments as argv entries: through the watchdog's"$@"on POSIX and a per-argumentquote_argon Windows, never a shell.-
core/nextpi_provisioner.{h,cpp}makes the NextPi directory exist, on the SD provisioner's seams (DownloadFn,ConfirmFn,ProgressFn,BusyFn, so the same GUI dialogs and terminal prompts serve both): it asks, downloads the release and its MD5 from the mirror, streams the image out of the.tar.gz(GNU base-256 and pax sizes — the image is past 8 GB), and copieskernel.imgand the device tree out of the image's FAT32 boot partition with the lenientfat32_read_tree. Areleasemarker records what is installed, so a changed release (orlatestmoving on) re-provisions and discards the overlay. The order matters: an archive already in the directory is checked first (the.md5beside it is preferred, the mirror's is fetched only if there is none) and installed with no question when it matches; nothing is deleted before the user agrees to a download; a download step that fails never deletes a complete file (the backend keeps the partial, writes the archive's own name only when whole, and a complete.partwhose rename failed is finished next time with no request; Windows once reported failure for a finished 5.7 GB file); only a fresh download whose MD5 differs is removed.unprepared_reasonnames the piece an unusable directory lacks (the provisioner's own fetch files are the only entries that do not count as a setup having been tried; a manual unpack does), and the question and the[uart]log both say it. File existence goes throughsdcard::regular_file_exists(std::filesystem), never::stat: MinGW'sstatisstat64i32and fails withEOVERFLOWfor files of 4 GiB or more. The download itself is resumable (contract and decision table:08-media-and-loaders.md): the backend keeps<archive>.part, the question offers to continue it, and only a finished download is renamed to the archive's name, so a partial is never mistaken for an archive. -
audio/pi_audio.{h,cpp}carries the Pi's sound into the mixer, the way a real Next takes it over I2S. QEMU'swavaudio back-end writes 44.1 kHz s16 stereo into a FIFO (on Windows a named pipe\\.\pipe\jnext-pi-<pid>-<n>.audiothatpi_audio_win.cppserves, created before QEMU is spawned because QEMUfopens the path at startup);PiAudio's reader thread drains it into a lock-free ring (so QEMU never blocks), andEmulator::feed_pi_audiopops one frame at each mixer output sample intoI2s, whose NR 0xA2 gating and mixer sum already modelled the hardware. The ring prebuffers 50 ms, plays silence on an underrun and trims a backlog beyond 300 ms, absorbing the drift between QEMU's host clock and the emulated one. While NR 0xA2 sends the Pi's audio to EAR it switches to tape mode (250 ms, single-frame drift correction), so a tape loaded from the Pi is not cut by a refill or a trim. RZX playback does not consume it; a rewind's replay never generates audio at all.
main() wires them: it merges --nextpi/--no-nextpi over the saved
preference (GUI sessions only, as for the ESP), puts the --nextpi-dir,
--nextpi-release, --nextpi-qemu, --nextpi-audio and --nextpi-qemu-args
flags over the saved text settings (nextpi::merge_settings, any session),
splits the extra QEMU
arguments (before anything else, so a malformed value is reported with no download
offered), checks the QEMU programs
(PiQemu::find_programs, so nobody is offered 6 GB for a Pi that cannot run),
provisions, starts QEMU, and
puts the link (PiQemu::link_config) in the EmulatorConfig. A Pi asked for on the command line
that cannot start is a usage error; one enabled only in Preferences is reported
in a dialog and the session goes on without it. Design:
doc/design/NEXTPI-DESIGN.md.
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.
The setting form is refused with ERROR unless a server is running — also a
measurement, and one that cost a consumer six builds: a value set between
AT+CIPMUX=1 and AT+CIPSERVER was never in force on hardware, and a stub that
tolerates ERROR there (because older firmware lacks the command entirely) had
no way to notice. The query form answers in both states. Which precondition the
refusal keys off is a choice: the hardware probe issued AT+CIPMUX=1 and
AT+CIPSERVER=1 together, so gating on the listener is the narrower of the two
readings available, and the AT+CIPMUX-only state is left claiming nothing.
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. - Guest-bound bytes never wait for the worker.
AtEnginekeeps the framed bytes for the guest in a wire with its own short lock (held for a push or pop, never across a transport or sink call), andThreadedEsp::tick()paces from it without the core lock; it try-locks the core only to frame the next+IPD, once the wire has drained. A lost try-lock keeps the elapsed time; dropping it under wine (worker inside a Winsock call, 1-25 ms) paused a reply mid-message for up to 382 018 emulated cycles, and NextSync's 20-poll window between+IPD,<n>:header bytes then abandoned the packet. A real ESP-01 clocks a message out back to back; each message is pushed as one unit. - Detached workers go through
net::start_detached. The DNS,AT+PINGandAT+CIPSNTPTIME?workers are threads nobody joins, started by that one function inesp_socket_platform.h. On Windows it is a Win32 thread, notstd::thread::detach: MinGW winpthreads recycles the descriptor of a thread that ended beforepthread_detachran while that thread's exit callback can still reach it, so the next thread created may never run its body.test/lint-thread-detach.shfailsmake unit-testandmake regressionon any other.detach().
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.