JNEXT
Skip to content

3.1 CPU

The Next runs a Z80, and every program it will ever run is a stream of Z80 instructions. That makes the CPU the emulator's metronome as much as its processor: Emulator executes one instruction, then hands every other subsystem the T-states that instruction consumed, and everything else — video, audio, timers, DMA — advances by that amount. Nothing in JNEXT moves without the CPU moving first.

It is not quite an ordinary Z80, though. The FPGA implements a soft core called T80N: a Z80 with an extra instruction set of its own (Z80N), a clock that software can switch between 3.5, 7, 14 and 28 MHz through NextREG 0x07, and an interrupt fabric with fourteen prioritised sources instead of the single /INT line a 48K Spectrum has. JNEXT reaches memory through Mmu and I/O through PortDispatch, both behind abstract interfaces, and calls back out at instruction-fetch time so that DivMMC paging, the NMI state machine and the Multiface window can settle before the byte they affect is read.

src/cpu/ holds the Z80Cpu wrapper (z80_cpu.h/.cpp), the Z80N extension opcodes (z80n_ext.h/.cpp), the IM2 interrupt fabric (im2.h/.cpp) and the small Im2Client facade peripherals use to reach it. The CTC is not here despite driving interrupts — it lives in src/peripheral/ctc.h/.cpp.

The wrapper and the backend

The rest of the emulator only ever sees Z80Cpu. No FUSE type appears in z80_cpu.h, and that is the point: swapping the backend would mean rewriting z80_cpu.cpp and nothing else. Z80Cpu reaches the machine through two abstract interfaces declared in that header — MemoryInterface and IoInterface — implemented by Mmu and PortDispatch.

The backend itself is the FUSE Z80 core, vendored under third_party/fuse-z80/ from FUSE 1.6.0 (GPLv2-or-later, compatible with JNEXT's GPLv3). fuse_z80_core.c is a single translation unit that #includes the opcode files, while fuse_z80_shim.h stubs out every FUSE emulator dependency those files would otherwise pull in. The core is inherently single-instance — it keeps one global z80 struct and one global tstates counter — which happens to suit JNEXT's one-CPU design. Z80Cpu re-points the file-static s_mem / s_io pointers on every reset() and execute(), and copies the register file in and out around each instruction.

Z80Registers carries the architectural registers plus MEMPTR, FUSE's Q latch and IncDecZ. That last one is a one-bit shadow that exists only in the VHDL (t80n.vhd:1358-1367), where it becomes observable through the Z80N LDWS instruction. It cannot be read out of FUSE at all, so execute() reconstructs it after the fact for DJNZ, INC BC / DEC BC and the ED block transfers, walking any DD/FD prefix chain first because those prefixes do not change which mcode path runs.

The Z80N instruction set

Z80N is the Next's own extension to the Z80. Every one of its instructions is encoded behind the ED prefix, in slots a real Z80 leaves undefined and quietly executes as a two-byte NOP, so Z80N code is harmless on a plain Spectrum and does real work on a Next. The additions exist to make cheap the things a Z80 is bad at: MUL D,E is an 8×8→16-bit multiply, the BSLA / BSRA / BSRL / BSRF / BRLC family barrel-shifts DE by B, PIXELAD converts a (Y, X) coordinate pair in DE into the corresponding ULA screen address in HL while PIXELDN steps that address down one pixel row, NEXTREG nn,n writes a NextREG directly from an opcode, and the LDIX family copies blocks while treating one byte value as transparent. This is not an optional corner of the machine — NextZXOS itself uses NEXTREG, MUL and the ADD rr,A forms constantly.

Interception

execute() reads the byte at PC itself. On 0xED it reads the extension byte and consults kZ80NOpcodeTable, a 256-entry table built at static-init from the Z80NOpcode enum. A hit dispatches to execute_z80n() and never reaches FUSE, which would otherwise decode it as a NOP; a miss falls through untouched.

Two encodings are checked before that table: ED FF and DD 01, the two magic breakpoints. A magic breakpoint is an instruction a program plants in its own code to stop the emulator at that exact spot and hand control to the debugger — the two encodings are the ones ZEsarUX and CSpect established, and JNEXT honours both. They only bite when --magic-breakpoint is given; otherwise they behave as the NOPs they are on real silicon.

Z80N instructions account for their own time rather than quoting a table constant. The wrapper charges the two M1 fetches via contend_read(), execute_z80n() charges every operand access through the same FUSE bus callbacks the standard opcodes use, and the wrapper returns the elapsed tstates delta. NEXTREG nn,n and NEXTREG nn,A route through IoInterface::nextreg_opcode_write() instead of writing ports 0x243B/0x253B, because the opcode must not disturb the port-0x243B select latch: a following IN A,(0x253B) still has to read whatever that port last selected.

Bus callbacks and contention

On a 48K or 128K Spectrum the CPU and the ULA share the same RAM, and the ULA wins. While it is fetching display bytes it holds the CPU off the bus, so an instruction that touches contended memory takes longer than the data book says — and by an amount that depends on where the raster beam happens to be. Software can and does measure this, which is why contention has to be modelled rather than approximated. On the Next it is a compatibility feature: present under 48K, 128K and +3 timing, absent under Pentagon timing, switchable off outright (NR 0x08 bit 6), and simply outrun at any CPU speed above 3.5 MHz. The gate itself lives in ContentionModel — see 3.2 Memory — and this section is about how the CPU calls into it.

fuse_z80_readbyte, fuse_z80_writebyte, fuse_z80_readport and fuse_z80_writeport are the extern "C" functions the FUSE macros expand to; they dispatch to s_mem / s_io and add the base FUSE timing.

Contention is the subtle part, and it is not applied where you might expect. There is no per-instruction contention hook on Z80Cpu — there used to be an on_contention callback, but Emulator::init() only ever assigned it nullptr and it has been removed — and no pre-computed table is ever consulted either. Every bus cycle instead calls ContentionModel::contention_tick(), a per-cycle transcription of the VHDL gate. There are seven such call sites: the four callbacks above, plus the three FUSE in-opcode macros contend_read, contend_read_no_mreq and contend_write_no_mreq. Those three become extern calls only because src/cpu/CMakeLists.txt compiles the FUSE translation unit — and only that one — with -DCORETEST, which flips the macros in z80_macros.h from table lookups into overridable functions.

Each site first tests contention_possible() (NR 0x08 bit 6, NR 0x07 CPU speed, Pentagon timing), so the raster arithmetic is skipped entirely when contention cannot fire. When it can, derive_hc_vc() turns the FUSE tstates counter into a raw frame position and to_ula_counters() rebases that onto the ULA's own display-relative counters. The rebase is not a nicety: the VHDL gate is written against i_hc/i_vc, which reset at the start of the active display rather than at the start of the frame, so feeding it raw coordinates would contend the top border while missing the bottom 64 display lines. The runtime is installed by z80_set_contention_runtime(); with it null, all seven sites are inert.

A separate stretch is the 28 MHz SRAM read wait (zxnext.vhd:3171-3181): at CPU speed 3, every memory read cycle that reaches external SRAM costs one extra T-state, qualified per target by Mmu::sram_read_wait28().

M1 hooks and interrupts

Two callbacks bracket the opcode fetch, and the order matters. on_m1_prefetch fires before the read, because DivMMC automap, the NMI FSM and the Multiface window all decide what memory the CPU is about to see, and must be settled before the byte is fetched through them. on_m1_cycle fires after, once per fetched byte of a prefix chain — including the inner byte of DD ED xx — because the IM2 opcode decoder models one event per M1.

request_interrupt() records the T-state at which /INT was asserted, and execute() drops a pending request once the hardware pulse window has elapsed (32 CPU cycles on 48K/+3, 36 otherwise, per zxnext.vhd:2033). It does so unconditionally rather than gating on IFF1, because the hardware line goes high again regardless of whether anyone was listening. It also replicates FUSE's EI-grace rejection before calling on_int_ack, so the daisy chain is not advanced by an acknowledge cycle that never happens. The vector comes from on_int_ack() when one is installed, and from int_vector_ otherwise. NMI has a path of its own, including the Next's stackless mode (NR 0xC0 bit 3), which suppresses the stack writes and substitutes the live NR 0xC3:0xC2 pair on the matching RETN/RETI.

The IM2 fabric

Im2Controller owns 14 device slots in VHDL priority order (DevIdx). Each slot carries the im2_peripheral.vhd wrapper state — request edge detect, enable bit, unqualified one-shot, status latch — and the im2_device.vhd four-state machine S_0 → S_REQ → S_ACK → S_ISR with its IEI/IEO daisy chain. The controller additionally holds the im2_control.vhd decoder that recognises RETI/RETN and IM-mode changes out of the M1 byte stream, the legacy pulse-mode /INT generator, and the NR 0xCC/CD/CE DMA-delay latch. It is ticked once per instruction with the T-states that instruction consumed.

Peripherals do not call the controller directly in new code. They hold an Im2Client instead — a two-field facade binding one DevIdx — so a device needs to know its own priority slot and nothing else about the fabric.

What the FUSE suite proves

test/fuse/fuse_z80_test.cpp runs FUSE's own tests.in / tests.expected corpus of 1356 cases, comparing the entire register file, MEMPTR, IFF1/IFF2, IM, the halt flag, every touched memory byte, and the total T-state count. test/z80n/z80n_test.cpp is the equivalent harness for the Z80N set, with 85 cases.

What those suites do not prove matters just as much. They do not check the per-bus-cycle event trace FUSE's coretest emits, only the total. And they run Z80Cpu against a flat 64 KB TestMemory and a TestIO that echoes the port high byte: no MMU, no contention runtime, no IM2 acknowledge callback, no M1 hooks. A green 1356/1356 is a statement about instruction semantics and per-instruction totals in isolation. Everything the wrapper layers on top — contention stretch, IM2 arbitration, automap timing, the interrupt pulse window — is proved by other suites and by the screenshot regression.