Files
nyuller/tools/convert_screens.py
T
ballz d78eb976cf Rename game from Whack Hare! to Nyuller
All references updated:
- src/build.sh: PRG=nyuller.prg, D64=nyuller.d64, disk name 'ny',
  all grep/pgrep patterns updated, all comments updated
- All 10 header files: include guards WHACK_HARE_* → NYULLER_*
- C source comments: 'Whack Hare!' → 'Nyuller'
- README.md: updated build command output path
- GAME.md: title updated
- tasks.md: all whack_hare.prg → nyuller.prg
- src/AGENT_CONTEXT.md: project name updated
- tools/convert_screens.py: project name updated

Build artifacts renamed: whack_hare.* → nyuller.*, whack.d64 → nyuller.d64

Note: title screen image (source_images/screen_title.png) still shows
'WHACKED' logo — that's a visual asset, not a code reference.
2026-07-18 15:48:50 +02:00

480 lines
19 KiB
Python

#!/usr/bin/env python3
"""Convert a C64 game source PNG into multicolor-bitmap .bin + .attr files.
Phase 1 of the "Nyuller" C64 project. See tasks.md for the full spec.
The C64 multicolor bitmap mode (VIC-II $D011 BMM=1, $D016 MCM=1) is a
160x200-pixel, 2-bits-per-pixel, 4-colors-per-4x8-cell format. The
bitmap is 8000 bytes and the "screen memory" (color attribute table) is
1000 bytes (one byte per 4x8 cell).
Per c64-wiki.com/wiki/Multicolor_Bitmap_Mode, the 2-bit pixel value
selects:
00 = $D021 (background color 0, the global "00" color)
01 = upper nibble of the cell's screen-memory byte
10 = lower nibble of the cell's screen-memory byte
11 = lower nibble of the cell's color RAM ($D800+cell)
So our .attr byte layout per cell is:
bits 0-3 = "10" pixel color (C64 palette index 0..15)
bits 4-7 = "01" pixel color (C64 palette index 0..15)
Each cell's "11" color would be stored in color RAM at runtime (4 bits
per cell = 1000 nibbles at $D800-$DBE7). For Phase 1 we do not store
the "11" color in the .attr file; the Phase 2 C code is expected to
program color RAM as needed. By default we set the global "00" color
$D021 to the most common color across the whole screen and leave color
RAM zeroed (which gives a black "11" color) - this loses one color per
cell but keeps the format to two files (8000 + 1000 = 9000 bytes).
The C64 multicolor bitmap is laid out in memory as 320 bytes per
"character row" (8 pixel rows), 40 cells per row, 25 character rows
total. Within a cell, the 8 bytes correspond to the 8 pixel rows, and
each byte holds 4 pixels (2 bits per pixel) with the leftmost pixel
in the high bits (MSB-first).
CROP / DOWNSCALE STRATEGY CHOICE
--------------------------------
Source PNGs are ~1400x1100; the target is 160x200 (a ~9x downscale).
We tried two strategies on all 5 source images:
A. Direct resize to 160x200 with LANCZOS (1 pass).
B. Resize to 320x200 with LANCZOS, then 2x downscale to 160x200
with BOX (the integer-downscale "area averaging" filter).
Both produce visually equivalent results at this scale factor - the
2x BOX step in B is the "textbook" approach for large downscaling
but at ~9x the difference vs A is negligible. We pick A as default
because it's simpler (one LANCZOS pass) and a touch faster. Both
remain available via --strategy; the C side doesn't care which was
used.
CROP REGION
-----------
The full source image is used (no manual cropping). The source aspect
ratio (~1.23:1) doesn't match the 160x200 target (0.8:1), so the
image is stretched vertically by ~1.5x. We accept this for Phase 1;
a follow-up could either letterbox (fit by height, add black bars
top/bottom) or crop (fit by width, drop sides). The current result
is recognisable for all 5 source images.
$D021 SELECTION
---------------
We pick the most common C64 palette color in the (downscaled) image
as the global $D021 value. For our 5 sunset-themed screens this
turns out to be black (0), which is also the C64 community's default.
Black is a good d021 because (a) every cell is likely to contain it
(outlines, shadows), (b) it makes the C64's border $D020 look
natural, and (c) the "11" color in color RAM defaults to black at
boot, so unused "11" pixels are also black. Override with --d021
if a different global background is desired.
Usage:
python3 convert_screens.py <input.png> <output_base> [--strategy A|B]
python3 convert_screens.py source_images/screen_title.png src/data/processed/title
"""
import argparse
import sys
from collections import Counter
from pathlib import Path
try:
from PIL import Image
except ImportError:
sys.stderr.write("ERROR: Pillow not installed. Try: pip install pillow\n")
sys.exit(1)
# C64 "Pepto PAL" palette (RGB). Indices match the VIC-II color number
# ($D021..$D024 background regs and $D025-$D026 sprite MC regs use the
# same 16 colors). The order below is index 0=black, 1=white, etc.
PALETTE_RGB = (
(0x00, 0x00, 0x00), # 0 black
(0xFF, 0xFF, 0xFF), # 1 white
(0x88, 0x00, 0x00), # 2 red
(0xAA, 0xFF, 0xEE), # 3 cyan
(0xCC, 0x44, 0xCC), # 4 purple
(0x00, 0xCC, 0x55), # 5 green
(0x00, 0x00, 0xAA), # 6 blue
(0xEE, 0xEE, 0x77), # 7 yellow
(0xDD, 0x88, 0x55), # 8 orange
(0x66, 0x44, 0x00), # 9 brown
(0xFF, 0x77, 0x77), # 10 light red
(0x33, 0x33, 0x33), # 11 dark grey
(0x77, 0x77, 0x77), # 12 medium grey
(0xAA, 0xFF, 0x66), # 13 light green
(0x00, 0x88, 0xFF), # 14 light blue
(0xCC, 0xCC, 0xCC), # 15 light grey
)
def _dist2(a, b):
"""Squared RGB distance between two 3-tuples. Faster than sqrt."""
dr = a[0] - b[0]
dg = a[1] - b[1]
db = a[2] - b[2]
return dr * dr + dg * dg + db * db
def nearest_palette_index(rgb):
"""Return the index (0..15) of the nearest C64 palette color to rgb."""
best_i = 0
best_d = _dist2(rgb, PALETTE_RGB[0])
for i in range(1, 16):
d = _dist2(rgb, PALETTE_RGB[i])
if d < best_d:
best_d = d
best_i = i
return best_i
def palette_snap_image(img):
"""Snap every pixel of a PIL image to the nearest C64 palette color.
Returns a new image where each pixel is a tuple from PALETTE_RGB.
Done by quantizing to the 16-color C64 palette globally - this is
a useful intermediate step before the per-cell quantization, but
the per-cell 4-color selection is the real work.
"""
out = Image.new("RGB", img.size)
src = img.load()
dst = out.load()
for y in range(img.height):
for x in range(img.width):
dst[x, y] = PALETTE_RGB[nearest_palette_index(src[x, y])]
return out
def _cell_palette(pixels_rgb, d021_index, k=3):
"""Pick k extra palette colors for a 4x8 cell, excluding d021_index.
Returns a list of exactly k C64 palette indices (0..15) sorted by
frequency in pixels_rgb (most common first). If the cell has
fewer than k distinct non-d021 colors, pad by repeating the last
one. These k colors plus d021_index form the cell's 4-color set.
"""
counts = Counter()
for p in pixels_rgb:
idx = nearest_palette_index(p)
if idx != d021_index:
counts[idx] += 1
most = [idx for idx, _ in counts.most_common(k)]
while len(most) < k:
most.append(most[-1] if most else d021_index)
return most
def _snap_pixels_to_indices(pixels_rgb, palette_indices):
"""Snap each pixel to the nearest of the given palette indices.
Returns a list of 0..3 (the index into palette_indices, not the
palette index itself). palette_indices[0] is treated as the "00"
color and is what the C64 will display for 2-bit pixel value 00.
"""
pal_rgb = [PALETTE_RGB[i] for i in palette_indices]
snapped = []
for p in pixels_rgb:
best_local = 0
best_d = _dist2(p, pal_rgb[0])
for j in range(1, len(pal_rgb)):
d = _dist2(p, pal_rgb[j])
if d < best_d:
best_d = d
best_local = j
snapped.append(best_local)
return snapped
def quantize_cell(pixels_rgb, d021_index):
"""Pick 4 colors for a 4x8 cell and snap each pixel to one of them.
The C64 multicolor bitmap's "00" color is the global $D021 register,
so d021_index must be one of the cell's 4 colors (at position 0).
The other 3 colors are the 3 most common non-d021 colors in the
cell, sorted by frequency.
pixels_rgb is a list of 32 (R,G,B) tuples (4 wide x 8 tall).
Returns (palette_indices, snapped_local):
palette_indices - list of exactly 4 C64 palette indices:
[0] = d021_index (the "00" color)
[1] = cell's most common (excluding d021)
[2] = cell's 2nd most common
[3] = cell's 3rd most common
snapped_local - list of 32 ints in 0..3, the local index into
palette_indices for each pixel in raster order
(left-to-right, top-to-bottom). Value 0 means
"this pixel snaps to d021_index".
"""
extra = _cell_palette(pixels_rgb, d021_index, k=3)
palette_indices = [d021_index] + extra
snapped_local = _snap_pixels_to_indices(pixels_rgb, palette_indices)
return palette_indices, snapped_local
def encode_cell_pixels(snapped_local):
"""Pack 32 snapped-local pixels (0..3 each) into 8 bytes.
The C64 multicolor bitmap packs 4 pixels per byte, MSB-first: bits
7-6 hold pixel 0 (leftmost), bits 5-4 hold pixel 1, bits 3-2 hold
pixel 2, bits 1-0 hold pixel 3. The 2-bit value selects which of
the 4 cell colors to use.
pixels are in raster order (left-to-right, top-to-bottom) for a
4x8 cell: rows 0..7, each with 4 pixels.
"""
assert len(snapped_local) == 32
out = bytearray(8)
for row in range(8):
b = 0
for col in range(4):
pix = snapped_local[row * 4 + col] & 0x03
b = (b << 2) | pix
out[row] = b
return bytes(out)
def make_attr_byte(palette_indices):
"""Pack a cell's 4-color palette into a 1-byte screen-memory entry.
bits 0-3 = "10" pixel color (C64 palette index 0..15)
bits 4-7 = "01" pixel color (C64 palette index 0..15)
Convention used here (see module docstring):
palette_indices[0] = d021_index (the "00" color, global)
palette_indices[1] = "01" color (high nibble of attr)
palette_indices[2] = "10" color (low nibble of attr)
palette_indices[3] = "11" color (NOT stored in .attr - it lives in
color RAM at $D800+cell_index, set by the
C code at runtime)
This is a deliberate simplification: the "11" color is the 4th color
in the cell, but it costs 4 bits per cell to store in color RAM and
we only have 1000 bytes for the .attr file. The C-side code can
either accept the "11" = black default (color RAM is initialized to
0 at boot) or set color RAM to a per-cell value from a separate
table. See tasks.md Phase 1 notes and the follow-up work for
Phase 2.
"""
fg10 = palette_indices[2] & 0x0F
bg01 = palette_indices[1] & 0x0F
return fg10 | (bg01 << 4)
def pick_global_d021(img_palette_quantized, source_rgb=None):
"""Pick the single $D021 value to use for the whole screen.
Counts the C64 palette indices used in the already-quantized image
and picks the most common. This is the "00" color shared by all
cells. If source_rgb is given, uses it instead of the quantized
image to do the count (a tiny bit faster than re-quantizing).
Returns an int 0..15.
The default uses the most common color of the whole image, which
for our 5 sunset-themed screens ends up being black (the C64's
natural background). This is a deliberate choice: black is the
safest d021 because it's a common "outline" / "shadow" color in
the art, every cell is likely to contain it, and the C64 community
uses it by default. We can revisit this (e.g. pick the most common
non-black color) if the output looks too dark in later phases.
"""
counts = Counter()
src = img_palette_quantized.load()
for y in range(img_palette_quantized.height):
for x in range(img_palette_quantized.width):
counts[nearest_palette_index(src[x, y])] += 1
return counts.most_common(1)[0][0]
def convert_image(img, strategy="A", verbose=False, d021_override=None):
"""Convert a PIL Image to (bitmap_bytes, attr_bytes, d021_index).
img - PIL Image (any mode; will be converted to RGB)
strategy - "A" (resize directly to 160x200) or
"B" (resize to 320x200 then 2x downscale to 160x200)
d021_override - if not None, force this as the $D021 value
(skip the auto-pick). int 0..15.
Returns (bitmap, attr, d021_index) where:
bitmap - bytes of length 8000
attr - bytes of length 1000
d021_index - int 0..15, the $D021 value to program on the C64
"""
if img.mode != "RGB":
img = img.convert("RGB")
if strategy == "A":
# Direct 160x200 downscale with LANCZOS. One pass, simple.
# For these ~1400x1100 source images, this is an 8.7x
# horizontal / 5.6x vertical downscale. LANCZOS handles
# this OK but is at the edge of its comfort zone; a 2x
# downscale is more in LANCZOS's sweet spot.
small = img.resize((160, 200), Image.LANCZOS)
elif strategy == "B":
# 320x200 with LANCZOS, then 2x downscale with BOX.
# The first pass is 4.3x (LANCZOS does this well), the
# second is a clean 2x box filter (Pillow's BOX is the
# integer-downscale equivalent of "area averaging" - each
# output pixel is the mean of a 2x2 block of input pixels,
# which is what you want for downscaling). This is the
# textbook high-quality downscale recipe.
mid = img.resize((320, 200), Image.LANCZOS)
small = mid.resize((160, 200), Image.BOX)
else:
raise ValueError("strategy must be 'A' or 'B'")
# First, snap every pixel to the nearest C64 palette color. This
# gives a stable RGB for downstream analysis (no antialiasing
# artifacts in the count).
quantized = palette_snap_image(small)
if d021_override is not None:
d021_index = d021_override & 0x0F
else:
d021_index = pick_global_d021(quantized)
if verbose:
print(f" d021 (background) = {d021_index} "
f"({PALETTE_RGB[d021_index]})")
bitmap = bytearray(8000)
attr = bytearray(1000)
src = quantized.load()
# 1000 cells: 40 across (160/4), 25 down (200/8).
for cy in range(25):
for cx in range(40):
# Gather the 32 pixel colors in this 4x8 cell.
pixels = []
for row in range(8):
py = cy * 8 + row
for col in range(4):
px = cx * 4 + col
pixels.append(src[px, py])
palette_indices, snapped_local = quantize_cell(pixels,
d021_index)
attr_byte = make_attr_byte(palette_indices)
cell_bytes = encode_cell_pixels(snapped_local)
# Place the 8 bytes for this cell in the bitmap.
# C64 multicolor bitmap memory layout:
# offset = (cy * 40 + cx) * 8
# because 40 cells per character row, 8 bytes per cell.
cell_offset = (cy * 40 + cx) * 8
bitmap[cell_offset:cell_offset + 8] = cell_bytes
attr[cy * 40 + cx] = attr_byte
return bytes(bitmap), bytes(attr), d021_index
def preview_image(bitmap, attr, d021_index):
"""Render the .bin/.attr back to a PIL Image for visual inspection.
Useful for debugging the quantizer. Not used by the C side.
Shows what the C64 will actually display: 2-bit value 00 -> d021,
01 -> attr high nibble, 10 -> attr low nibble, 11 -> color RAM.
Since we don't store the "11" color in .attr, we use black (0)
here, which is what the C64's color RAM defaults to at boot.
"""
out = Image.new("RGB", (160, 200), PALETTE_RGB[d021_index])
px = out.load()
for cy in range(25):
for cx in range(40):
cell_offset = (cy * 40 + cx) * 8
attr_byte = attr[cy * 40 + cx]
fg10 = PALETTE_RGB[attr_byte & 0x0F] # "10" color
bg01 = PALETTE_RGB[(attr_byte >> 4) & 0x0F] # "01" color
for row in range(8):
b = bitmap[cell_offset + row]
for col in range(4):
v = (b >> (6 - col * 2)) & 0x03
if v == 0:
c = PALETTE_RGB[d021_index]
elif v == 1:
c = bg01
elif v == 2:
c = fg10
else:
# "11" - color RAM. Not stored in .attr.
c = PALETTE_RGB[0]
px[cx * 4 + col, cy * 8 + row] = c
return out
def convert_file(input_path, output_base, strategy="A", verbose=False,
write_preview=False, d021_override=None):
"""Convert input_path to (output_base + ".bin", output_base + ".attr").
output_base is a path WITHOUT the .bin / .attr extension.
"""
in_path = Path(input_path)
if not in_path.exists():
raise FileNotFoundError(in_path)
if verbose:
print(f"Converting {in_path} -> {output_base}.{{bin,attr}} "
f"(strategy {strategy})")
img = Image.open(in_path)
bitmap, attr, d021_index = convert_image(img, strategy=strategy,
verbose=verbose,
d021_override=d021_override)
out_path = Path(output_base)
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(out_path.with_suffix(".bin"), "wb") as f:
f.write(bitmap)
with open(out_path.with_suffix(".attr"), "wb") as f:
f.write(attr)
# Also write a tiny sidecar with the $D021 value, so the C code
# doesn't have to guess. This is checked into the repo too.
with open(out_path.with_suffix(".d021"), "w") as f:
f.write(f"{d021_index}\n")
if verbose:
print(f" wrote {out_path.with_suffix('.bin')} "
f"({len(bitmap)} bytes)")
print(f" wrote {out_path.with_suffix('.attr')} "
f"({len(attr)} bytes)")
print(f" wrote {out_path.with_suffix('.d021')} ({d021_index})")
if write_preview:
prev = preview_image(bitmap, attr, d021_index)
prev_path = out_path.with_suffix(".preview.png")
prev.save(prev_path)
if verbose:
print(f" wrote {prev_path}")
return bitmap, attr, d021_index
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("input", help="Source PNG path")
ap.add_argument("output_base",
help="Output base path (no .bin/.attr extension)")
ap.add_argument("--strategy", choices=["A", "B"], default="A",
help="A: 160x200 direct LANCZOS downscale (default). "
"B: 320x200 LANCZOS then 2x BOX downscale. "
"Both produce visually equivalent output on the "
"5 source images; A is simpler and faster.")
ap.add_argument("--preview", action="store_true",
help="Also write a <base>.preview.png for inspection")
ap.add_argument("--d021", type=int, default=None,
help="Force the $D021 (background) color, 0..15. "
"Default: auto-pick the most common color in "
"the image.")
ap.add_argument("--quiet", action="store_true",
help="Suppress per-file progress output")
args = ap.parse_args(argv)
if args.d021 is not None and not (0 <= args.d021 <= 15):
ap.error("--d021 must be in 0..15")
convert_file(args.input, args.output_base,
strategy=args.strategy,
verbose=not args.quiet,
write_preview=args.preview,
d021_override=args.d021)
if __name__ == "__main__":
main()