Skip to main content
Back to articles
2026 / 01
| 9 min read

Deep Dive: PETSCII and the VIC-II Text Adapter

Translating PETSCII screen memory into ANSI terminal output: character maps, colour approximation, and the minimal contract a terminal-based VIC‑II can keep.

emulator c64 petscii ansi terminal
On this page

The first time the terminal printed **** COMMODORE 64 BASIC V2 ****, I sat looking at it for a while. Those characters came out of screen RAM at $0400, went through a PETSCII lookup table, and arrived as Unicode glyphs wrapped in ANSI escape codes. No pixels, no framebuffer, no canvas. What I had written was not a display emulator; it was a translation layer between two text systems forty years apart, which is not what I set out to build.

So this is not a faithful VIC-II. It is the narrow version that works: character mapping, colour approximation, and a set of constraints that keep the adapter honest about what it cannot do.

Why Not Just Emulate the VIC-II Properly?

I tried that first. The plan was to intercept every VIC-II register write, keep a raster counter, and render bitmap frames. It lasted about two days, until it was obvious I was building a graphics emulator for a target with no pixels.

A terminal has no raster interrupt, no sprite collision, and no opinion about $D011. So I backed up to a smaller question: what is the minimum I have to preserve for 6502 programs to be visible in a text terminal?

Not much, as it turns out. Read video memory, translate bytes to glyphs and colour indices to ANSI codes, emit the result. No scanlines, no character ROM, no raster timing: a pure function from memory state to terminal output. It felt like giving up at the time. It is also what makes the adapter testable, because the 6502 program sees the memory addresses it expects, the terminal sees the escape sequences it expects, and nothing in between invents state that neither side can check.

PETSCII: 256 Codes In, Mostly Question Marks Out

PETSCII is Commodore’s character encoding, and it is a strange cousin of ASCII: where ASCII has control codes, PETSCII has graphics characters — hearts, diamonds, diagonal lines. I spent a while with reference charts trying to work out how to map all of it to Unicode. You can’t, not completely.

Here’s what I ended up with for the box-drawing characters that actually matter:

// web/src/video/c64-text-adapter.ts
map[0x60] = '━'; // Horizontal line
map[0x7b] = '┼'; // Cross
map[0x7c] = '│'; // Vertical line
map[0xa0] = '█'; // PETSCII solid block (inverse space)
map[0xc0] = '─'; // Horizontal bar
map[0xe0] = '┌'; // Top-left corner
map[0xee] = '└'; // Bottom-left corner
map[0xf2] = '┐'; // Top-right corner
map[0xfd] = '┘'; // Bottom-right corner

The mapping is deliberately sparse; I only handle the characters I actually need. Printable ASCII (0x20–0x7E) maps directly. Lowercase letters live at 0x61–0x7A. Box-drawing characters get their Unicode equivalents. Everything else becomes a question mark.

That ? is a choice, not a bug, though a screen full of them looked like failure the first few times. When a C64 program draws a heart or a diagonal line with no Unicode equivalent, the question mark says “this character exists and I cannot show it” instead of dropping it silently or guessing wrong.

Control codes (0x00–0x1F) map to spaces, which is more of a cop-out. On real hardware they move the cursor, change colours, and toggle reverse video. The adapter can’t execute them because a terminal render has no cursor state between frames: each render is a complete redraw from memory. I don’t have a better answer that fits the constraints I set.

The Video Memory Structure (and the Dirty Flag)

The 6502 core exposes C64 video state through a small Rust struct:

// cores/mos6502/src/video.rs
pub struct VideoState6502 {
    pub screen_ram: [u8; 1024],
    pub color_ram: [u8; 1024],
    pub border_color: u8,
    pub background_color: u8,
    pub display_enabled: bool,
    pub dirty: bool,
}

The memory map is fixed: screen RAM at $0400–$07FF (1024 bytes for a 40×25 grid, though only 1000 cells are used), colour RAM at $D800–$DBFF, border and background colours at $D020 and $D021.

The dirty flag went in almost as an afterthought and turned out to matter more than the rest. A write to screen RAM, colour RAM, or any VIC-II register sets it. The adapter checks it before each render and skips unchanged frames. For a BASIC prompt blinking a cursor once a second, 59 frames out of 60 are free. I had been rendering every frame and wondering why performance was so bad.

I later made the flag stricter — set it only when a write actually changes the value, not on every write — which the commit message records as 10–30% fewer render triggers. Whether that was worth the extra branch on every write, I’m not convinced either way.

Rendering: A Nested Loop with Some Fiddly Bits

The render loop itself is boring, which is probably the point:

renderToANSI(vterm: VirtualTerminal): void {
  if (!this.dirty) return;

  const bgHex = C64_PALETTE[this.backgroundColor & 0x0f];
  const bgAnsi = hexToAnsiBg(bgHex);

  for (let y = 0; y < this.mode.height; y++) {
    for (let x = 0; x < this.mode.width; x++) {
      const offset = y * this.mode.width + x;
      const petscii = this.screenData[offset];
      const colorIndex = this.colorData[offset] & 0x0f;

      const char = this.petsciiToChar(petscii);
      const fgHex = C64_PALETTE[colorIndex];
      const fgAnsi = hexToAnsi(fgHex);

      vterm.putChar(x, y, char, { fg: fgAnsi, bg: bgAnsi });
    }
  }

  this.dirty = false;
}

For each of 1000 cells: read the PETSCII code, look up the glyph, read the colour index, look up the ANSI code, emit. The background colour applies uniformly because it comes from the VIC-II register rather than per-cell colour RAM.

The & 0x0f mask on the colour index is easy to miss, and I missed it. Colour RAM stores 8 bits per cell but only the low nibble is valid; on real hardware the high bits are open bus, so a program reading colour RAM can see garbage in the upper nibble. The mask makes the render consistent regardless of what the 6502 program wrote there. It cost me an hour of chasing colour glitches that changed between runs.

Colour Translation: Embracing the Loss

The C64 palette has 16 colours. ANSI terminals have 16 colours. They are not the same 16. The adapter approximates by looking at each hex colour’s brightness and dominant channels:

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;

  const max = Math.max(r, g, b);
  const threshold = max * 0.6;
  const isRed = r >= threshold;
  const isGreen = g >= threshold;
  const isBlue = b >= threshold;

  if (isRed && isGreen && isBlue) return bright ? 97 : 37;
  if (isRed && isGreen) return bright ? 93 : 33;
  // ... and so on
}

The mapping is deterministic and lossy. C64 cyan (#AAFFEE) and light blue (#0088FF) both have strong blue, but cyan also has green, so they land on different ANSI codes. Orange (#DD8855) has red and green, so it becomes yellow. Brown (#664400) is dark red plus green, so it becomes dark yellow, which most terminals render as brown anyway.

The goal isn’t colour accuracy — I chased that for a while and it was a mess. The goal is consistent tone: a program that uses contrasting colours should still show contrast in the terminal, even if the hues shift. It looks right to me, but I’ve been looking at it long enough that I’m not a reliable judge.

The VIC-20: Same Problem, Smaller Canvas

I expected VIC-20 support to be a rewrite. It’s nearly the same adapter: same PETSCII encoding, same memory-mapped approach, a smaller display (22×23 instead of 40×25), and a more restricted palette.

The entire difference in colour handling is one mask:

// web/src/video/vic20-text-adapter.ts
const colorIndex = this.colorData[offset] & 0x07;

That & 0x07 instead of & 0x0f encodes the VIC-20’s entire foreground colour constraint. The VIC chip can only display 8 foreground colours; indices 8–15 are for background and auxiliary use. I had budgeted a day for this and spent about ten minutes.

The Things I Decided Not to Do

Some of what’s missing is deliberate, and worth naming:

Reverse video. On real hardware, setting bit 7 of a screen RAM byte swaps foreground and background for that cell. I ignore it because I treat the full byte as a lookup key. Doing it properly means tracking per-cell state the terminal can’t persist between renders, and that makes the adapter stateful.

Character set switching. The C64 can toggle between the uppercase/graphics and lowercase/uppercase character sets through a VIC-II register. I assume the default set. Supporting both doubles the lookup table for something BASIC programs rarely touch, and it will bite me the first time someone runs a program that expects the other one.

Control code execution. PETSCII control codes embedded in screen RAM render as spaces. The adapter has no cursor to move and no colour state to change, because each frame is a stateless translation. This is the one I’m least sure about.

Listing these isn’t an apology. It makes the boundary visible, so that when output looks wrong you know whether you found a bug or a documented limit.

Testing: How Do You Validate a Translation?

I built ten test patterns that stress different parts of the adapter: horizontal stripes for colour RAM, vertical stripes for column rendering, a checkerboard for spatial accuracy. Each pattern draws to video memory, renders to ANSI, and checks the output numerically.

The checkerboard expects exactly 500 blocks. The full palette pattern expects at least 8 unique ANSI colour codes, since the C64’s 16 collapse to roughly 10 under the lossy mapping. The border frame expects exactly 126 blocks (40×2 + 25×2 − 4 corners).

These tests can’t check fidelity, and they aren’t meant to. They check that the translation is stable — same input, same output — and that the output keeps enough structure for a human to recognize what the program drew.

The Full Path (For Reference)

Getting from a 6502 instruction to a terminal glyph crosses several boundaries:

The 6502 core (Rust, compiled to WASM) executes instructions and writes to memory-mapped video addresses. The video state struct holds screen RAM, colour RAM, registers, and the dirty flag. The text adapter (TypeScript) reads that state and translates PETSCII to Unicode and C64 colours to ANSI codes. The virtual terminal buffers the output and emits escape sequences. The host terminal interprets them and draws glyphs.

Each layer has a narrow contract. The core doesn’t know about ANSI, the adapter doesn’t know about escape sequences, and the virtual terminal doesn’t know about PETSCII. That is what makes each piece testable on its own, and it means a wrong glyph on screen points at exactly one layer.

What the Adapter Leaves Out

I set out to build a video emulator and ended up with a protocol translator. It doesn’t simulate a VIC-II; it preserves enough of the original semantics for 6502 programs to be visible without a canvas.

The hard part wasn’t technical. It was deciding what to throw away. Reverse video and control code execution both need per-cell or per-frame state, and the stateless render model is what keeps the adapter cheap to reason about. Each omission is a statement about what the contract covers, and writing them down changed how I read the rest of the code: when a screen looks wrong, the first question is now which contract was broken, not which pixel.