Video Chips and Serial Streams: Rendering C64 Graphics Over a Modem
How I emulated the VIC-II video chip and forwarded its framebuffer as ANSI escape sequences over a serial connection—turning a 40-year-old graphics architecture into something a BBS terminal can display.
On this page
The first time someone dials into the emulated Commodore 64, they’re connected via a simulated 300-baud modem. They’re expecting text. The C64 has a video chip. That chip renders to a framebuffer. How do you bridge those two worlds?
I stared at the problem for a while before the shape became clear: the video chip doesn’t matter. What matters is the memory. A 6502 program writes bytes to screen RAM, and somewhere on the other end, a terminal needs to display characters. Everything between those two points is translation.
Once I saw it that way, the adapter wrote itself.
The Smallest Contract: Memory and a Dirty Flag
For text mode, the VIC-II’s relevant state fits in a few hundred bytes:
- Screen RAM at
$0400–$07FF: 40×25 = 1000 PETSCII characters (stored in a 1024-byte block) - Colour RAM at
$D800–$DBFF: 4-bit foreground colour per cell - Border and background registers at
$D020and$D021 - Display enable bit at
$D011bit 4
The 6502 core in Rust tracks writes to these addresses and flips a dirty flag whenever something changes. That flag is the contract between the CPU and the video layer: if dirty, the screen may have changed; if clean, nothing to do.
pub fn write_byte(&mut self, address: u16, value: u8) {
match address {
0x0400..=0x07FF => {
let offset = (address - 0x0400) as usize;
if self.screen_ram[offset] != value {
self.screen_ram[offset] = value;
self.dirty = true;
}
}
// ... colour RAM, border, background follow the same pattern
}
}
The comparison before assignment matters. A program that writes the same value repeatedly—common in screen-clearing loops—won’t trigger unnecessary renders.
The Adapter Is a Translator, Not a Renderer
Here’s where the model helps: the video adapter doesn’t build ANSI strings directly. It writes into a VirtualTerminal buffer, and the terminal produces ANSI diffs that get streamed over serial.
That separation exists because the terminal already knows how to generate minimal updates. It tracks which cells changed since the last flush. The adapter’s job is simpler: read video memory, translate PETSCII to Unicode, translate C64 palette indices to ANSI colour codes, and call vterm.putChar() for each cell.
updateFromCore(core: any): boolean {
if (!core.getVideoDirty || !core.getVideoDirty()) {
return false;
}
const screenArray = core.getVideoScreen();
const colorArray = core.getVideoColors();
this.screenData = new Uint8Array(screenArray);
this.colorData = new Uint8Array(colorArray);
this.backgroundColor = core.getVideoBackgroundColor();
this.borderColor = core.getVideoBorderColor();
core.clearVideoDirty();
this.dirty = true;
return true;
}
The render loop walks each cell, looks up the PETSCII code in a mapping table, converts the C64 palette index to an ANSI code, and hands everything to the terminal buffer. The adapter owns translation; the terminal owns diffing.
PETSCII and Colour: The Lossy Middle
PETSCII isn’t ASCII. The C64 ships with uppercase and graphics characters by default, control codes live in different places, and there’s a whole set of box-drawing glyphs that don’t map cleanly to Unicode. The adapter builds a lookup table at initialization and uses ? for anything unmapped—honest and debuggable.
Colour is its own translation problem. The C64 palette is 16 specific RGB values. ANSI terminals have 16 named slots, but the colours don’t match. The adapter approximates: it looks at dominant channels and brightness to pick the closest ANSI code.
function hexToAnsi(hex: string): number {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const brightness = (r + g + b) / 3;
const bright = brightness > 128;
// ... compare channels, return closest ANSI code
}
The conversion is lossy but stable. Cyan doesn’t look exactly right. Neither does light blue. But the program remains readable, and that’s the constraint that matters most for a text-based BBS terminal.
Timing: 30 FPS and the Silence of Static Screens
The video backend runs a render loop at 30 FPS. Every 33 milliseconds, it checks the dirty flag. If the core is clean, nothing happens—no rendering, no serial output, no CPU cost.
With a static screen, the modem is silent. With a busy screen, the backend sends only the cells that changed, not a full repaint. That delta comes from VirtualTerminal.flush(), which compares the current buffer to the last-flushed state and emits the minimal ANSI to update the difference.
This cadence is deliberate. Text doesn’t need 60 FPS, but it does need the illusion of continuity—a cursor that blinks, a prompt that appears without lag. Thirty frames per second is enough to feel responsive without flooding a slow connection.
The VIC-20: Same Pipeline, Different Numbers
The VIC-20 adapter is the same architecture with different constants:
- 22×23 character grid (506 cells vs. 1000)
- Screen RAM at
$1000(with expansion) instead of$0400 - Colour RAM at
$9600instead of$D800 - Foreground colours restricted to 0–7 (masked with
& 0x07)
The PETSCII mapping is nearly identical—the VIC-20 uses £ instead of \, a quirk of Commodore’s British roots. Everything else—the dirty flag, the render loop, the terminal integration—stays the same. That’s the advantage of the translator model. Platform differences reduce to a handful of constants and one bitmask.
What We Don’t Emulate (By Design)
A real VIC-II does far more than text mode. It handles sprites, raster interrupts, smooth scrolling, bitmap graphics, and character set switching via $D018. None of those survive a 300-baud serial link.
The adapter is deliberately text-only. It assumes the C64’s default uppercase/graphics character set. It ignores the reverse-video bit (bit 7 of screen RAM), which is a real hardware feature but lives at a different abstraction layer. The constraint is compatibility with a minimal ANSI terminal, not cycle-accurate video fidelity.
That boundary turned out to be clarifying. Once I stopped asking “how do I emulate a VIC-II?” and started asking “how do I translate 1000 bytes of memory into terminal output?”, the complexity collapsed.
Where This Sits in the System
When a 6502 program executes STA $0400, it isn’t “drawing pixels.” It’s mutating a byte in memory. The CPU core sets a flag. Thirty times a second, the video backend checks that flag. If it’s set, the adapter reads the memory, translates it, and hands the result to a terminal buffer. The terminal diffs against its last state and sends the minimal ANSI. That ANSI travels down the serial layer to the connected client.
Five components, five responsibilities, one direction of data flow. The video chip is gone. What remains is a pipeline that turns memory writes into terminal characters—and that pipeline works for any platform that stores its screen state in a known memory region.
The VIC-II was designed to drive a CRT. I needed it to drive a modem. The trick was realizing I didn’t need the VIC-II at all. I needed a translator that spoke PETSCII on one side and ANSI on the other. The chip was always just memory with opinions about how to display it.