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 / cvc ULA pixel counters |
7 MHz, shifted origin | 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.
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 derives the
byte from the current raster position, and 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.
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 RGB compare, 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 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
(Nirvana-class mid-frame attribute rewrites, resolved per column rather than
per row); and NR 0x15 layer priority plus sprite enable on the Renderer
itself.
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.
Accepted limitation: mid-line writes apply from the start of the row¶
Both mechanisms 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. The residual error is therefore bounded to at most one row, and is
one-directional — an effect can appear early, never late. Closing it properly
would need either sub-row granularity or the cycle-accurate rendering refactor,
both of which have been assessed and declined; this is a known modelling
limitation rather than a bug. AttributeMux is the one place the finer
granularity was genuinely needed, and it resolves per column.