Custom Palettes on the C64: Experimenting with Firmware 3.15's UCI Palette Control
Palette Morph: the idi8b logo with true per-scanline raster-bar ink colour, driven by UCI palette control

Custom Palettes on the C64: Experimenting with Firmware 3.15's UCI Palette Control

September 20, 2026
C64demoretroUltimate II+Oscar64firmware

Ultimate firmware 3.15 added something the real Commodore 64 hardware has never been able to do: RGB palette control. A standard C64 is stuck with 16 fixed hardware colours -- every VIC-II production in forty years has worked within that limit. The Ultimate Command Interface's new palette commands let software redefine what those 16 indices actually display, at runtime, over UCI. UltimateDemo2026 v1.1.0 exists mainly to try that out.

This is a follow-up to the original UltimateDemo2026 post about building the demo itself. This one is about the palette work specifically -- what the feature does, how it got retrofitted into scenes that were never designed for it, and the bugs that came with it.

What the command actually does

Four UCI commands cover it: get-palette reads the current 16 RGB triples, set-palette replaces all 16 at once, set-palette-colour updates a single index, and reset-palette restores the stock VIC colours. None of this touches pixel data -- a scene's bitmap or screen RAM still just references colour indices 0-15 the way it always did. What changes is what those indices mean. Repaint index 7's RGB value a few times a second and every pixel already drawn with index 7 shifts colour, with zero redraw cost.

That last part is the interesting bit for a demo: colour cycling that costs nothing per pixel, on hardware that was never capable of it.

Palette Morph: a logo the C64 shouldn't be able to draw this way

The new scene renders the idi8b studio logo with the sweeping colour done at the scanline level, not the character-row level. That distinction matters because of how C64 text-mode colour actually works: colour RAM is fetched once per 8-scanline character row by the VIC -- that's a hardware limit, not a software choice. Colouring a logo's ink one scanline at a time needs a different trick.

The trick: colour RAM is set to solid black everywhere, logo and background alike, and left alone. The logo's actual ink pixels are made to reference the background colour register instead of colour RAM, by inverting each character's glyph bits. Sweep that background colour once per scanline while it's within the logo's row-span, and the logo lights up with a genuine per-line gradient while everything else on screen -- driven by colour RAM -- stays untouched.

Getting there had one real bug worth mentioning. On the C64 there's no special hardware behaviour for "reversed" character codes that swaps which register drives a pixel -- reversed codes are simply pre-inverted bitmap data in the character ROM, and the same rule (an ink bit routes to colour RAM, a background bit to the background register) applies regardless. Forcing a cell's top bit set to invert its ink only works for cells that started non-reversed. A common PETSCII fill technique -- a reversed space character for a solid block -- is already reversed in its source data, so forcing the same bit again is a no-op, and that cell's ink stays on colour RAM (black) instead of moving to the background register. The fix was to toggle that bit relative to each cell's own original state, not force it on -- inverting every cell's glyph exactly once regardless of its starting state.

Mandelbrot fractal coloured by escape depth, cool-to-warm gradient rolling via UCI palette control

Retrofitting scenes that predate the feature

The more interesting problem was applying this to scenes that were built around the stock 16-colour palette. The Mandelbrot fractal is the clearest case: cell colour used to be picked from a hand-tuned 4x4 table keyed by screen quadrant and local escape-iteration mix -- a reasonable way to get variety out of 16 fixed hues, but not a true depth gradient. Two cells with identical escape depth could get different colours just because they landed in different screen quadrants.

With a real palette, that constraint goes away. Cell colour is now driven purely by escape depth -- shallow exterior through to the boundary -- mapped onto a 10-stop cool-to-warm gradient that continuously rolls via the set-palette command. Two cells at the same depth now always match, anywhere on screen.

Finding the right kind of "alive"

The plasma, ball, and flower scenes got a different treatment: a gentle colour pulse, rather than a static custom palette. Getting the feel right took three attempts.

The first was an additive brightness delta -- add a flat amount to each RGB channel, cycling over time. On hardware this read as barely-there; too subtle to notice.

The second went the other way: true colour-cycling in the classic demoscene sense, rotating which palette index displays which colour in a sequence -- the technique behind old Amiga and LucasArts art. On this project's plasma and floor effects specifically, it read as too strong and, on the rotating floor, laggy.

What actually worked: multiplicative brightness scaling. Instead of adding a delta, scale every RGB channel by the same percentage. The reason this matters is hue: adding a flat delta to an unbalanced colour (say, a saturated red where G and B are near zero) changes the ratio between channels, which shifts the hue, not just the brightness. Multiplying every channel by the same factor leaves the ratio -- and therefore the hue -- exactly fixed, and only brightness moves. Several palette indices pulsing on independent phase offsets, each scaled this way, gave a result that read as "alive" without drifting off-colour.

One design detail from that: in the plasma scene, if independent indices pulse on independent phases, a "dark" shade can briefly outshine a "bright" one -- and plasma's whole structure is a low-to-high intensity map, not four arbitrary hues, so that inversion undermines the effect. The fix was giving the three plasma shades one shared phase instead of independent ones -- their relative brightness order is then mathematically fixed, only the theme's overall brightness breathes.

Plasma: three shades sharing one pulse phase, so their relative order can't invert mid-pulse

Real bugs, found the hard way

A few things broke along the way that are worth recording honestly.

An abandoned optimization. An early-exit check for points inside the Mandelbrot set's main cardioid and period-2 bulb -- a real ~2.85x speedup, adapted from a sibling project -- turned out to race with the MOD player's own interrupt handler. The 32-bit multiply it needed compiles to a library call that writes its result through a zero-page range the interrupt handler doesn't save, and an interrupt landing at the wrong moment silently corrupted the result. Several fix attempts (the usual interrupt-disable bracket, verified correct by reading the actual disassembly) still didn't stop intermittent corruption on real hardware. Given this only affects a one-time static render, not a per-frame cost, the fix was to remove the optimization rather than keep chasing it.

A tunnel artifact that was always there. The tunnel's outermost few scanlines, top and bottom, showed hard rectangular blocks instead of the expected smooth curve -- a pre-existing issue, not something the palette work introduced. Root cause: at those extreme rows, the screen-space horizontal offset range is smaller than the vertical one, which collapses the angle lookup's resolution near screen centre. Widening the horizontal range helped the rest of the tunnel look rounder, but a handful of scanlines at the very top and bottom remain mathematically unresolvable at this table resolution regardless -- those get cropped instead of rendered.

A hang that took two attempts to find. After all the palette work landed, the demo started hanging on exit back to BASIC. The first theory -- a heap budget squeezed too tight while fitting new code, with Oscar64's runtime heap checking known to jam the CPU outright on overflow -- fit the evidence and got fixed, but didn't fix the hang. The real find came from probing the live, frozen Ultimate 64 directly over its own REST API -- reading screen RAM, CIA registers, and the VIC border/background colour registers on the actual hung device. The border and background colours had already been set to their exit values, which meant execution had gotten past where a heap issue could plausibly matter, narrowing it to the literal last UCI call before the program exits: a "just in case" palette reset that a different scene's own cleanup had already performed moments earlier. Removing the redundant call fixed it.

There's also a genuinely open question still sitting in the code: what string does Commodore 64 Ultimate's hardware-info command actually report? One source says "C64 Ultimate"; another, more recent check pointed to "ULTIMATE 64" instead -- the same string the original 48 MHz-class hardware reports. Neither is a fully clean, direct read confirmed on real C64U silicon, so it's left unresolved on purpose rather than guessed at.

Try it

The standalone test_palette.c harness in the repository is the actual tool this was built with -- it skips hardware detection and the rest of the demo sequence entirely and just runs the Palette Morph scene at 64 MHz, so an edit-build-test cycle takes seconds instead of sitting through the full sequence each time. It is a development tool, not part of the shipped demo.

A capture of the full v1.1.0 sequence is on YouTube: Ultimate 64: UltimateDemo2026 (v1.1.0) - playing with palette control.

Credits

Code: Xander Mol

Music: 4ev.mod ("Forever Young") -- artist unknown

UCI/DOS library: Scott Hutter and Francesco Sblendorio -- github.com/xlar54/ultimateii-dos-lib

PETSCII font: Small Round PETSCII Font by Cupid

Compiler: Oscar64 by drmortalwombat -- github.com/drmortalwombat/oscar64

Ultimate 64 hardware and firmware: designed by Gideon Zweijtzer / Gideon's Logic -- ultimate64.com

Licensed under the GNU General Public License v3.0.

Sources and More Information

UltimateDemo2026 v1.1.0 release -- release notes and download: github.com/xahmol/UltimateDemo2026/releases/tag/v1.1.0

UltimateDemo2026 repository -- source code, libraries, and all documentation manuals: github.com/xahmol/UltimateDemo2026

Standalone palette test harness -- the fast-iteration dev tool used to build the Palette Morph scene: src/test_palette.c

Original UltimateDemo2026 post -- the project's origin story, toolchain, and the first eight scenes: idreamtin8bits.com/blogs/ultimatedemo2026

Oscar64 compiler -- C99/C++ cross-compiler for 6502, by drmortalwombat: github.com/drmortalwombat/oscar64

Ultimate 64 firmware documentation -- firmware releases and update instructions: ultimate64.com/Firmware

Share:
Facebook X Bluesky WhatsApp