C64 READY.

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

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

A high-level map of how the MOS 6569 VIC-II emulation is structured: the timing model, the display/bad-line state machine, sprites, collisions, borders, interrupts, the rendering pipeline, chip variants, and the performance gates.

This document describes the implementation — it points at the real method and field names so it can be used as a guide into the 7k-line vic2.js. The hardware behaviour itself is governed by Christian Bauer's "The MOS 6567/6569 video controller (VIC-II)" (cebix.net/VIC-Article.txt) and the community VIC-Addendum (VICE techdocs); section numbers like "§3.7.2 rule 3" in the code comments and below refer to the Bauer document.

Scope: PAL 6569 only (63 cycles/line, 312 lines/frame). NTSC variants are deliberately out of scope. Two PAL chip revisions are modelled — 6569 and 8565 — selectable at runtime (see Chip variants).


1. Big picture

The VIC-II is driven one master cycle at a time by machine._runMasterCycle(). Each PAL frame is 312 × 63 = 19656 cycles. The chip:

  1. Reads memory it does not "own" RAM; it shares the bus with the 6510 and steals cycles (asserting BA/AEC) for bad-line character fetches and sprite data fetches.
  2. Runs a display state machine advancing the video counters (VC, VMLI, RC) and deciding, per cycle, whether it is fetching the character matrix.
  3. Renders pixels into a 384 × 272 RGBA framebuffer (fb32), a full PAL visible area: 320×200 active display + surrounding border.
  4. Raises interrupts (raster compare, two collision types, light-pen) on a shared IRQ line back 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. This is the default and is what makes timing-critical demos (FLI, VSP, sprite multiplexers, raster-IRQ 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

Real hardware runs the VIC during phi1 and the CPU during phi2 of each cycle. machine._runMasterCycle() models this with strict ordering:

_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

The key consequence: a CPU write on cycle N is not visible to VIC phi1 logic until cycle N+1. This is why so much VIC logic is split across clock() (phi1) and phi2() (after the CPU). Examples:

CPU-visible raster lag

The internal raster counter increments at cycle 63→0 (phi1, inside clock()). A CPU read of $D011/$D012 in the boundary master cycle must still see the old raster. _lineJustEnded is a one-shot set at the line wrap and consumed by _cpuVisibleRaster() / _cpuVisibleRasterAndCycleForWrite(); phi2() clears it after the CPU step. The raster-compare edge detector, however, 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 register bank. read(reg) / write(reg, val) are the CPU entry points; both update vicInternalBus (the open-bus latch — see §13).

_readRegRaw() models the unconnected/partial registers:

write() is where many timing tricks live:


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 (real silicon already latched them at g-access time; re-fetching at render time is an emulator convenience that must not disturb the open bus).

Ultimax is the exception to the normal RAM/character-ROM view. 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-selected bank, while local $1000 continues to read DRAM instead of character ROM. _vicMemRead applies this to both live fetches and renderer peeks; freezer cartridges such as Action Replay and Final Cartridge III use the ROMH window for their freeze code/display.

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

Refresh (_advanceRefreshAccess, cycles 11-15): five DRAM-refresh accesses walk refreshCounter down from $FF; when vicRefreshDrivesBus is set they actually drive the bus (matters for open-I/O torture tests).


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

The four canonical Bauer counters (§3.7.2):

Field Meaning
vcBase video counter base, reloaded to 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), the pixel row within a character; reset to 0 at cy 14 on a bad line

Two top-level 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). It is what _isBadLine reads, not the live DEN bit.

The matrix line buffer (rowScreenCodes/rowColorNibbles/rowFetchedCols, and their per-cycle snapshots) is retained across lines and frames — only c-accesses (bad lines) write it. This is load-bearing: 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

The defining VIC-II mechanic. A Bad Line Condition (_isBadLine) is:

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 that this machinery reproduces (all covered by 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. The sequence around the bottom of each line:

Cycle Method Action
15 _spriteSequencerCycle15 no-op — sprite-crunch detection lives in the $D017 write hook instead
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)

Notes:

Sprite memory fetch

Pointer p-access + 3 data s-accesses per sprite, scheduled by the fixed SPRITE_PTR_ACCESS / SPRITE_ROW_ACCESS cycle tables. BA/AEC for the sprite cycle-steal are computed by _spriteBaLow / _spriteAecLow (with a c-3 historical lookback that wraps across the line boundary via prevLineExternalBaLow). When DMA is 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, spriteIdleFetchLeakEnabled).

Sprite rendering

_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 where the previous cycle left off. _spriteSequencerPixelInfo resolves a pixel:

Edge/variant passes: _paintSpriteBoundaryGarbage (the X=$163/$164 re-trigger garbage, spriteBoundaryGarbage, default on for VICE-6569 parity), _renderSpriteSameLineHighX / _renderSpriteEndOfLineWrap (high-X / wrap cases).


8. The rendering pipeline

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

Per-cycle capture

_captureCycleState(cycle) snapshots, per cycle of the line, the register file (lineCycleRegs[cycle]), the display/border flags, the counters, the c-access write column, and the sprite state. These arrays are read by the segment builders. (Capture is deduped — see §14.)

Segments and the register-snapshot pipeline

_buildCycleRasterSegment(cycle) produces a render segment for one cycle's 8-pixel slice. The crucial part is which snapshot each thing samples, because different VIC subsystems latch at different points in the pixel pipeline:

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 mode bits 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 (the 8565's 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 lookahead (+3) window actually changed, merging the corrected pixels. The merge is gated on spriteVisibleBuffer so it never overwrites a visible sprite.

Graphics modes (_renderSourceColumn)

Handles 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 the 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 the freshly fetched columns are read shifted (the colorfetchbug fix).

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", with XSCROLL delaying the reload 0-7 pixels. Two consequences the renderer models explicitly:

Border re-color

Borders are painted on the X-coordinate timeline at line end by _recolorBorderRow (the incremental path) so the whole line's $D020 history is known — matching the output-stage nature of the border color. The pass is skipped on lines where $D020 never changes: the border color is then uniform, so the scan would repaint every border pixel its existing color (a provable no-op). A per-line latch (_d020WrittenThisLine, armed only by a value-changing $D020 write, same lifecycle as the grey-dot scratch) gates it.


9. Borders & flip-flops (§3.9)

Two independent flip-flops:

The §3.14.1 hyperscreen veto: a right-edge SET pulse is held in a pre-FF latch for 1-2 cycles. A $D016 CSEL write within that window that moves the compare can retroactively cancel the pulse. This is 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.

Queue entries are pooled, and the queue itself is a stable-capacity array with a manual _ffCount (not a growing/shrinking JS array). The border pushes ~2 transitions per raster line (right SET + left RESET, ~500/frame) that latch and drain within ~3 cycles, so entries are rented from a free-list (_rentFFEntry, which resets them to safe defaults so no field leaks across the hRightSet/hLeftReset kinds) and recycled on drain; _evaluatePendingTransitions compacts in place over [0, _ffCount) rather than rebuilding a keep-list. Crucially it does not do q.length = w: oscillating the length to 0 each line and re-growing on the next push made V8 right-trim + reallocate the backing store every raster line — the dominant idle allocation. Instead a drained slot keeps its (recycled) entry with kind blanked, so scans and serialize skip it (the border FF is never >1-deep, so a kept entry is never slid over a live slot). That removes a per-line object/array-churn source invisible on V8 (escape analysis) but real on JavaScriptCore/mobile — 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 is emitted. Each pipe stage also tracks a phi2-half subset (_collLateE/_collLateF, pixels in 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 itself (_applySpriteSpriteBits / _applySpriteBgBits) raises the IMMC/IMBC IRQ only on the 0 → non-zero transition (§3.12).

The pipeline persists across raster lines (the registers are sticky until a CPU read), so _initRenderRasterLine must not clear it.

Known-open edge cases (reviewed, demo-neutral, left unfixed): high-X sprite-sprite collisions in the right-border/wrap zone (spritescan), per-index collision gaps in the border (spritegap), and mid-sprite sub-cycle register splits (spritesplit, spritebug X-expand). See the corresponding memory notes.


11. Interrupts & light-pen

irqStatus ($D019) / irqMask ($D01A) with the standard four sources:

The IRQ line back to the CPU is irqHandler(asserted); irqStatus bit 7 is the "any enabled source active" master flag, and clearRasterIrq / the $D019 write path handle acknowledgement.

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. If the LP input is held low across the frame boundary it 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 needed):

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 bus artifact on same-value $D02x writes; VSP glitch address $3807

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


13. Open bus & the internal data bus

vicInternalBus is the VIC's data-bus latch. It resets to $FF at the start of each master cycle, and is driven by VIC RAM/char-ROM fetches and by CPU $D000-$D3FF accesses. It is the source for the sprite idle fetch leak and the open-I/O behaviour. The sprite idle-fetch sample point is in phi2() (after the CPU step) so a same-cycle STA $D0xx leaks before the next cycle's reset. Scope is vic-registers-only by default (a full "every CPU bus cycle" model would need extra wiring in memory.js).


14. Performance gates

Several optimisations are behind boolean gates, each proven byte-identical (orbit framebuffer hash + the full spec suite + a dedicated equivalence test) and kept flippable for A/B bisection:

Line-batch rendering (Tier-3, lineBatchRender)

The per-cycle incremental render pays a fixed dispatch/build/split tax on every cycle regardless of content (~28% of frame time, measured content-independent to within ~6% across raster_time_gp's scenes). The line-batch mode removes most of it by deferring 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 then replayed in one burst through the same incremental machinery (_catchUpDeferredLine):

The 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 explicitly not part of the contract (no C64 program can read pixels back); spec tests that assert per-cycle render internals pin lineBatchRender = false and say why. 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.

Verified: full suite green in both modes, orbit framebuffer hash and all 195 reference screenshots byte-identical, demo-status board parity. Measured (clean interleaved A/B): orbit −14.5%, raster_time_gp −25.8% ms/frame; the win scales with how quiet the content's lines are (a line with a mid-line write or observer simply renders live — FLI-class content keeps today's cost).


15. Debug / trace facilities

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

The investigation/ directory holds many headless trace + VICE-compare scripts (.mjs) built on these hooks.


16. Key invariants & gotchas (quick reference)