First public commit.

This commit is contained in:
The Dust Council 2026-07-03 19:35:35 -07:00
parent 2a48f52979
commit 4bac9d83ed
288 changed files with 18417 additions and 1076 deletions

0
lenser/pet/__init__.py Normal file
View file

61
lenser/pet/convert.py Normal file
View file

@ -0,0 +1,61 @@
"""PET image encoder: monochrome quadrant-block pseudo-bitmap.
40-column models -> 80x50 pixels (40x25 chars); 80-column -> 160x50. The image
is dithered to one bit, then each 2x2 pixel block becomes the PETSCII quadrant
character with that pattern. Output is the screen-RAM byte array ($8000): one
screen code per character cell, row-major.
"""
from __future__ import annotations
import numpy as np
from .. import dither, palette as c64pal, imageprep
from ..convert.base import Conversion, perceptual_error
from . import palette as petpal
ROWS = 25 # character rows (both 40- and 80-col PETs)
def _dims(cols):
return cols * 2, ROWS * 2, cols # pixel W, pixel H, char cols
def convert(img_rgb, cols=40, dither_mode="floyd", base_color=None):
W, H, _ = _dims(cols)
plab = petpal.palette_lab()
prgb = petpal.get_palette().astype(np.uint8)
# one-bit luminance dither (1 = lit phosphor)
L = c64pal.srgb_to_lab(img_rgb)[..., 0]
mono = np.zeros((H, W, 3)); mono[..., 0] = L
pmono = np.zeros_like(plab); pmono[:, 0] = plab[:, 0]
allowed = np.tile(np.array([0, 1]), (H, W, 1))
idx = dither.quantize(mono, allowed, pmono, dither_mode).astype(np.uint8)
# each 2x2 block -> quadrant screen code
screen = bytearray(cols * ROWS)
for r in range(ROWS):
for c in range(cols):
tl = idx[r * 2, c * 2]; tr = idx[r * 2, c * 2 + 1]
bl = idx[r * 2 + 1, c * 2]; br = idx[r * 2 + 1, c * 2 + 1]
key = (tl << 3) | (tr << 2) | (bl << 1) | br
screen[r * cols + c] = petpal.QUAD[key]
return Conversion(
mode="mono", width=W, height=H,
pixel_aspect=0.83 if cols == 40 else 0.42,
index_image=idx.astype(np.uint16), data=bytes(screen), data_addr=0x8000,
viewer="pet", preview_rgb=prgb[idx],
error=perceptual_error(idx, mono, pmono),
meta={"palette": "pet", "dither": dither_mode, "cols": cols},
)
def convert_image(path_or_img, cols=40, dither_mode="floyd", intensive=False,
prep_opt=None, base_color=None):
prep_opt = prep_opt or imageprep.PrepOptions()
W, H, _ = _dims(cols)
aspect = 0.83 if cols == 40 else 0.42
img_rgb = imageprep.prepare(path_or_img, W, H, aspect, prep_opt,
border_rgb=(0, 0, 0))
return convert(img_rgb, cols, dither_mode, base_color=base_color)

16
lenser/pet/exporter.py Normal file
View file

@ -0,0 +1,16 @@
"""Build a Commodore PET .prg (loaded + run via MAME quickload / on real HW)."""
from __future__ import annotations
from .viewer import assemble
_EXTS = (".prg", ".p00")
def export_prg(conv, output_path, source_path=None, display="forever",
seconds=0, video="ntsc"):
if not output_path.lower().endswith(_EXTS):
output_path += ".prg"
prg = assemble.build_prg(bytes(conv.data))
with open(output_path, "wb") as f:
f.write(prg)
return output_path

30
lenser/pet/palette.py Normal file
View file

@ -0,0 +1,30 @@
"""Commodore PET / CBM monochrome display.
The PET has no bitmap or colour -- a 40- or 80-column text screen of a fixed
character ROM on a monochrome (green P1 phosphor) monitor. Images are rendered
with the PETSCII 2x2 quadrant-block graphics characters, giving an effective
80x50 (40-col) or 160x50 (80-col) one-bit pixel grid.
"""
from __future__ import annotations
import numpy as np
from ..palette import srgb_to_lab
# 0 = background (dark), 1 = foreground (lit phosphor). Green to match the
# classic PET monitor; the signal is one bit, so only luminance matters.
PALETTE = np.array([(0, 0, 0), (0x40, 0xE0, 0x40)], dtype=np.float64)
def get_palette() -> np.ndarray:
return PALETTE
def palette_lab() -> np.ndarray:
return srgb_to_lab(PALETTE)
# screen (poke) code for each 2x2 quadrant pattern, bits TL<<3|TR<<2|BL<<1|BR.
# Derived from the PET character ROM: 8 blocks exist directly, the other 8 are
# their reverse-video forms (screen code | $80).
QUAD = [32, 108, 123, 98, 124, 225, 255, 254, 126, 127, 97, 252, 226, 251, 236, 160]

View file

View file

@ -0,0 +1,68 @@
"""Assemble the PET viewer with `xa` and build the loadable .prg.
The PRG loads at the PET BASIC start ($0401): a BASIC stub ``10 SYS1056`` then
the 6502 viewer (at $0420 = 1056) then the screen data (at $0500). Running it
(or SYS 1056) copies the screen codes to $8000.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
BASIC_START = 0x0401
ML_ORG = 0x0420 # 1056
DATA_ORG = 0x0500
# BASIC: 10 SYS1056 (bytes from $0401)
_STUB = bytes([0x0B, 0x04, 0x0A, 0x00, 0x9E,
0x31, 0x30, 0x35, 0x36, 0x00, 0x00, 0x00])
class AssemblerError(RuntimeError):
pass
def have_xa() -> bool:
return shutil.which("xa") is not None
def _assemble(pages: int) -> bytes:
if not have_xa():
raise AssemblerError("The 'xa' (xa65) assembler was not found (apt install xa65).")
wrapper = (f"#define DATA ${DATA_ORG:04X}\n"
f"#define PAGES {pages}\n"
'#include "viewer.s"\n')
with tempfile.TemporaryDirectory() as td:
out = os.path.join(td, "v.bin")
fd, wrap = tempfile.mkstemp(suffix=".s", prefix="_wrap_", dir=VIEWER_DIR)
try:
with os.fdopen(fd, "w") as f:
f.write(wrapper)
proc = subprocess.run(["xa", "-o", out, os.path.basename(wrap)],
capture_output=True, text=True, cwd=VIEWER_DIR)
if proc.returncode != 0:
raise AssemblerError(f"xa failed:\n{proc.stdout}{proc.stderr}")
with open(out, "rb") as f:
return f.read()
finally:
os.unlink(wrap)
def build_prg(screen: bytes) -> bytes:
"""screen = screen-RAM bytes (1000 for 40-col, 2000 for 80-col)."""
pages = (len(screen) + 255) // 256
data = bytes(screen) + bytes(pages * 256 - len(screen)) # pad to whole pages
code = _assemble(pages)
mem = bytearray()
mem += _STUB
mem += b"\x00" * (ML_ORG - BASIC_START - len(mem))
mem += code
if len(mem) > DATA_ORG - BASIC_START:
raise AssemblerError("viewer code overruns the data area")
mem += b"\x00" * (DATA_ORG - BASIC_START - len(mem))
mem += data
return bytes([BASIC_START & 0xFF, BASIC_START >> 8]) + bytes(mem)

View file

@ -0,0 +1,34 @@
; Commodore PET screen viewer (6502). Copies PAGES*256 bytes of precomputed
; screen codes from DATA into the PET screen RAM at $8000, then loops. The PET
; video hardware continuously displays $8000 as text, so the picture stays up.
;
; #defines from viewer/assemble.py -- DATA = source address, PAGES = 256-byte
; pages to copy (4 for 40-col / 1000 bytes, 8 for 80-col / 2000 bytes).
* = $0420
src = $fb
dst = $fd
start:
lda #<DATA
sta src
lda #>DATA
sta src+1
lda #$00
sta dst
lda #$80
sta dst+1 ; dest = $8000 (screen RAM)
ldx #PAGES
ldy #$00
cp:
lda (src),y
sta (dst),y
iny
bne cp
inc src+1
inc dst+1
dex
bne cp
hang:
jmp hang