JNEXT
Skip to content

2.3 A frame, end to end

This page follows a single frame from the frontend's tick to the image the frontend gets back. It is worth reading in order, because almost every surprising thing in the video, debug and rewind code turns out to be a consequence of the sequence below rather than a decision made locally.

Frame execution

One call to Emulator::run_frame().

The tick

A frontend calls Emulator::run_frame() once per displayed frame. In QtApp that call comes from a QTimer paced by frame_period_ms() and the current speed multiplier; in SdlApp it comes from that frontend's own paced loop; in HeadlessApp it comes from a bare loop with no cap at all. Whichever it is, once the call returns the frontend reads the finished image with get_framebuffer() and presents it.

How the Qt frontend paces and presents

The Qt tick is frame_sequencer::Sequencer::tick() (src/platform/frame_sequencer.h), driven by one timer and four rules that only show their worth on a host at the edge of real time:

  • The timer is single-shot and is re-armed from now at the end of every tick (src/gui/frame_timer.h), whatever the interval. The deadline schedule (src/platform/frame_deadline.h) hands back "whole ms from now to the next deadline", which only means that if the timer counts from now. It used to be a periodic timer restarted only when the value changed; after a tick that outlasted an unchanged interval it fired at once on the old phase, and on Windows that timer event (posted at normal priority) was dispatched ahead of the widget's repaint request (posted at Qt::LowEventPriority), so the frame the previous tick produced was never shown. Linux hid it, because glib lets posted events run before a timer fires again. When the next deadline is already due (the schedule's 1 ms floor — a host that is behind, or fast-forward beyond what it can keep up with) the timer is armed for 0 ms, which Qt fires once the window-system queue is drained, rather than charging every tick a whole ms.
  • A frame not yet painted is painted at tick entry. EmulatorWidget::flush_pending_present() delivers the repaint request update() already posted, through the event loop's own paint path, before the tick overwrites the framebuffer. It closes the residual Windows case: a GUI thread descheduled past a 1 ms interval before its repaint ran.
  • A stall resync restarts the grid at now. When a tick finds itself more than two periods late, the backlog is dropped and the next tick runs at once; it used to wait a whole period first, which on a host that cannot keep up idled the machine every other tick while it was already behind.
  • The audio band's estimate stays within one device chunk of the reading (BandState::envelope_ms in src/platform/audio_pacing.h, set from the opened device's buffer; see Audio).

The status bar's emu and shown figures and the cadence: / ticks: lines of --log-level platform=debug are the instruments for all of this. The ticks: line ends with the wiring counters: timer re-armed= (must equal the window's tick count), rescued= (re-anchors of a tick that ended with the timer unarmed — must be 0) and pending-paint checked=/painted= (how often the tick-entry paint was asked, and how often it found a frame to paint — ~0 on Linux, the Windows residual elsewhere). The frame-loop-wiring-func regression row runs the real GUI binary and pins the first three.

An unchanged frame is not presented again. Emulator:: framebuffer_generation() advances at every write of the framebuffer (the end-of-frame render, both rewind re-renders, a cold boot); it is never reset. render_policy::upload_needed() decides, for EmulatorWidget::update_frame() (also in front of the CRT overlay's submit()) and for SdlApp::run(): upload when the generation differs from the last one uploaded, when the target changed (physical size, pixel ratio, fullscreen, source size; in SDL an exposed, resized or fullscreen-toggled window, via SdlDisplay::request_redraw()), or when nothing has been uploaded yet. A paused machine therefore does no copy, no prescale and no repaint, though the 50 Hz tick keeps running. The ticks: line ends with uploads= and panel-refreshes= (the latter is 0 without the debugger); the SDL loop logs sdl present: ticks=N uploads=K once a second. The paused-idle-qt-func and paused-idle-sdl-func rows hold a machine paused over GDB and assert both stay at 0. frame_sequencer_test's FS-EDGE rows run the whole loop on a fake host whose frame costs 15.6 ms and 23 ms — the two figures from a real Windows report — and pin real-time pacing at the first and back-to-back emulation with no skips and no waits at the second.

Frame start

run_frame() (src/core/emulator.cpp:9997) opens with two steps that have to come before anything else. service_esp_frame() applies the replay gate and mirrors the ESP worker thread's state into the flag the hot path reads, and the rewind step modes get their chance to return early — if the debugger has asked to move backwards, no forward emulation may happen at all.

The frame's end point is then computed as frame_end = frame_cycle_ + timing_.master_cycles_per_frame, and:

if (!frame_in_progress_) { begin_new_frame(); frame_in_progress_ = true; }

That guard is load-bearing, and the reason is the debugger. When the debugger pauses, it does so by returning from inside the loop below, part-way through a frame; the next call to run_frame() must therefore resume that frame rather than start a fresh one. Calling begin_new_frame() in the middle of a frame would re-baseline the per-scanline change logs the compositor replays. The visible symptom is a frame that renders flat.

begin_new_frame() (src/core/emulator.cpp:9061) does the following, in order:

  1. Take a rewind snapshot, if rewind is enabled. Frame boundaries are chosen for this precisely because the scheduler queue is empty at that instant, so it never has to be serialised.
  2. Commit the frame-edge NextREG latches — NR 0x03 machine timing, and NR 0x05 for 50/60 Hz and scandouble — re-deriving every timing surface if the effective mode changed, the pulse-mode /INT width included. This is the only place an NR 0x03 timing write takes effect: not the write itself, and not a soft reset, which hands the effective timing back unchanged (eff_nr_03_machine_timing, zxnext.vhd:6696-6703).
  3. Rebase the frame-relative FUSE T-state counter, from which contention derives its (hc, vc) position, onto the new frame — seeded with the last instruction's overshoot past the frame end, not zero, so it keeps agreeing with the clock — after folding the outgoing frame into the monotonic base that real-time tape playback runs off.
  4. Schedule the ULA frame interrupt at VideoTiming::frame_int_master_cycle_offset(), and the line interrupt via reschedule_line_interrupt().
  5. Begin an RZX playback or recording frame. The Copper gets no frame-start call: its mode-11 restart is at cvc 0, inside Copper::execute().
  6. Baseline about fifteen per-scanline change logs and snapshots: the palette, Layer 2 scroll and bank, sprite attributes, ULA scroll, palette-select, Timex mode and border, tilemap scroll and fetch state and NR 0x6B, the attribute mux, NR 0x15, NR 0x68, NR 0x14, NR 0x4A, NR 0x1A and LoRes.
  7. Call schedule_frame_events(), which queues one SCANLINE event per line, the frame's CVC_RELOAD (where cvc takes NR 0x64) and a single VSYNC.

The inner loop

while (clock_.get() < frame_end) { … }

Each iteration does four things.

Debugger checks come first. If the machine is already paused, a PC breakpoint has been hit, a step-into is pending, or a run-to-cycle target has been reached, the function returns immediately — leaving frame_in_progress_ true and frame_cycle_ untouched, which is what the resume guard above relies on.

Then the tape ROM traps, which only apply while ROM is paged into slot 0. Fast loading intercepts LD-BYTES at 0x0556 for both TAP and TZX, and --tape-save intercepts SA-BYTES at 0x04C2. The save trap additionally checks the ROM's identity, because other ROMs have perfectly ordinary code at that address. When a trap fires it charges a small cycle cost and continues. A .tzx or .wav save also samples the tape-out level (Emulator::tape_out_level()) at port 0xFE writes, while a tape plays, and at the end of the frame; see 3.8.

Then step_one_instruction() (src/core/emulator.cpp:10567), the one shared per-instruction body. It picks exactly one of four things to do:

  • If the DMA genuinely holds the bus, run a burst of up to 16 bytes and charge the cycles to the CPU.
  • If the CPU is parked — which happens for a load-only NEX whose header gives PC = 0 — advance by a NOP-sized step and execute nothing.
  • If a NEX boot hold is still counting down, likewise.
  • Otherwise, execute one Z80 instruction.

Around that choice it also records the trace entry, notes what the end-of-slot interrupt tick will need (the instruction's T-states, its first edge, its opcode fetches), counts RZX instructions, ticks real-time tape playback, and advances the master clock. All of that is shared verbatim with execute_single_instruction(), and the sharing was not free: the debugger's single-step once kept its own copy of this cluster, silently lost the IM2 tick and both /INT polls, and so delivered no interrupts at all while you were stepping.

Then a data-breakpoint check, which returns early if one fired.

Finally tick_devices_after_instruction(master_cycles) (src/core/emulator.cpp:11253), the shared post-instruction cluster. Its order matters: the Copper is stepped at 28 MHz granularity across the window the instruction consumed, stopping at every video-row boundary inside that window; the deferred CPU NextREG writes are drained, so that a same-register collision ends up holding the CPU's value exactly as the VHDL does; the NR 0x07 and NR 0x08 bit-6 commit edges are applied; CTC, UART and MD6 are ticked for whatever part of the instruction a port access inside it has not already brought them through; the I/O-mode injectors run; the NMI source pipeline and the /NMI falling edge are handled; PSG ticking and audio integration happen; run_scheduled_until(clock_) drains the scheduler and the separate queue that holds the ULA frame and line interrupt requests; and last of all finish_slot_interrupts() ticks the IM2 fabric for the instruction and hands the CPU the exact /INT window for the next boundary.

The scheduler drain is where the interrupt events actually fire, and it has a consequence worth internalising: the interrupt callbacks run at instruction boundaries, on the first instruction that carries the clock past their timestamp — not at the exact cycle they were scheduled for.

on_scanline is the exception, and it is one on purpose. It decides which framebuffer row a display register write belongs to — it snapshots the row that is ending and retags the per-scanline change logs — so running it late means attributing writes to the wrong row. The Copper writes at 28 MHz, so a burst released in horizontal blanking straddles a row boundary within a single Z80 instruction; stepping the whole window first and firing the boundary afterwards would put writes that are physically after the boundary into the row before it, and split a burst across two rows at a point that drifts with the CPU's phase from frame to frame. That is a flickering line. advance_copper_across_row_boundaries() therefore walks the video-row queue across the instruction's window and crosses each boundary at its own master cycle, carrying the part of the deferred CPU NextREG queue that commits before it across with it. The walk is unconditional — not gated on the Copper running — so a boundary always falls at the same point in the cluster.

For interrupts that is not the whole story. Every request — frame and line interrupt, CTC ZC/TO, UART — carries the CLK_28 edge it happened on, and because the IM2 tick runs after the devices and the event queues, the fabric resolves the instruction's own requests on that timeline and the CPU takes an interrupt at the first boundary whose last T-state samples it (see the CPU page). A port read or write whose value depends on these devices — CTC, UART, the interrupt-status NextREGs, the tape EAR bit — first brings them up to the edge its bus cycle latches or commits on (Emulator::sync_io_devices_to()), so it sees what the VHDL register holds at that edge; the rest of the instruction is ticked afterwards, so every edge is ticked exactly once.

Emulator::on_scanline(line) (src/core/emulator.cpp:12960) renders nothing at all. What it does is capture the previous row's per-line render state through snapshot_row_render_state() — fallback colour, ULA-enable, stencil and blend mode, transparent RGB, ULA clip, LoRes registers, border, the sprite and ULA controls, and the tilemap's scroll, fetch and output state — and re-tag every per-scanline change log with the new framebuffer row. The conversion from raw scanline to framebuffer row is a subtraction of video_timing_.vblank_top(), which differs per machine.

Frame end

Once the loop exits, end_of_frame() runs: the end-of-frame raster position is saved, frame_cycle_ advances to frame_end, frame_in_progress_ clears, and any NEX boot hold counts down. The last visible row, 255, gets its snapshots taken here only when its raw line is the frame's last (60 Hz), because then no on_scanline follows it. At 50 Hz on_scanline has already taken them, and taking them again here would paint row 255 with writes made in the raw lines below the display, which the hardware never shows.

end_of_frame() is a separate function rather than the tail of run_frame() for the same reason step_one_instruction() is: the debugger's Step needs it too. step_frame_slot() — one instruction slot with the begin-frame and end-frame seams around it — is what debugger_step() drives, because nothing calls run_frame() while the debugger holds the machine. See 3.9.4 Execution control and rewind.

Only then is the picture actually made, and it is made once:

renderer_.render_frame(framebuffer_.data(), mmu_, ram_, palette_,
                       layer2_, &sprites_, &tilemap_);

Rendering is skipped entirely in replay mode, and may also be skipped when the frontend has hinted that nobody is going to look at this frame — the Qt GUI at 400 % emulates far more frames than it presents, so set_render_enabled(false) lets it avoid compositing the ones it will drop. The hint is overridden while video recording or the debugger is active, and headless never sets it at all. A skipped frame still runs run_sprite_side_effects() and advance_flash(), because sprite collision and the line-budget overtime flag are readable by the emulated program through port 0x303B and so must be computed on every emulated frame regardless.

The frame closes with the video recorder capturing it, RZX recording ending it, and the phantom typist and the keyboard auto-type and scan state machines ticking once.

What the frontend gets

A complete 640 × 256 ARGB8888 image — nothing incremental, nothing delivered per scanline. That single fact is the entire reason the per-scanline change-log machinery described in 2.4 The video pipeline has to exist.