Working Python version for Commodore.

This commit is contained in:
The Dust Council 2026-06-14 17:43:12 -07:00
commit 2a48f52979
51 changed files with 3095 additions and 0 deletions

125
README.md Normal file
View file

@ -0,0 +1,125 @@
# c64view
Convert a modern image (PNG/JPG/GIF/BMP/WEBP) into a **Commodore 64 disk image**
(`.d64` / `.d71` / `.d81`) containing a self-contained viewer that displays the
picture on a real C64 or in an emulator. The converter works hard to preserve
quality within the VIC-II's tight colour and resolution limits.
![c64view GUI](docs/gui.png)
## Highlights
- **Five display modes** (auto-selectable):
- **Hires** — 320×200, 2 colours per 8×8 cell. Best for sharp line art.
- **Multicolor** — 160×200, 1 shared background + 3 colours per 4×8 cell
(the classic "Koala" format). Best general-purpose photo mode.
- **FLI** — re-points the video matrix every scanline for per-line (4×1) colour.
- **Interlace** — two multicolor frames blended at 50 Hz for ~136 apparent colours.
- **Mono** — highest-resolution path: 320×200 matched by *luminance* to a colour
ramp, so detail is carried by intense dithering. Greyscale by default, or pick
any palette colour as the base for a tinted monochrome (black → blue → light
blue → white, etc.).
- **Perceptual conversion.** All colour decisions are made in CIELAB. Each screen
cell's colour set is chosen by an exhaustive, vectorised search that minimises
reproduction error; an *Intensive* mode additionally searches the global
background colour.
- **Selectable dithering** — ordered Bayer (default, artifact-free), FloydSteinberg,
Atkinson, and the larger Stucki / Jarvis error-diffusion kernels (smoothest
gradients, ideal for mono), or none — all constrained so a cell never shows a
colour it can't.
- **Explore variations.** One click renders every Mode × Palette × Dither
combination as a contact sheet (parallelised across CPU cores); pick the best,
then fine-tune brightness/contrast/saturation/gamma on that choice.
- **PAL / NTSC.** Choose the target video standard. Static and interlace viewers
work on both (interlace flips per frame, so it flickers at the standard's field
rate automatically); FLI ships a separately-timed viewer for each.
- **Run in VICE.** One click builds the disk and launches it in VICE in the chosen
standard (warp mode, except interlace which needs real-time for its flicker): it
lists the directory, then `LOAD"*",8,1` + `RUN` to show the picture.
- **On-disk info program.** When there's room, a colourful BASIC program is added
that prints the original name, dimensions, format, colour depth, oldest EXIF date,
file date, EXIF comment, when the C64 version was made, the host platform, and the
Linux distribution/version.
- **Self-contained viewers.** Each disk's first program embeds the picture and loads
in a single pass, so `LOAD"*",8,1` then `RUN` just works — no second disk access,
no emulator-config surprises.
- **Standard interchange files.** Multicolor exports also drop a `PICTURE.KOA`
(Koala) and hires a `PICTURE.ART` (OCP Art Studio) file for use in other C64 tools.
- **GUI and CLI.**
## Requirements
- Python 3.9+, with `numpy` and `Pillow`.
- `PyQt5` (GUI only).
- [`xa`](https://www.floodgap.com/retrotech/xa/) (xa65) — assembles the 6502 viewers.
- [VICE](https://vice-emu.sourceforge.io/)'s `c1541` — builds the disk images.
On Debian/Ubuntu:
```sh
sudo apt install python3-numpy python3-pil python3-pyqt5 xa65 vice
```
## Usage
### GUI
```sh
python -m c64view.gui # or: c64view
```
Open an image, pick a mode / disk format / dithering, watch the live C64 preview,
then **Export**.
### Command line
```sh
# Multicolor picture onto a .d64, plus a preview PNG of how it will look:
python -m c64view.cli photo.jpg -m multicolor -o photo.d64 --preview photo.png
# Let the tool pick the best standard mode, write a .d81:
python -m c64view.cli photo.jpg -m auto -o photo.d81
# Best quality (slower) FLI with error-diffusion dithering:
python -m c64view.cli photo.jpg -m fli -d floyd --intensive -o photo.d64
# High-res greyscale with smooth Stucki dithering:
python -m c64view.cli photo.jpg -m mono -d stucki -o photo.d64
# ...or tinted monochrome in blue:
python -m c64view.cli photo.jpg -m mono --mono-base blue -d jarvis -o photo.d64
```
Options: `-m/--mode {auto,hires,multicolor,fli,interlace,mono}`,
`-f/--format {d64,d71,d81}`, `-p/--palette {colodore,pepto}`,
`-d/--dither {bayer,floyd,atkinson,stucki,jarvis,none}`,
`--mono-base {grayscale,<colour name>}`, `--video {pal,ntsc}`,
`-a/--aspect {fit,fill,stretch}`, `--intensive`,
`--brightness/--contrast/--saturation/--gamma`, `--preview`.
### On the C64 / in an emulator
Attach the disk to drive 8 and:
```
LOAD"*",8,1
RUN
```
Press any key to return to BASIC (hires/multicolor). For FLI/interlace, reset to exit.
## Notes on the advanced modes
- **FLI** is timing-critical: the viewer runs a cycle-stable raster loop. Expect a
small settling artifact in the top rows (a well-known FLI characteristic) and the
leftmost few pixels reserved by the hardware "FLI bug".
- **Interlace** flickers at 25 Hz on a CRT; it looks best in an emulator or on an LCD.
- Multicolor and Hires are the universally safe, flicker-free choices.
## How conversion works
`c64view/convert/` holds one encoder per mode on top of a shared core
(`convert/base.py` + `dither.py`). The pipeline: prepare & resize the image to the
mode's pixel grid (`imageprep.py`), convert to CIELAB (`palette.py`), choose each
cell's legal colour set by exhaustive search, dither within those sets, then pack the
VIC-II bytes. `viewer/*.s` are the 6502 viewers (assembled by `xa`), combined with the
picture data and written to a disk image by `diskimage.py` / `exporter.py`.

3
c64view/__init__.py Normal file
View file

@ -0,0 +1,3 @@
"""c64view -- convert modern images into Commodore 64 disk images with a viewer."""
__version__ = "0.1.0"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

120
c64view/basicgen.py Normal file
View file

@ -0,0 +1,120 @@
"""Generate a small, colourful Commodore 64 BASIC program (tokenised .PRG) that
prints image metadata. We emit the tokenised bytes directly -- token bytes for
the few keywords used (PRINT, POKE) and PETSCII for everything else -- so there
is no dependency on an external BASIC tokeniser.
"""
from __future__ import annotations
# BASIC V2 keyword tokens.
_PRINT = 0x99
_POKE = 0x97
_CHR = 0xC7 # CHR$( function token
_QUOTE = 0x22
# PETSCII control codes.
CLR = 0x93
RVON = 0x12
RVOFF = 0x92
# colours
WHITE, RED, GREEN, BLUE = 0x05, 0x1c, 0x1e, 0x1f
BLACK, ORANGE, BROWN = 0x90, 0x81, 0x95
LT_RED, DK_GREY, GREY, LT_GREEN = 0x96, 0x97, 0x98, 0x99
LT_BLUE, LT_GREY, PURPLE, YELLOW, CYAN = 0x9a, 0x9b, 0x9c, 0x9e, 0x9f
# label colours cycled down the screen (none black, all readable on black).
_LABEL_COLOURS = [CYAN, YELLOW, LT_GREEN, LT_RED, LT_BLUE, PURPLE, ORANGE, GREEN]
# per-character rainbow for the title (bright, distinct, no black).
_RAINBOW = [RED, ORANGE, YELLOW, LT_GREEN, GREEN, CYAN, LT_BLUE, BLUE, PURPLE,
LT_RED, WHITE]
_SCREEN_W = 40
_VALUE_COL = 11 # column where a value starts (= label field width)
_LINE_MAX = _SCREEN_W - 1 # keep lines < 40 so the screen never auto-wraps
_VALUE_W = _LINE_MAX - _VALUE_COL # printable value chars per line
def _petscii(text: str) -> bytes:
"""Map an ASCII string to printable PETSCII (upper-case glyph range)."""
out = bytearray()
for ch in str(text).upper():
b = ord(ch)
if b == _QUOTE:
b = 0x27 # avoid closing the BASIC string
if 0x20 <= b <= 0x5F:
out.append(b)
else:
out.append(0x2E) # '.'
return bytes(out)
def _print_str(inner: bytes) -> bytes:
return bytes([_PRINT, _QUOTE]) + inner + bytes([_QUOTE])
def _assemble(lines: list[tuple[int, bytes]]) -> bytes:
"""Link tokenised lines into a PRG (load address $0801)."""
cur = 0x0801
pieces = []
for num, toks in lines:
body = bytes([num & 0xFF, (num >> 8) & 0xFF]) + toks + b"\x00"
nxt = cur + 2 + len(body)
pieces.append(bytes([nxt & 0xFF, (nxt >> 8) & 0xFF]) + body)
cur = nxt
return bytes([0x01, 0x08]) + b"".join(pieces) + b"\x00\x00"
def _rainbow_title(text: str) -> bytes:
"""Centred, per-character rainbow title (control codes don't take columns)."""
pad = max(0, (_SCREEN_W - len(text)) // 2)
out = bytes([CLR]) + _petscii(" " * pad)
for k, ch in enumerate(text):
out += bytes([_RAINBOW[k % len(_RAINBOW)]]) + _petscii(ch)
return bytes([_PRINT, _QUOTE]) + out + bytes([_QUOTE])
def _field_lines(label: str, value: str, colour: int) -> list[bytes]:
"""Word-/width-wrapped PRINT lines for one field; continuations are indented
to the value's start column so a long value lines up under itself."""
label_p = _petscii((label + ":").ljust(_VALUE_COL))
value = str(value)[:_VALUE_W * 4] # cap at four screen lines
chunks = [value[i:i + _VALUE_W] for i in range(0, len(value), _VALUE_W)] or [""]
out = [_print_str(bytes([colour]) + label_p + bytes([WHITE]) + _petscii(chunks[0]))]
indent = _petscii(" " * _VALUE_COL)
for chunk in chunks[1:]:
out.append(_print_str(bytes([WHITE]) + indent + _petscii(chunk)))
return out
def build_info_prg(fields: list[tuple[str, str]]) -> bytes:
"""Return a tokenised BASIC PRG that prints ``fields`` (label, value)."""
lines: list[tuple[int, bytes]] = []
num = 0
def add(toks: bytes):
nonlocal num
num += 10
lines.append((num, toks))
# border and background both black
add(bytes([_POKE]) + b"53280,0:" + bytes([_POKE]) + b"53281,0")
add(_rainbow_title("c64view picture info"))
add(bytes([_PRINT])) # blank line
for i, (label, value) in enumerate(fields):
col = _LABEL_COLOURS[i % len(_LABEL_COLOURS)]
for line in _field_lines(label, value, col):
add(line)
add(bytes([_PRINT]))
# PRINT " load "CHR$(34)"*"CHR$(34)",8,1 to view picture" -- CHR$(34) is the
# double-quote, which can't appear literally inside a BASIC string.
q = bytes([_CHR]) + _petscii("(34)")
add(bytes([_PRINT])
+ bytes([_QUOTE]) + bytes([GREY]) + _petscii(" load ") + bytes([_QUOTE])
+ q
+ bytes([_QUOTE]) + _petscii("*") + bytes([_QUOTE])
+ q
+ bytes([_QUOTE]) + _petscii(",8,1 to view picture") + bytes([_QUOTE]))
return _assemble(lines)

80
c64view/cli.py Normal file
View file

@ -0,0 +1,80 @@
"""Headless entry point: convert an image, write a disk image and/or a preview PNG."""
from __future__ import annotations
import argparse
import sys
from PIL import Image
from . import imageprep
from .convert import MODES, convert_image, render_preview
from .palette import COLOR_NAMES
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="c64view", description=__doc__)
p.add_argument("image", help="source image (png/jpg/gif/bmp/webp)")
p.add_argument("-o", "--output", help="disk image path (.d64/.d71/.d81)")
p.add_argument("-m", "--mode", default="auto",
choices=["auto", *MODES], help="C64 display mode")
p.add_argument("-f", "--format", default=None,
choices=["d64", "d71", "d81"],
help="disk format (default: from -o extension, else d64)")
p.add_argument("-p", "--palette", default="colodore",
choices=["colodore", "pepto"])
p.add_argument("-d", "--dither", default="bayer",
choices=["bayer", "floyd", "atkinson", "stucki", "jarvis", "none"])
p.add_argument("--mono-base", default="grayscale",
choices=["grayscale", *COLOR_NAMES],
help="base colour for 'mono' mode (default greyscale)")
p.add_argument("-a", "--aspect", default="fit",
choices=["fit", "fill", "stretch"])
p.add_argument("--video", default="pal", choices=["pal", "ntsc"],
help="target video standard (affects the FLI viewer timing)")
p.add_argument("--intensive", action="store_true",
help="exhaustive background search + slower, higher-quality passes")
p.add_argument("--brightness", type=float, default=1.0)
p.add_argument("--contrast", type=float, default=1.0)
p.add_argument("--saturation", type=float, default=1.0)
p.add_argument("--gamma", type=float, default=1.0)
p.add_argument("--preview", help="also write a PNG preview to this path")
p.add_argument("--disk-name", default=None, help="disk + viewer name (PETSCII)")
return p
def main(argv=None) -> int:
args = build_parser().parse_args(argv)
prep = imageprep.PrepOptions(
aspect=args.aspect, brightness=args.brightness, contrast=args.contrast,
saturation=args.saturation, gamma=args.gamma,
)
base_color = (None if args.mono_base == "grayscale"
else COLOR_NAMES.index(args.mono_base))
conv = convert_image(args.image, mode=args.mode, palette_name=args.palette,
dither_mode=args.dither, intensive=args.intensive,
prep_opt=prep, base_color=base_color)
print(f"mode={conv.mode} mean dE={conv.error:.2f} "
f"data={len(conv.data)}B extra={[f[0] for f in conv.extra_files]}")
if args.preview:
rgb = render_preview(conv, args.palette, scale=2)
Image.fromarray(rgb, "RGB").save(args.preview)
print(f"wrote preview {args.preview}")
if args.output:
from .exporter import export_disk
fmt = args.format
path = export_disk(conv, args.output, disk_format=fmt,
disk_name=args.disk_name, source_path=args.image,
video=args.video)
print(f"wrote disk image {path}")
if not args.output and not args.preview:
print("nothing to do: pass -o DISK and/or --preview PNG", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,81 @@
"""Conversion dispatch + preview rendering."""
from __future__ import annotations
import numpy as np
from .. import imageprep, palette as pal
from . import base, hires, mono, multicolor
# mode name -> module
_MODULES = {
"hires": hires,
"multicolor": multicolor,
"mono": mono,
}
# Registered lazily so FLI/IFLI can be added without import cycles.
try:
from . import fli # noqa: E402
_MODULES["fli"] = fli
except Exception:
pass
try:
from . import ifli # noqa: E402
_MODULES["interlace"] = ifli
except Exception:
pass
MODES = list(_MODULES.keys())
def convert_image(path_or_img, mode="multicolor", palette_name="colodore",
dither_mode="bayer", intensive=False,
prep_opt: imageprep.PrepOptions | None = None,
base_color=None) -> base.Conversion:
"""Prepare an image for ``mode`` and convert it. ``mode='auto'`` tries every
standard mode and returns the lowest-error result. ``base_color`` (palette
index, or None for grayscale) only applies to the ``mono`` mode."""
prep_opt = prep_opt or imageprep.PrepOptions()
if mode == "auto":
best = None
for m in ("multicolor", "hires"):
c = convert_image(path_or_img, m, palette_name, dither_mode, intensive, prep_opt)
if best is None or c.error < best.error:
best = c
return best
module = _MODULES[mode]
border_rgb = pal.get_palette(palette_name)[prep_opt.border_index]
img_rgb = imageprep.prepare(
path_or_img, module.WIDTH, module.HEIGHT, module.PIXEL_ASPECT,
prep_opt, border_rgb=border_rgb,
)
if mode == "mono":
return module.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color)
return module.convert(img_rgb, palette_name, dither_mode, intensive)
def render_preview(conv: base.Conversion, palette_name="colodore",
scale: int = 2) -> np.ndarray:
"""Render the conversion's index image to a displayed-resolution RGB array.
Logical pixels are widened by the mode's pixel aspect (so multicolor pixels
are twice as wide), giving a uniform 320x200 base which is then integer-scaled.
"""
if conv.preview_rgb is not None:
rgb = conv.preview_rgb
if scale > 1:
rgb = np.repeat(np.repeat(rgb, scale, axis=0), scale, axis=1)
return rgb
prgb = pal.get_palette(palette_name).astype(np.uint8)
rgb = prgb[conv.index_image] # (H, W, 3)
xrep = int(round(conv.pixel_aspect))
if xrep > 1:
rgb = np.repeat(rgb, xrep, axis=1)
if scale > 1:
rgb = np.repeat(np.repeat(rgb, scale, axis=0), scale, axis=1)
return rgb

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

124
c64view/convert/base.py Normal file
View file

@ -0,0 +1,124 @@
"""Shared machinery for every C64 display mode.
The hard part of "make this photo look good on a C64" is that each screen cell
may only show a handful of the 16 fixed colours. We solve that per cell with an
exhaustive, vectorised search over every legal colour combination, scored by
perceptual (CIELAB) reproduction error. The winning per-cell colour sets then
drive a constrained dither (see ``dither.py``) to produce the final index image.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from itertools import combinations
import numpy as np
@dataclass
class Conversion:
"""Result of converting an image to one C64 display mode."""
mode: str
width: int # logical pixel width (160 or 320)
height: int # logical pixel height (200)
pixel_aspect: float # on-screen width of one logical pixel
index_image: np.ndarray # (H, W) uint8 palette indices, for preview
data: bytes = b"" # picture block that must reside from data_addr up
data_addr: int = 0x2000 # memory address where ``data`` must load
preview_rgb: np.ndarray = None # optional explicit preview (e.g. interlace blend)
extra_files: list = field(default_factory=list) # (cbm_name, full_prg_bytes)
viewer: str = "" # viewer key (see viewer/assemble.py)
error: float = 0.0 # mean per-pixel CIELAB error
meta: dict = field(default_factory=dict)
def cells_lab(img_lab: np.ndarray, cell_w: int, cell_h: int):
"""Reshape (H,W,3) -> (n_cells, cell_w*cell_h, 3) plus (rows, cols)."""
H, W, _ = img_lab.shape
rows, cols = H // cell_h, W // cell_w
a = img_lab.reshape(rows, cell_h, cols, cell_w, 3)
a = a.transpose(0, 2, 1, 3, 4).reshape(rows * cols, cell_h * cell_w, 3)
return a, rows, cols
def cell_distance(cells: np.ndarray, palette_lab: np.ndarray) -> np.ndarray:
"""Squared CIELAB distance from every cell pixel to every palette colour.
cells: (n_cells, P, 3) -> (n_cells, P, 16)
"""
return np.sum((cells[:, :, None, :] - palette_lab[None, None, :, :]) ** 2, axis=-1)
def best_global_color(img_lab: np.ndarray, palette_lab: np.ndarray) -> int:
"""Palette index closest, on average, to the whole image (good bg seed)."""
flat = img_lab.reshape(-1, 3)
d = np.sum((flat[:, None, :] - palette_lab[None, :, :]) ** 2, axis=-1)
return int(np.argmin(d.mean(axis=0)))
def select_cell_sets(dist: np.ndarray, available, n_free: int, fixed=()):
"""Pick, per cell, the ``n_free`` palette colours (plus any ``fixed`` ones)
that minimise nearest-colour reproduction error.
dist: (n_cells, P, 16) squared distances (from ``cell_distance``).
Returns (sets, errors): sets is (n_cells, len(fixed)+n_free) palette indices,
errors is (n_cells,) summed squared error of the winning set.
"""
n_cells = dist.shape[0]
fixed = list(fixed)
combos = list(combinations(sorted(available), n_free))
best_err = np.full(n_cells, np.inf)
best_combo = np.zeros((n_cells, n_free), dtype=np.int64)
if fixed:
fixed_min = dist[:, :, fixed].min(axis=2) # (n_cells, P)
for combo in combos:
idx = list(combo)
m = dist[:, :, idx].min(axis=2) # (n_cells, P)
if fixed:
m = np.minimum(m, fixed_min)
err = m.sum(axis=1)
better = err < best_err
best_err = np.where(better, err, best_err)
best_combo[better] = idx
if fixed:
fixed_arr = np.tile(np.array(fixed, dtype=np.int64), (n_cells, 1))
sets = np.concatenate([fixed_arr, best_combo], axis=1)
else:
sets = best_combo
return sets, best_err
def optimize_background(dist: np.ndarray, n_free: int, candidates=range(16)):
"""Choose the single shared background colour (multicolor/FLI) that minimises
total image error, returning (bg_index, sets, errors)."""
best_total = np.inf
best = None
for bg in candidates:
avail = [i for i in range(16) if i != bg]
sets, errors = select_cell_sets(dist, avail, n_free, fixed=(bg,))
total = errors.sum()
if total < best_total:
best_total = total
best = (bg, sets, errors)
return best
def per_pixel_allowed(sets: np.ndarray, rows: int, cols: int,
cell_w: int, cell_h: int, H: int, W: int) -> np.ndarray:
"""Expand per-cell colour sets to an (H, W, K) per-pixel allowed-index table."""
yy, xx = np.indices((H, W))
cell_idx = (yy // cell_h) * cols + (xx // cell_w)
return sets[cell_idx]
def prg(load_addr: int, data: bytes) -> bytes:
"""Wrap raw bytes as a CBM PRG (little-endian load address prefix)."""
return bytes([load_addr & 0xFF, (load_addr >> 8) & 0xFF]) + bytes(data)
def mean_error(index_image: np.ndarray, img_lab: np.ndarray, palette_lab: np.ndarray) -> float:
"""Mean CIELAB delta-E between the chosen indices and the source image."""
chosen = palette_lab[index_image]
return float(np.sqrt(np.sum((chosen - img_lab) ** 2, axis=-1)).mean())

142
c64view/convert/fli.py Normal file
View file

@ -0,0 +1,142 @@
"""FLI (Flexible Line Interpretation) multicolor mode.
A stable raster routine re-points the VIC video matrix every scanline, so the two
screen-RAM-derived colours gain per-line (4x1) resolution while the colour-RAM
colour stays per-cell (4x8) and the background is global. Per 4x1 strip the
displayable colours are therefore {background, colourRAM(cell), screen01(line),
screen10(line)} -- four colours that refresh every line, far more than plain
multicolor.
Memory layout of the appended data block (loads from $4000), matched to
viewer/fli.s:
$4000+L*$400 screen RAM for line L of each char row (L=0..7), 1000 bytes each
$6000 bitmap 8000 (VIC reads here, offset $2000 in bank 1)
$8000 colour RAM 1000 (viewer copies to $D800)
$83E8 background 1
"""
from __future__ import annotations
from itertools import combinations
import numpy as np
from .. import dither, palette as pal
from . import base
WIDTH, HEIGHT = 160, 200
CELL_W, CELL_H = 4, 8
PIXEL_ASPECT = 2.0
DATA_ADDR = 0x4000
N_COLS, N_ROWS = 40, 25
N_CELLS = N_COLS * N_ROWS
def convert(img_rgb, palette_name="colodore", dither_mode="bayer", intensive=False):
plab = pal.palette_lab(palette_name)
img_lab = pal.srgb_to_lab(img_rgb)
# (n_cells, 8 rows, 4 px, 3): one 4x1 strip per (cell, line).
a = img_lab.reshape(N_ROWS, CELL_H, N_COLS, CELL_W, 3)
a = a.transpose(0, 2, 1, 3, 4).reshape(N_CELLS, CELL_H, CELL_W, 3)
dist = np.sum((a[:, :, :, None, :] - plab[None, None, None, :, :]) ** 2, axis=-1)
# dist: (n_cells, 8, 4, 16)
bg_candidates = range(16) if intensive else [base.best_global_color(img_lab, plab)]
best = None
for bg in bg_candidates:
c11, c01, c10, err = _solve(dist, bg)
if best is None or err < best[-1]:
best = (bg, c11, c01, c10, err)
bg, c11, c01, c10, _ = best
index_image = _quantize(img_lab, plab, bg, c11, c01, c10, dither_mode)
data = _encode(index_image, bg, c11, c01, c10)
conv = base.Conversion(
mode="fli", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=index_image, data=data, data_addr=DATA_ADDR, viewer="fli",
error=base.mean_error(index_image, img_lab, plab),
meta={"palette": palette_name, "dither": dither_mode, "background": int(bg)},
)
return conv
def _solve(dist, bg):
"""Pick per-cell colour-RAM colour c11 and per-line free colours c01,c10."""
n = dist.shape[0]
dbg = dist[:, :, :, bg] # (n,8,4)
# c11: the single shared colour that best complements bg across the whole cell.
cell_err = np.empty((16, n))
for c in range(16):
m = np.minimum(dbg, dist[:, :, :, c])
cell_err[c] = m.sum(axis=(1, 2))
cell_err[bg] = np.inf
c11 = np.argmin(cell_err, axis=0) # (n,)
# base error per strip using {bg, c11}.
dc11 = np.take_along_axis(dist, c11[:, None, None, None], axis=3)[..., 0] # (n,8,4)
sbase = np.minimum(dbg, dc11) # (n,8,4)
# per strip (cell,line) choose the best 2 free colours.
best_err = np.full((n, 8), np.inf)
c01 = np.zeros((n, 8), dtype=np.int64)
c10 = np.zeros((n, 8), dtype=np.int64)
for x, y in combinations(range(16), 2):
e = np.minimum(np.minimum(sbase, dist[:, :, :, x]), dist[:, :, :, y]).sum(axis=2)
better = e < best_err
best_err = np.where(better, e, best_err)
c01 = np.where(better, x, c01)
c10 = np.where(better, y, c10)
total = best_err.sum()
return c11, c01, c10, float(total)
def _allowed_map(bg, c11, c01, c10):
"""(H, W, 4) per-pixel allowed palette indices."""
yy, xx = np.indices((HEIGHT, WIDTH))
ci = (yy // CELL_H) * N_COLS + (xx // CELL_W)
r = yy % CELL_H
allowed = np.empty((HEIGHT, WIDTH, 4), dtype=np.int64)
allowed[:, :, 0] = bg
allowed[:, :, 1] = c11[ci]
allowed[:, :, 2] = c01[ci, r]
allowed[:, :, 3] = c10[ci, r]
return allowed
def _quantize(img_lab, plab, bg, c11, c01, c10, dither_mode):
allowed = _allowed_map(bg, c11, c01, c10)
return dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
def _encode(index_image, bg, c11, c01, c10):
bitmap = np.zeros(8000, dtype=np.uint8)
screens = [np.zeros(1000, dtype=np.uint8) for _ in range(8)]
colram = np.zeros(1000, dtype=np.uint8)
for cr in range(N_ROWS):
for cc in range(N_COLS):
ci = cr * N_COLS + cc
colram[ci] = c11[ci] & 0x0F
base_addr = cr * 320 + cc * 8
for r in range(8):
a01, a10 = int(c01[ci, r]), int(c10[ci, r])
screens[r][ci] = ((a01 & 0x0F) << 4) | (a10 & 0x0F)
lut = {int(bg): 0b00, int(c11[ci]): 0b11, a01: 0b01, a10: 0b10}
row = index_image[cr * 8 + r, cc * 4:cc * 4 + 4]
byte = 0
for x in range(4):
byte = (byte << 2) | lut.get(int(row[x]), 0b00)
bitmap[base_addr + r] = byte
block = bytearray()
for r in range(8):
block += bytes(screens[r]) + bytes(24) # pad each screen to 1K
block += bytes(bitmap) # $6000
block += bytes(0x8000 - (0x6000 + 8000)) # pad to $8000
block += bytes(colram) # $8000
block += bytes([int(bg) & 0x0F]) # $83E8
return bytes(block)

64
c64view/convert/hires.py Normal file
View file

@ -0,0 +1,64 @@
"""Hires bitmap mode: 320x200, two colours per 8x8 cell.
Data file layout (PRG, load $2000), matched to viewer/hires.s:
$2000 bitmap 8000 bytes (VIC reads here directly)
$3F40 screen RAM 1000 bytes (viewer copies to $0400)
"""
from __future__ import annotations
import numpy as np
from .. import dither, palette as pal
from . import base
WIDTH, HEIGHT = 320, 200
CELL_W, CELL_H = 8, 8
PIXEL_ASPECT = 1.0
DATA_LOAD = 0x2000
def convert(img_rgb: np.ndarray, palette_name="colodore",
dither_mode="bayer", intensive=False) -> base.Conversion:
plab = pal.palette_lab(palette_name)
img_lab = pal.srgb_to_lab(img_rgb)
cells, rows, cols = base.cells_lab(img_lab, CELL_W, CELL_H)
dist = base.cell_distance(cells, plab)
sets, _ = base.select_cell_sets(dist, range(16), n_free=2)
allowed = base.per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH)
index_image = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
bitmap, screen = _encode(index_image, sets, rows, cols)
payload = bytes(bitmap) + bytes(screen)
conv = base.Conversion(
mode="hires", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=index_image, data=payload, viewer="hires",
error=base.mean_error(index_image, img_lab, plab),
meta={"palette": palette_name, "dither": dither_mode},
)
# Standard OCP Art Studio hires file (load $2000): bitmap, screen, border.
conv.extra_files = [("picture.art", base.prg(0x2000, payload + b"\x00"))]
return conv
def _encode(index_image, sets, rows, cols):
"""Build the 8000-byte bitmap and 1000-byte screen RAM."""
bitmap = np.zeros(8000, dtype=np.uint8)
screen = np.zeros(1000, dtype=np.uint8)
for cr in range(rows):
for cc in range(cols):
ci = cr * cols + cc
bg_col, fg_col = int(sets[ci, 0]), int(sets[ci, 1])
screen[ci] = ((fg_col & 0x0F) << 4) | (bg_col & 0x0F)
base_addr = cr * 320 + cc * 8
block = index_image[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8]
for r in range(8):
row = block[r]
byte = 0
for x in range(8):
byte = (byte << 1) | (1 if row[x] == fg_col else 0)
bitmap[base_addr + r] = byte
return bitmap, screen

151
c64view/convert/ifli.py Normal file
View file

@ -0,0 +1,151 @@
"""Interlace mode: two multicolor frames shown on alternating fields (50Hz each).
The eye averages the two frames, so each pixel can show the blend of its colour
in frame A and frame B -- up to ~136 distinct apparent colours (16 base + 120
pairs). Frame A is an ordinary multicolor conversion; frame B targets the
*residual* (2*target - A) so that (A+B)/2 reconstructs the original. Both frames
share the global background and the colour-RAM colour per cell (the only VIC state
the viewer cannot cheaply swap per frame), and differ in bitmap + screen RAM.
Memory layout of the appended data (loads from $2000), matched to viewer/interlace.s:
$2000 bitmap A 8000 (bank 0, VIC reads here)
$3F40 screen A 1000 (copied to $0400)
$4400 screen B 1000 (bank 1 video matrix, in place)
$6000 bitmap B 8000 (bank 1, VIC reads here)
$8000 colour RAM 1000 (shared, copied to $D800)
$83E8 background 1
"""
from __future__ import annotations
from itertools import combinations
import numpy as np
from .. import dither, palette as pal
from . import base
WIDTH, HEIGHT = 160, 200
CELL_W, CELL_H = 4, 8
PIXEL_ASPECT = 2.0
DATA_ADDR = 0x2000
N_COLS, N_ROWS = 40, 25
N_CELLS = N_COLS * N_ROWS
def convert(img_rgb, palette_name="colodore", dither_mode="bayer", intensive=False):
plab = pal.palette_lab(palette_name)
prgb = pal.get_palette(palette_name)
img_lab = pal.srgb_to_lab(img_rgb)
# ---- frame A: ordinary multicolor (bg + 3 free per cell) ----
cellsA, _, _ = base.cells_lab(img_lab, CELL_W, CELL_H)
distA = base.cell_distance(cellsA, plab)
if intensive:
bg, setsA, _ = base.optimize_background(distA, n_free=3)
else:
bg = base.best_global_color(img_lab, plab)
avail = [i for i in range(16) if i != bg]
setsA, _ = base.select_cell_sets(distA, avail, n_free=3, fixed=(bg,))
# colour-RAM colour (shared by both frames) = third free colour of A.
c11 = setsA[:, 3].astype(np.int64)
allowedA = base.per_pixel_allowed(setsA, N_ROWS, N_COLS, CELL_W, CELL_H, HEIGHT, WIDTH)
idxA = dither.quantize(img_lab, allowedA, plab, dither_mode).astype(np.uint8)
# ---- frame B: match residual 2*target - A in linear light ----
lin_target = pal.srgb_to_linear(img_rgb)
lin_A = pal.srgb_to_linear(prgb[idxA])
resid = np.clip(2.0 * lin_target - lin_A, 0.0, 1.0)
resid_srgb = pal.linear_to_srgb(resid)
resid_lab = pal.srgb_to_lab(resid_srgb)
setsB = _solve_frameB(resid_lab, plab, bg, c11)
allowedB = base.per_pixel_allowed(setsB, N_ROWS, N_COLS, CELL_W, CELL_H, HEIGHT, WIDTH)
idxB = dither.quantize(resid_lab, allowedB, plab, dither_mode).astype(np.uint8)
# ---- blended preview (linear average -> sRGB, widened to display aspect) ----
blend_lin = (pal.srgb_to_linear(prgb[idxA]) + pal.srgb_to_linear(prgb[idxB])) / 2.0
blend = pal.linear_to_srgb(blend_lin)
preview = np.repeat(blend, int(round(PIXEL_ASPECT)), axis=1)
blend_lab = pal.srgb_to_lab(blend)
error = float(np.sqrt(np.sum((blend_lab - img_lab) ** 2, axis=-1)).mean())
data = _encode(idxA, idxB, setsA, setsB, bg, c11)
return base.Conversion(
mode="interlace", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idxA, data=data, data_addr=DATA_ADDR, viewer="interlace",
preview_rgb=preview, error=error,
meta={"palette": palette_name, "dither": dither_mode, "background": int(bg)},
)
def _solve_frameB(resid_lab, plab, bg, c11):
"""Per cell, pick the 2 free colours for frame B given shared {bg, c11[cell]}."""
cells, _, _ = base.cells_lab(resid_lab, CELL_W, CELL_H)
dist = base.cell_distance(cells, plab) # (n, P, 16)
dbg = dist[:, :, bg] # (n, P)
dc11 = np.take_along_axis(dist, c11[:, None, None], axis=2)[:, :, 0]
sbase = np.minimum(dbg, dc11)
n = dist.shape[0]
best = np.full(n, np.inf)
b1 = np.zeros(n, dtype=np.int64)
b2 = np.zeros(n, dtype=np.int64)
for x, y in combinations(range(16), 2):
e = np.minimum(np.minimum(sbase, dist[:, :, x]), dist[:, :, y]).sum(axis=1)
better = e < best
best = np.where(better, e, best)
b1 = np.where(better, x, b1)
b2 = np.where(better, y, b2)
bg_arr = np.full(n, bg, dtype=np.int64)
return np.stack([bg_arr, b1, b2, c11], axis=1)
def _pack_frame(index_image, screen_assign, colram_assign, bg, get_lut):
"""Build (bitmap, screen) for one frame. ``get_lut`` maps cell index -> dict."""
bitmap = np.zeros(8000, dtype=np.uint8)
screen = np.zeros(1000, dtype=np.uint8)
for cr in range(N_ROWS):
for cc in range(N_COLS):
ci = cr * N_COLS + cc
hi, lo, lut = get_lut(ci)
screen[ci] = ((hi & 0x0F) << 4) | (lo & 0x0F)
base_addr = cr * 320 + cc * 8
block = index_image[cr * 8:cr * 8 + 8, cc * 4:cc * 4 + 4]
for r in range(8):
byte = 0
for x in range(4):
byte = (byte << 2) | lut.get(int(block[r, x]), 0b00)
bitmap[base_addr + r] = byte
return bitmap, screen
def _encode(idxA, idxB, setsA, setsB, bg, c11):
def lutA(ci):
cc11 = int(c11[ci])
a01, a10 = int(setsA[ci, 1]), int(setsA[ci, 2])
return a01, a10, {int(bg): 0b00, a01: 0b01, a10: 0b10, cc11: 0b11}
def lutB(ci):
cc11 = int(c11[ci])
b01, b10 = int(setsB[ci, 1]), int(setsB[ci, 2])
return b01, b10, {int(bg): 0b00, b01: 0b01, b10: 0b10, cc11: 0b11}
bitmapA, screenA = _pack_frame(idxA, None, None, bg, lutA)
bitmapB, screenB = _pack_frame(idxB, None, None, bg, lutB)
colram = (c11 & 0x0F).astype(np.uint8)
block = bytearray()
block += bytes(bitmapA) # $2000
block += bytes(screenA) # $3F40
block += bytes(0x4400 - (0x3F40 + 1000)) # pad to $4400
block += bytes(screenB) # $4400
block += bytes(0x6000 - (0x4400 + 1000)) # pad to $6000
block += bytes(bitmapB) # $6000
block += bytes(0x8000 - (0x6000 + 8000)) # pad to $8000
block += bytes(colram) # $8000
block += bytes([int(bg) & 0x0F]) # $83E8
return bytes(block)

77
c64view/convert/mono.py Normal file
View file

@ -0,0 +1,77 @@
"""Monochrome / grayscale mode -- the highest-resolution path.
Renders at hires (320x200) but matches the image by *luminance* to a small ramp
of palette colours, so detail is carried entirely by spatial dithering. With the
grayscale ramp (black -> dark grey -> grey -> light grey -> white) this gives a
proper greyscale photo; pick any base colour and the ramp becomes that hue's
shades (e.g. black -> blue -> light blue -> white) for a tinted monochrome.
Output is ordinary hires-format data, so it reuses the hires viewer.
"""
from __future__ import annotations
import numpy as np
from .. import dither, palette as pal
from . import base, hires
WIDTH, HEIGHT = 320, 200
CELL_W, CELL_H = 8, 8
PIXEL_ASPECT = 1.0
DATA_LOAD = 0x2000
# Luminance-ordered grey ramp: black, dark grey, grey, light grey, white.
GRAY_RAMP = [0, 11, 12, 15, 1]
# A few palette colours have a lighter sibling, giving a richer tinted ramp.
SIBLINGS = {2: 10, 10: 2, 5: 13, 13: 5, 6: 14, 14: 6, 8: 9, 9: 8}
def build_ramp(base_color, plab):
"""Return palette indices (luminance-sorted) used to render the image."""
if base_color is None or base_color in (0, 1, 11, 12, 15):
ramp = list(GRAY_RAMP)
else:
ramp = {0, 1, base_color}
if base_color in SIBLINGS:
ramp.add(SIBLINGS[base_color])
ramp = list(ramp)
ramp.sort(key=lambda i: plab[i, 0]) # by Lab lightness
return ramp
def convert(img_rgb, palette_name="colodore", dither_mode="floyd",
intensive=False, base_color=None):
plab = pal.palette_lab(palette_name)
# Work purely in luminance: collapse image and palette to (L, 0, 0).
L_pix = pal.srgb_to_lab(img_rgb)[..., 0]
img_mono = np.zeros((HEIGHT, WIDTH, 3))
img_mono[..., 0] = L_pix
plab_mono = np.zeros((16, 3))
plab_mono[:, 0] = plab[:, 0]
ramp = build_ramp(base_color, plab)
n_free = min(2, len(ramp))
cells, rows, cols = base.cells_lab(img_mono, CELL_W, CELL_H)
dist = base.cell_distance(cells, plab_mono)
sets, _ = base.select_cell_sets(dist, ramp, n_free=n_free)
if n_free == 1: # pad to 2 colours per cell for hires
sets = np.concatenate([sets, sets], axis=1)
allowed = base.per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH)
index_image = dither.quantize(img_mono, allowed, plab_mono, dither_mode).astype(np.uint8)
bitmap, screen = hires._encode(index_image, sets, rows, cols)
payload = bytes(bitmap) + bytes(screen)
conv = base.Conversion(
mode="mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=index_image, data=payload, data_addr=DATA_LOAD, viewer="hires",
error=base.mean_error(index_image, img_mono, plab_mono),
meta={"palette": palette_name, "dither": dither_mode,
"base_color": base_color, "ramp": ramp},
)
conv.extra_files = [("picture.art", base.prg(0x2000, payload + b"\x00"))]
return conv

View file

@ -0,0 +1,80 @@
"""Multicolor bitmap mode ("Koala"): 160x200, one shared background plus three
freely chosen colours per 4x8 cell.
Data file layout (PRG, load $2000), matched to viewer/multicolor.s:
$2000 bitmap 8000 bytes (VIC reads here directly)
$3F40 screen RAM 1000 bytes (viewer copies to $0400)
$4328 colour RAM 1000 bytes (viewer copies to $D800)
$4710 background 1 byte (viewer writes to $D021)
"""
from __future__ import annotations
import numpy as np
from .. import dither, palette as pal
from . import base
WIDTH, HEIGHT = 160, 200
CELL_W, CELL_H = 4, 8
PIXEL_ASPECT = 2.0
DATA_LOAD = 0x2000
# bit-pair -> colour source: 01 screen hi nibble, 10 screen lo nibble, 11 colour RAM
_SLOT_BITS = {1: 0b01, 2: 0b10, 3: 0b11}
def convert(img_rgb: np.ndarray, palette_name="colodore",
dither_mode="bayer", intensive=False) -> base.Conversion:
plab = pal.palette_lab(palette_name)
img_lab = pal.srgb_to_lab(img_rgb)
cells, rows, cols = base.cells_lab(img_lab, CELL_W, CELL_H)
dist = base.cell_distance(cells, plab)
if intensive:
bg, sets, _ = base.optimize_background(dist, n_free=3)
else:
bg = base.best_global_color(img_lab, plab)
avail = [i for i in range(16) if i != bg]
sets, _ = base.select_cell_sets(dist, avail, n_free=3, fixed=(bg,))
allowed = base.per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH)
index_image = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
bitmap, screen, colram = _encode(index_image, sets, bg, rows, cols)
# This block also *is* a Koala body: bitmap, screen, colram, background.
payload = bytes(bitmap) + bytes(screen) + bytes(colram) + bytes([bg])
conv = base.Conversion(
mode="multicolor", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=index_image, data=payload, viewer="multicolor",
error=base.mean_error(index_image, img_lab, plab),
meta={"palette": palette_name, "dither": dither_mode, "background": bg},
)
# Standard "Koala Painter" file (load $6000) for use in other C64 art tools.
conv.extra_files = [("picture.koa", base.prg(0x6000, payload))]
return conv
def _encode(index_image, sets, bg, rows, cols):
bitmap = np.zeros(8000, dtype=np.uint8)
screen = np.zeros(1000, dtype=np.uint8)
colram = np.zeros(1000, dtype=np.uint8)
for cr in range(rows):
for cc in range(cols):
ci = cr * cols + cc
# sets[ci] = [bg, c1, c2, c3]; assign the three non-bg colours to slots.
c1, c2, c3 = int(sets[ci, 1]), int(sets[ci, 2]), int(sets[ci, 3])
screen[ci] = ((c1 & 0x0F) << 4) | (c2 & 0x0F)
colram[ci] = c3 & 0x0F
color_to_bits = {bg: 0b00, c1: 0b01, c2: 0b10, c3: 0b11}
base_addr = cr * 320 + cc * 8
block = index_image[cr * 8:cr * 8 + 8, cc * 4:cc * 4 + 4]
for r in range(8):
row = block[r]
byte = 0
for x in range(4):
byte = (byte << 2) | color_to_bits.get(int(row[x]), 0b00)
bitmap[base_addr + r] = byte
return bitmap, screen, colram

145
c64view/diskimage.py Normal file
View file

@ -0,0 +1,145 @@
"""Build .d64/.d71/.d81 Commodore disk images with VICE's `c1541`.
We shell out to `c1541` rather than re-implementing CBM-DOS: it is battle-tested
and handles BAM/directory layout for all three formats correctly.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
FORMATS = {
"d64": ("d64", 174848), # 35 tracks, 664 blocks free
"d71": ("d71", 349696), # double sided
"d81": ("d81", 819200), # 3.5" 800K
}
# Per-format usable data budget (blocks * 254), conservative.
BLOCKS_FREE = {"d64": 664, "d71": 1328, "d81": 3160}
class DiskError(RuntimeError):
pass
def have_c1541() -> bool:
return shutil.which("c1541") is not None
# x64sc is the accurate C64 emulator; x64 is the faster fallback.
VICE_EMULATORS = ["x64sc", "x64"]
def vice_emulator() -> str | None:
for exe in VICE_EMULATORS:
path = shutil.which(exe)
if path:
return path
return None
def have_vice() -> bool:
return vice_emulator() is not None
def launch_in_vice(disk_path: str, warp: bool = True, standard: str = "pal"):
"""Open VICE on ``disk_path`` (drive 8), list the directory, then run the viewer.
Types ``LOAD"$",8`` / ``LIST`` / ``LOAD"*",8,1`` / ``RUN`` via the keyboard
buffer. BASIC commands must be *lower* case here: VICE maps lower-case ASCII
to the PETSCII keyword range, and "\\n" is the RETURN key. Runs detached so
the GUI stays responsive. ``warp`` should be False for the interlace mode,
whose 50 Hz field-flip flickers too fast under warp.
"""
exe = vice_emulator()
if not exe:
raise DiskError(
"VICE (x64sc) was not found on PATH.\n"
"Install it with: sudo apt install vice (Debian/Ubuntu)")
keys = 'load"$",8\nlist\nload"*",8,1\nrun\n'
# -default keeps the device config predictable so LOAD"*" reads the attached
# image rather than a host-filesystem virtual device; -warp runs full speed.
cmd = [exe, "-default", "-ntsc" if standard == "ntsc" else "-pal"]
if warp:
cmd.append("-warp")
cmd += ["-8", os.path.abspath(disk_path), "-keybuf", keys]
return subprocess.Popen(cmd, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
def petscii_name(text: str, maxlen: int = 16) -> str:
"""Sanitise a host string into a legal CBM filename.
Letters are forced to lower case: c1541 maps lower-case ASCII into the
standard PETSCII letter range ($41-$5A), which renders as clean letters in a
C64 directory listing, whereas upper-case ASCII lands in the shifted range
that displays oddly.
"""
out = []
for ch in text.lower():
if ch.isalnum() or ch in " -+.":
out.append(ch)
name = "".join(out).strip() or "c64view"
return name[:maxlen]
def fmt_from_path(path: str, override: str | None) -> str:
if override:
if override not in FORMATS:
raise DiskError(f"unknown disk format: {override}")
return override
ext = os.path.splitext(path)[1].lower().lstrip(".")
return ext if ext in FORMATS else "d64"
def build_disk(path: str, disk_format: str, disk_name: str, disk_id: str,
files: list[tuple[str, bytes]]) -> str:
"""Create ``path`` of ``disk_format`` containing ``files`` (cbm_name, prg_bytes).
The first file in the list lands first in the directory, so ``LOAD"*",8,1``
loads it -- make that the viewer.
"""
if not have_c1541():
raise DiskError(
"VICE's 'c1541' tool was not found on PATH.\n"
"Install it with: sudo apt install vice (Debian/Ubuntu)\n"
"or build from https://vice-emu.sourceforge.io/")
total = sum(len(b) for _, b in files)
budget = BLOCKS_FREE[disk_format] * 254
if total > budget:
raise DiskError(
f"data ({total} bytes) exceeds {disk_format} capacity (~{budget} bytes); "
f"use a larger disk format")
vtype = FORMATS[disk_format][0]
name = petscii_name(disk_name)
did = petscii_name(disk_id, 2) or "01"
path = os.path.abspath(path)
if os.path.exists(path):
os.remove(path)
with tempfile.TemporaryDirectory() as td:
cmd = ["c1541", "-format", f"{name},{did}", vtype, path]
for i, (cbm, data) in enumerate(files):
host = os.path.join(td, f"f{i}.prg")
with open(host, "wb") as f:
f.write(data)
cmd += ["-write", host, petscii_name(cbm)]
proc = subprocess.run(cmd, capture_output=True, text=True,
errors="replace")
# c1541 prints a harmless "OPENCBM ... libopencbm.so" warning; only fail
# if the image was not actually produced.
if not os.path.exists(path):
raise DiskError(f"c1541 failed:\n{proc.stdout}{proc.stderr}")
return path
def directory(path: str) -> str:
"""Return the disk directory listing (for verification)."""
proc = subprocess.run(["c1541", "-attach", path, "-dir"],
capture_output=True, text=True, errors="replace")
return proc.stdout

136
c64view/dither.py Normal file
View file

@ -0,0 +1,136 @@
"""Palette-constrained dithering.
Every routine takes the working image in CIELAB plus a per-pixel table of the
palette indices that pixel is *allowed* to use (because the VIC-II only lets a
given screen cell show a small set of colours), and returns an (H,W) image of
chosen palette indices (0..15). Because the allowed set is per-pixel, error that
diffuses across a cell boundary is automatically re-clamped to the neighbour
cell's own colours -- exactly the constraint real C64 hardware imposes.
"""
from __future__ import annotations
import numpy as np
DITHER_MODES = ["bayer", "floyd", "atkinson", "stucki", "jarvis", "none"]
def bayer_matrix(n: int) -> np.ndarray:
"""Normalised (0..1) Bayer threshold matrix of size n x n (n power of two)."""
if n == 1:
return np.array([[0.0]])
smaller = bayer_matrix(n // 2)
m = np.block([
[4 * smaller + 0, 4 * smaller + 2],
[4 * smaller + 3, 4 * smaller + 1],
])
return m / (n * n)
def _gather_colors(palette_lab: np.ndarray, allowed: np.ndarray) -> np.ndarray:
# allowed: (H,W,K) palette indices -> (H,W,K,3) Lab
return palette_lab[allowed]
def quantize_ordered(img_lab, allowed, palette_lab, strength=1.0, n=8):
"""Ordered (Bayer) dithering between the two best colours of each pixel's set.
For every pixel we find its nearest and second-nearest allowed colour, project
the pixel onto the segment between them, and use the Bayer threshold to decide
which of the two to emit -- giving smooth ordered blends without ever leaving
the cell's legal colour set.
"""
H, W, _ = img_lab.shape
colors = _gather_colors(palette_lab, allowed) # (H,W,K,3)
d = np.sum((img_lab[:, :, None, :] - colors) ** 2, axis=-1) # (H,W,K)
i1 = np.argmin(d, axis=-1)
d2 = np.array(d)
np.put_along_axis(d2, i1[..., None], np.inf, axis=-1)
i2 = np.argmin(d2, axis=-1)
yy, xx = np.indices((H, W))
c1 = colors[yy, xx, i1] # (H,W,3)
c2 = colors[yy, xx, i2]
seg = c2 - c1
seg_len2 = np.sum(seg * seg, axis=-1) + 1e-9
t = np.sum((img_lab - c1) * seg, axis=-1) / seg_len2 # projection 0..1
t = np.clip(t * strength, 0.0, 1.0)
thr = bayer_matrix(n)
thr_full = thr[yy % n, xx % n]
pick2 = t > thr_full
chosen = np.where(pick2, i2, i1)
return np.take_along_axis(allowed, chosen[..., None], axis=-1)[..., 0]
def _quantize_diffusion(img_lab, allowed, palette_lab, kernel, divisor):
"""Generic serpentine error-diffusion constrained to per-pixel allowed sets."""
H, W, _ = img_lab.shape
work = img_lab.astype(np.float64).copy()
out = np.zeros((H, W), dtype=np.int64)
pal = palette_lab
for y in range(H):
cols = range(W) if (y % 2 == 0) else range(W - 1, -1, -1)
flip = 1 if (y % 2 == 0) else -1
for x in cols:
allow = allowed[y, x]
cand = pal[allow]
diff = cand - work[y, x]
k = int(allow[np.argmin(np.sum(diff * diff, axis=-1))])
out[y, x] = k
err = work[y, x] - pal[k]
for dx, dy, w in kernel:
nx, ny = x + dx * flip, y + dy
if 0 <= nx < W and 0 <= ny < H:
work[ny, nx] += err * (w / divisor)
return out
# (dx, dy, weight) relative to current pixel, assuming left-to-right scan.
_FLOYD = [(1, 0, 7), (-1, 1, 3), (0, 1, 5), (1, 1, 1)]
_ATKINSON = [(1, 0, 1), (2, 0, 1), (-1, 1, 1), (0, 1, 1), (1, 1, 1), (0, 2, 1)]
# Larger kernels spread error further -> smoother gradients (best for grayscale).
_STUCKI = [(1, 0, 8), (2, 0, 4),
(-2, 1, 2), (-1, 1, 4), (0, 1, 8), (1, 1, 4), (2, 1, 2),
(-2, 2, 1), (-1, 2, 2), (0, 2, 4), (1, 2, 2), (2, 2, 1)]
_JARVIS = [(1, 0, 7), (2, 0, 5),
(-2, 1, 3), (-1, 1, 5), (0, 1, 7), (1, 1, 5), (2, 1, 3),
(-2, 2, 1), (-1, 2, 3), (0, 2, 5), (1, 2, 3), (2, 2, 1)]
def quantize_floyd(img_lab, allowed, palette_lab):
return _quantize_diffusion(img_lab, allowed, palette_lab, _FLOYD, 16)
def quantize_atkinson(img_lab, allowed, palette_lab):
return _quantize_diffusion(img_lab, allowed, palette_lab, _ATKINSON, 8)
def quantize_stucki(img_lab, allowed, palette_lab):
return _quantize_diffusion(img_lab, allowed, palette_lab, _STUCKI, 42)
def quantize_jarvis(img_lab, allowed, palette_lab):
return _quantize_diffusion(img_lab, allowed, palette_lab, _JARVIS, 48)
def quantize_none(img_lab, allowed, palette_lab):
colors = _gather_colors(palette_lab, allowed)
d = np.sum((img_lab[:, :, None, :] - colors) ** 2, axis=-1)
i1 = np.argmin(d, axis=-1)
return np.take_along_axis(allowed, i1[..., None], axis=-1)[..., 0]
def quantize(img_lab, allowed, palette_lab, mode="bayer"):
if mode == "bayer":
return quantize_ordered(img_lab, allowed, palette_lab)
if mode == "floyd":
return quantize_floyd(img_lab, allowed, palette_lab)
if mode == "atkinson":
return quantize_atkinson(img_lab, allowed, palette_lab)
if mode == "stucki":
return quantize_stucki(img_lab, allowed, palette_lab)
if mode == "jarvis":
return quantize_jarvis(img_lab, allowed, palette_lab)
return quantize_none(img_lab, allowed, palette_lab)

51
c64view/exporter.py Normal file
View file

@ -0,0 +1,51 @@
"""Orchestrate: converted image -> self-contained viewer -> Commodore disk image."""
from __future__ import annotations
import os
from . import basicgen, diskimage, imginfo
from .convert.base import Conversion
from .viewer.assemble import SOURCES, build_viewer_prg
def export_disk(conv: Conversion, output_path: str,
disk_format: str | None = None,
disk_name: str | None = None,
include_graphic_file: bool = True,
source_path: str | None = None,
video: str = "pal") -> str:
"""Write ``conv`` to a disk image at ``output_path``.
The disk's first (bootable) file is a self-contained viewer that already
embeds the picture, so ``LOAD"*",8,1`` then ``RUN`` displays it. When
``include_graphic_file`` is set, the picture is also written in a standard
interchange format (e.g. Koala) for use in other C64 art tools. When
``source_path`` is given and there is room, a colourful BASIC "info" program
describing the original image is added too. Returns the absolute path written.
"""
fmt = diskimage.fmt_from_path(output_path, disk_format)
stem = os.path.splitext(os.path.basename(output_path))[0]
name = diskimage.petscii_name(disk_name or stem or "c64view")
# Timing-sensitive viewers (FLI) have an NTSC variant; others run on both.
vkey = conv.viewer
if video == "ntsc" and f"{conv.viewer}_ntsc" in SOURCES:
vkey = f"{conv.viewer}_ntsc"
viewer_prg = build_viewer_prg(vkey, conv.data, conv.data_addr)
files: list[tuple[str, bytes]] = [(name, viewer_prg)]
if include_graphic_file:
files.extend(conv.extra_files)
if source_path:
# Best effort: never let the info file break a successful export.
try:
info = basicgen.build_info_prg(imginfo.gather(source_path))
budget = diskimage.BLOCKS_FREE[fmt] * 254
info_name = "info" if name != "info" else "picinfo"
if sum(len(b) for _, b in files) + len(info) <= budget:
files.append((info_name, info))
except Exception:
pass
return diskimage.build_disk(output_path, fmt, name, "01", files)

29
c64view/gallery.py Normal file
View file

@ -0,0 +1,29 @@
"""Render every Mode x Palette x Dither variation of an image, for the GUI's
"Explore variations" contact sheet. Kept import-light (no Qt) so it can run in
ProcessPoolExecutor worker processes.
"""
from __future__ import annotations
from . import imageprep
from .convert import MODES, render_preview, convert_image
PALETTES = ["colodore", "pepto"]
DITHERS = ["bayer", "floyd", "atkinson", "none"]
# (mode, palette, dither) for every concrete mode (auto is excluded on purpose).
COMBOS = [(m, p, d) for m in MODES for p in PALETTES for d in DITHERS]
def render_variation(args):
"""Worker entry point. ``args`` = (path, mode, palette, dither, prep_kwargs).
Returns (mode, palette, dither, error, rgb) where rgb is the displayed-
resolution (320x200) preview as a uint8 HxWx3 array.
"""
path, mode, palette, dither, prep_kwargs = args
prep = imageprep.PrepOptions(**prep_kwargs)
conv = convert_image(path, mode=mode, palette_name=palette,
dither_mode=dither, intensive=False, prep_opt=prep)
rgb = render_preview(conv, palette, scale=1)
return (mode, palette, dither, conv.error, rgb)

490
c64view/gui.py Normal file
View file

@ -0,0 +1,490 @@
"""PyQt5 GUI: open an image, tune the conversion with a live C64 preview, export a disk image."""
from __future__ import annotations
import os
import sys
import traceback
import numpy as np
from PyQt5 import QtCore, QtGui, QtWidgets
from . import gallery, imageprep
from .convert import MODES, convert_image, render_preview
from .diskimage import FORMATS, have_c1541, have_vice, launch_in_vice
from .exporter import export_disk
from .palette import COLOR_NAMES
from .viewer.assemble import have_xa
MODE_CHOICES = ["auto", *MODES]
DITHER_CHOICES = ["bayer", "floyd", "atkinson", "stucki", "jarvis", "none"]
PALETTE_CHOICES = ["colodore", "pepto"]
ASPECT_CHOICES = ["fit", "fill", "stretch"]
BASE_CHOICES = ["grayscale", *COLOR_NAMES] # for mono mode
def numpy_to_pixmap(rgb: np.ndarray) -> QtGui.QPixmap:
rgb = np.ascontiguousarray(rgb)
h, w, _ = rgb.shape
img = QtGui.QImage(rgb.data, w, h, 3 * w, QtGui.QImage.Format_RGB888)
return QtGui.QPixmap.fromImage(img.copy())
class ConvertWorker(QtCore.QThread):
"""Runs one conversion off the UI thread."""
done = QtCore.pyqtSignal(object) # Conversion
failed = QtCore.pyqtSignal(str)
def __init__(self, path, params):
super().__init__()
self.path = path
self.params = params
def run(self):
try:
p = self.params
prep = imageprep.PrepOptions(
aspect=p["aspect"], brightness=p["brightness"],
contrast=p["contrast"], saturation=p["saturation"], gamma=p["gamma"],
)
conv = convert_image(
self.path, mode=p["mode"], palette_name=p["palette"],
dither_mode=p["dither"], intensive=p["intensive"], prep_opt=prep,
base_color=p["base_color"],
)
self.done.emit(conv)
except Exception:
self.failed.emit(traceback.format_exc())
class GalleryWorker(QtCore.QThread):
"""Renders every Mode x Palette x Dither variation across CPU cores."""
result = QtCore.pyqtSignal(int, str, str, str, float, object)
progress = QtCore.pyqtSignal(int, int)
finished_all = QtCore.pyqtSignal()
def __init__(self, path, prep_kwargs):
super().__init__()
self.path = path
self.prep_kwargs = prep_kwargs
self._cancel = False
def cancel(self):
self._cancel = True
def run(self):
from concurrent.futures import ProcessPoolExecutor, as_completed
combos = gallery.COMBOS
args = [(self.path, m, p, d, self.prep_kwargs) for (m, p, d) in combos]
total = len(combos)
done = 0
try:
with ProcessPoolExecutor() as ex:
futs = {ex.submit(gallery.render_variation, a): i
for i, a in enumerate(args)}
for fut in as_completed(futs):
if self._cancel:
ex.shutdown(wait=False, cancel_futures=True)
break
i = futs[fut]
try:
m, p, d, err, rgb = fut.result()
except Exception:
continue
done += 1
self.result.emit(i, m, p, d, err, rgb)
self.progress.emit(done, total)
finally:
self.finished_all.emit()
class VariationsDialog(QtWidgets.QDialog):
"""Contact sheet of every Mode x Palette x Dither combination to pick from."""
def __init__(self, path, prep_kwargs, parent=None, cached=None):
super().__init__(parent)
self.setWindowTitle("Explore variations -- pick the best looking one")
self.resize(940, 680)
self.choice = None
self.cells = {}
self.best = (float("inf"), None)
self.collected = {} # index -> (m, p, d, err, rgb)
self.worker = None
outer = QtWidgets.QVBoxLayout(self)
self.info = QtWidgets.QLabel("Rendering variations... click any thumbnail to choose it")
outer.addWidget(self.info)
scroll = QtWidgets.QScrollArea()
scroll.setWidgetResizable(True)
inner = QtWidgets.QWidget()
self.grid = QtWidgets.QGridLayout(inner)
scroll.setWidget(inner)
outer.addWidget(scroll, 1)
cols = 4
for i, (m, p, d) in enumerate(gallery.COMBOS):
btn = QtWidgets.QToolButton()
btn.setToolButtonStyle(QtCore.Qt.ToolButtonTextUnderIcon)
btn.setIconSize(QtCore.QSize(200, 125))
btn.setText(f"{m} - {p} - {d}\n(rendering...)")
btn.setEnabled(False)
btn.clicked.connect(lambda _checked, idx=i: self._choose(idx))
self.cells[i] = btn
self.grid.addWidget(btn, i // cols, i % cols)
btns = QtWidgets.QHBoxLayout()
btns.addStretch(1)
cancel = QtWidgets.QPushButton("Cancel")
cancel.clicked.connect(self.reject)
btns.addWidget(cancel)
outer.addLayout(btns)
if cached and len(cached) == len(gallery.COMBOS):
for i, vals in cached.items():
self._populate(i, *vals)
self._on_done() # show "best" marker + final message
else:
self.worker = GalleryWorker(path, prep_kwargs)
self.worker.result.connect(self._on_result)
self.worker.progress.connect(self._on_progress)
self.worker.finished_all.connect(self._on_done)
self.worker.start()
def _populate(self, i, m, p, d, err, rgb):
btn = self.cells[i]
pm = numpy_to_pixmap(rgb).scaled(200, 125, QtCore.Qt.KeepAspectRatio,
QtCore.Qt.SmoothTransformation)
btn.setIcon(QtGui.QIcon(pm))
btn.setText(f"{m} - {p} - {d}\ndE {err:.1f}")
btn.setEnabled(True)
btn.setProperty("combo", (m, p, d))
self.collected[i] = (m, p, d, err, rgb)
if err < self.best[0]:
self.best = (err, i)
def _on_result(self, i, m, p, d, err, rgb):
self._populate(i, m, p, d, err, rgb)
def _on_progress(self, done, total):
self.info.setText(f"Rendering variations... {done}/{total} "
f"-- click any thumbnail to choose it")
def _on_done(self):
msg = "Click the variation you like best."
if self.best[1] is not None:
m, p, d = self.cells[self.best[1]].property("combo")
self.cells[self.best[1]].setText(
self.cells[self.best[1]].text() + " *best*")
msg += f" (lowest error: {m} / {p} / {d})"
self.info.setText(msg)
def _choose(self, i):
self.choice = self.cells[i].property("combo")
self.accept()
def reject(self):
if self.worker:
self.worker.cancel()
self.worker.wait(2000)
super().reject()
class MainWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("c64view -- image to Commodore 64 disk")
self.resize(980, 620)
self.source_path = None
self.last_conv = None
self.worker = None
self.pending = False
self._gallery_key = None # cache key for Explore variations
self._gallery_results = None # cached {index: (m, p, d, err, rgb)}
self._build_ui()
self._check_tools()
# ---- UI construction ----
def _build_ui(self):
central = QtWidgets.QWidget()
self.setCentralWidget(central)
root = QtWidgets.QHBoxLayout(central)
# previews
previews = QtWidgets.QVBoxLayout()
self.src_label = self._make_preview("Original")
self.c64_label = self._make_preview("C64 preview")
previews.addWidget(self.src_label["box"])
previews.addWidget(self.c64_label["box"])
root.addLayout(previews, 3)
# controls
panel = QtWidgets.QVBoxLayout()
root.addLayout(panel, 1)
open_btn = QtWidgets.QPushButton("Open image...")
open_btn.clicked.connect(self.open_image)
panel.addWidget(open_btn)
self.explore_btn = QtWidgets.QPushButton("Explore variations...")
self.explore_btn.setToolTip(
"Render every Mode x Palette x Dither combination, pick the best,\n"
"then fine-tune brightness/contrast/etc.")
self.explore_btn.clicked.connect(self.explore_variations)
self.explore_btn.setEnabled(False)
panel.addWidget(self.explore_btn)
form = QtWidgets.QFormLayout()
self.mode_cb = self._combo(MODE_CHOICES, "multicolor")
self.format_cb = self._combo(list(FORMATS.keys()), "d64")
self.palette_cb = self._combo(PALETTE_CHOICES, "colodore")
self.dither_cb = self._combo(DITHER_CHOICES, "bayer")
self.base_cb = self._combo(BASE_CHOICES, "grayscale")
self.video_cb = self._combo(["pal", "ntsc"], "pal")
self.aspect_cb = self._combo(ASPECT_CHOICES, "fit")
form.addRow("Mode", self.mode_cb)
form.addRow("Disk format", self.format_cb)
form.addRow("Palette", self.palette_cb)
form.addRow("Dither", self.dither_cb)
form.addRow("Mono base", self.base_cb)
form.addRow("Video", self.video_cb)
form.addRow("Aspect", self.aspect_cb)
panel.addLayout(form)
self.intensive_cb = QtWidgets.QCheckBox("Intensive analysis (slower, best quality)")
self.intensive_cb.stateChanged.connect(self.schedule_convert)
panel.addWidget(self.intensive_cb)
self.sliders = {}
for key, lo, hi in [("brightness", 50, 200), ("contrast", 50, 200),
("saturation", 0, 200), ("gamma", 50, 200)]:
panel.addLayout(self._slider(key, lo, hi))
for cb in (self.mode_cb, self.format_cb, self.palette_cb, self.dither_cb,
self.base_cb, self.aspect_cb):
cb.currentIndexChanged.connect(self.schedule_convert)
panel.addStretch(1)
self.vice_btn = QtWidgets.QPushButton("Run in VICE")
self.vice_btn.setToolTip(
"Build a temporary disk, open it in VICE, list the directory,\n"
'then LOAD"*",8,1 and RUN the viewer.')
self.vice_btn.clicked.connect(self.run_in_vice)
self.vice_btn.setEnabled(False)
panel.addWidget(self.vice_btn)
self.export_btn = QtWidgets.QPushButton("Export disk image...")
self.export_btn.clicked.connect(self.export)
self.export_btn.setEnabled(False)
panel.addWidget(self.export_btn)
self.status = self.statusBar()
self._temp_disks = []
def _make_preview(self, title):
box = QtWidgets.QGroupBox(title)
lay = QtWidgets.QVBoxLayout(box)
label = QtWidgets.QLabel("(no image)")
label.setAlignment(QtCore.Qt.AlignCenter)
label.setMinimumSize(320, 200)
label.setStyleSheet("background:#202020;color:#888;")
lay.addWidget(label)
return {"box": box, "label": label}
def _combo(self, items, default):
cb = QtWidgets.QComboBox()
cb.addItems(items)
if default in items:
cb.setCurrentText(default)
return cb
def _slider(self, key, lo, hi):
row = QtWidgets.QHBoxLayout()
row.addWidget(QtWidgets.QLabel(key[:4]))
s = QtWidgets.QSlider(QtCore.Qt.Horizontal)
s.setRange(lo, hi)
s.setValue(100)
s.sliderReleased.connect(self.schedule_convert)
self.sliders[key] = s
row.addWidget(s)
return row
def _check_tools(self):
missing = []
if not have_xa():
missing.append("xa (xa65 assembler)")
if not have_c1541():
missing.append("c1541 (VICE)")
if missing:
QtWidgets.QMessageBox.warning(
self, "Missing tools",
"Export needs these tools on PATH:\n " + "\n ".join(missing) +
"\n\nInstall: sudo apt install xa65 vice")
# ---- params ----
def params(self):
base = self.base_cb.currentText()
return {
"mode": self.mode_cb.currentText(),
"palette": self.palette_cb.currentText(),
"dither": self.dither_cb.currentText(),
"base_color": None if base == "grayscale" else COLOR_NAMES.index(base),
"aspect": self.aspect_cb.currentText(),
"intensive": self.intensive_cb.isChecked(),
"brightness": self.sliders["brightness"].value() / 100.0,
"contrast": self.sliders["contrast"].value() / 100.0,
"saturation": self.sliders["saturation"].value() / 100.0,
"gamma": self.sliders["gamma"].value() / 100.0,
}
# ---- actions ----
def open_image(self):
path, _ = QtWidgets.QFileDialog.getOpenFileName(
self, "Open image", "",
"Images (*.png *.jpg *.jpeg *.gif *.bmp *.webp);;All files (*)")
if not path:
return
self.source_path = path
self.explore_btn.setEnabled(True)
pm = QtGui.QPixmap(path)
self.src_label["label"].setPixmap(
pm.scaled(self.src_label["label"].size(), QtCore.Qt.KeepAspectRatio,
QtCore.Qt.SmoothTransformation))
self.schedule_convert()
def explore_variations(self):
if not self.source_path:
return
p = self.params()
prep_kwargs = dict(aspect=p["aspect"], brightness=p["brightness"],
contrast=p["contrast"], saturation=p["saturation"],
gamma=p["gamma"])
# Reuse cached results when the source and prep adjustments are unchanged.
key = (self.source_path, tuple(sorted(prep_kwargs.items())))
cached = self._gallery_results if self._gallery_key == key else None
dlg = VariationsDialog(self.source_path, prep_kwargs, self, cached=cached)
result = dlg.exec_()
if len(dlg.collected) == len(gallery.COMBOS):
self._gallery_key = key
self._gallery_results = dict(dlg.collected)
if result == QtWidgets.QDialog.Accepted and dlg.choice:
mode, palette, dither = dlg.choice
# apply choice, then let the focused live preview + tuning take over
for cb in (self.mode_cb, self.palette_cb, self.dither_cb):
cb.blockSignals(True)
self.mode_cb.setCurrentText(mode)
self.palette_cb.setCurrentText(palette)
self.dither_cb.setCurrentText(dither)
for cb in (self.mode_cb, self.palette_cb, self.dither_cb):
cb.blockSignals(False)
self.status.showMessage(
f"Chosen: {mode} / {palette} / {dither}. "
f"Now fine-tune brightness/contrast/saturation/gamma.")
self.schedule_convert()
def schedule_convert(self):
if not self.source_path:
return
if self.worker and self.worker.isRunning():
self.pending = True # coalesce: re-run when current finishes
return
self.status.showMessage("Converting...")
self.worker = ConvertWorker(self.source_path, self.params())
self.worker.done.connect(self.on_converted)
self.worker.failed.connect(self.on_failed)
self.worker.finished.connect(self._worker_finished)
self.worker.start()
def _worker_finished(self):
if self.pending:
self.pending = False
self.schedule_convert()
def on_converted(self, conv):
self.last_conv = conv
rgb = render_preview(conv, conv.meta.get("palette", "colodore"), scale=2)
pm = numpy_to_pixmap(rgb)
self.c64_label["label"].setPixmap(
pm.scaled(self.c64_label["label"].size(), QtCore.Qt.KeepAspectRatio,
QtCore.Qt.SmoothTransformation))
self.export_btn.setEnabled(True)
self.vice_btn.setEnabled(have_vice())
self.status.showMessage(
f"{conv.mode}: mean dE {conv.error:.1f} "
f"(palette {conv.meta.get('palette')}, dither {conv.meta.get('dither')})")
def on_failed(self, msg):
self.status.showMessage("Conversion failed")
QtWidgets.QMessageBox.critical(self, "Conversion failed", msg)
def export(self):
if not self.last_conv:
return
fmt = self.format_cb.currentText()
suggested = os.path.splitext(os.path.basename(self.source_path or "picture"))[0]
path, _ = QtWidgets.QFileDialog.getSaveFileName(
self, "Export disk image", f"{suggested}.{fmt}",
f"Disk image (*.{fmt})")
if not path:
return
try:
out = export_disk(self.last_conv, path, disk_format=fmt,
source_path=self.source_path,
video=self.video_cb.currentText())
self.status.showMessage(f"Wrote {out}")
QtWidgets.QMessageBox.information(
self, "Exported",
f"Wrote {out}\n\nIn an emulator or on a C64:\n"
f' LOAD"*",8,1 then RUN')
except Exception:
QtWidgets.QMessageBox.critical(self, "Export failed", traceback.format_exc())
def run_in_vice(self):
if not self.last_conv:
return
if not have_vice():
QtWidgets.QMessageBox.warning(
self, "VICE not found",
"The VICE emulator (x64sc) was not found on PATH.\n"
"Install it with: sudo apt install vice")
return
try:
import tempfile
fmt = self.format_cb.currentText()
stem = os.path.splitext(os.path.basename(self.source_path or "picture"))[0]
fd, path = tempfile.mkstemp(suffix=f".{fmt}", prefix=f"{stem}_")
os.close(fd)
standard = self.video_cb.currentText()
export_disk(self.last_conv, path, disk_format=fmt, disk_name=stem,
source_path=self.source_path, video=standard)
self._temp_disks.append(path)
# interlace flickers at the field rate; warp would make it too fast.
warp = self.last_conv.mode != "interlace"
launch_in_vice(path, warp=warp, standard=standard)
self.status.showMessage(
f"Launched VICE ({standard}): directory, then LOAD\"*\",8,1 + RUN."
+ ("" if warp else " (no warp for interlace)"))
except Exception:
QtWidgets.QMessageBox.critical(self, "Run in VICE failed",
traceback.format_exc())
def closeEvent(self, event):
for path in self._temp_disks:
try:
os.remove(path)
except OSError:
pass
super().closeEvent(event)
def main(argv=None):
app = QtWidgets.QApplication(argv if argv is not None else sys.argv)
win = MainWindow()
win.show()
return app.exec_()
if __name__ == "__main__":
raise SystemExit(main())

78
c64view/imageprep.py Normal file
View file

@ -0,0 +1,78 @@
"""Load a modern image and prepare it for a given C64 display mode.
The output is always a plain HxWx3 uint8 sRGB numpy array sized to the mode's
*logical* pixel grid (e.g. 160x200 for multicolor, 320x200 for hires). The 2:1
multicolor pixel aspect is handled here so the source image is never visually
squashed: we resize to the displayed shape and then sub-sample to the logical
grid, which keeps circles round.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from PIL import Image, ImageEnhance, ImageOps
@dataclass
class PrepOptions:
aspect: str = "fit" # "fit" (letterbox), "fill" (crop), "stretch"
brightness: float = 1.0 # 1.0 = unchanged
contrast: float = 1.0
saturation: float = 1.0
gamma: float = 1.0 # <1 brightens midtones, >1 darkens
border_index: int = 0 # palette index used for letterbox bars
def _apply_enhancements(img: Image.Image, opt: PrepOptions) -> Image.Image:
if opt.brightness != 1.0:
img = ImageEnhance.Brightness(img).enhance(opt.brightness)
if opt.contrast != 1.0:
img = ImageEnhance.Contrast(img).enhance(opt.contrast)
if opt.saturation != 1.0:
img = ImageEnhance.Color(img).enhance(opt.saturation)
if opt.gamma != 1.0:
lut = [min(255, int((i / 255.0) ** opt.gamma * 255 + 0.5)) for i in range(256)]
img = img.point(lut * 3)
return img
def prepare(
path_or_img,
logical_w: int,
logical_h: int,
pixel_aspect: float,
opt: PrepOptions,
border_rgb=(0, 0, 0),
) -> np.ndarray:
"""Return an (logical_h, logical_w, 3) uint8 sRGB array.
``pixel_aspect`` is width/height of one logical pixel on screen (2.0 for
multicolor, 1.0 for hires). ``border_rgb`` fills letterbox bars for "fit".
"""
if isinstance(path_or_img, Image.Image):
img = path_or_img
else:
img = Image.open(path_or_img)
img = ImageOps.exif_transpose(img).convert("RGB")
img = _apply_enhancements(img, opt)
# Displayed target shape (square display pixels): the logical grid stretched
# by the pixel aspect ratio horizontally.
disp_w = int(round(logical_w * pixel_aspect))
disp_h = logical_h
if opt.aspect == "stretch":
fitted = img.resize((disp_w, disp_h), Image.LANCZOS)
elif opt.aspect == "fill":
fitted = ImageOps.fit(img, (disp_w, disp_h), Image.LANCZOS, centering=(0.5, 0.5))
else: # "fit" -> letterbox
scaled = img.copy()
scaled.thumbnail((disp_w, disp_h), Image.LANCZOS)
fitted = Image.new("RGB", (disp_w, disp_h), tuple(int(c) for c in border_rgb))
fitted.paste(scaled, ((disp_w - scaled.width) // 2, (disp_h - scaled.height) // 2))
# Collapse displayed shape back to the logical grid (undo the aspect stretch).
logical = fitted.resize((logical_w, logical_h), Image.LANCZOS)
return np.asarray(logical, dtype=np.uint8)

142
c64view/imginfo.py Normal file
View file

@ -0,0 +1,142 @@
"""Collect descriptive metadata about a source image for the on-disk BASIC
info program: name, dimensions, format, colour depth, EXIF dates/comments, the
file's own date, when the C64 version was made, and the host platform.
"""
from __future__ import annotations
import datetime
import os
import platform
from PIL import Image
_DEPTH = {
"1": "1 bit mono", "L": "8 bit gray", "LA": "8 bit gray+a",
"P": "8 bit palette", "PA": "8 bit pal+a", "RGB": "24 bit rgb",
"RGBA": "32 bit rgba", "RGBX": "32 bit rgb", "CMYK": "32 bit cmyk",
"YCbCr": "24 bit ycc", "I": "32 bit int", "F": "32 bit float",
"I;16": "16 bit gray",
}
# EXIF tag ids.
_DATETIME = 306
_DATETIME_ORIGINAL = 36867
_DATETIME_DIGITIZED = 36868
_IMAGE_DESCRIPTION = 270
_USER_COMMENT = 37510
_XP_COMMENT = 40092
_EXIF_IFD = 0x8769
def _fmt_ts(ts) -> str:
return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
def _parse_exif_dt(value) -> datetime.datetime | None:
if isinstance(value, bytes):
value = value.decode("ascii", "ignore")
try:
return datetime.datetime.strptime(str(value).strip(), "%Y:%m:%d %H:%M:%S")
except (ValueError, TypeError):
return None
def _decode_comment(value) -> str | None:
if not value:
return None
if isinstance(value, bytes):
raw = value
# EXIF UserComment has an 8-byte charset prefix (ASCII / UNICODE / ...).
if raw[:8] in (b"ASCII\x00\x00\x00", b"\x00" * 8):
raw = raw[8:]
elif raw[:8].rstrip(b"\x00") == b"UNICODE":
try:
return raw[8:].decode("utf-16-be", "ignore").strip("\x00 ") or None
except Exception:
pass
for enc in ("utf-8", "utf-16-le", "latin-1"):
try:
text = raw.decode(enc, "ignore").strip("\x00 ")
if text:
return text
except Exception:
continue
return None
text = str(value).strip()
return text or None
def gather(path: str) -> list[tuple[str, str]]:
"""Return an ordered list of (label, value) metadata strings."""
fields: list[tuple[str, str]] = []
fields.append(("name", os.path.basename(path)))
img = None
try:
img = Image.open(path)
except Exception:
pass
if img is not None:
fields.append(("size", f"{img.width} x {img.height}"))
fields.append(("format", img.format or "?"))
fields.append(("color", _DEPTH.get(img.mode, img.mode)))
dates: list[datetime.datetime] = []
comment = None
if img is not None:
try:
exif = img.getexif()
sub = {}
try:
sub = exif.get_ifd(_EXIF_IFD)
except Exception:
sub = {}
for tag, src in ((_DATETIME, exif), (_DATETIME_ORIGINAL, sub),
(_DATETIME_DIGITIZED, sub)):
dt = _parse_exif_dt(src.get(tag))
if dt:
dates.append(dt)
comment = (_decode_comment(sub.get(_USER_COMMENT))
or _decode_comment(exif.get(_IMAGE_DESCRIPTION))
or _decode_comment(exif.get(_XP_COMMENT)))
except Exception:
pass
if dates:
fields.append(("exif date", min(dates).strftime("%Y-%m-%d %H:%M")))
try:
st = os.stat(path)
birth = getattr(st, "st_birthtime", None)
if birth:
fields.append(("file date", _fmt_ts(birth)))
else:
fields.append(("mod date", _fmt_ts(st.st_mtime)))
except OSError:
pass
if comment:
fields.append(("comment", comment))
fields.append(("c64 made", datetime.datetime.now().strftime("%Y-%m-%d %H:%M")))
fields.append(("system", f"{platform.system()} {platform.release()}"))
distro = _linux_distro()
if distro:
fields.append(("distro", distro))
return fields
def _linux_distro() -> str | None:
"""Linux distribution name + version from /etc/os-release, if available."""
try:
rel = platform.freedesktop_os_release() # Python 3.10+
except (OSError, AttributeError):
return None
pretty = rel.get("PRETTY_NAME")
if pretty:
return pretty
name = rel.get("NAME", "")
version = rel.get("VERSION", rel.get("VERSION_ID", ""))
return (f"{name} {version}".strip() or None)

111
c64view/palette.py Normal file
View file

@ -0,0 +1,111 @@
"""Commodore 64 (VIC-II) 16-colour palettes and colour-space helpers.
All colour distance work in the converter happens in CIELAB, which is far more
perceptually uniform than RGB, so the per-cell colour choices and dithering land
much closer to what a human eye judges as "the same colour".
"""
from __future__ import annotations
import numpy as np
# 16 fixed VIC-II colours, in canonical index order:
# 0 black 4 purple 8 orange 12 grey (medium)
# 1 white 5 green 9 brown 13 light green
# 2 red 6 blue 10 light red 14 light blue
# 3 cyan 7 yellow 11 dark grey 15 light grey
# "Colodore" (pepto's reworked, calibrated values) -- the modern default.
COLODORE = np.array([
(0x00, 0x00, 0x00),
(0xff, 0xff, 0xff),
(0x81, 0x33, 0x38),
(0x75, 0xce, 0xc8),
(0x8e, 0x3c, 0x97),
(0x56, 0xac, 0x4d),
(0x2e, 0x2c, 0x9b),
(0xed, 0xf1, 0x71),
(0x8e, 0x50, 0x29),
(0x55, 0x38, 0x00),
(0xc4, 0x6c, 0x71),
(0x4a, 0x4a, 0x4a),
(0x7b, 0x7b, 0x7b),
(0xa9, 0xff, 0x9f),
(0x70, 0x6d, 0xeb),
(0xb2, 0xb2, 0xb2),
], dtype=np.float64)
# "Pepto" (PAL) -- classic reference values, slightly more saturated.
PEPTO = np.array([
(0x00, 0x00, 0x00),
(0xff, 0xff, 0xff),
(0x68, 0x37, 0x2b),
(0x70, 0xa4, 0xb2),
(0x6f, 0x3d, 0x86),
(0x58, 0x8d, 0x43),
(0x35, 0x28, 0x79),
(0xb8, 0xc7, 0x6f),
(0x6f, 0x4f, 0x25),
(0x43, 0x39, 0x00),
(0x9a, 0x67, 0x59),
(0x44, 0x44, 0x44),
(0x6c, 0x6c, 0x6c),
(0x9a, 0xd2, 0x84),
(0x6c, 0x5e, 0xb5),
(0x95, 0x95, 0x95),
], dtype=np.float64)
PALETTES = {"colodore": COLODORE, "pepto": PEPTO}
COLOR_NAMES = [
"black", "white", "red", "cyan", "purple", "green", "blue", "yellow",
"orange", "brown", "light red", "dark grey", "grey", "light green",
"light blue", "light grey",
]
def srgb_to_linear(rgb: np.ndarray) -> np.ndarray:
"""sRGB (0..255) -> linear-light (0..1)."""
c = rgb.astype(np.float64) / 255.0
return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
def linear_to_srgb(lin: np.ndarray) -> np.ndarray:
"""linear-light (0..1) -> sRGB (0..255)."""
c = np.clip(lin, 0.0, 1.0)
s = np.where(c <= 0.0031308, c * 12.92, 1.055 * (c ** (1 / 2.4)) - 0.055)
return np.clip(s * 255.0 + 0.5, 0, 255).astype(np.uint8)
# D65 reference white.
_XYZ_FROM_LIN = np.array([
[0.4124564, 0.3575761, 0.1804375],
[0.2126729, 0.7151522, 0.0721750],
[0.0193339, 0.1191920, 0.9503041],
])
_WHITE = np.array([0.95047, 1.0, 1.08883])
def srgb_to_lab(rgb: np.ndarray) -> np.ndarray:
"""sRGB (0..255, last axis = RGB) -> CIELAB. Shape preserved except last axis."""
lin = srgb_to_linear(rgb)
xyz = lin @ _XYZ_FROM_LIN.T
xyz = xyz / _WHITE
eps = 216 / 24389
kappa = 24389 / 27
f = np.where(xyz > eps, np.cbrt(xyz), (kappa * xyz + 16) / 116)
fx, fy, fz = f[..., 0], f[..., 1], f[..., 2]
L = 116 * fy - 16
a = 500 * (fx - fy)
b = 200 * (fy - fz)
return np.stack([L, a, b], axis=-1)
def get_palette(name: str = "colodore") -> np.ndarray:
"""Return the 16x3 sRGB palette (float64, 0..255)."""
return PALETTES[name]
def palette_lab(name: str = "colodore") -> np.ndarray:
"""Return the 16 palette colours in CIELAB (16x3)."""
return srgb_to_lab(get_palette(name))

View file

@ -0,0 +1,9 @@
"""6502 viewer programs, assembled on demand by assemble.py."""
from .assemble import ( # noqa: F401
AssemblerError,
SOURCES,
assemble_stub,
build_viewer_prg,
have_xa,
)

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,82 @@
"""Assemble the 6502 viewer stubs with `xa` and build self-contained viewer PRGs.
Each viewer is a small ML stub originating at $0801 (behind a BASIC SYS 2061
autostart). The picture data is appended after the stub, zero-padded so the
bitmap lands exactly at $2000, screen RAM at $3F40, etc. The whole thing loads
in a single pass -- no second disk access -- so it works identically on real
hardware and in any emulator regardless of device configuration.
`xa -o` emits raw bytes starting at the origin without the 2-byte CBM load
address, which we prepend here.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
LOAD_ADDR = 0x0801
DATA_ADDR = 0x2000 # where appended picture data must land
# mode/viewer key -> source filename
SOURCES = {
"hires": "hires.s",
"multicolor": "multicolor.s",
"fli": "fli.s",
"fli_ntsc": "fli_ntsc.s",
"interlace": "interlace.s",
}
_cache: dict[str, bytes] = {}
class AssemblerError(RuntimeError):
pass
def have_xa() -> bool:
return shutil.which("xa") is not None
def assemble_stub(viewer_key: str) -> bytes:
"""Assemble a viewer stub to raw bytes (origin $0801, no load-address prefix)."""
if viewer_key in _cache:
return _cache[viewer_key]
if not have_xa():
raise AssemblerError(
"The 'xa' (xa65) assembler was not found on PATH.\n"
"Install it with: sudo apt install xa65 (Debian/Ubuntu)\n"
"or build from https://www.floodgap.com/retrotech/xa/")
src = os.path.join(VIEWER_DIR, SOURCES[viewer_key])
if not os.path.exists(src):
raise AssemblerError(f"viewer source missing: {src}")
with tempfile.TemporaryDirectory() as td:
out = os.path.join(td, "viewer.bin")
proc = subprocess.run(["xa", "-o", out, src], capture_output=True, text=True)
if proc.returncode != 0:
raise AssemblerError(f"xa failed for {src}:\n{proc.stdout}{proc.stderr}")
with open(out, "rb") as f:
raw = f.read()
_cache[viewer_key] = raw
return raw
def build_viewer_prg(viewer_key: str, data: bytes, data_addr: int = DATA_ADDR) -> bytes:
"""Combine the assembled stub + padding + picture ``data`` into one PRG.
``data`` is the block that must reside from ``data_addr`` upward (bitmap,
screen, colour RAM, background, ...).
"""
stub = assemble_stub(viewer_key)
pad_len = (data_addr - LOAD_ADDR) - len(stub)
if pad_len < 0:
raise AssemblerError(
f"viewer stub {viewer_key} is {len(stub)} bytes, exceeds the "
f"{data_addr - LOAD_ADDR} bytes available before ${data_addr:04x}")
payload = stub + bytes(pad_len) + bytes(data)
return bytes([LOAD_ADDR & 0xFF, (LOAD_ADDR >> 8) & 0xFF]) + payload

151
c64view/viewer/fli.s Normal file
View file

@ -0,0 +1,151 @@
; c64view -- FLI multicolor viewer (self-contained)
;
; Re-points the VIC video matrix ($D018) and forces a badline ($D011 yscroll)
; on every visible raster line via a cycle-timed loop, giving per-line colour.
;
; Memory layout of appended data (loads from $4000):
; $4000+L*$400 screen RAM for line L (L=0..7), 1000 bytes each
; $6000 bitmap 8000 (offset $2000 in VIC bank 1)
; $8000 colour RAM 1000 (copied to $D800)
; $83E8 background 1
;
; assembled by viewer/assemble.py via xa
; BASIC autostart, SYS 2061
* = $0801
.word basicend
.word 10
.byte $9e
.byte "2061"
.byte 0
basicend:
.word 0 ; ML begins at $080D
start:
sei
; copy colour RAM $8000 -> $D800 (1024 bytes)
ldx #0
ccopy:
lda $8000,x
sta $d800,x
lda $8100,x
sta $d900,x
lda $8200,x
sta $da00,x
lda $8300,x
sta $db00,x
inx
bne ccopy
; build per-line tables. d018tab = ((line AND 7) << 4) OR $08
; d011tab = $38 OR (line AND 7) = BMM+DEN+RSEL plus yscroll
ldx #0
btab:
txa
and #$07
asl
asl
asl
asl
ora #$08
sta d018tab,x
; yscroll must equal (raster AND 7) to force a badline; the first
; displayed raster is 51, so line x maps to raster 51+x.
txa
clc
adc #$03
and #$07
ora #$38
sta d011tab,x
inx
bne btab
; VIC bank 1 ($4000-$7FFF)
lda $dd00
and #$fc
ora #$02
sta $dd00
lda $83e8
sta $d021 ; background
lda #$00
sta $d020 ; border black
lda #$d8
sta $d016 ; multicolor on
; raster IRQ setup
lda #<irq1
sta $0314
lda #>irq1
sta $0315
lda #$7f
sta $dc0d ; disable CIA timer IRQs
sta $dd0d
lda $dc0d
lda $dd0d
lda #$01
sta $d01a ; enable raster IRQ
lda #$30
sta $d012 ; line 48
lda $d011
and #$7f
sta $d011
asl $d019 ; ack
cli
hang:
jmp hang
; first IRQ, arrives with jitter on line 48, sets up the stabilised one
irq1:
lda #<irq2
sta $0314
lda #>irq2
sta $0315
inc $d012 ; fire again next line (49)
asl $d019 ; ack
tsx
cli
; burn the rest of the line so irq2 fires inside this NOP slide
.dsb 40,$ea ; 40 NOPs
; stabilised IRQ on line 49, runs the FLI loop
irq2:
txs ; restore sp from irq1
; cancel the remaining 0/1-cycle jitter: read the raster twice; the
; branch is 2 or 3 cycles depending on alignment, normalising entry.
lda $d012
cmp $d012
beq jl
jl:
; fixed delay so the first store lands before the c-access window
ldx #$0d
d0: dex
bne d0
nop
nop
ldx #$00
fliloop:
lda d011tab,x ; force badline (yscroll = line&7)
sta $d011
lda d018tab,x ; point video matrix at screen[line]
sta $d018
inx
cpx #200
bne fliloop
; bottom border: leave bitmap on but stop forcing badlines
lda #<irq1
sta $0314
lda #>irq1
sta $0315
lda #$30
sta $d012
asl $d019
jmp $ea81 ; restore regs + RTI
d018tab:
.dsb 256,0
d011tab:
.dsb 256,0

155
c64view/viewer/fli_ntsc.s Normal file
View file

@ -0,0 +1,155 @@
; c64view -- FLI multicolor viewer, NTSC timing (self-contained)
;
; Same as fli.s but the inner loop is one NOP (2 cycles) longer so it self-syncs
; to the NTSC 65-cycle raster line (25 free CPU cycles per badline) vs PAL's 63.
;
; Re-points the VIC video matrix ($D018) and forces a badline ($D011 yscroll)
; on every visible raster line via a cycle-timed loop, giving per-line colour.
;
; Memory layout of appended data (loads from $4000):
; $4000+L*$400 screen RAM for line L (L=0..7), 1000 bytes each
; $6000 bitmap 8000 (offset $2000 in VIC bank 1)
; $8000 colour RAM 1000 (copied to $D800)
; $83E8 background 1
;
; assembled by viewer/assemble.py via xa
; BASIC autostart, SYS 2061
* = $0801
.word basicend
.word 10
.byte $9e
.byte "2061"
.byte 0
basicend:
.word 0 ; ML begins at $080D
start:
sei
; copy colour RAM $8000 -> $D800 (1024 bytes)
ldx #0
ccopy:
lda $8000,x
sta $d800,x
lda $8100,x
sta $d900,x
lda $8200,x
sta $da00,x
lda $8300,x
sta $db00,x
inx
bne ccopy
; build per-line tables. d018tab = ((line AND 7) << 4) OR $08
; d011tab = $38 OR (line AND 7) = BMM+DEN+RSEL plus yscroll
ldx #0
btab:
txa
and #$07
asl
asl
asl
asl
ora #$08
sta d018tab,x
; yscroll must equal (raster AND 7) to force a badline; the first
; displayed raster is 51, so line x maps to raster 51+x.
txa
clc
adc #$03
and #$07
ora #$38
sta d011tab,x
inx
bne btab
; VIC bank 1 ($4000-$7FFF)
lda $dd00
and #$fc
ora #$02
sta $dd00
lda $83e8
sta $d021 ; background
lda #$00
sta $d020 ; border black
lda #$d8
sta $d016 ; multicolor on
; raster IRQ setup
lda #<irq1
sta $0314
lda #>irq1
sta $0315
lda #$7f
sta $dc0d ; disable CIA timer IRQs
sta $dd0d
lda $dc0d
lda $dd0d
lda #$01
sta $d01a ; enable raster IRQ
lda #$30
sta $d012 ; line 48
lda $d011
and #$7f
sta $d011
asl $d019 ; ack
cli
hang:
jmp hang
; first IRQ, arrives with jitter on line 48, sets up the stabilised one
irq1:
lda #<irq2
sta $0314
lda #>irq2
sta $0315
inc $d012 ; fire again next line (49)
asl $d019 ; ack
tsx
cli
; burn the rest of the line so irq2 fires inside this NOP slide
.dsb 40,$ea ; 40 NOPs
; stabilised IRQ on line 49, runs the FLI loop
irq2:
txs ; restore sp from irq1
; cancel the remaining 0/1-cycle jitter: read the raster twice; the
; branch is 2 or 3 cycles depending on alignment, normalising entry.
lda $d012
cmp $d012
beq jl
jl:
; fixed delay so the first store lands before the c-access window
ldx #$0d
d0: dex
bne d0
nop
nop
ldx #$00
fliloop:
lda d011tab,x ; force badline (yscroll = line&7)
sta $d011
lda d018tab,x ; point video matrix at screen[line]
sta $d018
nop ; NTSC extra 2 cycles for 65 cycle line
inx
cpx #200
bne fliloop
; bottom border: leave bitmap on but stop forcing badlines
lda #<irq1
sta $0314
lda #>irq1
sta $0315
lda #$30
sta $d012
asl $d019
jmp $ea81 ; restore regs + RTI
d018tab:
.dsb 256,0
d011tab:
.dsb 256,0

80
c64view/viewer/hires.s Normal file
View file

@ -0,0 +1,80 @@
; c64view -- hires bitmap viewer (self-contained)
;
; The picture data is appended to this program by the exporter and loads in one
; pass. Fixed memory layout after load:
; $0801 this program (BASIC stub + ML, padded up to $2000)
; $2000 bitmap 8000 (VIC reads here directly)
; $3F40 screen 1000 (copied to $0400)
;
; assembled by viewer/assemble.py via xa
; BASIC autostart, SYS 2061
* = $0801
.word basicend
.word 10
.byte $9e
.byte "2061"
.byte 0
basicend:
.word 0 ; ML begins at $080D
SRC = $fb
DST = $fd
start:
lda #$0b
sta $d011 ; blank during setup
; copy screen RAM $3F40 -> $0400
lda #$40
sta SRC
lda #$3f
sta SRC+1
lda #$00
sta DST
lda #$04
sta DST+1
jsr copy1024
lda $dd00
ora #$03
sta $dd00 ; VIC bank 0
lda #$00
sta $d020
lda #$18
sta $d018 ; screen $0400, bitmap $2000
lda #$c8
sta $d016 ; hires (multicolor off)
lda #$3b
sta $d011 ; bitmap mode, display on
lda #$ff
sta $cc
waitkey:
jsr $ffe4
beq waitkey
lda #$1b
sta $d011
lda #$c8
sta $d016
lda #$15
sta $d018
lda #$00
sta $cc
jsr $e544
rts
copy1024:
ldx #4
ldy #0
cploop:
lda (SRC),y
sta (DST),y
iny
bne cploop
inc SRC+1
inc DST+1
dex
bne cploop
rts

139
c64view/viewer/interlace.s Normal file
View file

@ -0,0 +1,139 @@
; c64view -- multicolor interlace viewer (self-contained)
;
; Shows two multicolor frames on alternating fields by flipping the VIC bank in a
; once-per-frame raster IRQ (no cycle-exact timing needed, so it is robust).
; Frame A lives in bank 0 (bitmap $2000, screen $0400); frame B in bank 1 (bitmap
; $6000, screen $4400). Both use $D018=$18; only the $DD00 bank bit toggles.
;
; Memory layout of appended data (loads from $2000):
; $2000 bitmap A 8000
; $3F40 screen A 1000 (copied to $0400)
; $4400 screen B 1000 (in place, bank 1 video matrix)
; $6000 bitmap B 8000
; $8000 colour RAM 1000 (copied to $D800)
; $83E8 background 1
;
; assembled by viewer/assemble.py via xa
; BASIC autostart, SYS 2061
* = $0801
.word basicend
.word 10
.byte $9e
.byte "2061"
.byte 0
basicend:
.word 0
SRC = $fb
DST = $fd
start:
sei
; screen A $3F40 -> $0400
lda #$40
sta SRC
lda #$3f
sta SRC+1
lda #$00
sta DST
lda #$04
sta DST+1
jsr copy1024
; colour RAM $8000 -> $D800
lda #$00
sta SRC
lda #$80
sta SRC+1
lda #$00
sta DST
lda #$d8
sta DST+1
jsr copy1024
lda $83e8
sta $d021 ; background
lda #$00
sta $d020 ; border black
lda $dd00
and #$fc
ora #$03
sta $dd00 ; start on VIC bank 0 (frame A)
lda #$18
sta $d018 ; screen $x400, bitmap $x000+$2000
lda #$d8
sta $d016 ; multicolor on
lda #$3b
sta $d011 ; bitmap mode, display on
; frame-flip raster IRQ near the bottom border
lda #<irq
sta $0314
lda #>irq
sta $0315
lda #$7f
sta $dc0d
sta $dd0d
lda $dc0d
lda $dd0d
lda #$01
sta $d01a
lda #$fa
sta $d012 ; line 250
lda $d011
and #$7f
sta $d011
asl $d019
cli
waitkey:
jsr $ffe4 ; GETIN (scanned via our IRQ -> $ea31)
beq waitkey
; restore text mode + KERNAL IRQ
sei
lda #$00
sta $d01a ; disable raster IRQ
lda #$81
sta $dc0d ; re-enable CIA timer IRQ
lda #$31
sta $0314
lda #$ea
sta $0315
lda #$1b
sta $d011
lda #$c8
sta $d016
lda #$15
sta $d018
lda $dd00
ora #$03
sta $dd00
asl $d019
cli
jsr $e544 ; clear screen
rts
; once per frame, flip bank 0 <-> bank 1, then let the KERNAL IRQ finish
irq:
lda $dd00
eor #$01
sta $dd00
asl $d019 ; ack raster IRQ
jmp $ea31 ; KERNAL housekeeping (keyboard) + RTI
copy1024:
ldx #4
ldy #0
cploop:
lda (SRC),y
sta (DST),y
iny
bne cploop
inc SRC+1
inc DST+1
dex
bne cploop
rts

View file

@ -0,0 +1,98 @@
; c64view -- multicolor (Koala) bitmap viewer (self-contained)
;
; The picture data is appended to this program by the exporter and loads in one
; pass, so no second disk access is needed. Fixed memory layout after load:
; $0801 this program (BASIC stub + ML, padded up to $2000)
; $2000 bitmap 8000 (VIC reads here directly)
; $3F40 screen 1000 (copied to $0400)
; $4328 colram 1000 (copied to $D800)
; $4710 background 1
;
; assembled by viewer/assemble.py via xa
; BASIC autostart, SYS 2061
* = $0801
.word basicend
.word 10
.byte $9e
.byte "2061"
.byte 0
basicend:
.word 0 ; ML begins at $080D
SRC = $fb
DST = $fd
start:
lda #$0b
sta $d011 ; blank screen during setup
; copy screen RAM $3F40 -> $0400
lda #$40
sta SRC
lda #$3f
sta SRC+1
lda #$00
sta DST
lda #$04
sta DST+1
jsr copy1024
; copy colour RAM $4328 -> $D800
lda #$28
sta SRC
lda #$43
sta SRC+1
lda #$00
sta DST
lda #$d8
sta DST+1
jsr copy1024
; program the VIC-II
lda $dd00
ora #$03
sta $dd00 ; VIC bank 0 ($0000-$3FFF)
lda $4710
sta $d021 ; background colour
lda #$00
sta $d020 ; border black
lda #$18
sta $d018 ; screen $0400, bitmap $2000
lda #$d8
sta $d016 ; multicolor mode on
lda #$3b
sta $d011 ; bitmap mode, display on
lda #$ff
sta $cc ; disable cursor blink
waitkey:
jsr $ffe4 ; GETIN
beq waitkey
; restore text mode and return to BASIC
lda #$1b
sta $d011
lda #$c8
sta $d016
lda #$15
sta $d018
lda #$00
sta $cc
jsr $e544 ; clear screen
rts
; copy 1024 bytes from (SRC) to (DST)
copy1024:
ldx #4
ldy #0
cploop:
lda (SRC),y
sta (DST),y
iny
bne cploop
inc SRC+1
inc DST+1
dex
bne cploop
rts

BIN
docs/gui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

31
pyproject.toml Normal file
View file

@ -0,0 +1,31 @@
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "c64view"
version = "0.1.0"
description = "Convert modern images into Commodore 64 disk images with a built-in viewer"
readme = "README.md"
requires-python = ">=3.9"
license = { text = "MIT" }
dependencies = [
"numpy>=1.22",
"Pillow>=9.0",
# PyQt5 is needed only for the GUI; the CLI works without it.
]
[project.optional-dependencies]
gui = ["PyQt5>=5.15"]
[project.scripts]
c64view-cli = "c64view.cli:main"
[project.gui-scripts]
c64view = "c64view.gui:main"
[tool.setuptools.packages.find]
include = ["c64view*"]
[tool.setuptools.package-data]
"c64view.viewer" = ["*.s"]

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
numpy>=1.22
Pillow>=9.0
PyQt5>=5.15

BIN
samples/test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

118
tests/test_roundtrip.py Normal file
View file

@ -0,0 +1,118 @@
"""Regression tests: decode each mode's emitted VIC-II bytes and check they
reproduce the converter's own index image, and that every viewer assembles and
fits. Run with `pytest` or directly: `python tests/test_roundtrip.py`.
These tests exercise the byte-packing that the GUI preview deliberately does *not*
touch (the preview renders from the index image), so they are the safety net that
catches an encoding bug before it reaches a real C64.
"""
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from c64view import imageprep, palette as pal # noqa: E402
from c64view.convert import fli, hires, ifli, multicolor # noqa: E402
from c64view.viewer.assemble import SOURCES, build_viewer_prg, have_xa # noqa: E402
def _gradient(w, h):
yy, xx = np.mgrid[0:h, 0:w]
rgb = np.stack([(xx * 255 // w), (yy * 255 // h),
((xx + yy) * 255 // (w + h))], axis=-1)
return rgb.astype(np.uint8)
def _decode_mc(bitmap, screen, colram, bg):
dec = np.zeros((200, 160), np.uint8)
for cr in range(25):
for cc in range(40):
ci = cr * 40 + cc
lut = [bg, screen[ci] >> 4, screen[ci] & 0xF, colram[ci] & 0xF]
for r in range(8):
byte = bitmap[cr * 320 + cc * 8 + r]
for x in range(4):
dec[cr * 8 + r, cc * 4 + x] = lut[(byte >> (6 - 2 * x)) & 3]
return dec
def test_multicolor_roundtrip():
img = imageprep.prepare(_imgobj(160, 200), 160, 200, 2.0, imageprep.PrepOptions())
c = multicolor.convert(img)
d = c.data
dec = _decode_mc(np.frombuffer(d[:8000], np.uint8),
np.frombuffer(d[8000:9000], np.uint8),
np.frombuffer(d[9000:10000], np.uint8), d[10000])
assert np.array_equal(dec, c.index_image)
def test_hires_roundtrip():
img = imageprep.prepare(_imgobj(320, 200), 320, 200, 1.0, imageprep.PrepOptions())
c = hires.convert(img)
bitmap = np.frombuffer(c.data[:8000], np.uint8)
screen = np.frombuffer(c.data[8000:9000], np.uint8)
dec = np.zeros((200, 320), np.uint8)
for cr in range(25):
for cc in range(40):
ci = cr * 40 + cc
fg, bgc = screen[ci] >> 4, screen[ci] & 0xF
for r in range(8):
byte = bitmap[cr * 320 + cc * 8 + r]
for x in range(8):
dec[cr * 8 + r, cc * 8 + x] = fg if (byte >> (7 - x)) & 1 else bgc
assert np.array_equal(dec, c.index_image)
def test_fli_roundtrip():
img = imageprep.prepare(_imgobj(160, 200), 160, 200, 2.0, imageprep.PrepOptions())
c = fli.convert(img)
d = c.data
screens = [np.frombuffer(d[L * 1024:L * 1024 + 1000], np.uint8) for L in range(8)]
bitmap = np.frombuffer(d[8192:8192 + 8000], np.uint8)
colram = np.frombuffer(d[16384:16384 + 1000], np.uint8)
bg = d[17384]
dec = np.zeros((200, 160), np.uint8)
for cr in range(25):
for cc in range(40):
ci = cr * 40 + cc
for r in range(8):
sb = screens[r][ci]
lut = [bg, sb >> 4, sb & 0xF, colram[ci] & 0xF]
byte = bitmap[cr * 320 + cc * 8 + r]
for x in range(4):
dec[cr * 8 + r, cc * 4 + x] = lut[(byte >> (6 - 2 * x)) & 3]
assert np.array_equal(dec, c.index_image)
def test_interlace_blend_better():
"""Interlace blend error should beat plain multicolor on a gradient."""
img = imageprep.prepare(_imgobj(160, 200), 160, 200, 2.0, imageprep.PrepOptions())
assert ifli.convert(img).error < multicolor.convert(img).error + 1e-6
assert len(ifli.convert(img).data) == 25577
def test_viewers_assemble_and_fit():
if not have_xa():
return # xa not installed; skip
sizes = {"hires": 9000, "multicolor": 10001, "fli": 17385,
"fli_ntsc": 17385, "interlace": 25577}
for key in SOURCES:
prg = build_viewer_prg(key, bytes(sizes[key]),
0x4000 if key.startswith("fli") else 0x2000)
assert prg[:2] == bytes([0x01, 0x08]) # PRG load address $0801
def _imgobj(w, h):
from PIL import Image
return Image.fromarray(_gradient(w, h), "RGB")
if __name__ == "__main__":
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
for fn in fns:
fn()
print(f"PASS {fn.__name__}")
print(f"\nAll {len(fns)} tests passed.")