Skip to main content
Back to articles
2026 / 02
| 5 min read

Video Chips and Serial Streams: Rendering C64 Graphics Over a Modem

Emulating the VIC-II's text mode and forwarding its screen memory as ANSI escape sequences over a serial connection.

emulator video 6502 commodore ansi rust typescript
On this page

Someone dials into the emulated Commodore 64 over a simulated 300-baud modem and expects text. The C64 has a video chip that renders to a framebuffer. I stared at that gap for a while before the shape of it came clear.

The video chip doesn’t matter. The memory does. A 6502 program writes bytes to screen RAM, and a terminal somewhere needs to display characters. Everything in between is translation.


Memory and a Dirty Flag

For text mode, the VIC-II state I care about 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 $D020 and $D021
  • Display enable bit at $D011 bit 4

The 6502 core in Rust tracks writes to these addresses and sets a dirty flag when something changes. That flag is the contract between the CPU and the video layer.

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
    }
}

Comparing before assigning matters. Screen-clearing loops write the same value repeatedly, and without that check every one of them would trigger a render.


The Adapter Translates, It Doesn’t Render

The video adapter doesn’t build ANSI strings. It writes into a VirtualTerminal buffer, and the terminal produces the ANSI diffs that get streamed over serial.

The terminal already knows how to generate minimal updates; it tracks which cells changed since the last flush. So the adapter’s job is smaller: 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, converts the palette index to an ANSI code, and hands the result to the terminal buffer. The adapter owns translation; the terminal owns diffing.


PETSCII and Colour: The Lossy Middle

PETSCII isn’t ASCII. The C64 boots into uppercase and graphics characters, control codes live in different places, and there’s a set of box-drawing glyphs that don’t map cleanly to Unicode. The adapter builds a lookup table at initialization and falls back to ? for anything unmapped, which is at least debuggable.

Colour is a separate problem. The C64 palette is 16 specific RGB values; ANSI terminals have 16 named slots, and they don’t line up. The adapter approximates by looking at dominant channels and brightness.

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 right, and neither does light blue. The program stays readable, which is what a text BBS terminal needs.


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, only the changed cells go out. That delta comes from VirtualTerminal.flush(), which compares the current buffer against the last flushed state and emits the minimal ANSI to reconcile them.

Text doesn’t need 60 FPS, but it does need continuity — a cursor that blinks, a prompt that appears without lag. Thirty is enough for that without flooding a slow link.


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 (assuming expansion RAM) instead of $0400
  • Colour RAM at $9600 instead of $D800
  • Foreground colours restricted to 0–7 (masked with & 0x07)

The PETSCII mapping table is identical to the C64’s — both machines use the same character codes, so the only real differences are the addresses, the grid size, and one bitmask. That’s the advantage of the translator model.


What We Don’t Emulate (By Design)

A real VIC-II does far more than text mode: sprites, raster interrupts, smooth scrolling, bitmap graphics, character set switching via $D018. None of that survives a 300-baud serial link.

The adapter is text-only, assumes the C64’s default uppercase/graphics character set, and ignores the reverse-video bit (bit 7 of screen RAM), which is a real hardware feature living at a different abstraction layer. The target is a minimal ANSI terminal, not cycle-accurate video.

Drawing that boundary was clarifying. “How do I emulate a VIC-II?” is a much harder question than “how do I translate 1000 bytes of memory into terminal output?”


Where This Sits in the System

When a 6502 program executes STA $0400 it isn’t drawing pixels; it’s mutating a byte. The core sets a flag. Thirty times a second the video backend checks that flag, and 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 down the serial layer.

The pipeline works for any platform that keeps its screen state in a known memory region, which is how the VIC-20 came along for free. The VIC-II was designed to drive a CRT and I needed it to drive a modem, and it turned out I didn’t need the VIC-II at all — just something that speaks PETSCII on one side and ANSI on the other.