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.