Indexed-color undithering design

Status: design only. No implementation is implied by this document.

Design date: 2026-08-22. Updated: 2026-08-23.

Summary

Undithering should operate on a game's native indexed pixels and its effective palette before those pixels become RGBA. It must not activate merely because a surface or bitmap has a low bit depth. Many 1/4/8-bpp games use flat colors, sharp pixel art, masks, stipples, or photographic texture that would be damaged by a generic smoothing pass.

The safe design is therefore:

  1. Preserve every 1-bpp source exactly; only 4/8-bpp color sources are eligible.
  2. Treat eligible indexed color as eligibility, not proof of dithering.
  3. Preserve raw indices, bit depth, palette, and source role through the render path.
  4. Detect credible spatial color mixtures at native resolution.
  5. Build an explicit structural-edge and semantic rejection mask; do not rely on a smoother merely being described as edge-aware.
  6. Reconstruct only high-confidence regions and leave every other pixel exact.
  7. Validate every initially supported game and rendering path with native PNG captures, nearest-neighbor A/B enlargements, and a changed-pixel mask.
  8. Feed the same-size reconstructed texture into the existing scaler. Scaling, aspect-ratio fitting, Retina sizing, and CRT effects remain separate stages.

The intended first target is 8-bpp fullscreen DirectDraw. The second target is complete 4/8-bpp GDI DIB buffers such as Pinball's back buffer and CWordZap's RLE4 splash. Arbitrary WEP bitmap resources come later because many color images participate in multi-step raster operations. Their 1-bpp resources stay exact and never enter the undither detector.

Terminology

Goals

Non-goals

What the current games establish

Complete indexed framebuffers

Game Observed source Initial implication
Age of Empires 800x600x8 DirectDraw Exact index plane and live palette are available. Terrain is heavily textured, so global smoothing is unsafe.
Marbles 640x480x8 DirectDraw Exact indices and palette are available, including palette animation. Stone and soil detail is intentional texture.
RollerCoaster Tycoon 640x480x8 DirectDraw Exact indices and palette are available. Requires gameplay screenshot audit.
Liquid War 640x480x8 DirectDraw Exact indices and palette are available. Requires menu and gameplay audit.
StarCraft Shareware 8-bpp DirectDraw Exact palette data is available once its primary presents. Resolution and representative captures still need a focused audit.
Pinball 600x416x8 DIB_PAL_COLORS back buffer Exact indices and logical palette exist at the DIB source. Title and table artwork must be audited separately.

Lower-depth WEP and community assets

A local resource census found:

Assets Resource depths
cards.dll used by FreeCell, Solitaire, Cruel, and Golf 41 x 1-bpp, 33 x 4-bpp
SkiFree 89 x 4-bpp
Rattler/Snake 5 x 1-bpp, 2 x 4-bpp
Taipei 49 x 1-bpp, 38 x 4-bpp
Minesweeper 3 x 1-bpp, 3 x 4-bpp
TicTactics 1 x 1-bpp, 1 x 4-bpp
Bricks 13 x 1-bpp, 4 x 4-bpp
EmPipe 34 x 4-bpp
Funtris/FunPack 127 x 4-bpp, 2 x 8-bpp
CWordZap splash BI_RLE4 with logical-palette indices
Quick Blackjack 30 x 1-bpp, 9 x 4-bpp, 13 x 24-bpp

These counts describe source resources, not final framebuffer formats. Normal GDI windows are backed by a 32-bpp canonical surface, so palette provenance is usually lost after the indexed bitmap is decoded and blitted.

Preliminary screenshot audit

This is a rejection-oriented audit, not an enablement allowlist. Captures must be repeated after the audit tooling described below is implemented.

Capture Visible result Design decision
FreeCell deal Mostly flat card colors, hard glyph edges, and suit shapes No broad undithering. Card resources are a negative corpus.
SkiFree gameplay/title Mostly flat sprites and UI colors No broad undithering. Low bit depth alone would be a false positive.
Quick Blackjack table Flat colors plus deliberate fine card-back lines Preserve the lines; do not classify periodicity alone as dither.
Marbles gameplay Dense stone, soil, and sprite texture Do not smooth globally. Any valid regions must be detected locally.
Age of Empires gameplay Dense terrain and sprite texture Do not smooth globally. Terrain is a critical negative corpus.
CWordZap splash A nearest-pixel crop shows alternating gray/tan palette pixels inside anti-aliased background lettering Localized candidate only. Reconstruct letter interiors while protecting their boundaries, the hard-color logo, and window chrome. No high-color truth exists yet.
Pinball 600x416 back buffer A nearest-pixel crop shows deliberate palette stippling in the launch-ramp gradient, lamp halos, and dark playfield shading, mixed with sharp rails and labels Strong mixed candidate. Detection must isolate smooth shaded areas and preserve mechanical texture, outlines, and display glyphs. No high-color truth exists yet.

The default conclusion is therefore no-op for an indexed frame until the detector and screenshots demonstrate a real improvement.

Current architecture and loss point

DirectDraw already retains display width, height, bpp, surface pitch, and the raw surface address. It also retains the effective primary palette and applies IDirectDrawPalette::SetEntries updates. The host currently expands each index through that palette into an RGBA ImageData, after which the presentation filter receives only a Canvas.

guest DirectDraw surface
  raw 8-bit indices + pitch + effective palette
                     |
                     | current host conversion
                     v
                RGBA Canvas
                     |
                     v
      current MDAPT/Jinc2 presentation pass
                     |
                     v
          scaler -> CRT -> Retina canvas

For ordinary GDI, the loss occurs earlier and at smaller granularity:

indexed RT_BITMAP/DIB + per-resource/logical palette
                     |
          decode and GDI raster operations
                     v
          32-bit top-level window surface
                     |
                     v
             final window composite

Once multiple assets, primitives, masks, and ROPs have been combined into that 32-bit surface, there may be no single correct palette or index plane for the whole frame.

Required indexed-source descriptor

Presentation code needs an explicit descriptor instead of inferring format from a Canvas:

{
  kind: 'indexed',
  owner: 'ddraw-surface' | 'gdi-dib' | 'gdi-bitmap',
  role: 'framebuffer' | 'color' | 'mask' | 'unknown',
  width,
  height,
  bpp: 4 | 8,
  stride,
  topDown,
  indexEncoding: 'packed-msb-1' | 'packed-high-nibble-4' | 'u8',
  memoryOffset,
  palette,             // 2, 16, or 256 effective RGB entries
  paletteId,
  pixelsVersion,
  paletteVersion,
  transparentIndex: null | number,
}

The concrete API may use a memory getter rather than exposing palette or a long-lived typed-array view. A descriptor must not retain a stale view if the WASM memory buffer is replaced. It must also identify whether the index bytes are a complete presentable frame or merely one asset participating in a later composition.

Per-surface DirectDraw palettes

The current DirectDraw implementation stores one global effective primary palette pointer. That is enough for the common one-primary-palette case, but it is not a sound source contract when different surfaces attach different palettes. Add palette ownership keyed by DirectDraw surface slot, preferably in a parallel table so the established 32-byte DX_OBJECTS entry layout does not need unrelated reinterpretation. The global pointer can remain a compatibility fallback while callers migrate.

SetPalette changes the surface's palette identity. SetEntries changes that palette's version and RGB contents. A presented offscreen substitute must use its own attached palette, not whichever palette was assigned most recently to some other surface.

Effective versus submitted palette

Undithering must use the palette that the emulator actually displays. The current DirectDraw compatibility behavior intentionally ignores one suspicious all-black full-table update in a palette-cycling case. That means the effective emulated palette can differ from the last table submitted by the guest. The effective palette is the correct input because it corresponds to the visible source and preserves current compatibility behavior.

Target render graph

For fullscreen indexed DirectDraw, keep the entire graph on the GPU:

native index texture ----+
                         +--> detect/reconstruct at native size
palette texture ----------+              |
                                         v
                              same-size RGB(A) texture
                                         |
                           Scale 2x/3x/4x, FSR1, sharp/HQ
                                         |
                                  optional CRT effects
                                         |
                           aspect-preserving Retina output

There must be no derived logical Win98 desktop canvas between the native framebuffer and detection. There must also be no readback to Canvas followed by another upload when the selected scaler already runs on WebGL. Canvas/browser HQ modes may consume the reconstructed GPU canvas at their natural boundary, but should not introduce an extra native-resolution round trip.

Undithering never changes width, height, the source crop, or the presentation viewport. Existing physical-pixel multiplier selection and aspect-ratio letterboxing remain authoritative.

Detection: bit depth is not a trigger

The automatic mode has three gates. All must pass.

1. Source eligibility gate

2. Spatial evidence gate

Build candidate color pairs from actual index co-occurrence, not all palette pairs. For each local region, measure:

Ordered patterns may produce a strong phase score. Error-diffusion and random dither may not; they instead require a stable local distribution in an edge-aware window. Neither path is allowed to equate "high frequency" with "dither."

3. Structural edge-protection gate

Edge protection is an explicit native-resolution mask, not just a property of the reconstruction filter. A raw Sobel/Prewitt threshold is insufficient: dither itself deliberately creates strong one-pixel transitions, so treating all high-frequency energy as an edge would reject every useful positive.

Build the structural mask from linear-light luminance and chroma using both a native 3x3 gradient and gradients after two small low-pass scales. Classify an edge as coherent when its direction and magnitude survive the larger scale; alternating dither transitions normally cancel as scale increases, while a silhouette, glyph boundary, rail, or UI border remains aligned. Palette-index boundaries and alpha/color-key boundaries contribute independent hard evidence.

The resulting protection behavior is:

In compact form:

mixture confidence ----\
structural edge mask ---+--> final confidence --> reconstruction blend
text/line mask ---------+              |
alpha/ROP mask ---------/              +--> zero means exact source pixel

finalConfidence = mixtureConfidence * (1 - structuralProtection) * (1 - semanticRejection). Implementations may factor the terms differently, but must expose them separately for debugging so a false positive can be attributed to detection rather than hidden inside a filter weight.

4. Rejection gate

Reject or reduce confidence for:

The output of detection is a confidence mask and a candidate mixture identity, not a global yes/no bit. Reconstruction strength is zero below a conservative threshold and ramps only within validated connected regions.

Relationship to current testbed algorithms

The standalone testbed currently contains several different kinds of edge behavior. They must not be presented as equivalent:

Method Current edge behavior Suitable as the final rejection mask?
Kornelski undither Explicit Prewitt field: strong edges bypass reconstruction and medium edges heavily weight the source pixel Useful prior art, but its fixed luminance thresholds and 3x3 scale are not sufficient alone
Multiscale adaptive FIR Correlates gradients at two low-pass scales and continuously reduces its blend near coherent edges Closest signal-processing prototype; still needs text/line, index, transparency, and composition rejection
Bilateral / anisotropic diffusion Reduce cross-edge color flow implicitly No; they do not identify or expose protected zones and can still alter fine contours
Palette-pair / ordered-cell / MDAPT Limit changes to detected color patterns No; pattern confidence is positive evidence, not an independent structural-edge veto
SGENPT-MIX Uses local contrast and anti-ringing bounds No explicit protected-zone mask
Box / Gaussian / Jinc2 Smooth without proving dither or protecting structure No

The testbed should therefore grow diagnostic views for the raw mixture confidence, structural-edge protection, semantic rejection, final confidence, and actual changed pixels. Until an algorithm returns those masks, its changed- pixel image is only an observed effect, not proof that its detector selected the right zones.

Reconstruction algorithms

Palette-aware MDAPT

The existing experimental MDAPT port detects connected checkerboard and pseudo-transparency signals over multiple native-resolution passes. Retain its connected-pattern logic, but provide actual palette/index information and gate its output with the rejection mask above. Index equality is preferable to floating-point RGB equality, especially during palette fades.

Best fit:

Weak fit:

Palette-aware local mixture reconstruction

Add an edge-aware statistical path for irregular/error-diffusion dither:

  1. Find the dominant co-occurring two- or three-index set in a local window.
  2. Verify that it is stable across overlapping windows and belongs to a connected region.
  3. Estimate mixture weights from index coverage, excluding edges and transparent pixels.
  4. Reconstruct the mixture color and blend it only through the confidence mask.
  5. Preserve the original center pixel when evidence is weak.

Candidate palette-pair distance should be precomputed on palette changes in a perceptual space. The emitted mixture should be evaluated in linear light and against a CRT-like gamma curve during screenshot validation; the choice must be fixed by real-game A/B results rather than assumed from theory.

This path can handle non-periodic dither, but it is also the easiest path to confuse with grass, soil, stone, cloth, and noise. Its thresholds must be tuned primarily against negative screenshots such as Age of Empires and Marbles.

Jinc2

Jinc2 is a useful aggressive/manual comparison because it suppresses broad high-frequency dither without requiring an index model. It must not be the automatic indexed-color path: it can blur texture and pixel art, and it does not use the actual palette.

The current experimental Jinc2 shader is attributed GPL-2.0-or-later while this repository is MIT. Shipping that source requires an explicit licensing decision. Otherwise omit it from release or replace it with an independently licensed implementation. The design does not depend on Jinc2.

Algorithms intentionally excluded

Do not restore standalone "simple checkerboard" or fixed "ordered 2x2" modes. They accept too little evidence, cover only a narrow subset of real dithering, and make deliberate one-pixel patterns easy to damage. Those patterns remain useful as unit fixtures inside the full detector, not as user-facing algorithms.

Palette and index analysis

For every candidate frame the implementation can know:

The number of colors alone is not a detection feature. A 16-color image may have no dither; a 256-color image may dither only one gradient; an 8-bpp game may use intentional noisy textures everywhere.

Palette changes and pixel changes need separate versions. Detection masks based on indices can often survive a palette fade, while pair distances and emitted colors must be recomputed. A palette animation must not force the detector to rediscover unchanged geometry every frame.

GPU representation

DirectDraw integration

DirectDraw is the first implementation target because the primary or selected present surface is a complete native framebuffer.

  1. Associate palettes with surfaces, while retaining the current global effective palette as a compatibility fallback.
  2. Extend the present descriptor with surface slot, dimensions, bpp, pitch, index address, palette identity, and versions.
  3. Attach that descriptor to the exclusive presentation source.
  4. Let PresentationFilter.present receive the descriptor alongside the Canvas.
  5. When enabled and eligible, upload indices and palette directly and start the GPU render graph there.
  6. When disabled or rejected, preserve the existing RGBA/Canvas path exactly.

DirectDraw modes below 8 bpp are not currently part of the emulated mode table or pitch implementation. The undither scope is 8-bpp DirectDraw plus 4/8-bpp GDI color sources. It excludes 1-bpp everywhere and does not assume that 4-bpp DirectDraw already works.

GDI integration

GDI needs source-role tracking because the final top-level window surface is 32 bpp.

Phase GDI-1: complete indexed DIB buffers

Target calls where one indexed buffer is itself the logical frame or a large stable image:

Retain packed indices and the resolved logical palette until the source has been classified and reconstructed. Apply GDI clipping and destination geometry normally. This phase avoids arbitrary resource composition.

Phase GDI-2: standalone color resources

Extend DIB/resource metadata so 4/8-bpp sources retain indices and palettes. Only color resources copied by safe operations such as an ordinary SRCCOPY are initially eligible. Cache an optional reconstructed color canvas alongside the exact original. ROPs, readback, mask construction, and guest-visible bitmap operations continue to use the original pixels. This is a derived presentation cache for a bitmap, not another GDI window drawing surface or guest-visible render target.

Phase GDI-3: composed sprites

Some WEP sprites use an exact 1-bpp mask plus a 4-bpp color bitmap through a sequence of boolean ROPs. Undithering an input color layer can change mask/color-key semantics. If these games need support, detect the completed sprite or stable strip after composition, not either raw layer. This requires provenance or an explicit composite cache and should not be inferred from bit depth alone.

1-bpp hard safety rule

All 1-bpp sources bypass undithering unconditionally. There is no screenshot, palette, source-role, or manual-mode exception. They retain their existing indices, palette expansion, ROP behavior, scaling, and presentation exactly.

UI and persistence

Keep one independent Dedither/Undither dropdown in the debug menu. Proposed eventual options:

Legacy checkerboard and ordered2 persisted values may continue migrating to MDAPT. Scale and CRT selections remain separate persisted settings.

Debug diagnostics should expose, without changing the normal image:

Screenshot audit protocol

Every enabled game/path must pass this protocol. A low-bpp declaration or synthetic shader fixture is not enough.

Capture

  1. Disable undithering, scaling filters, and CRT effects.
  2. Capture the native source at 1:1 to lossless PNG, before Retina enlargement.
  3. Capture at least a menu/title, representative gameplay, and any palette fade or animation. Capture multiple consecutive gameplay frames.
  4. Record source owner, dimensions, bpp, palette size, used indices, and palette version with the image.
  5. For GDI, record whether the image is a complete DIB, a standalone color bitmap, a mask, or an already composed 32-bpp window.

For an eligible 4/8-bpp source, capture a replayable indexed-frame bundle:

frame.png       palette-expanded native screenshot for human review
frame.idx       normalized one-byte-per-pixel palette indices
frame.pal       effective RGB(A) palette in index order
frame.json      width, height, bpp, pitch/orientation, source owner/slot,
                palette and pixel versions, transparent index, hashes,
                app/scene/capture provenance

The normalized .idx makes offline algorithm tests independent of packed 4-bpp nibble layout. When format decoding itself is under test, also retain or regenerate the original packed/RLE bytes and describe them in frame.json. For DIB_PAL_COLORS, frame.pal contains the effective RGB colors after resolving the selected logical palette, while the metadata records that palette resolution was used.

The capture point must be before index-to-RGBA conversion and must use the palette attached to that exact surface or DIB. A screenshot histogram cannot reconstruct duplicate palette entries, unused entries, index identity, or a palette that changes between frames.

Review

For each capture generate:

Review the images visually. Pixel statistics can locate changes but cannot decide whether grass, stipple, cloth, or card-back lines are intentional. The structural-edge mask must also be reviewed directly: an unchanged edge can otherwise be accidental cancellation from a weak filter rather than a reliable detector veto.

Acceptance

An automatic result passes only when:

Initially, a human-reviewed screenshot manifest should record no-op, candidate, or approved for each capture and algorithm. Do not encode an application allowlist before representative screenshots exist. An approved capture validates a rendering path and scene, not every frame that executable can produce.

Test plan

Metadata and format tests

Synthetic still-image laboratory

Build a deterministic still-image harness before tuning Auto against games. A fixture begins with a known high-color truth.png, quantizes it to an explicit 4-bpp or 8-bpp palette, applies a selected dither, runs undithering on the resulting index plane and palette, and compares the reconstruction with the original truth.

high-color truth + protected-region mask
                    |
           quantize to 4/8-bpp palette
                    |
          apply deterministic dither
                    |
             indices + palette
                    |
               run undither
                    |
        compare reconstruction to truth

The generated source should combine positive and negative regions in one image:

Generate fixed-seed 4-bpp and 8-bpp variants using at least:

Every case stores or deterministically regenerates the truth pixels, palette, indices, dither name, seed, and protected-region mask. A PNG by itself is not a complete palette-aware fixture because it has already lost index identity.

For each case emit:

Automated assertions should require:

Use error metrics to catch regressions and rank parameter changes, not to decide visual quality alone. SSIM, PSNR, or aggregate color error can reward unwanted blur. The generated comparison sheet and changed-pixel mask remain required review artifacts.

A practical repository layout is a reusable tools/undither-stills.js runner, a small committed fixture manifest containing palettes/seeds/expected bounds, and test/test-undither-stills.js for deterministic assertions. Large A/B sheets and masks should be generated into a requested output directory rather than committed or read from incidental scratch/ captures.

Real-game indexed replay corpus

Feed captured .idx + .pal + .json bundles into the same still-image runner used for synthetic cases. This separates three questions cleanly:

  1. Did the emulator capture the correct native indices and effective palette?
  2. Does the detector identify credible regions in a real game frame?
  3. Does reconstruction improve those regions without damaging the rest?

Capture representative scenes, not one convenient startup frame: menus, gameplay, flat UI, texture-heavy regions, gradients, palette fades, palette cycling, and consecutive animation frames. Prefer small annotated native crops for regression tests, but retain full frames for human review and detector tuning.

Commercial-game images and palettes may not be appropriate committed public fixtures. The harness must therefore support both committed fixtures and local capture bundles. A committed manifest can record source, dimensions, hashes, expected decisions, and crop coordinates while a focused test skips when the corresponding local candidate assets are unavailable. Synthetic fixtures remain the portable baseline; real indexed captures are the fidelity gate.

Detector tests

Synthetic tests should cover ordered, clustered, error-diffusion, and stochastic mixtures, but are only unit tests. Negative fixtures are equally important:

Real screenshot tests

Presentation tests

Performance tests

Rollout

Phase 0: audit tooling

Phase 1: 8-bpp DirectDraw metadata and manual modes

Phase 2: conservative Auto for DirectDraw

Phase 3: complete 4/8-bpp GDI DIBs

Phase 4: WEP color resources

Phase 5: default decision

Consider making Auto the user-facing default only after the screenshot manifest contains broad positive and negative coverage, temporal tests pass, licensing is resolved, and measured browser cost is acceptable. Until then, Off remains the default and Auto remains an explicit debug choice.

Completion criteria

The design is implemented when:

Relevant implementation files