JNEXT
Skip to content

3.3 Video

The picture a ZX Spectrum Next puts on screen is composed, not drawn. Four generators run in parallel — the classic ULA screen, a Layer 2 bitmap, a tilemap and a sprite engine — each offering a pixel for every position on the display, and a hardware compositor chooses between them using a programmable priority order, per-layer transparency and a pair of blend modes. A fifth mode, LoRes, does not add a layer at all: it substitutes chunky pixels into the ULA's slot. All of it is under software control at scanline granularity, and through the Copper at a position within a scanline, which is what makes raster effects on this machine so much richer than on a 48K Spectrum.

That makes video the largest subsystem in JNEXT — about 10 200 lines across eight modules in src/video/, plus the part of src/core/emulator.cpp that feeds it. It produces exactly one artefact: a 640×256 ARGB8888 framebuffer, built one scanline at a time by Renderer::render_frame at the end of every emulated frame (see The video pipeline).

The consequence of that last sentence shapes most of this page. Hardware draws the picture while the CPU runs, so a register written half-way down the screen affects only the rows below it. JNEXT runs the whole frame first and renders afterwards, so a naive renderer would see nothing but end-of-frame register values and every mid-screen change would vanish. A large part of the video code therefore exists to record what each register was at each row, so that the deferred render can reconstruct a picture that changed while it was being drawn.

Raster counters and their clock domains

Confusing these has cost this project real bugs. Three counters exist, at three rates:

Counter Rate Lives in
master cycle 28 MHz Clock, MachineTiming in src/core/emulator_config.h
hc / vc raw frame counters 7 MHz pixel clock VideoTiming in src/video/timing.h
hc_ula / vc_ula / cvc ULA pixel counters 7 MHz, shifted origin derived from VideoTiming
phc practical pixel counter 7 MHz, hc_ula - 12 derived from VideoTiming

One pixel tick is 4 master cycles, and one 3.5 MHz T-state is 2 pixel ticks. The per-machine limits — c_max_hc, c_max_vc, c_min_hactive, c_min_vactive and the interrupt position — are VHDL constants held by VideoTiming, keyed on the NR 0x03 timing axis rather than on the machine personality.

hc_ula is not hc. It is a registered counter, reset one tick after hc matches c_min_hactive - 12, so hc_ula == 0 at raw hc == c_min_hactive - 11 (VideoTiming::hc_ula_zero_raw_hc), and cvc counts lines in that counter's frame. The Copper compares its WAIT against hc_ula/cvc, which is why Emulator::tick_copper_for_master_cycles rebases the master-cycle position onto that pair before stepping it. Passing the raw master-cycle offset through instead — wrong by a factor of four in scale and wrong in origin — made every non-trivial WAIT fire early. That was a real defect here, not a hypothetical one.

phc is a fourth origin again: the pixel actually being generated. The VHDL loads it with -48 and counts up, so phc == 0 is the first paper pixel, which is hc_ula == 0xC (zxula.vhd:43-46). The twelve-tick gap is the ULA's prefetch lead, and it is why the fetch window and the visible paper area are not the same span of a line.

src/debug/raster_state.h is the one place that spells all four out together, for the debugger's raster indicator. It classifies the beam's region (blank_n from zxula_timing.vhd:348-357, border_active from zxula.vhd:415) and the ULA's fetch phase (vram_a/vram_rd on zxula.vhd:226-263 — bit 1 of hc_ula selects bitmap vs attribute, gated by border_active_ula at zxula.vhd:416). It is Qt-free and takes a VideoTiming by reference: the per-machine constants stay in the one timing model, and the c_max_hblank / c_max_vblank accessors were added there rather than duplicated in the panel. raster_state_test pins it per machine against the VHDL.

NR 0x1E/0x1F read cvc back, and when they read it matters as much as the origin. The master clock only advances once an instruction completes, so inside an IN clock_ is still the instruction's start. Emulator::io_read_sample_cycle() adds the T-states the CPU has already spent in the instruction — Z80Cpu::io_clock_into_instruction(), counted on the FUSE counter the bus cycles advance, contention stretches included — and places the read where the VHDL latches it: for port 0x253B, the CLK_CPU falling edge of the I/O cycle's third clock. A read made outside an instruction (a test, the debugger) samples at clock_ itself. The floating bus below uses the same helper.

cvc's offset is NR 0x64, but not live. The counter loads the register once per frame, at hc_ula == 0 of the c_min_vactive line (zxula_timing.vhd:457-462), and only increments everywhere else, so a mid-frame NR 0x64 write reaches the readback, the line interrupt and the Copper at the next frame's reload. VideoTiming::cu_offset() is that reloaded value, not the register, which Copper::offset() holds for the readback. Emulator::reload_cvc_offset_() performs the reload as a scheduler event at VideoTiming::cvc_reload_master_cycle_offset() and re-derives the line-interrupt schedule there. A frame whose reload changes the value counts from two offsets, one either side of it, so a cvc line can occur twice in it or not at all. The reloaded value is saved state (cvc_offset_delta, stored relative to NR 0x64).

A write to the line-interrupt registers is timed the same way. reschedule_line_interrupt() takes the cycle the write lands on — a CPU write's commit edge, a Copper MOVE's own cycle — because the compare at hc_ula == 255 reads the enable as it stands at the end of its pixel but the target as it stood before it (zxula_timing.vhd:563-583). A change landing inside that pixel still fires the old target, and a compare earlier in the same instruction window keeps its event.

The other conversion that matters everywhere is framebuffer_row = vc - VideoTiming::vblank_top(). vblank_top is min_vactive - 32 and it is per-machine — 32 for the Next family, 48 for Pentagon timing, 8 for the 60 Hz branches — so the constant 32 is right only by coincidence. Emulator::schedule_frame_events queues one SCANLINE event per line plus a VSYNC, and Emulator::on_scanline(line) is the hook all per-line work hangs off.

The ULA

The ULA is the original Spectrum display generator, and on the Next it is still the layer everything else is composited around: 256×192 pixels with the familiar attribute cells, a border, and the Timex extensions that predate the Next itself. The Next adds hardware scrolling and a choice of palettes on top of it.

src/video/ula.{h,cpp} renders one framebuffer row at a time through Ula::render_scanline. Border rows go to render_border_line; display rows dispatch on the Timex screen mode last written to port 0xFF. STANDARD, STANDARD_1 (the 0x6000 screen) and HI_COLOUR each double every source bit into two adjacent framebuffer cells, while HI_RES emits a true 512-pixel line from screens 0 and 1 byte-interleaved. The ULA also owns ULA scroll (NR 0x26 / 0x27, NR 0x68 b2), ULAnext (NR 0x42/0x43), ULA+ (ports 0xBF3B/0xFF3B), the shadow-screen bank selector, the flash counter, and the active-palette selector bits for all four palettes.

Two things a reader expects to find here are somewhere else. The clip window is one: Ula::render_scanline emits an unclipped line and Renderer::apply_ula_clip masks it afterwards, because in hardware NR 0x1A feeds a compositor-stage signal — Layer 2, tilemap and sprites each clip themselves instead. The floating bus is the other: Emulator::floating_bus_read is the port-0xFF read mux, with its NR 0x08 b2 Timex arm, its NR 0x82 b0 gate, and the per-machine gate that delivers ULA content only under 48K/128K timing. ula_floating_bus_active_arm evaluates the ULA's reload schedule (zxula.vhd:319-340) in hc_ula/vc_ula — the same counters contention uses — at the instant the CPU latches the byte, T3's falling edge after every stretch (io_read_sample_cycle), and reads the byte the ULA fetched there, through Ula::fetch_vram(): Timex mode, ULA scroll and the 128K shadow screen all move it. The +3's separate latch lives on the Mmu.

LoRes is not a layer

LoRes trades resolution for colour freedom: 128×96 chunky pixels, each carrying its own palette index, which is exactly what the ULA's attribute cells prevent. Radastan mode is the 4-bit variant of the same idea. Neither is a new plane in the display — the hardware feeds these pixels into the ULA's own slot in the compositor, and the rest of the machine cannot tell the difference.

src/video/lores.{h,cpp} models the lores VHDL entity as pure functions plus its registers (NR 0x15 b7, 0x32, 0x33, 0x6A), and JNEXT keeps the substitution literal: Renderer::apply_lores writes into the already-rendered ULA scanline, and there is deliberately no fifth layer buffer. The LoRes byte indexes the ULA palette in the bank NR 0x43 b1 selects, and is clipped by the ULA's window because that is where hardware wires it. Both 8-bit LoRes and Radastan fetch from bank 5 directly.

Each byte is fetched at its pixel's own instant, through the AttributeMux replay (see the table under "Per-scanline state" below). All the fetches of one row fall in one span of bank 5, 128 bytes in 8-bit mode and 64 in Radastan, so apply_lores first asks the mux whether any byte of that span is hot (a write the mux has not folded into its baseline). When none is, it reads the row straight from the mux baseline, which is what the replay would return for every pixel; only a row with a hot byte pays for the per-pixel replay.

Layer 2, tilemap, sprites

These three are the Next's own additions, and each removes a specific piece of work a Spectrum program used to do in software.

Layer 2 is a linear bitmap: every pixel carries its own palette index in a straight run of RAM, so it has none of the ULA's one-colour-pair-per-cell restriction, and it scrolls in hardware on both axes. NR 0x70 selects between three geometries, listed below. Because the frame can be larger than a 16 KB bank and is fetched by the display hardware rather than by the CPU, Layer2 reads physical RAM banks directly instead of going through the MMU.

Tilemap is a character display in the modern sense. A 40×32 or 80×32 grid of cells names 8×8 patterns held once in RAM as 4-bit-per-pixel definitions, so a screenful of graphics costs a screenful of indices; the hardware scrolls the whole grid (NR 0x2F/0x30/0x31) without anything being copied. Each cell normally carries an attribute byte alongside its index — palette offset in the top nibble, X mirror, Y mirror, rotate, and a per-tile "ULA over tilemap" bit — and NR 0x6B can strip those flags, extend the palette offset for text mode, or repurpose the low bit as a ninth tile-index bit for 512-tile mode.

Sprites are drawn by the display hardware as the raster passes, so moving an object costs a few attribute writes rather than a blit and a background restore. There are 128 of them, 16×16 pixels, at 4 or 8 bits per pixel, with mirroring, rotation, ×1/2/4/8 scaling, and anchor-plus-relative composites for objects bigger than one sprite.

Layer File Modes Notes
Layer 2 src/video/layer2.* 256×192 8bpp, 320×256 8bpp, 640×256 4bpp (NR 0x70) reads physical RAM banks directly, not through the MMU; clip NR 0x18, scroll NR 0x16/0x17/0x71, banks NR 0x12/0x13; per-pixel priority promotion from palette bit 15
Tilemap src/video/tilemap.* 40×32 and 80×32, 4bpp patterns + text mode map and definitions in bank 5 or the dedicated bank-7 BRAM; emits the per-pixel tm_pixel_below and tm_pixel_textmode flags the compositor needs
Sprites src/video/sprites.* 128 sprites, 16×16, 4/8-bit, ×1/2/4/8 scale, anchored composites ports 0x303B/0x57/0x5B, 16 KB pattern RAM, clip NR 0x19

Each of the four clip windows is a single NextREG written as a rotating 4-value cycle — NR 0x18 Layer 2, NR 0x19 sprites, NR 0x1A ULA (and LoRes), NR 0x1B tilemap — with the rotating index held by Emulator rather than by the layer.

Sprite collision and the per-line-budget overtime flag (port 0x303B) are computed inside SpriteEngine::render_scanline and are software-visible, so they cannot be skipped along with the pixels. A frame whose render the frontend drops still runs Renderer::run_sprite_side_effects: the same pipeline, with the pixels discarded.

PaletteManager (src/video/palette.*) holds all four palettes in two banks each, RGB333 internally with an ARGB8888 cache rebuilt on write, plus the ULA+ region aliased at ULA indices 0xC0-0xFF. See Ports and NextREG for the register plumbing and Peripherals for the Copper.

The compositor

Renderer::render_row is the whole per-row pipeline: clear the layer buffers, render each layer, substitute LoRes, apply the NR 0x68 b7 blank and the ULA clip, then call composite_scanline. That last step dispatches once per row on the NR 0x15 priority into a composite_scanline_mode<PRIO> specialisation, so the per-pixel loop that follows carries no branch on the mode at all.

The per-pixel logic follows the VHDL compositor point for point: per-layer transparency (an NR 0x14 compare on the colour's top 8 bits (RRRGGGBB, VHDL rgb(8 downto 1)), the layer's own enable, the clip result), the ULA/tilemap merge with its tm_pixel_below and stencil (NR 0x68 b0) variants, Layer 2 priority promotion, blend modes 6 and 7 with their NR 0x68 b6:5 source selection, the border exception, and the NR 0x4A fallback colour wherever every layer is transparent. Renderer::LayerMask — what --delayed-screenshot-layers drives — forces a layer transparent at that input; it is host-side debug state, which is why reset() and save_state() deliberately ignore it.

Per-scanline state: snapshots and change logs

This is where the deferred-rendering problem from the top of the page is paid for. Splitting the screen mid-frame is the standard trick of the machine: change the palette at row 96 for a sky gradient, change Layer 2's scroll each row for parallax, change the layer priority so a HUD sits above the playfield. All of those are register writes that land while the beam is somewhere specific, and a renderer that only sees the final register values would show one flat frame. Two mechanisms prevent that.

Snapshot arrays store one value per framebuffer row, sampled in Emulator::on_scanline. They cover the NR 0x4A fallback colour, ULA enable (NR 0x68 b7), stencil and blend mode (NR 0x68 b0 / b6:5), NR 0x14 transparent RGB, the NR 0x1A ULA clip window, the LoRes register set, the ULA border colour, and tilemap scroll plus fetch state (NR 0x6C/0x6E/0x6F). All but tilemap scroll snapshot the previous row, because by the time on_scanline(N) fires the Copper has finished line N-1; tilemap scroll latches at the start of the current row, as hardware does.

Change logs are the finer instrument: they record every write with a line tag — and, for the two Layer 2 logs, a column tag as well — and replay it during render. Each owner exposes the same five calls — start_frame, set_current_line, rewind_to_baseline, apply_changes_for_line and flush_remaining_changes — so a new one costs no new protocol. The logs are: palette contents (PaletteManager); Layer 2 scroll, clip, bank, enable and NR 0x70 (five separate logs); sprite attributes and sprite patterns; the ULA's port-0xFF screen mode, ULA scroll, and the NR 0x43 / NR 0x6B b4 active-palette selectors; the tilemap's NR 0x6B; the AttributeMux on the Mmu (every VRAM byte the ULA, the tilemap and LoRes fetch, replayed at the instant each fetches it, per column rather than per row); the SramBeamLog on the Mmu (the SRAM bytes Layer 2 fetches, same way); and NR 0x15 layer priority plus sprite enable on the Renderer itself.

The two memory logs are queried by the layer instead of replayed into it, and the fetch instant differs by layer (raw hc, c_min_hactive = the machine's display origin; every instant is derived from the VHDL in beam_counters.h and the headers of the two classes):

Layer Fetch instant of the pixel / group VHDL
ULA per display slot, attribute_mux.h table zxula.vhd:270-303
Tilemap group g (8 pixels, 4 whc ticks in 80-column mode) is fetched while whc = 8(g-1) (4(g-1)), one character ahead; map entry and tile bytes share the instant; a tile split by fine X scroll is read again in each group tilemap.vhd:226-231, 260-300
LoRes pixel p at phc = p, raw hc c_min_hactive + 1 + p zxnext.vhd:4250, 6603-6631
Layer 2 256x192 column X at raw hc c_min_hactive + X layer2.vhd hc_eff, zxula_timing.vhd:510-522
Layer 2 320x256 / 640x256 column (byte) X at raw hc c_min_hactive - 32 + X layer2.vhd, zxula_timing.vhd:476-492

Most tilemap rows read nothing that was written during the frame, so the tilemap does not replay them. When neither mux holds a hot offset (a write the mux has not folded into its baseline), the row is drawn by a second instance of the same loop, Tilemap::render_tiles_<false>. That instance reads the mux baselines directly, which is what every replayed read of the row would return, and keeps one block per tile. A hot offset anywhere sends the row through render_tiles_<true>, the replay. Both instances come from one loop source, and tilemap_test TM-BR-11..21 and TM-BR-24 draw each mode both ways and require the same output. The replay, like LoRes' per-pixel read, goes through AttributeMux::read_prepared_hot(): a byte that is not hot is read inline and only a hot one walks its chain, out of line. The ULA keeps read_prepared(), whose walk is inlined into its column loop, since a multicolour frame reads hot attributes on every line.

Two details there are load-bearing. Writes tagged during vblank never match a visible row, so flush_remaining_changes must drain them or they are lost forever — the next frame's baseline would not contain them. And writes that arrive before the display starts are coalesced to row 0 rather than given a sentinel tag, because a sentinel at the head of the log stalls the cursor walk.

A few registers are still read live, at frame granularity: NR 0x4B/0x4C transparency indices, the sprite and tilemap clip windows, and NR 0x68 b3 (ULA+ enable — Ula has the per-line snapshot API, but nothing calls it). The coverage is demand-driven by design: a register gets a log when a real program turns out to need one.

Mid-line writes: three logs resolve per column, the rest per row

Snapshot arrays and most change logs are row-granular, so a register write that physically lands part-way through a scanline is applied from the beginning of that row. Hardware samples those latches per pixel and would change only the pixels after the write.

Three logs do better. AttributeMux was the first (Nirvana-class attribute rewrites; it covers the pixel bytes too, so a frame can no longer show new glyphs on old attributes; the tilemap and LoRes read through it, and SramBeamLog does the same for Layer 2's bitmap), and a later change added the Layer 2 bank (NR 0x12/0x13) and scroll (NR 0x16/0x17/0x71) logs. Those two carry an hpos beside the line tag, Layer2::apply_changes_for_line merges them into one per-row list of render segments ordered by column, and render_scanline draws the row as one span per segment. With no mid-line write there is exactly one span and the emitted pixels are identical to the single-span loop that preceded it.

The column comes from Emulator::nr_write_hpos(). A Copper MOVE reports its own raster position through Copper::active_move_hc() — it has to, because the Copper window for one CPU instruction is replayed after the clock has already advanced past it, so the live counter would be up to a whole instruction late. Everything else (a CPU OUT, a loader, a soft reset) takes current_hc(). Both are rebased onto the raw frame counter, because the line tag is in raw-frame space: Emulator::on_scanline is scheduled at raw hc 0, while an hc_ula line starts 125 pixels later. seg_first_col() in layer2.cpp then maps the column onto the first affected source column — hpos + 2 narrow, hpos + 34 wide — and carries the VHDL derivation of those two constants.

For every other log the row-granular approximation stands, and the bound once claimed for it does not: an effect can appear up to a row early, but a line carrying two writes to the same register loses the first one outright, which is a horizontal error with no vertical component at all. That is what was reported for the Layer 2 bank. Closing it for the remaining logs needs the same treatment or the cycle-accurate rendering refactor, which has been assessed and declined.