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.
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 atQt::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 requestupdate()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_msinsrc/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:
- 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.
- 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). - 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. - Schedule the ULA frame interrupt at
VideoTiming::frame_int_master_cycle_offset(), and the line interrupt viareschedule_line_interrupt(). - Begin an RZX playback or recording frame. The Copper gets no frame-start
call: its mode-11 restart is at
cvc0, insideCopper::execute(). - 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.
- Call
schedule_frame_events(), which queues oneSCANLINEevent per line, the frame'sCVC_RELOAD(wherecvctakes NR 0x64) and a singleVSYNC.
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.