zxplay_go — conformance dashboard
Live conformance of the zxplay_go emulation core against its oracles and the external ZX Spectrum Next test suites. Generated from the manifest and the actual test run on every publish — do not edit by hand. Background: known-gaps register · Next FPGA emulation docs · VHDL conformance matrix.
Summary
pass 24 skip 1
CPU (Z80)
| Check | Kind | Status | Oracle / tracking |
|---|---|---|---|
| zexdoc — documented Z80 instruction exerciser | internal | pass | Cringle zexdoc |
| zexall — undocumented-flags Z80 instruction exerciser | internal | pass | Cringle zexall |
| Z80N gate-level golden (GHDL simulation of the FPGA t80n core) | internal | pass | t80n VHDL via GHDL |
| raxoft/z80test v1.2a — all six CPU variants Stricter than zexall: all six variants pass 160/160 (Q register, block/OUTx flags and MEMPTR included) — see the breakdown page. z80ccfscr is manual-only. |
external | pass | roadmap: ZX Play #r3 · breakdown |
Audio
| Check | Kind | Status | Oracle / tracking |
|---|---|---|---|
| AY / YM2149 FPGA golden | internal | pass | audio/ym2149.vhd via GHDL |
| DAC / SounDrive FPGA golden | internal | pass | DAC VHDL via GHDL |
Memory / paging
| Check | Kind | Status | Oracle / tracking |
|---|---|---|---|
| Memory paging FPGA golden | internal | pass | zxnext.vhd memory mux via GHDL |
Storage
| Check | Kind | Status | Oracle / tracking |
|---|---|---|---|
| divMMC automap FPGA golden | internal | pass | divmmc.vhd via GHDL |
| SD SPI master FPGA golden | internal | pass | SPI VHDL via GHDL |
Video (Next)
| Check | Kind | Status | Oracle / tracking |
|---|---|---|---|
| Layer 2 address + pixel FPGA goldens | internal | pass | layer2.vhd via GHDL |
| Tilemap render + below/text status FPGA goldens | internal | pass | tilemap.vhd via GHDL |
| Sprite render FPGA golden | internal | pass | sprites.vhd via GHDL |
| LoRes / Radastan FPGA golden | internal | pass | lores.vhd via GHDL |
| Palette FPGA golden | internal | pass | zxnext.vhd palette via GHDL |
| Video mixer (compositor) FPGA golden | internal | pass | zxnext.vhd mixer via GHDL |
| Copper FPGA golden | internal | pass | copper.vhd via GHDL |
Interrupts / timing
| Check | Kind | Status | Oracle / tracking |
|---|---|---|---|
| CTC channel FPGA golden | internal | pass | ctc_chan.vhd via GHDL |
| IM2 daisy-chain FPGA golden | internal | pass | peripherals.vhd / im2_*.vhd via GHDL |
Peripherals
| Check | Kind | Status | Oracle / tracking |
|---|---|---|---|
| Multiface FPGA golden | internal | pass | multiface.vhd via GHDL |
| UART FPGA golden | internal | pass | UART VHDL via GHDL |
| Keyboard / keymap FPGA golden | internal | pass | keymap VHDL via GHDL |
Boot
| Check | Kind | Status | Oracle / tracking |
|---|---|---|---|
| NextZXOS cold boot end-to-end (real licensed ROMs) The licensed ROMs are never committed; the Pages workflow fetches them from the official specnext.com distro at build time. If that fetch fails the test skips cleanly and this row shows grey until the next build. |
internal | skip | Real NextZXOS through the FPGA-bootrom chain |
Meta
| Check | Kind | Status | Oracle / tracking |
|---|---|---|---|
| VHDL conformance matrix (per-axis enumeration of the FPGA surface) Hand-maintained matrix — ALL GREEN since the 2026-08 close-out (port deferrals, NR$82-$89 gating, DMA-delay enables, wide-L2 ULA residues). Rendered here from VHDL_CONFORMANCE.md; sub-row-grain notes live in known-gaps.md. |
manual | pass |
Next acceptance
| Check | Kind | Status | Oracle / tracking |
|---|---|---|---|
| MrKWatkins/ZXSpectrumNextTests — Z80N, sprites, Layer 2, mixing, Copper, DMA, ULA, interrupts, timing Community acceptance suite with real-hardware reference screenshots. 35 tests in 8 groups, all wired — see the breakdown page. DmaInteractive is manual-only (a free-form WR-register editor judged against real Zilog hardware photos; its port-$0B finding is covered by the ZilogDMA runner). |
external | pass | roadmap: ZX Play #139 · breakdown |
| Threetwosevensixseven/ZXSpectrumNextTests — all five tests All five tests pass (16 builds: upstream DMA binaries + sjasmplus ports of the Zeus-only sources). Found and pinned two emulator gaps en route: the palette power-on identity default and ULA-palette transparency. |
external | pass | roadmap: ZX Play #140 · breakdown |
Known gaps and simplifications
Rendered from docs/architecture/known-gaps.md — the hand-maintained register of deliberate
differences from real hardware. Rows link work to the roadmap via the statuses and sources recorded there.
Video
| Gap | Status | Source |
|---|---|---|
| Copper / palette sub-line visibility CLOSED (#183, r59): the render resolves at the FPGA's own 14 MHz half-pixel grain — the copper interleaves at 2-cycle RunToCycle targets (one MOVE write per half-pixel, copper.vhd:87-109; TestNexttestsCopper pins the native half-width flag cells), every layer's palette lookup and the mixer state (NR$15/$68/$14/$43) read LIVE per half-pixel inside the interleave (the fused ComposeLiveHalfPixel pass; zxnext.vhd:6799-6832/:6981/:7033), and border rows — paper-row segments AND the top/bottom sweep — resolve per half-pixel on lines where the copper can retire (CanRetireOnLine event gate; event-free rows coalesce, provably identical). Remaining grain is the HARDWARE's own: sprites build line N+1 into a line buffer while N displays (sprites.vhd:537-540 — per-line by construction, SprDelay pins it), Layer 2 fetches ~1 px ahead and the tilemap 1-2 characters ahead (layer2.vhd:107-148, tilemap.vhd:201-229) — our per-row layer INDEX buffers sit at/inside those grains. Residue: mid-row LAYER ENABLE flips (e.g. NR$69 bit 7) land on the next row (per-row prefetch), and a copper NR$43 ULA-select flip mid-row can be masked by the row's stamped select push | CLOSED (half-pixel grain; per-line sprite / per-char tilemap grains are the hardware's own) | `pkg/next/copper/copper.go` (RunToCycle, CanRetireOnLine), `pkg/ula/ula.go` (applyNextCompositor, renderNextULARow), `pkg/next/compositor/compositor.go` (BeginLiveRow/ComposeLiveHalfPixel) |
| Compositor blend modes 6/7 (additive L+U / L+U-5) now route the scanline painter through the FPGA-golden `Mix` per pixel, with the NR$68 blend-operand bits wired — pinned by TestNexttestsGraphicsLightenDarken + TestComposeScanlineAdditiveBlend. The tilemap `tm_below` bit is now PER-PIXEL everywhere (#154): the line-buffer bit (attr0 OR mode_512) AND NOT on_top (tilemap.vhd:388) arbitrates against opaque-vs-transparent ULA per pixel (zxnext.vhd:7116), replacing the on-top/nibble-0 approximation — TestTMBelow*. Residue: the wide overlay/80-col passes SKIP below pixels only while the ULA output is ENABLED (they cannot consult the ULA pixel at that point), under-painting a below tile over a transparent ULA pixel; with NR$68 bit 7 set the ULA is everywhere-transparent and below tiles paint (#196, r85). Mid-frame MAP-CONTENT coherence (#196, r86): a game pairing a scroll-register write with a map REWRITE in one mid-frame update (Atic Atac pans: NR$31 at raster ~276 then the whole 2560-byte map) had rows above the update rendered from the folded OLD register + the end-of-frame NEW map — a full-tile-row displacement on every 8px wrap frame (the door/trapdoor jitter). logScroll now snapshots the map bank page at the frame's FIRST CPU scroll stamp; rows above the stamp row render from the snapshot (TestScrollFoldMapContentSnapshot). Residue: a mid-frame map rewrite with NO scroll write that frame keeps end-of-frame content (no coherence signal to split on); tile DEFS in a different bank from the map stay live | CLOSED (blend modes + tm_below + map snapshot); enabled-ULA overlay under-paint residue documented | `pkg/next/compositor/compositor.go`, `pkg/next/tilemap` (RenderScanlineWithBelow, snapshotMapBank) |
| Next wide-frame geometry (r51, #171): the Next renders the FPGA's full 320×256 wide frame (paper at 32,32) — sprites, tilemap and wide Layer 2 share ONE vertical origin (the whc/wvc counters, zxnext.vhd:4208/4337/4389), all 256 hi-res Layer 2 rows show, and sprites repaint above wide Layer 2 in the non-L-first NR$15 modes. The overlay repaints the layers each NR$15 mode places above L2 from their sources (OverpaintWideL2Row): sprites in the non-L-topmost modes, the tilemap in the U-above-L modes (SUL below sprites, USL/ULS above — the RAMS/Galaxian menu-text fix, NR$15=$33 USL + NR$68 ULA-off). Classic ULA PIXELS above wide L2 in SUL/USL/ULS repaint from a pure-ULA frame snapshot (CaptureULABase, taken before the overlay pass mutates the base; per-pixel NR$14 transparency via ulaPixTransparent — r94, #204: Space Invaders draws its whole arcade overlay on the ULA with NR$14=black over a 320×256 planet, mode USL; before the repaint the game looked stuck on its initial screen while running invisibly). Residue: the repaint ignores the NR$1A ULA clip window (the captured base is unclipped — a U-above-L wide-L2 title with a non-default ULA clip would show out-of-window ULA pixels), the ULA-vs-below-tile per-pixel arbitration is not applied within the repainted slot (ULA paints first, tilemap over it), and the overlay's tilemap arm skips per-pixel BELOW tiles (tilemap.vhd:388, now carried per pixel — #154) ONLY while the ULA output is enabled — with NR$68 bit 7 set every ULA pixel is transparent (zxnext.vhd:7102) so below tiles paint unconditionally (the FPGA's ulatm mux, zxnext.vhd:7116; #196 — Atic Atac's story-scene backdrop and closed-door graphics are below tiles over a disabled ULA, r85); a below tile over a TRANSPARENT pixel of an ENABLED ULA still under-paints (needs the un-repaintable per-pixel ULA data) | CLOSED (geometry); U-above-hi-res-L2 repaints ULA pixels (r94) with clip/below-tile residues documented | `pkg/ula/ula.go` (SetNextCompositor geometry, renderHiResLayer2, CaptureULABase call), `pkg/next/compositor` (SpritesAboveLayer2, ComposeSpriteOverlayRow, CaptureULABase/composeULAOverlayRow), `TestNextWideFrameLayerAnchoring`, `TestULAAboveWideL2USL` |
| ULA inner screen + border render through the LIVE Next ULA palette — colour redefinition AND transparency, ULANext + standard decode per zxula.vhd:483-558 — with mid-frame CPU flips of the DISPLAYED palette / ULANext state replayed per raster-stamped line (ULA/ClassicPaletized). The Timex modes are UNIFIED in the one row walk (#183, r59): the display mode re-latches per character cell (zxula.vhd:191-214), and hi-res renders the NATIVE 512-half-pixel stream (both display files interleaved byte by byte, one bit per 14 MHz tick, zxula.vhd:389) in stable frames and copper-banded MIXED frames alike — the decimation branch, the stable/mixed frame split and the dedicated renderWideTimexHiRes pass (whose palette resolved at end-of-frame) are deleted; hi-res rows resolve palette through the same paced per-row replay as everything else (TestCopperMidRowNR69HiResBand pins a mid-row hi-res band with half-pixel-wide strokes). Residue: the ULA-output-disabled fill keeps the classic pre-render | CLOSED for all ULA display modes at the native half-pixel grain; disabled-fill is a documented simplification | `pkg/ula/ula.go` (renderNextULARow, ulaVideoLine), `pkg/next/compositor/compositor.go` (ULARGBA, composePixel) |
| ULA hardware scroll (NR$26/$27) + NR$1A clip apply in every live ULA mode, Timex screen-1/hi-colour/hi-res included, and NR$68 bit 2 fine-scroll-X is LIVE (#183, r59): the fine bit is the LSB of the 14 MHz barrel-shift amount (zxula.vhd:199 px(8), :353 scroll_0 <= px(2:0) & px(8), applied at the shift-register load :395) — a +1 half-pixel term in the source map, rendered natively by the half-pixel row stride (TestULAFineScrollXHalfPixelShift pins the one-slot left shift). LoRes keeps its own scroll pair (NR$32/$33 — the ULA fine bit does not apply to it, matching the zxula-only px path) | CLOSED (standard mode pinned by TestNexttestsULAScroll, Timex modes by the Graphics group, fine-scroll by its unit pin) | `pkg/ula/ula.go` (renderNextULARow), `pkg/next/wire.go` (WireULAControl) |
| Turbo-speed video timing CLOSED (#180): mid-frame raster stamps (border changes, ULA video-state flips) ride the 3.5 MHz reference timeline (currentScanline → BeamPosition), speed-independent under NR$07 turbo — the palette/tilemap raster sources already did. Classic models keep the CPU-clock division (no turbo there). TestBorderStampSpeedIndependent / TestVideoStateStampSpeedIndependent | CLOSED | `pkg/ula/ula.go` (currentScanline) |
| Next raster geometry — CLOSED end-to-end. TIMING side (#182): guest NR$03 machine-timing / NR$05 bit-2 (50/60 Hz) writes retune the frame per the FPGA constant table (zxula_timing.vhd:146-311, transcribed by next.FrameGeometryFor): frame length (70908 / 69888 48K / 71680 Pentagon / 60192 and 59136 60 Hz), T-states per line (228/224), frame-INT assert+pulse, the contention paper anchor (PaperStartTstate — 14655 boot, 14394 under 48K timing), the NR$1E/$1F line-counter wrap (lines + min_vactive) and the NR$22/$23 line-INT offset (cvc wrap semantics — high targets land in the TOP border, zxula_timing.vhd:458-468) all follow the live selection, applied from the next frame origin (the vsync eff-latch, zxnext.vhd:6693-6706); Pentagon timing forces the stored 50 Hz flag (:5834-5836); the audio window and samples-per-frame scale with the live frame duration (boot geometry keeps exactly 882). RENDER side (geometry render close-out): the copper interleave's line clock follows the live line length — hcounts×4 copper cycles per line, Step driven to c_max_hc and RunToCycle's internal line clock pushed via SetLineCycles, so copper WAITs no longer drift under the 448-hcount timings and the WAIT wrap boundary (X≥56 on 456-hc lines, X≥55 on 448) is emergent; the raster-stamp row map anchors at the live c_min_vactive (border/ULA-video folds, palette replay, per-row scroll captures, and the border sweep's top/bottom mapping via raster = imgRow + paperTop − 32 — the wvc preload, zxula_timing.vhd:476-507); layer2/tilemap per-line scroll tables sized for Pentagon's 320 lines. Key derivation: the copper's hcount input is hc_ula, the ULA-anchored counter (reset at c_min_hactive−12, :423-424; zxnext.vhd:6737/:3949), so the paper/WAIT +12 anchor is timing-INDEPENDENT; the left-border tail rides at hcounts−28 (the board-pinned 428 on boot timing — the 8-hc lead over the raw whc-preload position is fixed video-pipeline depth, a clocked-stage count that does not scale with line length). Remaining grain notes (not geometry-dependent): border sweep rows are line-granular, and the palette-stamp hpos convention treats stamp hpos and hc_ula as one space (board-tuned at boot timing; a timing switch shifts it by c_min_hactive 136→128 = one hpos unit — sub-pixel) | CLOSED (TestCopperInterleaveAnchorTimingIndependent / TestCopperWaitWrapFollowsLineLength / TestBorderFoldFollowsPaperTop, TestCopperEngineEquivalence448 / TestWaitWrapThresholdPerLineLength; nexttests board goldens unchanged) | `pkg/next/geometry.go` (FrameGeometryFor), `pkg/next/wire.go` (WireFrameGeometry), `pkg/memory/memory.go` (NextFrameGeometry + contentionDelay), `pkg/ula/ula.go` (applyNextCompositor, paperTopRaster, BeamPosition, ActiveVideoLine, samplesForFrame), `pkg/next/copper/copper.go` (SetLineCycles), `pkg/next/layer2` + `pkg/next/tilemap` (frameRasterLines), `pkg/z80/z80.go` (frame-origin timing sample) |
| Mid-frame CPU palette CONTENT writes (NR$41/$44) raster-stamp into palette.Bank's per-frame write log with the FULL (line, hpos) beam position and replay through the row walk at their own half-pixel (#183 stage 5: ReplayToLineStart + ReplayWithinLine; a row with its own stamps takes the half-pixel stride) — the FPGA's write-visible-on-the-next-lookup BRAM rule (zxnext.vhd:4919-4930, :6969-6977). The ScanlineReadingAndInterrupt "starts somewhat midway" behaviour now renders literally: the mid-line poll's recolour starts mid-row where the write landed. The border sweep rows keep line-granular stamp application (their stamps land between lines in practice) | CLOSED at (line, hpos) granularity (Timing group + TestStampedWritesSubLineReplay) | `pkg/next/palette/palette.go` (stamped-write log, ReplayWithinLine), `pkg/ula/ula.go` (applyNextCompositor replay) |
| NR$64 video line offset (core 3.1.5+) is stored raw with no effect on NR$1E/$1F reads, the line interrupt or the copper (ScanlineReadingAndInterrupt's O/K/P/L keys would show it) | deferred | `pkg/next/wire.go` |
NextReg / ports (see VHDL_CONFORMANCE.md for the full matrix)
| Gap | Status | |
|---|---|---|
| NR$C0 is live (r54, #169): bit 0 selects the hardware-IM2 vectored interrupt mode (see the CTC/IM2 row below), bits 7:5 form the generated vector's upper bits (zxnext.vhd:1999), bit 3 stackless NMI, and the read composes the LIVE Z80 IM mode into bits 2:1 (vhd:6230). Pinned by TestWireIM2* | CLOSED | |
| NR$C4 is composed on read (zxnext.vhd:6239: expbus-enable bit 7 & "00000" & ula_int_en — bit 1 the NR$22 line enable, bit 0 the INVERTED frame-INT disable latch), writes alias BOTH low bits into the shared NR$22 path (bit 1 → line enable vhd:5610, bit 0 → port_ff_reg(6) inverted vhd:3621), and the reset default seeds the expbus enable (vhd:5096 → reads $81). Pinned by TestSpec_NRC4_InterruptEnable0 + TestFrameIntDisableSharedLatchWriters | CLOSED | |
| NextReg decode is enumerated end-to-end (#153): `TestNRDecodeConformance` probes all 256 registers against the FPGA read mux (zxnext.vhd:5882-6289) and write masks on the fully wired machine — composed read-backs ($68/$C0/$C4/$C6/$CC-$CE/$0B/$03 bit 7 = live palette sub-idx/$10/$20/$28/$8E/$A9...), stored masks, and the `others => '0'` floor: registers outside the mux read $00 (WireZeroReads). Remaining deliberate divergences, encoded in the test with rationale: NR$00/$0F stay writable (game hardware-probes, Nextoid), $98-$9A GPIO stored-byte-as-pin-state | CLOSED (was the ~30-read-back + no-exhaustive-decode-test audit rows) | |
| Port $FF bit 6 (Timex/SCLD ULA-frame-INT disable) is wired: the ULA's port-$FF byte IS the FPGA's shared port_ff_reg (zxnext.vhd:3609-3635), written by port $FF bit 6 / NR$22 bit 2 / NR$C4 bit 0 (inverted), pushed into cpu.FrameIntDisabled (gates INT generation at the source, zxula_timing.vhd:551, mid-pulse disable withdraws the line) and composed back at NR$22 bit 2, NR$C4 bit 0 and the NR$08-gated port-$FF read. Cleared on NR$02 hard/soft reset + machine reset. Pinned by TestPortFFBit6FrameIntDisable / TestFrameIntDisableSharedLatchWriters / TestFrameIntDisableResetClears / TestFrameInt_NarrowPulse_DisableMidPulseWithdraws. Residue: only classic-model port $FF writes never gate the INT (a plain 48K/128K has no SCLD — correct for the machines zxplay_go models, but a real Timex TS2068 would) | CLOSED (matrix axis 4) | |
| I/O-port decode sweep (#158): `TestNextPortDecode_*` (pkg/ula) pins the implemented decode surface to the FPGA predicates (zxnext.vhd:2540-2700) — canonical addresses, partial-decode aliases ($4001→$7FFD, $1001→$1FFD, $D001→$DFFD, $C005→$FFFD, low-byte-only $57/$5B/$6B/$0B/$1F/$37), and near-miss rejections ($263B, $173B, $203B, $2001, $C001). The Next's AY decode gained its A2=1 term (:2646-2647 — $C001/$8001 are NOT AY ports on the Next, unlike the classics). The formerly-deferred ports ALL landed 2026-08-01: MF enable/disable pair (the GHDL-golden multiface.Core via next.MFBlock), Kempston mouse $FADF/$FBDF/$FFDF (pkg/next/mouse; desktop + wasm zxMouse feeds), +3 float (bit-0-forced fetch byte on the classic-validated grid; $FF when 7FFD-locked), FDC iotrap $2FFD/$3FFD (NR$D8-gated MF-class NMI + NR$DA cause/NR$D9 byte latches). Residual grain: the +3 float's idle/border slots approximate the FPGA's last-contended-bus-byte latch (i_p3_floating_bus, zxnext.vhd:4503-4514) as $FF — the latch's never-written value; latching the true byte costs a store on the hot memory path for a niche-of-a-niche read | CLOSED | |
| Port decode IS gated by the internal/bus port-enable registers (2026-08-01): the composed internal_port_enable vector (NR$85..$82 AND NR$89..$86 under NR$80 bit 7, zxnext.vhd:2392-2443) is pushed into the ULA dispatch (next.WirePortEnableSink) and every internal decode consults its bit — TestNextPortDecode_InternalPortEnableGating. Residual grain: the FPGA latches the hw_en gates on mreq so decode never changes mid-IO-cycle (we are instruction-atomic), and disabling the MF decode holds the FPGA's MF module in RESET while ours freezes its state | CLOSED | |
| NR$02 iotrap read bit 4 composes the live NR$DA cause (2026-08-01, next.WireIOTraps — latched by $2FFD/$3FFD traps, cleared by an NR$02 write with bit 4 low); the NR$0A mouse bits (DPI 1:0 — reset $01 per the nr_0a_mouse_dpi init, button-reverse bit 3) drive the live Kempston mouse. (NR$8C's soft-reset low-nibble promote IS implemented — WireReset, TestSpec_NR8C_ResetSemantics) | CLOSED | |
| NR$69 is now a fully live alias: writes fan out (bit 7 → Layer 2 enable, bit 6 → shadow display, bits 5:0 → Timex port-$FF mode, zxnext.vhd:3617/3658/3924) AND the read composes the three live registers back (:6096), so guest writes to $FF/$7FFD/$123B are reflected. Port $123B likewise reads its composed control state (:3933), and port $FF reads return the Timex register when NR$08 bit 2 is set (:2813). Pinned by TestNexttestsGraphicsNextReg0x69 ("Tests OK: 10/10") + TestSpec_NR69_ComposedRead | CLOSED (removed from the ~30-composed-read-backs audit row) | |
| Frame-origin offset (CPU tstate 0 vs ULA hc0/vc0) unvalidated; line-INT at turbo; IM2 vector table gates not all wired | audit gap (matrix axis 5) | |
| Copper NR$02 NMI pulses are delivered on the CPU timeline at the hardware wrap rate (#187, r64): render-side MOVEs to NR$02 are filtered and a per-frame throwaway simulation (`copper.FrameMoveInstants`) schedules them through the CPU's `ExtNMIFunc` poll (live during HALT). The dispatcher enforces the FPGA NMI gates: NR$06 button enables (zxnext.vhd:2091) and the arbiter's no-nesting assertion-to-RETN envelope (:2096-2116, `divmmc.Pager.NMIInFlight`). Pinned by TestCopperNMIPacerDeliversAtHardwareRate / TestNR02NMIGatedOnNR06 / TestNR02DivMMCNMINeverNests. Residue: the schedule predicts one frame ahead from the render-authoritative copper state — a WAIT-anchored NR$02 list mid-frame-reprogrammed lands with approximate phase (rate exact); and plain headless without per-frame render still advances the copper only when frames render. r66 hardening: the production copper runs FPGA-true continuous pacing (`SetContinuousPacing` — cross-line/cross-call cycle overrun carries instead of resetting; golden/per-tick tests keep the legacy budget contract), the render's frame-line count follows live geometry (311 on +3 50Hz, was hardcoded 312), and a generation bump clears the pacer's seam carry (a reprogrammed list no longer inherits the dead list's pending instants) | CLOSED (delivery); prediction residue noted | |
| Copper mid-frame stop→start PAIR rendered as the FPGA's control timeline (#205, r89): a game that stops the copper, re-uploads the whole list and restarts it EVERY frame mid-raster (TX-1696's per-band engine: NR$62=$00 at ~line 194, ~512 NR$60 writes, NR$62=$C0 OnVBL at ~line 203) previously lost its ENTIRE program from every displayed frame — the start-only #197 debt deferred the list past its early-line WAITs, whose strict same-line release parked it for the whole pass (live pass retired ZERO copper writes; the stale re-render pass ran them, which is why harness screenshots that re-render masked the bug). Now the RUNNING→stop banks its instant and the following start converts the pair into a pause/resume schedule consumed by Step and RunToCycle: the pass runs the list from the frame top to the stop instant, freezes across [stop, start), then resumes with the start's pc. Start-only debt (#197 Quantum Storm) unchanged; pinned by TestMidFrameStopStartPairKeepsBands + TestStartOnlyDebtStillDefers. RESIDUES: (a) the pass runs the POST-upload list content — the FPGA's frame N runs the pre-upload one (no per-frame program snapshot; band values one frame early); (b) TX-1696's score-strip band first rendered white/mangled after the fix: the strip renders ABOVE the game's ~line-257 CPU scroll write, and the fold's frame-start baseline (the CPU stamp's oldVal) was contaminated by the copper's render-time band writes — fixed (r90) by carrying each fresh fold's post-stamp final value as the next frame's raster frame-top baseline (identical to oldVal for CPU-only writers — Atic/RAMS pins green; TestScrollFoldCarryBaselineUnderCopperWrites); whether the strip CONTENT now tracks per-frame in real play is pending browser verification, with the next suspect being NR readback coherence (the CPU reads copper-written registers at execution time and sees the previous pass's values, not the raster instant's); (c) ZX_GO_NO_PACED_ROWS=1 added as a render-interleave bisection switch | partial (pair timeline CLOSED; list-content snapshot deferred; TX HUD band OPEN) | `pkg/next/copper/copper.go` (pauseAt/resumeAt), `pkg/ula/ula.go` |
| Atic Atac's fire-skip transition (title→menu) kills its NMI-driven stream engine in our timing. Mechanism CAPTURED 2026-07-18 (probe46): the game's SP-repointed descriptor walks (e.g. the $D107 stream-descriptor writer) budget exactly one NMI push of slack; on FPGA-exact timing every walk lands in a collision-free slot of the 170.125-refT copper-NMI lattice (proven on silicon — stalling real hardware kills it identically), and in our timing a walk drifts under an NMI, the pushed return address poisons a CMD18 stream sector (captured live: arg $D11351B6), and the stream interpreter drains garbage forever. The T80N 28 MHz cycle-cost conformance audit (2026-07-18) closed every CPU-side gap it found: NMI acceptance 12 cycles at 28 MHz (the discarded M1 fetch is a waited bus read), NEXTREG r,n/r,A +2 (trailing microcode cycles are dummy bus reads — t80n_mcode.vhd X"91"/X"92"), PUSH nn +1, end-of-instruction ExtNMI sampling (FPGA samples NMI_s at the final T_Res — delivery was one instruction late), and halt-NOP-grid wake quantization (T80N accepts NMI/INT out of HALT only at 4T/5T NOP boundaries) — all pinned by `pkg/z80/z80n_28mhz_conformance_test.go` incl. the game's dumped handler-stub/walk sequences vs hand-computed FPGA totals. The skip still dies (corpse re-rolls with any phase change; probe now shows a zeroed-RAM NOP slide from frame ~5003 with the pacer's 206-cycle handler + 231×5-cycle NOP slide summing to exactly one 1361-cycle NMI period). Both follow-ups from the audit are now IMPLEMENTED: (1) the bank-7 BRAM no-wait quirk — reads resolving to 8K page 14 pay no 28 MHz wait (dedicated dpram2 CPU port, zxnext.vhd:6670-6686, absent from the :3175 wait term; `pkg/memory` Read28NoWait + `pkg/z80` readMem/halt-grid exemption, pinned by `TestZ80N28MHz_Bank7BRAMNoWait` + `TestRead28NoWait_Decode`; hot in the NextZXOS era — bank 7 is the DOS workspace at $C000, ~150k exempt reads in the probe's boot+load era — though the game's own slots hold no page 14); and (2) a deterministic two-state SD Nac model replacing the pseudo-random 4..64 pad (`pkg/next/sdcard/spi.go`: sequential read-ahead continuation = 2 byte-times, random-access first block = 8; read-ahead tracked across CMD18 auto-stream/CMD12, invalidated by writes/erase/CMD0; `TestCard_NacModel_Deterministic`). The fire-skip STILL dies, and a Nac grid (random-access 2/4/8/16) dies at every value — SD latency is a phase lottery, not the lever. 2026-07-18 (late): the "$1C12 polled observable" was identified — it is a RASTER wait (NR$1F video line LSB vs a target line at $20EC; the game's Layer 2 scroll raster sync, writing NR$16/$71), so the fatal walk entry is raster-slaved while the NMI lattice free-runs. Two more conformance fixes landed: (1) copper start anchoring — a stopped→running NR$62 transition now starts the list at its WRITE instant (copper.vhd:70-83 enable-edge; `Copper.SetStartPhaseSource`/startDebt consumed by Step/RunToCycle/FrameMoveInstants, pinned by `TestStartPhaseAnchor`) instead of the frame top, a constant lattice phase error of (write−origin) mod wrap that decided where every raster-slaved walk sat inside the NMI period (Atic starts its pacer mid-frame at ~refT 41852); (2) pacer instants are 28 MHz cycle-granular (Ref8Tstates compare; the /8 truncation fired up to 7 cycles early) with the gen-bump fast-forward cutting at the bump instant so quiet-gap instants deliver instead of dropping. The game's own protection model is now understood: its SP-repointed walks normally run INSIDE the NMI handler (IY-wheel stubs call them before RETN, where the FPGA arbiter holds further NMIs off — zxnext.vhd:2118 divmmc_nmi_hold — measured sinceNMI=7-8 refT on every healthy walk), and its mainline SP-cursor idioms keep the two bytes below SP consumed/about-to-be-rewritten, so most collisions are harmless BY DESIGN. What remains are 1-instruction-wide hazard windows in the game's own mainline: the menu-era per-frame raster-slaved `$19FB → CALL $2674` walk (fatal only in the ~14-refT pre-pop stretch of its SP=$26DD variant, where the NMI push overwrites the `LD SP,$F9FE`/`RET` code bytes at $26DB/$26DC) and the `POP DE; DEC SP` byte-queue readers (a push at that one boundary scribbles the interrupted PC over a to-be-re-read queue byte). Because the lattice precesses 1088 cycles/frame (five quasi-static phase classes drifting ~0.5 refT per 5 frames), an IDLE menu deterministically walks into the fatal band after ~100-350 frames — the same arithmetic applies to real silicon (prediction: real hardware's menu should die after ~10+ s idle too; the hardware sessions never idled it), so this is now believed to be at or near the game's own design margin rather than an emulator timing error. With a prompt menu-select the probe now reaches: title → fire-skip → live colour menu → menu-select accepted (new track chain) → doors-era scene with new tracks starting — far past every previous death — then soft-wedges ONE frame after the doors track ($0D) starts — deterministically, at any menu-select timing (select@5060 → wedge 5148; select@5045 → wedge 5151) — when a doors-era `POP DE; DEC SP` queue pass at $1397 consumes bytes an earlier pass's pop-boundary NMI pushes left in the $886F+ event queue (visible in the ring: popped DE=$B067/$B068 = interrupted-PC values of the $AFxx/$B0xx reader; downstream track_start seed $B6B6, stream tick stops, NMI/DAC engine stays alive behind the frozen doors scene through frame 26000). 2026-07-18 (night): the doors wedge is now fully reconstructed (whole-run write forensics on the queue page with MMU-slot attribution). The "$886F queue" is a static 6-byte-slot event-record block on 8K page 4 ($8870 + 6×index; consumer pops slot[read-index] via `POP DE; DEC SP`, first byte 0 = empty). After the fire-skip, EVERY write into it — in every trajectory — is an NMI-acceptance push from the ~20 kHz pacer landing while the game's own paced SP-cursor walks park in the block (geometry that is silicon-identical and design-tolerated: pushes land at/below the ascending cursor, on bytes already consumed); NO producer ever posts a real record (the select-transition's heavy $88xx-window writes target rotating stream-BUFFER pages $42-$5F mapped at MMU slot 4, not the page-4 queue — earlier reads of those as "queue traffic" were an MMU aliasing artifact). At doors-track+1 the handoff pass ($D7CD → $1385/$1397) pops slot 0 expecting a scene record and finds leftover scribble: E=0 → the doors engine state $F99C-$F9A4 stays zeroed, the $D7D3 OR-scan takes the idle branch and the $CF80/$D030 tick+countdown never run again; E≠0 garbage (earlier builds) → insane track seed $B6B6. Either way the scene freezes while the NMI/DAC stream engine plays on. The missing piece is the PRODUCER that posts the doors record on silicon — most plausibly a stream-script opcode executed by the select-transition's paced interpreter over the rotating buffer pages (44 frames ≈ 0.9 s) whose trajectory diverges from silicon upstream; the scribbles themselves implicate no remaining FPGA-timing discrepancy. Two more conformance gaps WERE found and fixed en route (both VHDL-cited, both re-roll the trajectory without moving the wedge off doors+1): (1) the copper NR$02 assert pipeline is +5 28 MHz cycles from the MOVE's first cycle, not +1 — write pulse on the MOVE's second cycle (copper.vhd:87-96), copper_req edge-detect register (zxnext.vhd:4709-4737), arbiter nmi_divmmc latch (zxnext.vhd:2096-2116), T80N NMI_s synchronizer (t80n.vhd:1650-1670) and the same-edge sampling rule (t80n.vhd:1765); (2) the FPGA arbiter's NMI gate reopens MID-RETN, ~6 CPU cycles before the instruction ends (retn_seen at T3 of the $45 M1 fetch, im2_control.vhd:236; divmmc hold-drop divmmc.vhd:108,126; S_NMI_HOLD→S_NMI_END→S_NMI_IDLE walk zxnext.vhd:2118-2166) — the pacer previously delivered every pulse that elapsed during a handler-ending RETN at the end-of-instruction poll (after the retnHook cleared the envelope), including pulses the FPGA drops; `copperNMIPacer.noteEnvelopeReopen` (called from the RETN hook) now drops pre-reopen pure-$04 pulses (pinned by `TestCopperNMIPacerEnvelopeReopen`). `ZX_GO_NO_COPPER_NMI_PACER=1`, `ZX_GO_SD_FAST_NAC=1` and `ZX_GO_SD_NAC_RANDOM=<n>` remain as bisection switches. 2026-07-18 (late night): the "missing producer" premise is REFUTED and the doors wedge re-rooted with instruction-level evidence. The $8870 scene-record table is LEGITIMATELY empty at the doors handoff: the record consumer's empty path and the $D7D3 OR-scan idle branch both fall through (the idle branch's CALL $14A9 is a sprite pass that returns), the doors loop reaches its unconditional stream-tick call at $D83F, and both SD music streams keep flowing (F990 counting, CMD18 args advancing) for ~9 frames after the doors track ($0D) starts. Static reversing of the engine established the design (the .NEX carries its era code pages in-file: doors slot-0 page 8 at file 0x18000, the two $A000-window pages at 0x12000/0x22000, era controller $2000-page at 0x2a000, sequencer $C000-page at 0x14000): records at $8870+6×($F918) are a per-scene doors-event script — byte 0 indexes the 180-entry 12-byte doors table at $8000-$886F on page 4 (built at fire-skip by the $40xx builder), the $1385 consumer publishes half-entry pointers to $F9A1/$F9A3, and its music-position-gated path ($145B) posts scene-advance event $16 to $F996 — so records are posted at runtime and none is due at doors+1. The REAL death, byte-identical in every trajectory tested (menu-select at frame 5045/5060/5075/5090 → doors track at 5157/5147/5182/5176 → death always ≈doors+8): the doors era's per-frame OBJECT system — 4-byte list entries at $F800 (count $F923/$F924), object structs in 8K page 5, per-object proc pointer at struct+$0A CALLed via self-modified operand by the $B001 dispatch — runs a bubble-sort pass ($B064) that walks the list with SP as cursor (LD SP,IX; POP AF pairs; EX (SP),HL swaps after DEC SP rewinds). A pacer-NMI acceptance push landing while SP sits in the list writes the interrupted PC over entry bytes; most land on slots the pass immediately rewrites (design-tolerated — observed harmlessly at doors+2/+4/+5), but a push in the pre-swap compare window leaves bytes the following EX (SP),HL READS, propagating them into entry 0's object pointer. Captured live at frame 5155: NMI push of pc=$B0AF writes $AF,$B0 at $F803/$F804; the swap copies pointer $AFB0 into $F800/$F801; the next frame's dispatch reads the fake object's +$0A "proc" = $EF10 (DAC-buffer bytes) and CALLs it → data execution, then total engine death (~doors+12: tick stops, F9E3/F990 freeze, the room renders frozen forever). Open question, sharply posed: the exposure arithmetic applies to silicon too (mainline sort, free-running 1361-cycle lattice; our frame is the FPGA-conformant 228×311-refT grid = 567,264 cycles at 28 MHz, mod 1361 = 1088 = the measured precession), so the fatal window should be swept within ~10 frames of any doors entry on hardware as well whenever the sort swaps (the two bat objects cross Y-order every few frames) — yet the shipped game presumably survives its doors attract. Either a still-unknown FPGA gate suppresses pacer NMIs in these windows, or the real attract posts its door record and advances before the race matures, or real hardware genuinely takes attract-screen crashes. Decisive next instrument is a HARDWARE capture of the $F800 list around the sort (the DZRP harness's campaign10 already anchors track_start; a doors-era anchor + $F800/$F923 dump at doors+N settles whether silicon takes the same pushes). 2026-07-19: RESOLVED — the paradox's answer is the Next's STACKLESS NMI mode. Atic Atac runs with NR$C0 bit 3 set (NEXTREG $C0,$09 at pc=$E1AE / $C0,$08 at $DAD3, in the .NEX at file offsets 0xA3AA/0x15ACF; NextZXOS itself boots with $08), and on the FPGA that bit makes NMI acceptance MEMORY-SILENT: `cpu_mreq_n <= z80_mreq_n or z80_stackless_nmi` (zxnext.vhd:1828) suppresses both push write cycles (asserted during the NMIACK_LSB/MSB microcode cycles, zxnext.vhd:2052, t80n_mcode.vhd:830-849 — SP still decrements internally) with the return PC captured into NR$C2/$C3, and the armed RETN (z80_stackless_retn_en, zxnext.vhd:2073-2083, one-shot per acceptance, consumed by any RETN-family opcode ED 45/55/5D/65/6D/75/7D per t80n_mcode.vhd:2426-2455) returns via NR$C2/$C3 with its two pop READS suppressed too (`cpu_di <= z80_retn_address`, zxnext.vhd:1850). So on silicon NO NMI ever writes the stack: every SP-cursor idiom in the game — the object sorter, the $2674 walks, the POP DE;DEC SP queue readers, the menu-era raster walk — is NMI-safe BY HARDWARE DESIGN, and every "scribble"/"one-push pad"/"collision-free lattice slot" reading of earlier sessions was an emulator-only artifact: our core wrote the push to RAM (and read the pop) in stackless mode, only redirecting RETN's PC. Fixed in pkg/z80 (`CPU.NMI` suppresses the write, SP-=2; RETN family takes the armed stackless path with SP+=2 and no read; `StacklessRETNArmed` models the one-shot arming incl. the held-reset while bit 3 is 0, wired in pkg/next WireInterruptControl), pinned by pkg/z80/stackless_nmi_test.go (write-suppression sentinels, one-shot arming, mirror-opcode grouping, disable-clears-arm). Probe outcome: title → fire-skip → menu → select → doors scene survives (sorter runs mainline envelope-open every frame, chain $AEDD/$D839, cnt=2, NR$C0=$08 — verified live) → GAMEPLAY: Knight in the Entrance Hall with live monsters, game clock counting, engine healthy through frame 26000 (SD streaming ~4070 blocks/frame-window, 416.8 NMIs/frame). NR$02 window forensics (frames 5150-5160): strict $04-delivery/$00-ack alternation ~20 refT apart, no game-side re-assert, no NR$06/NR$C0 writes — the ack protocol plays no protective role. Hardware-verification harness for the whole verdict: scratchpad campaign11.py (arms the $B064 sorter anchor when the doors page streams in; NR$C2/$C3 at the hits shows where NMIs landed mid-sorter on silicon; $F800 list dump proves no corruption) | resolved 2026-07-19 (#187) | |
| Atic Atac follow-ups (post-playable, #187): (1) mid-frame Layer 2 X/Y-scroll writes rendered with ONE per-frame value — the FPGA re-latches NR$16/$17/$71 into the Layer 2 address generator every 7 MHz pixel (layer2.vhd:105-116 "capture settings for pixel period", combinational into x_pre/y_pre at :152/:156; live wiring zxnext.vhd:4215), so the game's NR$1E/$1F raster-wait + NEXTREG $16/$71 rewrite (target cvc 184, wrap at 320) split-scrolls the character-select screen's text band; our per-frame latch rendered that band as offscreen black and parked the menu logo split. Fixed with the tilemap's raster-stamped per-line scroll fold applied to Layer 2 (`pkg/next/layer2` FoldScrollStamps/CaptureRowScroll, compositor FoldLayerScrolls bracket, WireLayer2 beam clock; pinned by `TestLayer2MidFrameScrollFold` + `TestLayer2WideModeScrollFoldAnchor`); probe frame 5060 renders the full menu/credits text where the black band was. NR$18 clip stays per-frame-latched (no known consumer). 2026-07-19 addendum: the game's intro-story screen (moon/castle + the three heroes) STILL rendered its text-crawl band black after the scroll fold — in BOTH boot modes (a direct-boot-only report, but a controlled A/B at the same game phase produced pixel-identical broken screens and only non-video NextReg diffs NR$06/$08/$11 — the boot-mode attribution was a coincidence of where each report looked). That band is driven by a THIRD mid-frame mechanism: a raster-waited NR$70 palette-offset program (offset 7 outside the band, stepped 6→0 across raster 115-128, back 1→7 at 254-266, ~16 writes/frame at pc=$11BC) selecting per-band 16-colour palette groups — group 7 is black (hides the L2 text plane outside the band), groups 1-6 are the fade ramp, group 0 full brightness; the text lives in fb columns 116-299 as low-nibble indices with an NR$17-crawled Y. NR$70's offset now rides the same raster-stamped per-line fold (stampPalOff in `pkg/next/layer2`; `TestLayer2MidFramePaletteOffsetFold`); mid-frame RESOLUTION changes stay per-frame (no known consumer, and a res change alters row layout mid-walk). (2) 28 MHz + pacer performance: the per-instruction ExtNMIFunc closure dispatch (and per-HALT-T-state poll) is now gated by a raw-T-state deadline the pacer arms with its next instant (`CPU.ArmExtNMIDeadline`, exact ceil conversion, self-clearing on speed change/frame origin; copper gen-hook voids it on reprogramming), NR$02 pulse writes no longer pre-compute soft-reset forensics (WireReset gates the 32-byte capture + Sprintf on softFire — was 2 allocs per delivered NMI), `FrameMoveInstants` batches uniform observation-free runs, and the render copper batches NOOP runs + skips consecutive-identical `MOVE NR$7F` pad writes (inert user register). Native 28 MHz Atic-style frame: ~7.0→3.3 ms (CPU-only 3.43→1.70 ms/frame — pacer overhead vs no-pacer eliminated; 844→12 allocs/frame). Delivery instants bit-identical (416 NMIs/frame, all conformance pins green). | closed 2026-07-19 (#187) | `pkg/next/layer2/layer2.go`, `pkg/ula/ula.go`, `pkg/z80/z80.go`, `pkg/next/copper/copper.go`, `cmd/zxplay_go/copper_nmi.go` |
DMA, buses, peripherals
| Gap | Status | Source |
|---|---|---|
| Port $0B (the legacy Zilog-DMA port — the zxnDMA's "Z80 DMA compatibility mode" decode) is wired: both ports reach the one controller, the accessed port latches dma_mode (zxnext.vhd:1811-1819), and the mode seeds the byte counter -1 at LOAD/CONTINUE/auto-restart so a Zilog-mode block moves length+1 bytes (dma.vhd:482-486). LOAD also latches the source/destination pointers by the direction in force at LOAD, surviving a later direction flip, exactly like the FPGA. Pinned by TestNexttestsZilogDMA against the Misc/ZilogDMA core-3.1.5 board photo | CLOSED (found by a manual run of Misc/DmaInteractive, whose default port is $0B) | `pkg/ula/ula.go` (dmaClaims), `pkg/next/dma/dma.go` |
| zxnDMA: match logic and DMA-vs-CPU bus contention not modelled; descriptor mode (port $DB) deferred. Interrupt behaviour is CLOSED (2026-08-01): the FPGA's dma.vhd carries the Zilog interrupt-control machinery commented OUT (:94-96/:836-856) so the zxnDMA generates no interrupts on hardware either, and the NR$CC-$CE DMA-interrupt enables (which pause an ongoing transfer for enabled interrupt sources / NMI) are live — see the CTC/IM2 row. Read/write cycle-length costs are charged in CPU T-states (the FPGA FSM ticks at 28MHz) — a documented model convention; the prescaler delay IS turbo-exact (prescaler*4^turbo/2 T-states, dma.vhd:250-255/424). Continuous-mode transfers run synchronously and charge their duration afterwards, so their port writes all raster-stamp at one instant: a transfer streaming VARYING bytes to the border collapses to its final value on screen (Misc/ZilogDMA's top-border noise band), while fixed-value streams (the flashing timing blocks) render their band geometry exactly | deferred | `pkg/next/dma/dma.go` |
| CTC (r50) + hardware-IM2 vectored interrupts (r54, #169): channels 0-3 live behind ports $183B-$1F3B with NR$C5 int-enables; pulse mode as before, and with NR$C0 bit 0 set the im2.go daisy chain is WIRED end-to-end — line INT (vector 0), CTC 0-3 (vectors 3-6) and the ULA frame INT (vector 11) latch as level requests, the winning source supplies `NR$C0[7:5] & vector & 0` at the Z80's IM2 acknowledge (z80.CPU.IntAckFunc), exact ED 4D releases the in-service device, the ULA still pulses when the Z80 is not in IM 2 (the one EXCEPTION source), and NR$20/$C8/$C9 give software-generated requests + sticky status with write-1-to-clear (`pkg/next/im2block.go`, TestWireIM2*). This closed TX-1696 (#169) — its audio install is caught by CTC ch0 in hw-IM2 mode, measured on real silicon first. The channel-to-channel ZC/TO cascade is wired (#158): ch N's CLK/TRG = ch (N-1) mod 4's ZC/TO (zxnext.vhd:4082), so counter-mode channels divide their upstream neighbour and wait-on-trigger timers start on it (TestCTCCascade*; pulses land at the block's per-instruction observation granularity). UART sources (vectors 1/2/12/13) are live too (#158): uart0 RX-avail level + honest constant TX-empty, NR$C6 enables, NR$CA sticky status with write-1-to-clear (zxnext.vhd:1941-1956). NR$CC-$CE DMA-interrupt enables are LIVE (2026-08-01): they gate the DMA-DELAY condition, not DMA-generated interrupts (the FPGA's dma.vhd has the Zilog interrupt machinery commented out, so no-op interrupt commands are conformant) — an enabled chain device outside idle, or an outstanding NMI with NR$CC bit 7, holds the zxnDMA off the bus between byte transfers until the RETI release (zxnext.vhd:1957-1958/:2005-2008, im2_device.vhd:151, dma.vhd:269/427); IM2Block.DMAPause + dma.SetPauseFunc via next.WireDMAPause, TestWireDMAPause*. Pulse-mode-only status recording is conformant: the FPGA holds the chain reset in pulse mode too (im2_peripheral.vhd:105) | CLOSED (DMA-interrupt enables live 2026-08-01) | `pkg/next/ctcblock.go`, `pkg/next/im2block.go`, `pkg/next/im2.go` |
| hw-IM2 poll deadline gate (#208, 2026-07-20): a game running a high-rate CTC/IM2 sample engine (TX-1696's ~22 kHz $DF DAC streamer, ~440 vectored interrupts/frame) previously paid an `IntLine` poll on EVERY CPU instruction (~70k/frame) — hw-im2 mode returned deadline 0 (`ExtIntDeadlineFunc`), so the CPU sampled the chain every instruction, each assembling an `IM2Inputs` struct + running `ctc.ConsumeZC`. `IM2Block.NextAssertRef8` now returns the CTC block's own next-ZC deadline whenever the chain is inert — no device mid-REQ/ACK, no latched request, no pending pend* input, no enabled UART level — and every earlier wake source kicks the CPU gate (RouteInt/Unq/ClearC8-9-A pends, CTC reschedule via `SetRescheduleNotify`, NR$C0 mode flip, NR$C6 UART-enable). Polls dropped ~70k → ~4.1k/frame; native TX-1696 gameplay exec `2.07 ms` vs `3.97 ms` pre-fix (−48%; full frame incl. render `4.06` vs `5.96 ms`). Correctness held DELIVERY-IDENTICAL: an ISR-parked device (S_ISR, no latched request) is tick-stable and its only exit is the RETI hook's own tick, so the gate holds through the ISR body; CTC accesses that bank fresh ZCs drain them to the chain BEFORE mutating channel state (`CTCBlock.SetZCConsumer`) so a pulse latches under the enables in force when it fired; a post-pulse fall tick (`needFallTick`) preserves the edge detector; sticky-status reads (NR$20/$C8/$C9/$CA) sync the chain through "now" first. Pinned by `TestWireIM2DeadlineGateDeliveryIdentical` (gated vs `ZX_GO_IM2_NO_GATE=1` deliver at byte-identical step instants) + `TestWireIM2StaleDisabledZCNotLatchedOnEnable`; IM2 golden vectors + all wire_im2/ctc tests green. `ZX_GO_IM2_NO_GATE=1` kept as the bisection switch. Benchmark: `BenchmarkTXGameplayFrame` (cmd/zxplay_go, env-gated on the local game). Render side is NOT the bottleneck for this title — a live-frame stride census shows only 19/192 paper rows take the paced per-half-pixel stride (the copper band-boundary lines), so the ~18 ms wasm render is the compose walk itself (already #187-trimmed + row-cached), left unchanged. #206 REFINEMENT (2026-07-20, WOTEF): the #208 "gate holds through the ISR body" claim was NOT what the code did — `updateActive` counted any `im2IntReq` latch as chain-active, and the latch is HELD through the whole in-service window (im2_int_req sticky clears only on the S_ISR→S_0 RETI transition, im2_peripheral.vhd:167-178), so an in-service device reopened the per-instruction poll for the ENTIRE ISR. TX-1696's few-instruction sample ISR barely noticed; WOTEF's fat ~23 kHz music-mix ISR (NR$C0=$09, CTC ch2 via NR$C5=$04, 1.5 DAC samples/scanline) spent most of each period in service and the 14-device chain `Tick` per instruction was ~30% of native exec (browser: 43 fps, audio pulled at ~38k/44.1k, continuous underruns at the title). `updateActive` now treats S_ISR as tick-stable regardless of the latch (only S_REQ/S_ACK transitions and an S_0 device with a latched request need per-sample-point ticking); mid-ISR request edges merge into the held latch exactly as on the FPGA (set wins, RETI-tick clears both) and the CTC ZC deadline reopens the gate at those instants anyway. Native WOTEF probe wall −28%; interrupt machinery 33% → 9% of exec. Pinned by `TestWireIM2ISRParkedGateStaysClosed` (long-ISR delivery-identical A/B + poll count O(interrupts), fails pre-fix at 20,643 polls/323 ints) | closed (exec −48% #208, ISR-body polls closed #206); render compose-bound, unchanged | `pkg/next/im2block.go`, `pkg/next/ctcblock.go`, `pkg/next/wire.go` |
| 28 MHz timing: REAL-HARDWARE VALIDATED (#169, `_tools/hw-probe`, 2026-07-15). NOPs 33 lines/12k (5T exact), pushes 40 lines/6k (~12.2T — writes do NOT wait, ROM-window writes = RAM writes), frame INT at raster 248 (exact). Residual: real pushes read ~0.2T/insn slower than the model (40 vs 39 lines per 6,000) — below one raster line per 4,632-push slide, not worth modelling. TX-1696's geometry is impossible on real silicon too; suspicion moved to the NextZXOS version (2.07k/2.08 tested-working vs our 2.09 card) shifting the slide-entry phase | closed (validated) | `pkg/z80/z80.go` (readMem), `_tools/hw-probe/`, docs/compatibility.md TX-1696 row |
| UART/ESP: served at the real ports $133B-$163B (decode zxnext.vhd:2639; NR$A8/$A9 are the ESP GPIO registers, not the UART) — but the responder is AT-stub only, no real networking or socket emulation, and baud/prescaler timing is accepted and ignored | out of scope (networking); ports conformant | `pkg/next/uart/doc.go` |
| NR$0B joystick I/O mode: register exact, pin-repurposing behaviour (GPIO/UART on joystick pins) not modelled | out of scope | `pkg/next/wire.go` |
| NR$B0/$B1/$B2 (extended keys / MD pad) are wired: composed on every read from the live input state, pinned bit-for-bit to the read mux (zxnext.vhd:6206-6215) by TestWireExtendedKeys*. The port $1F/$37 side is composed too (r53): both ports decode on the low address byte and idle at $00 (zxnext.vhd:2546-2547/:2829-2830), with the NR$05 routing incl. the MD-mode START/A bits 7:6 (:3472-3494) through `pkg/ula` nextJoyPortByte. The MD-pad BUTTON SOURCE now exists (r77, #161): `ULA.MDExtraState` holds vector bits 11..5 (MODE X Z Y START A C) beside the Kempston byte's 4..0, `MDJoyLeft()` composes the two, and the browser's Gamepad API poll feeds the whole 12-bit vector in through `zxJoystickState`. The membrane KEY-JOYSTICK injection is modelled too (r98, #202): the FPGA's membrane_stick module (input/membrane/membrane_stick.vhd; keymap ROM ram/init/keyjoy_64_6.coe) presses membrane keys for pads NR$05 routes to a keyboard mode — Sinclair 1 (011) = 67890, Sinclair 2 (000) = 12345, Cursor (010) = 5678+0, User Defined (111) via the NR$28/$29/$2B joymap RAM (whose write path existed since the keymap work but was never consumed), incl. the Kempston/MD-mode excess-button slots ("excess buttons generate keypresses if so programmed", ports.txt) — ANDed into port $FE reads (`pkg/ula` joymembrane.go, `TestJoyMembrane*`), gated off in NR$0B I/O mode. NOTE the official naming: nextreg.txt pins Sinclair 1 = 67890 and Sinclair 2 = 12345 (the zxnext.vhd comment block has the parentheticals swapped; the core changelog records the FPGA itself once shipped them reversed). Our frontend had the same swap — fixed with this change (joystickKeySymbols + UI labels), and on the Next the frontend selection now writes NR$05 joy0 (Kempston→MD-1 "101" superset) instead of injecting keys, so the machine's own routing is the single truth (`applyNextJoystickMode`). Bomb Jack's JOYSTICK mode (reads row $EFFE directly) is the pinning consumer: `TestBombJack202Probe`. Remaining: only the LEFT pad is modelled, so `MDJoyRight()` is still constant 0, NR$B2's high nibble always reads idle and right-pad membrane injection never fires — two-player input is out of scope; the desktop (GLFW) build has no pad source yet either (desktop arrows do feed the vector); membrane columns 5-6 (extended-key columns) are representable in a user mapping but not routed into the NR$B0/$B1 extended-key vector | CLOSED (read-back, $1F/$37 decode, left-pad button source, membrane key-joystick injection); right pad + desktop pad source deferred | `pkg/next/wire.go` (WireExtendedKeys, Wire keymap hookup), `pkg/keyboard` (ExtendedKeys), `pkg/ula` (MDJoyLeft/Right, SetMDExtraButtons, joymembrane.go), `cmd/zxplay_go/joystick_state.go` |
| RTC: clock-register writes discarded (host time is truth); only NVRAM persists; 1 Hz output disabled | pragmatic model | `pkg/next/rtc/rtc.go` |
| divMMC $2009 FRAMES-bump stub and bank-1 stub write-protect emulate firmware-installed handlers non-literally | pragmatic model | `pkg/next/divmmc/divmmc.go` |
| esxDOS F_READDIR entry layout simplified; F_FSTAT fills size + dir bit only; no success-with-carry contract | deferred | `pkg/next/esxdos/file_handlers.go` |
| .NEX loader: `Copper` field exists but is never populated (code states standard V1.2 carries no copper section, package doc says otherwise) — doc/implementation inconsistency to resolve | needs resolution | `pkg/next/nex/nex.go` vs `doc.go` |
| ROM SHA-256 digests reported but not enforced as a boot gate | planned | `pkg/next/install/install.go` |
| Audio event placement above 3.5 MHz is approximate (sample-exact placement is a known limit); Next DAC granularity notes in docs/spectrum-next.md | deferred | docs/spectrum-next.md |
| Next DAC port decode is complete per zxnext.vhd:2658-2664 with power-on port enables (r87, #207: mono $DF→A+D and $B3→B+C, stereo aliases $3F→A and $5F→D added; $FB stays D-only — soundrive-2 precedence over the Pentagon mono-AD map). The internal-port-enable personality bits (NR$82-$85 bits 17-23) and the NR$08 bit 3 dac_hw_en master gate are LIVE (2026-08-01, next.WireDACGates + dac.SetDecodeGates, TestDecodeGates — per-alias channel unions per zxnext.vhd:2657-2664 incl. the $FB sd2-over-monoAD precedence). Residue: the NR$2C/$2D/$2E SounDrive NextReg mirrors stay unwired (`pkg/next/dac/soundrive.go` exists but nothing calls WriteNRMono/Left/Right) | decode + enables CLOSED; NR mirrors deferred | `pkg/next/dac/dac.go`, `pkg/next/dac/soundrive.go` |
Classic line scope limits
| Item | Status | Source |
|---|---|---|
| IF1 RS-232 and SinclairNET: stubbed as "no peripheral connected"; CTR WAT CPU-stall not modelled | out of scope | `pkg/if1/ula.go` |
| Floppy controllers are I/O-advanced: no rotational/seek timing (weak sectors and Speedlock are modelled on the +3) | pragmatic model | fdc package docs |
| Beta density bit not modelled (TR-DOS always MFM) | pragmatic model | `pkg/betadisk/interface.go` |
| Pentagon-1024 mapping mode ($EFF7 reg 2) latched but not modelled | deferred | `pkg/memory/memory.go` |
| TZX blocks 0x12/0x13/0x15 (pure tone / pulse sequence / direct recording) parsed but skipped | deferred | `pkg/ula/tzx.go` |
| Floating bus returns $FF on +2A/+3/Next (correct) — noted here because it surprises people | correct behaviour | `pkg/ula/ula.go` |
| Multiface paging readback is the full FPGA surface (2026-08-01): the enable/disable pair per NR$0A personality with the +3 paging-shadow arms ($1FFD motor+low nibble, $7FFD, $DFFD, $EFF7 bits, border default) and the MF128 bit-7 form, invisible-gated, via the GHDL-golden multiface.Core (next.MFBlock) | CLOSED | `pkg/next/mf.go`, `pkg/multiface/core.go`, `pkg/ula/ula.go` |
| MEMPTR implemented to the depth zexall observes; some exotic update sites may be missing (only visible via F3/F5) — passes z80test v1.2a's memptr variant outright | documented depth | `pkg/z80/z80.go`; `pkg/testharness/z80test_test.go` |
| Per-access ULA memory contention is LIVE on ModelNext (#181): the CPU's cycle helpers consult ContendMemory, gated exactly like the FPGA (3.5 MHz only, NR$08 bit 6, non-Pentagon timing — zxnext.vhd:4481), with the contended page set selected by the NR$03 timing and following the page wherever mapped, MMU8 included (:4490-4494: 48K=bank 5, 128K=odd banks, +3=banks 4-7). Port contention only under 48K/128K timing (the +3-timing wait arm is memory-only, zxula.vhd:604 — the Next's default +3 timing has UNcontended ports, which also removed a spurious +4T the old classic-branch fallthrough charged every even-port I/O). Paper window anchored at t=14655 (INT at cvc 248 − 291 T). TestNextContention*. Remaining: the pattern is the canonical 6,5,4,3,2,1,0,0 rather than a GHDL-derived per-cycle trace. CLASSIC models CLOSED too (#189, r79): contention moved from the per-opcode cycle helpers to the central access path — `readMem` (every M1 fetch, prefix byte, immediate operand and data read) and `writeMem` (lump-timed writes) — so all 256 opcodes contend instead of the ~81 converted to m1/rd/wr/exec, and the opcode FETCH contends at all, which it never did before. That fetch gap was the bug: a 48K game with code in the lower 16K ran the whole display window at full speed. `cpu.MemContend` is now on for classic models in `newEmulator` (`ZX_GO_NO_MEM_CONTEND=1` restores the old model for bisection). Magnitude pinned from first principles by TestMemContention_FrameThroughputMagnitude: an all-NOP frame from contended RAM settles at 8 T/NOP in-window (a repeating access LOCKS to one pattern slot rather than averaging), giving 14400 instr/frame vs 17472 uncontended. Port I/O is cycle-exact since #194: every IN/OUT (DB/D3, the full ED IN r,(C)/OUT (C),r families, INI/IND/OUTI/OUTD + repeats) runs through `ioIn`/`ioOut` — 4 T I/O cycle charged by the CPU, holds-only `ContendPortEarly`/`ContendPortLate` (FUSE shapes), bus SAMPLED at the cycle's 4th T (IN r,(C): T+11), WRITE after its 1st. That also removed a +4 T overcharge the old `ContendPort` added on every ULA/contended-port I/O (IN A,($FE) cost 15 T, not 11). Same change fixed the classic contention/floating-bus grids per libspectrum: contention 48K 14335 on 224 T lines (was 228), 128K 14361 (was 14335); floating-bus paper window from the top-left-pixel time with NO extra left-border offset (was 24 T late), 128K origin 14362 (was 14592). The floating bus also reads the RAW frame-relative T counter now (ExecuteFrame wraps it each frame — the same grid contentionDelay anchors on): it used to subtract the ULA's frameStartTstate, which the AUDIO flush stamps at the previous frame's overshoot (0..~20 T, varying per frame), jittering the bus slots against the contention pattern — so the bug reproduced ONLY with audio running (browser/desktop) and never in the audio-less harness (TestFloatingBusIgnoresAudioFrameStamp; TestArkanoidBrowserPath covers the newEmulator+audio path). Arkanoid's floating-bus beam-race pacing (DI + IN A,(C) $28FF, phase-locked via a contended screen write) now runs exactly one game update per frame — the grid mismatches made it misread "raster left the paper" mid-frame and run the ball at 2x+ speed with a flickering bat (TestArkanoidBallSpeed pins 3 px/frame against the FUSE 1.6.0 reference). The classic paper render also moved to beam-time scanline capture (ula.CaptureScanlines via CPU.ScanlineFunc): end-of-frame memory is the wrong render source for beam-racers — Arkanoid XOR-erases its bat in the vblank and redraws next frame ahead of the beam, so an end-of-frame render showed no bat in ANY correctly-paced frame (and a flickering one under the old broken pacing). Residue: NON-I/O lump-timed opcodes still contend at the instruction's current T position rather than the exact machine cycle's start — multicolour-grade raster tricks want the remaining per-opcode conversion | Next CLOSED; classic CLOSED (sub-instruction placement approximate for non-I/O lump opcodes) | `pkg/memory/memory.go` (isContendedAddr/contentionDelay/ContendPortEarly/Late), `pkg/next/wire.go`, `pkg/z80/z80.go` (readMem/writeMem/m1/rd/wr/ioIn/ioOut), `pkg/ula/ula.go` (floatingBusByte), `cmd/zxplay_go/main.go` |
| SAM: MIDI, clock port, SD/IDE ports ignored | out of scope | `pkg/sam/io.go` |
| SAA1099 is datasheet-modelled (no hardware-verified reference core exists) | best available | `pkg/saa1099/saa1099.go` |
Tooling and port gaps
| Item | Status | Source |
|---|---|---|
| Time-travel ring captures CPU + visible 64K + ports + border only; upper RAM banks, divMMC RAM, NextRegs, MMU slots not captured/restored (phases 2a/2b catalogued) | deferred | `cmd/zxplay_go/timetravel.go` |
| wasm binary ~31 MB (Fyne linked as dead code); shrinking requires splitting the core out of `package main` | later optimisation | `wasm/STATUS.md` |
| Desktop run loop does not use the fastboot fast-forward (browser only) | deferred | `cmd/zxplay_go/fastboot.go` |
| The Next cannot tape-load in the browser player; .tap on the Next falls back to the 128K | deferred | `GoEmulator.js` |
| Browser direct-boot seed table and the nexload menu-index are coupled to the SD distro version; re-verification procedure documented | maintenance coupling | `packages/emulator-core/README.md` |
| Faithful firmware boot (FPGA bootrom → TBBLUE.FW) of the OFFICIAL distro card geometry (1 GB, 32 KB clusters, partition at LBA 63) lands in the config tool with "Error opening 'menu.ini/.def'" — every firmware file open fails, while NextZXOS itself reads the same card fine. Not the partition offset (rebasing to LBA 2048 reproduces), not firmware content or config.ini (byte-identical to the staged card, which boots). The browser's direct-boot path is unaffected (all boot regression tests pass against the prepped official image), but the "delete the go.env lines" fallback does not currently work on the official card. Consequence for the STAGED card: `scripts/trim-distro-card.sh` rebuilds the trimmed distro system at distro capacity but STAGED geometry (LBA 2048, 4 KB clusters) — both boot paths verified — instead of cloning the official layout, which reproduces this gap. Cluster size (32 KB) or a dependent FAT parameter is the remaining suspect | open (r60) | `cmd/zxplay_go/distro_prep.go`, `packages/emulator-core/README.md` |
| Warm-boot path (`--warm-boot`) uses captured reference dumps; non-faithful by design, default off | dev tool | `cmd/zxplay_go/next.go` |
| TZX blocks with pause=0 get the legacy ~1 s TAP silence instead of the spec's "no pause" (back-to-back turbo chains could over-wait; harmless now that pause chunks emit no edges, #192) | deferred | `pkg/ula/tape.go` |
| Custom loaders using the ROM's LD-EDGE (mid-LD-BYTES entries, RAM byte loops CALLing $05E7/$05E3) are O(1)-trapped timeline-neutrally; loaders that COPY the sampling routine into RAM (Speedlock class) still interpret at real time under the fast-tape turbo — byte-matching the copied routine is the known extension if one shows up | partial (by design) | `cmd/zxplay_go/main.go` |