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 —
6569and8565— 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:
- 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. - Runs a display state machine advancing the video counters (VC, VMLI, RC) and deciding, per cycle, whether it is fetching the character matrix.
- Renders pixels into a
384 × 272RGBA framebuffer (fb32), a full PAL visible area: 320×200 active display + surrounding border. - 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)#
CYCLES_PER_LINE = 63,LINES_PER_FRAME = 312,CYCLES_PER_FRAME = 19656.CANVAS_W = 384,CANVAS_H = 272. Active display at canvas(32,36),320×200. Graphics window X is[32, 352)(GRAPHICS_WINDOW_START/END).- A raster line's canvas row is
raster - 15(the first 15 lines fall above the visible crop). VICE PNGs are cropped one row differently — see the recurring "row0 = raster16" gotcha in the test suite.
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:
- Cycle-58 bad-line sample (
§3.7.2 rule 5):clock()samples the bad-line condition at phi1 into_cycle58BadLineSample; the actual idle→display transition (_advanceDisplayStateCycle58) runs inphi2()so a same-cycle$D011write is correctly not seen, but a cycle-57 write (already landed) is. - Cycle-56 Y-expand FF (
§3.8.1 rule 3): the FF inversion runs at phi1 in_spriteSequencerCycle56();phi2()folds in a same-cycle$D017write (level-sensitiveMxYE=0force only — a phi2 set is too late to invert).
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:
$D02F-$D03Fread$FF(unconnected).$D016bits 6-7,$D018bit 0,$D019/$D01Ahigh bits, color regs$D020-$D02Ehigh nibble all read as 1.$D011bit 7 returns raster bit 8;$D012returns raster low 8 — both via_cpuVisibleRaster().$D01E/$D01F(collision) reads clear the register and clear the in-flight collision visibility pipeline (phi-resolved — see §10).
write() is where many timing tricks live:
$D016CSEL change is logged (_lastCselChange*) to drive the border comparator veto window.$D011/$D012raster compare is edge-triggered (VIC-Addendum): the IRQ latches on the rising edge ofraster == target. A HIGH→LOW dip arms_rasterCompMidLineDip; a LOW→HIGH write fires the IRQ immediately (_fireRasterIrqMidLine) — that is the OrbitUntold chained-IRQ mechanism. A$D012-follows-raster loop holds the comparator continuously HIGH, so no edge → no IRQ.- Late-line
$D011YSCROLL → bad-line recovery (raster_time_gp permanent bad-line trick): a write at cy 59-62 that produces a fresh bad-line condition re-activatesdisplayActivesynchronously. - 8565 grey-dot same-value
$D02xwrite artifact is recorded here (_greyDotXs) and overlaid at line end.
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:
nmosBankDelay(6569): an extra-cycle delay when a CIA2 DDRA write turns a pin to output. Default off (the addendum calls it "unstable").c64cBankGlitch(8565): a 1-cycle blip through bank 3 on PA0/PA1 10↔01 transitions. Default off.
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):
- idle→display "occurs as soon as there is a Bad Line Condition" (§3.7.1) —
caught at cy 14 phi1 (
_advanceDisplayStateCycle14) for the canonical case, or mid-line by the edge handler_onBadLineConditionEdge. - display→idle "in cycle 58 if RC=7 and there is no Bad Line Condition"
(§3.7.2 rule 5, run at phi2 in
_advanceDisplayStateCycle58).
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:
- Edge detection (
_updateBadLineStateForCycle, cycles 1-54): re-evaluates the condition each cycle and fires_onBadLineConditionEdgeon a transition (rising = condition arises, falling = condition ceases). The window starts at cy 1 (not 12) so a condition both raised and cancelled inside cy 1-11 still registers — needed for the DMA-delay/VSP trick where a 1-cycle YSCROLL pulse lands at cy 0-1. - c-access fetch queue (
_queueBadLineFetchPhase→_beginBadLineFetchPhase→_runTextPhase2Access): one c-access per cycle in the window 15-54 (last c-access = column 39 at cy 54)._fetchScreenRowColumnreads screen RAM + color RAM into the line buffer and recordslineCycleCWriteCol(the buffer index this c-access wrote — critical for the late-transition column shift, §8). - BA/AEC:
_isBadLineBaLowdrives BA low across the fetch window so the CPU is stalled; AEC follows 3 cycles later (Bauer §3.6.1). The rising edge can also patch the trigger-cycle idle g-access to the VSP glitch address ($38FFon 6569 /$3807on 8565) — the §3.14.6 DMA-delay idle-byte glitch.
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:
- The display flip-flop is owned exclusively by cy 58 rule 4 — DMA start at
cy 55/56 never touches it. This is what makes the "restart on the last display
line" trick work (
spriterestart, nine.prg maskers). - MxE re-check at cy 58: DMA start (cy 55/56) and display turn-on (cy 58)
read
$D015independently, so a CPU rewrite between them lets DMA run (and steal cycles) while the sprite never displays (testprogsspriteenablecores 1/2/4). - Late-DMA open-bus byte 0 (
_spriteByte0Floats): if a sprite is enabled so late that only the cy-56 check catches it, the BA→AEC lead-in hasn't completed by its byte-0 fetch, so that first byte reads the floating bus ($FF). Only sprite 0 can hit this.
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:
- hires: 1 bit/pixel, color = sprite color.
- multicolor (
$D01C): 2 bits/pixel → transparent /$D025/ sprite color /$D026. - X-expand (
$D01D) doublespixelsPerUnit._drawSpritePixelenforces priority/inheritance viaspriteOwnerBuffer(a pixel claimed by a higher-priority/lower-index sprite masks lower ones, even when the higher one was hidden behind foreground) andgraphicsPriorityBuffer(the$D01Bsprite-vs-bg priority bit).spriteVisibleBuffermarks pixels that actually reached output, so the column fixup in §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, 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:
- Line-start preload. The line's first reload lands at canvas
32 + XSCROLL; before it the shifter is empty (drained since the previous line's last g-access) and emits "0" bits. Those pixels take the current mode's idle background:$D021in the text modes, the adjacent column's matrix low nibble in standard bitmap (VICE-oracle-verified), black in the invalid ECM+BMM / ECM+MCM modes and in hires-bitmap idle. Display state handles this in_renderCycleSegmentGraphics(isStdBitmap/isInvalidModefiller gates); idle state via the cycle-15_fillSegmentBg0preload. With CSEL=0 the widened left border (canvas ≤38) covers the whole preload — nothing of it may leak into x≥39. - Drain zone. Inside the line, every 8-pixel group's first XSCROLL pixels
still shift out the previous g-access byte before the delayed reload. In
idle state the segment carries both bytes (
seg.idleByte= fetched atregCycle+1,seg.idleBytePrev= one g-access earlier) and_renderOpenBorderIdleSpanswitches source atcycleStart + XSCROLL(bit phase 0, so bit/pair indexing is continuous). Steady idle lines make the two bytes identical; the split is observable only across a mid-line idle-fetch change — ECM flip, VIC bank switch, or a VSP glitch that flips the idle byte$00↔$FFmid-line at both screen edges. In display state the same continuity falls out of the column span math (srcXoffsets). - Opened-right-border tail. XSCROLL moves the complete 320-pixel graphics
stream, so its last XSCROLL pixels land at canvas
352..351+XSCROLL. The normal right border overlays them; when the cycle-56 CSEL trick keeps the border open,_renderRightXscrollSpillexposes the tail using the final display column's fetch/mode snapshot before the side zone settles to its empty-shifter background.
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:
- Vertical border FF (
vBorderActive,_advanceVerticalBorderFlipFlop): a two-stage set/latch run every cycle. Bottom compare arms the latch; top compare + DEN opens it; cy 1 copies the latch into the live FF. Compare lines: RSEL=1 → 51/251, RSEL=0 → 55/247. Rule 4 (left-compare close on the same line) is the sole discriminator for thevborder2-35/36cycle-exact case. - Horizontal/main border FF (
hBorderActive,_advanceHorizontalBorderState): right-edge SET / left-edge RESET comparators, gated on the vertical FF.
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):
- Sprite-sprite (
$D01E):_latchSpriteSpriteCollisionORs the writing sprite's bit intospriteCollisionBuffer[pixel]; if another sprite already marked that pixel, both bits commit. - Sprite-background (
$D01F):_latchSpriteBackgroundCollisionfires when the pixel is a foreground graphics pixel (graphicsCollisionBuffer).
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,spritebugX-expand). See the corresponding memory notes.
11. Interrupts & light-pen#
irqStatus ($D019) / irqMask ($D01A) with the standard four sources:
- bit 0 — raster (
_checkRasterIrqat cy 1, cy 2 for line 0;_fireRasterIrqMidLinefor the immediate LOW→HIGH write case). Edge-triggered comparator (see §3). - bit 1 — sprite-bg collision (IMBC), bit 2 — sprite-sprite (IMMC) — from the collision commit path.
- bit 3 — light-pen (
_latchLightpen).
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:
batchRender—_fixupColumnsre-renders only the columns whose +2 mode / +3 bg-color window changed, not all 48 cycles twice.captureDedup—_captureCycleStatealiases the previous cycle's row/sprite snapshot buffers (tracked by_rowSnapVersion/_sprSnapVersion) when the source is unchanged, instead of re-copying ~10 typed arrays per visible cycle._home*buffers decouple the slot pointer from the owned write buffer so aliasing is safe.captureDedupVerifyadds a runtime cross-check.spriteSkipIdle— skips_renderSpriteSegmentForSpritefor sprites that are neither displaying nor started this line (a provable no-op).- Scratch objects —
_scratchRasterSeg,_scratchSpriteSeg, split parts, and per-pixel/cell return objects are reused in place to kill hot-path allocation. lineBatchRender— the Tier-3 line-batch renderer (below). Default on;?LINE_BATCH=0(browser) /LINE_BATCH=0(node) forces the per-cycle live path for A/B or triage.
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):
- At line end (the common case): graphics are emitted as maximal uniform
spans — one wide segment per stretch of cycles whose captured inputs are
identical (
_spanExtends: regs-snapshot pointer equality over[c-1..c+2], courtesy of thecaptureDedupaliasing, plus idle-byte and per-cycle scalar equality)._renderCycleSegmentGraphicsderives its columns from segment geometry, so wideningseg.endis exact. Segments 15/53/54 stay solo (the CSEL border edges must remain single-edge per segment for the splitter). Sprites and the collision-pipe drains replay strictly cycle-by-cycle after the spans (order-safe: graphics never feeds the pipe; sprite paints are segment-bounded). - Immediately on a mid-line observer — anything that would let the CPU see
render-derived state:
$D019/$D01E/$D01Freads, a$D01Awrite arming the collision IRQs (armed lines render live outright), a fetch-config change ($D018base bits,$D011BMM, VIC bank), a CPU write into the line's g-access RAM window (a fetch watch armed inmemory.js— the renderer re-reads glyph/bitmap bytes from RAM at paint time, and beam-racing charset animation would otherwise reach the replay ~40 cycles later than the live paints saw it), orserialize()(save-states stay canonical).
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):
- Per-cycle border traces (
frameTraceHBorder/VBorder). - Per-line scalar snapshots of every interesting register (D011/D016/D015/ D01C/D01D/D017/D01B/D010/D021/D012/D019/D01A/D020), sprite pointers, MC/MCBASE, Y-expand FF, sprite XY/colors, VIC bank, and DMA/display state.
- Per-write logs for raster-IRQ-relevant registers + IRQ assertion/acceptance timestamps (for measuring interrupt latency across two snapshots).
_staleRowRenderHits— a §3.7.1 invariant counter (renderer consuming row data while in idle state).
The investigation/ directory holds many headless trace + VICE-compare scripts
(.mjs) built on these hooks.
16. Key invariants & gotchas (quick reference)#
- VIC acts before CPU: phi1 logic in
clock(), same-cycle CPU-write reconciliation inphi2(). A cycle-N CPU write is visible to VIC at cycle N+1. - The matrix line buffer is retained across lines and frames — never wiped at the frame boundary.
- Background colors are output-stage (+3, beam-timed); mode bits are +2;
$D018CB / bitmap base is +1 (g-access cycle). Get these offsets wrong and mid-line splits shift by a character. - Collisions are visible ~2 cycles late and the registers are sticky across lines; a read clears them phi-resolved (retains the read cycle's late half).
displayEnabled≠ live DEN — it is latched from raster$30.- Don't reset sprite DMA/display at the frame boundary — a sprite mid-display when raster wraps 311→0 carries into the next frame's top border.
- Canvas row
r= rasterr+15; VICE reference PNGs are cropped one row off (row0 = raster16) — a recurring source of "1px" false diffs.