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.
One of the weirder problems in building a BBS terminal emulator is this: what happens when someone dials into a Commodore 64? The C64 has a video chip. It renders to a framebuffer. But the user is connected via a simulated 300 baud modem. They’re expecting text, not pixels.
The answer, it turns out, is to intercept the video memory, convert it to ANSI escape sequences, and stream the result over the serial connection. It’s absurd and wonderful and it actually works.
The VIC-II in Brief
The VIC-II (Video Interface Chip II) is the graphics chip in the Commodore 64. In text mode—which is what we care about for BBS use—it displays a 40×25 character grid. Each character position has two bytes associated with it:
- Screen RAM ($0400-$07FF): Which character to display (PETSCII code)
- Color RAM ($D800-$DBFF): What color that character should be (4 bits, 16 colors)
There are also a few VIC-II registers that matter:
- $D020: Border color
- $D021: Background color
- $D011: Display control (bit 4 enables the screen)
That’s 1,000 bytes of screen RAM, 1,000 nybbles of color RAM (the upper 4 bits of color RAM are open bus, reading garbage), and a handful of registers. The real VIC-II does vastly more—sprites, smooth scrolling, raster interrupts, bitmap modes—but for text-mode emulation, this is enough.
Memory Interception
The 6502 CPU core is written in Rust and compiled to WebAssembly. To capture video memory writes, I added interception logic to the memory write path:
pub fn write_memory(&mut self, addr: u16, value: u8) {
match addr {
0x0400..=0x07FF => {
// Screen RAM
let offset = (addr - 0x0400) as usize;
self.video_state.screen_ram[offset] = value;
self.video_state.dirty = true;
}
0xD800..=0xDBFF => {
// Color RAM (only lower 4 bits matter)
let offset = (addr - 0xD800) as usize;
self.video_state.color_ram[offset] = value & 0x0F;
self.video_state.dirty = true;
}
0xD020 => {
self.video_state.border_color = value & 0x0F;
self.video_state.dirty = true;
}
0xD021 => {
self.video_state.background_color = value & 0x0F;
self.video_state.dirty = true;
}
_ => {
// Normal memory write
self.memory[addr as usize] = value;
}
}
}
The dirty flag is crucial. We don’t want to re-render the entire screen on every memory write—a tight loop clearing screen memory would generate 1,000 render requests. Instead, we set a flag and let the rendering loop check it periodically.
The Rendering Loop
On the TypeScript side, a VideoBackendInterface base class handles the timing:
export class VideoBackendInterface {
private renderInterval: NodeJS.Timeout | null = null;
private readonly TARGET_FPS = 30;
protected startRendering(): void {
const frameTime = 1000 / this.TARGET_FPS;
this.renderInterval = setInterval(() => {
if (this.cpu.isDirty()) {
this.renderFrame();
this.cpu.clearDirty();
}
}, frameTime);
}
protected abstract renderFrame(): void;
}
At 30 FPS, we check the dirty flag ~33ms apart. If the CPU has written to video memory since the last check, we render. If not, we skip. This optimization dropped CPU usage dramatically—a static screen costs almost nothing.
PETSCII to Unicode
The C64 uses PETSCII, Commodore’s character encoding. It’s almost ASCII, but not quite. Control characters are different, uppercase and lowercase are swapped by default, and there’s a whole set of graphical characters for drawing boxes, lines, and patterns.
The conversion table handles the mappings:
const PETSCII_TO_UNICODE: Record<number, string> = {
// Control codes
0x00: ' ', // Null → space
0x0D: '\n', // Return
// Uppercase letters (PETSCII 65-90 are the same as ASCII)
// Lowercase letters (PETSCII 193-218 map to a-z)
// Graphics characters
0x6C: '╮', // Upper-right corner
0x7B: '├', // Left tee
0x7C: '─', // Horizontal line
0x7D: '│', // Vertical line
0x7E: '╰', // Lower-left corner
// Block graphics
0xA0: '▌', // Left half block
0xDB: '█', // Full block
0xDC: '▄', // Lower half block
// ... 200+ more mappings
};
Some PETSCII characters have no Unicode equivalent (the checkerboard patterns, some of the diagonal lines). For those, I picked the closest visual match or fell back to a placeholder.
C64 Colors to ANSI
The C64 has a fixed 16-color palette. ANSI terminals have… a different 16-color palette. They’re not the same colors, but they’re close enough:
const C64_TO_ANSI: Record<number, string> = {
0x0: '\x1b[30m', // Black
0x1: '\x1b[97m', // White
0x2: '\x1b[31m', // Red
0x3: '\x1b[36m', // Cyan
0x4: '\x1b[35m', // Purple
0x5: '\x1b[32m', // Green
0x6: '\x1b[34m', // Blue
0x7: '\x1b[33m', // Yellow
0x8: '\x1b[33m', // Orange (no ANSI orange, use yellow)
0x9: '\x1b[33m', // Brown (use yellow)
0xA: '\x1b[91m', // Light red
0xB: '\x1b[90m', // Dark gray
0xC: '\x1b[37m', // Medium gray
0xD: '\x1b[92m', // Light green
0xE: '\x1b[94m', // Light blue
0xF: '\x1b[37m', // Light gray
};
Orange and brown map to yellow because standard ANSI doesn’t have those colors. It’s not perfect, but it’s recognizable. The VIC-20’s palette is similar enough that the same mapping works for both machines.
The C64TextAdapter
Putting it together, the C64TextAdapter class converts the video state to an ANSI string:
export class C64TextAdapter {
renderToAnsi(videoState: VideoState6502): string {
let output = '';
// Home cursor
output += '\x1b[H';
// Set background color
output += this.ansiBackground(videoState.backgroundColor);
let lastColor = -1;
for (let row = 0; row < 25; row++) {
for (let col = 0; col < 40; col++) {
const offset = row * 40 + col;
const char = videoState.screenRam[offset];
const color = videoState.colorRam[offset];
// Only emit color code if color changed
if (color !== lastColor) {
output += C64_TO_ANSI[color];
lastColor = color;
}
output += PETSCII_TO_UNICODE[char] ?? '?';
}
output += '\r\n';
}
return output;
}
}
The color tracking optimization matters. Without it, every character would include a 5-byte escape sequence. With it, we only emit color codes when the color actually changes. For typical screens with large regions of the same color, this cuts output size significantly.
Streaming Over Serial
The final piece is getting this ANSI output to the user. The video backend runs in a Web Worker, and when it renders a frame, it posts the result to the main thread:
protected renderFrame(): void {
const videoState = this.cpu.getVideoState();
const ansi = this.adapter.renderToAnsi(videoState);
// Send to serial output (goes to user's terminal)
this.serialPort.write(ansi);
}
At 30 FPS with a full 40×25 screen, that’s potentially 30 KB/second of ANSI data. But remember the dirty flag—if the screen isn’t changing, we’re sending nothing. And at 300 baud (simulated), the modem abstraction buffers and throttles the output anyway, creating that authentic “watching text appear character by character” experience.
Demo Programs
To test the system, I wrote a few 6502 assembly demos:
video-hello.s - Prints “HELLO WORLD” with color cycling:
LDX #$00
LOOP: LDA MESSAGE,X
BEQ DONE
STA $0400,X ; Screen RAM
TXA
AND #$0F
STA $D800,X ; Color RAM (cycles through colors)
INX
BNE LOOP
DONE: JMP DONE
MESSAGE: .byte "HELLO WORLD", 0
video-colors.s - Fills the screen with all 16 colors:
LDX #$00
LDA #$A0 ; Block character
FILL: STA $0400,X
STA $0500,X
STA $0600,X
STA $0700,X
TXA
LSR
LSR
LSR
LSR ; Divide by 16 for color bands
STA $D800,X
STA $D900,X
STA $DA00,X
STA $DB00,X
INX
BNE FILL
RTS
Both programs work—you can dial into the 6502 video backend, watch the demo run, and see colors and characters render in your terminal. It’s surreal to watch PETSCII graphics appear over a simulated phone line.
The VIC-20 Variant
The VIC-20 uses the VIC (Video Interface Chip, the predecessor to VIC-II*). It has a smaller display—22×23 characters by default—and different memory locations:
- Screen RAM: $1E00-$1FFF (varies with memory expansion)
- Color RAM: $9600-$97FF
- VIC registers: $9000-$900F
The adapter handles both by detecting which machine is being emulated and adjusting the memory ranges and screen dimensions accordingly. The PETSCII-to-Unicode and color mappings are nearly identical.
What We Didn’t Emulate
The VIC-II can do much more than text mode:
- Bitmap graphics: 320×200 or 160×200 multicolor
- Sprites: 8 hardware sprites with collision detection
- Smooth scrolling: Hardware scroll registers
- Raster interrupts: Trigger at specific scan lines
None of this makes sense for serial output. You can’t stream a 320×200 bitmap at 300 baud in real-time. Text mode is the sweet spot—it’s what people actually used for telecommunications in the 80s, and it’s what our emulated modems can handle.
The Authenticity Problem
There’s a philosophical question here: is this authentic? A real C64 connected to a modem would use terminal software—a program that sends typed characters and displays received characters. It wouldn’t stream its video memory over the phone line.
But that’s what makes this project interesting. We’re not recreating exactly what was possible in 1984. We’re creating what feels like 1984 while taking advantage of modern capabilities. A user connecting to the C64 video backend sees a C64-style screen, with C64 colors and PETSCII characters, rendered in their browser’s terminal emulator. The illusion is maintained even though the implementation is completely different.
Lessons Learned
Dirty flags are essential. Without change detection, the system renders constantly whether anything changed or not. With it, a static screen is free.
Color state tracking pays off. ANSI escape sequences are verbose. Only emitting them when colors change reduces bandwidth significantly.
PETSCII is weird. The character set is a product of its time—designed for the C64’s ROM, not for international communication. The Unicode mappings are approximate but good enough.
30 FPS is plenty. Text doesn’t need 60 FPS. At 30 frames per second, updates feel smooth and the CPU overhead is minimal.
The video system was one of the later additions to the emulator, but it opened up a whole category of backends—anything with a character-mode display can now work over our serial infrastructure. Which, given how many 80s computers had character-mode displays, means there’s a lot more to emulate.