Skip to Content
API ReferenceConstraints

Constraints

Constraint solvers that reproduce the actual color limitations of retro hardware. These enforce per-tile, per-scanline, and per-byte palette restrictions after initial palette mapping.

import { solveAttributeClash, solveTilePalette, solveHAM, solveScanline, solveApple2Artifact, solvePerRowInTile, solveCGASubpalette, decodeNeoGeoColor, encodeNeoGeoColor, } from 'bitmapped'; // Types import type { AttributeBlockConfig, TilePaletteConfig, HAMConfig, ConstraintResult, } from 'bitmapped';

These solvers are called internally by process() when you set the constraintType option. You only need to call them directly for advanced workflows where you want full control over the processing pipeline.

For background on how these constraints map to real hardware, see the Hardware Constraints guide.

ConstraintResult

All constraint solvers that return image data follow this shape:

interface ConstraintResult { imageData: ImageData; grid?: RGB[][]; }
FieldTypeDescription
imageDataImageDataThe constrained output image.
gridRGB[][]Optional 2D array of RGB values representing the pixel grid.

The individual solver functions documented below return ImageData directly, not ConstraintResult. The ConstraintResult type is used by the internal pipeline orchestrator to wrap solver output with optional grid data.


solveAttributeClash

Enforces per-block color limits, the way the ZX Spectrum, NES, and C64 restrict how many colors can appear within a character cell.

For each block of pixels, the solver selects the optimal color subset from the palette and remaps every pixel to its nearest match within that subset.

function solveAttributeClash( imageData: ImageData, palette: Palette, config: AttributeBlockConfig, distanceAlgorithm?: DistanceAlgorithm, ): ImageData;
ParameterTypeDefaultDescription
imageDataImageDatarequiredSource image data.
palettePaletterequiredAvailable palette colors (must have at least 2 entries).
configAttributeBlockConfigrequiredBlock dimensions and color constraints.
distanceAlgorithmDistanceAlgorithm'euclidean'Color distance metric.

AttributeBlockConfig

interface AttributeBlockConfig { width: number; height: number; maxColors: number; brightLocked?: boolean; globalBackground?: number; }
FieldTypeDefaultDescription
widthnumberrequiredBlock width in pixels.
heightnumberrequiredBlock height in pixels.
maxColorsnumberrequiredMaximum colors allowed per block.
brightLockedbooleanfalseWhen true, bright (indices 8—15) and non-bright (indices 0—7) colors cannot mix within the same block. ZX Spectrum mode.
globalBackgroundnumberPalette index of a globally shared background color that is always included in every block’s subset. Used by C64 multicolor mode.

Solving strategies

The solver automatically picks the best algorithm depending on the configuration:

  • ZX Spectrum (brightLocked: true) — Tests all valid ink/paper pairs within each brightness group (56 candidates per block).
  • Exhaustive pairs (maxColors === 2, palette size 64 or fewer) — Tests all C(n, 2) palette pairs per block.
  • Greedy selection (maxColors > 2) — Seeds with the most popular palette color, then iteratively adds the color that reduces total error the most.

Systems

SystemBlock sizeMax colorsOptions
ZX Spectrum8 x 82brightLocked: true
C64 Multicolor4 x 84globalBackground: 0
NES16 x 164

Example

import { solveAttributeClash } from 'bitmapped'; import { getPreset } from 'bitmapped/presets'; const zx = getPreset('zx-spectrum')!; const constrained = solveAttributeClash( imageData, zx.palette!, { width: 8, height: 8, maxColors: 2, brightLocked: true, }, 'redmean', );

solveTilePalette

Assigns an optimal subpalette to each tile, the way tile-based systems like the SNES, Genesis, and Master System select from a small set of color groups per tile.

For each tile, the solver counts which master-palette colors appear most often, picks the top N as the tile’s subpalette, and remaps every pixel to its nearest match within that subpalette.

function solveTilePalette( imageData: ImageData, palette: Palette, config: TilePaletteConfig, distanceAlgorithm?: DistanceAlgorithm, ): ImageData;
ParameterTypeDefaultDescription
imageDataImageDatarequiredSource image data (already palette-quantized or raw).
palettePaletterequiredThe full master palette available to the system.
configTilePaletteConfigrequiredTile size and subpalette parameters.
distanceAlgorithmDistanceAlgorithm'redmean'Color distance metric.

TilePaletteConfig

interface TilePaletteConfig { tileWidth: number; tileHeight: number; subpaletteCount?: number; colorsPerSubpalette: number; sharedTransparent: boolean; }
FieldTypeDefaultDescription
tileWidthnumberrequiredTile width in pixels.
tileHeightnumberrequiredTile height in pixels.
subpaletteCountnumberNumber of subpalettes available. Reserved for future use; not currently used by the solver.
colorsPerSubpalettenumberrequiredNumber of colors in each tile’s subpalette.
sharedTransparentbooleanrequiredWhen true, the first palette entry is always included in every tile’s subpalette (occupying one slot). Used for shared transparent/background color.

Systems

SystemTile sizeColors per subpaletteShared transparent
SNES8 x 816true (color 0)
Genesis / Mega Drive8 x 816false
Master System8 x 816false
Game Gear8 x 816false
Game Boy Color8 x 84false

Example

import { solveTilePalette } from 'bitmapped'; import { getPreset, enumerateColorSpace } from 'bitmapped/presets'; const genesis = getPreset('genesis-ntsc')!; const palette = enumerateColorSpace(genesis.colorSpace!); const constrained = solveTilePalette( imageData, palette, { tileWidth: 8, tileHeight: 8, colorsPerSubpalette: 16, sharedTransparent: false, }, 'redmean', );

solveHAM

Implements Amiga Hold-And-Modify mode. Each pixel is encoded as either a direct base palette lookup or a single-channel modification of the previous pixel’s color. This allows near-true-color images from a 16-color base palette, at the cost of fringing artifacts on sharp horizontal edges.

function solveHAM( imageData: ImageData, basePalette: Palette, config: HAMConfig, distanceAlgorithm?: DistanceAlgorithm, ): ImageData;
ParameterTypeDefaultDescription
imageDataImageDatarequiredSource image data.
basePalettePaletterequiredThe base palette (typically 16 colors for HAM6, 64 for HAM8).
configHAMConfigrequiredHAM configuration.
distanceAlgorithmDistanceAlgorithm'redmean'Color distance metric.

HAMConfig

interface HAMConfig { basePaletteSize: number; modifyBits: number; }
FieldTypeDescription
basePaletteSizenumberNumber of base palette colors to use.
modifyBitsnumberNumber of bits for channel modification. Determines quantization resolution of the modify operations.

How HAM encoding works

For each pixel (processed left-to-right per scanline), the solver picks the option with the lowest error:

  1. Direct palette — Use one of the base palette colors.
  2. Modify R — Replace the top bits of the red channel of the previous pixel’s color.
  3. Modify G — Replace the top bits of the green channel.
  4. Modify B — Replace the top bits of the blue channel.

Each scanline starts with the first palette color as the initial “previous” value.

HAM variants

VariantBase paletteModify bitsSystem
HAM616 colors4Amiga OCS/ECS
HAM864 colors6Amiga AGA

Example

import { solveHAM } from 'bitmapped'; const constrained = solveHAM( imageData, basePalette, { basePaletteSize: 16, modifyBits: 4, }, 'redmean', );

solveScanline

Limits each horizontal scanline to at most N unique colors from the palette. Colors are selected per row using frequency-weighted scoring to minimize overall error.

function solveScanline( imageData: ImageData, palette: Palette, maxColorsPerLine: number, distanceAlgorithm?: DistanceAlgorithm, ): ImageData;
ParameterTypeDefaultDescription
imageDataImageDatarequiredSource image data.
palettePaletterequiredAvailable palette colors.
maxColorsPerLinenumberrequiredMaximum number of palette colors allowed per scanline.
distanceAlgorithmDistanceAlgorithm'redmean'Color distance metric.

Algorithm

For each scanline row:

  1. Find the nearest palette color for every pixel in the row.
  2. Rank palette colors by how many pixels they are the nearest match for.
  3. Select the top N colors by frequency.
  4. Remap every pixel in the row to the nearest of those N colors.

If the palette already has fewer colors than the scanline limit, the solver performs a simple nearest-color map with no additional constraint.

Systems

SystemMax colors per line
Atari 2600 (NTSC)4
Atari 2600 (PAL)4

Example

import { solveScanline } from 'bitmapped'; import { getPreset } from 'bitmapped/presets'; const atari = getPreset('atari-2600-ntsc')!; const constrained = solveScanline( imageData, atari.palette!, 4, 'redmean', );

solveApple2Artifact

Reproduces Apple II Hi-Res NTSC artifact color generation. In this mode, each byte controls 7 pixels, and a high bit selects between two color groups. The solver tests both groups for each pixel group and picks the one with the lowest total error.

function solveApple2Artifact( imageData: ImageData, paletteSets: readonly (readonly string[])[], pixelsPerGroup: number, distanceAlgorithm?: DistanceAlgorithm, ): ImageData;
ParameterTypeDefaultDescription
imageDataImageDatarequiredSource image data.
paletteSetsreadonly (readonly string[])[]requiredArray of color groups, each an array of hex color strings (#RRGGBB or RRGGBB).
pixelsPerGroupnumberrequiredNumber of pixels per group (typically 7).
distanceAlgorithmDistanceAlgorithm'redmean'Color distance metric.

Apple II color groups

The standard Apple II Hi-Res palette sets are:

GroupMSBColors
Group 10Black, Purple, Green, White
Group 21Black, Blue, Orange, White

Within a 7-pixel group, all pixels share the same color group. The solver tests both groups and picks the one that produces the lowest mapping error.

Example

import { solveApple2Artifact } from 'bitmapped'; const paletteSets = [ ['#000000', '#FF00FF', '#00FF00', '#FFFFFF'], // Group 1 ['#000000', '#0000FF', '#FF8000', '#FFFFFF'], // Group 2 ]; const constrained = solveApple2Artifact( imageData, paletteSets, 7, // 7 pixels per byte group 'redmean', );

Throws an error if paletteSets is empty or if any palette set contains an empty array.


solvePerRowInTile

Enforces per-row color limits within each tile, as used by the TMS9918A video display processor. Each row of pixels within an 8x8 tile independently selects a foreground and background color from the full palette. This is more flexible than the ZX Spectrum’s per-cell constraint but still heavily limited.

function solvePerRowInTile( imageData: ImageData, palette: Palette, tileWidth: number, tileHeight: number, distanceAlgorithm?: DistanceAlgorithm, ): ImageData;
ParameterTypeDefaultDescription
imageDataImageDatarequiredSource image data.
palettePaletterequiredAvailable palette colors.
tileWidthnumberrequiredTile width in pixels (typically 8).
tileHeightnumberrequiredTile height in pixels (typically 8).
distanceAlgorithmDistanceAlgorithm'redmean'Color distance metric.

Algorithm

For each tile, for each of its rows:

  1. Try all C(paletteSize, 2) color pairs from the palette.
  2. Select the pair with the lowest total error for that row.
  3. Assign each pixel to the closer of the two colors.

This means an 8x8 tile can use up to 16 distinct colors (2 per row x 8 rows), but each individual row is limited to exactly 2.

Systems

SystemTile sizeColors per row
ColecoVision8 x 82
MSX18 x 82
SG-10008 x 82
Thomson MO58 x 82

Example

import { solvePerRowInTile } from 'bitmapped'; import { getPreset } from 'bitmapped/presets'; const coleco = getPreset('colecovision')!; const constrained = solvePerRowInTile( imageData, coleco.palette!, 8, // tileWidth 8, // tileHeight 'redmean', );

solveCGASubpalette

Maps every pixel to the nearest color in the provided CGA subpalette. In CGA Mode 4, only 4 colors are available (1 background + 3 from a fixed subpalette).

This is the simplest constraint solver — it performs the same operation as a standard nearest-color palette map. It exists for consistency with the constraint system so that all hardware constraint types have a corresponding solver.

function solveCGASubpalette( imageData: ImageData, palette: Palette, distanceAlgorithm?: DistanceAlgorithm, ): ImageData;
ParameterTypeDefaultDescription
imageDataImageDatarequiredSource image data.
palettePaletterequiredThe CGA subpalette (typically 4 colors).
distanceAlgorithmDistanceAlgorithm'redmean'Color distance metric.

CGA Mode 4 subpalettes

PaletteColors
Palette 0, low intensityBlack, Dark Green, Dark Red, Dark Yellow
Palette 0, high intensityBlack, Green, Red, Yellow
Palette 1, low intensityBlack, Dark Cyan, Dark Magenta, Dark White
Palette 1, high intensityBlack, Cyan, Magenta, White

Example

import { solveCGASubpalette } from 'bitmapped'; import { getPreset } from 'bitmapped/presets'; const cga = getPreset('cga-mode4-pal1-high')!; const constrained = solveCGASubpalette( imageData, cga.palette!, 'redmean', );

Neo Geo color utilities

The Neo Geo uses a unique 16-bit color word format with a non-standard bit layout. These utility functions encode and decode between standard RGB and the Neo Geo’s native format.

decodeNeoGeoColor

Decodes a Neo Geo 16-bit color word into an RGB color.

function decodeNeoGeoColor(word: number): RGB;
ParameterTypeDescription
wordnumberThe 16-bit Neo Geo color word.

Returns: An RGB object with channels in the 0—255 range.

encodeNeoGeoColor

Encodes an RGB color into a Neo Geo 16-bit color word.

function encodeNeoGeoColor(r: number, g: number, b: number): number;
ParameterTypeDescription
rnumberRed channel (0—255).
gnumberGreen channel (0—255).
bnumberBlue channel (0—255).

Returns: A 16-bit integer representing the Neo Geo color word.

Bit layout

The Neo Geo 16-bit color word uses this layout:

Bit: 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0 D R0 G0 B0 R4 R3 R2 R1 G4 G3 G2 G1 B4 B3 B2 B1
  • D — “Dark” bit (shadow bit) that provides an extra shade level.
  • R0, G0, B0 — Least significant bits of each channel’s 5-bit value.
  • R4-R1, G4-G1, B4-B1 — Upper 4 bits of each channel.

Each channel is effectively 6-bit (5 explicit bits + dark bit), expanded to 8-bit by replicating the top bits. The dark bit in encodeNeoGeoColor is determined by majority vote of the three channels’ LSBs.

Example

import { decodeNeoGeoColor, encodeNeoGeoColor } from 'bitmapped'; // Decode a color word from ROM data const rgb = decodeNeoGeoColor(0xffff); // => { r: 255, g: 255, b: 255 } // Encode an RGB color for Neo Geo hardware const word = encodeNeoGeoColor(255, 0, 0); // word is a 16-bit integer // Round-trip (with minor quantization error from 8-bit to 6-bit) const original = { r: 128, g: 64, b: 200 }; const encoded = encodeNeoGeoColor(original.r, original.g, original.b); const decoded = decodeNeoGeoColor(encoded); // decoded is close to original (within ~8 per channel)

Round-tripping through encodeNeoGeoColor and decodeNeoGeoColor may introduce small quantization errors (up to ~8 per channel) because the Neo Geo uses 6-bit color depth while RGB uses 8-bit.


Using constraints via process()

In most cases, you do not need to call these solvers directly. Set the constraintType option on process() and it will invoke the appropriate solver after palette mapping and dithering.

import { process } from 'bitmapped'; import { getPreset } from 'bitmapped/presets'; const zx = getPreset('zx-spectrum')!; const result = process(imageData, { blockSize: 1, palette: zx.palette!, dithering: 'floyd-steinberg', distanceAlgorithm: 'redmean', constraintType: 'attribute-block', attributeBlockConfig: { width: 8, height: 8, maxColors: 2, brightLocked: true, }, });

The mapping from constraintType to solver function:

constraintTypeSolverConfig option
'attribute-block'solveAttributeClashattributeBlockConfig
'per-tile-palette'solveTilePalettetilePaletteConfig
'ham'solveHAMhamConfig
'per-scanline'solveScanlinescanlineConfig
'artifact-color'solveApple2ArtifactartifactConfig
'per-row-in-tile'solvePerRowInTileperRowInTileConfig
'sub-palette-lock'solveCGASubpalette— (uses main palette)
'none'
'monochrome-global'— (no spatial constraint)

See How It Works for where constraints fit in the full processing pipeline.

Last updated on