Deep Dive: PETSCII and the VIC-II Text Adapter
Translating Commodore's weird character encoding and VIC-II video memory into ANSI terminal output—box drawing, color mapping, and 40-column nostalgia.
Deep Dive: PETSCII and the VIC-II Text Adapter
The first time I saw a C64 emulator running in a browser, the video was rendered pixel-by-pixel to a canvas. It looked great, but it was also fundamentally incompatible with what I wanted to build—a terminal-based system where everything flows through a serial line.
So I had to answer a question that shouldn’t really need answering in 2026: how do you make a VIC-II display appear inside a terminal emulator? The answer turned out to be a weird little translation layer that converts PETSCII screen memory into ANSI escape sequences. It works better than it has any right to.
What Even Is PETSCII?
PETSCII—PET Standard Code of Information Interchange—is Commodore’s character encoding. It predates the C64, going back to the original PET in 1977. If ASCII was designed by pragmatists, PETSCII was designed by someone who thought “wouldn’t it be neat if we could draw boxes?”
The encoding overlaps with ASCII in the printable range (0x20-0x3F and 0x41-0x5A), but it goes completely off-script after that. Lowercase letters live in the 0x61-0x7A range, but they’re also available at 0xC1-0xDA in uppercase-graphics mode. There are two display modes—uppercase/graphics and lowercase/uppercase—and they swap which character set occupies which range.
Then there’s the graphics characters. PETSCII has a full set of box-drawing characters, block elements, diagonal fills, and card suits. These occupy the 0x60-0x7F and 0xA0-0xFF ranges, and they’re the reason every C64 program from the era had that distinctive angular aesthetic.
// PETSCII to ASCII/Unicode character mapping
// The highlights of the weird parts:
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
That mapping above is from web/src/video/c64-text-adapter.ts. It’s not complete—there are 256 possible PETSCII codes—but it covers the characters that matter for most text-mode programs. Everything unmapped gets a ? placeholder, which is honest if nothing else.
(The VIC-20 adapter uses nearly identical mappings. The main difference is that the VIC-20 used £ where ASCII has \. Very British, that.)
The VIC-II Memory Map (Or: Where the Screen Lives)
The VIC-II chip in the C64 is a remarkable piece of engineering for 1982. It supports sprites, smooth scrolling, raster interrupts, and multiple graphics modes. For text mode—which is what we care about—the relevant bits are:
| Address Range | Description | Default Value |
|---|---|---|
| $0400-$07FF | Screen RAM (40×25 characters) | $20 (spaces) |
| $D800-$DBFF | Color RAM (4-bit color per cell) | $0E (light blue) |
| $D020 | Border color register | $0E (light blue) |
| $D021 | Background color register | $06 (blue) |
| $D011 | Display control (bit 4 = enable) | $1B (enabled) |
Screen RAM holds 1000 bytes (40 columns × 25 rows = 1000, rounded up to 1024 for nice address alignment). Each byte is a PETSCII character code. Color RAM holds the corresponding foreground color for each character—just the low 4 bits matter, giving you 16 possible colors.
Here’s what that looks like in Rust, from the 6502 core:
pub struct VideoState6502 {
/// Screen character memory ($0400-$07FF): 40x25 PETSCII characters
pub screen_ram: [u8; 1024],
/// Color memory ($D800-$DBFF): 4-bit color values per character
pub color_ram: [u8; 1024],
/// VIC-II border color register ($D020): 4-bit color index
pub border_color: u8,
/// VIC-II background color register ($D021): 4-bit color index
pub background_color: u8,
/// Display enable flag from $D011 bit 4
pub display_enabled: bool,
/// Dirty flag indicating video state has changed since last render
pub dirty: bool,
}
That dirty flag is important. We don’t want to re-render 1000 characters 30 times per second if nothing changed. The adapter checks the dirty flag, skips rendering if false, and clears it after rendering if true. It’s a simple optimization, but it matters when you’re bridging a WASM core to a terminal emulator.
The 16-Color Palette
The C64’s palette is distinctive. If you grew up with one, you can probably identify the colors by instinct. They’re not quite RGB primary colors—there’s a warmth to them that came from the NTSC/PAL encoding and the particular phosphors of the era’s monitors.
export const C64_PALETTE: string[] = [
'#000000', // 0 - Black
'#FFFFFF', // 1 - White
'#880000', // 2 - Red
'#AAFFEE', // 3 - Cyan
'#CC44CC', // 4 - Purple
'#00CC55', // 5 - Green
'#0000AA', // 6 - Blue
'#EEEE77', // 7 - Yellow
'#DD8855', // 8 - Orange
'#664400', // 9 - Brown
'#FF7777', // 10 - Light Red
'#333333', // 11 - Dark Grey
'#777777', // 12 - Grey
'#AAFF66', // 13 - Light Green
'#0088FF', // 14 - Light Blue
'#BBBBBB', // 15 - Light Grey
];
The problem is that ANSI terminals only have 8 standard colors, plus 8 bright variants (16 total), and they don’t map 1:1 to the C64 palette. The adapter has to make judgement calls.
function hexToAnsi(hex: string): number {
// Parse hex color
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
// Calculate brightness
const brightness = (r + g + b) / 3;
const bright = brightness > 128;
// Determine dominant color
const max = Math.max(r, g, b);
const threshold = max * 0.6;
const isRed = r >= threshold;
const isGreen = g >= threshold;
const isBlue = b >= threshold;
// Map to ANSI foreground codes (30-37, 90-97)
if (isRed && isGreen && isBlue) {
return bright ? 97 : 37; // White/gray
} else if (isRed && isGreen) {
return bright ? 93 : 33; // Yellow
} else if (isRed && isBlue) {
return bright ? 95 : 35; // Magenta
} else if (isGreen && isBlue) {
return bright ? 96 : 36; // Cyan
} else if (isRed) {
return bright ? 91 : 31; // Red
} else if (isGreen) {
return bright ? 92 : 32; // Green
} else if (isBlue) {
return bright ? 94 : 34; // Blue
} else {
return 30; // Black
}
}
It’s a lossy conversion—C64 Brown becomes dark yellow, C64 Orange becomes bright red—but it’s close enough that text-mode programs look recognizable. The alternative would be to use 256-color mode or true color ANSI, but that’s less compatible with minimal terminals.
The Text Adapter Pattern
The actual adapter implements a VideoAdapter interface that’s shared between the C64 and VIC-20 backends:
export interface VideoAdapter {
updateFromCore(core: any): boolean;
renderToANSI(vterm: VirtualTerminal): void;
getVideoMode(): VideoMode;
reset(): void;
}
The C64 adapter reads video state from the WASM core, translates it, and writes to a VirtualTerminal. Here’s the render loop:
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;
}
It’s not complicated, which is the point. The complexity lives in the PETSCII mapping and color conversion; the actual rendering is just a nested loop.
The VIC-20 Variant
The VIC-20 uses the same PETSCII encoding but with different hardware constraints:
- 22×23 character display (vs. C64’s 40×25)
- 8 foreground colors only (colors 8-15 restricted to backgrounds)
- Screen RAM at $1000 (with expansion) instead of $0400
- Color RAM at $9600 (in the VIC chip register space)
export class VIC20TextAdapter implements VideoAdapter {
private mode: VideoMode = {
width: 22,
height: 23,
colorDepth: 4,
type: 'text',
};
// ... same PETSCII mapping ...
renderToANSI(vterm: VirtualTerminal): void {
// ...
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];
// VIC-20: only colors 0-7 valid for foreground
const colorIndex = this.colorData[offset] & 0x07;
// ...
}
}
}
}
The mask changes from & 0x0f to & 0x07 because the VIC-20 only allows 8 foreground colors. That’s it. The rest of the logic is identical.
Edge Cases and Weirdness
Control Characters (0x00-0x1F)
PETSCII control codes include cursor movement, color changes, and other screen operations. In the original hardware, writing these to the screen would execute them. Our adapter just maps them to spaces:
// Control characters and spaces (0x00-0x1F)
for (let i = 0x00; i <= 0x1f; i++) {
map[i] = ' '; // Map control codes to space
}
This is technically wrong—a real C64 wouldn’t display anything for control codes—but it’s the safest behavior for a text adapter. If we tried to interpret control codes in screen RAM, we’d have to track cursor state, and that’s a different project.
Reverse Video (Bit 7)
On a real C64, setting bit 7 of a character code displays that character in reverse video (foreground/background swapped). Our adapter doesn’t currently handle this—it just maps the full 8-bit code. Characters 0x80-0xFF get their own mappings in the table, which works for graphics characters but isn’t quite right for reversed text.
(Adding proper reverse video support would mean checking bit 7, stripping it for the character lookup, and swapping fg/bg colors. It’s not hard, just not done yet.)
Uppercase/Graphics Mode
The C64 has two character sets that can be switched at runtime by writing to $D018. Our adapter assumes uppercase/graphics mode, which is the default at boot. Supporting the lowercase/uppercase mode would require maintaining two PETSCII mapping tables and reading the character set register.
Unknown Characters
Anything not in the mapping table gets a ? placeholder:
private petsciiToChar(code: number): string {
return this.petsciiMap[code] || '?';
}
It’s honest. You’ll see question marks where the adapter doesn’t know what to show. The alternative—rendering nothing—would be more confusing.
How It All Fits Together
The data flow looks like this:
6502 Assembly Program
↓
[STA $0400] (write to screen RAM)
↓
Rust Video State (dirty flag set)
↓
WASM Memory Access (getVideoScreen, getVideoColors)
↓
TypeScript Video Adapter (PETSCII → Unicode, C64 → ANSI)
↓
VirtualTerminal (putChar with color attributes)
↓
ANSI Escape Sequences
↓
xterm.js Terminal
↓
Browser DOM
Each layer does one thing. The 6502 code writes to memory addresses it knows. The Rust core tracks what changed. The TypeScript adapter translates encodings. The terminal renders ANSI. No layer needs to understand the layers above or below it.
Test Patterns
We have a test pattern library that exercises the video system comprehensively. Each pattern has a draw() method that writes to video memory and a validate() method that checks the ANSI output:
export const ALL_PATTERNS: TestPattern[] = [
new HorizontalStripesPattern(), // 25 rows of different colors
new VerticalStripesPattern(), // 40 colored columns
new CheckerboardPattern(), // Alternating blocks and spaces
new BorderFramePattern(), // Solid border around edges
new TextRenderingPattern(), // All printable ASCII/PETSCII
new ColorGradientPattern(), // Grayscale fade
new QuadrantsPattern(), // 4 colored regions
new SingleCellAnimationPattern(), // One modified cell
new BoxDrawingPattern(), // PETSCII graphics characters
new FullPalettePattern(), // All 16 colors with labels
];
The validation is mathematical—we count block characters (should be exactly 500 for a checkerboard on a 1000-cell screen), verify color diversity, and check for specific Unicode box-drawing characters. If the adapter breaks, the tests catch it.
What’s Missing
The current implementation is text-mode only. The VIC-II can do:
- Bitmap graphics mode (320×200 or 160×200)
- Hardware sprites (8 movable objects)
- Smooth scrolling (per-pixel, not per-character)
- Raster interrupts (effects that change mid-screen)
None of that works with a terminal emulator. Adding graphics support would mean canvas rendering, which defeats the purpose of the terminal-based architecture. For now, if you want to run a C64 game with sprites, you’ll need a different emulator.
Why This Matters
I mentioned in the January 24 journey post that this adapter makes “real hardware” visible inside a modern browser. That’s the goal.
When you dial into the C64 video backend and see **** COMMODORE 64 BASIC V2 **** appear on screen, you’re not looking at pre-rendered text or a video stream. You’re looking at a 6502 CPU executing real code, writing to real (emulated) video memory, which gets translated through the PETSCII adapter into ANSI escape sequences that your terminal understands.
It’s a lot of layers to accomplish something that seems simple. But each layer is testable, replaceable, and understandable in isolation. That’s the architecture I wanted.
Related Files
- C64 Adapter:
web/src/video/c64-text-adapter.ts - VIC-20 Adapter:
web/src/video/vic20-text-adapter.ts - Color Palettes:
web/src/video/palette.ts - Rust Video State:
cores/mos6502/src/video.rs - C64 Video Backend:
web/src/backend/6502-video/index.ts - Test Patterns:
web/src/backend/vic20-video/test-patterns.ts - Unit Tests:
web/src/video/c64-text-adapter.test.ts
Previous: 2026-01-24 — Emulation milestones and TypeScript migration