Libraries used by the C64 system¶
Core library¶
The C64 system logic — VIC2, CIA, SID, 1541 — lives in:
This library has no UI, rendering, or I/O dependencies. It exposes abstractions that the implementation libraries below plug into.
Device timing¶
The CPU executes one instruction at a time, but every cycle of it is a bus access counted by
CPU.BusCycles. The VIC-II and the two CIAs are brought up to date lazily from that counter: at
every instruction boundary, and at every access to one of their registers, to the cycle of the
access. A read of $D012 therefore returns the raster line at the cycle the read happens, a CIA
timer read returns the count at that cycle, a raster-compare or timer-control write takes effect
on its own cycle, and a VIC-II bank change through CIA 2's port reaches the chip's fetches from
the cycle after the write (the fetch of the write's own cycle still reads the old bank). The CIA timers have the 6526's pipeline between a control write and the
counter: after a start the counter holds through the two cycles after the write and shows its
first decrement on the third; a force load shows the latch two cycles after the write and counts
from it two cycles later; a stop lets the counter move for two more cycles; a one-shot timer
stops with the latch in the counter; writing the latch's high byte while the timer is stopped
loads the counter, the low byte alone does not. A running counter counts down to 1 and underflows
in the next cycle, where it shows the latch again and holds it for a second cycle, so it never
reads 0 and a continuous timer with latch N has a period of N + 1 cycles; a latch of 0 counts like
a latch of 1, and a latch written in the underflow cycle is the one reloaded. A force load or a
start leaves the interrupt flag alone. The underflow flag shows in the interrupt control register
in the underflow cycle, and the interrupt output follows in the next cycle unless a read has
taken the flag away; the CPU sees the output from the cycle after that, so a read of the
interrupt control register in the output's first cycle still keeps an IRQ from being taken, while
an NMI, latched on the output's edge, is taken. A read that releases the output only in an
instruction's last cycle is too late: the CPU sampled the line in the cycle before and takes the
interrupt, and the handler then finds the register already cleared. Two quirks of the 6526 (the
6526A's timing is not modelled): a read in the cycle before timer B's underflow loses that flag,
which is never set, though the output still follows and the register then shows only the
interrupt bit; and a read in the cycle after a read that took an enabled source's flag still
shows the interrupt bit, without the output being driven. Enabling a source whose flag is
already set drives the output the same way, a cycle after the write, seen by the CPU a cycle
after that; disabling a source stops its output from the cycle after the write, so timer B's
output due in the write cycle is still driven. The CiaSyncedSplit sample in the Avalonia and browser menus
places a raster split with a CIA timer started in a known cycle, the classic timer stabiliser, and
marks where the split's edge belongs with that pipeline. A raster or CIA timer interrupt is dated to the cycle on
which the raster line began or the CPU first sees the CIA's output, and the CPU applies its
sampling rule to that cycle: taken after
the current instruction if it fell at or before the second-to-last cycle, otherwise after the
next one. The raster interrupt is raised when the raster compare goes from not matching to
matching, checked as the raster enters a line (a cycle later for line 0) and in the cycle after a
$D011 or $D012 write: writing the current line's number raises it at once, while a program
that moves the compare value to the next line in every line's last cycle keeps it matched and
gets no further interrupt. The light pen input, CIA 1's port B bit 4, latches the beam position on
a negative edge: $D013 the X coordinate at the end of that cycle halved, $D014 the raster line,
once per frame, with the light pen interrupt; programs pull it low to learn the cycle they are on.
The SID does the same through its audio provider (see below). The rasterizer applies a
write to the border or background colour registers ($D020-$D024) from the cycle after the
write lands, so a colour change in the middle of a line splits that line at the write's pixel
position, as on hardware; the VIC-II reports each register write with its frame cycle for this.
The other display registers (mode, scroll, 38/24-column, memory setup) reach the picture at the
pixel the graphics data sequencer takes them at (see below); only the legacy pixel generator samples
them once per raster line, so that with it a mid-line change becomes visible on the next line. Where a cycle's
pixels land follows the chip: the display window's first pixel is 124 pixels into the raster line
on both PAL and NTSC, taken from the VIC-II's display window at X 24 and where X 0 falls relative to
the line's first cycle. The visible frame is the chip's own: the lines and pixels outside its
vertical and horizontal blanking, raster lines 16-299 and X 480-378 on the 6569, lines 41-12 and
X 489-394 on the 6567R8. So the border above the display window is smaller than the one below it
(35 and 49 lines on PAL, 10 and 25 on NTSC), and nothing from the blanking, where a sprite or an
opened border can still produce pixels, is shown. A colour register write is shown a few pixels away from the
cycle boundary it lands on, by an amount taken from VICE's VICII test programs that is the same on PAL and NTSC. The
rasterizer does hold a character row's 40 screen codes and colour nibbles the way
the VIC-II does: fetched on the row's first line and shown for its remaining seven, so a screen
write made after that fetch appears from the next row on. When a CPU read is stalled, the VIC-II
and the renderer are brought through the stalled cycles before the CPU continues, so what the
VIC-II fetched during the stall reflects memory before the stalled instruction's write. A CPU write into the VIC-II's bank is likewise seen by the chip's fetches from the cycle after the write on, not from the instruction's end: the chip reads in a cycle's first clock phase and the CPU writes in its second, so a byte rewritten in the middle of a line reaches the screen from the column after the write's cycle (an idle byte changed mid-line, graphics rewritten as they are fetched). The VIC-II's bank follows the levels on CIA 2 port A's two bank pins, not the bytes written: a bit the direction register makes an input floats up through its pull-up, so a program selects a bank with $DD02 as well as with $DD00.
The VIC-II also takes the bus from the CPU as on hardware: 40 cycles on every bad line (BA low from cycle 12, video matrix fetches in cycles 15-54) and two cycles per sprite with DMA on, BA low three cycles ahead (a sprite enabled, or moved onto the line, after the line's two DMA compares does not start there and takes no bus). A CPU read that falls inside such a window waits until the window ends; writes do not wait. A CPU read of an address nothing answers, the I/O 1 and I/O 2 areas ($DE00-$DFFF) with no cartridge there, returns the byte the VIC-II read in the first half of that cycle, which stays on the data bus: a sprite pointer, the middle byte of a fetching sprite's row, a refresh access, a graphics access or an idle access ($3FFF), depending on the cycle; the colour RAM supplies only the low four bits of a read and the high four come from that byte too. Bad lines follow YSCROLL and the DEN bit as the VIC-II saw it during raster line $30: clearing DEN before that line switches the display, and its bad lines, off for the whole frame, clearing it later has no effect until the next frame, and setting it partway through line $30 makes bad lines from the cycle after the write on (a write in the line's last cycle still counts, one in the next line's first cycle does not). Sprites follow the chip's data counters: the compare in cycles 55 and 56 of the line an enabled sprite's Y register names switches its DMA on (on the 6567R8's 65-cycle line, cycles 56 and 57) and the display follows in cycle 58 (59), the row it fetches for each line comes from a counter that takes the fetch's end position (three on) in cycle 16 of every line where the Y-expansion flip-flop is set (inverted while the expand bit is set, which a write as late as cycle 55 still decides, held set while it is cleared), and the DMA ends once that counter has reached 63. So a Y-expanded sprite shows every row twice, a change of the expand bit mid-sprite changes the line count from there on, a Y written to a line the raster has already passed costs nothing until the raster comes round again, and a Y rewritten below a finished sprite shows it again there. The display decision asks for the enable bit again, as it stands in that cycle, so a sprite switched on for the compares and off before cycle 58 is fetched but not shown, and the display stays on until that decision finds the DMA off, so a sprite whose Y is rewritten to its last line restarts there and shows its first row again on the next line. A DMA the second compare starts leaves sprite 0's first data byte to the CPU (its fetch is only two cycles on), so that byte reads $FF; a sprite 3-7 shown on the line its DMA starts, with an X beyond cycle 58, carries what its slot read while the DMA was off: $FF, the idle byte, $FF, or in place of an $FF the byte of a VIC-II register access the CPU makes in that cycle of the slot. The SpriteEnable sample in the Avalonia and browser menus shows the enable timing, the $FF byte and the restart. Clearing the expand bit in cycle 15 of a line where the flip-flop is clear crunches the sprite: the fetch position becomes a bit-merge of the counter and itself, the counter leaves its stride of three, misses 63 and wraps, and the sprite's rows come out reordered and its DMA runs on for up to 63 rows, as the demos use it. With per-line sprites on, the rasterizer's sequencer generator draws each raster line's sprites from what the chip fetched for it, so sprite pointer and data changes mid-sprite show as on hardware; the legacy generator keeps its per-sprite bands. Where a sprite appears on a line follows the chip's X compare per pixel: a sprite starts shifting its row out where its X register equals the beam position, an X written in a cycle counts from that cycle's fifth pixel, and a sprite whose X is moved past the beam after it was shown starts again, so one sprite can appear twice on a line. It cannot start while its own data is being fetched (cycles 58 and 59 for sprite 0, two cycles on per sprite, so X 355 to 366 on the 6569); one still shifting when its fetch begins repeats its last pixel for seven pixels and stops (a multicolour pair caught in its first pixel shows only its high bit there, as a standard pixel); and the fetch loads the next row, which can start on the same line, so a sprite whose X lies beyond its fetch shows each row a line higher. An X the beam never reaches (504 to 511 on the 6569) is never shown. The sprite's multicolour, X-expand and priority bits are read at every pixel too, so a change while a sprite shifts takes effect from that pixel: setting X-expand repeats the pixel being shown and clearing it fetches the next at once, switching multicolour on or off realigns the pixel pairs on the shape's odd bits (the sprite split effect), a sprite can be in front of the graphics on one part of a line and behind them on the rest, and a sprite colour written mid-line changes from that pixel like the background colours do. Sprite-to-sprite collisions come from these runs and are latched as the line ends, and so are sprite-to-background collisions, from the runs' opaque pixels against the foreground pixels the graphics sequencer output on the line (a set bit, or a 10/11 pair in multicolour; nothing while the vertical border flip-flop is set). The collision interrupt flags latch whether or not the source is enabled, and reading a collision register clears the collisions, not the flag. A collision register read in the middle of a line reports the collisions of the sprite pixels the beam has passed, up to four pixels before the start of the read's cycle, and clears the register as that cycle ends, so the collisions of the twelve pixels in between are lost and those of the rest of the line show in the next read. Where sprites overlap, the lowest-numbered one with an opaque pixel is shown and its own priority bit alone decides against the foreground graphics, so a sprite in front of the graphics does not show through a higher-priority sprite that is behind them. The SpriteX sample in the Avalonia and browser menus shows each of these cases.
Which character row a raster line shows, and which of its eight lines, is not arithmetic on the
line number but the chip's own display state: a bad line starts a row (the row counter resets and
the row is fetched), the eighth line of a row advances the row pointer by 40 and drops the chip
into idle state until the next bad line, and in idle state the display area shows the byte at
$3FFF ($39FF with ECM) in black over the background colour. The VIC-II keeps that state per
line for the legacy pixel generator and the bus stalls; the sequencer pixel generator runs the
chip's counters cycle by cycle after the VIC-II article's rules: the bad line condition (raster
line $30-$F7, its low three bits equal to YSCROLL as it is in that cycle, DEN seen in a cycle of
line $30 up to the one the write lands in) is evaluated every cycle, cycle 14 loads the video
counter and resets the row counter on a condition, a condition in any cycle puts the sequencer in
display state from the cycle after it (the g-access of the cycle itself is still an idle one, and
reads $38FF rather than $3FFF, as the 6569 and 6567R8 do) and one in cycles 12-54 starts the
video matrix fetches, whose first three read $FF while the CPU still holds the bus, and cycle 58
ends a row after its eighth line unless a condition keeps the display going. A DEN bit set
partway through line $30 therefore decides between a normal row, a row whose counter reset was
missed and a row shifted by a column, by the cycle the write lands in. That is what
makes the DMA delay (a bad line condition created in the middle of a line shifts the screen right
by a column per cycle), linecrunch, doubled rows and FLD come out as on hardware. Running every
cycle of every line makes an opened border, where the whole line is output, about twice the work
of a text screen, so the per-cycle path is kept cheap: a cycle whose fetched bytes, mode and
pipeline state equal the cycle before repeats that block's pixels by copy (runs of identical
cells, the idle byte across an opened border, blank cells under any XSCROLL), the line's colour
resolve copies such blocks as well, and sprite rows are written as runs of pixels rather than a
pixel at a time. The vertical border compares are
checked in every cycle with the registers as they are then: the top compare (line 51, or 55 with
RSEL clear) with DEN set clears the vertical border flip-flop at once, and the bottom compare (251
or 247) arms a latch that the flip-flop takes over as the raster enters a line and at the display
window's left edge; while the flip-flop is set the whole line, sprites included, is border colour.
That is what makes vertical fine scrolling, a switched-off screen, a border opened by toggling
RSEL around line 251 and row stretching by avoiding bad lines come out as on hardware, and it is
why RSEL cleared for a few cycles in line 247 closes the border at that line's left edge or, if
later in the line, from the next line. A $D011 write is seen from the cycle after it, so one
early enough in a line (before the left edge check in cycle 16 and the bad line check at cycle 14)
still counts for that line, and one in a line's last cycle counts for the next.
The side borders come from the chip's main border flip-flop, followed pixel by pixel: it is set
when the X coordinate reaches the right compare value (344 with 40 columns, 335 with 38) and reset
at the left one (24 or 31) on a line the vertical flip-flop leaves open, and a pixel is border
colour while it is set. Each compare is evaluated in the cycle after the one its X coordinate
falls in, with the registers as written before that cycle: the 38 column right compare in cycle
56 (counting from 1) and the 40 column one in 57. The 40 and 38 column layouts are what those
rules give on an ordinary line; a program that selects 38 columns with a write in cycle 56 misses
both compares, and the display then runs to the frame's edges on that line and the left border of
the next, with sprites visible there. The column select bit follows the register write journal at the
cycle boundary after the write. An opened border shows the sequencer's idle output, the byte at
$3FFF in black over the background colour.
The graphics themselves come from the chip's graphics data sequencer, followed pixel by pixel as
the VIC-II article describes it. Where in a cycle a register change reaches the output is not in the
article; those points are the chip's observed behaviour as VICE's VICII test programs show it, and
the output is checked against their reference pictures. Each cycle's g-access (the chip's cycles
16-55, one per column) puts a byte into a two-stage pipeline, and the shift register takes it at the pixel XSCROLL selects in the cycle
after that, together with the video matrix byte and colour nibble that belong to it; in idle state
the byte comes from $3FFF ($39FF with ECM) with no matrix data, and outside those cycles the
pipeline is fed zeros. XSCROLL, the mode bits and the memory pointers reach the sequencer through
the register write journal, from the cycle after the write: XSCROLL moves the next load within its
cycle (the old byte's zeros show until then), MCM takes effect four pixels into the cycle, ECM and
BMM four pixels in when set and six when cleared, and the pointers apply to the next g-access. The
g-access's address follows BMM as it is in that cycle or as it was in the cycle before, whichever
is set, so switching bitmap mode off reaches the fetch a cycle after it reaches the sequencer; when
that late fetch moves from RAM into the character ROM its address takes the low byte from the
bitmap address and the rest from the character set's (the NMOS chips' behaviour). Each
pixel's two bits select its colour source from the mode table (a background colour register, the
matrix nibbles, the colour nibble, or black in the invalid modes) and its priority, and the line's
pixels are resolved into the two layers when the line ends, with the background colour registers'
values at each pixel. That is what makes mid-line mode, scroll and character set switches, and the
opened-border and idle pictures of VICE's border and videomode tests, come out as on hardware. The
sequencer costs more render time than the generator it replaced (in a scrolling game, Giana
Sisters, about a quarter of a millisecond more per frame on an Apple M1; see the benchmark results
in benchmarks/Highbyte.DotNet6502.Benchmarks/RESULTS.md), so that generator
(8-pixel blocks, the display registers sampled once per line) is kept as the legacy pixel
generator, selectable with the Vic2RasterizerPixelGeneratorType configuration option and in the
hosts' C64 settings, for browsers and other hosts where that matters. It receives no new features. Sprite X positions wrap at 512 as on the chip, so a sprite at X 496 sits in the left
border. Sprite DMA takes the bus one cycle later on the 6567R8 than on the 6569 (its sprite 0
pointer fetch is in cycle 59 rather than 58), which is what a program timed by that hold sees.
Implementation libraries¶
C64-specific host code lives in its own engine-plugin libraries, one per host technology,
named Highbyte.DotNet6502.Impl.<Tech>.Commodore64. Each carries the C64 render targets for that
host technology (where one exists), C64 host config, and an ISystemEnginePlugin that registers
the C64 with the host app's DI container. Host apps discover these plugins at runtime — see
Highbyte.DotNet6502.Systems.Plugins — and
hold no direct project reference to them.
| Engine-plugin library | Host technology | Used by app |
|---|---|---|
Highbyte.DotNet6502.Impl.Skia.Commodore64 |
SkiaSharp | Blazor WASM, SilkNetNative |
Highbyte.DotNet6502.Impl.SilkNet.Commodore64 |
OpenGL shaders via Silk.NET | SilkNetNative |
Highbyte.DotNet6502.Impl.SadConsole.Commodore64 |
SadConsole | SadConsole |
Highbyte.DotNet6502.Impl.Avalonia.Commodore64 |
Avalonia | Avalonia Desktop, Avalonia Browser |
Highbyte.DotNet6502.Impl.AspNet.Commodore64 |
Blazor / JS interop | Blazor WASM |
Highbyte.DotNet6502.Impl.Headless.Commodore64 |
none (headless) | Headless |
Highbyte.DotNet6502.Impl.Terminal.Commodore64 |
Terminal.Gui (text cells) | Terminal (TUI) |
Render¶
C64 render targets live under Commodore64/Render/ in the engine-plugin libraries above
(Impl.Skia.Commodore64, Impl.SilkNet.Commodore64, Impl.SadConsole.Commodore64). The Avalonia
desktop and browser apps render the C64 via the generic Avalonia bitmap render target in
Highbyte.DotNet6502.Impl.Avalonia — there is no
bespoke C64 renderer, so Impl.Avalonia.Commodore64 exists only for engine registration and host
config. Likewise the Terminal (TUI) app renders the C64 (character mode only) via the generic
terminal render target in Highbyte.DotNet6502.Impl.Terminal,
so Impl.Terminal.Commodore64 exists only for engine registration and host config.
Input¶
C64 keyboard handling is no longer per host. One reusable C64InputHandler (with
C64HostKeyboard / C64InputConfig) lives in the C64 system core
Highbyte.DotNet6502.Systems.Commodore64 under Input/;
each host only supplies a small native-key → HostKey translation table inside its own input
context. A few genuinely host-specific bits remain in the engine-plugin libraries (for example
C64SilkNetGamepad in Impl.SilkNet.Commodore64).
Audio¶
C64 audio is host-agnostic. The C64 system declares two interchangeable audio providers; the
host app's audio target chain consumes whichever one is currently selected via the C64 config
UI (Audio provider / Audio target / SID emulation combos).
| Provider | Default | Accuracy | CPU | Notes |
|---|---|---|---|---|
Sample-based (C64SidSampleProvider) |
yes | Good but not perfect | Higher | Pure-managed sample-accurate SID emulation. All four waveforms (individual and combined via bitwise AND), full ADSR with the real 16 rate-counter periods, hard sync, ring modulation, TEST-bit hold, OSC3/ENV3 readback, a generic resonant 2-pole state-variable filter (LP / BP / HP), and the $D418 volume DAC's audible DC term so digi / sample-playback tunes should work. Inner loop takes auto fast paths when the current SID state doesn't actively use the advanced features. Default output rate is 48 kHz, with integer Bresenham downsampling from the SID clock. Register writes are applied on the exact CPU cycle they happen (the core is caught up to the write's bus cycle first), OSC3/ENV3 reads see the state at the cycle of the read, and the $D418 volume DAC is averaged over each output sample's window, so high-rate $D418 sample playback has neither instruction-boundary jitter nor sample-point aliasing. Reads of write-only registers return the chip's data-bus latch (the last byte written to or read from the SID) until it decays after about 7,400 cycles, as on a 6581, so a loader that does DEC $D418 for its loading noise counts the volume down and buzzes like the real machine. Missing: chip-variant filter models (6581 R1/R2/R3/R4 vs 8580), chip-measured combined-waveform tables, and anti-aliased downsampling. |
Command stream (C64SidCommandStream) |
no | Not very accurate | Lower | Legacy. Decodes SID register changes into host-agnostic synth commands (volume, voice ADSR + oscillator). A host-side oscillator graph (NAudio or WebAudio) turns them into sound. Cannot reproduce the SID filter, combined waveforms, ring modulation, hard sync, or digi / sample playback. |
The sample-based provider has two SID emulation modes (selectable in the config UI):
Auto(default) — full accuracy as listed above.Fast— drops the advanced features (single waveform per voice, no sync / ring mod / TEST hold / OSC3/ENV3 readback / filter). Modest savings (~4% per frame) on sync-using tunes, near zero on simple tunes. Many tunes will sound wrong.
Each provider is paired with a host audio target that knows how to play its output style:
| Provider | Compatible target on desktop | Compatible target in browser |
|---|---|---|
| Sample-based | NAudioSampleTarget (Impl.NAudio, playback via OpenAL) |
NAudioSampleTarget (Impl.NAudio, playback via WebAudio JS interop) — Avalonia Browser only |
| Command stream | NAudioCommandTarget (Impl.NAudio, playback via OpenAL) |
NAudioCommandTarget (Impl.NAudio, playback via WebAudio JS interop) on Avalonia Browser; WebAudioCommandTarget (Impl.AspNet, direct WebAudio oscillator nodes) on Blazor WASM |
Per-app availability:
- Avalonia Desktop, SadConsole, SilkNetNative: both providers available (sample-based by default).
- Avalonia Browser: both providers available (sample-based by default), playback via WebAudio JS interop.
- Blazor WASM: command-stream only. The sample-based provider is not yet wired up here — see the design log for the planned work.
- Terminal (TUI): no audio (terminals have no audio output).
There is no C64-specific audio library — the former Impl.NAudio.Commodore64 was removed
when the audio command vocabulary was generalised, and the new sample path is also
system-agnostic on the target side.
For the cross-system view (which app uses which library, including Generic), see the Implementation libraries overview.