█▓▒░C64 READY.

Docs / VIC-II (src/vic2*.js): Architecture Overview

VIC-II (src/vic2*.js): Architecture Overview

How the MOS 6569 VIC-II emulation is structured: timing model, display/bad-line state machine, sprites, collisions, borders, interrupts, rendering pipeline, chip variants and performance gates. It describes the implementation and names the real methods and fields, so it doubles as a guide into the source.

The chip is one VIC2 class (~7k lines) split across five files; each sibling installs its method group onto VIC2.prototype (partial-class assembly note at the bottom of vic2.js), so every method below is reachable from the one class:

File Contents
vic2.js chip state + constructor, clock()/phi2(), register read/write, VIC bus fetches + banking, IRQ line, lightpen, frame trace, reset, save-state
vic2-tables.js colour palettes + PALETTE_RGBA, canvas/display geometry, PAL timing, sprite p/s-access cycle tables
vic2-line.js per-cycle state capture, cycle segment builders, display & text-window state, bad-line sequencing, DRAM refresh, border flip-flops
vic2-sprites.js sprite DMA + MC/MCBASE bookkeeping, p/s-access fetches, sprite BA/AEC, per-cycle sequencer events, sprite rendering, collision pipeline
vic2-render.js graphics rendering (text/bitmap/idle modes), XSCROLL/border edges, incremental render + deferred line replay, end-of-line fixups

Hardware behaviour follows Christian Bauer's "The MOS 6567/6569 video controller (VIC-II)" (cebix.net/VIC-Article.txt) and the community VIC-Addendum (VICE techdocs); "§3.7.2 rule 3"-style references here and in code comments are Bauer sections.

Scope: PAL 6569 only (63 cycles/line, 312 lines/frame); NTSC is deliberately out of scope. Two PAL revisions, 6569 and 8565, are selectable at runtime (§12).


1. Big picture

machine._runMasterCycle() drives the VIC one master cycle at a time; a PAL frame is 312 × 63 = 19656 cycles. The chip:

  1. Reads memory it does not own: it shares the bus with the 6510 and steals cycles (asserting BA/AEC) for bad-line character fetches and sprite data.
  2. Runs a display state machine (VC, VMLI, RC) deciding per cycle whether it is fetching the character matrix.
  3. Renders pixels into a 384 × 272 RGBA framebuffer (fb32): the full PAL visible area, 320×200 active display plus border.
  4. Raises interrupts (raster compare, two collision types, light-pen) on a shared IRQ line to the CPU.

The renderer is cycle-incremental: each cycle paints its own ~8-pixel slice as the beam passes, so a mid-line CPU read of the collision registers sees cycle-accurate state. That is what makes timing-critical demos (FLI, VSP, sprite multiplexers, stable rasters) reproduce.

                 ┌───────────────────────────────────────┐
  CPU $D000-$D3FF│ register file  regs[0x40]             │
  ───────────────▶ read()/write()  + per-cycle snapshots │
                 │ lineCycleRegs[cycle]                  │
                 └───────────┬───────────────────────────┘
                             │ clock(1) per master cycle
              ┌──────────────▼────────────────────┐
              │ display state machine             │  VC / VCBASE / VMLI / RC
              │ bad-line logic                    │  displayActive, displayEnabled
              │ sprite DMA + sequencer            │  spriteDmaOn / spriteDisplayOn
              │ border flip-flops (H + V)         │  hBorderActive / vBorderActive
              └──────────────┬────────────────────┘
                             │ per-cycle segment
              ┌──────────────▼────────────────────┐
              │ renderer                          │  graphics + sprites + border
              │ _renderCycleSegmentGraphics       │  → fb32 (RGBA framebuffer)
              │ _renderSpriteSegmentForSprite     │  → collision/owner buffers
              │ _fixupColumns / _recolorBorderRow │
              └───────────────────────────────────┘

2. Timing model & master-cycle ordering

Constants (top of file)

phi1 / phi2 within one cycle

Hardware runs the VIC during phi1 and the CPU during phi2. machine._runMasterCycle() orders strictly:

_masterPhase = 'vic'      → vic2.clock(1)     // VIC phi1 logic, reads pre-CPU regs
              'cia'        → cia1/cia2.clock(1)
              'cpu'        → cpu.clock()        // CPU phi2: register writes land here
              'vic-phi2'   → vic2.phi2()        // VIC reconciles same-cycle CPU writes
              'cia-post'   → datasette, end-of-cycle

So a CPU write on cycle N is not visible to VIC phi1 logic until cycle N+1, which is why much VIC logic is split between clock() (phi1) and phi2():

CPU-visible raster lag

raster increments at cycle 63→0 (phi1, inside clock()), but a CPU read of $D011/$D012 in the boundary cycle must still see the old raster. _lineJustEnded is a one-shot set at the wrap, consumed by _cpuVisibleRaster() / _cpuVisibleRasterAndCycleForWrite(), cleared by phi2() after the CPU step. The raster-compare edge detector uses the true this.raster (the comparator is wired to the internal counter), so the two paths deliberately disagree at the boundary.


3. Register file & CPU access

regs is the 0x40-byte bank; read(reg) / write(reg, val) are the CPU entry points and both update vicInternalBus (§13).

_readRegRaw() models the unconnected/partial registers:

Timing tricks in write():


4. Memory access & VIC bank

VIC sees a 16 KB window selected by CIA2 port A (currentVicBank). Helpers: _vicBusRead / _vicRead / _vicReadWithBank (char-ROM shadow at $1000-$1FFF and $9000-$9FFF of each bank), and _vicMemRead, a non-bus-driving peek the renderer uses to re-read g-bytes. Silicon latched them at g-access time; re-fetching at render time is an emulator convenience and must not disturb the open bus.

Ultimax: PLA table A.11 maps the upper 4 KB of active cartridge ROMH into the VIC's local $3000-$3FFF window regardless of the CIA bank, while local $1000 reads DRAM instead of character ROM. _vicMemRead applies this to live fetches and renderer peeks alike; freezers such as Action Replay and Final Cartridge III use the ROMH window for their freeze code/display.

noteBankChange(bank, delay) applies a bank change with a 1-cycle pipeline delay (visible to the next cycle's fetches). Two opt-in quirks, both default off:

Refresh (_advanceRefreshAccess, cycles 11-15): five DRAM-refresh accesses walk refreshCounter down from $FF and drive the bus (open-I/O torture tests).


5. Display state machine: VC / VCBASE / VMLI / RC

Bauer's four counters (§3.7.2):

Field Meaning
vcBase video counter base, reloaded from VC at cy 58 when RC=7; reset to 0 at top of frame
vc video counter (which matrix cell), loaded from vcBase at cy 14
vmli video-matrix line index (0..39), the c-access write column
rc row counter (0..7), pixel row within a character; reset to 0 at cy 14 on a bad line

Two states, idle vs display (displayActive):

displayEnabled is the latched DEN: DEN set during any cycle of raster $30 enables bad lines for the whole frame (latched in clock() at raster $30 and at the $30→$31 phi2 boundary). _isBadLine reads it, not the live bit.

The matrix line buffer (rowScreenCodes/rowColorNibbles/rowFetchedCols and their per-cycle snapshots) is retained across lines and frames; only c-accesses write it. A display row entered via a cy-58 idle→display transition with no bad line shows the previously fetched codes (testprogs sequencer-bug), so _beginRasterLine deliberately does not wipe it at the frame boundary.


6. Bad lines

A Bad Line Condition (_isBadLine):

displayEnabled  AND  raster in [$30, $F7]  AND  (raster & 7) == (D011 & 7)   // YSCROLL

On a bad line the VIC steals 40+ cycles to fetch the character matrix:

Tricks this reproduces, all under registered spec tests: mid-line bad-line cancel (FPP scrollers), late forced bad lines (FLI), line crunch / sprite-crunch interactions, VSP (dmadelay, vsp-tester), and the fldscroll "1 $ff char on the right" cy-54 edge case.


7. Sprites

Eight sprites, each with a full DMA + display lifecycle and an independent 24-pixel sequencer.

DMA lifecycle (§3.8.1)

Per-sprite state: spriteDmaOn, spriteDisplayOn, spriteMC, spriteMCBase, spriteYExpandFF, spriteLineDataRow, plus start/stop pending flags.

Cycle Method Action
15 _spriteSequencerCycle15 no-op; sprite-crunch detection lives in the $D017 write hook
16 _spriteSequencerCycle16 if Y-expand FF set: MCBASE := MC (rule 7), or the sprite-crunch interleave formula (rule 7a); then MCBASE==63 → DMA off
55 _spriteSequencerCycle55 DMA-start check (_tryStartSpriteDma): Y match → DMA on, MC=MCBASE=0, FF=1
56 _spriteSequencerCycle56 2nd DMA-start check; FF inversion if MxYE=1 (rule 3), or force FF=1 if MxYE=0 (rule 1)
58 _spriteSequencerCycle58 MC := MCBASE; display on iff DMA on + Y match + still enabled; DMA off → display off (_endSpriteDisplayLine)

Proven by: testprogs spriterestart (the nine.prg maskers use the trick) and spriteenable cores 1/2/4.

Sprite memory fetch

Pointer p-access + 3 data s-accesses per sprite, scheduled by the fixed SPRITE_PTR_ACCESS / SPRITE_ROW_ACCESS tables. Sprite BA/AEC come from _spriteBaLow / _spriteAecLowHistoric. AEC requires unified BA low now and in each of the preceding three cycles, wrapping across the line boundary via prevLineExternalBaLow. After memory fetches, clock() computes one BA sample for history capture and one canonical AEC sample for CPU arbitration. The earlier baLow sample remains separate because completing a matrix fetch can change the live bad-line contribution. isAecLowPhi2(true) returns the transient arbitration sample; the default form evaluates live state. Reset and state restore clear the transient sample, and the next clock() refreshes it before CPU arbitration. With DMA off, the three buffer bytes come from three distinct half-cycles (VIC-Addendum "sprite idle fetch"): byte 0 = p-cycle phi2 bus, byte 1 = $3FFF ghost access, byte 2 = s-cycle phi2 bus (_spritePCyclePhi2Bus, _spriteSCyclePhi1Ghost).

Sprite rendering

The pre-canvas X-match guard applies to the current fetched row. An early DMA-fetch bitmask records actual s-accesses for sprites 3-7, including reloads of identical bytes. If offscreen emission finishes before the first s-access (p-cycle phi2), the fresh row remains eligible for a later X match. Emission overlapping the fetch retains the existing guard. The bitmask is constant during visible rendering, so live rendering, deferred replay and sprite-state rollback share the same fetch history. Line start and reset clear it; save states preserve it.

_renderSpriteSegmentForSprite drives a per-sprite sequencer state (_createSpriteRenderState / _advanceSpriteSequencerState) that persists across cycle segments, so the incremental render resumes the shifter at the exact X the previous cycle left off. _spriteSequencerPixelInfo resolves a pixel: hires 1 bit/pixel, sprite color; multicolor ($D01C) 2 bits → transparent / $D025 / sprite color / $D026; X-expand ($D01D) doubles pixelsPerUnit. _drawSpritePixel enforces priority/inheritance via spriteOwnerBuffer (a pixel claimed by a lower-index sprite masks lower ones, even when the higher one was hidden behind foreground) and graphicsPriorityBuffer (the $D01B bit). spriteVisibleBuffer marks pixels that actually reached output, so the column fixup (§8) doesn't pull a background color through a visible sprite that happens to match the gfx RGBA.

Edge/variant passes: _paintSpriteBoundaryGarbage (the X=$163/$164 re-trigger garbage, matching the 6569 behavior), _renderSpriteSameLineHighX / _renderSpriteEndOfLineWrap (high-X / wrap).


8. The rendering pipeline

The renderer never renders "from registers now"; it renders from per-cycle register snapshots taken at phi1 with staggered sampling offsets.

graphicsPriorityBuffer and graphicsCollisionBuffer alias the same line-sized foreground buffer. Graphics rendering and fixups write through graphicsPriorityBuffer once per foreground update; sprite priority and collision detection read that shared result. The mode-split fixup saves and restores a single foreground copy alongside its pixel and border snapshots.

Per-cycle capture

Hardware history (BA samples, idle-bus accesses, border flags and counters) continues to advance every cycle. Renderer payloads use three independent histories: registers, matrix/color data and sprite fetch/display data. A changed payload is copied into its preallocated snapshot slot; unchanged cycles store a one-byte index of the existing snapshot. Tracing and diagnostic access retain dense per-cycle recording.

Sprite fields retain their typed views over one 96-byte buffer per snapshot. Compact recording copies that buffer once; dense recording retains individual field copies. Live state and each historical slot own separate buffers.

The cycle-indexed lineCycleRegs, row and sprite arrays remain diagnostic views. Inspecting a view materializes the compact history and selects dense recording for the rest of that line. Historical register patches use private buffers; patching a sample cannot change other cycles sharing its original payload. Tracing uses dense recording throughout.

On 6569, graphics span grouping tracks graphics-register versions independently of sprite and IRQ writes. The 8565 retains full-register equivalence for grouping because its delayed register pipeline needs the narrower reference spans.

Segments and the register-snapshot pipeline

_buildCycleRasterSegment(cycle) produces one cycle's 8-pixel render segment. Which snapshot each field samples matters, because VIC subsystems latch at different points in the pixel pipeline. The rule of thumb throughout: the later the snapshot offset, the later the pipeline stage it models, the closer to the beam:

Field on seg Snapshot Why
seg.regs cycle (+regOffset) base / border color / XSCROLL
seg.nextRegs +1 $D018 CB & bitmap base, sampled at the g-access cycle (§3.7.4)
seg.modeRegs +1 default, +2 via fixup ECM/BMM/MCM take effect one char earlier on screen than CB
seg.bgRegs live default, +3 via fixup $D021-$D024 are output-stage (beam-timed, no 12px gfx-data delay)

regOffset is 0 on 6569 and -1 on 8565 (its extra pipeline cycle). The incremental render can't read +2/+3 in time, so it renders with the +1 default and the end-of-line _fixupColumns pass re-renders only the columns whose mode (+2) or background-color (+3) window actually changed, merging the corrected pixels; the merge is gated on spriteVisibleBuffer so it never overwrites a visible sprite.

Graphics modes (_renderSourceColumn)

All eight ECM:BMM:MCM combinations: standard text, multicolor text, extended-background text, hires bitmap, multicolor bitmap, and the "invalid" ECM+BMM / ECM+MCM modes that render black. Idle-state graphics (_renderOpenBorderIdleSpan, _fillSegmentBg0) clock the $3FFF/$39FF idle byte through the same sequencer with matrix data forced to 0 (§3.7.3.9). lineCycleCWriteCol supplies the column shift for late idle→display transitions (FLI / line crunch): VMLI lags the beam by the idle gap, so freshly fetched columns are read shifted (colorfetchbug).

XSCROLL edge filler (§3.7.3 shifter model)

Bauer's sequencer is an 8-bit shift register "reloaded with new graphics data after each g-access", XSCROLL delaying the reload 0-7 pixels. Modelled explicitly:

The pixel-level mechanics (which segment fields carry the two g-bytes, the per-mode filler gates, bit-phase continuity) live in the source comments around _renderOpenBorderIdleSpan and _fillSegmentBg0 in vic2-render.js.

Border re-color

Borders are painted on the X-coordinate timeline at line end by _recolorBorderRow (incremental path), when the whole line's $D020 history is known, matching the border color's output-stage nature. Lines where $D020 never changes skip the pass (repainting every border pixel its existing color is a provable no-op); a per-line latch _d020WrittenThisLine, armed only by a value-changing write and sharing the grey-dot scratch's lifecycle, gates it.


9. Borders & flip-flops (§3.9)

The §3.14.1 hyperscreen veto: a right-edge SET pulse sits in a pre-FF latch for 1-2 cycles, and a $D016 CSEL write within that window that moves the compare retroactively cancels it. Modelled as a pending FF-transition queue (_pendingFFTransitions, evaluated at phi1 of the latch cycle by _evaluatePendingTransitions); on invalidation _vetoFFTransition rewinds FF state and re-renders the affected cycles (saving/restoring the sprite-line snapshot so a re-render doesn't double-count). Segments straddling a border edge are split by _splitRasterSegmentAtBorderEdges.

The PAL wide left comparator samples CSEL at cycle 17 phi1. A CSEL 1→0 write at cycle 15 or 16 phi2 therefore selects the narrow opening at canvas x39. The pending left transition updates the captured split; the live renderer repaints only x32..38 as border, preserving sprites from x39 and foreground collision data under the border. Deferred rendering uses the corrected capture on replay. Writes from cycle 17 onward cannot narrow an already open wide edge, and a left RESET cannot close a main border that was already open.

The queue is allocation-free by design: entries are pooled (rented from a free-list, reset to safe defaults, recycled on drain) and the queue is a stable-capacity array with a manual _ffCount whose length never oscillates, so the backing store is not reallocated every raster line on engines that right-trim arrays; see the performance doc.


10. Collisions (§3.8.2, §3.12)

Per-pixel detection during the sprite render (_processSpritePixelCollision):

Both feed a 2-cycle visibility pipeline (_collPipeE/_collPipeF, drained by _drainSpriteCollisionCommit once per cycle before the CPU step): the 6569 makes a collision CPU-readable ~2 cycles after the pixel. Each stage also tracks a phi2-half subset (_collLateE/_collLateF, the late 4px of the cycle), so a $D01E/$D01F read clears the elapsed stage and the phi1-half but retains the current stage's phi2-half, letting a back-to-back double read still catch the read-cycle's late pixels (spritevssprite). The register update (_applySpriteSpriteBits / _applySpriteBgBits) raises IMMC/IMBC only on the 0 → non-zero transition (§3.12). The pipeline persists across raster lines (registers are sticky until read), so _initRenderRasterLine must not clear it.

Final-row gap (spritegap3): a sprite whose X is reached after the cycle-58 display-FF drop shows nothing on its last line, so two such sprites do not collide there. _offCanvasSpriteSpriteCollision erases the latched row and restarts collision at a sprite-slot-dependent X in the right border.

Known-open sprite deviations

All reviewed, demo-neutral, left unfixed. Pixel counts are the real diff against each testprog's 6569 reference (one-row crop offset excluded).

Testprog Status
spritex testsuite 2/28 fail (7, 14): mid-line $D000 sprite-X comparator latch
spritegap gap2 only PAL reference is 8565R2, whose double gap ($170 on, $173+(m−1)·$10 off, $17f+(m−1)·$10 on) is unmodelled; gap3 passes
split-tests/spritescan 307 byte diffs, all at raw X ≥ $14c: sprite-sprite collision in the border/wrap zone
spritesplit 16/17 diverge, 264–2200 px: mid-sprite sub-cycle register split; ss-xpos is exact
spritebug 104/105/106 4 / 4 / 12 px: mid-sprite $D01D X-expand sub-cycle
sb_sprite_fetch 163/164 163 exact; 164 leaves 12 px on one row: high-X display-end turn-off
spritefetchbug 270 px: high-X X-expanded multicolor fetch tail

One deliberate deviation: a DMA-start clears the sprite shift register, as a bleed-avoidance shortcut. Bauer §3.8.1 says it should survive; it does for the same-line X≥$164 case sb_sprite_fetch exercises (test/vic2/vic2-sprite-sb-fetch-spec-test.js), not for ordinary sprites.


11. Interrupts & light-pen

irqStatus ($D019) / irqMask ($D01A), four sources:

irqHandler(asserted) is the line to the CPU; irqStatus bit 7 is the "any enabled source active" flag; clearRasterIrq / the $D019 write path acknowledge.

Light-pen (§3.11): negative-edge triggered on the LP pin (setLightpenLevel, wired to CIA1 port-B bit 4 in machine.js); one trigger per frame (_lpLatchedThisFrame, re-armed at frame start); $D013/$D014 latch LPX/LPY. An LP input held low across the frame boundary re-triggers at L0 c1. The light-pen testprogs pass; only an R1 silicon quirk at the exact frame boundary is unmodelled.


12. Chip variants

Selectable at runtime (the UI button cycles them, no machine reset):

vicVariant Model Distinguishing behaviour
6569 original PAL NMOS (breadbin) baseline; regOffset = 0
8565 late PAL HMOS (C64C/C128) 1-cycle register-pipeline delay (regOffset = -1); grey-dot artifact on same-value $D02x writes; VSP glitch address $3807

The setter caches _is8565 / _regOffset / _vspIdleGlitchAddr as primitives so the hot path tests a boolean instead of comparing strings. regOffset shifts every segment-builder snapshot read by one cycle: that is the entire 8565 pipeline-delay model.


13. Open bus & the internal data bus

vicInternalBus is the VIC's data-bus latch: reset to $FF at the start of each master cycle, driven by VIC RAM/char-ROM fetches and CPU $D000-$D3FF accesses. It sources the sprite idle fetch leak and open-I/O behaviour. The idle-fetch sample point is in phi2() (after the CPU step), so a same-cycle STA $D0xx leaks before the next cycle's reset. CPU accesses outside the VIC register range do not drive this internal latch.


14. Renderer optimizations

The renderer permanently enables selective fixups, versioned capture and sprite idle skipping. Test-only reference implementations provide equivalence coverage without shipping alternate implementations.

Sprite interval scheduling

During deferred replay, an unchanged sprite waits until the segment containing its next horizontal output position, or cycle 58 when its shifter is exhausted. Register or sprite-payload changes wake it immediately. Pending wrap, invalid rows and uninitialized shifters retain per-cycle processing. Cycle 58 always executes the wrap and off-canvas passes. Collision drains still run every virtual cycle in their original order. Live rendering and tracing retain per-cycle dispatch for phi2 rollback.

Fetch-fed graphics

The renderer records 40 packed graphics/matrix/color samples per line. Live rendering, deferred replay and correction passes consume them through the same combined color/foreground decoder as the RAM-reading path; mode and background colors keep their output-stage timing. Captured bytes survive RAM/DMA, bank and register writes, including XSCROLL tails.

Fetch-fed lines skip RAM-write watches and fetch-configuration catch-ups, but retain collision/IRQ observer drains. Save states preserve captured samples; older states use the reference path until line wrap. The 8565 span guard covers its earlier register snapshots.

Cartridge and tracing lines use the reference renderer; hardware bus behavior is unchanged. Write-boundary tests use hardware fetch rules because the reference renderer can reread memory after the original fetch.

Automatic line batching

The per-cycle render pays a fixed dispatch/build/split tax on every cycle regardless of content (~28% of frame time, content-independent to within ~6% across raster_time_gp's scenes). Line-batch mode defers pixel emission: state capture, FF evaluation, the collision-pipe drain and every patch-up's state rewrite still run per cycle, but nothing paints; the line is replayed in one burst through the same incremental machinery (_catchUpDeferredLine):

Contract: byte-identical to the live path at every CPU-observable point (register read values, IRQ timing, line-end framebuffer rows). Mid-line framebuffer state is not part of the contract (no C64 program can read pixels back). Tests that inspect per-cycle rendering use the test-only forceLiveRendering() helper. vic2-line-batch-spec-test.js locksteps live vs deferred machines through collision reads at the detection cycle, same-cycle sprite-X writes, rasterbars, armed IRQs and mid-line serialize.

The lockstep tests compare live and deferred output. The default renderer also passes the full suite, screenshot comparisons and demo-status checks. Frequent CPU observers reduce batching gains; fetch-fed memory writes alone do not force early rendering.


15. Debug / trace facilities

All behind frameTraceEnabled (off by default, ~zero overhead when off; toggle via window.c64Trace):

The project's private VICE-compare tooling (headless trace scripts, not in the shipped tree) builds on these hooks.


The optional VIC_COLLISION_OVERLAY captures final graphics/sprite collision buffers at visible line end without enabling tracing. Presentation tints a separate RGBA buffer; hardware state, raw pixels and serialization stay unchanged. Buffers allocate only when enabled; reset/restore clear diagnostic snapshots.

16. Key invariants & gotchas (quick reference)