Guide
This is the one part of the toolkit that needed a design rather than a decoder, so it is worth explaining in full.
Sprite pixels are not colours. They are indices into a palette that is not stored anywhere in the game.
The mechanism is decompiled and settled. The graphics object begins with a Win16
LOGPALETTE:
| Offset | Field | Value |
|---|---|---|
0x000 |
WORD palVersion |
0x0300, written literally |
0x002 |
WORD palNumEntries |
0x0100 = 256, written literally |
0x004 |
PALETTEENTRY[256] |
4 bytes each: R, G, B, flags |
0x404 |
HPALETTE |
the handle CreatePalette returns |
4 + 256 × 4 = 0x404 exactly, which is why the handle sits there. And the
sequence that fills it is:
1070:0000 checks GetDeviceCaps(hdc, RASTERCAPS) & RC_PALETTE. On a device
without a palette it clears a global flag and no palette is created at
all — every later CreatePalette and SetPaletteEntries is guarded by it.GetSystemPaletteEntries. The initial palette
is the Windows system palette, not game data.1068:004e calls CreatePalette and stores the handle at +0x404.1068:083b(obj, start, count, rgb) walks a
packed 3-byte RGB array and ends in SetPaletteEntries. It has 25 direct
callers. CTAB blobs are exactly its argument list, and
the loader at 1068:0180 is what establishes they are one of its payloads.1070:0342 sets peFlags = PC_NOCOLLAPSE, downgrading it where a colour
duplicates an entry in the first or last NUMCOLORS/2 slots.So there is nothing on disk to read sprite colours out of, and a tool that claims otherwise is guessing.
Every one of the 75 256-entry palettes that does ship — 70 8-bpp canvases, the FLIC’s first frame, 4 icon DIBs — was scored against 19,029 sprite runs, using the mean squared RGB distance between horizontally adjacent pixels of different index (real art through its real palette is locally smooth), calibrated against pairings whose answer is known:
| ratio | |
|---|---|
| genuine pairing — a canvas through its own palette, median (n=68) | 0.167 |
| genuine pairing, 90th percentile | 0.350 |
| best shipped palette against the sprites | 0.413 |
Only 5 of 68 genuine pairings fit as badly as the best candidate does. Rendering through it produces recognisable figures — the shapes were never in doubt — but muddy, over-dark colour, which is what a structurally similar wrong palette looks like. No palette shipped with the game fits the sprites the way a real palette fits its own art. The two results are independent: the decompilation says the palette is not on disk, the sweep says nothing on disk behaves like it.
Indices are the data. A palette is a way of looking at them.
Three things follow.
Every extracted image is an indexed PNG whose pixel values are the game’s index bytes, byte for byte. Whatever the palette turns out to be, the export has lost nothing. Index 0 is written transparent, which is safe because none of the 915 sprites uses it — re-checked per sprite on every extract.
If two indices held the same colour, colour → index would be a guess. So before a view palette is written, duplicates are nudged: one step on one channel, blue first, then green, then red, by the smallest amount that lands on a free colour. One part in 255 is invisible to the eye and decisive for the inverse.
That is what lets an artist work in a truecolour editor — one that throws the
indices away on save — and still get a bit-exact round trip. Introducing a
colour the palette does not hold is the one thing that cannot work, and
colwin build names those colours and stops rather than quietly rounding the
art. --nearest overrides it when that is what you mean.
python3 colwin.py palette ws --set=ctab:111
rewrites the PLTE chunk of every sprite PNG and touches no pixel index. When
the real runtime palette is established, applying it will not lose a single edit
made before then. Swapping away and back leaves all 915 sprites still
re-encoding byte-identically — the verification proves it.
| Indices | Source | Status |
|---|---|---|
| 0–9, 246–255 | the Windows 3.1 static system colours | established |
| 142–238 | a CTAB |
payload established; which table is a heuristic |
| 10–141, 239–245 | a grey ramp | UNKNOWN, and says so |
The system-colour band is corroborated by the art itself. NUMCOLORS is 20 on a
standard 256-colour driver, so the runtime’s flagging loops scan exactly indices
0–9 and 246–255 — and of those twenty, sprites use exactly 246, 247 and 249:
cream, medium grey and red, the three that look like art colours. They never use
the dark system entries. (Index 249 alone is 5.2% of all sprite pixels.)
Measured over all 915 sprites and 4,226,289 opaque pixels, the unused indices are exactly 0–9, 248 and 250–255. That 248 is the one interior hole while 246, 247 and 249 are all used is not explained.
--palette= |
What it does |
|---|---|
ctab-auto (default) |
per sprite, the CTAB whose id matches or is nearest, applied only inside the id range the tables occupy |
ctab:<id> |
one table for every sprite |
index |
an identity grey ramp — i → (i,i,i), claiming nothing at all |
ctab-auto is a rendering heuristic with a measured hit rate, not the
program’s rule. The program computes colour-table ids from game state, never
from a sprite id. What the heuristic reflects is that art and its colour tables
were numbered together during authoring, which is why sprite id predicts colour
well without the program ever using it that way.
Sprites outside the CTAB id range keep the neutral ramp rather than being
coloured by extrapolation — there is no id to pair on, and it was measured 0/4
for COLDATA6, whose sprite ids sit far above every table id.
A CVPC and an RT_BITMAP each carry their own complete
palette, so what you see is what the game shows, and editing the PNG’s palette
edits the game’s. Only sprites have the problem above.