Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

The timing model

Copperline’s compatibility with cycle-sensitive software comes from one idea applied consistently: the chip bus is a single resource arbitrated per colour clock (CCK), and everyone pays for their slots. Reference numbers come from real hardware via the timing-test/ disk. The Copper and blitter timing models are documented in full below; the 68000 prefetch model is in CPU integration. Every rule here is backed by named regression tests in the inline suites (src/chipset/copper.rs, blitter.rs, src/bus.rs).

Chip-bus arbitration

Every colour clock of every scanline has an owner, decided in priority order (fixed_dma_owner_at, src/bus.rs):

  1. Memory refresh -- fixed odd slots 1/3/5 plus the line-end refresh slot (E2, or E3 on NTSC long lines). Refresh only ever uses odd slots, which is why it never collides with the Copper’s even-slot cadence.

  2. Disk DMA -- fixed slots 7/9/B, when DSKEN is set and a transfer is live.

  3. Audio DMA -- one fixed slot per Paula channel (D/F/11/13), claimed only when that channel actually has a fetch pending.

  4. Sprite DMA -- the fixed per-sprite slot pairs starting at 15/17.

  5. Bitplane DMA -- the fetch pattern determined by DDFSTRT/DDFSTOP and the plane count; at high plane counts this is what starves everyone else, exactly as on hardware.

  6. Copper -- even-CCK fetch cadence when not waiting.

  7. Blitter -- any remaining slots its schedule claims.

  8. CPU -- whatever is left.

For FMODE=0 fetches (OCS/ECS, and AGA with a 1-word fetch quantum) the bitplane decision (step 5) is driven by the Agnus DDF sequencer flop model (src/chipset/ddf_sequencer.rs, walked per line by src/bus/ddf_line.rs): DDFSTRT and DDFSTOP are comparator EDGES that set/clear flip-flops, not a value range. A stop request drains through one final fetch unit (which applies the modulos per plane), the hardwired window (18/18/D8, HARDDIS relaxes the stop to E0)gatesstartsandforcesstops,andtheflopstatecarriesacrosslineboundaries.Missedcomparatorsthereforeproducethehardwares"oldstop"behaviours:arewrittentoolateDDFSTOPletstheruncontinuetothehardwarestopdrain,aDDFSTRTmatchpastE0) gates starts and forces stops, and the flop state carries across line boundaries. Missed comparators therefore produce the hardware's "old stop" behaviours: a rewritten-too-late DDFSTOP lets the run continue to the hardware-stop drain, a DDFSTRT match past D8 starts a run that wraps through horizontal blanking into the next line, and an early-blanked DDFSTRT (10withSHWstilldown)neverstartsonOCSonafreshline.BecauseOCSonlyclearsSHWwhenafetchruncompletes,thelatchsurvivesarunlessline:thenextlinesbelow10 with SHW still down) never starts on OCS on a fresh line. Because OCS only clears SHW when a fetch run completes, the latch survives a run-less line: the next line's below-18 DDFSTRT match then does arm a run, anchored at its raw comparator position, so such lines fetch on alternating rasters with the picture sitting linearly left of the standard grid (its early words run through the left border). The renderer honours this through the captured run geometry: a below-$18 run origin keeps its raw fetch grid, and a line without a captured fetch paints nothing (vAmigaTS Agnus/DDF/DDF/oldhwstop3/4 A500 photos). The per-line fetch table is rebuilt when DDFSTRT/DDFSTOP/BPLCON0/DMACON/DIW writes land (DDF writes commit to the comparators four colour clocks after the write slot; an old DDFSTOP still fires on its commit clock, an old DDFSTRT does not - vAmiga’s sequencer semantics, hardware-verified in aggregate by the vAmigaTS Agnus/DDF/DDF/oldhwstop1-4 A500 photos). A mid-row BPLCON0 change switches the fetch-unit slot layout from its commit clock; word addressing is unit-based, so late-enabled planes keep their word positions and earlier words stay zero. Wide-FMODE (quantum > 1) fetches keep the memoized value-window plan: the effective DDF window, fetch cadence, and per-plane fetch-order mask live in a BitplaneSlotPlan keyed on the register inputs (BitplaneSlotKey, src/bus.rs). Wide-FMODE lo-res slots are packed into the first eight CCKs of each 16/32-CCK fetch unit; the rest of the unit remains available to later arbitration priorities.

Slow RAM at $C00000 is arbitrated through Agnus like chip RAM: a CPU access to slow RAM contends with DMA even though the RAM is outside the chip range. (Getting this wrong is observable: too-fast slow RAM can race a guest DMACON clear against the vertical-blank interrupt.) Fast RAM and ROM are external-bus accesses billed at the CPU clock without chip-bus arbitration -- so an accelerated CPU speeds up exactly what a real accelerator would.

Deferred timed-device ticks

The chipset and beam advance every colour clock, but the timed devices the CPU can only observe indirectly -- the CIAs, serial port, pots, audio, floppy, Akiko -- are not ticked per CPU bus access (that dominated the host profile). Instead their colour clocks accumulate in pending_device_tick and flush_timed_devices applies them in one batch at the next observation boundary: a CIA/custom/peripheral register read or write, or an instruction boundary for interrupt recognition. Read-only custom registers (INTREQR/DSKBYTR/SERDATR/POTxDAT) and writes that change device state (INTREQ/INTENA/ADKCON/DSKLEN/AUDxxx/SERDAT) flush first, so a device always reflects time right up to the moment it is read or written. The CIA E-clock divider and every device tick are exact under batching, so observable timing is unchanged -- the accumulator is a host-CPU optimisation only, and it is flushed to zero at every frame boundary (so it is never serialized into a save state).

CPU vs blitter

DMACON’s BLTPRI (“blitter nasty”) is modelled as on hardware: with BLTPRI set the blitter wins every slot it requests, and the request line stays asserted through the blit’s warm-up -- the startup ladder and, for D-writing blits, the first word’s cycles including the empty first-D bubble -- so the CPU is fenced out of those holes too (the Copper and fixed DMA still use them). Once the pipeline is primed, genuinely bus-free micro-cycles (line-mode Bresenham cycles, fill’s idle cycle, disabled-channel gaps) release the request and stay CPU-available even under BLTPRI, matching FS-UAE/vAmiga per-slot arbitration. Without BLTPRI, the blitter yields a slot after the CPU has missed three consecutive slots, matching the Minimig RTL. The counter and the back-pressure rule are detailed under CPU contention below.

Sprite DMA control rewrites

Sprite DMA fetches POS/CTL at the fixed pair slots, then data words for the active line. Standard hard vertical blank suppresses those fetches until PAL line 19orNTSCline19 or NTSC line 14, so frame-start SPRxPT writes made before that boundary still name a memory descriptor rather than retargeting a descriptor that could not yet have been fetched. Software can still rewrite SPRxPOS/SPRxCTL while a descriptor is pending or before a later pair slot to reposition an already active sprite on that scanline. Those writes update the live horizontal and vertical comparators, but they do not restart the sprite data stream: the data pointer stays with the descriptor that armed the sprite, and active row offsets remain relative to that descriptor. Copperline therefore keeps a runtime-only data-origin VSTART alongside the live comparator VSTART, preserving it across active POS/CTL rewrites while still using the rewritten HSTART for the line. SPRxPT rewrites on a later beam line while a descriptor is still pending retarget that descriptor’s data stream; same-line rewrites after the descriptor fetch restart from a memory descriptor on the next sprite slot. Directly armed register sprites use the same runtime origin marker when an after-slot SPRxPT write refreshes a data stream instead of a memory descriptor. The runtime origin is skipped in save states to preserve the fixed bincode layout; after load, retained Denise armed state and the next after-slot SPRxPT low-word write reconstruct this case for subsequent full frames. Future save-state versioning should serialize it if mid-line sprite-DMA resume accuracy is tightened. Tests: pending_sprite_control_rewrite_preserves_descriptor_data_origin, active_sprite_control_rewrite_preserves_descriptor_data_origin, pending_descriptor_sprite_pointer_write_retargets_data_stream, after_slot_armed_sprite_pointer_write_seeds_dma_data_stream.

Beam-timed SPRxPOS writes are replayed in Denise’s horizontal-comparator domain, seven colour clocks ahead of the normal register-output position. This matters for manual sprite reuse with sprite DMA disabled: Copper lists can write consecutive SPRxPOS values whose HSTARTs exactly abut. SPRxDATA and SPRxDATB writes update Denise’s data latches at their ordinary beam position, but the sprite serializer copies those latches only when the horizontal comparator fires. A same-line DATA/DATB write after that compare therefore waits for a later compare or scanline instead of replacing the word already shifting. Same-line POS/CTL writes also do not truncate a manual sprite word that has already started shifting; they re-arm a later compare while the active word completes. Tests: manual_sprite_position_write_before_hstart_uses_sprite_compare_domain, manual_sprite_position_writes_use_denise_compare_lag, manual_sprite_position_write_does_not_truncate_started_word, manual_sprite_data_write_after_compare_waits_for_next_scanline, sprite_register_data_write_after_compare_preserves_dma_latch_on_same_beam_line.

The Copper

The Copper (src/chipset/copper.rs) is a two-cycle processor that accesses chip RAM on every other colour clock. The HRM describes MOVE as a two-word, two-memory-cycle instruction; Copperline models the cadence and its edge cases in detail.

The fetch cadence and the MOVE write boundary

The two-CCK cadence is locked to the beam’s horizontal parity, not a free-running internal phase: the Copper fetches on even in-line colour clocks and idles on the odd ones (Copper::hpos_is_access_cycle). So:

Anchoring the cadence to the beam rather than to a carried-over flip-flop is what makes a back-to-back colour MOVE list land its writes at the same hpos on every line. With a free-running phase, fixed-DMA interference drifts the phase line-to-line and a Copper-driven horizontal gradient shimmers instead of showing clean vertical bands. The parity check is applied by Copper::step_eligible_slot, the single primitive shared by the live bus path and the blitter-deadline predictor’s cloned simulation, so prediction and execution cannot drift apart.

Register writes take effect a fixed number of colour clocks after the chip-bus slot that carried them, and the delay is a property of the register pipeline, not of the bus master. Denise-boundary registers apply to the pixel pipeline about four colour clocks after the slot (DENISE_WRITE_EFFECT_DELAY_CCK; vAmiga: a register-change delay plus pixel-domain application offsets). Agnus’s two-cycle register class (DMACON, BPLxPT, BPLxMOD, SPRxPT; vAmiga recordRegisterChange(DMA_CYCLES(2))) applies two colour clocks after the slot (AGNUS_WRITE_EFFECT_DELAY_CCK); the bitplane/sprite DMA-gating replay is calibrated against events recorded at that position. Render events are recorded at the effective position for every writer: a Copper MOVE executes at its bus slot, so its event records at the current beam position plus the delay; a CPU write is applied once its whole bus cycle has been billed (the beam sits past the granted slot by then), so its event is referenced from the granted slot itself (Bus::cpu_custom_access_slot) plus the same delay (Bus::record_render_write). Copper-sourced events currently record the Denise delay for the Agnus two-cycle class as well -- the copper-driven DMA-gating replay was calibrated with that offset when the copper landings became bus-exact (a documented TODO). The Denise delay was verified two-sided with the COPPERLINE_DIAG_CPU_WRITES / VAMIGA_CPU_PROBE landing traces on the vAmigaTS DMACON sprena dense CPU COLOR00 stream: with landings matched line-for-line, the rendered rows sat exactly two colour clocks left of vAmiga until the CPU side carried the same slot-referenced delay; the Agnus delay is pinned by the DMACON bplon bitplane-gating bars, which sit 8 px right of vAmiga when those events carry the Denise delay instead.

For the low-res renderer, a same-line COLORxx event recorded at hpos starts affecting pixels at (hpos - $35) * 4 (COLOR_WRITE_HPOS_FB0 in src/video/bitplane.rs); beam-timed placement is anchored at COPPER_WAIT_HPOS_FB0 (28),andbitplanecontrolwritesaddthefetchtodisplaypipelineoffset(BITPLANECONTROLPIPELINEFB).ThismodelsCOLORxxonDenisesfinalpalette/outputphase,onelorespixelaheadofwritesthatfeeddelayedshifter/controlpaths.OCSDenise(8362)andECSDenise(8373)sharethistiming;theonlyOCS/ECScolourpathdifferenceistheOCS12bitvaluemask.(AGALisadelayscolourchangesbyonehirespixelrelativetoOCS/ECSperWinUAE;thatsubcolourclockoffsetisnotyetmodelled.)AGABPLCON4sspritepalettebasebyteusesLisasearlierspritecolourlookuppathat(hpos28), and bitplane-control writes add the fetch-to-display pipeline offset (`BITPLANE_CONTROL_PIPELINE_FB`). This models COLORxx on Denise's final palette/output phase, one lores pixel ahead of writes that feed delayed shifter/control paths. OCS Denise (8362) and ECS Denise (8373) share this timing; the only OCS/ECS colour-path difference is the OCS 12-bit value mask. (AGA Lisa delays colour changes by one hires pixel relative to OCS/ECS per WinUAE; that sub-colour-clock offset is not yet modelled.) AGA BPLCON4's sprite palette-base byte uses Lisa's earlier sprite colour-lookup path at `(hpos - 36) * 4 (SPRITE_PALETTE_CONTROL_HPOS_FB0), one lores pixel ahead of ordinary COLORxx replay. Tests: copper_move_writes_visible_registers_on_second_dma_slot, copper_move_spends_four_color_clocks_leaving_alternate_cycles_free, color_register_writes_use_final_output_position`.

WAIT/SKIP edge cases

The Copper compares its masked beam position against the Agnus beam counters; the BFD bit controls whether a satisfied position wait must also wait for the blitter to go idle.

COPJMP and frame reload

Copper writes to “dangerous” registers are gated by COPCON’s CDANG bit. References: HRM Coprocessor Hardware.

A 68000 byte write drives the byte onto both halves of the data bus, and the custom chips latch the full 16-bit word (they have no byte lanes), so move.b v,COLOR00+1 lands $vvvv in the register and a byte bit operation such as bset #1,COPCON sets CDANG. The vAmigaTS CIA/oldcnt cnt1/cnt3/cnt5 ramps (which paint move.b TALO,COLOR00+1 mid-count) photograph the mirrored high byte on real hardware. Test: custom_byte_write_drives_the_byte_onto_both_bus_halves.

The blitter

The blitter (src/chipset/blitter.rs) is not a do-it-all-at-once engine with a delay: it is scheduled per DMA slot. Each word issues its hardware channel bus sequence, so DMACONR’s BBUSY reflects genuine in-flight state and BBUSY-polling loops see hardware timing. Because the renderer and the CPU both need to know when a blit finishes, cck_until_blitter_completes (src/bus.rs) predicts completion by walking the same slot-eligibility primitive the live engine uses.

Per-slot FSM

Scheduled blits use explicit phases matching the hardware controller, cross-checked cycle-for-cycle against vAmiga’s slot-accurate blitter with a two-sided slot-trace probe (COPPERLINE_DIAG_BLT_SLOTS in Copperline, VAMIGA_BLT_PROBE hooks in a local vAmiga build):

Normal-mode A/B barrel-shifter carry is cleared at the first word of a new BLTSIZE, then carries from the last source word of one row into the first source word of the next inside that blit; masks, modulos, and fill carry still observe row boundaries. Line blits follow vAmiga’s four line micro-programs, indexed by the USEB/USEC pair: with USEB clear each pixel is four cycles (L1-L4: L2 fetches C when USEC is set, L3 propagates, L4 stores), and with USEB set each pixel is six (an LB cycle fetches B -- adding only BLTBMOD to BLTBPT -- and a bare LBus cycle allocates the bus without a transfer). With USEC set the line D cycle allocates the bus even when SING suppresses the store (unlike copy mode, where a locked D slot is bus-idle); with USEC clear no line cycle touches the bus, and the terminal BLTDONE cycle of a USEB program is itself a bus-idle cycle. A SING-suppressed dot only locks the store: the A shifter, minterm and BZERO update still run on the full inputs. The Bresenham error accumulator (BLTAPT’s low word) advances only while USEA is enabled, the line texture register is BLTBDAT rotated by the LIVE BSH each pixel (no write-time latch, unlike the USEB-off copy hold word), and C DMA fetches load the BLTCDAT register itself, so a later USEC-off blit consumes the last fetched C word. At completion the hardware-visible ASH, BSH, SIGN, and low-word BLTAPT accumulator state is written back. Verified cycle-for-cycle against the vAmiga line traces (bususage1l/5l/15l): both emulators run the pixel micro-cycles, bus grants and stalls on the same colour clocks relative to the BLTSIZE poke. Tests: scheduled_normal_mode_bbusy_start_delay_precedes_first_source_slot, blit_pipeline_identifies_idle_cycles_per_hrm_diagrams, scheduled_normal_clear_writes_progressively, scheduled_line_mode_latches_c_source_before_store_phase, scheduled_shift_carry_crosses_normal_mode_row_boundary, scheduled_a_shift_zero_fills_first_word_of_new_blit.

Mid-operation register writes

The HRM documents the blitter as an asynchronous DMA engine, says BLTSIZE starts a blit and must be written last, and tells software to check BLTDONE before touching blitter registers. Copperline’s deterministic classification of writes while BBUSY is set:

Register groupClassificationNotes
BLTCON0Immediate D disableClears the current blit’s remaining D output path; the register value still updates immediately.
BLTCON1Immediate / snapshot-protectedThe public register updates immediately, but the in-flight blit keeps its normal/line snapshot so a transient line-mode bit does not reinterpret the active pipeline.
BLTCON0L (ECS)DeferredThe minterm-only write drains the current blit before latching.
BLTAFWM, BLTALWMDeferredFirst/last-word masks captured by the scheduled state at BLTSIZE.
BLT[ABCD]PTH/PTLDeferredPointer writes do not retarget the already-scheduled transfer.
BLT[ABCD]MODDeferredModulos captured at BLTSIZE.
BLTBDATImmediate, write-time shifterThe write runs the B barrel shifter with the BSH/DESC current at write time (unlike BLTADAT/BLTCDAT); USEB-off blits consume that latched hold word. The first B write after completion zeros the B old register; later writes shift through the latched B data.
BLTSIZEDeferred restartA second start strobe drains the current blit first, then starts the replacement from the post-completion pointer state.
DMACON.DMAEN/BLTENImmediateGate blitter bus grants immediately; clearing leaves BBUSY set and preserves the pending blit until re-enabled.
DMACON.BLTPRIImmediateBus arbitration observes the priority bit directly.

No covered mid-operation write is modelled as ignored; less common writes are treated as deferred by draining first. Tests: busy_bltcon0_write_disables_remaining_d_output_without_draining_blit, busy_bltsize_write_finishes_current_blit_then_starts_replacement.

Micro-cycle stall classes

Non-bus blitter cycles come in two hardware classes, mirrored from vAmiga’s micro-instructions and encoded as BlitSlotClass:

Verified cycle-for-cycle against the vAmiga slot traces: a Copper-poked blit on a 6-plane display line stalls its BLT_STRT cycles through the display fetches and lands its first A fetch on the same colour clock in both emulators.

CPU contention

With BLTPRI clear, Copperline models the Agnus blitter-slowdown counter (the Minimig RTL bls_cnt). A busy “nice” blitter still holds the chip bus on its access cycles -- the CPU gets no regular alternate slot. Each colour clock the waiting CPU misses still increments the diagnostic miss count, but the BLS pressure counter only advances when the pending blitter micro-cycle is a real pressure source: a bus access, fill’s explicit idle cycle, or line-mode BUSIDLE. Ordinary normal-mode “-” cycles from disabled channels or the empty D pipeline bubble stall through fixed DMA but do not make the blitter yield earlier. After BLITTER_SLOWDOWN_CPU_MISS_LIMIT (3) consecutive pressure misses the blitter yields one slot, matching the HRM “one bus cycle in four” rule while also matching UAE’s blitter_nasty distinction between normal idle cycles and fill/line BUSIDLE. Idle blit pipeline cycles never claim the bus and stay CPU-available -- with one exception: with BLTPRI set, the blit’s warm-up holes (the startup ladder and, for D-writing blits, the first word’s cycles including the empty first-D bubble) fence the CPU, because the sequencer’s bus request is held asserted by its queued back-to-back first fetches. Software depends on the warm-up fence: MFM-decode trackloaders (e.g. Jim Power’s) save the word below a decode blit’s destination, write BLTSIZE, and restore it two instructions later; granting the CPU the startup and first-D holes let the restore (and its prefetches) land mid-blit and the blitter overwrite the restored word, corrupting the last data word of every decoded sector. Once the pipeline is primed, bus-free micro-cycles are CPU-available even under BLTPRI -- line-heavy demo main loops (Rampage’s vector parts) depend on that CPU time, and a whole-blit fence overshoots the FS-UAE/vAmiga references on timing-test row 26 by ~+70 cck. The counter resets when the CPU gets the bus, when BLTPRI is set, or when blitter DMA cannot run.

A 68000 TAS read-modify-write is unsafe on chip RAM (the HRM warns against it); the m68k backend exposes it as a byte read then a byte write, so Copperline models TAS against chip RAM as two separately Agnus-arbitrated accesses, each subject to the same back-pressure rule. Tests: blithog_clear_busy_blitter_yields_to_cpu_only_after_starvation, bltpri_stalls_cpu_chip_access_through_blitter_access_cycles.

Area fill

Area fill is applied only when BLTCON1.DESC is set (the HRM requires descending mode because the fill carry propagates in descending bit order across each row); IFE/EFE in ascending mode is treated as ordinary minterm output. With USEC clear, fill adds one extra idle cycle (no bus access) per word, placed AFTER the D slot -- the trailing “-” in vAmiga’s fill micro-programs for USE masks 1/5/9/D (“A0 -- -- A1 D0 -- A2 D1 --”). So an A->D area fill costs 3 CCK/word vs 2 for an A->D copy. The fill carry datapath (apply_fill in finish_source_word) is not what adds the cycle -- the FillIdle slot in the controller sequence is. Cross-emulator validated: FS-UAE and vAmiga both time the bltcon0=0x09F0 A->D fill at 3 CCK/word (timing-test rows 23/24/26). USEC-carrying fills reuse their real C cycle and D-less fills match their copy timing (vAmiga’s fill programs change only masks 1/5/9/D). The idle fill phase advances on CPU/Copper/idle arbitration slots but not through fixed DMA (bitplane/sprite/disk/audio/refresh) slots. (An earlier experiment dropped the extra cycle to improve one capture, but that made the blitter faster than hardware and broke a separate blitter-heavy regression.) Test: descending_area_fill_costs_one_extra_idle_cycle_per_word.

ECS registers

BLTCON0L, BLTSIZV, and BLTSIZH are active only on an ECS Agnus. BLTSIZV latches the 15-bit vertical size but does not start the blit; BLTSIZH latches the 11-bit horizontal size and is the ECS start strobe; zero decodes to the documented maxima (32768 lines, 2048 words). BLTCON1.DOFF suppresses destination writes while still running the D channel timing, advancing the D pointer, and updating BZERO from the generated D data. Tests: ecs_bltsizv_bltsizh_start_extended_blit, ecs_bltcon1_doff_suppresses_destination_writes_but_advances_pointer.

Known residuals

Cross-emulator timing-test comparisons (corroborated in timing-test/README.md) now put the D-only clear and the active-display fill/copper phase within a few colour clocks of the FS-UAE/real reference after the BLS pressure-counter distinction above:

The previous row-23 D-only clear residual was caused by feeding BLS pressure from normal-mode disabled-channel idle phases blocked by fixed DMA. Those phases still stall as micro-cycles, but they no longer make the nice blitter yield earlier to the CPU.

References: HRM Blitter Hardware.

Interrupt-recognition latency

A 68000 does not enter an exception the moment INTREQ rises. Two hardware mechanisms sit between the two events, and Copperline models both:

  1. The Paula IPL pipe. A change to the enabled-pending interrupt set reaches the CPU’s IPL pins only after a few chip clocks of pipelining inside Paula (DEFAULT_IRQ_LATENCY_CCK = 5, src/bus.rs; COPPERLINE_IRQ_LATENCY_CCK overrides, 0 disables both mechanisms). The delay is attached to asynchronous Paula/CIA/blitter/Copper source assertions. A CPU write that merely changes INTENA/INTREQ masking or acknowledges a latch normally only updates the delayed-bit bookkeeping. PORTS is level-fed by CIA-A/Gayle-style INT2 sources and remains immediately visible when software unmasks an already-latched level; other newly exposed latched sources are treated as freshly-present CPU IPL inputs and still pass through the pipe. INTREQR reads are never delayed: the pipe sits between the level encoder and the pins, not on the register.

  2. Boundary sampling. The CPU latches its IPL pins during bus cycles, and the take-interrupt decision at an instruction boundary uses the level latched at the previous instruction’s last bus access (CpuBus::sample_ipl, src/cpu.rs). A level that rises during an instruction’s trailing internal cycles is therefore recognised one instruction later, exactly as on silicon.

Together these reproduce the raise-to-handler-entry positions measured against vAmiga (and the vAmigaTS real-A500 photos) across VERTB and copper-poked INTREQ sources under a range of foreground loops; the residual is 0..+7 CCK of per-instruction IPL poll-point detail the vendored core does not model. An earlier revision used a blanket 65 CCK “recognition latency” calibrated against timing-test row 19 with a mis-decoded VHPOSR (the low byte is the CCK position, not CCK/2); that delivered every interrupt ~50 CCK late and dominated the vAmigaTS cputim/irqtim divergence.

Beam-register readback

Live VHPOSR reads expose a pipelined beam position, not the exact internal counter. Copperline reports the horizontal byte three colour clocks ahead of the internal Agnus counter at the CPU-visible register-read point, while the first two reported positions of a new line still carry the previous vertical line number. vAmiga models the same hardware quirk as a five-cycle lead at its peek point; Copperline’s smaller residual lead accounts for the chip-bus grant already advancing the beam before read_vhposr samples it. The low byte is raw colour clocks, not half clocks. This is visible in timing-test rows 19/20/22/27 and in line-wrap polling loops that read VHPOSR immediately after INTREQR bits become visible.

Real-time pacing

Pacing never changes emulated behaviour -- it only decides how much emulated time to advance per host frame and when to sleep. Real mode debits a per-frame instruction budget one of two ways, selected by [emulation] pacing_budget:

COPPERLINE_REAL_PACING_BUDGET=cycles|instructions overrides the config for one run; the config overrides the built-in cycles default. COPPERLINE_REAL_PACING_PROFILE=1 emits a one-second pacing log (see Peripherals and expansion). Accelerated CPUs scale the budget by cpu_clocks_per_cck; fast RAM/ROM access costs are scaled with sub-CCK carry accumulation so fractional costs are not lost.

cycles became the default once the core’s 68000 cycle counts were made accurate: under flat instruction pacing, or with the old static cycle counts, a blitter-bound chip-RAM scene had the CPU over-issuing chip-bus accesses and starving the line blitter. With accurate per-instruction costs the CPU’s chip-bus slot ratio settles at a physically valid value naturally, which (together with the area-fill C-slot fix) resolved that blanking regression.

Thread scheduling priority

Pacing only works if the host actually runs the right thread at the right moment. Two threads are latency-critical: the pacer (the main thread, which advances the core and calls thread::sleep in Emulator::sleep_until_realtime_device_time) and the cpal audio callback (which drains the sample ring buffer the pacer keeps ~150 ms ahead of the device clock). During live-audio startup and rebuffering, the pacer treats the unfilled prebuffer as additional required lead; the large-stall self-heal allows for that lead so it does not cancel the refill as if it were a host pause. The copperline-render worker (Architecture overview) is a throughput thread, not a latency one, and is left at normal priority. When the host is busy, a scheduler that preempts the pacer shows up as frame stutter, and one that preempts the audio callback shows up as an audible underrun.

[emulation] realtime_priority (off by default; COPPERLINE_REALTIME_PRIORITY overrides it for one run) asks the OS to schedule those two threads above normal. It is best effort -- src/priority.rs logs what it did and never fails the run -- and, like all pacing, it never changes emulated behaviour; it only changes when host work is scheduled. The implementation is per-platform because “real-time priority” is portable in neither API nor semantics:

The pacer sleeps between work chunks rather than spinning, so even the strongest scheduling class it can land in still yields the CPU and cannot starve the host -- which is why elevating it is safe to offer.

Cross-checking against hardware

timing-test/ is a bootable disk that measures CPU and chip-bus operation timings against the CIA E-clock and reports them on screen and over serial. The same numbers can be collected from Copperline, vAmiga, FS-UAE, and real Amigas; several timing fixes (IRQ latency, the area-fill C slot) were validated this way. When changing the timing model, update the corresponding reference doc and add a named regression test for the hardware behaviour.