RIFT64 // REMOTE_GATEWAY

METATILES

High-Efficiency Screen Compression & Map Rendering

🖥️ V1 Protocol Beta
🏎️ SwiftLink 38400+ Baud
💾 Legacy C64/C128 & Replicas

The Bandwidth Problem & The Metatile Cure

When building real-time, remote-rendered games on the Commodore 64, your biggest bottleneck is the serial transfer speed. Standard C64 screens comprise exactly 1000 character cells (40 columns by 25 rows) of Screen RAM and a parallel 1000 bytes of Color RAM.

To redraw a complete screen over a high-speed 38400 baud serial connection takes over half a second. This is too slow to support fluid side-scrolling, map transitions, or full-grid rendering in fast action games.

🎯 The 10:1 Compression Cure: Metatiles

Instead of sending 1000 individual character write commands to the screen, we group character cells into pre-configured 2x2 Metatiles (representing a 2x2 grid of 4 character cells, i.e., a 16x16 pixel block).

By using metatiles, the server compresses the entire 40x25 character grid into a incredibly light 20x12 tile matrix. Instead of sending 1000 bytes, the server only needs to stream 240 bytes over the wire! This yields an immediate, massive data compression ratio, allowing high-performance, fluid map scrolling and instantaneous screen transitions over a slow serial link!

The Metatile Layout Technique

RIFT64 actually exposes three related rendering modes via the D command, all driven by the same MT_RenderWindow routine in metatile.asm:

Parallel Slot-Page Storage

The client does not store metatile cells as 4 (or 9) contiguous bytes per id. Instead it uses parallel slot pages: each cell position in the tile gets its own page-aligned 256-byte table indexed by tile id, so look-up at render time is a single absolute-Y load with no multiply.

For a 2×2 metatile bank, you upload 4 contiguous pages (1 KB total) starting at a page-aligned address $HH00. Only the high byte HH is given to the renderer (metaHi):

   Bank base = $HH00     ┌──────────────────────────┐
   ──────────────────────│  Slot 0  (Top-Left)      │  256 bytes, indexed by tile id
   $HH00 + id            └──────────────────────────┘
   ──────────────────────┌──────────────────────────┐
   $HH00 + $0100 + id    │  Slot 1  (Top-Right)     │
   ──────────────────────└──────────────────────────┘
   ──────────────────────┌──────────────────────────┐
   $HH00 + $0200 + id    │  Slot 2  (Bottom-Left)   │
   ──────────────────────└──────────────────────────┘
   ──────────────────────┌──────────────────────────┐
   $HH00 + $0300 + id    │  Slot 3  (Bottom-Right)  │
   ──────────────────────└──────────────────────────┘

Mode 3 follows the same scheme with 9 pages (2.25 KB) laid out row-major: slots 0/1/2 = top row, 3/4/5 = middle row, 6/7/8 = bottom row. Mode 1 has no metatile bank — the renderer reads characters directly from the map.

The D Command in One Round Trip

A single D (Draw Metatile) command renders an entire window of the map in one ACK‑validated round trip. You give it the map address and dimensions, the metatile bank's high byte, the source map offset (OffX, OffY), the destination screen coordinate / stride, the window size in characters, and a fill character used for any cells that fall outside the map. Optionally you can also drive colour RAM in four modes: NONE, FILL (one colour everywhere), MAP (a parallel colour table; for modes 2/3 it's a 256-byte colour-per-tileId lookup, for mode 1 a per-cell colour map), or PERCELL (mode-2 only; a 1 KB page-aligned bank giving each child cell of every metatile id its own colour).

Sub-Tile Scroll Offsets

OffX and OffY are character-cell offsets inside the first metatile, clamped by the client to the range 0 .. (mode − 1). They let you nudge the camera by half a tile (mode 2) or by 1 / 2 of a tile (mode 3) without redrawing the map. To scroll further you advance the source mapAddr (or x/y) by whole tiles and reset the offset.

Implementing Metatiles with the C# SDK

The full SDK signature mirrors the asm config block one-to-one:

Task<bool?> DrawMetatileAsync(
    byte mode,           // 1 = 1x1, 2 = 2x2, 3 = 3x3
    ushort mapAddr,     // base of the tile-id map in C64 RAM
    byte mapW, byte mapH,
    byte metaHi,         // hi byte of page-aligned metatile bank
    ushort targetAddr,  // usually $0400 (screen RAM)
    byte stride,         // usually 40
    byte winW, byte winH, // window size in CHARACTER cells
    byte x, byte y,       // destination top-left in screen cells
    byte offX, byte offY, // sub-tile char offset, 0..(mode-1)
    byte fillChar = 32,
    byte colorMode = 0,  // 0=NONE, 1=FILL, 2=MAP
    ushort colorTgtAddr = 0xD800,
    ushort colorSrcAddr = 0,
    byte colorFill = 14,
    TimeSpan timeout = default);

Step 1: Upload a 2×2 Metatile Bank to RAM

Build the bank as four parallel 256-byte slot pages — one page per cell position — and upload them to a page-aligned address. Index id in each page is the character code for that corner of tile id.

// Bank base must be page-aligned. We'll use $4000.
const ushort bankBase = 0x4000;
var bank = new byte[4 * 256];

// Tile 0 = "grass" : TL=$30 TR=$30 BL=$31 BR=$31
bank[0 * 256 + 0] = 0x30; bank[1 * 256 + 0] = 0x30;
bank[2 * 256 + 0] = 0x31; bank[3 * 256 + 0] = 0x31;

// Tile 1 = "brick" : all four corners $A0
bank[0 * 256 + 1] = 0xA0; bank[1 * 256 + 1] = 0xA0;
bank[2 * 256 + 1] = 0xA0; bank[3 * 256 + 1] = 0xA0;

// Push the four pages up to the C64 (StoreMemoryLargeCheckedAsync chunks & verifies)
await client.StoreMemoryLargeCheckedAsync(bankBase, bank);

Step 2: Upload a Tile-ID Map and Render a Window

The map is just a flat array of tile ids (one byte per tile). A single D command then draws as much of that map as fits in the requested window — one packet, one ACK.

// 20x12 tile map = covers the full 40x24 character region in mode 2
const ushort mapAddr = 0x6000;
var map = new byte[20 * 12]; // fill with tile ids 0/1/...
await client.StoreMemoryLargeCheckedAsync(mapAddr, map);

// Draw the entire 20x12 metatile map (= 40x24 chars) at screen (0,0).
await client.DrawMetatileAsync(
    mode:        2,
    mapAddr:     mapAddr,
    mapW:        20, mapH: 12,
    metaHi:      0x40,            // bankBase $4000 -> hi byte $40
    targetAddr:  0x0400,
    stride:      40,
    winW:        40, winH: 24,    // window size in CHARACTERS
    x:           0,  y:    0,
    offX:        0,  offY: 0,
    fillChar:    32,
    colorMode:   1,               // FILL all cells with one colour
    colorFill:   (byte)Rift64Color.LightGreen,
    timeout:     TimeSpan.FromSeconds(2));

Smooth Scrolling

OffX/OffY shift the camera by one character cell at a time within the first tile. To scroll past a whole tile you reset the offset and advance the source map — either by passing a new mapAddr base, or by reissuing the same packet with the same map and shifting x/y by one tile in the opposite direction. The map and metatile bank stay in C64 RAM — you only resend the 20-byte D packet.

// Pixel-level horizontal scroll across a 64-tile-wide world (mode 2).
int cameraCharsX = 0;            // scroll position in CHARACTER cells (= half-tiles)
while (running)
{
    byte tileX = (byte)(cameraCharsX >> 1); // whole tiles already scrolled
    byte offX  = (byte)(cameraCharsX & 1);  // 0 or 1: sub-tile shift

    await client.DrawMetatileAsync(
        mode:       2,
        mapAddr:    (ushort)(mapAddr + tileX), // advance source by whole tiles
        mapW:       64, mapH: 12,
        metaHi:     0x40,
        targetAddr: 0x0400, stride: 40,
        winW:       40, winH: 24,
        x:          0,  y:    0,
        offX:       offX, offY: 0,
        timeout:    TimeSpan.FromMilliseconds(50));

    cameraCharsX++;
    await Task.Delay(20);
}

Worked Example: How One Cell Is Resolved (Mode 2)

Suppose mapAddr = $6000, mapW = 20, metaHi = $40, and the render is asked to draw the cell at character position (2, 3) in the destination window with OffX/OffY = 0:

  1. Tile column & row: tx = 2 >> 1 = 1, ty = 3 >> 1 = 1.
  2. Tile id read from the map: id = peek($6000 + ty * 20 + tx) = peek($6014).
  3. Sub-tile cell: cx = 2 & 1 = 0, cy = 3 & 1 = 1 → slot cy*2 + cx = 2 (Bottom-Left).
  4. Character code: peek($4000 + slot*$0100 + id) = peek($4200 + id).
  5. Written to $0400 + 3*40 + 2 = $047A.

Mode 1 Example — Raw HUD / Dialog Blit

Mode 1 treats every map byte as a screen character code, so it's perfect for pre-rendered overlays: a status bar, a popup dialog, or a PETSCII title card. Pair it with colorMode = 2 (MAP) to get a parallel per-cell colour overlay.

// 16x4 dialog box. Map is 16*4 = 64 chars; colour map is parallel 64 bytes.
const ushort dlgChars  = 0x6800;
const ushort dlgColors = 0x6840;

var chars  = new byte[16 * 4];
var colors = new byte[16 * 4];
// ... fill chars/colors with your dialog content ...

await client.StoreMemoryLargeCheckedAsync(dlgChars,  chars);
await client.StoreMemoryLargeCheckedAsync(dlgColors, colors);

// Pop the dialog at screen (12, 10), 16x4 chars in size.
await client.DrawMetatileAsync(
    mode:           1,
    mapAddr:        dlgChars,
    mapW:           16, mapH: 4,
    metaHi:         0,                // not used in mode 1
    targetAddr:     0x0400, stride: 40,
    winW:           16, winH: 4,      // chars (mode 1: same as map)
    x:              12, y: 10,
    offX:           0,  offY: 0,
    fillChar:       32,
    colorMode:      2,                // MAP - parallel per-cell colour
    colorTgtAddr:   0xD800,
    colorSrcAddr:   dlgColors,
    timeout:        TimeSpan.FromSeconds(1));

Tip: Snapshot the screen with SaveScreenBufferAsync(0) first, draw the dialog, then call RestoreScreenBufferAsync(0) to dismiss it — no need to re-stream the background.

Mode 3 Example — 3×3 Overworld Tiles

Mode 3 expands every map byte into a 3×3 character grid (24×24 px). The bank is 9 parallel pages (2.25 KB), so a single 256-id bank gives you 256 large, distinct tiles. With a 13×8 map you cover the entire 39×24 character viewport (104 map bytes for ~768 cells — nearly 8× compression).

// 9-page bank at $4000. Each page = one cell position in the 3x3 grid.
const ushort bankBase = 0x4000;
var bank = new byte[9 * 256];

// Slot layout (0-based row-major): 0 1 2 / 3 4 5 / 6 7 8
// Tile 0 = "stone block" - all 9 cells the same brick char ($A0)
for (int slot = 0; slot < 9; slot++)
    bank[slot * 256 + 0] = 0xA0;

// Tile 1 = "torch" - mostly stone with a torch char in the centre
for (int slot = 0; slot < 9; slot++)
    bank[slot * 256 + 1] = 0xA0;
bank[4 * 256 + 1] = 0x57;  // centre slot = torch glyph

await client.StoreMemoryLargeCheckedAsync(bankBase, bank);

// 13x8 map = 39x24 chars in mode 3.
const ushort mapAddr = 0x6000;
var map = new byte[13 * 8];
// ... populate map with tile ids 0/1/... ...
await client.StoreMemoryLargeCheckedAsync(mapAddr, map);

await client.DrawMetatileAsync(
    mode:        3,
    mapAddr:     mapAddr,
    mapW:        13, mapH: 8,
    metaHi:      0x40,
    targetAddr:  0x0400, stride: 40,
    winW:        39, winH: 24,
    x:           0,  y:    0,
    offX:        0,  offY: 0,
    fillChar:    32,
    timeout:     TimeSpan.FromSeconds(2));

Colour-RAM Companion Pass

Every D render can drive colour RAM in lockstep with the character pass. The mode is selected by colorMode:

Value Mode Behaviour
0 NONE Skip colour RAM entirely. Use this when you've already painted the colour grid (e.g. via FillColorBlockAsync) and only want to refresh characters.
1 FILL Every cell rendered (in-bounds and edge fill) is written with colorFill. Cheapest option that still touches colour RAM.
2 MAP Mode 1: a parallel per-cell colour map at colorSrcAddr (same dimensions as the character map).
Modes 2/3: a single 256-byte colour-per-tile-id table at colorSrcAddr — every cell of every metatile of id N is painted with peek(colorSrcAddr + N). The whole table is just 256 bytes.
3 PERCELL Mode 2 only: a 1 KB page-aligned bank of 4 parallel slot pages indexed by tile id, mirroring the metatile char bank. Each child cell of every metatile id can have its own colour. colorSrcAddr low byte must be $00; only the page (high byte) is used.
Mode 1: identical to MAP (parallel per-cell colour map).
Mode 3: falls back to MAP semantics (one colour per tile id) — for fully arbitrary per-cell colour at 3×3, use mode 1 with a parallel colour map instead.

Example: per-tile-id colour table for mode 2.

// One byte per tile id; colour is applied to all 4 corners of that tile.
const ushort tileColors = 0x6400;
var palette = new byte[256];
palette[0] = (byte)Rift64Color.LightGreen; // "grass"
palette[1] = (byte)Rift64Color.Brown;      // "brick"
palette[2] = (byte)Rift64Color.Blue;       // "water"
await client.StoreMemoryLargeCheckedAsync(tileColors, palette);

await client.DrawMetatileAsync(
    mode:         2,
    mapAddr:      mapAddr, mapW: 20, mapH: 12,
    metaHi:       0x40,
    targetAddr:   0x0400, stride: 40,
    winW:         40, winH: 24, x: 0, y: 0,
    offX: 0, offY: 0,
    colorMode:    2,                // MAP
    colorTgtAddr: 0xD800,
    colorSrcAddr: tileColors,
    timeout:      TimeSpan.FromSeconds(2));

Example: per-cell colour for mode 2 (PERCELL).

Each metatile id gets four independent colour-RAM nibbles — one per child cell — in slot order (TL, TR, BL, BR). The SDK helper BuildMode2PerCellColorBank assembles the 1 KB page-aligned bank for you in the exact layout the renderer expects.

// Per-tile palette: (TopLeft, TopRight, BottomLeft, BottomRight).
var palette = new (byte TL, byte TR, byte BL, byte BR)[]
{
    (1,  2,  3,  4),  // tile 0: white red cyan purple
    (5,  6,  7,  8),  // tile 1: green blue yellow orange
    (9, 10, 11, 12),  // tile 2: brown lt-red dk-grey mid-grey
};
var bank = Rift64ProtocolClient.BuildMode2PerCellColorBank(palette, defaultColor: 14);

// Bank is 1 KB; address must be page-aligned ($XX00).
const ushort PERCELL_BANK = 0x4800;
await client.StoreMemoryLargeCheckedAsync(PERCELL_BANK, bank);

await client.DrawMetatileAsync(
    mode:         2,
    mapAddr:      mapAddr, mapW: 20, mapH: 12,
    metaHi:       0x40,
    targetAddr:   0x0400, stride: 40,
    winW:         40, winH: 24, x: 0, y: 0,
    offX: 0, offY: 0,
    color:        MetatileColorMode.MapPerCell,
    colorSrcAddr: PERCELL_BANK,
    colorTgtAddr: 0xD800);

Tips, Pitfalls & Performance Notes