Changelog
All notable changes to zen80 are documented in this file.
The format is based on Keep a Changelog.
[0.5.1] - 2026-08-26
Added
- Permanent debug hooks for trace instrumentation:
DebugPCHook(top of every Step, pre-fetch),DebugMemWriteHook(every memory write, with old and new values), andDebugIOInHook(every port read, with the value returned to the guest). All package-level, nil by default at one branch of cost; the write hook adds one read per write while set. Replaces the add-and-strip diagnostic-patch cycle the cross-emulator tracing campaigns previously required.
Fixed
BIT n,(HL)no longer refreshes WZ (MEMPTR): the undocumented X/Y result flags for this instruction take their value from MEMPTR's inherited high byte -- the residue left by earlier instructions -- not from HL. A previous fix, aimed at making those flags non-arbitrary, setWZ = HL+1before reading them; that made the flags a function of HL instead of history, which is not what real hardware does. (DDCB/FDCB indexed forms are unaffected -- those correctly setWZ = IX/IY+d.)- HALT now advances R every M-cycle: previously R was frozen for the duration of a HALT, undercounting refresh by one increment per 4 T-states of halted time. A halted Z80 continuously executes NOP M1 fetches on real hardware, each incrementing R exactly as any other M1 cycle would.
JP cc,nnandCALL cc,nnnow set WZ from the operand even when the condition is not met: on real hardware WZ (MEMPTR) is a side effect of the 16-bit operand fetch itself, not of the branch being taken. Both instructions previously left WZ stale on their not-taken path.
All three were found auditing undocumented-flag and refresh-timer behaviour against protection code (Speedlock-class TZX loaders) that deliberately probes them; each is covered by a dedicated test.
[0.5.0] - 2026-08-25
Added
- ULA contention hooks:
ContendedMemDelayandContendedIODelay, optionalfunc(x uint16, cyclesBefore uint64) intfields called on every memory read/write and port access respectively. Returned delays accumulate in a privatependingContentionfolded into the instruction's cycle count exactly once byfinishStep, at everyStep()exit path. Both hooks nil by default: a complete no-op with zero overhead for existing callers. - Within-instruction access-position tracking for the hooks:
cyclesBeforeis not the instruction-start cycle count but the access's estimated true T-state position -- instruction start plus a running offset built from per-access base costs and contention delays already applied within the same instruction. Base costs are opcode-aware via a newfirstMCycleCosttable (fetch 5 for DJNZ/PUSH/RST/RET cc/LD SP,HL, 6 for INC/DEC dd, prefixed second fetch 4). Measured against real execution (Speedlock loader workload, 4.2M instructions), residual position error bounds at ~0.33% of total memory-contention delay and ~0% for I/O -- versus ~6% and ~20% for the same hooks at plain instruction-start positions. Tracking is skipped entirely when both hooks are nil.
[0.4.0] - 2026-08-24
Added
FastPortWriteOut: a further optional addition alongsideFastPort/FastPortReadIn, for a caller whose memory model has state driven by a specific port write -- a memory paging register being the motivating case. Called on everyOUTwhileFastPortis active (afterFastPort[port]is already updated), so a caller can react to a paging write the instant it happens rather than only discovering it later. MirrorsFastPortReadIn's existing design exactly; nil (the default) is a complete no-op.
Motivating case, found via zenzx integration testing: a flat 64K
FastMem snapshot has no way to represent banked memory on its own.
Reconciling it only periodically (e.g. at a caller-chosen checkpoint)
is provably insufficient for a program that pages banks during
ongoing execution, not just between well-defined phases -- confirmed
with a real 128K game (Cybernoid 2) that issues dozens of paging
writes within a few thousand instructions, including rapid ROM-bank
toggling. FastPortWriteOut lets the caller swap FastMem's affected
content the moment paging actually changes, which a purely periodic
reconciliation structurally cannot do correctly.
[0.3.0] - 2026-08-23
Added
- FastMem/FastPort: an optional addition alongside the existing
Memory/IOinterface fields, not a replacement -- every existing caller is completely unaffected (both fields default to nil, a complete no-op). When a caller setsZ80.FastMem(a*[65536]byte) and/orZ80.FastPort(a*[65536]byte), every memory/IO access in the package routes through it directly instead of theMemory/IOinterfaces, letting the Go compiler inline a plain array access instead of an interface's indirect call. Measured effect: roughly 3.6x throughput on a representative memory-access workload (up from a raw interface-only baseline), closing most of the gap to a fully-concrete, no-indirection upper bound. Intended for a caller that needs to run a large, closed number of instructions fast and correctly with no bank-switching concerns (a full flat 64K address space) -- e.g. a tape-loading fast path -- and is willing to reconcile FastMem/FastPort against its own real memory model itself; zen80 does not manage that reconciliation. - FastPortReadIn: a further optional addition alongside FastPort, for callers whose IO model is dynamic in a way a static array can't cheaply represent (e.g. a ULA-style port combining keyboard state and a frequently-toggling external signal from the port address's high byte). Checked first on every IN while FastPort is active, before falling back to the flat array; nil (the default) is a complete no-op.
Fixed
decode.gobypassed FastMem/FastPort entirely for 16 call sites covering some of the most common opcodes in the instruction set (LD A,(HL),LD (HL),n,INC (HL),DEC (HL),LD A,(BC),LD A,(DE),LD (BC),A,LD (DE),A,IN A,(n),OUT (n),A, and related indexed forms). Root cause: this file usescpuas its receiver variable name where every other file in the package usesz; the original mechanical substitution introducingmemRead/memWrite/ioIn/ioOutsearched specifically for thez.-prefixed form and silently missed every site in this file. A caller using FastMem/FastPort would see memory silently split into two diverging views -- most instructions correctly using the fast array, but these 16 forms always reading and writing the real, interface-backed memory regardless. Found via zenzx integration testing: a BASIC program's own interpreter loop (which leans on exactly these opcodes for variable and stack handling) would silently read stale values, eventually stalling program flow entirely. All four helper functions (memRead/memWrite/ioIn/ioOut) are now confirmed as the sole indirection point for every memory/IO access in the package, verified by an exhaustive grep across every file for any remaining direct<recv>.Memory./<recv>.IO.call regardless of receiver name.
Documentation
- Corrected README's stated ZEXDOC/ZEXALL runtime from "several
minutes"/"minutes" to the actual 30-60+ minutes, and added the same
timing note directly on both test functions (
zexdoc_test.go,zexall_test.go) plus a note onTestZEXALL_QuickCheckthat it currently reports 0 passing tests when actually run -- flagged, not investigated, since it wasn't in scope this session.
[0.2.0] - 2026-08-21
Added
- Z80N (ZX Spectrum Next) extended instruction set, all 29 opcodes,
gated behind a new
Z80N boolfield onZ80that defaults to false -- a plain Z80 core's behaviour is completely unaffected unless a caller opts in explicitly. Implemented in three stages: - Dispatch surgery: the 29 opcodes were carved out of their
previous NOP-catch-all case blocks in
prefix_ed.gointo their own named dispatch entries, gated onz.Z80N, calling into new bodies inz80n.go. Verified both directions:TestZ80N_OffByDefault_ByteFor ByteUnchangedconfirms a Z80N=false core's behaviour is genuinely identical to before the surgery, andTestZ80N_On_DispatchReaches CorrectHandlerconfirms each opcode routes to its own handler, not a neighbour's. - ~25 mechanical opcodes:
SWAPNIB,MIRROR A,TEST $im8, the barrel shift/rotate set (BSLA/BSRA/BSRL/BSRF/BRLC DE,B),MUL D,E,ADD HL/DE/BC,Aand,$im16,OUTINB,PIXELDN,PIXELAD,SETAE, and the extended block-copy set (LDIX,LDWS,LDDX,LDIRX,LDPIRX,LDDRX), each implemented directly from the SpecNext wiki's documented formulas. - 3 genuine edge cases:
PUSH $im16(the only operand in the whole Z80/Z80N set encoded big-endian in the instruction stream -- fetched explicitly in that order rather than through the package's own little-endianfetchWord(), with a test that would catch a byte-swap regression, confirmed by deliberately introducing one and watching it fail before restoring the fix);NEXTREG $im8,$im8/NEXTREG $im8,A(implemented as the two port writes -- register select via0x243B, data via0x253B-- real hardware's own observable equivalent, cross-checked against four independent sources before trusting the port numbers);JP (C)(the one instruction with no classic-Z80 shape at all, an I/O read feeding directly into PC --PC := (PC & $C000) | (IN(C) << 6), confirmed from the wiki's own two independently-phrased statements of the formula, tested specifically for reading the full 16-bitBCas the port address rather than justC, and for preserving PC's top bits correctly at a non-zero starting address).
Fixed
ADD HL/DE/BC,A(ED 31/32/33) no longer clears the carry flag. The three opcodes' first implementation setC:=0unconditionally, following the SpecNext wiki's 2025-01-25 hardware-test note ("most probably always reset"), which had itself superseded an older 2021-09-16 wiki note. Both turned out to be wrong: a real ground-truth test (SCFthenADD HL,A, run to completion on genuine CSpect via NextZXOS with the result read back by screen OCR, and separately on this package's own core embedded in ZenZX headless, with the result confirmed stable across multiple different frame counts after the same load rather than read once) showed carry left unchanged in both cases.zesarux(instruccion_ed_49,z80_codpred.c) had implemented it as unchanged all along -- unmodified since that file's first commit (2022-03-04), nearly three years before either wiki note existed to contradict it -- and turns out to have been right, not merely first. Seez80n.go's own comment onz80nAddHLAfor the full trail, andZ80N_ZESARUX_CROSSCHECK.mdfor the wider cross-checking write-up this fix came out of.
Testing
z80n_test.go: dispatch-correctness harness (both directions -- off-by-default and on-routes-correctly).z80n_semantics_test.go: one test per opcode against documented behaviour, including the specific corrections noted above.z80n_adversarial_test.go: edge cases picked to distinguish a correct implementation from a plausible-looking wrong one where two possible bugs could otherwise hide behind each other (wraparound arithmetic, maximum-magnitude multiply, R-register increments interleaved with classic instructions, zero-shift-count no-ops, shift mask boundaries, BC-starts-at-zero block copies).Z80N_ZESARUX_CROSSCHECK.md: the cross-checking methodology and findings for every opcode where the wiki's own documentation left genuine doubt, not a routine per-opcode audit.
0.1.0 - 2026-06-26
First versioned release. Establishes the release-hygiene baseline (VERSION
file, pkg/version package, syncver.sh, release.sh, this changelog) for
the existing zen80 codebase.
Emulator core
- Instruction-stepped Z80 CPU core with full documented instruction set: main, CB-prefixed, ED-prefixed, DD/FD-prefixed (IX/IY), and DDCB/FDCB indexed bit operations.
- Accurate flag handling including the undocumented X and Y flags, and the internal WZ (MEMPTR) register.
- Interrupt support: NMI (edge-triggered) and maskable interrupts in modes
0, 1, and 2, with a two-phase Mode 0 instruction-injection buffer and an
optional
InterruptControllerinterface for vector and Mode 0 supply. - Correct R-register refresh increment on the M1 cycle.
Memory and I/O
memorypackage:RAM(64 KB),ROM, andMappedMemory(ROM-low / RAM-high with power-of-two ROM mirroring).iopackage:NullIO,SimpleIO(256-port array), andMappedIO(per-port and 8-bit-decode fallback handlers).
System layer
systempackage providing a ZX Spectrum system: 48 KB memory map, ULA keyboard/border/speaker I/O, frame timing, and vertical-blank interrupt generation.
Tooling
pkg/versionpackage exposing theVersionconstant, kept in sync with the rootVERSIONfile viasyncver.sh.release.shsingle-pass release preparation: version validation, CHANGELOG check, version sync, build, single test pass with coverage, a version-consistency check, and a binary-free, artifact-free checkpoint zip.
Documentation
runtest.shnow skips the ZEXDOC/ZEXALL exercisers by default (they have their own runners and take minutes); pass--zexto include them.- Expanded the README testing section to document the three test tiers (fast
unit tests, the ROM-backed opcode-coverage test, and the ZEXDOC/ZEXALL
conformance exercisers), including the environment variables that gate the
exercisers and why
go testalone triggers a long run.