All tutorials Mighty Professional
Tutorial 17 ยท Graphics

Color Cycling from Scratch

A color-cycled picture never rewrites a pixel. The frame holds palette indices, and a few palette entries rotate on a schedule, so water falls and torches flicker for a handful of register writes. We rebuild the technique from the VGA DAC and the Amiga Copper up, then pack layers, animation frames and lighting into the same indices, and end with the traps that still ship in shaders and tools. Fourteen live widgets, C++ and Rust side by side, and the primary sources behind each claim.

Time~75 min LevelJunior to mid; senior review for the traps in §6, §12 and §13 PrereqsYou can read C++ or Rust. Bit Shifting §11 covers RGB565 and bit replication; this article builds on it instead of repeating it. HardwareNone. Every widget is Canvas 2D; the GLSL listings in §13 are compile-checked separately.

01Why color cycling

Mode 13h, the VGA's 256-color mode, stores a 320 by 200 frame as 64,000 bytes starting at A0000h, one byte per pixel[1]. The byte isn't a color. It is an index into 256 registers in the video DAC, 18 bits each, and the DAC turns the index into a color as the picture is scanned out[1]. Rewrite one register and every pixel holding that index changes color on the next frame, with no pixel written. Rotating a 32-entry water range costs 97 port writes; redrawing the same motion into the frame can touch all 64,000 bytes. That compares operation counts, not speed: Abrash calls OUTs "painfully slow", and the DAC accepts writes cleanly only in a short window, which is §4[2].

This article was inspired by Modern Vintage Gamer's video "The Beauty of Color Cycling in Video Games", published September 14, 2026[3], and by the Amiga Graphics Archive's color cycling page[4]. The video's description calls the technique "a method used to create animations and visual effects without using extra processing power or memory"[3]. §4 measures what it cost on period hardware and §14 what it costs now. Three things are in scope: cycling, which rotates a range of palette entries over time; palette swaps, which replace entries in one step and share the same machinery; and palette packing, which spends bits on layers, animation frames, light levels and blending.

Where it shows up:

The scene below is a real indexed renderer: a 320 by 180 grid of palette indices, resolved through a 256-entry palette every frame. Nothing in the grid changes after it is generated.

Live · One image, many motions

256 entries, 16 per row. An outline marks a cell inside a cycling range, a slash an entry no pixel in this scene uses. Click a cell, or press Enter on the picture, to select that entry.

index writes
0
entries changed · 1 s mean
0.0
pixels recolored · 1 s mean
0
pixels in active ranges
0.0%
scene repeats after
0 s
inspect
x 205, y 101
last palette write
none yet
Every pixel stores an index, and the grid below the scene shows what each index looks like right now. Switch to the X-ray view and the picture stops moving, because the indices never change; the index-write counter stays at zero. Select an entry and drag the hue slider: one 3-byte palette write recolors every pixel that uses it. The share of pixels in cycling ranges is what a CPU renderer has to repaint (§9 gives Canvas Cycle's figures), and the repeat time is the least common multiple of the range periods.
What you'll have by the end

A register-level model of palette hardware: the VGA DAC's index-then-triplet protocol, why IBM restricts DAC access to blanking and what a full load cost on an 8 MHz AT, and the Amiga's write-only color registers and Copper lists. Range rotation with direction, rate units and Deluxe Paint I's vertical-blank accumulator pinned to the line of source. A CRNG reader that survives real files. The Boing Ball's plane layout and 14-register spin, read from its code. Mark Ferrari's techniques as mechanisms: segment length as speed, phase offsets, rain paths and time-of-day keyframes. BlendShift, and why Canvas Cycle runs 2.5% slow against the ILBM rate. Bitplane priority palettes, Doom, Quake and Build light tables, and the rule that every table storing indices must follow a cycle. Index textures on the GPU and the sampler traps they walk into, and how OpenTTD, OpenRCT2, the Sonic Mania decompilation, SDL 3.4 and the fantasy consoles do this today. Fourteen live widgets.

02A short history

Color cycling reached most programmers through a paint program's range model, the file chunk that stored it, and palette hardware that games drove directly. The dates the rest of the article leans on:

1984
The Boing Ball is written in one night. Dale Luck dates the first Amiga version to between midnight and 4 a.m. on about January 7, 1984; sound and the left-right bounce arrived around May. Bob Pariseau, in the same BYTE collection: "The ball rotation is done by color cycle animation."[13] (§7)
1985
Deluxe Paint ships. Dan Silva's paint program for the Amiga, November 1985[5]. One shade range and three cycle ranges, each with its own speed slider, and Tab to cycle the picture[6].
1986
EA's ILBM spec stores the ranges. Jerry Morrison's January 17, 1986 ILBM text defines the CRNG chunk, whose third word is named active: "nonzero means cycle the colors"[14].
1987
VGA arrives with the PS/2 line. IBM's April 2, 1987 announcement letter for the Model 80 lists "320 x 200 graphics in 256 colors" among the new VGA modes[15].
1988
CRNG gets a direction bit. The October 1988 revision renames active to flags, with bit 0 active and bit 1 reverse, and leaves the older sentence about "the field active" standing next to it[14].
1991
Deluxe Paint IV. Up to eight ranges of 32 colors each, with a rate per range[16], and a DRNG chunk for ranges that skip registers or cycle one register through RGB values the palette doesn't hold[14].
1992
DCOLORS builds Doom's light table. John Carmack's DCOLORS 1.1 writes the PLAYPAL and COLORMAP lumps the renderer reads per pixel[10] (§12).
1993
Seize the Day's Living Worlds. An illustrated personal organizer ships twelve Mark Ferrari scenes, one per month, animated by cycling[17]. Ferrari gets the light and shadow of a whole day out of a series of palettes faded through one unchanged layer of 8-bit art[18] (§10).
2010
Canvas Cycle. Joseph Huckaby puts 35 of Ferrari's 640 by 480 scenes in an HTML5 canvas and adds BlendShift, a crossfade between steps; Kotaku covered it on July 26[19] (§9).
2014
Shovel Knight cycles in a shader. Yacht Club Games describes the sprite as an indexed unsigned-byte texture and the palette as a full 32-bit color texture, both handed to a pixel shader[11].
2015
Deluxe Paint I source released. The Computer History Museum releases the C source, which shows how the range struct and the CRNG chunk were really written[20] (§5, §6).
2026
SDL 3.4.0 adds palettized textures. Released January 1, 2026, with SDL_SetTexturePalette() and one palette shareable across many textures. SDL2's renderer answered the same request with "Palettized textures are not supported"[12].
Three claims, checked

The video says Ferrari's later work, naming Thimbleweed Park, builds entire images whose effects are driven by color cycling (about 07:42, auto-captions)[3]. In his GDC 2016 talk Ferrari says those backgrounds aren't 8-bit art but Photoshop work, with many times 256 colors in some of the pictures (about 18:36, auto-captions)[21].

It also says Sonic 3's Blue Sphere stage uses the same palette shifting[3]. The disassembly shows palette rotation, Rotate_SSPal, plus Update_SSMap rewriting the Plane A nametable from pre-rendered perspective maps on each animation frame[8]. The rotation is one half of the effect.

Third, the Boing Ball's bitplane layout as the Amiga Graphics Archive describes it, three planes with the shadow on the last one, doesn't match the disassembly[4]. §7 reads the planes out of the code.

03Pixels that point at colors

Three pieces make up the model. An index buffer holds one small integer per pixel. A palette maps each integer to a color. Resolve is the lookup that turns the first into the second, and on the VGA and the Amiga the hardware performs it during scan-out, once per pixel per frame, so no color-form copy of the frame exists in memory at all. Abrash puts it as a rule: "It isn't color until the DAC says it's color."[2] The Amiga Hardware Reference Manual names the scheme "color indirection"[22].

What that buys, per machine and mode:

Machine and modeIndexed frameSame frame at 24 bppPalette stateBudget
VGA mode 13h, 320x200 64,000 B 192,000 B 256 x 18 bits 256 KB of video memory: 24.4% against 73.2%[1]
Amiga 1000, 320x200, 5 bitplanes 40,000 B 192,000 B 32 x 12 bits 256 KB of chip RAM, the only memory bitplanes may live in[22]
NES, 256x240 of tiles 960 B nametable + 64 B attributes 184,320 B 28 physical 6-bit entries, 25 usable at once 2 KB of PPU RAM[23]

The 24 bpp column and the two percentages are arithmetic on the cited sizes, not figures quoted from the manuals.

A palette entry isn't 24 bits either, and the width differs per machine. The ILBM spec's hints table lists bits per gun by device: IBM EGA 2:2:2, Atari ST 3:3:3, Amiga 4:4:4, IBM VGA 6:6:6[14]. Any file that stores 8 bits per gun is converted on the way to the hardware, and the conversion is where palettes drift.

An Amiga 4-bit gun expands to 8 bits exactly as value * 17, because 255 = 15 x 17. Going the other way, DOSBox Staging converts an 8-bit value to the VGA's 6 bits by rounding, (value * 63 + 127) / 255 in fixed-point form[24]. Coming back out, replicating the top bits, (value << 2) | (value >> 4), differs from exact rounding on 10 of the 64 codes, by at most 1 each (derived). Why replication lands on 255 exactly is covered with RGB565 in Bit Shifting §11.

The ILBM spec documents a white that is "not quite white": 4-bit guns stored left-justified give 0xF0, which reads as 240 on 8-bit-per-gun hardware, where white is 255. The 1988 text suggests storing 0xFF instead, and suggests a reader rescale when every gun's low nibble is zero across the whole palette[14]. Files kept the older convention: 82 of the 87 ILBM files linked from the Amiga Graphics Archive's color cycling page still store left-justified guns in their CMAP chunk[4]. A reader that skips the rescale tops out at 240, and the whole picture reads dark.

Resolve an indexed frame and convert gun depths
#include <array>
#include <cstddef>
#include <cstdint>
#include <span>
#include <vector>

// Restated so this listing compiles on its own.
struct Rgb8 { std::uint8_t red; std::uint8_t green; std::uint8_t blue; };
using Palette256 = std::array<Rgb8, 256>;
struct IndexedImage { std::size_t width; std::size_t height; std::vector<std::uint8_t> indices; };

// One palette lookup per pixel, which is what a CLUT does at scan-out. Both
// sizes are checked before a byte is written, so a caller that sized the output
// for a different frame gets its buffer back untouched instead of half filled.
bool resolve_to_rgba(const IndexedImage& image, const Palette256& palette, std::span<std::uint8_t> rgba_out) {
    const std::size_t pixel_count = image.width * image.height;
    if (image.indices.size() != pixel_count) return false;
    if (rgba_out.size() != pixel_count * 4) return false;
    for (std::size_t pixel = 0; pixel < pixel_count; ++pixel) {
        // An index is one byte and the palette has 256 entries, so every value
        // is in range: no clamp and no branch, which is why the lookup is cheap.
        const Rgb8 color = palette[static_cast<std::size_t>(image.indices[pixel])];
        rgba_out[pixel * 4 + 0] = color.red;
        rgba_out[pixel * 4 + 1] = color.green;
        rgba_out[pixel * 4 + 2] = color.blue;
        rgba_out[pixel * 4 + 3] = 255;   // opaque: an indexed frame carries no alpha of its own
    }
    return true;
}

// How many pixels one palette write recolors, per entry. A cycling range earns
// the registers it costs only when its entries cover enough of the picture.
std::array<std::uint32_t, 256> count_index_uses(const IndexedImage& image) {
    std::array<std::uint32_t, 256> uses{};   // value-initialized, so all 256 counters start at zero
    for (const std::uint8_t index : image.indices) ++uses[static_cast<std::size_t>(index)];
    return uses;
}

// An Amiga gun is 4 bits. Multiplying by 17 is exact at both ends because
// 255 = 15 * 17, and it is the same operation as copying the nibble into both
// halves of the byte.
constexpr std::uint8_t gun4_to_channel8(std::uint8_t gun4) {
    return static_cast<std::uint8_t>((gun4 & 0x0F) * 17);
}

// Eight bits down to the VGA DAC's six. DOSBox Staging rounds rather than
// dropping the low two bits; both land 255 on 63, but they differ on 42 of
// the 256 codes (200 rounds to 49, truncates to 50).
constexpr std::uint8_t channel8_to_dac6(std::uint8_t channel8) {
    return static_cast<std::uint8_t>((channel8 * 63 + 127) / 255);
}

// Six bits back up to eight by replicating the top two bits into the gap: one
// shift and one or, correct at both ends, and 1 away from the exact round on
// ten of the 64 codes.
constexpr std::uint8_t dac6_to_channel8(std::uint8_t dac6) {
    const std::uint8_t code = static_cast<std::uint8_t>(dac6 & 0x3F);
    return static_cast<std::uint8_t>((code << 2) | (code >> 4));
}

// The INMOS IMS G171 keeps the low six bits of the byte written to 3C9h, so an
// 8-bit value sent without conversion is stored as its own low six bits: 200
// arrives as 8, a near-black where a bright color was meant.
constexpr std::uint8_t g171_stores(std::uint8_t written_byte) {
    return static_cast<std::uint8_t>(written_byte & 0x3F);
}
// Restated so this listing compiles on its own.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Rgb8 { pub red: u8, pub green: u8, pub blue: u8 }
pub type Palette256 = [Rgb8; 256];
pub struct IndexedImage { pub width: usize, pub height: usize, pub indices: Vec<u8> }

// One palette lookup per pixel, which is what a CLUT does at scan-out. Both
// sizes are checked before a byte is written, so a caller that sized the output
// for a different frame gets its buffer back untouched instead of half filled.
pub fn resolve_to_rgba(image: &IndexedImage, palette: &Palette256, rgba_out: &mut [u8]) -> bool {
    let pixel_count = image.width * image.height;
    if image.indices.len() != pixel_count { return false; }
    if rgba_out.len() != pixel_count * 4 { return false; }
    for pixel in 0..pixel_count {
        // An index is one byte and the palette has 256 entries, so every value
        // is in range: no clamp and no branch, which is why the lookup is cheap.
        let color = palette[image.indices[pixel] as usize];
        rgba_out[pixel * 4] = color.red;
        rgba_out[pixel * 4 + 1] = color.green;
        rgba_out[pixel * 4 + 2] = color.blue;
        rgba_out[pixel * 4 + 3] = 255;   // opaque: an indexed frame carries no alpha of its own
    }
    true
}

// How many pixels one palette write recolors, per entry. A cycling range earns
// the registers it costs only when its entries cover enough of the picture.
pub fn count_index_uses(image: &IndexedImage) -> [u32; 256] {
    let mut uses = [0u32; 256];   // all 256 counters start at zero
    for index in &image.indices { uses[*index as usize] += 1; }
    uses
}

// An Amiga gun is 4 bits. Multiplying by 17 is exact at both ends because
// 255 = 15 * 17, and it is the same operation as copying the nibble into both
// halves of the byte.
pub const fn gun4_to_channel8(gun4: u8) -> u8 {
    (gun4 & 0x0F) * 17
}

// Eight bits down to the VGA DAC's six. DOSBox Staging rounds rather than
// dropping the low two bits; both land 255 on 63, but they differ on 42 of
// the 256 codes (200 rounds to 49, truncates to 50).
pub const fn channel8_to_dac6(channel8: u8) -> u8 {
    // The product needs 14 bits, so the arithmetic widens before it multiplies.
    ((channel8 as u16 * 63 + 127) / 255) as u8
}

// Six bits back up to eight by replicating the top two bits into the gap: one
// shift and one or, correct at both ends, and 1 away from the exact round on
// ten of the 64 codes.
pub const fn dac6_to_channel8(dac6: u8) -> u8 {
    let code = dac6 & 0x3F;
    (code << 2) | (code >> 4)
}

// The INMOS IMS G171 keeps the low six bits of the byte written to 3C9h, so an
// 8-bit value sent without conversion is stored as its own low six bits: 200
// arrives as 8, a near-black where a bright color was meant.
pub const fn g171_stores(written_byte: u8) -> u8 {
    written_byte & 0x3F
}
Scan-out remap and draw-time remap are different tools

PICO-8 exposes both and keeps them apart. Its draw palette "does not affect anything that was already drawn to the screen", while its display palette "re-maps the whole screen when it is displayed at the end of a frame"[25]. Cycling needs the second kind, and it needs the platform to provide it. Under the 256-color compatibility mode in Windows 10 (and, its author reports, Windows 8), palette changes stopped updating pixels SimCity 2000 had already drawn until the game repainted that part of the window, which froze its palette animation; a community tool restores it by forcing the repaints[26].

04Palette hardware and the beam

The VGA DAC

The write protocol is an index then a triplet: the register number to port 3C8h, then three bytes to 3C9h in red, green, blue order. Only the low 6 bits of each byte are used, and the address auto-increments after the third byte, so consecutive triplets fill consecutive registers.[1] IBM's own prescribed sequence doesn't lean on that; it disables interrupts and rewrites the address for every entry.[1] With auto-increment a full load is 769 OUTs, one index plus 768 data bytes, and Abrash reports the write index wrapping from 255 back to 0.[2]

Nothing clamps an unconverted 8-bit value. On the INMOS IMS G171, which INMOS says IBM used in all PS/2 machines, only the low six bits of a written byte are kept, so 200 lands as 8.[27] RAMDACs vary, FreeVGA reports "great variance in the actual behavior of these registers on VGA chipsets"[28], and 8-bit-mode parts such as the G178 use the whole byte.[27] DOSBox Staging emulates the mask.[24]

IBM says a program reading or writing the DAC should do it only while the DAC's blank input is asserted, during the or with the screen-off bit of the Clocking Mode register set, to prevent snow.[1] The overscan border also passes through the DAC, so border time is no safer, and reading the DAC can snow as well as writing it.[2]

The restriction covers palette access only: the same manual says video-memory updates "may be made at any time without screen degradation".[1] Programs still polled 3DAh, for page flips and tear-free frames.[2] Bit 3 of Input Status 1 there is the detector, set during vertical retrace[1], and coarse: the sync pulse "starts partway through but not too far into vertical blanking", so waiting on the bit spends part of the window first.[2]

On an 8 MHz AT the 768 data OUTs alone take 288 µs, and Abrash puts the loading and looping around them at "in the ballpark of 1,800 microseconds more", about 2 ms for one full load.[2] He scopes the failure by machine: on "Pentiums, 486s, and 386s sometimes, 286s most of the time, and 8088s all the time" a 256-entry load won't finish between the start of vertical sync and the end of blanking.[2] The ways out: cycle a portion of the DAC, interleave cycle sets across frames, or load a few entries in each horizontal blank.[2]

The BIOS path is worse than doing it by hand. Of four clone VGAs Abrash tried with INT 10h AX=1012h, two waited for vertical retrace and two didn't, two blanked the display, and none loaded 256 entries without garbage for at least a frame; a program that waits and then calls a BIOS that waits again loads the DAC "only once every two frames".[2] Command & Conquer keeps it in hand: Wait_Vert_Blank(), then Set_Palette(GamePalette).[7]

In 16-color modes one write to the Attribute Controller's Color Select register repoints the screen at another block of DAC entries[1], which Abrash calls color paging and rates "generally superior to color cycling whenever it's available".[2] What lands inside the window is below.

Live · DAC writes against the beam
blanking window write completes in blanking write completes on the beam beam this load writes entries 128-159
strategy
port writes
97
load time
261.0 µs
blank window
1,000 µs
clean entries
32
late entries
0
snow specks
0
range steps/s
70
The beam is slowed so one 70 Hz frame takes two seconds. The gray band on the strip is the blanking window after vertical sync, a slider here because this is a model, not a measured VGA timing. The column beside it is the load: green where a write completes inside the window, red where it completes on a line the beam is already drawing. One entry costs 8.16 µs against a 14,286 µs frame, well under a pixel on this strip, so the load draws as one filled column. A late write tears the bars at that line and leaves a speck of snow. The 8 MHz AT preset spreads Abrash's 2 ms for a full load on that machine evenly over 256 entries. Reload the whole DAC and it spills out of the window; load only the 32 cycling entries, or half of them per frame, and it fits.

The listing drives real ports in DOS and an emulated bus in C++ and Rust.

Load VGA DAC entries after vertical retrace
#include <conio.h>   /* Open Watcom keeps inp and outp here. */
#include <i86.h>     /* _disable and _enable are the compiler's CLI and STI. */

#define DAC_WRITE_INDEX   0x3C8
#define DAC_DATA          0x3C9
#define INPUT_STATUS_1    0x3DA
#define VERTICAL_RETRACE  0x08   /* bit 3 of Input Status 1 */

/* Wait out a retrace already in progress, then catch the start of the next one.
   IBM restricts DAC transfers to a retrace interval or to a blanked screen,
   because an access during display time can put snow on the picture. */
void wait_for_vertical_retrace(void)
{
    /* Bit 3 stays set for the whole retrace interval, so a poll that arrives
       halfway through would start the load with most of the window gone. */
    while ((inp(INPUT_STATUS_1) & VERTICAL_RETRACE) != 0) {
    }
    /* Reading 3DAh also resets the attribute controller's address/data
       flip-flop, so an interrupt handler that polls this port can land between
       a main-line attribute address write and its data write and swap the two.
       Keep this poll out of interrupt handlers. */
    while ((inp(INPUT_STATUS_1) & VERTICAL_RETRACE) == 0) {
    }
}

/* One OUT to 3C8h, then red, green and blue to 3C9h per entry. Successive
   writes to the data register advance the address register on their own, so a
   full 256-entry load is 769 OUTs. IBM's own sequence rewrites the address for
   every entry instead; both work, and the auto-increment is what makes the 769
   figure possible. */
void upload_palette_range(unsigned char first_index, unsigned entry_count,
                          const unsigned char *rgb6_triplets)
{
    unsigned byte_count;
    unsigned i;

    /* 256 entries is 768 bytes, which still fits a 16-bit unsigned int. */
    byte_count = entry_count * 3;

    /* An interrupt taken between the address write and the third data byte
       leaves the DAC mid-triplet and the next write lands in the wrong gun, so
       the whole load runs with interrupts off. Abrash puts a full 256-entry
       load on an 8 MHz AT at a minimum of 2 ms, which is a long time to hold
       interrupts off for a palette. */
    _disable();
    outp(DAC_WRITE_INDEX, first_index);
    for (i = 0; i < byte_count; i++) {
        /* The DAC concatenates the six least-significant bits of each byte
           and ignores the rest. Masking here makes an out-of-range value
           truncate in one visible place, not quietly inside the register. */
        outp(DAC_DATA, rgb6_triplets[i] & 0x3F);
    }
    /* _enable() is an unconditional STI, not a restore of the flag state this
       routine found: call it from the main line, not from inside an interrupt
       handler or a critical section that has its own reason to keep them off. */
    _enable();
}
#include <array>
#include <cstddef>
#include <cstdint>
#include <span>
#include <utility>
#include <vector>

// Restated so this listing compiles on its own.
struct Rgb6 { std::uint8_t red; std::uint8_t green; std::uint8_t blue; };

inline constexpr std::uint16_t DAC_WRITE_INDEX = 0x3C8;
inline constexpr std::uint16_t DAC_DATA = 0x3C9;
inline constexpr std::uint16_t INPUT_STATUS_1 = 0x3DA;
inline constexpr std::uint8_t VERTICAL_RETRACE = 0x08;   // Input Status 1 bit 3

// The two routines below take a bus rather than calling out directly, so the
// same code drives real ports under DOS and the model below under a test.
class PortBus {
public:
    virtual ~PortBus() = default;
    virtual void out8(std::uint16_t port, std::uint8_t value) = 0;
    virtual std::uint8_t in8(std::uint16_t port) = 0;
};

// A register model of the documented protocol, not a timing model: it says what
// the DAC ends up holding and how much port traffic that took. It says nothing
// about snow.
class EmulatedVgaDac final : public PortBus {
public:
    std::array<Rgb6, 256> registers{};
    std::uint32_t port_writes = 0;
    std::uint32_t status_reads = 0;

    // The values 3DAh will return, in order; the last one repeats after that.
    void script_status(std::vector<std::uint8_t> sequence) {
        status_script = std::move(sequence);
        status_position = 0;
    }

    void out8(std::uint16_t port, std::uint8_t value) override {
        ++port_writes;
        if (port == DAC_WRITE_INDEX) {
            write_index = value;
            // Writing the address register during a transfer starts a new
            // cycle, so a half-finished triplet is dropped rather than
            // completed by whatever bytes arrive next.
            component = 0;
        } else if (port == DAC_DATA) {
            // Six-bit DACs: the register keeps value & 0x3F, whatever the
            // byte carried.
            staged[component] = static_cast<std::uint8_t>(value & 0x3F);
            ++component;
            if (component == 3) {
                registers[write_index] = Rgb6{staged[0], staged[1], staged[2]};
                // Abrash reports the write index wrapping from 255 back to 0
                // rather than stopping, so a range may run off the end of
                // the table and carry on at entry 0.
                write_index = static_cast<std::uint8_t>(write_index + 1);
                component = 0;
            }
        }
        // A write to any other port, 3C6h included, lands nowhere here.
    }

    std::uint8_t in8(std::uint16_t port) override {
        if (port != INPUT_STATUS_1) {
            return 0xFF;   // no other port is part of this model
        }
        ++status_reads;
        if (status_script.empty()) {
            return 0;
        }
        // Clamping rather than wrapping lets a test say "retrace starts at
        // this read and stays set".
        const std::size_t position = status_position < status_script.size()
            ? status_position : status_script.size() - 1;
        ++status_position;
        return status_script[position];
    }

private:
    std::uint8_t write_index = 0;
    std::uint8_t component = 0;   // 0 red, 1 green, 2 blue
    std::array<std::uint8_t, 3> staged{};
    std::vector<std::uint8_t> status_script;
    std::size_t status_position = 0;
};

void wait_for_vertical_retrace(PortBus& bus) {
    // Bit 3 stays set for the whole retrace interval, so arriving halfway
    // through it would leave only the tail of the window. Let it end first.
    while ((bus.in8(INPUT_STATUS_1) & VERTICAL_RETRACE) != 0) {
    }
    // Reading 3DAh also resets the attribute controller's address/data
    // flip-flop, so an interrupt handler that polls this port can swap a
    // main-line attribute write pair.
    while ((bus.in8(INPUT_STATUS_1) & VERTICAL_RETRACE) == 0) {
    }
}

// 1 + 3 * colors.size() port writes: 769 for the whole DAC, 97 for 32 entries.
void upload_palette_range(PortBus& bus, std::uint8_t first_index,
                          std::span<const Rgb6> colors) {
    bus.out8(DAC_WRITE_INDEX, first_index);
    for (const Rgb6& color : colors) {
        // Red, then green, then blue; the address advances on the third byte,
        // which is why one index write covers the whole range instead of one
        // write per entry.
        bus.out8(DAC_DATA, static_cast<std::uint8_t>(color.red & 0x3F));
        bus.out8(DAC_DATA, static_cast<std::uint8_t>(color.green & 0x3F));
        bus.out8(DAC_DATA, static_cast<std::uint8_t>(color.blue & 0x3F));
    }
}
// Restated so this listing compiles on its own.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Rgb6 { pub red: u8, pub green: u8, pub blue: u8 }

pub const DAC_WRITE_INDEX: u16 = 0x3C8;
pub const DAC_DATA: u16 = 0x3C9;
pub const INPUT_STATUS_1: u16 = 0x3DA;
pub const VERTICAL_RETRACE: u8 = 0x08;   // Input Status 1 bit 3

// The two functions below take a bus rather than calling out directly, so the
// same code drives real ports on bare metal and the model below under a test.
pub trait PortBus {
    fn out8(&mut self, port: u16, value: u8);
    fn in8(&mut self, port: u16) -> u8;
}

// A register model of the documented protocol, not a timing model: it says what
// the DAC ends up holding and how much port traffic that took. It says nothing
// about snow.
pub struct EmulatedVgaDac {
    pub registers: [Rgb6; 256],
    pub port_writes: u32,
    pub status_reads: u32,
    write_index: u8,
    component: u8,   // 0 red, 1 green, 2 blue
    staged: [u8; 3],
    status_script: Vec<u8>,
    status_position: usize,
}

impl EmulatedVgaDac {
    pub fn new() -> Self {
        Self {
            registers: [Rgb6::default(); 256],
            port_writes: 0,
            status_reads: 0,
            write_index: 0,
            component: 0,
            staged: [0; 3],
            status_script: Vec::new(),
            status_position: 0,
        }
    }

    // The values 3DAh will return, in order; the last one repeats after that.
    pub fn script_status(&mut self, sequence: Vec<u8>) {
        self.status_script = sequence;
        self.status_position = 0;
    }
}

impl PortBus for EmulatedVgaDac {
    fn out8(&mut self, port: u16, value: u8) {
        self.port_writes += 1;
        if port == DAC_WRITE_INDEX {
            self.write_index = value;
            // Writing the address register during a transfer starts a new
            // cycle, so a half-finished triplet is dropped rather than
            // completed by whatever bytes arrive next.
            self.component = 0;
        } else if port == DAC_DATA {
            // Six-bit DACs: the register keeps value & 0x3F, whatever the
            // byte carried.
            self.staged[self.component as usize] = value & 0x3F;
            self.component += 1;
            if self.component == 3 {
                self.registers[self.write_index as usize] = Rgb6 {
                    red: self.staged[0],
                    green: self.staged[1],
                    blue: self.staged[2],
                };
                // Abrash reports the write index wrapping from 255 back to 0
                // rather than stopping, so a range may run off the end of
                // the table and carry on at entry 0.
                self.write_index = self.write_index.wrapping_add(1);
                self.component = 0;
            }
        }
        // A write to any other port, 3C6h included, lands nowhere here.
    }

    fn in8(&mut self, port: u16) -> u8 {
        if port != INPUT_STATUS_1 {
            return 0xFF;   // no other port is part of this model
        }
        self.status_reads += 1;
        if self.status_script.is_empty() {
            return 0;
        }
        // Clamping rather than wrapping lets a test say "retrace starts at
        // this read and stays set".
        let position = if self.status_position < self.status_script.len() {
            self.status_position
        } else {
            self.status_script.len() - 1
        };
        self.status_position += 1;
        self.status_script[position]
    }
}

pub fn wait_for_vertical_retrace(bus: &mut dyn PortBus) {
    // Bit 3 stays set for the whole retrace interval, so arriving halfway
    // through it would leave only the tail of the window. Let it end first.
    while (bus.in8(INPUT_STATUS_1) & VERTICAL_RETRACE) != 0 {}
    // Reading 3DAh also resets the attribute controller's address/data
    // flip-flop, so an interrupt handler that polls this port can swap a
    // main-line attribute write pair.
    while (bus.in8(INPUT_STATUS_1) & VERTICAL_RETRACE) == 0 {}
}

// 1 + 3 * colors.len() port writes: 769 for the whole DAC, 97 for 32 entries.
pub fn upload_palette_range(bus: &mut dyn PortBus, first_index: u8,
                            colors: &[Rgb6]) {
    bus.out8(DAC_WRITE_INDEX, first_index);
    for color in colors {
        // Red, then green, then blue; the address advances on the third byte,
        // which is why one index write covers the whole range instead of one
        // write per entry.
        bus.out8(DAC_DATA, color.red & 0x3F);
        bus.out8(DAC_DATA, color.green & 0x3F);
        bus.out8(DAC_DATA, color.blue & 0x3F);
    }
}
What's intentionally missing

The DOS path saves no interrupt flag around its critical section, leaves out the I/O delay older chipsets want between consecutive OUTs, and never touches the PEL mask at 3C6h. It assumes a 6-bit part: an 8-bit-mode RAMDAC such as the G178 takes the whole byte[27]. Spreading one load across several frames, which is Abrash's own advice when 256 entries won't fit in blanking[2], is left to the caller.

What a palette load costs

The video's description says cycling works "without using extra processing power or memory".[3] At the register level a full palette load cost about 2 ms on an 8 MHz AT, didn't reliably fit in blanking, and the BIOS path could halve the rate.[2] Cycling is far cheaper than redrawing the pixels, but a load has to fit a blanking window, and once the display stops being indexed it becomes a full-screen walk (§14).

Amiga color registers and the Copper

COLOR00 through COLOR31 sit at $DFF180 to $DFF1BE, 12 bits each, four per gun, and write-only, so the manual tells programs to keep a shadow copy in RAM; COLOR00 doubles as the border.[22] waits for a beam position and then writes registers, the palette included; while it waits it "does not use the memory bus at all", and its list lives in chip RAM.[22]

Every instruction is two 16-bit words. MOVE has bit 0 of the first word clear and carries the register address; WAIT has bit 0 set, the vertical position in bits 15-8 and the horizontal in bits 7-1, with compare-enable masks in the second word. The manual's sample opens DC.W $9601,$FF00, commented "Wait for line 150, ignore horiz. position", then moves black into COLOR00; $FFFF,$FFFE ends a list.[22]

WAIT compares horizontal position at 113 positions per line, four lowres pixels each, but a MOVE costs four memory cycle times, so consecutive MOVEs change a register at best once every 8 lowres pixels, and more bitplanes make it slower.[22] Power Programs, a community source, measures that same 8-pixel floor.[29] Writability is uneven: color registers are always Copper-writable, $040 to $07E need COPCON's danger bit, and nothing below $040 is writable on the original chips. The manual's prose gives $20 and $10, which its own register table contradicts.[22]

Shipped lists work per line. Agony waits for horizontal position $42 to switch COLOR00 to its sky gradient and $D6 to restore black, its dump reading 4043 fffe, and alternates two slightly different lists frame to frame.[30]

Live · A Copper list, run against the beam

Drag the raster, or focus it and use the arrow keys, to park the beam on one line. The strip along the bottom is that line on its own, 15 pixels tall.

Skip the Copper instruction list
appended for you, and never edited
what the Copper reads, two words per instruction

          
instructions
33
list bytes
132
colors on screen
17
CPU writes
0
beam line
0
Each WAIT holds the Copper until the beam reaches a line, and a column when compare is on; each MOVE writes one register there. The panel shows the words the Copper would read, two 16-bit words per instruction. This raster uses Copper line numbers as its rows and puts horizontal position 0 at its left edge, a simplification of the real display window. Consecutive MOVEs land 8 lowres pixels apart. The sky shows more colors than the four registers hold, and the CPU writes no register during the frame.

The listing separates encoding from execution, and reports COLOR00 for all 256 lines.

Encode and run a Copper list
#include <cstddef>
#include <cstdint>
#include <optional>
#include <span>
#include <vector>

// Every Copper instruction is two 16-bit words fetched from chip RAM.
struct CopperInstruction {
    std::uint16_t first_word;
    std::uint16_t second_word;
    friend bool operator==(const CopperInstruction&,
                           const CopperInstruction&) = default;
};

inline constexpr std::uint16_t COLOR00 = 0x180;   // $DFF180, the background
inline constexpr std::size_t RASTER_LINES = 256;  // a WAIT's line field is 8 bits

// One 4-bit gun per nibble: $0RGB. The top nibble carries no color on OCS.
constexpr std::uint16_t rgb4(std::uint8_t red, std::uint8_t green,
                             std::uint8_t blue) {
    return static_cast<std::uint16_t>(
        ((red & 0x0F) << 8) | ((green & 0x0F) << 4) | (blue & 0x0F));
}

// From the hardware manual's register table rather than its "$20 / $10" prose,
// which the table itself contradicts: the Copper always reaches $080 and up,
// which covers every color register, needs COPCON's danger bit for $040 to
// $07E, and on the original chips can never touch anything below $040.
constexpr bool copper_may_write(std::uint16_t register_offset, bool danger_bit) {
    if (register_offset >= 0x080) {
        return true;
    }
    if (register_offset >= 0x040) {
        return danger_bit;
    }
    return false;
}

// WAIT: vertical position in bits 15-8, horizontal in bits 7-1, bit 0 set.
// The horizontal position has no home for its bit 0, which is why the manual
// writes the end-of-list wait as line 255, H = 254 and not H = 255.
constexpr CopperInstruction copper_wait(std::uint8_t vertical_position,
                                        std::uint8_t horizontal_position,
                                        bool compare_horizontal) {
    const std::uint16_t first_word = static_cast<std::uint16_t>(
        (static_cast<std::uint16_t>(vertical_position) << 8)
        | (horizontal_position & 0xFE) | 0x0001);
    // Bit 15 is the blitter-finished-disable bit; set, the wait ignores the
    // blitter. Bits 14-8 mask the vertical compare and bits 7-1 the horizontal
    // one, so a zero horizontal mask is how a list writes "any column".
    const std::uint16_t second_word = static_cast<std::uint16_t>(
        0x8000 | 0x7F00 | (compare_horizontal ? 0x00FE : 0x0000));
    return CopperInstruction{first_word, second_word};
}

// MOVE: the destination offset with bit 0 clear (the bit that would make it a
// WAIT), then the data word. Register offsets are even, so the mask is free.
constexpr CopperInstruction copper_move(std::uint16_t register_offset,
                                        std::uint16_t value) {
    return CopperInstruction{
        static_cast<std::uint16_t>(register_offset & 0x01FE), value};
}

// $FFFF,$FFFE ends a list: a wait for line 255 at column 254, which the beam
// never reaches.
constexpr CopperInstruction copper_end() {
    return CopperInstruction{0xFFFF, 0xFFFE};
}

// One column-free WAIT and one MOVE COLOR00 per band, then the end marker.
// Returns nullopt when a band would start past line 255: the vertical position
// is 8 bits, and a list that runs below that needs an extra mid-screen wait
// this builder does not emit.
std::optional<std::vector<CopperInstruction>>
build_sky_gradient(std::uint8_t first_line, std::uint8_t band_height,
                   std::span<const std::uint16_t> colors_rgb12) {
    std::vector<CopperInstruction> copper_list;
    copper_list.reserve(colors_rgb12.size() * 2 + 1);
    for (std::size_t band = 0; band < colors_rgb12.size(); ++band) {
        // Worked out in std::size_t rather than std::uint8_t so the overflow
        // is caught here instead of wrapping quietly to a line near the top.
        const std::size_t band_line = static_cast<std::size_t>(first_line)
            + band * static_cast<std::size_t>(band_height);
        if (band_line >= RASTER_LINES) {
            return std::nullopt;
        }
        const std::uint8_t wait_line = static_cast<std::uint8_t>(band_line);
        copper_list.push_back(copper_wait(wait_line, 0, false));
        copper_list.push_back(copper_move(COLOR00, colors_rgb12[band]));
    }
    copper_list.push_back(copper_end());
    return copper_list;
}

// COLOR00 at the left edge of lines 0 to 255, for lists built from column-free
// WAITs, MOVEs and the end marker. Returns nullopt for a list this reader
// cannot answer: a column compare, or a SKIP. MOVEs to other registers are
// stepped over.
std::optional<std::vector<std::uint16_t>>
color00_per_line(std::span<const CopperInstruction> copper_list,
                 std::uint16_t initial_color) {
    std::vector<std::uint16_t> color_by_line(RASTER_LINES, initial_color);
    std::size_t beam_line = 0;
    for (const CopperInstruction& instruction : copper_list) {
        if (instruction == copper_end()) {
            break;
        }
        if ((instruction.first_word & 0x0001) != 0) {
            // Bit 0 of the second word separates WAIT (clear) from SKIP (set).
            if ((instruction.second_word & 0x0001) != 0) {
                return std::nullopt;
            }
            // A non-zero horizontal mask makes the wait depend on the column
            // too, and this reader only reports the left edge of each line.
            if ((instruction.second_word & 0x00FE) != 0) {
                return std::nullopt;
            }
            const std::size_t wait_line =
                static_cast<std::size_t>(instruction.first_word >> 8);
            // A wait for a line the beam is already past falls through at once.
            if (wait_line > beam_line) {
                beam_line = wait_line;
            }
        } else if ((instruction.first_word & 0x01FE) == COLOR00) {
            // A register holds its value until something writes it again, so
            // one MOVE paints every line from the beam down to the bottom.
            for (std::size_t line = beam_line; line < RASTER_LINES; ++line) {
                color_by_line[line] = instruction.second_word;
            }
        }
    }
    return color_by_line;
}
// Every Copper instruction is two 16-bit words fetched from chip RAM.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CopperInstruction { pub first_word: u16, pub second_word: u16 }

pub const COLOR00: u16 = 0x180;       // $DFF180, the background register
const RASTER_LINES: usize = 256;      // a WAIT's line field is 8 bits

// One 4-bit gun per nibble: $0RGB. The top nibble carries no color on OCS.
pub const fn rgb4(red: u8, green: u8, blue: u8) -> u16 {
    (((red & 0x0F) as u16) << 8)
        | (((green & 0x0F) as u16) << 4)
        | ((blue & 0x0F) as u16)
}

// From the hardware manual's register table rather than its "$20 / $10" prose,
// which the table itself contradicts: the Copper always reaches $080 and up,
// which covers every color register, needs COPCON's danger bit for $040 to
// $07E, and on the original chips can never touch anything below $040.
pub const fn copper_may_write(register_offset: u16, danger_bit: bool) -> bool {
    if register_offset >= 0x080 {
        return true;
    }
    if register_offset >= 0x040 {
        return danger_bit;
    }
    false
}

// WAIT: vertical position in bits 15-8, horizontal in bits 7-1, bit 0 set.
// The horizontal position has no home for its bit 0, which is why the manual
// writes the end-of-list wait as line 255, H = 254 and not H = 255.
pub const fn copper_wait(vertical_position: u8, horizontal_position: u8,
                       compare_horizontal: bool) -> CopperInstruction {
    let first_word = ((vertical_position as u16) << 8)
        | ((horizontal_position & 0xFE) as u16) | 0x0001;
    // Bit 15 is the blitter-finished-disable bit; set, the wait ignores the
    // blitter. Bits 14-8 mask the vertical compare and bits 7-1 the horizontal
    // one, so a zero horizontal mask is how a list writes "any column".
    let horizontal_mask: u16 = if compare_horizontal { 0x00FE } else { 0x0000 };
    CopperInstruction { first_word, second_word: 0x8000 | 0x7F00 | horizontal_mask }
}

// MOVE: the destination offset with bit 0 clear (the bit that would make it a
// WAIT), then the data word. Register offsets are even, so the mask is free.
pub const fn copper_move(register_offset: u16, value: u16) -> CopperInstruction {
    CopperInstruction { first_word: register_offset & 0x01FE, second_word: value }
}

// $FFFF,$FFFE ends a list: a wait for line 255 at column 254, which the beam
// never reaches.
pub const fn copper_end() -> CopperInstruction {
    CopperInstruction { first_word: 0xFFFF, second_word: 0xFFFE }
}

// One column-free WAIT and one MOVE COLOR00 per band, then the end marker.
// Returns None when a band would start past line 255: the vertical position is
// 8 bits, and a list that runs below that needs an extra mid-screen wait this
// builder does not emit.
pub fn build_sky_gradient(first_line: u8, band_height: u8,
                          colors_rgb12: &[u16]) -> Option<Vec<CopperInstruction>> {
    let mut copper_list = Vec::with_capacity(colors_rgb12.len() * 2 + 1);
    for (band, color) in colors_rgb12.iter().enumerate() {
        // Worked out in usize rather than u8 so the overflow is caught here
        // instead of wrapping quietly to a line near the top of the screen.
        let band_line = first_line as usize + band * band_height as usize;
        if band_line >= RASTER_LINES {
            return None;
        }
        copper_list.push(copper_wait(band_line as u8, 0, false));
        copper_list.push(copper_move(COLOR00, *color));
    }
    copper_list.push(copper_end());
    Some(copper_list)
}

// COLOR00 at the left edge of lines 0 to 255, for lists built from column-free
// WAITs, MOVEs and the end marker. Returns None for a list this reader cannot
// answer: a column compare, or a SKIP. MOVEs to other registers are stepped
// over.
pub fn color00_per_line(copper_list: &[CopperInstruction],
                        initial_color: u16) -> Option<Vec<u16>> {
    let mut color_by_line = vec![initial_color; RASTER_LINES];
    let mut beam_line: usize = 0;
    for instruction in copper_list {
        if *instruction == copper_end() {
            break;
        }
        if (instruction.first_word & 0x0001) != 0 {
            // Bit 0 of the second word separates WAIT (clear) from SKIP (set).
            if (instruction.second_word & 0x0001) != 0 {
                return None;
            }
            // A non-zero horizontal mask makes the wait depend on the column
            // too, and this reader only reports the left edge of each line.
            if (instruction.second_word & 0x00FE) != 0 {
                return None;
            }
            let wait_line = (instruction.first_word >> 8) as usize;
            // A wait for a line the beam is already past falls through at once.
            if wait_line > beam_line {
                beam_line = wait_line;
            }
        } else if (instruction.first_word & 0x01FE) == COLOR00 {
            // A register holds its value until something writes it again, so
            // one MOVE paints every line from the beam down to the bottom.
            for line in beam_line..RASTER_LINES {
                color_by_line[line] = instruction.second_word;
            }
        }
    }
    Some(color_by_line)
}
What's intentionally missing

No COPJMP or COP1LC handling, so the list runs once from the top and is never restarted or swapped, and none of the frame-to-frame list alternation Agony uses[30]. The comments in the listing mark the two cases the reader itself declines: a column compare, and a list that needs to wait past line 255.

Other machines

MachineRuleWhat goes wrong
NES Palette writes belong in vblank or with rendering off.[23] Outside that, backdrop override briefly draws the written entry on screen.[23]
Mega Drive Write CRAM outside active display.[31] CRAM dots appear on the line; Sonic hides them under the water-surface sprites.[31]
Windows 8-bit GDI AnimatePalette changes only entries flagged PC_RESERVED.[32] 20 static colors at indices 0-9 and 246-255 leave 236 entries to animate.[32]

05Rotating a range, and which way is forward

The ILBM spec fixes the direction: "Normally, color cycling is done so that colors move to the next higher position in the cycle, with the color in the high slot moving around to the low slot."[14] The second bit of the flags word, mask 0x2, reverses it.[14]

Deluxe Paint I writes that as temp = colors[cyc->high]; for (j = cyc->high; j > cyc->low; j--) colors[j] = colors[j-1]; colors[cyc->low] = temp;.[20] Register j takes the color of j - 1. A pixel holding index k shows base colors k, k-1, k-2 over successive steps, so a gradient painted with ascending indices reads as motion toward higher indices.

Predict

A water shader computes palette[(index + t) % n] with t counting up. The artist painted index 16 at the top of the fall and 23 at the bottom and saved the range with RNG_REVERSE clear, so Deluxe Paint shows it falling. Which way does the shader move it?

The one-liner is ILBM reverse. palette[(index + t) % n] puts base color c at index c - t, walking the colors toward lower indices. Lode Vandevenne's plasma tutorial uses that exact form, palette[(plasma[y][x] + paletteShift) % 256] with a shift that grows with time.[33] The same tutorial says video cards rotate the palette automatically; the VGA has no rotate command, only DAC loads.[33][2]

DevilutionX rotates with std::rotate(begin + from, begin + from + 1, begin + to + 1), so entry i takes the color of i + 1: ILBM reverse again, read from the quoted call.[34] Each is self-consistent, and each disagrees with the format the art was saved in.

Canvas Cycle recomputes the palette instead of accumulating changes: it copies the untouched base palette every frame and applies each range at its absolute phase, so rounding never builds up over a long session.[19] Its shift moves one slot at a time, k times, which is O(k x n) per range; a modular gather is O(n) for any k, which is what colcycle and Tilengine do.[35][36]

Live · Which way is forward, and how fast
top strip · ILBM direction
bottom strip · your lookup
rate source
clock one field, 60 per second
rate word
3072
ILBM steps per second
11.250
16-bit wraps
0
Canvas Cycle drift
n/a
last six field gaps
no step yet
register 5
register 5 shows base entry 5
direction check
B runs the opposite way: that is RNG_REVERSE
Under the ILBM rule each color moves to the next higher register, so the highlight in the top strip travels right. The bottom strip uses the lookup you pick; base[(i + t) mod n] sends it left, which is RNG_REVERSE. The ruler marks the fields where Deluxe Paint I's accumulator stepped: rate 3072 gives gaps of 5, 5 and 6. Type a rate above 16384 and the signed 16-bit counter wraps, so the strip slows down. The Canvas Cycle clock divides by 280 and falls behind the ILBM formula.

Rate scales linearly, with 16384 meaning 60 steps per second, so steps per second is rate x 60 / 16384 and one step per second is about 273.[14] That formula describes a , not a schedule. Deluxe Paint I defines #define OnePerTick 16384, adds rate to a count in a vertical-blank interrupt server, and steps once when count >= OnePerTick, subtracting it.[20] It is an if, not a while: at most one step per field.

The gaps follow from that loop. Rate 3072 steps with gaps of 5, 5 and 6 fields, 11.25 steps per second on average. Rate 2730 steps every 6 fields, with one 7-field gap about every 680 steps. count is a signed 16-bit SHORT, so rates above 16384 overflow, and a higher rate steps slower: rate 20000 steps 2,030 times in 6,000 fields, about 20 per second where the formula predicts 73.

Real files don't carry arbitrary rates. Deluxe Paint I maps its 64 speed positions logarithmically in rateFromIndex, from 36 to 8192, eight steps per octave, 30 steps per second at the top.[20] 161 of the 162 live ranges in the Amiga Graphics Archive's cycling files use exactly those values; the exception is 2730, Deluxe Paint I's default OnePerSec*10 in C integer arithmetic.[4][20] On a 50 Hz machine the same code would step at 50/60 of the nominal rate, which is inference: no source documents Deluxe Paint on PAL.

Later readers keep their own clocks. Canvas Cycle computes phase from milliseconds since load, so a dropped frame skips states instead of slowing the cycle.[19] Tilengine's converted scenes store floor(16384 / rate) frames per step, so rate 4914 plays at 20 steps per second instead of 18.[36]

Cycling in shipped games, read from source:

GameWhat cyclesTimingSource
Sonic the Hedgehog (Mega Drive) Green Hill water: a 4-step table into palette line 3, colors 8-B every 6 frames [8]
Command & Conquer water entries 32-38; entry 255 pulses for the radar glow 1/4 s; 1/8 s [7]
Diablo, via DevilutionX caves: entries 1-31 once per game tick, 50 ms at the default speed [34]
Transport Tycoon Deluxe, via OpenTTD 28 entries, 227-254 per 27 ms game tick [9]
Doom nothing cycles: 14 PLAYPAL palettes are swapped on damage and pickups per counter [37]
Rotate a range: in place, by gather, and on Deluxe Paint's clock
#include <array>
#include <cstddef>
#include <cstdint>
#include <span>

// Restated so this listing compiles on its own.
struct Rgb8 { std::uint8_t red; std::uint8_t green; std::uint8_t blue; };
enum class CycleDirection : std::uint8_t { Forward, Reverse };
struct CycleRange { std::uint8_t low; std::uint8_t high; CycleDirection direction; };

// Deluxe Paint I's #define OnePerTick: the count a range reaches to step once.
inline constexpr std::int16_t ONE_PER_TICK = 16384;

// The bounds are inclusive, so a one-entry range has length 1 and an inverted
// record (high below low) describes no registers at all.
std::size_t range_length(const CycleRange& range) {
    if (range.high < range.low) return 0;
    return static_cast<std::size_t>(range.high - range.low) + 1;
}

// One step of Deluxe Paint I's CCYCLE.C loop: register j takes what j - 1 held,
// and the low register takes what the high one held. The ILBM spec describes
// the same motion in words, colors moving to the next higher position. Reverse
// is the mirror image, which is what the flags word's 0x2 bit selects.
void rotate_range_one_step(std::span<Rgb8> palette, const CycleRange& range) {
    const std::size_t count = range_length(range);
    const std::size_t low = static_cast<std::size_t>(range.low);
    const std::size_t high = static_cast<std::size_t>(range.high);
    // A single entry cycles to itself, and files carry ranges that run past the
    // palette shipped with them, so neither case may touch memory.
    if (count < 2 || high >= palette.size()) return;
    if (range.direction == CycleDirection::Forward) {
        const Rgb8 wrapped = palette[high];   // saved first: the high entry is the one that comes back around
        for (std::size_t entry = high; entry > low; --entry) palette[entry] = palette[entry - 1];
        palette[low] = wrapped;
    } else {
        const Rgb8 wrapped = palette[low];
        for (std::size_t entry = low; entry < high; ++entry) palette[entry] = palette[entry + 1];
        palette[high] = wrapped;
    }
}

// The range as it stands after whole_steps steps, read from the untouched base
// palette. One pass whatever the step count, so a player can jump straight to a
// phase instead of replaying steps, and repeated rotation cannot drift. The two
// palettes must not overlap: every read comes from the base.
void gather_range(std::span<const Rgb8> base_palette, std::span<Rgb8> out_palette,
                  const CycleRange& range, std::int64_t whole_steps) {
    const std::size_t shared = base_palette.size() < out_palette.size() ? base_palette.size() : out_palette.size();
    for (std::size_t entry = 0; entry < shared; ++entry) out_palette[entry] = base_palette[entry];   // entries outside the range keep their base color
    const std::size_t count = range_length(range);
    if (count < 2 || static_cast<std::size_t>(range.high) >= shared) return;
    const std::int64_t length = static_cast<std::int64_t>(count);
    const std::int64_t steps = ((whole_steps % length) + length) % length;   // folds negative and very large counts into 0..length-1
    const std::size_t low = static_cast<std::size_t>(range.low);
    for (std::int64_t slot = 0; slot < length; ++slot) {
        // Forward reads from further back in the base, which is what puts base
        // color c on register c + k. Reverse is the one-liner most shader code
        // reaches for, base[(index + k) mod n], and it runs the other way.
        const std::int64_t source = (range.direction == CycleDirection::Forward)
            ? (slot - steps + length) % length
            : (slot + steps) % length;
        out_palette[low + static_cast<std::size_t>(slot)] = base_palette[low + static_cast<std::size_t>(source)];
    }
}

// Deluxe Paint I's Range.count: a signed 16-bit SHORT, carried in the file.
struct FieldAccumulator { std::int16_t count; };

// One vertical-blank field of a range's clock. The rate is added to the count,
// and the range steps once when the count reaches OnePerTick.
bool advance_one_field(FieldAccumulator& accumulator, std::int16_t rate) {
    // C++20 defines conversion to a signed type as modular, so the cast
    // reproduces the SHORT's wraparound rather than relying on signed overflow.
    // Rates above 16384 land on a negative count here and step less often.
    accumulator.count = static_cast<std::int16_t>(accumulator.count + rate);
    if (accumulator.count < ONE_PER_TICK) return false;
    // Subtract rather than clear: the leftover phase carries into the next
    // field, which is what makes the gaps between steps uneven.
    accumulator.count = static_cast<std::int16_t>(accumulator.count - ONE_PER_TICK);
    return true;   // an if, not a while: at most one step per field
}

// DOPALETT.C's rateFromIndex. The speed slider's 64 positions are logarithmic,
// eight per octave, covering rates 36 to 8192 (0.13 to 30 steps per second).
std::int16_t rate_from_slider(std::uint8_t slider_position) {
    // The program adds one before splitting the value, so the octave and the
    // interpolation come from 1..64. The mask keeps a stray argument from
    // shifting past the width of an int.
    const int position = (slider_position & 63) + 1;
    const int octave_base = 1 << ((position >> 3) + 5);   // the three high bits pick the octave: 32, 64, 128 up to 8192
    return static_cast<std::int16_t>(octave_base + (((position & 7) * octave_base) >> 3));   // the low three bits interpolate in eighths
}

// The ILBM rate unit: 16384 means 60 steps per second, scaled linearly. This is
// the nominal rate in the file; the accumulator above is what a viewer gets.
double spec_steps_per_second(std::int32_t rate) {
    return static_cast<double>(rate) * 60.0 / 16384.0;
}
// Restated so this listing compiles on its own.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Rgb8 { pub red: u8, pub green: u8, pub blue: u8 }
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CycleDirection { Forward, Reverse }
#[derive(Clone, Copy, Debug)]
pub struct CycleRange { pub low: u8, pub high: u8, pub direction: CycleDirection }

// Deluxe Paint I's #define OnePerTick: the count a range reaches to step once.
pub const ONE_PER_TICK: i16 = 16384;

// The bounds are inclusive, so a one-entry range has length 1 and an inverted
// record (high below low) describes no registers at all.
pub fn range_length(range: &CycleRange) -> usize {
    if range.high < range.low { return 0; }
    (range.high - range.low) as usize + 1
}

// One step of Deluxe Paint I's CCYCLE.C loop: register j takes what j - 1 held,
// and the low register takes what the high one held. The ILBM spec describes
// the same motion in words, colors moving to the next higher position. Reverse
// is the mirror image, which is what the flags word's 0x2 bit selects.
pub fn rotate_range_one_step(palette: &mut [Rgb8], range: &CycleRange) {
    let count = range_length(range);
    let low = range.low as usize;
    let high = range.high as usize;
    // A single entry cycles to itself, and files carry ranges that run past the
    // palette shipped with them, so neither case may touch memory.
    if count < 2 || high >= palette.len() { return; }
    match range.direction {
        CycleDirection::Forward => {
            let wrapped = palette[high];   // saved first: the high entry is the one that comes back around
            for entry in (low + 1..=high).rev() { palette[entry] = palette[entry - 1]; }
            palette[low] = wrapped;
        }
        CycleDirection::Reverse => {
            let wrapped = palette[low];
            for entry in low..high { palette[entry] = palette[entry + 1]; }
            palette[high] = wrapped;
        }
    }
}

// The range as it stands after whole_steps steps, read from the untouched base
// palette. One pass whatever the step count, so a player can jump straight to a
// phase instead of replaying steps, and repeated rotation cannot drift.
pub fn gather_range(base_palette: &[Rgb8], out_palette: &mut [Rgb8], range: &CycleRange, whole_steps: i64) {
    let shared = base_palette.len().min(out_palette.len());
    out_palette[..shared].copy_from_slice(&base_palette[..shared]);   // entries outside the range keep their base color
    let count = range_length(range);
    if count < 2 || range.high as usize >= shared { return; }
    let length = count as i64;
    let steps = whole_steps.rem_euclid(length);   // folds negative and very large counts into 0..length-1
    let low = range.low as usize;
    for slot in 0..length {
        // Forward reads from further back in the base, which is what puts base
        // color c on register c + k. Reverse is the one-liner most shader code
        // reaches for, base[(index + k) mod n], and it runs the other way.
        let source = match range.direction {
            CycleDirection::Forward => (slot - steps).rem_euclid(length),
            CycleDirection::Reverse => (slot + steps) % length,
        };
        out_palette[low + slot as usize] = base_palette[low + source as usize];
    }
}

// Deluxe Paint I's Range.count: a signed 16-bit SHORT, carried in the file.
pub struct FieldAccumulator { pub count: i16 }

// One vertical-blank field of a range's clock. The rate is added to the count,
// and the range steps once when the count reaches OnePerTick.
pub fn advance_one_field(accumulator: &mut FieldAccumulator, rate: i16) -> bool {
    // wrapping_add reproduces the SHORT's wraparound instead of panicking on
    // overflow, so rates above 16384 land on a negative count and step less
    // often, which is what the original does.
    accumulator.count = accumulator.count.wrapping_add(rate);
    if accumulator.count < ONE_PER_TICK { return false; }
    // Subtract rather than clear: the leftover phase carries into the next
    // field, which is what makes the gaps between steps uneven.
    accumulator.count -= ONE_PER_TICK;
    true   // an if, not a while: at most one step per field
}

// DOPALETT.C's rateFromIndex. The speed slider's 64 positions are logarithmic,
// eight per octave, covering rates 36 to 8192 (0.13 to 30 steps per second).
pub fn rate_from_slider(slider_position: u8) -> i16 {
    // The program adds one before splitting the value, so the octave and the
    // interpolation come from 1..64. The mask keeps a stray argument from
    // shifting past the width of the integer.
    let position = (slider_position & 63) as i32 + 1;
    let octave_base = 1i32 << ((position >> 3) + 5);   // the three high bits pick the octave: 32, 64, 128 up to 8192
    (octave_base + (((position & 7) * octave_base) >> 3)) as i16   // the low three bits interpolate in eighths
}

// The ILBM rate unit: 16384 means 60 steps per second, scaled linearly. This is
// the nominal rate in the file; the accumulator above is what a viewer gets.
pub fn spec_steps_per_second(rate: i32) -> f64 {
    rate as f64 * 60.0 / 16384.0
}
Forward is a convention, and the obvious one-liner gets it backwards

palette[(index + t) % n] is ILBM reverse. If your engine reads CRNG files, write the forward gather as base[low + ((j - k) mod n)] and test one step against the spec sentence before trusting either.

06Deluxe Paint and the CRNG chunk

Cycling reached disk inside EA IFF 85, Jerry Morrison's container standard of January 14, 1985. An IFF chunk is a four-character ID, a big-endian LONG size, and that many bytes, with a pad byte after an odd size that the size doesn't count. Readers skip IDs they don't know, and every size is a virtual end of file[14]. The ILBM appendix: "You can NOT ever depend on any particular ILBM chunk being at any particular offset into the file!"[14]

A holds 8 bytes, WORD pad1; WORD rate; WORD flags; UBYTE low, high;, with RNG_ACTIVE 1 and RNG_REVERSE 2. The comment on the first field reads "reserved for future use; store 0 here"[14].

Deluxe Paint I doesn't leave pad1 empty. It holds a range as {SHORT count; SHORT rate; BOOL active; UBYTE low, high;} and dumps that struct with PutCk(..., ID_CRNG, sizeof(Range), ...), so pad1 carries the accumulator phase from §5[20]. Of 315 CRNG chunks in 87 archived ILBMs, 249 have a non-zero pad1, 246 of them between 1 and 16,383, the range of a phase kept below 16384[4]. The source doesn't say why.

That field was a boolean first: the January 1986 text defines WORD active; /* nonzero means cycle the colors */. The October 1988 revision renames it flags, adds RNG_REVERSE, and keeps "The field active indicates whether color cycling is on or off."[14]

Commodore's iffp/ilbm.h makes rate 36 a sentinel: #define RNG_NORATE 36 /* Dpaint uses this rate to mean non-active */, the slowest position of Deluxe Paint I's slider[38][20]. The warning that "one popular paint package always sets the RNG_ACTIVE bit" survives only in the AmigaOS wiki copy, and 22 of the 29 rate-36 chunks in the archive files have the active bit clear[14][4].

Three records from real files, chunk header then payload: two from the Deluxe Paint "Yacht" sample[4], one from a Unity importer's sample PBM, writing tool unidentified[39].

43 52 4E 47 00 00 00 08  1F 94 07 00 00 01 04 07   live range: pad1 0x1F94, rate 1792, flags 1, registers 4-7
43 52 4E 47 00 00 00 08  0A 5D 00 FC ED 55 00 00   unused slot: garbage flags 0xED55 set bit 0; low equals high
43 52 4E 47 00 00 00 08  00 00 0A AA 01 00 20 23   flags bytes 01 00, a byte-swapped RNG_ACTIVE

Register order is no safer than the flags: 121 of the 315 archive chunks have low >= high[4].

Deluxe Paint II Enhanced gave the PC 16 gradients, each with its own speed, and made the rate the off switch: "You can turn cycling off for a particular gradient by setting the rate to zero"[40]. PC Deluxe Paint wrote both chunky PBM files and FORM ILBM[14][41]. Flag conventions then split by writer. Mark Ferrari's 48 archived LBMs are FORM PBM, the form type padded to four characters with a trailing space, 640 by 480, 16 CRNG chunks each, EA bits set on active ranges: flags 1 or 3[35]. Canvas Cycle's published scenes store those ranges as 0 and 2, bit 0 cleared, for no documented reason[19]. Future Crew's Second Reality art carries 848 CRNG chunks, RNG_ACTIVE never set, a flag value of 4 that no spec defines, every rate 0[41]. The spec defines bit 0 as active and bit 1 as reverse. Real files deviate, and a reader has to pick a policy.

A DOS file's rates may not share the Amiga's clock either: EA's 1990 programmer's kit for DeluxePaint Animation stores cycles "for 18x/sec timer" or "70x/sec timer"[42].

Five shipping readers, five policies:

31 archive chunks carry flags 3, active and reverse on the Amiga, which Canvas Cycle ping-pongs[19][4].

Predict

An Amiga file stores an active, reversed range as flags 3. Before loading the second preset below, predict what each of the four readers does with it.

Live · One CRNG chunk, four readers
chunk bytes the ID and the size are fixed; the 8 payload bytes are editable
C43 R52 N4E G47 len00 len00 len00 len08 pad1 pad1 rate rate flags flags low high

Big-endian: the left byte of each pair is the high byte.

  1. 1 EA bitsflags & 1, rate > 0, high > low; reverse from flags & 2; rate × 60 / 16384 off
  2. 2 EA bits, rate 36 off (MonteMedia)flags & 1, rate > 36, high > low; same direction and speed off
  3. 3 Rate only (colcycle-style)rate > 0, high > low, active bit ignored; colcycle itself tests low != high off
  4. 4 Canvas Cycle palette.jsflags as a signed 16-bit mode number, rate / 280, no range check off
pad1
0x0000
rate
2730
flags
0x0001
low register
4
high register
15
range length
12 entries
flags bits 15 to 0 flags in binary: 0000000000000001 bit 1 RNG_REVERSE · bit 0 RNG_ACTIVE
Every preset is a chunk ID, a big-endian size of 8, and the 8-byte record; the four lanes decode the same bytes. The EA bits reader wants bit 0. The MonteMedia rule also treats rate 36 as off. The rate-only reader ignores the active bit. Canvas Cycle reads the whole flags word as a mode number, so the spec's active-and-reverse value ping-pongs. Presets marked synthetic are made up. The others are bytes from real files. Edit any payload byte to try your own. Lane motion is capped at 18 steps per second on screen, so preset 2 is drawn slower than it decodes; every verdict prints the true figure.

The eight presets, four readers:

PresetEA bits+ rate 36 offRate onlyCanvas Cycle
1 EA example forward, 9.998/s forward, 9.998/s forward, 9.998/s forward, 9.750/s
2 EA flags 3 reverse, 30.000/s reverse, 30.000/s reverse, 30.000/s ping-pong, 29.257/s
3 Yacht live forward, 6.563/s forward, 6.563/s forward, 6.563/s forward, 6.400/s
4 Yacht unused off: high isn't above low off: high isn't above low off: high isn't above low forward on a 1-entry range (no visible change), 0.900/s
5 Unity sample off: active bit clear off: active bit clear forward, 9.998/s broken: no mode for flags 256
6 TESTRAMP off: active bit clear off: active bit clear forward, 7.324/s forward, 7.143/s
7 Rate 36 forward, 0.132/s off: rate 36 forward, 0.132/s forward, 0.129/s
8 Second Reality off: active bit clear off: active bit clear off: rate 0 off: rate 0
Walk IFF chunks and decide what a CRNG range does
#include <array>
#include <cstddef>
#include <cstdint>
#include <optional>
#include <span>
#include <vector>

// IFF numbers are 68000 order, high byte first, on every platform, so the
// bytes are assembled by hand. Reading a header into a struct instead depends
// on the host's byte order, its padding and how wide its long is: Canvas
// Cycle's converter does that with an unsigned long, 8 bytes on 64-bit Linux
// and macOS, where the parse then walks off.
std::uint16_t read_u16_be(std::span<const std::uint8_t> bytes, std::size_t offset) {
    // A truncated file must not read past its end.
    if (offset + 2 > bytes.size()) return 0;
    return static_cast<std::uint16_t>((bytes[offset] << 8) | bytes[offset + 1]);
}

std::uint32_t read_u32_be(std::span<const std::uint8_t> bytes, std::size_t offset) {
    if (offset + 4 > bytes.size()) return 0;
    return (static_cast<std::uint32_t>(bytes[offset]) << 24)
         | (static_cast<std::uint32_t>(bytes[offset + 1]) << 16)
         | (static_cast<std::uint32_t>(bytes[offset + 2]) << 8)
         | static_cast<std::uint32_t>(bytes[offset + 3]);
}

struct IffChunk {
    std::array<std::uint8_t, 4> id;       // four ASCII characters, compared as bytes
    std::span<const std::uint8_t> data;   // a view into the file, not a copy
};

struct IffForm {
    // ILBM for Amiga planar files, PBM (with a trailing space) for DOS ones.
    std::array<std::uint8_t, 4> form_type;
    std::vector<IffChunk> chunks;
};

bool has_id(const IffChunk& chunk, char a, char b, char c, char d) {
    return chunk.id[0] == static_cast<std::uint8_t>(a)
        && chunk.id[1] == static_cast<std::uint8_t>(b)
        && chunk.id[2] == static_cast<std::uint8_t>(c)
        && chunk.id[3] == static_cast<std::uint8_t>(d);
}

// A FORM is the ID, a big-endian size, a 4-character form type, then chunks:
// ID, size, data, and a pad byte after an odd size that the size does not
// count. Nothing is found by seeking to a fixed offset; the ILBM spec says so.
std::optional<IffForm> read_iff_form(std::span<const std::uint8_t> file) {
    constexpr std::size_t HEADER_BYTES = 8;   // 4 for the ID, 4 for the size
    // Too short to hold FORM, a size and a form type.
    if (file.size() < HEADER_BYTES + 4) return std::nullopt;
    if (!(file[0] == 'F' && file[1] == 'O' && file[2] == 'R' && file[3] == 'M')) {
        return std::nullopt;
    }

    // The form size counts the form type and every chunk after it.
    const std::uint32_t form_size = read_u32_be(file, 4);
    if (form_size < 4) return std::nullopt;
    if (static_cast<std::uint64_t>(HEADER_BYTES) + form_size > file.size()) {
        return std::nullopt;                 // the size runs past the end of the file
    }

    IffForm form{};
    form.form_type = { file[8], file[9], file[10], file[11] };
    const std::size_t form_end = HEADER_BYTES + form_size;
    std::size_t offset = HEADER_BYTES + 4;

    // A partial header at the end stops the walk rather than failing the parse.
    while (offset + HEADER_BYTES <= form_end) {
        const std::uint32_t chunk_size = read_u32_be(file, offset + 4);
        const std::size_t data_start = offset + HEADER_BYTES;
        // Each size is a virtual end of file for its chunk, so data that runs
        // past the form is corruption rather than a short read.
        if (static_cast<std::uint64_t>(data_start) + chunk_size > form_end) {
            return std::nullopt;
        }
        IffChunk chunk{};
        chunk.id = { file[offset], file[offset + 1],
                     file[offset + 2], file[offset + 3] };
        chunk.data = file.subspan(data_start, chunk_size);
        form.chunks.push_back(chunk);        // unknown IDs are kept, never interpreted
        // An odd size is followed by a pad byte the size does not count.
        offset = data_start + chunk_size + (chunk_size & 1u);
    }
    return form;
}

struct CrngRecord {
    // The spec says store 0 in pad1; Deluxe Paint stored its step counter there.
    std::uint16_t pad1;
    std::uint16_t rate;    // 16384 is 60 steps per second in the ILBM definition
    // RNG_ACTIVE 1, RNG_REVERSE 2, and whatever else a writer felt like.
    std::uint16_t flags;
    std::uint8_t low;
    std::uint8_t high;
};

std::optional<CrngRecord> parse_crng(const IffChunk& chunk) {
    if (!has_id(chunk, 'C', 'R', 'N', 'G')) return std::nullopt;
    // The record is 8 bytes; a shorter chunk does not hold one.
    if (chunk.data.size() < 8) return std::nullopt;
    CrngRecord record{};
    record.pad1 = read_u16_be(chunk.data, 0);
    record.rate = read_u16_be(chunk.data, 2);
    record.flags = read_u16_be(chunk.data, 4);
    record.low = chunk.data[6];
    record.high = chunk.data[7];
    return record;
}

struct CcrtRecord {
    std::int16_t direction;    // 0 does not cycle, 1 forwards, -1 backwards
    std::uint8_t start;
    std::uint8_t end;
    std::int32_t seconds;      // a delay per step, not a rate
    std::int32_t microseconds;
};

std::optional<CcrtRecord> parse_ccrt(const IffChunk& chunk) {
    if (!has_id(chunk, 'C', 'C', 'R', 'T')) return std::nullopt;
    if (chunk.data.size() < 14) return std::nullopt;
    CcrtRecord record{};
    // IFF WORD and LONG are signed, and C++20 defines the conversion from the
    // unsigned value the reader built as modular: a backwards CCRT is 0xFFFF
    // on disk and -1 here.
    record.direction = static_cast<std::int16_t>(read_u16_be(chunk.data, 0));
    record.start = chunk.data[2];
    record.end = chunk.data[3];
    record.seconds = static_cast<std::int32_t>(read_u32_be(chunk.data, 4));
    record.microseconds = static_cast<std::int32_t>(read_u32_be(chunk.data, 8));
    return record;             // the trailing pad WORD carries nothing
}

double ccrt_steps_per_second(const CcrtRecord& record) {
    const std::int64_t delay = static_cast<std::int64_t>(record.seconds) * 1000000
                             + record.microseconds;
    if (delay <= 0) return 0.0;   // no delay is not an infinitely fast cycle
    return 1000000.0 / static_cast<double>(delay);
}

// The 1988 ILBM revision deprecated storing 4-bit guns left justified (0xF as
// 0xF0) in favor of scaling them (0xFF), and suggests treating a CMAP whose
// guns all have zero low bits as the old form. Scaled colors that happen to
// end in zero fool it, which is why it is a heuristic and not a flag.
bool cmap_is_left_justified(const IffChunk& cmap_chunk) {
    if (!has_id(cmap_chunk, 'C', 'M', 'A', 'P')) return false;
    if (cmap_chunk.data.empty()) return false;
    for (const std::uint8_t gun : cmap_chunk.data) {
        if ((gun & 0x0Fu) != 0u) return false;
    }
    return true;
}

enum class ReaderPolicy : std::uint8_t {
    EaBits, EaBitsRate36Off, RateOnly, CanvasCycle
};
enum class PlayMode : std::uint8_t { Off, Forward, Reverse, PingPong, Broken };
struct CycleDecision { PlayMode mode; double steps_per_second; };

// None of these four is the one right reader. The 1986 ILBM text called the
// word active, the 1988 revision named RNG_ACTIVE and RNG_REVERSE, Commodore's
// iffp/ilbm.h says Deluxe Paint used rate 36 to mean not cycling, and files
// break all of it: Future Crew's DOS art never sets bit 0, a Unity importer
// sample stores the flags word byte swapped, and Canvas Cycle reads the whole
// word as a mode number. Pick a policy and name it where the user can see it.
CycleDecision decide_cycle(const CrngRecord& record, ReaderPolicy policy) {
    // The ILBM unit: 16384 means 60 steps per second.
    constexpr double ILBM_RATE_FOR_60_STEPS = 16384.0;
    // palette.js CYCLE_SPEED, which is not 16384 / 60 = 273.07.
    constexpr double CANVAS_CYCLE_DIVISOR = 280.0;

    if (policy == ReaderPolicy::CanvasCycle) {
        // palette.js never tests a bit. It compares the flags word against mode
        // numbers, so an Amiga range saved as active and reverse (flags 3)
        // ping-pongs.
        if (record.rate == 0) return CycleDecision{ PlayMode::Off, 0.0 };
        const double steps_per_second = record.rate / CANVAS_CYCLE_DIVISOR;
        // Modular again: the garbage word 0xED55 reads as -4779.
        const std::int16_t mode = static_cast<std::int16_t>(record.flags);
        if (mode == 2) return CycleDecision{ PlayMode::Reverse, steps_per_second };
        if (mode == 3) return CycleDecision{ PlayMode::PingPong, steps_per_second };
        if (mode < 3) return CycleDecision{ PlayMode::Forward, steps_per_second };
        // Modes 4 and 5 call an undefined DFLOAT_MOD and throw. A larger value
        // matches no branch at all, and palette.js declares cycleAmount once
        // per call, so the range then shifts by whatever the previous range
        // left in that variable, or by nothing when there was no previous one.
        return CycleDecision{ PlayMode::Broken, 0.0 };
    }

    // The other three readers differ in whether they test the active bit and
    // in which rates they accept.
    if (policy != ReaderPolicy::RateOnly && (record.flags & 1u) == 0u) {
        return CycleDecision{ PlayMode::Off, 0.0 };
    }
    const std::uint32_t minimum_rate =
        (policy == ReaderPolicy::EaBitsRate36Off) ? 36u : 0u;
    if (static_cast<std::uint32_t>(record.rate) <= minimum_rate) {
        return CycleDecision{ PlayMode::Off, 0.0 };
    }
    // colcycle tests low != high. Testing high > low also rejects the inverted
    // pairs, and 121 of the 315 CRNG chunks in the archived Amiga files have
    // low >= high, most of them unused editor slots holding stale bytes.
    if (record.high <= record.low) return CycleDecision{ PlayMode::Off, 0.0 };
    const double steps_per_second = record.rate * 60.0 / ILBM_RATE_FOR_60_STEPS;
    const PlayMode mode =
        (record.flags & 2u) != 0u ? PlayMode::Reverse : PlayMode::Forward;
    return CycleDecision{ mode, steps_per_second };
}
// IFF numbers are 68000 order, high byte first, on every platform, so the
// bytes are assembled by hand. Reading a header into a struct instead depends
// on the host's byte order, its padding and how wide its long is: Canvas
// Cycle's converter does that with an unsigned long, 8 bytes on 64-bit Linux
// and macOS, where the parse then walks off.
pub fn read_u16_be(bytes: &[u8], offset: usize) -> u16 {
    // A truncated file must not read past its end.
    if offset + 2 > bytes.len() { return 0; }
    ((bytes[offset] as u16) << 8) | bytes[offset + 1] as u16
}

pub fn read_u32_be(bytes: &[u8], offset: usize) -> u32 {
    if offset + 4 > bytes.len() { return 0; }
    ((bytes[offset] as u32) << 24)
        | ((bytes[offset + 1] as u32) << 16)
        | ((bytes[offset + 2] as u32) << 8)
        | (bytes[offset + 3] as u32)
}

pub struct IffChunk<'a> {
    pub id: [u8; 4],      // four ASCII characters, compared as bytes
    pub data: &'a [u8],   // a view into the file, not a copy
}

pub struct IffForm<'a> {
    // ILBM for Amiga planar files, PBM (with a trailing space) for DOS ones.
    pub form_type: [u8; 4],
    pub chunks: Vec<IffChunk<'a>>,
}

pub fn has_id(chunk: &IffChunk<'_>, a: char, b: char, c: char, d: char) -> bool {
    chunk.id[0] == a as u8
        && chunk.id[1] == b as u8
        && chunk.id[2] == c as u8
        && chunk.id[3] == d as u8
}

// A FORM is the ID, a big-endian size, a 4-character form type, then chunks:
// ID, size, data, and a pad byte after an odd size that the size does not
// count. Nothing is found by seeking to a fixed offset; the ILBM spec says so.
pub fn read_iff_form(file: &[u8]) -> Option<IffForm<'_>> {
    const HEADER_BYTES: usize = 8;   // 4 for the ID, 4 for the size
    // Too short to hold FORM, a size and a form type.
    if file.len() < HEADER_BYTES + 4 { return None; }
    if file[0..4] != *b"FORM" { return None; }

    // The form size counts the form type and every chunk after it.
    let form_size = read_u32_be(file, 4) as usize;
    if form_size < 4 { return None; }
    if HEADER_BYTES + form_size > file.len() {
        return None;                 // the size runs past the end of the file
    }

    let mut form = IffForm {
        form_type: [file[8], file[9], file[10], file[11]],
        chunks: Vec::new(),
    };
    let form_end = HEADER_BYTES + form_size;
    let mut offset = HEADER_BYTES + 4;

    // A partial header at the end stops the walk rather than failing the parse.
    while offset + HEADER_BYTES <= form_end {
        let chunk_size = read_u32_be(file, offset + 4) as usize;
        let data_start = offset + HEADER_BYTES;
        // Each size is a virtual end of file for its chunk, so data that runs
        // past the form is corruption rather than a short read.
        if data_start + chunk_size > form_end {
            return None;
        }
        form.chunks.push(IffChunk {  // unknown IDs are kept, never interpreted
            id: [file[offset], file[offset + 1], file[offset + 2], file[offset + 3]],
            data: &file[data_start..data_start + chunk_size],
        });
        // An odd size is followed by a pad byte the size does not count.
        offset = data_start + chunk_size + (chunk_size & 1);
    }
    Some(form)
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CrngRecord {
    // The spec says store 0 in pad1; Deluxe Paint stored its step counter there.
    pub pad1: u16,
    pub rate: u16,     // 16384 is 60 steps per second in the ILBM definition
    pub flags: u16,    // RNG_ACTIVE 1, RNG_REVERSE 2, and whatever a writer felt like
    pub low: u8,
    pub high: u8,
}

pub fn parse_crng(chunk: &IffChunk<'_>) -> Option<CrngRecord> {
    if !has_id(chunk, 'C', 'R', 'N', 'G') { return None; }
    // The record is 8 bytes; a shorter chunk does not hold one.
    if chunk.data.len() < 8 { return None; }
    Some(CrngRecord {
        pad1: read_u16_be(chunk.data, 0),
        rate: read_u16_be(chunk.data, 2),
        flags: read_u16_be(chunk.data, 4),
        low: chunk.data[6],
        high: chunk.data[7],
    })
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CcrtRecord {
    pub direction: i16,    // 0 does not cycle, 1 forwards, -1 backwards
    pub start: u8,
    pub end: u8,
    pub seconds: i32,      // a delay per step, not a rate
    pub microseconds: i32,
}

pub fn parse_ccrt(chunk: &IffChunk<'_>) -> Option<CcrtRecord> {
    if !has_id(chunk, 'C', 'C', 'R', 'T') { return None; }
    if chunk.data.len() < 14 { return None; }
    // IFF WORD and LONG are signed, and an as-cast between integers of one
    // width is the modular conversion the C++ pane relies on: a backwards CCRT
    // is 0xFFFF on disk and -1 here. The trailing pad WORD carries nothing.
    Some(CcrtRecord {
        direction: read_u16_be(chunk.data, 0) as i16,
        start: chunk.data[2],
        end: chunk.data[3],
        seconds: read_u32_be(chunk.data, 4) as i32,
        microseconds: read_u32_be(chunk.data, 8) as i32,
    })
}

pub fn ccrt_steps_per_second(record: &CcrtRecord) -> f64 {
    let delay = record.seconds as i64 * 1000000 + record.microseconds as i64;
    if delay <= 0 { return 0.0; }   // no delay is not an infinitely fast cycle
    1000000.0 / delay as f64
}

// The 1988 ILBM revision deprecated storing 4-bit guns left justified (0xF as
// 0xF0) in favor of scaling them (0xFF), and suggests treating a CMAP whose
// guns all have zero low bits as the old form. Scaled colors that happen to
// end in zero fool it, which is why it is a heuristic and not a flag.
pub fn cmap_is_left_justified(cmap_chunk: &IffChunk<'_>) -> bool {
    if !has_id(cmap_chunk, 'C', 'M', 'A', 'P') { return false; }
    if cmap_chunk.data.is_empty() { return false; }
    cmap_chunk.data.iter().all(|&gun| (gun & 0x0F) == 0)
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReaderPolicy { EaBits, EaBitsRate36Off, RateOnly, CanvasCycle }

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PlayMode { Off, Forward, Reverse, PingPong, Broken }

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CycleDecision { pub mode: PlayMode, pub steps_per_second: f64 }

// None of these four is the one right reader. The 1986 ILBM text called the
// word active, the 1988 revision named RNG_ACTIVE and RNG_REVERSE, Commodore's
// iffp/ilbm.h says Deluxe Paint used rate 36 to mean not cycling, and files
// break all of it: Future Crew's DOS art never sets bit 0, a Unity importer
// sample stores the flags word byte swapped, and Canvas Cycle reads the whole
// word as a mode number. Pick a policy and name it where the user can see it.
pub fn decide_cycle(record: &CrngRecord, policy: ReaderPolicy) -> CycleDecision {
    // The ILBM unit: 16384 means 60 steps per second.
    const ILBM_RATE_FOR_60_STEPS: f64 = 16384.0;
    // palette.js CYCLE_SPEED, which is not 16384 / 60 = 273.07.
    const CANVAS_CYCLE_DIVISOR: f64 = 280.0;

    if policy == ReaderPolicy::CanvasCycle {
        // palette.js never tests a bit. It compares the flags word against mode
        // numbers, so an Amiga range saved as active and reverse (flags 3)
        // ping-pongs.
        if record.rate == 0 {
            return CycleDecision { mode: PlayMode::Off, steps_per_second: 0.0 };
        }
        let steps_per_second = record.rate as f64 / CANVAS_CYCLE_DIVISOR;
        // Modular again: the garbage word 0xED55 reads as -4779.
        let mode = record.flags as i16;
        if mode == 2 {
            return CycleDecision { mode: PlayMode::Reverse, steps_per_second };
        }
        if mode == 3 {
            return CycleDecision { mode: PlayMode::PingPong, steps_per_second };
        }
        if mode < 3 {
            return CycleDecision { mode: PlayMode::Forward, steps_per_second };
        }
        // Modes 4 and 5 call an undefined DFLOAT_MOD and throw. A larger value
        // matches no branch at all, and palette.js declares cycleAmount once
        // per call, so the range then shifts by whatever the previous range
        // left in that variable, or by nothing when there was no previous one.
        return CycleDecision { mode: PlayMode::Broken, steps_per_second: 0.0 };
    }

    // The other three readers differ in whether they test the active bit and
    // in which rates they accept.
    if policy != ReaderPolicy::RateOnly && (record.flags & 1) == 0 {
        return CycleDecision { mode: PlayMode::Off, steps_per_second: 0.0 };
    }
    let minimum_rate: u16 =
        if policy == ReaderPolicy::EaBitsRate36Off { 36 } else { 0 };
    if record.rate <= minimum_rate {
        return CycleDecision { mode: PlayMode::Off, steps_per_second: 0.0 };
    }
    // colcycle tests low != high. Testing high > low also rejects the inverted
    // pairs, and 121 of the 315 CRNG chunks in the archived Amiga files have
    // low >= high, most of them unused editor slots holding stale bytes.
    if record.high <= record.low {
        return CycleDecision { mode: PlayMode::Off, steps_per_second: 0.0 };
    }
    let steps_per_second = record.rate as f64 * 60.0 / ILBM_RATE_FOR_60_STEPS;
    let mode =
        if (record.flags & 2) != 0 { PlayMode::Reverse } else { PlayMode::Forward };
    CycleDecision { mode, steps_per_second }
}
What's intentionally missing

ByteRun1 BODY decoding and row decoding for planar ILBM and chunky PBM; CAMG masking, HAM and Extra Half-Brite palettes, where cycling a register also cycles its half-bright twin (MonteMedia handles it[43]); DRNG cell rings; LIST, CAT and PROP containers; writing files. The listing also never reads a struct with fread: Canvas Cycle's converter reads chunk headers into one holding an unsigned long length, 8 bytes on an LP64 target, so the struct is 16 bytes and the parse breaks[19].

CRNG wasn't the only range chunk. Commodore's Graphicraft wrote CCRT: a direction (0 off, 1, -1), start and end registers, and a delay per step in seconds plus microseconds. The archived "Champagne" sample cycles registers 24-28 every 66,176 µs, 15.11 steps per second[14][4]. Reading +1 as CRNG forward or reverse is interpretation: GrafX2 and MonteMedia both take it as reverse, and no Graphicraft document settles it[43]. Deluxe Paint IV's DRNG allows non-contiguous and true-color cells, and defines no reverse bit at all[14].

07The Boing Ball, read from its code

Bob Pariseau of Commodore-Amiga described the demo in BYTE, March 1986: "The ball rotation is done by color cycle animation. Each red and white patch on the ball is actually 7 strips of color", and "We assign 14 of the 32 color registers to ball rotation." The bounce changes "the start-of-data pointers for the ball/shadow bit planes with respect to the grid bit plane", and "no blitting is involved". The part he calls difficult is the slow end: "the hard thing is making the ball go slowly and still appear to be smooth."[13]

The code backs that account and corrects parts of it. A disassembly of the demo, commented by Harry Sintonen, sits on the companion site for Jimmy Maher's The Future Was Here, next to Maher's C reconstruction boing4.c[44]. The listing carries compiler labels and calls into a C floating-point runtime, so Boing is compiled code, not hand-written assembly.

Predict

How many bitplanes hold the ball, and which plane holds its shadow?

Live · The Boing Ball, rebuilt from its register values
words / step
28
bytes / step
56
pixel writes
0
copy x & 15
8
plane 4 offset
0
strips move
◀ left
Planes 0 to 3 hold the ball and its shadow, drawn once; plane 4 holds the grid. Each spin step rewrites registers 2 to 15 and copies them to 18 to 31, 56 bytes, while the pixel-write counter stays at zero. Turn the mirror off and grid lines cut through the ball, because a ball pixel over the grid reads 16 higher. Turn the counter-offset off and the grid rides along with the ball. The register roles, the 14-entry table with its $FDD strip, the mirror and the grid-pointer formula follow the code; the ball geometry, speeds and square pixels are approximations. The planes are composited on the CPU each frame because a canvas has no bitplanes.

The display is a 320 by 200 screen of depth 5 over a 336 by 216 bitmap, out of one chip RAM allocation of 40,824 bytes: a 4,536-byte lead-in, then four planes of 9,072 bytes each, 42 bytes by 216 rows[44].

Planes 0 to 3 hold the ball and its shadow, drawn once at startup with the raster mask set to 15; plane 4 holds the grid. The shadow is palette index 1 inside those ball , not a plane of its own: decoding the ball bitmap in boing4.c gives 2,503 pixels of index 1, a crescent offset to the right of the ball[44].

Startup sets four registers: 0 to $AAA for the background, 1 to $666 for the shadow, 16 to $A0A for the grid, 17 to $606 for shadow over grid[44].

Each spin step rewrites registers 2 to 15 with seven red $F00 strips, six white $FFF, and one reddish-white $FDD placed at the leading or trailing edge of the white band depending on direction. The same 14 values then go to registers 18 to 31. A ball pixel over the grid has plane 4 set, so its index is k + 16, and the copy makes the two read identically. Fourteen registers written twice is 28 register words, 56 bytes[44].

The step direction follows the horizontal scroll, if (x_scroll>0) color_cycle--; else color_cycle++;, so the spin reverses on a wall bounce[44].

Those writes go into the ViewPort's ColorMap, followed by MakeScreen() and RethinkDisplay(), which rebuild the Copper list every frame[44]. "Nothing is redrawn" is true for pixels and false for the Copper list.

The bounce scrolls the bitmap through RxOffset and RyOffset and moves the plane 4 pointer the other way, so the grid stays put while the ball travels. A bitplane can begin only on an even byte boundary, so the program keeps 16 copies of the grid plane, each shifted one pixel, and selects one: Planes[4]=BGPtr[x_pos&15]-((x_pos>>4)*2)-(y_pos*42)[44].

Jez San of Argonaut Software, posting on BIX's Atari board, put the contrast with the Atari ST version in machine terms: the Amiga ball "moves the instant that the bit-plane Start registers have been altered, whereas the Atari one must block move the entire beachball"[13].

Boing's spin registers and grid pointer
#include <array>
#include <cstddef>
#include <cstdint>

// COLOR00 to COLOR31 as $0RGB words: every color a 5-bitplane OCS screen can
// resolve.
using OcsPalette = std::array<std::uint16_t, 32>;

// Which of the 16 pre-shifted copies of the grid plane 4 should point at, and
// how far to back that pointer up from the copy's start.
struct GridPlanePick {
    std::uint32_t copy_index;
    std::int64_t byte_offset;
    friend bool operator==(const GridPlanePick&,
                           const GridPlanePick&) = default;
};

inline constexpr std::int32_t STRIP_COUNT = 14;      // registers 2-15 hold strips
inline constexpr std::uint16_t STRIP_WHITE = 0xFFF;
inline constexpr std::uint16_t STRIP_RED = 0xF00;
inline constexpr std::uint16_t STRIP_BLUR = 0xFDD;   // the code calls it blur
inline constexpr std::int32_t GRID_ROW_BYTES = 42;   // 336 pixels is 42 bytes
inline constexpr std::size_t MIRROR_DISTANCE = 16;   // plane 4 adds 16 to an index

// The four registers the program sets once and never rotates: background, the
// ball's shadow, and the same two seen through the grid plane. The rest start
// black.
OcsPalette boing_initial_palette() {
    OcsPalette palette{};
    palette[0] = 0xAAA;    // background gray
    palette[1] = 0x666;    // the shadow over the background
    palette[16] = 0xA0A;   // background with plane 4 set: the purple grid
    palette[17] = 0x606;   // grid where the shadow darkens it
    return palette;
}

// boing4.c steps the phase down while the ball moves right and up while it
// moves left, wrapping into 0..13. The ball reverses its spin on a wall bounce
// because the same sign flip that reverses x_scroll reverses this.
std::int32_t next_color_cycle(std::int32_t color_cycle, std::int32_t x_scroll) {
    std::int32_t next = (x_scroll > 0) ? color_cycle - 1 : color_cycle + 1;
    if (next == -1) {
        next = STRIP_COUNT - 1;
    } else if (next == STRIP_COUNT) {
        next = 0;
    }
    return next;
}

// Seven white strips start at the current phase and seven red fill the rest,
// then the blur strip overwrites one white: six white, one blur, seven red,
// every step. Slot s lives in register 2 + s. With the mirror on, the same
// value also goes to register 18 + s, so a ball pixel that sits over the grid
// plane reads 16 higher and finds the same color; with it off, grid lines cut
// across the ball.
void write_rotation_registers(OcsPalette& palette, std::int32_t color_cycle,
                              std::int32_t x_scroll, bool mirror) {
    // Normalized so a phase that has drifted outside 0..13 still names a slot.
    const std::int32_t phase =
        ((color_cycle % STRIP_COUNT) + STRIP_COUNT) % STRIP_COUNT;
    for (std::int32_t strip = 0; strip < STRIP_COUNT; ++strip) {
        const std::int32_t slot = (phase + strip) % STRIP_COUNT;
        const std::uint16_t value = (strip < 7) ? STRIP_WHITE : STRIP_RED;
        const std::size_t register_index = static_cast<std::size_t>(2 + slot);
        palette[register_index] = value;
        if (mirror) {
            palette[register_index + MIRROR_DISTANCE] = value;
        }
    }
    // The blur strip replaces one end of the white band: the phase slot when
    // the ball moves right, the slot six higher when it moves left. The code
    // picks by direction and gives no reason, so this does not offer one.
    const std::int32_t blur_slot =
        (x_scroll > 0) ? phase : (phase + 6) % STRIP_COUNT;
    const std::size_t blur_index = static_cast<std::size_t>(2 + blur_slot);
    palette[blur_index] = STRIP_BLUR;
    if (mirror) {
        palette[blur_index + MIRROR_DISTANCE] = STRIP_BLUR;
    }
}

// boing4.c: Planes[4] = BGPtr[x_pos & 15] - ((x_pos >> 4) * 2) - (y_pos * 42)
// A bitplane may start only on an even byte boundary, so a pointer cannot
// express a scroll finer than 16 pixels. The program draws the grid 16 times,
// each copy staggered one pixel from the one before, and picks the copy that
// matches the low four bits of x; what is left is a whole-word scroll, 2 bytes
// per 16 pixels, plus 42 bytes per row of vertical scroll. The subtraction is
// what keeps the grid still while the screen's scroll offsets move.
// C++20 defines >> on a negative signed value as an arithmetic shift and fixes
// two's complement, so a negative x_pos floors the way the 68000 code does.
GridPlanePick grid_plane_pick(std::int32_t x_pos, std::int32_t y_pos) {
    const std::uint32_t copy_index = static_cast<std::uint32_t>(x_pos & 15);
    const std::int64_t byte_offset = -static_cast<std::int64_t>(x_pos >> 4) * 2
        - static_cast<std::int64_t>(y_pos) * GRID_ROW_BYTES;
    return GridPlanePick{copy_index, byte_offset};
}
// COLOR00 to COLOR31 as $0RGB words: every color a 5-bitplane OCS screen can
// resolve.
pub type OcsPalette = [u16; 32];

// Which of the 16 pre-shifted copies of the grid plane 4 should point at, and
// how far to back that pointer up from the copy's start.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct GridPlanePick { pub copy_index: u32, pub byte_offset: i64 }

const STRIP_COUNT: i32 = 14;            // registers 2-15 hold the strips
const STRIP_WHITE: u16 = 0xFFF;
const STRIP_RED: u16 = 0xF00;
const STRIP_BLUR: u16 = 0xFDD;          // the code calls this one blur
const GRID_ROW_BYTES: i64 = 42;         // 336 pixels wide is 42 bytes per row
const MIRROR_DISTANCE: usize = 16;      // plane 4 adds 16 to a pixel's index

// The four registers the program sets once and never rotates: background, the
// ball's shadow, and the same two seen through the grid plane. The rest start
// black.
pub fn boing_initial_palette() -> OcsPalette {
    let mut palette: OcsPalette = [0; 32];
    palette[0] = 0xAAA;    // background gray
    palette[1] = 0x666;    // the shadow over the background
    palette[16] = 0xA0A;   // background with plane 4 set: the purple grid
    palette[17] = 0x606;   // grid where the shadow darkens it
    palette
}

// boing4.c steps the phase down while the ball moves right and up while it
// moves left, wrapping into 0..13. The ball reverses its spin on a wall bounce
// because the same sign flip that reverses x_scroll reverses this.
pub fn next_color_cycle(color_cycle: i32, x_scroll: i32) -> i32 {
    let mut next = if x_scroll > 0 { color_cycle - 1 } else { color_cycle + 1 };
    if next == -1 {
        next = STRIP_COUNT - 1;
    } else if next == STRIP_COUNT {
        next = 0;
    }
    next
}

// Seven white strips start at the current phase and seven red fill the rest,
// then the blur strip overwrites one white: six white, one blur, seven red,
// every step. Slot s lives in register 2 + s. With the mirror on, the same
// value also goes to register 18 + s, so a ball pixel that sits over the grid
// plane reads 16 higher and finds the same color; with it off, grid lines cut
// across the ball.
pub fn write_rotation_registers(palette: &mut OcsPalette, color_cycle: i32,
                                x_scroll: i32, mirror: bool) {
    // Normalized so a phase that has drifted outside 0..13 still names a slot.
    let phase = ((color_cycle % STRIP_COUNT) + STRIP_COUNT) % STRIP_COUNT;
    for strip in 0..STRIP_COUNT {
        let slot = (phase + strip) % STRIP_COUNT;
        let value = if strip < 7 { STRIP_WHITE } else { STRIP_RED };
        let register_index = (2 + slot) as usize;
        palette[register_index] = value;
        if mirror {
            palette[register_index + MIRROR_DISTANCE] = value;
        }
    }
    // The blur strip replaces one end of the white band: the phase slot when
    // the ball moves right, the slot six higher when it moves left. The code
    // picks by direction and gives no reason, so this does not offer one.
    let blur_slot = if x_scroll > 0 { phase } else { (phase + 6) % STRIP_COUNT };
    let blur_index = (2 + blur_slot) as usize;
    palette[blur_index] = STRIP_BLUR;
    if mirror {
        palette[blur_index + MIRROR_DISTANCE] = STRIP_BLUR;
    }
}

// boing4.c: Planes[4] = BGPtr[x_pos & 15] - ((x_pos >> 4) * 2) - (y_pos * 42)
// A bitplane may start only on an even byte boundary, so a pointer cannot
// express a scroll finer than 16 pixels. The program draws the grid 16 times,
// each copy staggered one pixel from the one before, and picks the copy that
// matches the low four bits of x; what is left is a whole-word scroll, 2 bytes
// per 16 pixels, plus 42 bytes per row of vertical scroll. The subtraction is
// what keeps the grid still while the screen's scroll offsets move.
// Rust defines >> on a signed integer as an arithmetic shift, so a negative
// x_pos floors the same way the 68000 code does.
pub fn grid_plane_pick(x_pos: i32, y_pos: i32) -> GridPlanePick {
    GridPlanePick {
        copy_index: (x_pos & 15) as u32,
        byte_offset: -((x_pos >> 4) as i64) * 2 - (y_pos as i64) * GRID_ROW_BYTES,
    }
}
Three descriptions the code contradicts
  1. "It uses 3 bitplanes, while the grid uses another bitplane and the shadow uses the last bitplane", from the Amiga Graphics Archive and copied widely. The ball and shadow share four planes, and the grid uses one[4][44].
  2. "8 copies of the background", a figure repeated in summaries of the demo. The grid loop runs 16 times, commented ;16 loops[44].
  3. Pariseau's "At any instant 7 are white and 7 are red" rounds off the register contents: 7 red, 6 white and one $FDD[13][44].

08Art built to cycle

Mark Ferrari did EGA background art at Lucasfilm Games for Zak McKracken, Loom and The Secret of Monkey Island.[45] His cycling scenes were built in "Deluxe Paint - later Deluxe Paint II", whose gradient fills and stencils did the work, and one image took "three days to three weeks, (8 to 14 hours per day)".[18] What a viewer sees, in his words: "each affected pixel on screen is holding completely still, like those marquee light bulbs, but changing colors in a looping sequence".[18]

The loop is the cheap part. Ferrari puts the warning plainly: "One cannot just take Joe's open source code and apply it to any 8 bit image, and expect more than a simulated acid trip to result."[18]

Ferrari reserves about half of the 256 entries for cycling ramps. Static objects get three to seven colors each; a sky gets 16 to 32.[21]

Speed comes out of the drawing, not the rate word. One gradient at one rate pours slowly over a lip and rushes lower down when the ramp is painted in short repeats near the top and long repeats near the base.[21] The mechanism is arithmetic: one full cycle carries a band across exactly one repeat of the gradient, so band speed is repeat length × steps per second / range length. Double the repeat length at the same rate and the band moves twice as fast.

Repeats have to meet somewhere, and at that row the wrap from the last color to the first reads as a hard line that flashes once per revolution. Ferrari fills that margin half with the last color of the gradient above and half with the first color of the one below.[21]

Foam cells filled from the same entry flash dark and light in unison, because every cell is reading the same slot on the same step. He restarts some cells at other entries of the ramp, so neighboring cells sit at different points in the same cycle.[21]

Two more moves from the same talk. A cycling texture filled into one color of a checkerboard, with a shifted copy of it in the other color, reads as two layers crossing each other.[21] The same pattern remapped onto gradients "with the same number of colors moving at the same speed in the same direction" but shifted in value and hue shades a moving surface (about 55:00, auto-captions).[21] Foliage edges get clown colors: "a one pixel wide line of two 'clown colors'" that stencils then fill with a moving gradient, half matching the leaf and half the background.[18]

The waterfall Ferrari builds live at GDC isn't one of the browser scenes: it came from a Spyro character-set game.[21] The published Jungle Waterfall data shows the same parallel-range pattern independently. Its fall is six consecutive 7-entry ranges covering entries 175 to 216, all at one rate: the ILBM rate word 2841 in the clear version, 10.4 steps per second, and 2730 in the rain version.[46]

Live · Speed from segment length
lip band speed
4.5 px/s
base band speed
17.8 px/s
speed ratio
4.00x
cells at peak foam
40 of 40
index rebuilds
0
index writes in the frame loop
0
The whole fall is one 7-entry range at one rate. Each full cycle carries a band down one repeat of the gradient, so the short repeats at the lip crawl and the long ones at the base rush: at the defaults, 3 px and 12 px repeats move about 4.5 and 17.8 px per second. The fall is filled as three blocks that each start on their own entry, and the dashed lines mark where they meet. Foam cells that all start on the same entry pulse together, and phase offsets break that up. The scene is original; the techniques are Ferrari's.

Dither is chosen per effect. "Most often I use patterned dither", for motion that stays smooth and regular; diffusion where he wanted a chaotic texture, the Sea Cave foam being his example; "no dither" for snow and often rain.[18]

Dither inside a range, not across its edge

No source states this one; it follows from how cycling works. A dither that mixes a cycling index with a static one animates half its pixels and shimmers at the cycle rate. A dither between adjacent entries of one range moves with the band, because both entries rotate together. And an error-diffusion quantizer allowed to spend reserved cycling entries scatters them into unrelated pixels, which then blink. Exult ships the fix: its nearest-color search skips the rotating entries, commented "But don't search rotating colors."[47]

Rain and snow that aren't drawn

A raindrop isn't a sprite over a background. It is "just a dot or dash of noticeably contrasting color on a long 'path' filled with equally opaque proceeding and following colors carefully adjusted to 'match' the background colors they are crossing".[18] Snow at GDC is the same shape: a gradient of about seven entries tuned to one background family except for one entry tuned to snow, repeated per family at the same length, the same speed, and the snow entry in the same position.[21] Those screens of raindrops and snowflakes were overlays baked into the image only when needed, and their rows of palette space held rainbows or stars at other times.[18]

The Jungle Waterfall rain version is the same picture with the paths painted in. Clear and rain differ in 64,998 of 307,200 pixels, 21.2%, and 64,988 of those pixels hold an index in 145 to 174, a band the clear image never uses. The rain is six 5-entry ranges, four at rate 4914 (18.0 steps per second) and two at 4641 (17.0), and five of the six ramps read background, background, tail, drop, background. In the night palette all six drop entries are (11, 15, 35) in the scene files.[46]

Live · Rain that isn't drawn
six ranges, five entries each

Each row is one family's range, left to right in slot order, drawn in the colors the palette holds this frame. The ring marks the slot holding the drop. Slots that carry the family background are invisible in the picture.

Each of the six rain ranges holds five palette entries. Three of them carry that family's background color, one carries the tail and one carries the drop. The drops in step readout below counts how many families hold their drop in the same slot as family zero.

when the set repeats

Ideal wall-clock rates; Deluxe Paint's field accumulator adds uneven gaps.

A range covers one background color, so the conifers, which aren't one of the six families, have no lane crossing them. The tree-shaped gaps in the revealed paths are what that costs.

cycling ranges
6
index writes
0
drops in step
6 of 6
Each rain lane is a diagonal run of pixels painted with a 5-entry range whose entries match the background except for a tail and a drop, the shape five of the six ramps have in Ferrari's Jungle Waterfall rain. Reveal the paths and the lanes that were there all along appear. With one rate every family's drops fall in lockstep; the scene data uses two, about 18 and 17 steps per second. The night palette sets every drop entry to (11, 15, 35) and the rain almost disappears, still without a pixel written. The landscape is original, not Ferrari's.

Recording a finished scene as a short loop doesn't work, because the ranges have different lengths and rates: "your GIF might have to be thousands or even tens of thousands of frames".[18] Shipped code states the same arithmetic. DevilutionX runs the Hellfire crypt as a 15-entry range stepping every other tick beside a 16-entry range stepping every tick, which its comment calls "240 frames before it loops", 12 s at the default 20 ticks per second; the hive comment says "56 frames", but a step there happens every third tick, so that loop is 168 ticks.[34] A set of ranges repeats at the LCM of their periods, which is what the listing computes: the two rain rates above realign every 4096/819 s, about 5.0 s.

When does a scene repeat?
#include <cstdint>
#include <numeric>
#include <span>

// A period in seconds as an exact fraction, always reduced. Floating point
// would make two ranges look coincident when they are seconds apart.
struct Rational { std::uint64_t numerator; std::uint64_t denominator; };

// std::gcd and std::lcm come from <numeric>. The Rust pane defines its own,
// because the Rust standard library has neither.
Rational make_rational(std::uint64_t numerator, std::uint64_t denominator) {
    if (numerator == 0 || denominator == 0) return Rational{0, 1};   // no motion and no clock both report zero
    const std::uint64_t divisor = std::gcd(numerator, denominator);
    return Rational{numerator / divisor, denominator / divisor};
}

// One revolution of a range at an ILBM rate. The range takes range_length steps
// at rate * 60 / 16384 steps per second, so it comes back around after
// range_length * 16384 / (60 * rate) seconds. This is the rate as the file
// defines it, not what Deluxe Paint's field accumulator delivers.
Rational range_period_seconds(std::uint64_t range_length, std::uint32_t rate) {
    if (rate == 0) return Rational{0, 1};   // rate 0 in a CRNG record means the range does not move
    return make_rational(range_length * 16384ull, static_cast<std::uint64_t>(rate) * 60ull);
}

// A scene repeats when every range has come back around a whole number of
// times: the LCM of the numerators over the GCD of the denominators. That
// identity holds only because every fraction here is already reduced. Deluxe
// Paint's field accumulator lands every step on a whole field, which makes
// real repeats longer than this ideal one. The LCM fits 64 bits for any set
// of non-overlapping ranges in a 256-entry palette; overlapping ranges with
// coprime lengths can exceed it, which is why the widget on this page
// accumulates in BigInt.
Rational joint_period_seconds(std::span<const Rational> periods) {
    std::uint64_t numerator = 0;
    std::uint64_t denominator = 0;
    for (const Rational& period : periods) {
        const Rational reduced = make_rational(period.numerator, period.denominator);
        if (reduced.numerator == 0) continue;   // a range that never moves cannot delay the repeat
        numerator = (numerator == 0) ? reduced.numerator : std::lcm(numerator, reduced.numerator);
        denominator = (denominator == 0) ? reduced.denominator : std::gcd(denominator, reduced.denominator);
    }
    if (numerator == 0) return Rational{0, 1};
    return make_rational(numerator, denominator);
}

// A cycle on an integer clock: range_length entries, one step every
// ticks_per_step ticks. Tilengine stores its converted scenes this way, and
// OpenTTD's game tick is the same kind of clock.
struct TickCycle { std::uint64_t range_length; std::uint64_t ticks_per_step; };

// Integer clocks need no fractions: each cycle repeats after
// range_length * ticks_per_step ticks, and the scene repeats at their LCM.
std::uint64_t joint_period_ticks(std::span<const TickCycle> cycles) {
    std::uint64_t total = 0;
    for (const TickCycle& cycle : cycles) {
        const std::uint64_t cycle_ticks = cycle.range_length * cycle.ticks_per_step;
        if (cycle_ticks == 0) continue;
        total = (total == 0) ? cycle_ticks : std::lcm(total, cycle_ticks);
    }
    return total;
}
// A period in seconds as an exact fraction, always reduced. Floating point
// would make two ranges look coincident when they are seconds apart.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Rational { pub numerator: u64, pub denominator: u64 }

// C++ takes these two from <numeric>; the Rust standard library has neither.
pub fn gcd(first: u64, second: u64) -> u64 {
    let mut dividend = first;
    let mut divisor = second;
    while divisor != 0 {
        let remainder = dividend % divisor;   // Euclid: the common divisor survives every remainder step
        dividend = divisor;
        divisor = remainder;
    }
    dividend
}

pub fn lcm(first: u64, second: u64) -> u64 {
    if first == 0 || second == 0 { return 0; }
    first / gcd(first, second) * second   // divide before multiplying, so the product stays as small as it can
}

pub fn make_rational(numerator: u64, denominator: u64) -> Rational {
    if numerator == 0 || denominator == 0 { return Rational { numerator: 0, denominator: 1 }; }   // no motion and no clock both report zero
    let divisor = gcd(numerator, denominator);
    Rational { numerator: numerator / divisor, denominator: denominator / divisor }
}

// One revolution of a range at an ILBM rate. The range takes range_length steps
// at rate * 60 / 16384 steps per second, so it comes back around after
// range_length * 16384 / (60 * rate) seconds. This is the rate as the file
// defines it, not what Deluxe Paint's field accumulator delivers.
pub fn range_period_seconds(range_length: u64, rate: u32) -> Rational {
    if rate == 0 { return Rational { numerator: 0, denominator: 1 }; }   // rate 0 in a CRNG record means the range does not move
    make_rational(range_length * 16384, rate as u64 * 60)
}

// A scene repeats when every range has come back around a whole number of
// times: the LCM of the numerators over the GCD of the denominators. That
// identity holds only because every fraction here is already reduced. Deluxe
// Paint's field accumulator lands every step on a whole field, which makes
// real repeats longer than this ideal one. The LCM fits 64 bits for any set
// of non-overlapping ranges in a 256-entry palette; overlapping ranges with
// coprime lengths can exceed it, which is why the widget on this page
// accumulates in BigInt.
pub fn joint_period_seconds(periods: &[Rational]) -> Rational {
    let mut numerator = 0u64;
    let mut denominator = 0u64;
    for period in periods {
        let reduced = make_rational(period.numerator, period.denominator);
        if reduced.numerator == 0 { continue; }   // a range that never moves cannot delay the repeat
        numerator = if numerator == 0 { reduced.numerator } else { lcm(numerator, reduced.numerator) };
        denominator = if denominator == 0 { reduced.denominator } else { gcd(denominator, reduced.denominator) };
    }
    if numerator == 0 { return Rational { numerator: 0, denominator: 1 }; }
    make_rational(numerator, denominator)
}

// A cycle on an integer clock: range_length entries, one step every
// ticks_per_step ticks. Tilengine stores its converted scenes this way, and
// OpenTTD's game tick is the same kind of clock.
pub struct TickCycle { pub range_length: u64, pub ticks_per_step: u64 }

// Integer clocks need no fractions: each cycle repeats after
// range_length * ticks_per_step ticks, and the scene repeats at their LCM.
pub fn joint_period_ticks(cycles: &[TickCycle]) -> u64 {
    let mut total = 0u64;
    for cycle in cycles {
        let cycle_ticks = cycle.range_length * cycle.ticks_per_step;
        if cycle_ticks == 0 { continue; }
        total = if total == 0 { cycle_ticks } else { lcm(total, cycle_ticks) };
    }
    total
}

09Canvas Cycle and BlendShift

Joseph Huckaby's "Old School Color Cycling with HTML5" went up in July 2010 and ran 35 of Ferrari's 640 by 480 scenes in a browser canvas, with Ferrari's permission for non-commercial display.[19][18] The code shipped under LGPL v3 and was relicensed MIT in February 2024; the art didn't come with it. The README: "The actual artwork shown in the demo is copyright, and cannot be used."[19] That's why every scene in this article is generated in code.

The palette engine is a JavaScript port of a C++ library whose header is dated 2001-2002, and the blending isn't claimed as a first: "Someone may have invented this before me, but I've certainly never seen it used."[19]

At load the engine walks the frame once and keeps the offsets of every pixel whose index falls inside an active range, then repaints only those offsets per frame. Across the 35 scenes that set runs from 0.4% to 36.1% of pixels, median 10.2%. The saving stops at the blit: the whole 640 by 480 canvas still goes up through putImageData every frame.[19]

Rate handling is its own dialect. Steps per second is rate / 280, where the ILBM definition works out to a divisor of 273.07, so every range runs 2.48% slow against the Amiga definition.[19] That comparison assumes a DOS file's rate words share the Amiga's 60 Hz basis, which §6 showed nobody has established: the DeluxePaint Animation kit stores cycles for an 18 or 70 per second timer instead.[42]

The whole flags word is read as one mode number (§6): 0 and 1 forward, 2 reverse, 3 ping-pong, 4 and 5 sine.[19] The sine branch calls an unqualified DFLOAT_MOD and throws a ReferenceError if a range ever selects it, which no published scene does.[19] The engine works on its own files because they store only 0 and 2.

is the sub-step part. Given a phase with whole part k and fraction f, the range is shifted by the k whole steps, then each entry fades toward the color arriving next by f, in 100 levels, per channel floor(a + (b - a) x frame / 100), on 8-bit sRGB-encoded values with no linearization.[19] The result equals a per-entry linear crossfade between rotation k and rotation k + 1 (a port of the code matches it in 2,000 random trials). Huckaby's purpose for it: "to produce many 'in between' frames, while preserving the overall 'speed' of the cycling effect".[19]

The C++ original skips blending for any range containing pure magenta (255, 0, 255), its transparency key, because a blended key stops being the key. The JavaScript port dropped that check.[19]

The source doesn't say where it breaks. This follows from how the crossfade works. On a ramp whose neighboring entries are unrelated colors, the crossfade puts colors on screen that the art never contained. Ferrari's ramps are ordered gradients, so each intermediate sits between the two colors it is blending, and the fade reads as motion instead of as a wash.

Live · Hard shift against BlendShift
whole steps, k
0
fraction, f
0.00
level of 100
0
level of 256
0
largest 100 vs 256 gap
0
colors not in the ramp
0
Drift panel

8.438 steps/s by the ILBM formula, 8.229 by rate / 280

after 0.0 s: 0.00 steps by the ILBM formula, 0.00 by rate / 280

drift, ILBM minus rate / 280
0.00 steps
All three lanes read one clock. The first writes whole-step rotations. The second is Canvas Cycle's arithmetic: shift by the whole steps, then fade each entry toward the color arriving next in 100 levels, on 8-bit encoded values. The third does the same at 256 levels, as colcycle does. On the water ramp the fade reads as motion between steps. On the rain path the drop doesn't slide: it fades out of one slot and into the next, and the foreign-colors readout counts shades that exist nowhere in the ramp. The drift panel runs one rate through the ILBM formula and through Canvas Cycle's divide-by-280.

Three relatives:

BlendShift: a crossfade between two rotations
#include <array>
#include <cstddef>
#include <cstdint>
#include <span>

// Restated so this listing compiles on its own.
struct Rgb8 { std::uint8_t red; std::uint8_t green; std::uint8_t blue; };
enum class CycleDirection : std::uint8_t { Forward, Reverse };
struct CycleRange { std::uint8_t low; std::uint8_t high; CycleDirection direction; };

// floor(from + (to - from) * frame / levels), written so the numerator can
// never go negative and the integer divide is the same floor Canvas Cycle
// computes in floating point. Its fadeColor clamps the frame the same way and
// returns the source color rather than divide by zero.
std::uint8_t fade_channel(std::uint8_t from, std::uint8_t to,
                          std::uint32_t frame, std::uint32_t levels) {
    if (levels == 0) return from;
    const std::uint64_t total_levels = levels;
    const std::uint64_t step = frame > levels ? total_levels : frame;
    const std::uint64_t blended =
        static_cast<std::uint64_t>(from) * (total_levels - step)
        + static_cast<std::uint64_t>(to) * step;
    return static_cast<std::uint8_t>(blended / total_levels);
}

// The entry a slot holds after whole_steps steps, read from the unrotated
// base. Forward moves every color to the next higher register and the high
// entry around to the low one, so slot j reads from j - k; reverse reads from
// j + k. One modulo folds negative and very large step counts into the range.
std::size_t gathered_source(std::size_t slot, std::size_t count,
                            std::int64_t whole_steps, CycleDirection direction) {
    const std::int64_t length = static_cast<std::int64_t>(count);
    const std::int64_t steps = ((whole_steps % length) + length) % length;
    const std::int64_t source = static_cast<std::int64_t>(slot)
        + (direction == CycleDirection::Reverse ? steps : length - steps);
    return static_cast<std::size_t>(source % length);
}

// BlendShift, the way Canvas Cycle computes it: hard-shift the range by
// whole_steps, then fade each entry toward the color arriving next by
// frame / levels. Canvas Cycle uses 100 levels, colcycle 256. The blend runs
// on 8-bit encoded values with no linearization, which is what the original
// does. The result is a per-entry crossfade between rotation whole_steps and
// rotation whole_steps + 1, so the range keeps the hard cycle's average speed
// and loses its step. Entries outside the range are copied through.
void blend_shift_range(std::span<const Rgb8> base_palette,
                       std::span<Rgb8> out_palette, const CycleRange& range,
                       std::int64_t whole_steps, std::uint32_t frame,
                       std::uint32_t levels) {
    const std::size_t shared = base_palette.size() < out_palette.size()
                             ? base_palette.size() : out_palette.size();
    for (std::size_t entry = 0; entry < shared; ++entry) {
        out_palette[entry] = base_palette[entry];
    }
    // A range of one entry has nothing to rotate into.
    if (range.high <= range.low || levels == 0) return;
    // The range has to fit inside both palettes.
    if (static_cast<std::size_t>(range.high) >= shared) return;

    const std::size_t low = range.low;
    const std::size_t count = static_cast<std::size_t>(range.high - range.low) + 1;
    for (std::size_t slot = 0; slot < count; ++slot) {
        const std::size_t from_slot =
            gathered_source(slot, count, whole_steps, range.direction);
        const std::size_t to_slot =
            gathered_source(slot, count, whole_steps + 1, range.direction);
        const Rgb8 from = base_palette[low + from_slot];
        const Rgb8 to = base_palette[low + to_slot];
        out_palette[low + slot] = Rgb8{
            fade_channel(from.red, to.red, frame, levels),
            fade_channel(from.green, to.green, frame, levels),
            fade_channel(from.blue, to.blue, frame, levels) };
    }
}
// Restated so this listing compiles on its own.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Rgb8 { pub red: u8, pub green: u8, pub blue: u8 }
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CycleDirection { Forward, Reverse }
#[derive(Clone, Copy, Debug)]
pub struct CycleRange { pub low: u8, pub high: u8, pub direction: CycleDirection }

// floor(from + (to - from) * frame / levels), written so the numerator can
// never go negative and the integer divide is the same floor Canvas Cycle
// computes in floating point. Its fadeColor clamps the frame the same way and
// returns the source color rather than divide by zero.
pub fn fade_channel(from: u8, to: u8, frame: u32, levels: u32) -> u8 {
    if levels == 0 { return from; }
    let total_levels = levels as u64;
    let step = if frame > levels { total_levels } else { frame as u64 };
    let blended = from as u64 * (total_levels - step) + to as u64 * step;
    (blended / total_levels) as u8
}

// The entry a slot holds after whole_steps steps, read from the unrotated
// base. Forward moves every color to the next higher register and the high
// entry around to the low one, so slot j reads from j - k; reverse reads from
// j + k. One modulo folds negative and very large step counts into the range.
fn gathered_source(slot: usize, count: usize, whole_steps: i64,
                   direction: CycleDirection) -> usize {
    let length = count as i64;
    let steps = ((whole_steps % length) + length) % length;
    let source = slot as i64
        + if direction == CycleDirection::Reverse { steps } else { length - steps };
    (source % length) as usize
}

// BlendShift, the way Canvas Cycle computes it: hard-shift the range by
// whole_steps, then fade each entry toward the color arriving next by
// frame / levels. Canvas Cycle uses 100 levels, colcycle 256. The blend runs
// on 8-bit encoded values with no linearization, which is what the original
// does. The result is a per-entry crossfade between rotation whole_steps and
// rotation whole_steps + 1, so the range keeps the hard cycle's average speed
// and loses its step. Entries outside the range are copied through.
pub fn blend_shift_range(base_palette: &[Rgb8], out_palette: &mut [Rgb8],
                         range: &CycleRange, whole_steps: i64, frame: u32,
                         levels: u32) {
    let shared = base_palette.len().min(out_palette.len());
    out_palette[..shared].copy_from_slice(&base_palette[..shared]);
    // A range of one entry has nothing to rotate into.
    if range.high <= range.low || levels == 0 { return; }
    // The range has to fit inside both palettes.
    if range.high as usize >= shared { return; }

    let low = range.low as usize;
    let count = (range.high - range.low) as usize + 1;
    for slot in 0..count {
        let from_slot = gathered_source(slot, count, whole_steps, range.direction);
        let to_slot = gathered_source(slot, count, whole_steps + 1, range.direction);
        let from = base_palette[low + from_slot];
        let to = base_palette[low + to_slot];
        out_palette[low + slot] = Rgb8 {
            red: fade_channel(from.red, to.red, frame, levels),
            green: fade_channel(from.green, to.green, frame, levels),
            blue: fade_channel(from.blue, to.blue, frame, levels),
        };
    }
}

10One picture, many palettes

Cycling rotates entries inside a range. Fading replaces the whole palette over minutes, and the same buffer of indices carries a day. Ferrari: "light and shadow will actually gradually change angle, climb down the sides of things, move across lawns, up cliffs or building walls, as changing light does in life - all just by fading through palette series designed to make those things happen without altering or adding anything at all to the single layer of 8-bit pixel art."[18]

The method is painted in advance. A highlight that has to move down a surface is a dithered run of about five entries that all look like one color in the morning palette and diverge as the light changes.[21] Deluxe Paint showed him each state, "but not the transition between one palette and the next until I see it implemented in code later".[18]

For a Jakks Pacific X-Men plug-and-play game, one background became a city, a forested valley or a cloudscape, each at three times of day: nine palettes over one image, and "all of this still left nearly half of the available 256 color pallete space unused".[18]

The published scenes measure the same way. "Mountain Stream, Morning" and "Mountain Stream, Afternoon" have byte-identical index buffers and differ in 205 of 256 palette entries; "Mirror Pond" morning and afternoon are identical buffers 209 entries apart.[19] In Seize the Day (1993) the sun rose and set with the computer's clock.[17]

The Living Worlds demo stores per-scene palette keyframes keyed by seconds since midnight. Each second it finds the keyframes before and after the current time, fades every entry between them, imports the result as the base palette, and then applies cycling.[46] Mountain Stream's February timeline holds 27 keyframes from 06:15 to 18:10, the closest of them five minutes apart between 06:45 and 07:30.[46]

The search for the keyframe after midnight is broken. It iterates for (var offset in this.timeline), which yields string keys, so the wrap computes "22500" + 86400, the string "2250086400". The span it divides by becomes about 2.25 billion and the palette freezes on the last keyframe until midnight. It never shows, because every shipped timeline starts and ends on the same palette.[46]

Live · A day of palettes
keyframes, 05:30 to 21:45
00:0012:0024:00
cliff strata, entries 20 to 25
L0 lit
L1 shadow
L2 shadow
L3 shadow
L4 shadow
shadow 70 60 70
clock
06:30
keyframe before
06:15
keyframe after
06:45
fade fraction
0.5000
entries changed / min
0
index writes
0
The picture's indices never change. For each simulated second the palette is faded between the keyframes before and after the current time, and then the pond cycles on top, the order the Living Worlds demo uses. The five cliff strata use five entries that equal the shadow color at sunrise and turn lit one at a time through the morning, so light climbs down the rock by palette alone. Switch to string keys and play past 21:45: the ported search concatenates the midnight offset, and the palette freezes until midnight.

Fade first, cycle second: the ramps being rotated are themselves changing over the day, so cycling a stale base palette drags the wrong colors through the water.

Not every day-to-night change is a fade. Doom's PLAYPAL holds 14 palettes[10], and the status bar code picks one: 2-8 for damage, 10-12 for pickups, 13 for the radiation suit, with palettes 1 and 9 never selected and one palette active at a time, so tints never combine.[37] The Amiga Graphics Archive notes the same trick in Defender of the Crown, whose castle day and night screens are one picture with two palettes.[4]

Sample a day of palette keyframes
#include <cstddef>
#include <cstdint>
#include <span>

// Restated so this listing compiles on its own.
struct Rgb8 { std::uint8_t red; std::uint8_t green; std::uint8_t blue; };

inline constexpr std::uint32_t SECONDS_PER_DAY = 86400;

struct PaletteKey {
    std::uint32_t seconds_since_midnight;
    std::size_t palette_id;      // an index into the scene's stored palettes
};

struct TimelineSample {
    std::size_t before_palette;
    std::size_t after_palette;
    std::uint32_t elapsed;       // seconds since the before keyframe
    std::uint32_t span;          // seconds between the keyframes; 0 means hold
};

// keys_sorted: non-empty, ascending, unique, every time below 86400, and
// now_seconds below 86400. Before is the last key at or before now, or else
// the last key of the previous day; after is the first key past now, or else
// the first key of the next day. Seconds are integers the whole way through.
// The Living Worlds demo walked its keyframes with for (var offset in
// timeline), which hands back string keys, so its wrap evaluated "22500" +
// 86400 as string concatenation: the fade span became 2250086400 and the
// palette stopped moving between the last keyframe and midnight.
TimelineSample sample_timeline(std::span<const PaletteKey> keys_sorted,
                               std::uint32_t now_seconds) {
    if (keys_sorted.empty()) return TimelineSample{ 0, 0, 0, 0 };
    if (keys_sorted.size() == 1) {
        // One keyframe is a fixed palette: nothing on either side to fade to.
        const std::size_t only = keys_sorted[0].palette_id;
        return TimelineSample{ only, only, 0, 0 };
    }

    std::ptrdiff_t before_index = -1;   // -1 means every key is later than now
    for (std::size_t index = 0; index < keys_sorted.size(); ++index) {
        // Sorted, so the walk stops at the first key past now.
        if (keys_sorted[index].seconds_since_midnight > now_seconds) break;
        before_index = static_cast<std::ptrdiff_t>(index);
    }

    // Before the first keyframe, the palette is still on last night's last one.
    const PaletteKey& last_key = keys_sorted[keys_sorted.size() - 1];
    std::int64_t before_seconds =
        static_cast<std::int64_t>(last_key.seconds_since_midnight) - SECONDS_PER_DAY;
    std::size_t before_palette = last_key.palette_id;
    if (before_index >= 0) {
        const PaletteKey& key = keys_sorted[static_cast<std::size_t>(before_index)];
        before_seconds = key.seconds_since_midnight;
        before_palette = key.palette_id;
    }

    // After the last keyframe, the next one is the first keyframe of tomorrow.
    const std::size_t after_index = static_cast<std::size_t>(before_index + 1);
    const std::uint32_t first_seconds = keys_sorted[0].seconds_since_midnight;
    std::int64_t after_seconds =
        static_cast<std::int64_t>(first_seconds) + SECONDS_PER_DAY;
    std::size_t after_palette = keys_sorted[0].palette_id;
    if (after_index < keys_sorted.size()) {
        after_seconds = keys_sorted[after_index].seconds_since_midnight;
        after_palette = keys_sorted[after_index].palette_id;
    }

    return TimelineSample{ before_palette, after_palette,
                           static_cast<std::uint32_t>(now_seconds - before_seconds),
                           static_cast<std::uint32_t>(after_seconds - before_seconds) };
}

// floor(from + (to - from) * position / span), with a numerator that cannot go
// negative so the integer divide is the floor. The caller guarantees span > 0.
std::uint8_t blend_channel(std::uint8_t from, std::uint8_t to,
                           std::uint32_t position, std::uint32_t span) {
    const std::uint64_t blended = static_cast<std::uint64_t>(from) * (span - position)
                                + static_cast<std::uint64_t>(to) * position;
    return static_cast<std::uint8_t>(blended / span);
}

// Fade a whole palette between two keyframes. Every entry moves, including the
// ones a cycling range will rotate a moment later: fade first, cycle second,
// because the ramps being rotated are themselves changing over the day.
void blend_palettes(std::span<const Rgb8> before, std::span<const Rgb8> after,
                    std::uint32_t elapsed, std::uint32_t span,
                    std::span<Rgb8> out) {
    const std::size_t shared = before.size() < after.size() ? before.size()
                                                            : after.size();
    const std::size_t count = shared < out.size() ? shared : out.size();
    if (span == 0) {   // a single keyframe, or a clock that has not moved
        for (std::size_t entry = 0; entry < count; ++entry) out[entry] = before[entry];
        return;
    }
    // A clock that ran past the next key holds at the key instead of overshooting.
    const std::uint32_t position = elapsed > span ? span : elapsed;
    for (std::size_t entry = 0; entry < count; ++entry) {
        const Rgb8 first = before[entry];
        const Rgb8 second = after[entry];
        out[entry] = Rgb8{ blend_channel(first.red, second.red, position, span),
                           blend_channel(first.green, second.green, position, span),
                           blend_channel(first.blue, second.blue, position, span) };
    }
}
// Restated so this listing compiles on its own.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Rgb8 { pub red: u8, pub green: u8, pub blue: u8 }

pub const SECONDS_PER_DAY: u32 = 86400;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PaletteKey {
    pub seconds_since_midnight: u32,
    pub palette_id: usize,       // an index into the scene's stored palettes
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TimelineSample {
    pub before_palette: usize,
    pub after_palette: usize,
    pub elapsed: u32,            // seconds since the before keyframe
    pub span: u32,               // seconds between the keyframes; 0 means hold
}

// keys_sorted: non-empty, ascending, unique, every time below 86400, and
// now_seconds below 86400. Before is the last key at or before now, or else
// the last key of the previous day; after is the first key past now, or else
// the first key of the next day. Seconds are integers the whole way through.
// The Living Worlds demo walked its keyframes with for (var offset in
// timeline), which hands back string keys, so its wrap evaluated "22500" +
// 86400 as string concatenation: the fade span became 2250086400 and the
// palette stopped moving between the last keyframe and midnight.
pub fn sample_timeline(keys_sorted: &[PaletteKey], now_seconds: u32) -> TimelineSample {
    if keys_sorted.is_empty() {
        return TimelineSample { before_palette: 0, after_palette: 0,
                                elapsed: 0, span: 0 };
    }
    if keys_sorted.len() == 1 {
        // One keyframe is a fixed palette: nothing on either side to fade to.
        let only = keys_sorted[0].palette_id;
        return TimelineSample { before_palette: only, after_palette: only,
                                elapsed: 0, span: 0 };
    }

    let mut before_index: isize = -1;   // -1 means every key is later than now
    for index in 0..keys_sorted.len() {
        // Sorted, so the walk stops at the first key past now.
        if keys_sorted[index].seconds_since_midnight > now_seconds { break; }
        before_index = index as isize;
    }

    // Before the first keyframe, the palette is still on last night's last one.
    let last_key = keys_sorted[keys_sorted.len() - 1];
    let mut before_seconds =
        last_key.seconds_since_midnight as i64 - SECONDS_PER_DAY as i64;
    let mut before_palette = last_key.palette_id;
    if before_index >= 0 {
        let key = keys_sorted[before_index as usize];
        before_seconds = key.seconds_since_midnight as i64;
        before_palette = key.palette_id;
    }

    // After the last keyframe, the next one is the first keyframe of tomorrow.
    let after_index = (before_index + 1) as usize;
    let mut after_seconds =
        keys_sorted[0].seconds_since_midnight as i64 + SECONDS_PER_DAY as i64;
    let mut after_palette = keys_sorted[0].palette_id;
    if after_index < keys_sorted.len() {
        after_seconds = keys_sorted[after_index].seconds_since_midnight as i64;
        after_palette = keys_sorted[after_index].palette_id;
    }

    TimelineSample {
        before_palette,
        after_palette,
        elapsed: (now_seconds as i64 - before_seconds) as u32,
        span: (after_seconds - before_seconds) as u32,
    }
}

// floor(from + (to - from) * position / span), with a numerator that cannot go
// negative so the integer divide is the floor. The caller guarantees span > 0.
fn blend_channel(from: u8, to: u8, position: u32, span: u32) -> u8 {
    let blended = from as u64 * (span - position) as u64
                + to as u64 * position as u64;
    (blended / span as u64) as u8
}

// Fade a whole palette between two keyframes. Every entry moves, including the
// ones a cycling range will rotate a moment later: fade first, cycle second,
// because the ramps being rotated are themselves changing over the day.
pub fn blend_palettes(before: &[Rgb8], after: &[Rgb8], elapsed: u32, span: u32,
                      out: &mut [Rgb8]) {
    let shared = before.len().min(after.len());
    let count = shared.min(out.len());
    if span == 0 {   // a single keyframe, or a clock that has not moved
        out[..count].copy_from_slice(&before[..count]);
        return;
    }
    // A clock that ran past the next key holds at the key instead of overshooting.
    let position = if elapsed > span { span } else { elapsed };
    for entry in 0..count {
        let first = before[entry];
        let second = after[entry];
        out[entry] = Rgb8 {
            red: blend_channel(first.red, second.red, position, span),
            green: blend_channel(first.green, second.green, position, span),
            blue: blend_channel(first.blue, second.blue, position, span),
        };
    }
}
What's intentionally missing

The fade is linear between keyframes, with no easing and no per-entry timing: every entry moves on the same schedule, where an artist may want a sky to turn before the ground does. Composing the fade with a cycling range is left to the caller, and the order matters: fade the base palette first, then apply the cycle, or a stale base drags the wrong colors through the water.

Port the fade, not the midnight wrap

Store keyframe times as integers and wrap by adding 86,400 to the first keyframe, then test a timeline whose first and last palettes differ. The shipped timelines hide the bug; a timeline written for a new scene won't.

11Layers and frames packed into bits

A planar mode splits the index across memory instead of packing it into a byte. Each plane holds one bit of every pixel, plane 0 the least significant, and the bits a pixel has across the planes are its register number: a pixel set in planes 0 and 3 and clear elsewhere selects register binary 01001, register 9.[14] Once the index has that structure, the bits can carry something other than a color choice, and the palette decides what each combination shows.

Amiga hardware packs meaning into the plane count itself. Extra Half-Brite reads a sixth plane as a half-intensity flag on the color the other five selected, 64 colors out of 32 registers.[22] Dual playfield splits the planes into two layers that scroll independently: planes 0, 2 and 4 draw from registers 1 to 7, planes 1, 3 and 5 from 9 to 15, and value 0 is transparent in each.[22]

Abrash's bit-plane animation, chapter 43 of the Black Book, gives each moving object a plane of its own in a 16-color planar mode, then programs the 16 palette registers "so that the 1 bit from the plane with the highest precedence determines the color".[2] Table 43.1 writes that rule out: indices 1, 3, 5 and so on through 15 get attribute 3CH, indices 2, 6, 10 and 14 get 3AH, 4 and 12 get 39H, 8 gets 3FH, and 0 is the background 00H.[2] Two objects can then overlap without either repairing the other's pixels, because a pixel covered by both already holds both bits and the palette settles which one shows. Listing 43.1 moves 13 images 32 pixels on a side "at a good clip even on a primordial 8088".[2]

The bill for that: planar modes only (0DH, 0EH, 10H and 12H), five colors where the mode offers 16, one color per image, and images sharing a plane shouldn't cross.[2]

Duplicate palette entries turn a plane into a mask. Agony's background playfield sets colors 10 to 15 as three duplicate pairs, "because when there are graphics in the background layer, we want them to obscure the static moon layer graphics".[30] Boing's copy of registers 2 to 15 into 18 to 31 (§7) is the same device: the grid plane's bit stops changing what a ball pixel shows.[44]

Overlay bits can also select a whole palette. Spaceballs' Norwegian Kindness, an Amiga AGA demo that took first place at Datastorm 2011, adds two static overlay planes so each pixel picks one of four 64-color palettes; its author on the cost: "From the CPU's point of view this operation has no performance cost".[49]

Indices can also hold animation frames. Deluxe Paint II's sample picture draws a bouncing ball where "Each position of the ball was drawn in a different color from Range 2. Because five of the six colors in this range are black, only one frame shows at a time".[5] Deluxe Paint IV's running man is the same construction on Range 1.[16] Overlap is where it fails: a Datastorm color-cycling entry that gave every frame its own index shows artifacts wherever two frames want the same pixel, since the pixel can hold only one index.[50]

Overlap survives if the frames get bits instead of indices. Store n overlapping one-bit frames in n planes and give the palette 2n entries, lit so that every entry with bit k set shows frame k's ink; a pixel belonging to two frames carries both bits and appears in both. The cost is exponential in frames and it buys back the pixels one-index-per-frame loses. No shipped title or write-up is known to use it, so treat it as a derivation from the pieces above, not a documented format.

Live · Layers and frames in four bitplanes

No entry selected. Click a swatch or the picture to outline every pixel that uses one entry.

bits written, last step
p0 0, p1 0, p2 0, p3 0
palette writes, last load
16 entries
index writes since setup
0
pose pixels lost
0
Each layer lives in its own bitplane, so a pixel's index records which layers cover it. With plane 0 winning, every odd entry gets plane 0's color, entries 2, 6, 10 and 14 plane 1's, 4 and 12 plane 2's, and 8 plane 3's, the pattern of Abrash's table, so shapes cross without any shape redrawing another. Flip the rule and only the 16 swatches change. Frame select gives each pose a bit and lights one bit at a time. That scheme is derived from these pieces, not taken from a documented format. One index per frame, the Deluxe Paint sample method, loses the pixels where poses overlap; the counter measures them.
Palettes that pack layers and frames into bits
#include <array>
#include <bit>
#include <cstddef>
#include <cstdint>

// Abrash's bit-plane animation (Black Book chapter 43): every moving object owns one
// bitplane, so a pixel's index records which objects cover it and the palette decides
// which object is seen where they overlap. No object ever redraws another.
// Plane 0 has the highest precedence, which makes the winner the lowest set bit.
// Color is a template parameter because the period code stores a one-byte attribute
// while a software renderer stores a struct; the rule is the same either way.
template <typename Color>
std::array<Color, 16> build_priority_palette(const std::array<Color, 4>& plane_colors, Color background) {
    std::array<Color, 16> palette{};
    palette[0] = background;   // no plane covers this pixel, so the background shows through
    for (std::size_t index = 1; index < palette.size(); ++index) {
        // countr_zero counts trailing zero bits, which names the lowest set bit. Index 0 is
        // handled above, so the count here is always 0 to 3 and the lookup stays in bounds.
        const std::size_t winning_plane =
            static_cast<std::size_t>(std::countr_zero(static_cast<std::uint32_t>(index)));
        palette[index] = plane_colors[winning_plane];
    }
    return palette;
}

// One bit per animation frame instead of one index per frame. Four overlapping poses live
// in four planes, and the palette lights every entry whose frame bit is set, so the pixels
// two poses share still show the visible one. n frames need 2^n entries, which is why four
// is the practical limit in a 16-color mode.
// This is a derivation from the pieces above, not a documented format: no shipped title or
// write-up using it turned up.
template <typename Color>
std::array<Color, 16> build_frame_select_palette(std::uint32_t visible_frame, Color ink, Color background) {
    std::array<Color, 16> palette{};
    // A 4-bit index carries frames 0 to 3. Any other frame number lights nothing, rather
    // than shifting a 1 past the width of the index and reading whatever falls out.
    const std::size_t frame_bit = visible_frame < 4u ? std::size_t{1} << visible_frame : std::size_t{0};
    for (std::size_t index = 0; index < palette.size(); ++index) {
        palette[index] = (index & frame_bit) != 0 ? ink : background;
    }
    return palette;
}

// Extra Half-Brite: a sixth bitplane shows the color the other five select at half
// intensity, 64 colors out of 32 registers. An Amiga color register holds $0RGB with one
// 4-bit gun per nibble, so halving is one shift plus a mask that stops each gun's low bit
// from dropping into the gun below it.
constexpr std::uint16_t half_brite(std::uint16_t color_rgb12) {
    return static_cast<std::uint16_t>((color_rgb12 >> 1) & 0x0777);
}
// Abrash's bit-plane animation (Black Book chapter 43): every moving object owns one
// bitplane, so a pixel's index records which objects cover it and the palette decides
// which object is seen where they overlap. No object ever redraws another.
// Plane 0 has the highest precedence, which makes the winner the lowest set bit.
// Color is a type parameter because the period code stores a one-byte attribute while a
// software renderer stores a struct; the rule is the same either way.
pub fn build_priority_palette<Color: Copy>(plane_colors: &[Color; 4], background: Color) -> [Color; 16] {
    let mut palette = [background; 16];   // entry 0 keeps the background: no plane covers it
    for index in 1..palette.len() {
        // trailing_zeros names the lowest set bit, which is the winning plane. Index 0 is
        // skipped, so the count here is always 0 to 3 and the lookup stays in bounds.
        let winning_plane = (index as u32).trailing_zeros() as usize;
        palette[index] = plane_colors[winning_plane];
    }
    palette
}

// One bit per animation frame instead of one index per frame. Four overlapping poses live
// in four planes, and the palette lights every entry whose frame bit is set, so the pixels
// two poses share still show the visible one. n frames need 2^n entries, which is why four
// is the practical limit in a 16-color mode.
// This is a derivation from the pieces above, not a documented format: no shipped title or
// write-up using it turned up.
pub fn build_frame_select_palette<Color: Copy>(visible_frame: u32, ink: Color, background: Color) -> [Color; 16] {
    let mut palette = [background; 16];
    // A 4-bit index carries frames 0 to 3. Any other frame number lights nothing, rather
    // than shifting a 1 past the width of the index and reading whatever falls out.
    let frame_bit = if visible_frame < 4 { 1usize << visible_frame } else { 0usize };
    for index in 0..palette.len() {
        palette[index] = if index & frame_bit != 0 { ink } else { background };
    }
    palette
}

// Extra Half-Brite: a sixth bitplane shows the color the other five select at half
// intensity, 64 colors out of 32 registers. An Amiga color register holds $0RGB with one
// 4-bit gun per nibble, so halving is one shift plus a mask that stops each gun's low bit
// from dropping into the gun below it.
pub const fn half_brite(color_rgb12: u16) -> u16 {
    (color_rgb12 >> 1) & 0x0777
}

12Light and blend tables must cycle too

An indexed renderer can't darken or blend an index, because an index is a name and not a quantity. It does the arithmetic once over the palette and stores each answer as another index.

Doom's COLORMAP

DCOLORS 1.1, headed "by John Carmack, copyright (c) 1992 Id Software", writes Doom's COLORMAP as 34 rows of 256 bytes, 8,704 in all.[10] Rows 0 to 31 are the : for light level l each gun is scaled as (c * (32 - l) + 16) / 32 on the gamma-encoded value, and the nearest palette entry by unweighted squared RGB distance goes in the row.[10] Row 32 holds the inverted gray invulnerability uses, its luma weights typed 0.299, 0.587 and 0.144 where the blue coefficient is normally 0.114; row 33 is all black.[10]

Sector lighting reaches those rows through tables of row pointers. A sector's 0-255 light level is shifted down to one of sixteen, and those sixteen map onto the 32 rows through zlight[16][128] for flats and scalelight[16][48] for walls and sprites, so the inner loop is one byte lookup per pixel.[37] Three effects bypass that and pick a row outright. The light-amplification visor sets colormap 1, commented "almost full bright"; one Doom Wiki sentence says 0, the source says 1. The sky always draws through colormap 0, so invulnerability never inverts it. The spectre fuzz reads the framebuffer pixel one row above or below and remaps it through colormap 6, though the code comment claims one column left or right.[37] On the gamma-space multiply, Pekka Väänänen's verdict is "wrong in theory, but happens to give a pleasing nonlinear light falloff curve".[51]

Quake and Build

Quake's software renderer lights a texel as colormap[(light & 0xFF00) + texel] across 64 light grades, and keeps the last 32 palette entries as fullbright colors that every grade leaves alone.[52] id's QLUMPY grabber writes 256 bytes per grade and then the fullbright count as the file's final byte, so at 64 grades with 32 fullbrights colormap.lmp is 256 + 63 x 256 + 1 = 16,385 bytes, the last of them 0x20.[52] GLQuake dropped the colormap, lost the fullbrights with it, and forced flame models to full light under a comment that reads "HACK HACK HACK".[52]

Build puts all of it in one file: PALETTE.DAT holds a 768-byte palette, a shade count, the shade tables themselves (usually 32 rows of 256, 8,192 bytes) and a 65,536-byte translucency table giving, for any two palette colors, "the best match of the 2 colors when mixed together".[53] Duke Nukem 3D's copy is 82,690 bytes, 8,192 more than 74,498, because it was built with 64 shades and cut back to 32 for frame rate.[53]

The trap

A table that stores palette indices encodes the palette order. Rotate the palette and every such table is wrong until it follows.

DevilutionX is the worked example. Its 256 by 256 table of 50% blend results is a port addition ("Improved transparency", 1.2.0); the 1996 game drew transparency with stipple masks.[34] Whenever a range cycles the table is rotated on both axes, row order and row contents, and it is built so that no blend result lands inside the cycling range.[34] On Hell levels the same engine rotates entries 1 to 31 of its 16 light tables and leaves the palette alone, which is a different effect. The source doesn't say why.[34] The reconstruction names those functions, not Blizzard North: its identifiers were recovered largely from debug symbols in the Japanese PlayStation port.[34]

ScummVM's SCUMM engine applies the same two-axis fix to its indirect shadow palettes, remapping the values stored inside the cycle range and rotating the entries themselves.[54] It also cycles the intermediate palettes a fade is walking through, "otherwise color cycling will be disturbed by the palette fade".[54] OpenTTD declines the problem: "Disable palette animation for pixels with alpha, as the alpha and previous colour information will be lost when the palette is animated".[9]

The permutation is small. If one step sends the color at old slot σ(i) to slot i, a blend table has to become T′[a][b] = σ-1(T[σ(a)][σ(b)]): permute rows and columns so the table follows its inputs, and remap the stored values so it follows its outputs. Build the table with the cycling range excluded from its outputs and σ is the identity on every blend result it stores, so the value remap drops out and only the row and column permutation is left, which is what DevilutionX's two rotations do. The diagonal, written as T[i][i] = i outside that constraint, is the one place those two rotations run a step behind. The listing below checks all 65,536 entries against a table rebuilt from the rotated palette after every step.

Predict

The ghost below is drawn through a blend table built when the level loaded. Predict what the pixels under it do once the lava range starts turning.

Live · When a cycle leaves a table behind
what follows the cycle
ghost pixels matching
-
rim pixels matching
-
table entries moved
0 / step
palette entries written
15 / step
table builds
1
The rock around the pool is lit through a shade table and the ghost is blended through a 64 by 64 table, both built from the palette at load, scaled down from the 256-entry tables real engines use. Rotate only the palette and the pixels under the ghost stop following the lava. Permuting rows and columns follows the inputs, but blend results that fall inside the lava range still point at entries that moved, until their values are remapped, as ScummVM does, or the table is built to avoid the range, as DevilutionX does; both reach 100%. The last mode rotates the light tables and leaves the palette alone: lit lava moves and the unlit strip in the corner doesn't.
Build light and blend tables, and rotate them with a cycle
#include <cstddef>
#include <cstdint>
#include <span>
#include <vector>

// Restated so this listing compiles on its own.
struct Rgb8 { std::uint8_t red; std::uint8_t green; std::uint8_t blue; };
enum class CycleDirection : std::uint8_t { Forward, Reverse };

// Inclusive. first > last is the empty range, which is how "exclude nothing" is written:
// building a table with the cycling range excluded from its outputs is one of the two
// fixes below, so the absence of an exclusion has to be sayable too.
struct IndexRange { std::size_t first; std::size_t last; };
inline constexpr IndexRange NO_RANGE{1, 0};

// Unweighted squared distance in gamma-encoded RGB, which is what Doom's DCOLORS does.
// Integer arithmetic and a strict less-than, so C++, Rust and the JavaScript on this page
// agree on every entry: on a tie the lowest index always wins. Inputs are 0 to 255.
std::uint8_t nearest_index(std::span<const Rgb8> palette, int red, int green, int blue, IndexRange skip) {
    // best_entry starts as a placeholder, not an answer: if skip covers the whole palette
    // nothing updates it and the returned 0 is itself inside the excluded range.
    std::size_t best_entry = 0;
    int best_distance = 3 * 255 * 255 + 1;        // one past the largest distance two colors can have
    for (std::size_t entry = 0; entry < palette.size(); ++entry) {
        // Excluding the cycling range from a table's outputs is what lets the table survive
        // the cycle: nothing it stores can then point at an entry that moves.
        if (skip.first <= skip.last && entry >= skip.first && entry <= skip.last) continue;
        const int delta_red = static_cast<int>(palette[entry].red) - red;
        const int delta_green = static_cast<int>(palette[entry].green) - green;
        const int delta_blue = static_cast<int>(palette[entry].blue) - blue;
        const int distance = delta_red * delta_red + delta_green * delta_green + delta_blue * delta_blue;
        if (distance < best_distance) { best_distance = distance; best_entry = entry; }
    }
    return static_cast<std::uint8_t>(best_entry);
}

// level_count rows of palette.size() bytes. Row `level` holds, for every palette entry, the
// entry nearest to that color scaled toward black, so lighting a pixel at run time costs one
// byte lookup. The scale is DCOLORS's, (c * (levels - level) + levels / 2) / levels, applied
// to gamma-encoded guns: wrong as color math, and what the era shipped.
std::vector<std::uint8_t> build_shade_table(std::span<const Rgb8> palette, std::uint32_t level_count, IndexRange skip) {
    std::vector<std::uint8_t> shade_table;
    if (level_count == 0) return shade_table;     // no rows, and the divide below would be by zero
    const int levels = static_cast<int>(level_count);
    shade_table.resize(static_cast<std::size_t>(level_count) * palette.size());
    for (std::uint32_t level = 0; level < level_count; ++level) {
        const int remaining = levels - static_cast<int>(level);
        const std::size_t row_start = static_cast<std::size_t>(level) * palette.size();
        for (std::size_t entry = 0; entry < palette.size(); ++entry) {
            // The + levels / 2 is DCOLORS's rounding. It keeps row 0 an exact copy of the
            // palette, so a fully lit pixel is never remapped to a neighbouring color.
            const int red = (static_cast<int>(palette[entry].red) * remaining + levels / 2) / levels;
            const int green = (static_cast<int>(palette[entry].green) * remaining + levels / 2) / levels;
            const int blue = (static_cast<int>(palette[entry].blue) * remaining + levels / 2) / levels;
            shade_table[row_start + entry] = nearest_index(palette, red, green, blue, skip);
        }
    }
    return shade_table;
}

// The 50 percent translucency table Build's PALETTE.DAT carries and DevilutionX builds at
// startup: blend_table[first * size + second] is the entry nearest the per-channel mean of
// the two colors. The mean floors, so the table is symmetric; 256 entries cost 64 KB.
std::vector<std::uint8_t> build_blend_table(std::span<const Rgb8> palette, IndexRange skip) {
    const std::size_t size = palette.size();
    std::vector<std::uint8_t> blend_table(size * size);
    for (std::size_t first_entry = 0; first_entry < size; ++first_entry) {
        for (std::size_t second_entry = 0; second_entry < size; ++second_entry) {
            const int red = (static_cast<int>(palette[first_entry].red) + palette[second_entry].red) / 2;
            const int green = (static_cast<int>(palette[first_entry].green) + palette[second_entry].green) / 2;
            const int blue = (static_cast<int>(palette[first_entry].blue) + palette[second_entry].blue) / 2;
            blend_table[first_entry * size + second_entry] = nearest_index(palette, red, green, blue, skip);
        }
    }
    return blend_table;
}

// source[slot] is the slot whose old color `slot` shows after one step. Forward moves each
// color up one register and wraps the last back to the first, the direction an ILBM range
// runs; Reverse is the other way. Outside the range the map is the identity, and that is
// what keeps the table repairs below cheap.
std::vector<std::size_t> step_source_map(std::size_t palette_size, IndexRange range, CycleDirection direction) {
    std::vector<std::size_t> source(palette_size);
    for (std::size_t slot = 0; slot < palette_size; ++slot) source[slot] = slot;
    // An empty range, a single entry, or a range running off the end: nothing moves.
    if (range.first >= range.last || range.last >= palette_size) return source;
    const std::size_t length = range.last - range.first + 1;
    for (std::size_t offset = 0; offset < length; ++offset) {
        const std::size_t from = direction == CycleDirection::Forward
            ? (offset + length - 1) % length      // forward: this slot shows the color below it
            : (offset + 1) % length;              // reverse: the color above it
        source[range.first + offset] = range.first + from;
    }
    return source;
}

// The step itself. Reading a snapshot and gathering from it means the order of the writes
// cannot matter, which is the property every repair below relies on.
void rotate_palette_one_step(std::span<Rgb8> palette, IndexRange range, CycleDirection direction) {
    const std::vector<std::size_t> source = step_source_map(palette.size(), range, direction);
    const std::vector<Rgb8> before(palette.begin(), palette.end());
    for (std::size_t slot = 0; slot < palette.size(); ++slot) palette[slot] = before[source[slot]];
}

// new[a][b] = old[source[a]][source[b]]: rows and columns follow their inputs. This is the
// two-axis rotation DevilutionX applies to its blend table whenever a range cycles. Stored
// values are carried over untouched, which is correct only when none of them points inside
// the range; remap_table_values handles the case where they do.
void rotate_blend_table_one_step(std::span<std::uint8_t> blend_table, std::size_t palette_size,
                                 IndexRange range, CycleDirection direction) {
    // The buffer and its palette_size arrive as separate arguments, so check that they agree:
    // a table sized for a different palette comes back untouched instead of written past its end.
    if (blend_table.size() != palette_size * palette_size) return;
    const std::vector<std::size_t> source = step_source_map(palette_size, range, direction);
    const std::vector<std::uint8_t> before(blend_table.begin(), blend_table.end());
    for (std::size_t row = 0; row < palette_size; ++row) {
        for (std::size_t column = 0; column < palette_size; ++column) {
            blend_table[row * palette_size + column] = before[source[row] * palette_size + source[column]];
        }
    }
}

// Values, not positions. The color that sat in slot v now sits in the slot that source maps
// back to v, so every stored index inside the range has to be rewritten to follow it. This
// is ScummVM's fix for its indirect shadow palettes. A table built with the range excluded
// from its outputs stores nothing inside the range, so this does nothing there, which is
// why DevilutionX never needs it.
void remap_table_values(std::span<std::uint8_t> table, std::size_t palette_size,
                        IndexRange range, CycleDirection direction) {
    const std::vector<std::size_t> source = step_source_map(palette_size, range, direction);
    // Invert the map: source says where a slot reads from, and a stored value needs the
    // other direction, the slot that now shows the color that value used to name.
    std::vector<std::uint8_t> new_slot_of_old_color(palette_size);
    for (std::size_t slot = 0; slot < palette_size; ++slot) {
        new_slot_of_old_color[source[slot]] = static_cast<std::uint8_t>(slot);
    }
    for (std::uint8_t& stored : table) {
        // A value past the end of the palette is a malformed table, not a cycling problem.
        if (static_cast<std::size_t>(stored) < palette_size) stored = new_slot_of_old_color[stored];
    }
}

// new[level][slot] = old[level][source[slot]] in every row: the columns follow the palette
// and the light levels stay where they are. Run right after a palette step it keeps a shade
// table correct. Run with the palette left alone, Reverse over entries 1 to 31, it is
// DevilutionX's Hell-level light-table rotation, a different effect: the lit pixels move
// and the unlit ones do not.
void rotate_shade_table_one_step(std::span<std::uint8_t> shade_table, std::size_t palette_size,
                                 std::uint32_t level_count, IndexRange range, CycleDirection direction) {
    // Only the rows this call touches have to exist, because rotating the first level_count rows
    // of a taller table is a real use. A table sized for a different palette comes back untouched.
    if (shade_table.size() < static_cast<std::size_t>(level_count) * palette_size) return;
    const std::vector<std::size_t> source = step_source_map(palette_size, range, direction);
    const std::vector<std::uint8_t> before(shade_table.begin(), shade_table.end());
    for (std::uint32_t level = 0; level < level_count; ++level) {
        const std::size_t row_start = static_cast<std::size_t>(level) * palette_size;
        for (std::size_t slot = 0; slot < palette_size; ++slot) {
            shade_table[row_start + slot] = before[row_start + source[slot]];
        }
    }
}
// Restated so this listing compiles on its own.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Rgb8 { pub red: u8, pub green: u8, pub blue: u8 }

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CycleDirection { Forward, Reverse }

// Inclusive. first > last is the empty range, which is how "exclude nothing" is written:
// building a table with the cycling range excluded from its outputs is one of the two
// fixes below, so the absence of an exclusion has to be sayable too.
#[derive(Clone, Copy, Debug)]
pub struct IndexRange { pub first: usize, pub last: usize }

pub const NO_RANGE: IndexRange = IndexRange { first: 1, last: 0 };

// Unweighted squared distance in gamma-encoded RGB, which is what Doom's DCOLORS does.
// Integer arithmetic and a strict less-than, so Rust, C++ and the JavaScript on this page
// agree on every entry: on a tie the lowest index always wins. Inputs are 0 to 255.
pub fn nearest_index(palette: &[Rgb8], red: i32, green: i32, blue: i32, skip: IndexRange) -> u8 {
    // best_entry starts as a placeholder, not an answer: if skip covers the whole palette
    // nothing updates it and the returned 0 is itself inside the excluded range.
    let mut best_entry = 0usize;
    let mut best_distance = 3 * 255 * 255 + 1;    // one past the largest distance two colors can have
    for (entry, color) in palette.iter().enumerate() {
        // Excluding the cycling range from a table's outputs is what lets the table survive
        // the cycle: nothing it stores can then point at an entry that moves.
        if skip.first <= skip.last && entry >= skip.first && entry <= skip.last { continue; }
        let delta_red = color.red as i32 - red;
        let delta_green = color.green as i32 - green;
        let delta_blue = color.blue as i32 - blue;
        let distance = delta_red * delta_red + delta_green * delta_green + delta_blue * delta_blue;
        if distance < best_distance { best_distance = distance; best_entry = entry; }
    }
    best_entry as u8
}

// level_count rows of palette.len() bytes. Row `level` holds, for every palette entry, the
// entry nearest to that color scaled toward black, so lighting a pixel at run time costs one
// byte lookup. The scale is DCOLORS's, (c * (levels - level) + levels / 2) / levels, applied
// to gamma-encoded guns: wrong as color math, and what the era shipped.
pub fn build_shade_table(palette: &[Rgb8], level_count: u32, skip: IndexRange) -> Vec<u8> {
    if level_count == 0 { return Vec::new(); }    // no rows, and the divide below would be by zero
    let levels = level_count as i32;
    let mut shade_table = vec![0u8; level_count as usize * palette.len()];
    for level in 0..level_count {
        let remaining = levels - level as i32;
        let row_start = level as usize * palette.len();
        for entry in 0..palette.len() {
            // The + levels / 2 is DCOLORS's rounding. It keeps row 0 an exact copy of the
            // palette, so a fully lit pixel is never remapped to a neighbouring color.
            let red = (palette[entry].red as i32 * remaining + levels / 2) / levels;
            let green = (palette[entry].green as i32 * remaining + levels / 2) / levels;
            let blue = (palette[entry].blue as i32 * remaining + levels / 2) / levels;
            shade_table[row_start + entry] = nearest_index(palette, red, green, blue, skip);
        }
    }
    shade_table
}

// The 50 percent translucency table Build's PALETTE.DAT carries and DevilutionX builds at
// startup: blend_table[first * size + second] is the entry nearest the per-channel mean of
// the two colors. The mean floors, so the table is symmetric; 256 entries cost 64 KB.
pub fn build_blend_table(palette: &[Rgb8], skip: IndexRange) -> Vec<u8> {
    let size = palette.len();
    let mut blend_table = vec![0u8; size * size];
    for first_entry in 0..size {
        for second_entry in 0..size {
            let red = (palette[first_entry].red as i32 + palette[second_entry].red as i32) / 2;
            let green = (palette[first_entry].green as i32 + palette[second_entry].green as i32) / 2;
            let blue = (palette[first_entry].blue as i32 + palette[second_entry].blue as i32) / 2;
            blend_table[first_entry * size + second_entry] = nearest_index(palette, red, green, blue, skip);
        }
    }
    blend_table
}

// source[slot] is the slot whose old color `slot` shows after one step. Forward moves each
// color up one register and wraps the last back to the first, the direction an ILBM range
// runs; Reverse is the other way. Outside the range the map is the identity, and that is
// what keeps the table repairs below cheap.
pub fn step_source_map(palette_size: usize, range: IndexRange, direction: CycleDirection) -> Vec<usize> {
    let mut source: Vec<usize> = (0..palette_size).collect();
    // An empty range, a single entry, or a range running off the end: nothing moves.
    if range.first >= range.last || range.last >= palette_size { return source; }
    let length = range.last - range.first + 1;
    for offset in 0..length {
        let from = match direction {
            CycleDirection::Forward => (offset + length - 1) % length,   // the slot below it
            CycleDirection::Reverse => (offset + 1) % length,            // the slot above it
        };
        source[range.first + offset] = range.first + from;
    }
    source
}

// The step itself. Reading a snapshot and gathering from it means the order of the writes
// cannot matter, which is the property every repair below relies on.
pub fn rotate_palette_one_step(palette: &mut [Rgb8], range: IndexRange, direction: CycleDirection) {
    let source = step_source_map(palette.len(), range, direction);
    let before = palette.to_vec();
    for slot in 0..palette.len() { palette[slot] = before[source[slot]]; }
}

// new[a][b] = old[source[a]][source[b]]: rows and columns follow their inputs. This is the
// two-axis rotation DevilutionX applies to its blend table whenever a range cycles. Stored
// values are carried over untouched, which is correct only when none of them points inside
// the range; remap_table_values handles the case where they do.
pub fn rotate_blend_table_one_step(blend_table: &mut [u8], palette_size: usize,
                                   range: IndexRange, direction: CycleDirection) {
    // The buffer and its palette_size arrive as separate arguments, so check that they agree:
    // a table sized for a different palette comes back untouched instead of panicking mid-write.
    if blend_table.len() != palette_size * palette_size { return; }
    let source = step_source_map(palette_size, range, direction);
    let before = blend_table.to_vec();
    for row in 0..palette_size {
        for column in 0..palette_size {
            blend_table[row * palette_size + column] = before[source[row] * palette_size + source[column]];
        }
    }
}

// Values, not positions. The color that sat in slot v now sits in the slot that source maps
// back to v, so every stored index inside the range has to be rewritten to follow it. This
// is ScummVM's fix for its indirect shadow palettes. A table built with the range excluded
// from its outputs stores nothing inside the range, so this does nothing there, which is
// why DevilutionX never needs it.
pub fn remap_table_values(table: &mut [u8], palette_size: usize,
                          range: IndexRange, direction: CycleDirection) {
    let source = step_source_map(palette_size, range, direction);
    // Invert the map: source says where a slot reads from, and a stored value needs the
    // other direction, the slot that now shows the color that value used to name.
    let mut new_slot_of_old_color = vec![0u8; palette_size];
    for slot in 0..palette_size { new_slot_of_old_color[source[slot]] = slot as u8; }
    for stored in table.iter_mut() {
        // A value past the end of the palette is a malformed table, not a cycling problem.
        if (*stored as usize) < palette_size { *stored = new_slot_of_old_color[*stored as usize]; }
    }
}

// new[level][slot] = old[level][source[slot]] in every row: the columns follow the palette
// and the light levels stay where they are. Run right after a palette step it keeps a shade
// table correct. Run with the palette left alone, Reverse over entries 1 to 31, it is
// DevilutionX's Hell-level light-table rotation, a different effect: the lit pixels move
// and the unlit ones do not.
pub fn rotate_shade_table_one_step(shade_table: &mut [u8], palette_size: usize, level_count: u32,
                                   range: IndexRange, direction: CycleDirection) {
    // Only the rows this call touches have to exist, because rotating the first level_count rows
    // of a taller table is a real use. A table sized for a different palette comes back untouched.
    if shade_table.len() < level_count as usize * palette_size { return; }
    let source = step_source_map(palette_size, range, direction);
    let before = shade_table.to_vec();
    for level in 0..level_count as usize {
        let row_start = level * palette_size;
        for slot in 0..palette_size {
            shade_table[row_start + slot] = before[row_start + source[slot]];
        }
    }
}
What's intentionally missing

The nearest-color search is a linear scan. Production builders accelerate it: Build's TRANSPAL uses a spatial grid[53] and DevilutionX a k-d tree[34]. Also absent: Build's 30/59/11 weighted distance[53], linear-light mixing, Quake's fullbright columns, blend ratios other than 50%, and multithreaded building.

A table built from the palette is part of the palette

Blend, light, remap and fade tables all store palette indices, so on every cycle step each one must have every axis addressed by a palette index permuted, and its stored values remapped too unless it was built with the cycling range excluded from its outputs, which makes that remap a no-op. Test by rebuilding from the current palette with the same exclusion and comparing every entry, which catches the case where only the rows were permuted.

13Palettes on the GPU

No mainstream API still offers a general palettized texture format. DXGI keeps P8 and A8P8, documented for video processing: palettized RGB when the processor handles ISDB-T data, palettized YUV for Blu-ray.[55] Desktop OpenGL once had EXT_paletted_texture, and OpenGL ES 1.x had OES_compressed_paletted_texture, ratified in July 2003.[56] Code that wants a palette on a GPU rebuilds the lookup in a shader.

The rebuild is two textures. An holds the indices, a palette texture holds the colors, and the fragment shader reads the first to address the second. Shovel Knight, in David D'Angelo's words: "These palette cycling and shifting effects were created by passing an indexed unsigned byte texture representing the sprite and a full 32 bit color texture representing the palette to a pixel shader".[11] SDL 3.4's OpenGL backend is the same arrangement in library form: a 256 by 1 RGBA8 palette texture, updated with glTexSubImage2D, read at (index + 0.5) / 256.0.[12]

The index fetch has to be exact. GLSL ES 3.00's texelFetch reads one texel at integer coordinates, with no filtering and no wrap mode applied.[56] An integer-format texture is incomplete unless its magnification filter is NEAREST and its minification filter is NEAREST or NEAREST_MIPMAP_NEAREST, and a fetch from an incomplete texture is undefined, or (0, 0, 0, 1) where robust buffer access is enabled.[56] HLSL's Load is the same instrument on the other API: it reads texel data "without any filtering or sampling".[55]

Palette lookup in a pixel shader
#version 300 es
precision highp float;
precision highp int;
precision highp usampler2D;

// R8UI, NEAREST, one level. An integer-format texture is incomplete unless its
// magnification filter is NEAREST and its minification filter is NEAREST or
// NEAREST_MIPMAP_NEAREST, and no sRGB format in GL 4.6 core is an integer
// format, so the index arrives as the byte that was sent.
uniform usampler2D u_index_texture;
// 256 x 1 RGBA8, NEAREST. Not SRGB8_ALPHA8: the palette holds the colors the
// artist picked, and a decode here would move every one of them.
uniform sampler2D u_palette_texture;

in vec2 v_texcoord;
out vec4 out_color;

void main() {
    ivec2 index_size = textureSize(u_index_texture, 0);
    // texelFetch applies no wrap mode, so the coordinate is clamped by hand.
    vec2 texel_space = v_texcoord * vec2(index_size);
    ivec2 texel = clamp(ivec2(floor(texel_space)), ivec2(0), index_size - 1);
    // One integer fetch for the index, one for the color it names. Every pixel
    // on screen reads the same 1 KB palette texture, which is what makes a cycle
    // a palette upload rather than a rewrite of the picture.
    uint palette_index = texelFetch(u_index_texture, texel, 0).r;
    out_color = texelFetch(u_palette_texture, ivec2(int(palette_index), 0), 0);
}
// R8_UINT. Load reads a texel without any filtering or sampling, which is what
// keeps the index exact; a Sample call with a linear filter would not.
Texture2D<uint> u_index_texture : register(t0);
// 256 x 1 R8G8B8A8_UNORM, not _SRGB, for the reason the GLSL pane gives.
Texture2D<float4> u_palette_texture : register(t1);

float4 main(float2 v_texcoord : TEXCOORD0) : SV_Target
{
    uint index_width, index_height;
    u_index_texture.GetDimensions(index_width, index_height);
    int2 index_size = int2(index_width, index_height);
    // Load takes integer coordinates and applies no wrap mode either.
    float2 texel_space = v_texcoord * float2(index_size);
    int2 texel = clamp(int2(floor(texel_space)), int2(0, 0), index_size - 1);
    // The third component of Load is the mip level, not a coordinate.
    uint palette_index = u_index_texture.Load(int3(texel, 0));
    return u_palette_texture.Load(int3(int(palette_index), 0, 0));
}

Point sampling doesn't make the index safe. In OpenGL 4.6, texelFetch skips filtering and wrap modes but still performs "sRGB conversion of fetched values"; the decode touches red, green and blue and leaves alpha alone, and GL 4.6 core has no single-channel sRGB format.[56] The failure needs an index sitting in the color channels of a texture an importer marked sRGB. Run the spec's transfer curve over the byte range and index 128 comes back as 55, indices 0 through 6 all collapse to 0, and 183 of the 256 values survive as distinct (derived from that curve). Erik McClure's 2010 shader kept the index in alpha, the one channel the decode doesn't touch.[57]

texelFetch doesn't protect an index from the sRGB flag

Point sampling stops filtering, not decoding: keep indices in R8UI or R8 (or in alpha), and never let an importer take an index texture for color data.

Filter colors, not indices. Interpolating two indices produces a third index, and that index names whichever color the palette happens to keep between them. SDL 3.4's linear palette mode fetches the four nearest indices, looks each one up and blends the resulting colors, a path its code credits to bgolus.[12] OpenRCT2 resolves its index framebuffer into an RGB buffer and only then runs the GL_LINEAR upscale.[58] EDuke32 settles the conflict by declaring it: turning on texture filtering overrides indexed-color rendering.[59]

Generating a mip level is the same mistake one step removed: averaging four indices invents an entry that none of them named. Authored levels are fine. OpenTTD's sprite remap textures are GL_R8 with GL_NEAREST_MIPMAP_NEAREST and one explicitly uploaded level per zoom level.[9] Block compression can't hold indices at all: BC1 and its relatives store endpoint colors and return values interpolated between them.[60]

Texel centers are the last detail. (index + 0.5) / 256 is the explicit convention in SDL, Polymost and McClure's shader.[12][59][57] OpenTTD samples its 256-texel nearest-filtered palette at i / 255 and still lands on texel i; the half-texel offset starts to matter once the palette is linearly filtered or isn't exactly 256 wide.[9]

Live · Sampling an index texture
palette order
mip level 1 built by
off-palette output
0 of 25,600
invented entries
0 px
index 128
128
byte values
256 of 256
mip level 1 foreign
31 of 64
Nothing here runs on the GPU: each mode repeats the sampler's arithmetic in JavaScript so the results can be counted. Interpolating indices picks entries unrelated to either neighbor, which is why SDL and OpenRCT2 blend colors after the lookup; sort the palette into a ramp and the same bug passes for a gradient. An index texture flagged sRGB is decoded even by texelFetch in desktop OpenGL, and 256 indices come back as 183 distinct values. Averaged mip levels invent indices; point-sampled ones don't. The GLSL listings below run the correct paths on a GPU.
Filter colors, not indices
#version 300 es
precision highp float;
precision highp int;
precision highp usampler2D;

uniform usampler2D u_index_texture;   // R8UI, NEAREST: never filtered
uniform sampler2D u_palette_texture;  // 256 x 1 RGBA8, NEAREST, not SRGB8_ALPHA8

in vec2 v_texcoord;
out vec4 out_color;

// One index fetch and the palette fetch it names. The clamp is here because
// the 2 x 2 block below runs off the edge of the picture on the last texel.
vec4 lookup_texel(ivec2 texel, ivec2 index_size) {
    ivec2 clamped_texel = clamp(texel, ivec2(0), index_size - 1);
    uint palette_index = texelFetch(u_index_texture, clamped_texel, 0).r;
    return texelFetch(u_palette_texture, ivec2(int(palette_index), 0), 0);
}

void main() {
    ivec2 index_size = textureSize(u_index_texture, 0);
    // Texel centers sit at integer + 0.5, so half a texel comes off the scaled
    // coordinate to put the blend weights in [0, 1) between the two texels that
    // straddle the sample point.
    vec2 texel_space = v_texcoord * vec2(index_size) - 0.5;
    vec2 base_texel = floor(texel_space);
    vec2 blend_weight = texel_space - base_texel;
    ivec2 base = ivec2(base_texel);

    // Four indices, each resolved to a color before anything is blended. SDL
    // 3.4's linear palette mode does the same four lookups and blends what comes
    // back. Letting the sampler filter the index texture instead would average
    // the labels and land on an entry neither texel names.
    vec4 color_top_left     = lookup_texel(base + ivec2(0, 0), index_size);
    vec4 color_top_right    = lookup_texel(base + ivec2(1, 0), index_size);
    vec4 color_bottom_left  = lookup_texel(base + ivec2(0, 1), index_size);
    vec4 color_bottom_right = lookup_texel(base + ivec2(1, 1), index_size);

    // The blend runs on the stored values, as SDL's does. Blending in linear
    // light would mean decoding all four colors first and encoding the result.
    // The blend is also on straight alpha, so a palette with a transparent entry
    // bleeds that entry's RGB into sprite edges. Premultiply the four colors
    // before the mix, or keep every palette entry opaque and key transparency
    // separately.
    out_color = mix(mix(color_top_left, color_top_right, blend_weight.x),
                    mix(color_bottom_left, color_bottom_right, blend_weight.x),
                    blend_weight.y);
}

Cycling needs no palette upload at all. Pass each range's low, high and phase as uniforms and do the gather in the shader: the CPU updates a few numbers per range per tick, and the palette texture keeps the unrotated base colors. KoBeWi's Godot shader is the one-range form, offsetting the lookup by time and letting a REPEAT sampler wrap it.[61] McClure went the other way in 2010 and stored precomputed palette states as rows of a 2D texture, selecting a row with a frame value.[57]

Cycle ranges inside the shader
#version 300 es
precision highp float;
precision highp int;
precision highp usampler2D;

uniform usampler2D u_index_texture;   // R8UI, NEAREST, one level
uniform sampler2D u_palette_texture;  // the unrotated base colors, 256 x 1 RGBA8

in vec2 v_texcoord;
out vec4 out_color;

const int MAX_RANGES = 8;
uniform uint u_range_low[MAX_RANGES];    // inclusive
uniform uint u_range_high[MAX_RANGES];   // inclusive
uniform uint u_range_phase[MAX_RANGES];  // whole steps; the only per-tick update
uniform int u_range_count;

// Which base entry a stored index reads from once its range has advanced.
// Ranges must not overlap: the first match wins, so overlapping ranges would
// make the result depend on the order they were uploaded in.
uint cycled_index(uint palette_index) {
    for (int range_slot = 0; range_slot < MAX_RANGES; ++range_slot) {
        if (range_slot >= u_range_count) break;   // the rest is unused
        uint range_low = u_range_low[range_slot];
        uint range_high = u_range_high[range_slot];
        if (palette_index < range_low || palette_index > range_high) continue;
        uint range_size = range_high - range_low + 1u;
        uint slot_in_range = palette_index - range_low;
        // Forward, as ILBM defines it: register i shows the base color that sat
        // phase steps earlier in the range. Adding range_size before subtracting
        // keeps the arithmetic positive, where a borrow would wrap to about four
        // billion instead of going negative.
        uint whole_steps = u_range_phase[range_slot] % range_size;
        return range_low + (slot_in_range + range_size - whole_steps) % range_size;
    }
    return palette_index;   // outside every range: the index is its own lookup
}

void main() {
    ivec2 index_size = textureSize(u_index_texture, 0);
    vec2 texel_space = v_texcoord * vec2(index_size);
    ivec2 texel = clamp(ivec2(floor(texel_space)), ivec2(0), index_size - 1);
    uint stored_index = texelFetch(u_index_texture, texel, 0).r;
    // The palette texture never changes while a range cycles. Advancing a cycle
    // costs one uniform per range per tick, against 1 KB of palette upload for
    // the same motion done on the CPU. The work moves rather than disappearing:
    // the gather now runs once per fragment, ahead of the palette fetch.
    uint lookup_index = cycled_index(stored_index);
    out_color = texelFetch(u_palette_texture, ivec2(int(lookup_index), 0), 0);
}
A CPU reference for the three sampling paths
// Restated so this listing compiles on its own.
#include <array>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <span>
#include <vector>

struct Rgb8 { std::uint8_t red; std::uint8_t green; std::uint8_t blue; };
struct IndexedImage {
    std::size_t width;
    std::size_t height;
    std::vector<std::uint8_t> indices;   // width * height bytes, row-major
};

// Every path below ends on a byte. Ties round up here, in the Rust port and in
// the widget's JavaScript, so all three agree on every output pixel instead of
// disagreeing on the few that land exactly halfway.
std::uint8_t round_to_byte(double value) {
    double rounded = std::floor(value + 0.5);
    if (rounded <= 0.0) return 0;
    if (rounded >= 255.0) return 255;
    return static_cast<std::uint8_t>(rounded);
}

// One channel of a bilinear blend over a 2 x 2 block. The caller decides what
// it is blending: palette colors on the correct path, normalized indices on
// the broken one.
double blend_bilinear(double top_left, double top_right,
                      double bottom_left, double bottom_right,
                      double blend_weight_x, double blend_weight_y) {
    double top = top_left + (top_right - top_left) * blend_weight_x;
    double bottom = bottom_left + (bottom_right - bottom_left) * blend_weight_x;
    return top + (bottom - top) * blend_weight_y;
}

// GLSL texelFetch and HLSL Load read one texel at integer coordinates and apply
// no wrap mode, so nothing clamps the coordinate for you.
std::uint8_t fetch_index(const IndexedImage& image,
                         std::int64_t x, std::int64_t y) {
    if (image.width == 0 || image.height == 0) return 0;   // nothing to clamp to
    std::int64_t last_x = static_cast<std::int64_t>(image.width) - 1;
    std::int64_t last_y = static_cast<std::int64_t>(image.height) - 1;
    std::int64_t clamped_x = x < 0 ? 0 : (x > last_x ? last_x : x);
    std::int64_t clamped_y = y < 0 ? 0 : (y > last_y ? last_y : y);
    std::size_t row_start = static_cast<std::size_t>(clamped_y) * image.width;
    return image.indices[row_start + static_cast<std::size_t>(clamped_x)];
}

// The color one texel stands for: the index fetch above, then the palette
// entry it names. This is the whole of a palette lookup.
Rgb8 fetch_color(const IndexedImage& image, std::span<const Rgb8, 256> palette,
                 std::int64_t x, std::int64_t y) {
    return palette[static_cast<std::size_t>(fetch_index(image, x, y))];
}

// NEAREST picks the texel whose square contains the coordinate: one index
// fetch, one palette lookup, and the result is always a color the palette
// holds.
Rgb8 sample_nearest(const IndexedImage& image,
                    std::span<const Rgb8, 256> palette, double u, double v) {
    double texels_wide = static_cast<double>(image.width);
    double texels_high = static_cast<double>(image.height);
    std::int64_t texel_x = static_cast<std::int64_t>(std::floor(u * texels_wide));
    std::int64_t texel_y = static_cast<std::int64_t>(std::floor(v * texels_high));
    return fetch_color(image, palette, texel_x, texel_y);
}

struct BilinearFootprint {
    std::int64_t texel_x;      // top-left texel of the 2 x 2 block
    std::int64_t texel_y;
    double blend_weight_x;     // 0 at the left texel center, 1 at the right
    double blend_weight_y;
};

// Both filtered paths read the same four texels with the same weights, which is
// what makes the comparison below fair: only the place the blend happens moves.
BilinearFootprint bilinear_footprint(const IndexedImage& image,
                                     double u, double v) {
    // Texel centers sit at integer + 0.5, so half a texel comes off the scaled
    // coordinate to put the weights in [0, 1) between the straddling texels.
    double texel_space_x = u * static_cast<double>(image.width) - 0.5;
    double texel_space_y = v * static_cast<double>(image.height) - 0.5;
    double base_x = std::floor(texel_space_x);
    double base_y = std::floor(texel_space_y);
    return BilinearFootprint{
        static_cast<std::int64_t>(base_x), static_cast<std::int64_t>(base_y),
        texel_space_x - base_x, texel_space_y - base_y
    };
}

// Four index fetches, four palette lookups, then one blend of the colors that
// came back. SDL 3.4's linear palette mode does the same four lookups and
// blends the colors; like SDL, this blends the stored (encoded) channel
// values rather than decoding to linear light first.
Rgb8 sample_filter_colors(const IndexedImage& image,
                          std::span<const Rgb8, 256> palette,
                          double u, double v) {
    BilinearFootprint footprint = bilinear_footprint(image, u, v);
    std::int64_t left = footprint.texel_x;    // the 2 x 2 block starts here
    std::int64_t top = footprint.texel_y;
    Rgb8 top_left     = fetch_color(image, palette, left,     top);
    Rgb8 top_right    = fetch_color(image, palette, left + 1, top);
    Rgb8 bottom_left  = fetch_color(image, palette, left,     top + 1);
    Rgb8 bottom_right = fetch_color(image, palette, left + 1, top + 1);
    return Rgb8{
        round_to_byte(blend_bilinear(
            top_left.red, top_right.red, bottom_left.red, bottom_right.red,
            footprint.blend_weight_x, footprint.blend_weight_y)),
        round_to_byte(blend_bilinear(
            top_left.green, top_right.green, bottom_left.green, bottom_right.green,
            footprint.blend_weight_x, footprint.blend_weight_y)),
        round_to_byte(blend_bilinear(
            top_left.blue, top_right.blue, bottom_left.blue, bottom_right.blue,
            footprint.blend_weight_x, footprint.blend_weight_y))
    };
}

// The same footprint with the blend in the wrong place. A single-channel index
// texture read with GL_LINEAR hands the shader an interpolated label, and the
// shader scales it back to an index: halfway between index 0 and index 2 that
// is index 1, an entry neither texel names and whose color is unrelated to
// both unless the palette happens to be a ramp.
Rgb8 sample_filter_indices_wrong(const IndexedImage& image,
                                 std::span<const Rgb8, 256> palette,
                                 double u, double v) {
    BilinearFootprint footprint = bilinear_footprint(image, u, v);
    std::int64_t left = footprint.texel_x;    // the same 2 x 2 block
    std::int64_t top = footprint.texel_y;
    double top_left     = fetch_index(image, left,     top) / 255.0;
    double top_right    = fetch_index(image, left + 1, top) / 255.0;
    double bottom_left  = fetch_index(image, left,     top + 1) / 255.0;
    double bottom_right = fetch_index(image, left + 1, top + 1) / 255.0;
    double blended_value = blend_bilinear(
        top_left, top_right, bottom_left, bottom_right,
        footprint.blend_weight_x, footprint.blend_weight_y);
    std::uint8_t resolved_index = round_to_byte(blended_value * 255.0);
    return palette[resolved_index];
}

// What a shader receives for a stored index when the texture carries the sRGB
// flag: the hardware reads the label as a color and decodes it. In desktop
// OpenGL 4.6 texelFetch skips filtering and wrap modes but still performs the
// sRGB conversion of fetched values, so point sampling is no defense. Stored
// index 128 arrives as 55, indices 0 to 6 all arrive as 0, and 256 labels
// collapse onto 183 distinct values.
std::uint8_t srgb_decode_index(std::uint8_t stored_index) {
    double encoded = static_cast<double>(stored_index) / 255.0;
    double linear = encoded <= 0.04045 ? encoded / 12.92
                                       : std::pow((encoded + 0.055) / 1.055, 2.4);
    return round_to_byte(linear * 255.0);
}
// Restated so this listing compiles on its own.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Rgb8 { pub red: u8, pub green: u8, pub blue: u8 }

pub struct IndexedImage {
    pub width: usize,
    pub height: usize,
    pub indices: Vec<u8>,   // width * height bytes, row-major
}

// Every path below ends on a byte. Ties round up here, in the C++ port and in
// the widget's JavaScript, so all three agree on every output pixel instead of
// disagreeing on the few that land exactly halfway.
pub fn round_to_byte(value: f64) -> u8 {
    let rounded = (value + 0.5).floor();
    if rounded <= 0.0 { return 0; }
    if rounded >= 255.0 { return 255; }
    rounded as u8
}

// One channel of a bilinear blend over a 2 x 2 block. The caller decides what
// it is blending: palette colors on the correct path, normalized indices on
// the broken one.
pub fn blend_bilinear(top_left: f64, top_right: f64,
                      bottom_left: f64, bottom_right: f64,
                      blend_weight_x: f64, blend_weight_y: f64) -> f64 {
    let top = top_left + (top_right - top_left) * blend_weight_x;
    let bottom = bottom_left + (bottom_right - bottom_left) * blend_weight_x;
    top + (bottom - top) * blend_weight_y
}

// GLSL texelFetch and HLSL Load read one texel at integer coordinates and apply
// no wrap mode, so nothing clamps the coordinate for you.
pub fn fetch_index(image: &IndexedImage, x: i64, y: i64) -> u8 {
    if image.width == 0 || image.height == 0 { return 0; }   // nothing to clamp to
    let last_x = image.width as i64 - 1;
    let last_y = image.height as i64 - 1;
    let clamped_x = x.clamp(0, last_x);
    let clamped_y = y.clamp(0, last_y);
    let row_start = clamped_y as usize * image.width;
    image.indices[row_start + clamped_x as usize]
}

// The color one texel stands for: the index fetch above, then the palette
// entry it names. This is the whole of a palette lookup.
pub fn fetch_color(image: &IndexedImage, palette: &[Rgb8; 256],
                   x: i64, y: i64) -> Rgb8 {
    palette[fetch_index(image, x, y) as usize]
}

// NEAREST picks the texel whose square contains the coordinate: one index
// fetch, one palette lookup, and the result is always a color the palette
// holds.
pub fn sample_nearest(image: &IndexedImage, palette: &[Rgb8; 256],
                      u: f64, v: f64) -> Rgb8 {
    let texels_wide = image.width as f64;
    let texels_high = image.height as f64;
    let texel_x = (u * texels_wide).floor() as i64;
    let texel_y = (v * texels_high).floor() as i64;
    fetch_color(image, palette, texel_x, texel_y)
}

pub struct BilinearFootprint {
    pub texel_x: i64,            // top-left texel of the 2 x 2 block
    pub texel_y: i64,
    pub blend_weight_x: f64,     // 0 at the left texel center, 1 at the right
    pub blend_weight_y: f64,
}

// Both filtered paths read the same four texels with the same weights, which is
// what makes the comparison below fair: only the place the blend happens moves.
pub fn bilinear_footprint(image: &IndexedImage, u: f64, v: f64) -> BilinearFootprint {
    // Texel centers sit at integer + 0.5, so half a texel comes off the scaled
    // coordinate to put the weights in [0, 1) between the straddling texels.
    let texel_space_x = u * image.width as f64 - 0.5;
    let texel_space_y = v * image.height as f64 - 0.5;
    let base_x = texel_space_x.floor();
    let base_y = texel_space_y.floor();
    BilinearFootprint {
        texel_x: base_x as i64,
        texel_y: base_y as i64,
        blend_weight_x: texel_space_x - base_x,
        blend_weight_y: texel_space_y - base_y,
    }
}

// Four index fetches, four palette lookups, then one blend of the colors that
// came back. SDL 3.4's linear palette mode does the same four lookups and
// blends the colors; like SDL, this blends the stored (encoded) channel
// values rather than decoding to linear light first.
pub fn sample_filter_colors(image: &IndexedImage, palette: &[Rgb8; 256],
                            u: f64, v: f64) -> Rgb8 {
    let footprint = bilinear_footprint(image, u, v);
    let left = footprint.texel_x;    // the 2 x 2 block starts here
    let top = footprint.texel_y;
    let top_left = fetch_color(image, palette, left, top);
    let top_right = fetch_color(image, palette, left + 1, top);
    let bottom_left = fetch_color(image, palette, left, top + 1);
    let bottom_right = fetch_color(image, palette, left + 1, top + 1);
    Rgb8 {
        red: round_to_byte(blend_bilinear(
            top_left.red as f64, top_right.red as f64,
            bottom_left.red as f64, bottom_right.red as f64,
            footprint.blend_weight_x, footprint.blend_weight_y)),
        green: round_to_byte(blend_bilinear(
            top_left.green as f64, top_right.green as f64,
            bottom_left.green as f64, bottom_right.green as f64,
            footprint.blend_weight_x, footprint.blend_weight_y)),
        blue: round_to_byte(blend_bilinear(
            top_left.blue as f64, top_right.blue as f64,
            bottom_left.blue as f64, bottom_right.blue as f64,
            footprint.blend_weight_x, footprint.blend_weight_y)),
    }
}

// The same footprint with the blend in the wrong place. A single-channel index
// texture read with GL_LINEAR hands the shader an interpolated label, and the
// shader scales it back to an index: halfway between index 0 and index 2 that
// is index 1, an entry neither texel names and whose color is unrelated to
// both unless the palette happens to be a ramp.
pub fn sample_filter_indices_wrong(image: &IndexedImage, palette: &[Rgb8; 256],
                                   u: f64, v: f64) -> Rgb8 {
    let footprint = bilinear_footprint(image, u, v);
    let left = footprint.texel_x;    // the same 2 x 2 block
    let top = footprint.texel_y;
    let top_left = fetch_index(image, left, top) as f64 / 255.0;
    let top_right = fetch_index(image, left + 1, top) as f64 / 255.0;
    let bottom_left = fetch_index(image, left, top + 1) as f64 / 255.0;
    let bottom_right = fetch_index(image, left + 1, top + 1) as f64 / 255.0;
    let blended_value = blend_bilinear(
        top_left, top_right, bottom_left, bottom_right,
        footprint.blend_weight_x, footprint.blend_weight_y);
    let resolved_index = round_to_byte(blended_value * 255.0);
    palette[resolved_index as usize]
}

// What a shader receives for a stored index when the texture carries the sRGB
// flag: the hardware reads the label as a color and decodes it. In desktop
// OpenGL 4.6 texelFetch skips filtering and wrap modes but still performs the
// sRGB conversion of fetched values, so point sampling is no defense. Stored
// index 128 arrives as 55, indices 0 to 6 all arrive as 0, and 256 labels
// collapse onto 183 distinct values.
pub fn srgb_decode_index(stored_index: u8) -> u8 {
    let encoded = stored_index as f64 / 255.0;
    let linear = if encoded <= 0.04045 {
        encoded / 12.92
    } else {
        ((encoded + 0.055) / 1.055).powf(2.4)
    };
    round_to_byte(linear * 255.0)
}
What's intentionally missing

The listings sample one mip level, so authoring the smaller levels and sampling them with NEAREST_MIPMAP_NEAREST is left out, as is the palette upload path and the dirty range it should carry. Also absent: sRGB encoding on output, BlendShift in the shader, overlapping ranges, and alpha-blended pixels drawn over animated ones.

14Modern uses

The CLUT on a 1990 video card did the lookup at scan-out for free. Every modern implementation has to decide where the lookup happens, and that decision sets what one palette tick costs and what breaks.

Four places to put the lookup

Where the lookup happensShipping exampleOne palette tick costsWhat breaks
Scan-out (real or emulated CLUT) OpenTTD's 8bpp blitter, which hands animation to the video backend[9]; PICO-8's display palette[25] the changed entries nothing, until the OS composites in true color (§3)
CPU side buffer of indices OpenTTD's 32bpp-anim blitter[9]; Canvas Cycle's dirty list (§9) a walk over the whole buffer and a full-screen redraw alpha-blended pixels stop animating
CPU resolve at draw time the Sonic Mania decompilation's RSDKv5 renderer[48] re-resolving what is drawn that frame nothing; the HUD draws after the water bank is reset
GPU index texture and palette Shovel Knight[11], EDuke32[59], SDL 3.4[12], OpenTTD's 40bpp-anim[9], OpenRCT2[58] a sub-range upload or a 256-entry uniform write filtering, mipmaps, sRGB and compression (§13)

1 · OpenTTD: one game, three cost models

The 28 animated entries, slots 227 to 254, are fizzy drink, oil refinery fire, radio tower lights, the lighthouse and stadium lamp, dark water and glitter water, with separate dark and glitter tables for Toyland[9]. One counter drives all of them. palette_animation_counter += 8 runs once per 27 ms game tick, and (uint16_t)(counter * p) * q >> 16 scales the wrapped phase into a table of length q, so each effect takes its own speed p out of the same counter[9]. The complemented counter runs the fizzy drink and the refinery flare backwards, and the radio beacon skips the table and compares thresholds against a 7-bit window of the counter[9]. Periods follow: dark water turns over about every 691 ms, the lighthouse every 864 ms, the glitter every 1.73 s.

Three blitters pay for that differently. The 8bpp blitter writes the entries and leaves the animation to the video backend. The 32bpp-anim blitter keeps a uint16_t per pixel, index in the low byte and brightness in the high byte, 6 bytes per pixel once the color buffer is counted; on every palette tick it walks that whole side buffer, rewrites the animated pixels and marks the entire screen dirty, which is 2,073,600 pixels inspected per tick at 1920 by 1080[9]. The 40bpp-anim blitter with the OpenGL driver (1.11.0-beta2, 2021-02-28) uploads an R8 index texture and a 256-entry 1D palette texture, updates the palette with glTexSubImage1D over the changed range only, and re-derives brightness from max(r, g, b) of the color buffer instead of storing it[9]. Both true-color paths write 0 into the index buffer for semi-transparent pixels, for the reason §12 quotes[9].

OpenTTD's palette animation counter
#include <cstdint>

// OpenTTD advances one palette animation counter by 8 every 27 ms game tick,
// and every animated range reads its own entry out of that single counter.
inline constexpr std::uint32_t COUNTER_STEP_PER_TICK = 8;

// OpenTTD's EXTR macro. The low 16 bits of counter * speed are a phase in
// [0, 65536); scaling that phase by the table length and shifting right 16
// picks the entry. Speed sets how fast the phase wraps, so one counter drives
// water, glitter and the lighthouse at three different rates with no per-range
// state. Unsigned arithmetic throughout, so the wraparound is defined.
std::uint32_t cycle_table_index(std::uint32_t animation_counter, std::uint32_t speed, std::uint32_t table_length) {
    if (table_length == 0) return 0;                                     // an empty table has no entry to pick
    const std::uint32_t phase = (animation_counter * speed) & 0xFFFFu;   // the cast to uint16 in the original
    // The page's tables are a few dozen entries, so phase * table_length stays
    // far inside 32 bits; a table of 65,536 entries would need a wider product.
    return (phase * table_length) >> 16;                                 // a multiply and a shift instead of a modulo
}

// EXTR2 complements the counter before the multiply, which runs the same table
// backwards at the same speed. OpenTTD uses it for the fizzy drink animation.
std::uint32_t cycle_table_index_reversed(std::uint32_t animation_counter, std::uint32_t speed, std::uint32_t table_length) {
    if (table_length == 0) return 0;
    const std::uint32_t phase = (~animation_counter * speed) & 0xFFFFu;
    return (phase * table_length) >> 16;
}
// OpenTTD advances one palette animation counter by 8 every 27 ms game tick,
// and every animated range reads its own entry out of that single counter.
pub const COUNTER_STEP_PER_TICK: u32 = 8;

// OpenTTD's EXTR macro. The low 16 bits of counter * speed are a phase in
// [0, 65536); scaling that phase by the table length and shifting right 16
// picks the entry. Speed sets how fast the phase wraps, so one counter drives
// water, glitter and the lighthouse at three different rates with no per-range
// state. wrapping_mul keeps the wraparound instead of panicking on overflow.
pub fn cycle_table_index(animation_counter: u32, speed: u32, table_length: u32) -> u32 {
    if table_length == 0 { return 0; }                             // an empty table has no entry to pick
    let phase = animation_counter.wrapping_mul(speed) & 0xFFFF;    // the cast to uint16 in the original
    // The page's tables are a few dozen entries, so phase * table_length stays
    // far inside 32 bits; a table of 65,536 entries would need a wider product.
    (phase * table_length) >> 16                                   // a multiply and a shift instead of a modulo
}

// EXTR2 complements the counter before the multiply, which runs the same table
// backwards at the same speed. OpenTTD uses it for the fizzy drink animation.
pub fn cycle_table_index_reversed(animation_counter: u32, speed: u32, table_length: u32) -> u32 {
    if table_length == 0 { return 0; }
    let phase = (!animation_counter).wrapping_mul(speed) & 0xFFFF;
    (phase * table_length) >> 16
}
Live · Three ways to animate one palette
8bpp: palette at scan-out
bytes
84
touched
0
frozen
0
32bpp-anim: CPU walk
inspected
0
rewritten
0
frozen
0
40bpp-anim: GPU index texture (emulated)
bytes
112
shaded
0
frozen
0

At 1920 x 1080 the 32bpp-anim walk inspects 2,073,600 pixels per tick. tick 0 · counter 0

All three panels run OpenTTD's counter arithmetic on the same generated harbor: add 8 per 27 ms tick, then scale the wrapped phase to a table index. The 8-bit path writes 28 palette entries and leaves the pixels to scan-out. The 32bpp-anim path walks every pixel of its side buffer each tick and rewrites the animated ones. The 40bpp-anim path uploads the 28 entries and shades the whole frame on the GPU, emulated here. Turn on the cloud: a blend isn't an index, so the two true-color paths store 0 under it and that water freezes, while the 8-bit panel uses a stipple. The colors are illustrative, not OpenTTD's.

2 · OpenRCT2: the frame lives in index space

OpenRCT2 keeps the whole frame in indices. Its framebuffers and sprite atlas are GL_R8UI integer textures; the final pass is one line, oColour = uPalette[texture(uTexture, fTextureCoordinate).r];, over a 256-entry uniform array, so a palette change is a single glUniform4fv with no texture upload[58]. The resolve runs before the GL_LINEAR upscale, so the filter only ever sees colors[58]. Water waves write palette entries 230 to 234 and sparkles 235 to 239, each reading 5 entries from a 15-entry G1 palette table, every third entry, starting at the phase the counter picks; the effect clock is a fixed 40 Hz; a lightning flash brightens the dynamic range for one frame, restored on the next[58].

3 · Sonic Mania: palette banks per scanline

Per the public decompilation, RSDKv5 keeps 8 palette banks of 256 RGB565 entries and a per-scanline table that selects the bank a row uses; sprites are resolved through their row's bank as they are drawn into a 16-bit framebuffer, so the lookup happens at draw time and not at present[48]. Underwater is that bank switch at the water line, installed as a draw hook before the first draw group and removed before the HUD's, which is why the HUD isn't tinted; which bank is the water bank depends on the zone[48]. The decompiled objects hold 57 RotatePalette calls across 19 files. Green Hill rotates two keyframe banks and blends them into the display bank every frame (§9), and Oil Ocean does the same with two pairs, banks 3 and 4 into bank 1 and banks 5 and 6 into bank 2[48].

4 · Shaders, libraries and engines

5 · Fantasy consoles made the palette an API again

6 · The palette, generalized

7 · Tools and revivals

15Pitfalls

16What's next

If you maintain a palette renderer, the most useful test to add is the one the tables listing ships: rotate a range, rotate every table that stores indices, and compare against tables rebuilt from the rotated palette. Any mismatch is a lit or translucent pixel that will stop moving.

17Sources

  1. IBM. IBM VGA/XGA Technical Reference Manual (Video Subsystem), preliminary draft, May 19, 1992. bitsavers.trailing-edge.com (PDF). The primary for §3 and §4: mode 13h layout and 256 KB of video memory, the DAC write protocol at 3C8h/3C9h and IBM's per-entry sequence with interrupts disabled, the snow rule tied to the DAC blank input, Input Status 1 bit 3, and Color Select paging. The same manual says video memory "may be made at any time without screen degradation", which is what scopes the restriction to the DAC.
  2. Michael Abrash. Michael Abrash's Graphics Programming Black Book, Special Edition, chapters 33, 34 and 43. Coriolis, 1997. github.com/jagregory/abrash-black-book, phatcode.net (ch. 43). Supports §4 and §11: "It isn't color until the DAC says it's color", OUTs called "painfully slow", the 8 MHz AT figures of 288 µs for 768 OUTs plus "in the ballpark of 1,800 microseconds more", partial and interleaved loads, the four clone BIOSes and INT 10h AX=1012h, color paging as "generally superior to color cycling whenever it's available", and bit-plane animation with Table 43.1.
  3. Modern Vintage Gamer. "The Beauty of Color Cycling in Video Games." YouTube, September 14, 2026. youtube.com/watch?v=k9i17JizmcU. The video that inspired this article, cited in §1. Its description is quoted verbatim ("without using extra processing power or memory"); the claims about Ferrari's later work near 07:42 and about Sonic 3's Blue Sphere come from auto-captions, so they're paraphrased and checked in §2.
  4. Amiga Graphics Archive (LyCheSis). "Specials: Color Cycling," with the ILBM files it links. amiga.lychesis.net, DeluxePaint_Yacht.iff. The catalogue behind §2 and §6, including the Boing plane description §7 checks against the disassembly and the Defender of the Crown note in §10. Counts over those 87 ILBMs: 315 CRNG chunks, 249 with non-zero pad1 (246 in 1..16383), 121 with low not below high, 29 at rate 36 (22 with the active bit clear), 31 with flags 3, 161 of 162 live ranges on slider values, and 82 of 87 with left-justified CMAPs.
  5. Electronic Arts. DeluxePaint II manual, Apple IIGS edition. archive.org. The Dan Silva biography that dates Deluxe Paint to November 1985 (§1 and §2), and the bouncing-ball sample drawn in Range 2 colors that §11 quotes.
  6. Michael Boom. The Amiga: Images, Sounds, and Animation on the Commodore Amiga. Microsoft Press, 1986. archive.org. Period description of Deluxe Paint I in §1 and §2: one shade range and three cycle ranges, each with its own speed, cycled with Tab.
  7. Electronic Arts. CnC_Remastered_Collection source release, commit f1f0d42b, TIBERIANDAWN/CONQUER.CPP. github.com/electronicarts. The shipped example in §1 and §5: Color_Cycle() rotates water entries 32-38 every quarter second and pulses entry 255 every eighth of a second, then calls Wait_Vert_Blank(); Set_Palette(GamePalette);.
  8. Sonic Retro. s1disasm, commit 064e3c68, _inc/PaletteCycle.asm; skdisasm, commit 044fa467, sonic3k.asm. github.com/sonicretro/s1disasm, skdisasm. Community disassemblies, named as such in §2 and §5: Green Hill's 4-entry water table written into palette line 3 every 6 frames, and Blue Sphere's Rotate_SSPal and Update_SSMap.
  9. OpenTTD contributors. OpenTTD source, commit 1aca0b60: src/palette.cpp, src/table/palettes.h, src/blitter/{8bpp_base,32bpp_anim,40bpp_anim}.cpp, src/video/opengl.cpp, changelog.md. github.com/OpenTTD (pinned). The spine of §14: the 28 animated entries 227-254, palette_animation_counter += 8 with EXTR and EXTR2, the radio-tower thresholds, the three blitters' cost models, the 40bpp-anim OpenGL path added in 1.11.0-beta2 (2021-02-28), and GL_R8 remap textures sampled with GL_NEAREST_MIPMAP_NEAREST.
  10. John Carmack. DCOLORS 1.1, dcolors.c, id Software, 1992. github.com/linguica/dmutils (pinned). How PLAYPAL and COLORMAP are generated, which §12 rebuilds: 34 rows, the scaling (c*(32-l)+16)/32, unweighted RGB distance, the inverted-gray row with a blue weight of 0.144, the all-black row, and the damage and pickup palettes.
  11. David D'Angelo (Yacht Club Games). "Breaking the NES for Shovel Knight." Game Developer, June 25, 2014. gamedeveloper.com. The index-texture plus palette-texture pixel shader quoted in §13, with the 54-color NES palette plus four extra colors, the sprite color limits it works around, and the limited palettes used "to create enemy variants and for cycling damage and explosion effects".
  12. SDL developers. SDL source, release-3.4.0 (January 1, 2026) and commit dc058260: include/SDL3/SDL_render.h, src/render/SDL_render.c, src/render/opengl/SDL_render_gl.c, SDL_shaders_gl.c; release-2.32.10 for SDL2. github.com/libsdl-org/SDL. Supports §13 and §14: palettized textures arriving in 3.4.0 after SDL2 rejected them, the 256 by 1 palette texture updated with glTexSubImage2D and sampled at (index + 0.5) / 256.0, the linear mode's four lookups and color blend crediting bgolus, the re-blit fallback, and 9 of 14 in-tree backends carrying the native path on main in September 2026.
  13. "Best of BIX." BYTE 11(3), March 1986. archive.org. The period account quoted in §7: Bob Pariseau on Boing (color cycle rotation, 14 of 32 registers, bitplane start pointers, "no blitting", slow and smooth), Dale Luck on dates, and Jez San on the Atari version.
  14. Jerry Morrison (Electronic Arts). "EA IFF 85: Standard for Interchange Format Files," January 14, 1985, and "ILBM: IFF Interleaved Bitmap," January 17, 1986, with CRNG revised October 1988 and the DRNG registry entry by Lee Taran. wiki.amigaos.net, Morrison's PDF. The format authority for §6: chunk layout and pad byte, "You can NOT ever depend on any particular ILBM chunk being at any particular offset", the CRNG, CCRT and DRNG layouts, WORD active in 1986 against flags with RNG_ACTIVE and RNG_REVERSE in 1988, rate 16384 as 60 steps per second, the forward-direction sentence quoted in §5, the CMAP scaling advice and the bits-per-gun hints. The rate-36 warning appears only in the wiki copy.
  15. IBM. Announcement Letter 187-051, IBM Personal System/2 Model 80, April 2, 1987. ardent-tool.com (mirror). The dated primary for "320 x 200 graphics in 256 colors" in §2.
  16. Electronic Arts. DeluxePaint IV manual, Amiga, 1991. archive.org. Eight ranges of up to 32 colors with a rate per range (§2), plus the Running Man frames-in-indices example that §11 builds on.
  17. Pixfabrik LLC. "Living Worlds: Art by Mark Ferrari" and "Credits." pixfabrik.com/livingworlds, credits. Dates Seize the Day to 1993 with twelve scenes whose sun follows the computer clock (§10), and states the app's provenance "with Mark's support and additional code from Joseph Huckaby".
  18. Mark J. Ferrari. "Q & A with Mark J. Ferrari." EffectGames.com, 2010. effectgames.com. Written answers, quoted verbatim in §8 and §10: Deluxe Paint and stencils, image times, the marquee analogy, the "simulated acid trip" warning, dither choices, rain paths and overlays, foliage clown colors, time-of-day palettes, the nine X-Men palettes, the GIF frame count, and permission for Canvas Cycle.
  19. Joseph Huckaby. "Old School Color Cycling with HTML5," EffectGames.com, July 2010; canvascycle source, commit a7744fb, MIT since February 2024; the demo scene files. effectgames.com, github.com/jhuckaby/canvascycle, demo. The source for §9 and the fourth CRNG reader in §6: BlendShift and its stated purpose, the dirty-pixel list, rate / 280, flags read as modes with the sine branch, the C++ magenta check, the lbm2json struct, and scene statistics (dirty share, identical Mountain Stream and Mirror Pond buffers, no overlapping ranges). Kotaku dated the release July 26, 2010: kotaku.com.
  20. Dan Silva et al. (Electronic Arts). DeluxePaint I source code, released by the Computer History Museum, July 22, 2015: src/CCYCLE.C, src/DPIFF.C, src/PRISM.H, src/DOPALETT.C. computerhistory.org, mirror. The code §5 and §6 read directly: the rotation loop, OnePerTick 16384 with the vertical-blank accumulator that steps at most once per field, Range {SHORT count; SHORT rate; BOOL active; UBYTE low, high;} written with PutCk(..., sizeof(Range), ...), which is why pad1 holds a phase, plus rateFromIndex and OnePerSec*10.
  21. Mark Ferrari. "8 Bit & '8 Bitish' Graphics-Outside the Box." GDC 2016. gdcvault.com, video. Known from auto-captions, so §8 paraphrases, quoting at most a short phrase and marking it as an auto-caption. Timestamps used: ~18:36 Thimbleweed Park backgrounds in Photoshop, ~38:40 highlights as diverging runs, ~39:00 palette budget, ~52:00 segment length and speed, ~53:17 seam bridging, ~54:00 checkerboard interleave, ~55:00 value- and hue-shifted gradients, ~57:53 phase offsets, ~62:00 snow gradients, ~70:24 the Spyro waterfall.
  22. Commodore-Amiga. Amiga Hardware Reference Manual, 3rd ed., 1991, via Amiga Developer CD 2.1. amigadev.elowar.com. The hardware authority for §4 and §11: color indirection, the write-only 12-bit COLOR00-31 registers and their addresses, Extra Half-Brite and dual playfield, the Copper WAIT and MOVE encoding with the $9601,$FF00 sample and the end marker, 113 horizontal positions, "four memory cycle times" per MOVE, COPCON's danger bit with the register table, and the chip RAM figures.
  23. NESdev Wiki. "PPU palettes," "PPU memory map," "PPU nametables," "PPU attribute tables," "PPU rendering." nesdev.org. A community wiki, named as such in §4: palette RAM layout and usable entries, nametable and attribute sizes, 2 KB of PPU RAM, the vblank write rule and the backdrop override.
  24. DOSBox Staging. src/utils/rgb.h (commit 75ba804a) and src/hardware/video/vga_dac.cpp. rgb.h, vga_dac.cpp. What an emulator does with 8-bit input in §4: rgb8_to_6 as (c * 253 + 505) >> 10, identical to (c * 63 + 127) / 255 for every input, and write_p3c9 masking with val &= 0x3f.
  25. Lexaloffle. PICO-8 User Manual v0.2.7; zep on per-line screen palettes, Lexaloffle BBS, June 25, 2020; ShootingStar, "Secret Palette Discovered," September 4, 2019. manual, bbs 38555, bbs 35256. The fantasy-console row in §14: draw palette against display palette quoted from the manual, poke(0x5f5f,0x10) with 0x5f70-0x5f7f, and the undocumented colors 128-143 with the date they were first reported.
  26. Guspaz. SC2KRepainter README. github.com/Guspaz. The compatibility failure in §3: SimCity 2000's palette animation freezes under the 256-color compatibility mode in Windows 10, and its author reports the same for Windows 8, until the game repaints.
  27. INMOS / SGS-Thomson. Graphics Databook, 2nd ed., 1990, §3.2 (IMS G171) and the G178. transputer.net (PDF). The part behind §4's quiz: the G171's 256 by 18-bit look-up table with 6-bit DACs storing only the low six bits of each byte written, INMOS's own statement that IBM used it in all PS/2 machines, and the G178's 8-bit mode, which is why §4 says RAMDACs vary.
  28. Joshua D. Neal. FreeVGA Project: "DAC Operation" and "Color Registers." osdever.net (DAC Operation), Color Registers. The scope limit §4 states in one clause, on the Color Registers page: "great variance in the actual behavior of these registers on VGA chipsets".
  29. Power Programs. "Copper Chunky." powerprograms.nl. A community measurement, named as such in §4: a MOVE changes at best one color every 8 lowres pixels, and fewer with more bitplanes enabled.
  30. Codetapper. "Agony." Amiga Sprite Tricks. codetapper.com. The shipped Copper list quoted in §4: the dump containing 4043 fffe, per-line COLOR00 changes at $42 and $D6, the two alternating lists, and the duplicate color pairs.
  31. Plutiedev. "Tiles and palettes" and "Sonic water." plutiedev.com, sonic-water. A community site, named as such in §4: CRAM writes during active display produce CRAM dots, and Sonic hides them under the water-surface sprites.
  32. Microsoft. "Palette Animation," "AnimatePalette function," and "System Palette and Static Colors." Microsoft Learn; Ron Gery, "Using DIBs with Palettes," MSDN. learn.microsoft.com, Using DIBs with Palettes. The Windows path in §4: AnimatePalette changes only PC_RESERVED entries, against the 20 static colors Gery's article counts, which leave 236 free on an 8-bit device.
  33. Lode Vandevenne. "Plasma." Lode's Computer Graphics Tutorial. lodev.org. The widely copied shift loop palette[(plasma[y][x] + paletteShift) % 256], and the claim that video cards rotate the palette, which §5 corrects.
  34. diasurgical contributors. DevilutionX source, commit e00b7260: Source/engine/palette.cpp, Source/lighting.cpp, Source/utils/palette_blending.hpp, Source/diablo.cpp, Source/options.cpp; devilution README at commit 9f017572 for the recovered symbols. github.com/diasurgical (pinned). The fix §12 is built around: CycleColors via std::rotate, cave cycling per game tick, the Hellfire loop-length comments, the two-axis blend-table rotation with range exclusion added in 1.2.0 as a port addition, the Hell-level light-table rotation, and the k-d tree search.
  35. John Tsiombikas. colcycle, commit 375cad7, GPL-3.0. github.com/jtsiomb/colcycle. The third CRNG reader in §6: liveness from low != high and rate > 0, direction from flags & 2, gather plus a 256-level blend, and a DOS path through 3C8h/3C9h with a shift right by 2 and 3DAh polling. Its download script fetches Walter Randelshofer's archive of Ferrari's LBMs (randelshofer.ch), the 48 files measured for §6.
  36. Marc Palacios. Tilengine, commit 79895a4 (src/Animation.c, src/Palette.c); ColorCycle samples, commit 039ab57. github.com/megamarc/Tilengine, ColorCycle. A current engine's take in §5: O(n) strip cycling, and floor(16384 / rate) frames per step in the converted scenes.
  37. id Software. DOOM source, linuxdoom-1.10; Doom Wiki, "COLORMAP" and "PLAYPAL." github.com/id-Software/DOOM, doomwiki.org. The lighting tables in §12: the zlight and scalelight row tables, the visor's colormap 1 per the source against one wiki sentence that says 0, the sky's colormap 0, fuzz through colormap 6 reading rows above and below, and PLAYPAL selection for damage, pickups and the radiation suit with palettes 1 and 9 unused.
  38. Commodore-Amiga. iffp/ilbm.h, Amiga Developer CD 2.1, Devices Manual. amiga.nvg.org. The one line that explains the rate-36 reader rule in §6: #define RNG_NORATE 36 /* Dpaint uses this rate to mean non-active */.
  39. NootCollective. Unity3d-LBMColorCycling, sample Assets/Sample/Images/TileSetColorCycling.lbm, committed May 16, 2020. github.com/NootCollective. One of the preset payloads in §6: the CRNG chunk at offset 0x330 with flags bytes 01 00.
  40. Electronic Arts. DeluxePaint II Enhanced manual for IBM, 1989. archive.org. The DOS product in §6: 16 gradients each with its own cycling speed, and "You can turn cycling off for a particular gradient by setting the rate to zero".
  41. Future Crew. Second Reality source release. github.com/mtuomi/SecondReality. A DOS corpus for §6: PC Deluxe Paint files in both FORM PBM and FORM ILBM. Counts: 848 CRNG chunks across 53 files, RNG_ACTIVE never set, flag value 4 present, and every rate 0.
  42. Electronic Arts. Programmer's Kit for DeluxePaint Animation, version 1.0, October 24, 1990: ANIMFILE.TXT, LPFILE.C, ANIM.H. github.com/qb40. Confirms in §6 that the DOS line kept the same record, Range {WORD count; WORD rate; WORD flags; UBYTE low, high;}, with cycles stored "for 18x/sec timer" or "70x/sec timer".
  43. GrafX2 developers, GrafX2 source, commit f84cb09d (src/ifformat.c, src/pngformat.c, src/helpfile.h); Werner Randelshofer, MonteMedia (ILBMDecoder.java, CRNGColorCycle.java). gitlab.com/GrafX2, github.com/wrandelshofer. Two more readers for §6: GrafX2's (flags&1) ? rate/78 : 0, its crNg PNG chunk and the help text warning that "very few image viewers will play" it; MonteMedia's (flags & 1) != 0 && rate > 36 && high > low and its Extra Half-Brite twin cycling. Both read CCRT +1 as CRNG reverse.
  44. Dale Luck and R. J. Mical, Boing demo (1984), disassembly commented by Harry Sintonen; Jimmy Maher, C reconstruction boing4.c for The Future Was Here (MIT Press, 2012). boing.asm, boing4.c. Everything §7 reads from the program: screen and chip-RAM sizes, planes 0-3 for ball and shadow with plane 4 for the grid, the start palette, the 14-register rotation with the $FDD strip and the +16 copy, the x_scroll direction rule, MakeScreen() and RethinkDisplay(), the 16 grid copies with the Planes[4] formula, and the shadow pixel count decoded from the ball bitmap.
  45. Mark J. Ferrari. "About." markferrari.com. The dated credits §8 uses: EGA background art at Lucasfilm Games for Zak McKracken, Loom and The Secret of Monkey Island.
  46. Joseph Huckaby and Ian Gilman. "Living Worlds in HTML5" web demo: main.js, scenes.js, cache/*.js. effectgames.com/demos/worlds. The code §10 ports: keyframed palette timelines and setTimeOfDayPalette(), including the for...in wrap bug reproduced in the widget, and "CYCLE MODE: Classic Blend". Counts from the Jungle Waterfall scene: rain ranges 175-216 and rain indices 145-174 at rates 4914 and 4641, with 64,998 differing pixels.
  47. Exult team. Exult source, commit 8b6ab6b4, palette.cc. github.com/exult. The one-line rule §8 quotes: a nearest-color search that skips cycling entries, commented "But don't search rotating colors."
  48. RSDKModding. RSDKv5-Decompilation, commit 43d426f8 (RSDKv5/RSDK/Graphics/Palette.cpp, Drawing.cpp); Sonic-Mania-Decompilation, commit 9dc69942. RSDKv5, Sonic Mania. A decompilation, not official source, and §14 says so: 8 banks of 256 RGB565 entries with per-scanline bank selection, the water bank switch before the HUD, Green Hill's += 42 accumulator with keyframe banks and SetLimitedFade, and 57 RotatePalette calls across 19 object files.
  49. Coding with Balls. "Bitplanes + colour registers = A private rainbow!" April 17, 2011. codingwithballs.wordpress.com. The demoscene case in §11: two overlay planes selecting four 64-color palettes in Spaceballs' Norwegian Kindness at Datastorm 2011, with the author's claim that "From the CPU's point of view this operation has no performance cost".
  50. Dave (dalton-tulou). "My color cycling efforts at Datastorm." November 15, 2021. dalton-tulou.github.io. A recent practitioner account for §11: the Datastorm color-cycling competitions, one index per frame with its overlap artifacts, and a UV and brightness export with a parallel range per brightness level.
  51. Pekka Väänänen. "More accurate Doom colormap." 30fps.net, October 30, 2025. 30fps.net. The modern assessment §12 quotes: Doom's gamma-encoded scaling is "wrong in theory, but happens to give a pleasing nonlinear light falloff curve".
  52. id Software. Quake source (WinQuake and GLQuake); Quake-Tools, qutils/QLUMPY/QUAKEGRB.C. github.com/id-Software/Quake, Quake-Tools. The next generation of the same table in §12: colormap[(light & 0xFF00) + texel] with 64 grades, the fullbright count byte written by QLUMPY, and GLQuake ignoring the colormap with its "HACK HACK HACK" flame lighting.
  53. Ken Silverman. "palette.txt"; TRANSPAL, tools/transpal.c, modified by Jonathon Fowler. advsys.net/ken, transpal.c. The Build engine's tables in §12, quoted for the PALETTE.DAT layout, the shade tables and the 65,536-byte translucency table, plus Duke Nukem 3D's 82,690-byte file, the cut from 64 to 32 shades, and the 30/59/11 weighting with a grid search.
  54. ScummVM team. ScummVM source, commit 503d0747: engines/scumm/palette.cpp, engines/sci/graphics/palette32.h. github.com/scummvm (pinned). The value-remap half of §12: shadow-palette value remap and entry rotation during cycling, fade intermediates cycled "otherwise color cycling will be disturbed by the palette fade", and SCI32's cycler forbidding overlapping ranges.
  55. Microsoft. "DXGI_FORMAT enumeration" and "Load (DirectX HLSL Texture Object)." Microsoft Learn. DXGI_FORMAT, Load. The scope of paletted formats in §13: P8 and A8P8 documented for ISDB-T and Blu-ray video processing, and Load reading texel data "without any filtering or sampling".
  56. Khronos Group. The OpenGL ES Shading Language, Version 3.00, rev. 6, 2016; The OpenGL Graphics System, Version 4.6 (Core Profile), May 5, 2022; EXT_paletted_texture; OES_compressed_paletted_texture. GLSL ES 3.00 (PDF), GL 4.6 (PDF), EXT_paletted_texture, OES_compressed_paletted_texture. The spec language §13 depends on: texelFetch semantics, integer-texture completeness (§8.17), sRGB decode of fetched values (§8.24), and the two historical paletted-texture extensions, the OES one ratified July 23, 2003.
  57. Erik McClure. "8-bit color cycling." August 10, 2010. erikmcclure.com. An early shader treatment cited in §13: an HLSL palette-cycling shader with the index in alpha, a texel-center offset, and palette states stored as rows of a 2D texture.
  58. OpenRCT2 contributors. OpenRCT2 source, commit a8f6922e: data/shaders/applypalette.frag, src/openrct2/drawing/Palette.cpp, src/openrct2-ui/drawing/engines/opengl/. github.com/OpenRCT2 (pinned). A shipped indexed GL renderer for §13 and §14: GL_R8UI framebuffers and atlas, oColour = uPalette[...] over a uniform array, the palette resolved before the GL_LINEAR upscale, and the water and sparkle tables on a 40 Hz effect clock with lightning.
  59. Voidpoint. EDuke32 source, commit ec5824db, source/build/src/polymost1Frag.glsl and polymost.cpp; EDukeWiki, "Polymost." voidpoint.io (pinned), wiki.eduke32.com. The Build engine on the GPU in §13: an index texture with palette-swap and shade table over a base palette, sampled at (index + 0.5) / 256, and the filtering option that overrides indexed rendering.
  60. Microsoft. "Block Compression (Direct3D 10)." Microsoft Learn. learn.microsoft.com. The comparison §14 makes: BC1 stores two endpoints, two interpolated colors and a 2-bit index per texel, 48 bytes down to 8.
  61. KoBeWi. Godot Gradient Shift Shader, commit 36dc832, MIT. github.com/KoBeWi. A current engine-asset example in §14: a single-range palette lookup offset by time with a REPEAT sampler.
  62. nesbox et al. TIC-80 wiki, "BDR." github.com/nesbox/TIC-80. The fantasy-console scanline hook in §14, with the wiki's own framing: "The primary reason to do this is to manipulate the palette."
  63. Lexaloffle. Picotron User Manual v0.3.0d; zep, "picotron_gfx_pipeline.txt," July 6, 2024. manual, gfx pipeline. The newest indexed pipeline in §14: a 6-bit color index plus 2 bits selecting one of four 64 by 64 color tables, with four per-scanline display palettes.
  64. Jeremy Selan. "Using Lookup Tables to Accelerate Color Transformations." GPU Gems 2, chapter 24, 2005. developer.nvidia.com. The generalization §14 points at: 1D and 3D LUTs and the texel-center correction, quoted where the argument depends on the wording.
  65. Steffest, DPaint.js README, commit e270a1d; Cosmigo, Pro Motion NG features page; Deakcor, PixelOver manual, "Color cycling"; Aseprite issue #1067. github.com/steffest/DPaint-js, cosmigo.com, docs.pixelover.io, aseprite #1067. Current tool support in §14: cycling and ILBM support in DPaint.js, Pro Motion NG's "Create images containing palette based color cycling" and "Color cycling while drawing", PixelOver's "Match indexation" with shift, and the Aseprite request opened April 15, 2016 and still open, with the developer's April 1, 2026 comment quoted.
  66. Paul Heckbert. "Color Image Quantization for Frame Buffer Display." Computer Graphics (SIGGRAPH '82) 16(3):297-307, July 1982. DOI 10.1145/800064.801294. dl.acm.org. The starting point §16 hands off to: median cut, compared there with the older popularity algorithm.

See also