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

25
.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
# Python build artifacts
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
.eggs/
# Virtual environments
.venv/
venv/
# Editor / OS junk
*.swp
*~
.DS_Store
# Generated output (disk/cartridge images and previews)
*.d64
*.d71
*.d81
*.atr
*.dsk
*.adf
*.ans

483
README.md
View file

@ -1,63 +1,448 @@
# c64view
# 8 Bit Lenser
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)
The Commodore 64 is the reference target, but **25+ other retro machines** are
supported too — Atari 8-bit, Apple II / IIgs, BBC Micro, ZX Spectrum, Amstrad CPC,
TI-99/4A, NES, Sega Master System, Commodore 128 / 16 / Plus/4 / VIC-20 / PET,
Amiga, and more — each writing a self-contained, emulator-verified cartridge or
disk. There's also an **ANSI / CP437** text-art export for BBSes. Pick the target
with `--platform` (CLI) or the **Platform** selector (GUI). See the sections below.
![8 Bit Lenser 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.
- **Perceptual, dither-aware conversion.** All colour decisions are made in CIELAB.
Each screen cell's colour set is chosen by an exhaustive, vectorised search; for
error-diffusion dithering the search is **dither-aware** — it scores each colour
pair by distance to the *segment between* the colours (not just the nearest
colour), so the chosen colours bracket the cell and dithering blends to the true
shade. The result is dramatically smoother, more accurate images (perceptual ΔE
roughly halved). An *Intensive* mode additionally searches the global background.
- **Selectable dithering****Atkinson** (default), FloydSteinberg, the larger
Stucki / Jarvis / Sierra-3 / Burkes kernels, fast Sierra-Lite, tone-adaptive
Ostromoukhov and Hilbert-curve Riemersma (all paired with dither-aware
selection — best for photos), plus the ordered modes ordered Bayer, organic
**blue-noise** (no grid) and **Yliluoma** (mixes >2 palette entries per cell —
superb on the constrained flat-palette machines), and none — every one
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.
- **Run in an emulator.** One click builds the image and boots it (every platform
runs under **MAME**): for the C64 it attaches the disk, then `LOAD"*",8,1` + `RUN`
to show the picture. Runs in warp mode except where a mode needs real-time (e.g.
interlace flicker).
- **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.**
## Atari 8-bit support
A second target platform is built in (`--platform atari`, or the **Platform**
selector in the GUI). It produces a **self-booting `.atr` disk** (written natively
— no external tools) whose boot sectors load a viewer that shows the picture.
![8 Bit Lenser targeting the Atari 8-bit](docs/gui_atari.png)
Both GR.15 modes pick their colours with the same **dither-aware** search as the
C64 (for error-diffusion dithers): the 4 register colours are chosen so their
*blends* span the image gamut, so floyd dithering reproduces a rich, smooth image
instead of the muddy result of plain k-means centroids (perceptual ΔE ~17 → ~2.7
on a portrait). Defaults to FloydSteinberg.
Atari display modes:
- **GR.15** (ANTIC E) — 160×192, 4 colours chosen globally from 256 (no per-cell
limit, so cleaner than C64 multicolor).
- **GR.9** (GTIA) — 80×192, **16 real luminance shades of one hue** — superb
greyscale (hue 0) or tinted monochrome (pick any hue). Choose the hue via the
Mono/hue base control.
- **GR.8** — 320×192 hi-res, two tones.
- **GR.15+DLI** — a display-list interrupt rewrites the 4 colours every 2 scanlines
(96 colour bands) for far more colour — the Atari analogue of C64 FLI. (Every
*single* line is impossible: four register writes don't fit the inter-DLI window.)
The Atari palette is generated (NTSC YIQ), or — if the `atari800` emulator's bundled
palette file is installed (see *Requirements*) — read from that so the preview
matches exactly. "Run in emulator" boots the `.atr` in **MAME** (`a800xl` /
`a800xlp`).
> Status: all four Atari modes are **boot-verified** in MAME (`a800xl`), in
> addition to decode round-trips, previews and disk-structure checks.
## Apple II support
A third platform (`--platform apple`, or the GUI **Platform** selector) targets the
Apple II+ / //e. It writes a **self-booting `.dsk`** (DOS 3.3 sector order) with a
native, no-DOS boot loader: the boot sector reads the 8K HGR bitmap across three
tracks (stepping the drive head) and switches on graphics.
- **HGR mono** — 280×192, 1-bit black & white, universal across II+ and //e.
(On a colour monitor a finely-dithered HGR image shows NTSC artifact fringing;
on a mono monitor it's clean B&W.) **Boot-verified in MAME.**
- **HGR colour** (`hgr_color`) — 140×192 NTSC artifact colour (~6 colours), the
iconic II+ colour mode; reuses the HGR loader. **Boot-verified in MAME** (real
green/violet/blue/orange on the emulated Apple).
- **DHGR** (`dhgr`) — //e Double Hi-Res, 140×192, **16 colours**, the best Apple
photo mode. **Boot-verified in MAME** (`apple2ee`). The 16-colour palette is
measured from MAME's own DHGR output so the on-screen colours match the preview.
All three boot via a self-written multi-track loader (DOS-free): it uses the
standard phase-overlap head seek and sets both the sector (`$3D`) and track (`$41`)
the Disk II boot ROM verifies. "Run in emulator" boots the `.dsk` in **MAME**
(`apple2p` for II+, `apple2ee` for //e).
## More platforms
The same converter targets several other machines (each writes a self-contained,
MAME-verified cartridge or disk; pick with `--platform` or the GUI selector).
Every platform reports a consistent **perceptual** ΔE (measured after a light blur
that models how the eye/CRT averages a dither), so quality is comparable across
machines; and every machine with a constrained colour mode also offers a
**monochrome** mode (luminance-matched, detail carried by dithering — the
highest-detail path, and tintable via a base colour):
- **TI-99/4A** (`ti99`) — TMS9918A Graphics Mode 2, 256×192 / 2 colours per 8×8
cell (like C64 hires); 8 KB `.rpk` cartridge. Each cell's colour pair is chosen
by the same **dither-aware** segment search as the C64 (for error-diffusion
dithers), roughly halving perceptual error on photos. Defaults to **Atkinson**
dithering — its lighter diffusion bleeds less across the tight two-colour cell
boundaries than FloydSteinberg. Also a **mono** mode (luminance-matched grey
ramp — every cell neutral, so no colour clash and maximum detail; or tinted via
a base colour). Verified in MAME (`ti99_4a`).
- **TRS-80 Color Computer** (`coco`) — MC6847 PMODE 4 (256×192 mono) / PMODE 3
(128×192, 4 colours); `.ccc` Program Pak.
- **BBC Micro Model B** (`bbc`) — MODE 0/1/2/5 (up to 8 colours); DFS `.ssd` disk.
- **ColecoVision / Adam** (`coleco`) — TMS9918A GM2 (same chip as the TI-99/4A, so
it reuses that encoder, including the **dither-aware** colour-pair search and the
**mono** mode; defaults to **Atkinson**); `.col` cartridge. Verified in MAME
(`coleco`).
- **Atari 2600 / VCS** (`a2600`) — racing-the-beam 40×192 playfield kernel, **3
colours per scanline** (the background is rewritten mid-line so the left and
right halves differ, plus a shared foreground); `.a26` cartridge. Defaults to
Atkinson dithering (Bayer fares poorly on the 40px playfield). An optional
**interlace** mode (`pf_il`) ships an 8K bank-switched cart that alternates two
frames at 60Hz so each scanline shows ~4-6 *perceived* colours — much smoother,
at the cost of flicker (best on emulators / LCDs).
- **Mattel Intellivision** (`intv`) — STIC Foreground/Background mode, 160×96 =
20×12 cells of 8×8, **two colours per cell** (foreground from 8, background from
all 16) like C64 hires, drawn from a 64-tile GRAM dictionary; a hand-written
**CP1610** viewer on a clean cartridge (no copyrighted EXEC/game data). `.int`
cartridge, verified in MAME (`intv`). The 64 tile shapes and the per-cell
(tile, foreground, background) choices are optimised **jointly** by a
block-truncation / vector-quantisation iteration (alternately re-assign each
cell to its best tile, re-pick its two colours, and re-cut every tile's shape),
which drives the picture to within ~1.5 % of the theoretical floor of the
two-colour-per-cell model. Defaults to *no* dithering — within the 64-tile
budget error-diffusion is counterproductive (the clustering destroys it). A
**mono** mode (two greys, k-medoids tile codebook) gives a cleaner two-tone
picture than the colour mode at this resolution.
- **Commodore VIC-20** (`vic20`) — the VIC-20 has no bitmap mode, so images are
drawn from a **programmable 256-character set** clustered (k-means) from the
screen's 8×8 cells. Two modes:
- **Multicolor** (default) — 88×184, **four colours per 4×8 cell**: three global
registers (background and auxiliary from all 16, border from 07) plus one
per-cell colour (07). Because the warm tones (orange/pink) live only in the
global colours, this is the strong photo mode. The three globals are chosen by
a dither-aware nearest-colour-ranked search; defaults to FloydSteinberg.
- **Hires** — 176×184, one global background (any of 16) + one per-cell
foreground (07); best for high-contrast line art.
- **Mono** — 176×184 luminance-matched two-tone (black + white, or a tinted
base); the 256-char dictionary is built by a k-medoids codebook so dithered
detail survives.
Self-programming 6502 viewer (the KERNAL leaves the VIC uninitialised for an
autostart cart, so the viewer sets every register and copies the data to RAM).
8K `.a0` autostart cartridge, verified in MAME (`vic20`).
- **Sinclair ZX Spectrum** (`spectrum`) — 256×192, **two colours per 8×8 cell**
(ink + paper) like C64 hires, with the Spectrum's quirk that a cell's two
colours must share the BRIGHT bit (so the pair is chosen from one brightness
group). Uses the same **dither-aware** segment search; defaults to **Atkinson**
(lighter diffusion suits the attribute cells, as on the TI-99). Output is a 48K
`.sna` snapshot that bakes the picture into screen RAM plus a 3-byte idle loop,
so it appears instantly and holds — plus the standard 6912-byte `.scr` screen
file alongside. Also a **mono** mode (crisp black/white halftone at 256×192,
free of attribute clash, or a tinted ramp). Verified in MAME (`spectrum`).
- **Atari 5200** (`a5200`) — the 5200 is an Atari 8-bit (ANTIC + GTIA, 6502) in a
console, so it **reuses the Atari converters** unchanged: **GR.15** (160×192,
4 colours, dither-aware), **GR.8** (320×192 two-tone) and **GR.9** (80×192,
16 real luminance shades = the greyscale / tinted-mono mode). Having no OS, the
self-contained 6502 viewer programs ANTIC/GTIA hardware directly (GTIA is at
$C000 on the 5200) and ANTIC DMAs the bitmap + display list straight from the
cartridge ROM — nothing is copied to RAM. All display durations are supported
(hold forever, until a controller button, or for *N* seconds — timed off ANTIC's
VCOUNT, input read from the POKEY keypad / GTIA triggers). 32 KB `.a52`
cartridge, verified in MAME (`a5200`).
- **Atari 7800** (`a7800`) — the 7800's **MARIA** display processor is nothing like
ANTIC/GTIA (display-list-list → per-zone display lists → objects, 8 palettes of
3 colours + a shared background = 25 colours on screen). Its 160A mode is 2bpp
though, the same packing as GR.15, so the Atari encoder helpers are reused.
**c160** (160×192, **25 colours**) splits each line into objects and clusters the
image's segments into 8 palettes so each region gets a tuned 4-colour set;
**mono** restricts those palettes to one hue's luminances for a smooth greyscale.
A self-contained 6502 viewer loads MARIA's colour registers and points it at the
display-list list; MARIA DMAs the bitmap + display lists straight from the
cartridge ROM. 48 KB `.a78` cartridge, verified in MAME (`a7800`).
- **Commodore 128** (`c128`) — drives the **VDC 8563** 80-column chip, with three
display modes that trade resolution against colour:
- **mono****640×200** greyscale, the **highest resolution** of any target
here. MAME's VDC has no true linear bitmap (its bitmap path emits only one
bit per 8-pixel cell), so — like `hicolor` — this draws through a per-image
custom character set, restricted to the VDC's four greys, for smooth
multi-level greyscale. `--mono-base` tints the grey ramp toward a colour.
- **hicolor****640×200** in colour via a per-image **custom character set**.
Each 8×8 cell draws a custom glyph (full per-pixel detail) in its own **ink**
colour (attribute RAM) over one **global background**, a ZX-Spectrum-like
colour model at double the Spectrum's horizontal resolution. The ~2000 cell
glyphs are vector-quantised to a **512-glyph** set (two VDC charset banks,
the second selected per cell by the attribute's alternate-charset bit).
- **color** — a chunky **80×100** image in all **16 VDC colours**, one free
solid colour per 8×2 cell (full colour, or smooth greyscale on the four
greys), the coarsest but most colourful option.
The 8502 viewer banks the data in over the BASIC ROM (`$FF00`), programs the
VDC, and copies into the VDC's own RAM with an explicit per-byte update address
(MAME's 8563 corrupts the auto-increment stream). Delivered as an autobooting
**`.d64`** that loads and runs with BASIC 7.0 `RUN"PIC"`, verified in MAME
(`c128`).
- **Commodore 16** (`c16`) — drives the **TED** (7360/8360) chip, whose palette
is **121 colours** (8 luminance levels × 16 hues) — far richer than the VIC-II.
**hires** is the TED's **320×200** bitmap: two colours per 8×8 cell, but each
may be any of the 121 colours (vs the VIC-II's 16), so photos come out
noticeably more colourful than C64 hires. The two per-cell colours are stored
across the TED's two colour matrices (a hue matrix + a luminance matrix). A tiny
7501 viewer programs the TED registers; the whole picture (matrices + bitmap)
loads into the C16's 16 KB RAM. Also a **mono** mode (the TED's neutral grey
ramp — 8 luminances — for smooth greyscale, tintable via `--mono-base`).
Delivered as a **`.prg`** (MAME quickload, or `LOAD`+`RUN` on real hardware),
verified in MAME (`c16`).
- **Commodore Plus/4** (`plus4`) — the C16's bigger sibling: same **TED** chip
and BASIC 3.5, just 64 KB RAM instead of 16 KB. The TED's bitmap hardware is
identical, so the Plus/4 uses the C16 encoder unchanged (same **hires** and
**mono** modes, same 121-colour palette) and the same **`.prg`** is binary
compatible across both machines. Verified in MAME (`plus4`).
- **Amstrad CPC** (`cpc`) — Z80 + Gate Array, with a fixed 27-colour palette and
three true bitmap modes (no per-cell colour limit). **mode0** (160×200) is the
flagship photo mode: a flat **16-colour** palette chosen from the 27 — the
encoder greedily picks the best 16 for each image, then dithers, giving clean,
colourful results. **mode1** (320×200, 4 colours) trades colour for resolution;
**mono** (mode 2, 640×200, 2 colours) is the highest-resolution black & white.
Like the ZX Spectrum, it's delivered as a **`.sna`** snapshot that bakes the
screen, palette, mode and an idle CPU, so the picture appears instantly with no
loader. Verified in MAME (`cpc6128`).
- **Tandy CoCo 3** (`coco3`) — the CoCo 2's big upgrade: the **GIME** chip with a
**64-colour** palette and true bitmap modes (no per-cell colour limit). **gr16**
(160×192) is the flagship: a flat **16-colour** palette picked from the 64 per
image, then dithered — clean, colourful results. **gr4** (320×192, 4 colours)
trades colour for resolution; **mono** (640×192, 2 colours) is the highest-res
black & white. A 6809 viewer in a 16 KB Program Pak copies the image to RAM and
programs the GIME (palette, mode, geometry, video base), then idles. Delivered
as a **`.ccc`** cartridge, verified in MAME (`coco3`; the app forces the RGB
monitor, since MAME defaults to the composite artifact palette).
- **Nintendo NES / Famicom** (`nes`) — the 2C02 PPU is **tile-based** (not a
bitmap), so this is the most constrained target: a 256×240 background built from
a **32×30 grid of 8×8 tiles** (≤256 unique in CHR), coloured by **4 sub-palettes**
(a shared universal background + 3 colours each) with one chosen per **16×16**
region via the attribute table, all from the NES's 54-colour master palette.
The encoder picks the universal bg, clusters the image into 4 sub-palettes,
assigns each region its best one, dithers, then vector-quantises the tile
patterns to 256 CHR tiles. **bg** is the colour mode; **mono** uses the PPU's
grey ramp. A 6502 viewer programs the PPU (palette, nametable, attributes) and
enables the background. Delivered as an **iNES `.nes`** (NROM) cartridge,
verified in MAME (`nes`). The blockiness is the NES's inherent per-16×16
attribute-clash and 256-tile limits, not the encoder.
- **Apple IIGS** (`iigs`) — its **Super Hi-Res** mode is the highest-fidelity
target here: **320×200** with **16 colours per scanline**, each line choosing
one of **16 palettes** of 16 colours drawn from a **4096-colour** master — so up
to ~256 colours on screen, and photos come out near-photographic. **shr** is the
colour mode (the encoder clusters the 200 lines into 16 palettes, assigns each
line its best, and dithers); **mono** is a single 16-level grey palette (the
smoothest greyscale in lenser). The picture data is the 32 KB SHR block; a
small loader (6502 + a 65C816 block move into bank `$E1`) fills the SHR screen
and turns it on. Delivered as a bootable **5.25" `.dsk`** (boots via slot 6 like
an Apple II), verified in MAME (`apple2gs`). Note: the 32 KB loads off the
emulated floppy, so the picture takes ~30 s to appear.
- **Commodore PET / CBM** (`pet2001`, `pet4032`, `pet8032`, `superpet`) — the PET
has no bitmap or colour at all, just a fixed-character monochrome text screen.
Images are rendered with the PETSCII **2×2 quadrant-block** graphics characters
(16 patterns, derived from the character ROM), giving a one-bit pseudo-bitmap of
**80×50** on the 40-column models (`pet2001` = PET 2001 / CBM 3000, `pet4032` =
PET 40xx / CBM 40xx) and **160×50** on the 80-column ones (`pet8032` = PET 80xx /
CBM 80xx, `superpet` = SuperPET SP9000 / MMF 9000). The `mono` mode dithers to
one bit and maps each 2×2 block to its quadrant character; a 6502 loader pokes
the screen codes into screen RAM (`$8000`). Delivered as a **`.prg`** (quickload
/ `LOAD`+`RUN`), verified in MAME. (MAME's `superpet` driver is incomplete, so
that platform targets the identical-display `cbm8032`.) Necessarily very low
resolution — it's a text terminal — but the dithered portrait is recognisable.
- **Sega Master System** (`sms`) — tile-based like the NES but far less
constrained: each 8×8 tile is **4bpp (16 colours)** and picks one of **two
16-colour palettes** from a 64-colour master, so up to **32 colours** on screen
at 256×192. The encoder builds palette 0 for the whole image and palette 1 for
the colours it serves worst, assigns each tile its better palette, dithers, and
vector-quantises the patterns to the 448 tiles that fit VRAM. **bg** is the
colour mode (near-photographic — much better than the NES); **mono** uses the
VDP's 4 true greys. A Z80 viewer programs the VDP and uploads the
tiles/name-table/palette. Delivered as an iNES-free **`.sms`** cartridge (with a
valid "TMR SEGA" header), verified in MAME (`sms`).
- **Commodore Amiga** (`amiga`) — the 68000 graphics machine. **lowres** is the
flagship: 320×200 with **32 colours from 4096** (a true flat-palette bitmap, no
per-cell limit) — clean, near-photographic. **mono** uses the Amiga's 16 real
grey levels. A 68000 boot block loads the bitplanes into chip RAM via the boot
trackdisk request and points the Copper at them. Delivered as a bootable
**`.adf`** floppy (valid boot-block checksum), verified in MAME (`a500`). (The
Amiga's famous **HAM** 4096-colour mode was also implemented and looks superb,
but MAME's preliminary Amiga can't render 6-bitplane/HAM modes cleanly — black
bands at the screen edges — so only the MAME-verified 32-colour and mono modes
ship.)
## ANSI / BBS art (CP437)
Not a machine at all: `--platform ansi` (or the GUI **Platform** selector) emits a
**`.ANS` text file** — CP437 characters plus ANSI colour escapes — for display on a
BBS or in any ANSI art viewer. There's **no disk, no emulator, and no slideshow**;
it's a single text artefact, and it's fully **previewable** in the GUI.
Every cell picks its colours from the **16 EGA/VGA colours**, using **iCE colours**
(a bright background via the blink bit) so photos have a full palette. Two encoders,
chosen by the **Intensive** toggle:
- **Full glyph** (Intensive on, the default) — matches every **8×16** character cell
to the best of the *whole* CP437 repertoire (letters, punctuation, box-drawing and
block glyphs) together with an optimal foreground/background colour pair, by
minimum CIELAB error. Gradients turn into shade characters, so the result is far
richer than blocks alone. A blue-noise pre-dither kills banding.
- **Half-block** (Intensive off, fast) — every cell is the upper-half block `▀` with
an independent foreground (top pixel) and background (bottom pixel), giving two
freely-coloured pixel rows per text row with no cell-colour clash.
Modes set the canvas: **`80x25`** (one classic screen), **`80x50`** (taller, more
detail) and **`mono`** (a greyscale ramp, tintable toward any hue via the mono base
control). The output is verified byte-faithful — an independent CP437 re-render of
the `.ANS` reproduces the preview exactly.
```sh
python -m lenser.cli photo.jpg --platform ansi -m 80x50 -o photo.ans
```
## 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.
**Required**
On Debian/Ubuntu:
- **Python 3.9+**, with **`numpy`** and **`Pillow`**. That's all the ANSI/CP437
export needs.
**Optional external tools.** These are located by name on your **`PATH`** — the app
runs `xa`, `c1541`, `mame` as bare commands (via `shutil.which`), with no config
file or environment-variable override. If a tool isn't on `PATH`, the feature that
needs it is disabled (the GUI greys it out; the CLI raises a clear error naming the
missing command). Install them so the command is on `PATH`, or symlink the binary
into a `PATH` directory.
- **`PyQt5`** — only for the GUI (`import`ed at GUI start-up); the CLI works without
it. Install into the same Python environment you run lenser with.
- **[`xa`](https://www.floodgap.com/retrotech/xa/) (xa65)** — the 6502 assembler
that builds the on-machine viewers. Required to export for any real machine
(C64, Atari, Apple, …); looked up as the command **`xa`** on `PATH`. Not needed
for the ANSI export or for image previews.
- **[VICE](https://vice-emu.sourceforge.io/)'s `c1541`** — builds the Commodore
disk images (`.d64` / `.d71` / `.d81`, and the C128 `.d64`). Looked up as the
command **`c1541`** on `PATH` (it ships with VICE). Only needed when exporting
those Commodore disk formats.
- **[MAME](https://www.mamedev.org/)** — powers the **Run in emulator** button for
**every** platform (the C64 and Atari included; there is no VICE/atari800 runner).
Looked up as the command **`mame`** on `PATH`. MAME finds its own system ROMs via
its normal `rompath`; lenser only launches it. Purely optional — it's never needed
to *produce* a disk or cartridge, only to preview one.
- **[atari800](https://atari800.github.io/)** — *optional palette source only* (it
is **not** used to run anything). If its bundled NTSC palette file is present,
lenser reads it so the Atari preview matches the emulator exactly; otherwise a
generated YIQ palette is used. It is searched for at these fixed paths (not on
`PATH`), first match wins:
- `/usr/share/atari800/Palettes/Real.act`
- `/usr/share/atari800/default.pal`
- `/usr/local/share/atari800/default.pal`
On Debian/Ubuntu, installing the packages puts every command on `PATH`:
```sh
sudo apt install python3-numpy python3-pil python3-pyqt5 xa65 vice
sudo apt install python3-numpy python3-pil python3-pyqt5 xa65 vice mame atari800
```
## Usage
@ -65,33 +450,73 @@ sudo apt install python3-numpy python3-pil python3-pyqt5 xa65 vice
### GUI
```sh
python -m c64view.gui # or: c64view
python -m lenser.gui # or: 8bitlenser
```
Open an image, pick a mode / disk format / dithering, watch the live C64 preview,
Open an image (via **Open image…** or by **dragging an image file onto the
window**), pick a mode / disk format / dithering, watch the live C64 preview,
then **Export**.
### Slideshow (multiple images on one disk)
Tune an image, click **Add to slideshow**, repeat for as many images as you like
(each keeps its own mode / dithering / palette / adjustments), then open
**Slideshow…** to arrange the queue and **Export** one drive image. The dialog
shows a **live storage meter** and refuses to export once the queue exceeds the
chosen disk format's capacity (or its directory limit), so you always know how
many more images fit. Choose how the viewer advances: on a **keypress**,
automatically after **N seconds**, or **both** (keys still work, but it
auto-advances on the timer), and whether it **loops**. On the C64 the exported
disk boots with `LOAD"*",8,1` then `RUN` and steps through the pictures.
On the **C64**, hires, multicolor and mono may be **mixed freely** in one
slideshow; **FLI** and **interlace** are supported too, but as *uniform* shows
(all-FLI or all-interlace) since each needs its own raster engine. The
**Commodore 128** (640×200 VDC hicolor/mono, `.d64` booted with `RUN"PIC"`), the
**Atari** (GR.15 / GR.9 / GR.8 / GR.15+DLI, self-booting `.atr` that SIO-loads each slide), the **BBC
Micro** (single screen mode, DFS `.ssd` that `*LOAD`s each slide), the
**Apple II** (HGR, self-booting `.dsk`; up to 4 images loaded into RAM at once),
the **Apple IIgs** (Super Hi-Res, self-booting `.dsk` with a two-stage 65816
loader that banks each 32 KB image; up to 4), and the **Commodore Amiga**
(lo-res/HAM, bootable `.adf` whose 68000 boot block loads every image into chip
RAM and cycles them) are also supported — **all seven disk platforms**.
Headless equivalent — a JSON manifest fed to `8bitlenser-slideshow`:
```sh
python -m lenser.slideshow_cli show.json -o show.d64
```
where `show.json` lists the global options (`platform`, `format`, `advance`,
`seconds`, `loop`) and an `items` array, each item an `image` path plus any
per-image `mode` / `dither` / `palette` / `brightness` / `contrast` / `tint` /
… overrides.
### 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
python -m lenser.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
python -m lenser.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
python -m lenser.cli photo.jpg -m fli -d floyd --intensive -o photo.d64
# Atari: 16-shade greyscale, and a 4-colour-per-line image:
python -m lenser.cli photo.jpg --platform atari -m gr9 -d floyd -o photo.atr
python -m lenser.cli photo.jpg --platform atari -m gr15dli -o photo.atr
# High-res greyscale with smooth Stucki dithering:
python -m c64view.cli photo.jpg -m mono -d stucki -o photo.d64
python -m lenser.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
python -m lenser.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}`,
`-d/--dither {bayer,bluenoise,yliluoma,floyd,atkinson,stucki,jarvis,sierra,sierra_lite,burkes,riemersma,ostromoukhov,none}`,
`--mono-base {grayscale,<colour name>}`, `--video {pal,ntsc}`,
`-a/--aspect {fit,fill,stretch}`, `--intensive`,
`--brightness/--contrast/--saturation/--gamma`, `--preview`.
@ -112,12 +537,14 @@ Press any key to return to BASIC (hires/multicolor). For FLI/interlace, reset to
- **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
`lenser/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

View file

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

View file

@ -1,80 +0,0 @@
"""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

@ -1,124 +0,0 @@
"""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())

View file

@ -1,136 +0,0 @@
"""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)

View file

@ -1,29 +0,0 @@
"""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)

View file

@ -1,490 +0,0 @@
"""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())

View file

@ -1,82 +0,0 @@
"""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

BIN
docs/gui_atari.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

2
lenser.sh Executable file
View file

@ -0,0 +1,2 @@
#!/usr/bin/env bash
python3 -m lenser.gui

3
lenser/__init__.py Normal file
View file

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

1
lenser/a2600/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Atari 2600 (VCS) image conversion and cartridge export."""

View file

@ -0,0 +1,19 @@
"""Atari 2600 conversion dispatch."""
from __future__ import annotations
from ... import imageprep
from . import pf
# "pf" = flicker-free 3-colour/scanline; "pf_il" = temporal interlace (two
# frames at 60Hz blend to ~4-6 perceived colours/scanline, at the cost of flicker);
# "mono" = pf restricted to the TIA's greys (one hue's 8 luminances).
MODES = ["pf", "pf_il", "mono"]
def convert_image(path_or_img, mode="pf", palette_name="tia",
dither_mode="floyd", intensive=False, prep_opt=None, base_color=None):
prep_opt = prep_opt or imageprep.PrepOptions()
img_rgb = imageprep.prepare(path_or_img, pf.WIDTH, pf.HEIGHT,
pf.PIXEL_ASPECT, prep_opt, border_rgb=(0, 0, 0))
return pf.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color, interlace=(mode == "pf_il"),
gray=(mode == "mono"))

144
lenser/a2600/convert/pf.py Normal file
View file

@ -0,0 +1,144 @@
"""Atari 2600 playfield image: 40x192, 3 colours per scanline.
No framebuffer -- the picture is a table the "racing the beam" kernel feeds to
the TIA one scanline at a time: a 40-pixel asymmetric playfield (left 20 + right
20) plus a shared playfield colour (COLUPF) and TWO background colours -- COLUBK
is rewritten mid-line, so the left and right 20px halves each get their own
background. Per line we jointly pick the shared foreground and the two
backgrounds that minimise dithered error, then dither each half between its two
colours.
"""
from __future__ import annotations
import numpy as np
from ... import dither
from ...convert.base import Conversion, perceptual_error, _box_blur
from ...palette import srgb_to_lab
from .. import palette as tpal
WIDTH, HEIGHT = 40, 192
HALF = WIDTH // 2
PIXEL_ASPECT = (4 / 3) / (WIDTH / HEIGHT) # very wide pixels
def _best_triples(img_lab, plab, split_gain=0.12, cand=None):
"""Per scanline choose a shared foreground + a background for each 20px half.
For a fixed foreground f, each half's best error is min over its background
bg of sum_px min(d[px,f], d[px,bg]); we pick the f minimising left+right.
To avoid a gratuitous vertical seam down the centre, the two halves keep a
UNIFIED background unless splitting it reduces the line's error by more than
``split_gain`` (so a seam only appears where the image really differs L/R).
``cand`` (palette indices) restricts the usable colours, e.g. to the greys.
Returns (fg, bgL, bgR) index arrays."""
fg = np.zeros(HEIGHT, np.int64)
bgL = np.zeros(HEIGHT, np.int64)
bgR = np.zeros(HEIGHT, np.int64)
forbid = None
if cand is not None:
forbid = np.full(plab.shape[0], np.inf)
forbid[np.asarray(cand)] = 0.0
for y in range(HEIGHT):
d = np.sum((img_lab[y][:, None, :] - plab[None, :, :]) ** 2, axis=-1) # (40,P)
if forbid is not None:
d = d + forbid[None, :] # forbid non-candidate colours
dl = d[:HALF].T # (P, 20)
dr = d[HALF:].T
# G?[f,bg] = sum_px min(d[f,px], d[bg,px])
GL = np.minimum(dl[:, None, :], dl[None, :, :]).sum(-1) # (P,P)
GR = np.minimum(dr[:, None, :], dr[None, :, :]).sum(-1)
total = GL.min(1) + GR.min(1)
f = int(total.argmin())
fg[y] = f
split_err = total[f]
uni = GL[f] + GR[f] # same bg for both halves
bg_uni = int(uni.argmin())
uni_err = float(uni[bg_uni])
if split_err < uni_err * (1.0 - split_gain):
bgL[y] = int(GL[f].argmin())
bgR[y] = int(GR[f].argmin())
else:
bgL[y] = bgR[y] = bg_uni # unified -> no seam on this line
return fg, bgL, bgR
def _encode_frame(img_rgb, plab, dither_mode, cand=None):
"""Encode one frame -> (9-table data bytes, idx image (192x40 palette indices))."""
img_lab = srgb_to_lab(img_rgb)
fg, bgL, bgR = _best_triples(img_lab, plab, cand=cand)
allowed = np.empty((HEIGHT, WIDTH, 2), np.int64)
allowed[:, :HALF, 0] = bgL[:, None]; allowed[:, :HALF, 1] = fg[:, None]
allowed[:, HALF:, 0] = bgR[:, None]; allowed[:, HALF:, 1] = fg[:, None]
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
bit = (idx == fg[:, None]).astype(np.uint8) # 1 -> playfield
pf0L = np.zeros(HEIGHT, np.uint8); pf1L = np.zeros(HEIGHT, np.uint8)
pf2L = np.zeros(HEIGHT, np.uint8); pf0R = np.zeros(HEIGHT, np.uint8)
pf1R = np.zeros(HEIGHT, np.uint8); pf2R = np.zeros(HEIGHT, np.uint8)
colubkL = np.zeros(HEIGHT, np.uint8); colubkR = np.zeros(HEIGHT, np.uint8)
colupf = np.zeros(HEIGHT, np.uint8)
for y in range(HEIGHT):
pf0L[y], pf1L[y], pf2L[y] = tpal.pack20(bit[y, :HALF])
pf0R[y], pf1R[y], pf2R[y] = tpal.pack20(bit[y, HALF:])
colubkL[y] = tpal.color_byte(int(bgL[y]))
colubkR[y] = tpal.color_byte(int(bgR[y]))
colupf[y] = tpal.color_byte(int(fg[y]))
data = b"".join(bytes(t) for t in
(pf0L, pf1L, pf2L, pf0R, pf1R, pf2R, colubkL, colubkR, colupf))
return data, idx
def _widen(rgb):
disp_w = int(round(WIDTH * PIXEL_ASPECT))
xs = (np.arange(disp_w) * WIDTH) // disp_w
return rgb[:, xs]
def convert(img_rgb, palette_name="tia", dither_mode="floyd",
intensive=False, base_color=None, interlace=False, gray=False):
plab = tpal.palette_lab()
pal = tpal.PALETTE.astype(np.float64)
img_lab = srgb_to_lab(img_rgb)
# mono mode restricts the TIA to one hue's 8 luminances (hue 0 = greys, or
# the hue of base_color for a tinted mono); colour index = hue*8 + lum.
cand = None
if gray:
hue = 0 if base_color is None else (int(base_color) // 8)
cand = list(range(hue * 8, hue * 8 + 8))
if interlace:
# Frame A approximates the image; frame B is encoded so the per-pixel
# blend (averaged in linear light, the way 60Hz flicker is perceived)
# lands on the target -- giving ~4-6 perceived colours per scanline.
from ...palette import srgb_to_linear, linear_to_srgb
dataA, idxA = _encode_frame(img_rgb, plab, dither_mode, cand=cand)
renA = pal[idxA]
linT, linA = srgb_to_linear(img_rgb.astype(np.float64)), srgb_to_linear(renA)
targetB = linear_to_srgb(np.clip(2 * linT - linA, 0, 1))
dataB, idxB = _encode_frame(targetB, plab, dither_mode, cand=cand)
renB = pal[idxB]
blend = np.clip(linear_to_srgb((srgb_to_linear(renA) +
srgb_to_linear(renB)) / 2), 0, 255)
blend_lab = srgb_to_lab(blend)
# perceptual (blurred) error -- credits the 60Hz flicker blend the eye averages
err = float(np.sqrt(((_box_blur(blend_lab) - _box_blur(img_lab)) ** 2)
.sum(-1)).mean())
return Conversion(
mode="pf_il", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idxA.astype(np.uint16), data=dataA + dataB, data_addr=0,
viewer="a2600", preview_rgb=_widen(blend.astype(np.uint8)),
error=err, meta={"palette": "tia", "dither": dither_mode, "interlace": True},
)
data, idx = _encode_frame(img_rgb, plab, dither_mode, cand=cand)
return Conversion(
mode="mono" if gray else "pf", width=WIDTH, height=HEIGHT,
pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=0,
viewer="a2600", preview_rgb=_widen(tpal.PALETTE.astype(np.uint8)[idx]),
error=perceptual_error(idx, img_lab, plab),
meta={"palette": "tia", "dither": dither_mode},
)

12
lenser/a2600/exporter.py Normal file
View file

@ -0,0 +1,12 @@
"""Build an Atari 2600 cartridge (.a26) from a conversion."""
from __future__ import annotations
from .viewer.assemble import build_cart
def export_a26(conv, output_path, source_path=None, display="forever", seconds=0):
if not output_path.lower().endswith((".a26", ".bin")):
output_path += ".a26"
rom = build_cart(conv.data, display=display, seconds=seconds)
with open(output_path, "wb") as f:
f.write(rom)
return output_path

81
lenser/a2600/palette.py Normal file
View file

@ -0,0 +1,81 @@
"""Atari 2600 TIA (NTSC) palette + playfield bit packing.
The TIA colour byte is (hue << 4) | (lum << 1): 16 hues x 8 luminances = 128
colours (bit 0 unused, so register values are even 0..254). We generate the
NTSC palette with a YIQ formula -- close enough to be recognisable; the encoder
matches against it in CIELAB.
"""
from __future__ import annotations
import numpy as np
from ..palette import srgb_to_lab
TIA_NTSC = [
(0,0,0), (44,44,44), (83,83,83), (119,119,119),
(154,154,154), (188,188,188), (222,222,222), (255,255,255),
(33,11,0), (73,53,0), (109,90,0), (145,126,0),
(179,161,44), (213,195,83), (246,229,119), (255,255,154),
(60,0,0), (97,35,0), (133,74,0), (168,110,26),
(202,146,66), (235,180,103), (255,214,139), (255,247,173),
(74,0,0), (111,18,0), (146,58,29), (181,96,68),
(215,132,105), (248,167,141), (255,201,175), (255,234,209),
(75,0,0), (112,5,43), (147,48,81), (182,86,118),
(215,122,153), (249,157,187), (255,191,221), (255,225,254),
(62,0,56), (99,1,93), (135,45,129), (170,83,164),
(204,119,198), (237,154,232), (255,189,255), (255,222,255),
(36,0,97), (75,7,133), (112,49,168), (147,87,202),
(182,123,235), (215,159,255), (248,193,255), (255,226,255),
(0,0,118), (43,21,153), (82,61,187), (118,99,221),
(153,134,254), (188,169,255), (221,203,255), (254,236,255),
(0,0,117), (10,38,152), (51,77,187), (89,113,220),
(125,149,253), (160,183,255), (195,217,255), (228,250,255),
(0,15,94), (0,56,130), (25,93,165), (65,129,199),
(102,164,233), (138,198,255), (173,232,255), (206,255,255),
(0,32,53), (0,71,91), (10,108,127), (52,143,162),
(90,178,196), (126,212,229), (161,245,255), (195,255,255),
(0,41,0), (0,80,38), (12,116,77), (53,152,114),
(91,186,149), (127,220,183), (162,253,217), (196,255,250),
(0,44,0), (0,82,0), (29,118,24), (69,154,64),
(106,188,102), (141,221,137), (176,255,172), (210,255,206),
(0,38,0), (14,77,0), (56,114,0), (93,149,24),
(129,183,64), (164,217,101), (198,250,137), (232,255,171),
(7,24,0), (50,64,0), (88,102,0), (124,137,0),
(159,172,43), (193,206,82), (226,239,118), (255,255,153),
(42,5,0), (80,48,0), (117,86,0), (152,122,6),
(186,157,48), (220,191,86), (253,225,122), (255,255,158),
]
PALETTE = np.array(TIA_NTSC, dtype=np.float64) # (128,3) sRGB, calibrated from MAME
def color_byte(index: int) -> int:
"""TIA register value (even 0..254) for palette index hue*8+lum."""
hue, lum = divmod(index, 8)
return (hue << 4) | (lum << 1)
def palette_lab() -> np.ndarray:
return srgb_to_lab(PALETTE)
# ---- playfield packing -----------------------------------------------------
# The 20 playfield pixels (left to right) map to the PF registers in this order:
# px 0-3 PF0 bits 4,5,6,7
# px 4-11 PF1 bits 7,6,5,4,3,2,1,0
# px 12-19 PF2 bits 0,1,2,3,4,5,6,7
def pack20(bits) -> tuple[int, int, int]:
"""20 pixel on/off values (leftmost first) -> (PF0, PF1, PF2) bytes."""
pf0 = pf1 = pf2 = 0
for i in range(4):
if bits[i]:
pf0 |= 1 << (4 + i)
for i in range(8):
if bits[4 + i]:
pf1 |= 1 << (7 - i)
for i in range(8):
if bits[12 + i]:
pf2 |= 1 << i
return pf0, pf1, pf2

View file

@ -0,0 +1 @@

138
lenser/a2600/viewer/a2600.s Normal file
View file

@ -0,0 +1,138 @@
; lenser -- Atari 2600 (VCS) image viewer kernel (6502, "racing the beam").
;
; No framebuffer -- per visible scanline the kernel feeds the TIA a 40-pixel
; asymmetric playfield (left 20 + right 20, rewritten mid-line) plus a shared
; playfield colour (COLUPF) and two backgrounds -- COLUBK is set for the left
; half during HBLANK then rewritten mid-line for the right half, so each
; scanline shows 3 colours (left bg + right bg + foreground). The nine 192-byte
; data tables are page-aligned (set by the cartridge builder) so LDA tab,Y never
; crosses a page and the kernel stays cycle-exact.
;
; #defines from the wrapper -- PF0L,PF1L,PF2L,PF0R,PF1R,PF2R,BKL,BKR,PFT (table
; bases), WAITMODE (0 forever / 1 fire / 2 seconds), WAITSECS.
;
; assembled by a2600/viewer/assemble.py via xa
VSYNC = $00
VBLANK = $01
WSYNC = $02
NUSIZ0 = $04
COLUPF = $08
COLUBK = $09
CTRLPF = $0A
PF0 = $0D
PF1 = $0E
PF2 = $0F
INPT4 = $3C
FRLO = $80 ; frame counter (seconds mode)
FRHI = $81
PARITY = $82 ; interlace frame parity (IL mode)
* = $f000
start:
sei
cld
ldx #0
txa
clr:
sta $00,x
inx
bne clr ; clear $00-$FF (RAM + TIA)
ldx #$ff
txs
frame:
lda #2
sta VSYNC
sta WSYNC
sta WSYNC
sta WSYNC ; 3 VSYNC lines
lda #0
sta VSYNC
lda #2
sta VBLANK
sta CTRLPF ; reflect off (we draw both halves explicitly)
lda #0
sta CTRLPF
#if IL
; temporal interlace -- alternate the two 4K banks (frame A / frame B)
; each frame; the kernel is identical in both banks so execution
; continues seamlessly and only the data tables differ.
lda PARITY
eor #1
sta PARITY
beq selbank0
lda $1ff9 ; F8 hotspot -> select bank 1 (AD F9 1F)
jmp bankdone
selbank0:
lda $1ff8 ; F8 hotspot -> select bank 0 (AD F8 1F)
bankdone:
#endif
ldx #36
vbl:
sta WSYNC
dex
bne vbl
lda #0
sta VBLANK
; ---- visible image, 192 cycle-exact scanlines ----
ldy #0
kloop:
sta WSYNC
lda BKL,y ; left background (HBLANK)
sta COLUBK
lda PFT,y ; shared foreground
sta COLUPF
lda PF0L,y
sta PF0
lda PF1L,y
sta PF1
lda PF2L,y
sta PF2
lda PF0R,y ; right playfield PF0 (early, before mid-line)
sta PF0
lda BKR,y ; right background (lands at the half boundary)
sta COLUBK
lda PF1R,y
sta PF1
lda PF2R,y
sta PF2
iny
cpy #192
bne kloop
lda #0
sta PF0
sta PF1
sta PF2
sta COLUBK
; ---- hold / exit ----
#if WAITMODE == 1
lda INPT4
bmi over ; bit7 set = fire not pressed
jmp start ; pressed -> reset (re-display)
#endif
#if WAITMODE == 2
inc FRLO
bne fr_ok
inc FRHI
fr_ok:
lda FRHI
cmp #>(WAITSECS*60)
bcc over
lda FRLO
cmp #<(WAITSECS*60)
bcc over
jmp start
#endif
over:
lda #2
sta VBLANK
ldx #30
ovl:
sta WSYNC
dex
bne ovl
jmp frame

View file

@ -0,0 +1,92 @@
"""Assemble the Atari 2600 kernel with `xa` and lay out the cartridge.
A static image is a 4K cart (one kernel + nine 192-byte tables). An interlace
image is an 8K F8-bankswitch cart: two 4K banks, each holding the SAME kernel
plus one of the two table sets (frame A / frame B). The kernel toggles the bank
once per frame, so the two frames alternate at 60Hz and blend to ~4-6 perceived
colours per scanline.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
WAIT_MODES = {"forever": 0, "fire": 1, "key": 1, "seconds": 2}
# page-aligned data tables ($F100..$F900)
TABLES = {"PF0L": 0xF100, "PF1L": 0xF200, "PF2L": 0xF300, "PF0R": 0xF400,
"PF1R": 0xF500, "PF2R": 0xF600, "BKL": 0xF700, "BKR": 0xF800,
"PFT": 0xF900}
ORDER = ["PF0L", "PF1L", "PF2L", "PF0R", "PF1R", "PF2R", "BKL", "BKR", "PFT"]
class AssemblerError(RuntimeError):
pass
def have_xa() -> bool:
return shutil.which("xa") is not None
def _assemble_kernel(display: str, seconds: int, interlace: bool) -> bytes:
if not have_xa():
raise AssemblerError("The 'xa' (xa65) assembler was not found on PATH.\n"
"Install it with: sudo apt install xa65")
waitmode = WAIT_MODES.get(display, 0)
defs = "".join(f"#define {n} ${a:04X}\n" for n, a in TABLES.items())
wrapper = (defs +
f"#define WAITMODE {waitmode}\n"
f"#define WAITSECS {max(0, int(seconds))}\n"
f"#define IL {1 if interlace else 0}\n"
'#include "a2600.s"\n')
with tempfile.TemporaryDirectory() as td:
out = os.path.join(td, "k.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:
kernel = f.read()
finally:
os.unlink(wrap)
if len(kernel) > 0x100:
raise AssemblerError(f"kernel is {len(kernel)} bytes, overruns the table "
"page at $F100")
return kernel
def _lay_bank(kernel: bytes, data: bytes) -> bytearray:
"""One 4096-byte bank: kernel at $F000, nine tables, reset/IRQ vectors."""
rom = bytearray(b"\x00" * 4096)
rom[0:len(kernel)] = kernel
tables = [data[i * 192:(i + 1) * 192] for i in range(9)]
for name, tab in zip(ORDER, tables):
off = TABLES[name] - 0xF000
rom[off:off + 192] = tab
rom[0xFFC] = 0x00 # reset vector lo -> $F000
rom[0xFFD] = 0xF0
rom[0xFFE] = 0x00 # IRQ/BRK vector
rom[0xFFF] = 0xF0
return rom
def build_cart(data: bytes, display: str = "forever", seconds: int = 0) -> bytes:
"""data = nine 192-byte tables (static, 4K cart) or eighteen (two sets ->
interlace 8K F8 cart). Returns the cart image with reset vectors set."""
if len(data) == 9 * 192:
kernel = _assemble_kernel(display, seconds, interlace=False)
return bytes(_lay_bank(kernel, data))
if len(data) == 18 * 192:
kernel = _assemble_kernel(display, seconds, interlace=True)
bank0 = _lay_bank(kernel, data[:9 * 192])
bank1 = _lay_bank(kernel, data[9 * 192:])
return bytes(bank0 + bank1)
raise ValueError(f"expected 1728 or 3456 bytes of tables, got {len(data)}")

1
lenser/a5200/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Atari 5200 SuperSystem target for lenser."""

View file

@ -0,0 +1,23 @@
"""Atari 5200 conversion dispatch.
The 5200 has the same ANTIC + GTIA graphics hardware (and the same 256-colour GTIA
palette) as the Atari 400/800, so it reuses the Atari 8-bit encoders unchanged.
GR.9 doubles as the monochrome / tinted-mono mode (16 real luminance shades of one
hue). GR.15+DLI is omitted: per-scanline DLI colour changes need OS NMI vectoring
the 5200 lacks.
"""
from __future__ import annotations
from ...atari.convert import convert_image as _atari_convert
MODES = ["gr15", "gr9", "gr8", "mono"]
def convert_image(path_or_img, mode="gr15", palette_name="ntsc",
dither_mode="floyd", intensive=False, prep_opt=None,
base_color=None):
if mode not in MODES:
mode = "gr15"
return _atari_convert(path_or_img, mode=mode, palette_name="ntsc",
dither_mode=dither_mode, intensive=intensive,
prep_opt=prep_opt, base_color=base_color)

17
lenser/a5200/exporter.py Normal file
View file

@ -0,0 +1,17 @@
"""Build an Atari 5200 cartridge (.a52) from a conversion."""
from __future__ import annotations
from .viewer import assemble
_EXTS = (".a52", ".bin", ".car", ".rom")
def export_a52(conv, output_path, source_path=None, display="forever",
seconds=0, video="ntsc"):
if not output_path.lower().endswith(_EXTS):
output_path += ".a52"
rom = assemble.build_cart(conv.mode, bytes(conv.data), display=display,
seconds=seconds, video=video)
with open(output_path, "wb") as f:
f.write(rom)
return output_path

View file

@ -0,0 +1 @@
"""Atari 5200 6502 viewer (assembled by xa)."""

View file

@ -0,0 +1,131 @@
"""Assemble the Atari 5200 viewer with `xa` and lay out the 32K cartridge.
The cartridge fills $4000-$BFFF. The 5200 BIOS jumps to the address at $BFFE-F
(duplicated at $BFE8-9, with $BFFC=0 / $BFFD=$FF) -- verified in MAME a5200.
ANTIC DMAs the bitmap and display list straight from cartridge ROM, so we only
place them at fixed cart addresses and point ANTIC at them:
$4000 viewer code
$4100 GTIA register script (offset, value pairs, $FF-terminated)
$4200 display list (ANTIC reads it here)
$6000 bitmap (the converter's 4K-split blob -> $6000 / $7000)
"""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
CART_BASE = 0x4000
CART_SIZE = 0x8000
SCRIPT_ADDR = 0x4100
DLIST_ADDR = 0x4200
BITMAP_ADDR = 0x6000 # 4K-aligned: blob $4000/$5000 -> $6000/$7000
SPLIT_LINE = 102 # matches atari _common.split_screen
LINES = 192
# ANTIC mode byte + GTIA register script per display mode. Script entries are
# (GTIA offset from $C000, colour value); colours come from the converter blob.
ANTIC_MODE = {"gr15": 0x0E, "gr8": 0x0F, "gr9": 0x0F}
WAIT_MODES = {"forever": 0, "key": 1, "seconds": 2}
def _gtia_script(mode: str, colors) -> bytes:
c = list(colors)
if mode == "gr15": # 4 colours: COLBK, COLPF0, COLPF1, COLPF2
regs = [(0x1A, c[0]), (0x16, c[1]), (0x17, c[2]), (0x18, c[3]), (0x1B, 0x00)]
elif mode == "gr8": # 2 colours: background (COLPF2+COLBK), fg (COLPF1)
regs = [(0x18, c[0]), (0x1A, c[0]), (0x17, c[1]), (0x1B, 0x00)]
else: # gr9: hue in COLBK, GTIA mode 9 via PRIOR
regs = [(0x1A, c[0]), (0x1B, 0x40)]
out = bytearray()
for off, val in regs:
out += bytes([off & 0xFF, val & 0xFF])
out.append(0xFF) # terminator
return bytes(out)
def _dlist(mode: str) -> bytes:
m = ANTIC_MODE[mode]
dl = bytearray([0x70, 0x70, 0x70]) # 24 blank scan lines
dl += bytes([0x40 | m, 0x00, 0x60]) # LMS -> $6000
dl += bytes([m] * (SPLIT_LINE - 1)) # 102 lines from $6000
dl += bytes([0x40 | m, 0x00, 0x70]) # LMS -> $7000
dl += bytes([m] * (LINES - SPLIT_LINE - 1)) # 90 lines from $7000
dl += bytes([0x41, DLIST_ADDR & 0xFF, DLIST_ADDR >> 8]) # JVB -> dlist (loop)
return bytes(dl)
class AssemblerError(RuntimeError):
pass
def have_xa() -> bool:
return shutil.which("xa") is not None
def _assemble(display: str = "forever", seconds: int = 0,
video: str = "ntsc") -> bytes:
if not have_xa():
raise AssemblerError("The 'xa' (xa65) assembler was not found on PATH.\n"
"Install it with: sudo apt install xa65")
waitmode = WAIT_MODES.get(display, 0)
rate = 50 if video == "pal" else 60
wrapper = (f"#define SCRIPT ${SCRIPT_ADDR:04X}\n"
f"#define DLIST ${DLIST_ADDR:04X}\n"
f"#define WAITMODE {waitmode}\n"
f"#define WAITSECS {max(1, int(seconds))}\n"
f"#define RATE {rate}\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_cart(mode: str, data: bytes, display: str = "forever",
seconds: int = 0, video: str = "ntsc") -> bytes:
"""data = the atari converter blob (bitmap 4K-split at offset 0/0x1000, colour
register bytes from offset 0x2000)."""
if mode not in ANTIC_MODE:
raise ValueError(f"unsupported 5200 mode {mode}")
code = _assemble(display, seconds, video)
bitmap = data[:0x2000]
colors = data[0x2000:]
script = _gtia_script(mode, colors)
dlist = _dlist(mode)
rom = bytearray(b"\xff" * CART_SIZE)
def place(addr, blob):
off = addr - CART_BASE
rom[off:off + len(blob)] = blob
place(CART_BASE, code)
place(SCRIPT_ADDR, script)
place(DLIST_ADDR, dlist)
place(BITMAP_ADDR, bitmap)
# cartridge header (verified in MAME a5200)
def put16(addr, val):
off = addr - CART_BASE
rom[off] = val & 0xFF
rom[off + 1] = (val >> 8) & 0xFF
put16(0xBFFE, CART_BASE) # start address -- BIOS jumps here
put16(0xBFE8, CART_BASE) # duplicate (validation)
rom[0xBFFC - CART_BASE] = 0x00
rom[0xBFFD - CART_BASE] = 0xFF
return bytes(rom)

View file

@ -0,0 +1,53 @@
; Atari 5200 "how long to show the picture" epilogue (no OS, pure hardware).
; Selected at assembly time by WAITMODE (set by a5200/viewer/assemble.py):
; 0 forever -- just loop
; 1 until a button -- poll the keypad (POKEY) and triggers (GTIA), then reset
; 2 WAITSECS secs -- count frames via ANTIC VCOUNT, then reset
; "Exit" jumps through the BIOS reset vector, which re-runs the cartridge and so
; re-displays the picture (a cartridge console has no OS to return to).
#if WAITMODE == 0
awloop:
jmp awloop
#endif
#if WAITMODE == 1
lda #$03
sta $e80f ; POKEY SKCTL -- enable keypad scanning
awloop:
lda $c010 ; GTIA TRIG0 (fire button); 0 = pressed
and #$01
beq awexit
lda $e80f ; POKEY SKSTAT; bit2 = 0 while a keypad key is down
and #$04
bne awloop
awexit:
jmp ($fffc) ; BIOS reset -> re-display
#endif
#if WAITMODE == 2
lda #<(WAITSECS*RATE)
sta $80
lda #>(WAITSECS*RATE)
sta $81
awloop:
lda $80
ora $81
beq awexit ; counted all the frames
vw1:
lda $d40b ; ANTIC VCOUNT
bne vw1 ; wait for top of frame (VCOUNT = 0)
vw2:
lda $d40b
beq vw2 ; wait until it leaves the top -> one frame elapsed
lda $80
sec
sbc #$01
sta $80
lda $81
sbc #$00
sta $81
jmp awloop
awexit:
jmp ($fffc) ; BIOS reset -> re-display
#endif

View file

@ -0,0 +1,51 @@
; Atari 5200 image viewer -- self-contained, no OS.
;
; The 5200 has no operating system (only a small BIOS that jumps straight to the
; cartridge), so this code programmes ANTIC and GTIA hardware registers directly.
; ANTIC DMAs the bitmap and display list straight out of cartridge ROM, so nothing
; needs copying to RAM -- the viewer only applies the GTIA colour registers, points
; ANTIC at the display list, enables DMA, and holds.
;
; GTIA is at $C000 on the 5200 (not $D000 as on the 400/800); ANTIC is at $D400.
;
; #defines from viewer/assemble.py --
; DLIST cartridge address of the display list
; SCRIPT cartridge address of the GTIA register script -- (offset, value)
; byte pairs applied as $C000+offset = value, terminated by $FF
* = $4000
start:
sei
cld
ldx #$ff
txs
lda #$00
sta $d40e ; NMIEN = 0 -- no interrupts
sta $d400 ; DMACTL = 0 while we set up
; ---- apply the GTIA register script (offset, value pairs) ----
ldx #$00
sloop:
lda SCRIPT,x
cmp #$ff
beq sdone
tay ; Y = register offset within GTIA
inx
lda SCRIPT,x
sta $c000,y ; GTIA register = value
inx
jmp sloop
sdone:
; ---- point ANTIC at the display list ----
lda #<DLIST
sta $d402 ; DLISTL
lda #>DLIST
sta $d403 ; DLISTH
; ---- enable DMA -- normal playfield + display-list DMA ----
lda #$22
sta $d400 ; DMACTL
; ---- hold the picture (forever / until a button / N seconds) ----
#include "awyt5200.i"

1
lenser/a7800/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Atari 7800 ProSystem target for lenser (MARIA display processor)."""

View file

@ -0,0 +1,30 @@
"""Atari 7800 conversion dispatch.
The 7800's MARIA chip has its own architecture (display lists + zones + objects),
nothing like ANTIC/GTIA -- but its 160A graphics mode is 2 bits/pixel, 4 px/byte,
exactly the packing of ANTIC mode E (Atari GR.15). So the per-mode encoders pack
160x192 2bpp bitmaps the same way GR.15 does and reuse the Atari 256-colour NTSC
palette + dither-aware selection; the MARIA-specific display lists are built by
the cartridge packer (viewer/assemble.py).
Modes: ``c160`` = 25-colour 160A (8 MARIA palettes, per-segment palette choice);
``mono`` = luminance two-tone / tinted.
"""
from __future__ import annotations
from ... import imageprep
from . import c160, mono
_MODULES = {"c160": c160, "mono": mono}
MODES = list(_MODULES.keys())
def convert_image(path_or_img, mode="c160", palette_name="ntsc",
dither_mode="floyd", intensive=False, prep_opt=None,
base_color=None):
prep_opt = prep_opt or imageprep.PrepOptions()
module = _MODULES.get(mode, c160)
img_rgb = imageprep.prepare(path_or_img, module.WIDTH, module.HEIGHT,
module.PIXEL_ASPECT, prep_opt, border_rgb=(0, 0, 0))
return module.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color)

View file

@ -0,0 +1,127 @@
"""Atari 7800 MARIA 160A colour mode -- 160x192, 4 px/byte (2bpp).
MARIA gives 8 palettes of 3 colours each plus one shared background = up to 25
colours on screen. The line is split into 8 objects of 20 px (5 bytes) each, and
every object may use a different palette, so each 20 px segment can pick the
3-colour palette (+ shared background) that fits it best -- chosen globally by
clustering the segments into 8 palettes. The 2bpp bitmap packing is identical to
Atari GR.15, so the Atari encoder helpers are reused.
Conversion.data = bitmap(7680) + seg_palettes(192*8) + colours(25):
bitmap 192 lines x 40 bytes, 2bpp, value 0..3 per pixel
seg_palettes one palette index (0..7) per 20px segment (8 per line)
colours [BACKGRND, P0C1,P0C2,P0C3, P1C1..P7C3] (MARIA register order)
"""
from __future__ import annotations
import numpy as np
from ... import palette as c64pal
from ...convert.base import Conversion, perceptual_error, DIFFUSION_DITHERS
from ...atari import palette as apal
from ...atari.convert import _common
WIDTH, HEIGHT = 160, 192
PIXEL_ASPECT = 2.0
SEG_W = 40 # pixels per object (10 bytes, 2bpp)
N_SEG = WIDTH // SEG_W # 4 objects per line (MARIA DMA budget)
N_PAL = 8 # MARIA palettes
def _pack_bitmap(val_image):
return b"".join(bytes(b) for b in _common.pack_2bpp(val_image))
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None, candidates=None, _mode="c160"):
plab = apal.palette_lab("ntsc")
prgb = apal.get_palette("ntsc").astype(np.uint8)
img_lab = c64pal.srgb_to_lab(img_rgb)
bg, palettes, seg_pal, idx = _solve(img_lab, plab, dither_mode, intensive,
candidates)
# value image (0..3) per pixel: 0 = background, 1..3 = the segment palette's
# three colours; build it from idx (palette indices) + the per-segment palette.
val = np.zeros((HEIGHT, WIDTH), np.uint8)
for row in range(HEIGHT):
for s in range(N_SEG):
x0 = s * SEG_W
pal = [bg] + palettes[seg_pal[row, s]]
lut = {c: v for v, c in enumerate(pal)}
block = idx[row, x0:x0 + SEG_W]
val[row, x0:x0 + SEG_W] = [lut[int(c)] for c in block]
bitmap = _pack_bitmap(val)
seg_bytes = bytes(seg_pal.reshape(-1).astype(np.uint8))
colours = bytearray([bg])
for p in range(N_PAL):
colours += bytes(palettes[p])
data = bitmap + seg_bytes + bytes(colours)
err = perceptual_error(idx, img_lab, plab)
return Conversion(
mode=_mode, width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=0,
viewer="a7800", preview_rgb=np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1),
error=err, meta={"palette": "ntsc", "dither": dither_mode, "bg": bg},
)
def _pick(pixels_lab, plab, k, diff, candidates):
"""Pick k palette colours best representing the given pixels."""
if diff:
return _common.choose_palette_dither(pixels_lab, plab, k=k,
candidates=candidates)
if candidates is not None:
sub = plab[candidates]
return [candidates[i] for i in _common.choose_palette(pixels_lab, sub, k=k)]
return _common.choose_palette(pixels_lab, plab, k=k)
def _solve(img_lab, plab, dither_mode, intensive, candidates=None):
"""Choose a shared background + 8 three-colour palettes by clustering the
image's 40px segments by colour (so each palette is tuned to a group of
similar segments), assign each segment its cluster's palette, and dither.
Returns (bg, palettes[8][3], seg_pal, idx)."""
from ... import dither as _dith
diff = dither_mode in DIFFUSION_DITHERS
H, W, _ = img_lab.shape
# shared background = darkest of a small global palette
gp = _pick(img_lab, plab, 8, diff, candidates)
bg = min(gp, key=lambda c: plab[c, 0])
# cluster the H*N_SEG segments by their mean colour into N_PAL groups
seg_means = img_lab.reshape(H, N_SEG, SEG_W, 3).mean(axis=2) # (H,N_SEG,3)
feats = seg_means.reshape(-1, 3)
rng = np.random.default_rng(0)
cent = feats[rng.choice(len(feats), N_PAL, replace=False)].copy()
labels = np.zeros(len(feats), np.int64)
for _ in range(12):
labels = ((feats[:, None] - cent[None]) ** 2).sum(-1).argmin(1)
for g in range(N_PAL):
m = labels == g
if m.any():
cent[g] = feats[m].mean(0)
seg_pal = labels.reshape(H, N_SEG)
# each palette = 3 colours tuned to the pixels of its cluster's segments
seg_px = img_lab.reshape(H, N_SEG, SEG_W, 3)
palettes = []
for g in range(N_PAL):
mask = seg_pal == g # (H,N_SEG)
if not mask.any():
palettes.append([bg, bg, bg])
continue
px = seg_px[mask].reshape(-1, 1, 3) # (Npix,1,3)
palettes.append(_pick(px, plab, 3, diff, candidates))
# dither the whole image with each segment restricted to {bg} + its palette
sets = [[bg] + palettes[g] for g in range(N_PAL)]
allowed = np.zeros((H, W, 4), np.int64)
for r in range(H):
for s in range(N_SEG):
allowed[r, s * SEG_W:s * SEG_W + SEG_W] = sets[seg_pal[r, s]]
idx = _dith.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
return bg, palettes, seg_pal, idx

View file

@ -0,0 +1,36 @@
"""Atari 7800 monochrome / tinted-mono (MARIA 160A restricted to one hue).
Reuses the c160 machinery but restricts the colour pool to the 16 luminances of a
single hue (hue 0 = greyscale), so the 8 palettes become up to ~16 grey levels and
the per-segment palette choice yields a smooth, detailed luminance image.
"""
from __future__ import annotations
import numpy as np
from ... import palette as c64pal
from ...convert.base import DIFFUSION_DITHERS, perceptual_error
from ...atari import palette as apal
from . import c160
WIDTH, HEIGHT, PIXEL_ASPECT = c160.WIDTH, c160.HEIGHT, c160.PIXEL_ASPECT
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None):
# mono is carried by dithering -> needs error diffusion
if dither_mode not in DIFFUSION_DITHERS:
dither_mode = "floyd"
hue = 0 if base_color is None else (int(base_color) & 0x0F)
candidates = list(range(hue * 16, hue * 16 + 16)) # 16 lums of this hue
conv = c160.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color, candidates=candidates, _mode="mono")
# report error in LUMINANCE space (a greyscale image must not be scored
# against the colour original, as the colour modes are)
plab = apal.palette_lab("ntsc").copy()
plab[:, 1:] = 0.0
img_l = c64pal.srgb_to_lab(img_rgb)
img_l[..., 1:] = 0.0
conv.error = perceptual_error(np.asarray(conv.index_image), img_l, plab)
conv.meta["base_color"] = base_color
return conv

19
lenser/a7800/exporter.py Normal file
View file

@ -0,0 +1,19 @@
"""Build an Atari 7800 cartridge (.a78) from a conversion."""
from __future__ import annotations
import os
from .viewer import assemble
_EXTS = (".a78", ".bin")
def export_a78(conv, output_path, source_path=None, display="forever",
seconds=0, video="ntsc"):
if not output_path.lower().endswith(_EXTS):
output_path += ".a78"
title = os.path.splitext(os.path.basename(source_path or output_path))[0]
rom = assemble.build_cart(bytes(conv.data), title=title)
with open(output_path, "wb") as f:
f.write(rom)
return output_path

View file

@ -0,0 +1 @@
"""Atari 7800 6502/MARIA viewer (assembled by xa)."""

View file

@ -0,0 +1,139 @@
"""Assemble the Atari 7800 viewer with `xa` and lay out the 48K .a78 cartridge.
MARIA reads the display-list list (DLL), the per-line display lists and the bitmap
by DMA straight from cartridge ROM, so the packer just places them at fixed cart
addresses and the viewer points MARIA at the DLL.
ROM layout ($4000-$FFFF, 48K):
$4000 viewer code
$4100 colour register script (reg, value pairs, $FF-terminated)
$8000 bitmap 192 lines x 40 bytes (2bpp)
$A000 display lists 192 x 34 bytes (8 objects + end marker per line)
$BA00 DLL 192 x 3 bytes (one 1-line zone per line)
$FFFA 6502 vectors (NMI/RESET/IRQ -> $4000)
"""
from __future__ import annotations
import os
import shutil
import struct
import subprocess
import tempfile
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
CART_BASE = 0x4000
CART_SIZE = 0xC000 # 48K
SCRIPT_ADDR = 0x4100
BITMAP_ADDR = 0x8000
DL_BASE = 0xA000
DLL_ADDR = 0xBA00
WIDTH, LINES = 160, 192
BYTES_PER_LINE = 40
SEG_W_BYTES = 10 # 10 bytes = 40 px per object
N_SEG = 4
SEG_W_PX = 40
DL_LEN = N_SEG * 4 + 2 # 4 objects (4 bytes) + 2-byte end = 18
# MARIA colour-register addresses in the order the converter emits them:
# BACKGRND, then P0C1..P0C3, P1C1.., ... P7C1..P7C3.
COLOR_REGS = [0x20]
for _p in range(8):
COLOR_REGS += [0x21 + 4 * _p, 0x22 + 4 * _p, 0x23 + 4 * _p]
class AssemblerError(RuntimeError):
pass
def have_xa() -> bool:
return shutil.which("xa") is not None
def _assemble() -> bytes:
if not have_xa():
raise AssemblerError("The 'xa' (xa65) assembler was not found on PATH.\n"
"Install it with: sudo apt install xa65")
wrapper = (f"#define SCRIPT ${SCRIPT_ADDR:04X}\n"
f"#define DLL ${DLL_ADDR:04X}\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 _a78_header(rom_len: int, title: str) -> bytes:
h = bytearray(128)
h[0] = 1 # header version
h[1:1 + 9] = b"ATARI7800"
h[17:17 + 32] = title.encode("ascii", "replace")[:32].ljust(32, b"\x00")
h[49:53] = struct.pack(">I", rom_len) # ROM size (excl. header)
# cart type 0 = plain linear; controllers = joystick; NTSC
h[55] = 1
h[56] = 1
h[57] = 0
h[100:100 + 28] = b"ACTUAL CART DATA STARTS HERE"
return bytes(h)
def build_cart(data: bytes, title: str = "8bitlenser") -> bytes:
"""data = converter blob: bitmap(7680) + seg_palettes(192*8) + colours(25)."""
bitmap = data[:LINES * BYTES_PER_LINE]
seg_pal = data[LINES * BYTES_PER_LINE:LINES * BYTES_PER_LINE + LINES * N_SEG]
colours = data[LINES * BYTES_PER_LINE + LINES * N_SEG:]
code = _assemble()
rom = bytearray(b"\x00" * CART_SIZE)
def place(addr, blob):
off = addr - CART_BASE
rom[off:off + len(blob)] = blob
place(CART_BASE, code)
# colour register script: (reg, value) pairs, $FF terminator
script = bytearray()
for reg, val in zip(COLOR_REGS, colours):
script += bytes([reg, val])
script.append(0xFF)
place(SCRIPT_ADDR, script)
place(BITMAP_ADDR, bitmap)
# per-line display lists (8 objects of 5 bytes, each its own palette)
dls = bytearray()
for line in range(LINES):
for s in range(N_SEG):
gfx = BITMAP_ADDR + line * BYTES_PER_LINE + s * SEG_W_BYTES
pal = seg_pal[line * N_SEG + s] & 0x07
width = (-SEG_W_BYTES) & 0x1F # two's-complement byte count
dls += bytes([gfx & 0xFF, (pal << 5) | width, gfx >> 8, s * SEG_W_PX])
dls += bytes([0x00, 0x00]) # end of DL
place(DL_BASE, dls)
# display-list list: one 1-line zone per line
dll = bytearray()
for line in range(LINES):
dl = DL_BASE + line * DL_LEN
dll += bytes([0x00, dl >> 8, dl & 0xFF]) # offset 0 (1 line), DL hi, lo
place(DLL_ADDR, dll)
# 6502 vectors -> viewer entry ($4000)
for v in (0xFFFA, 0xFFFC, 0xFFFE):
off = v - CART_BASE
rom[off] = CART_BASE & 0xFF
rom[off + 1] = CART_BASE >> 8
return _a78_header(len(rom), title) + bytes(rom)

View file

@ -0,0 +1,47 @@
; Atari 7800 image viewer -- programs MARIA, then holds.
;
; MARIA DMAs the display-list list (DLL), the per-line display lists (DLs) and the
; bitmap straight out of cartridge ROM, so the viewer only loads MARIA's colour
; registers, points it at the DLL, turns DMA on, and loops. MARIA registers live
; in zero page ($20-$3F).
;
; #defines from viewer/assemble.py --
; SCRIPT address of the colour register script -- (reg, value) byte pairs
; written as $0000+reg = value, terminated by $FF
; DLL address of the display-list list
* = $4000
reset:
sei
cld
ldx #$ff
txs
lda #$00
sta $3c ; CTRL = 0 -- DMA off while we set up
; ---- load MARIA colour registers from the script ----
ldx #$00
sloop:
lda SCRIPT,x
cmp #$ff
beq sdone
tay ; Y = MARIA register ($20..$3F)
inx
lda SCRIPT,x
sta $0000,y
inx
jmp sloop
sdone:
; ---- point MARIA at the display-list list ----
lda #>DLL
sta $2c ; DPPH
lda #<DLL
sta $30 ; DPPL
; ---- enable DMA (160A, no DLI) ----
lda #$40
sta $3c ; CTRL
loop:
jmp loop ; the packer fills the reset vectors at $FFFA

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

View file

@ -0,0 +1,23 @@
"""Commodore Amiga conversion dispatch."""
from __future__ import annotations
from ... import imageprep
from . import lowres, mono
# NOTE: HAM6 (4096 colours) was implemented and looks superb, but MAME's
# preliminary Amiga can't render 6-bitplane/HAM modes cleanly (fixed-position
# black bands at the screen edges), so only the MAME-verified 5-plane low-res
# (32 colours) and mono modes are shipped.
_MODULES = {"lowres": lowres, "mono": mono}
MODES = list(_MODULES.keys())
def convert_image(path_or_img, mode="lowres", palette_name="amiga",
dither_mode="floyd", intensive=False, prep_opt=None,
base_color=None):
prep_opt = prep_opt or imageprep.PrepOptions()
module = _MODULES.get(mode, lowres)
img_rgb = imageprep.prepare(path_or_img, module.WIDTH, module.HEIGHT,
module.PIXEL_ASPECT, prep_opt, border_rgb=(0, 0, 0))
return module.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color)

View file

@ -0,0 +1,130 @@
"""Amiga encoders: HAM6 (4096 colours), flat low-res (<=32 of 4096), greyscale.
HAM (Hold-And-Modify) is the showpiece: 6 bitplanes where the top 2 bits choose
whether a pixel is one of 16 base palette colours or modifies one R/G/B channel
of the pixel to its left -- giving up to 4096 colours on screen. We pick 16 base
colours, then walk each scanline left-to-right choosing per pixel the option
(set / modify R / modify G / modify B) closest to the target.
"""
from __future__ import annotations
import numpy as np
from ... import dither, palette as c64pal
from ...convert.base import _box_blur
from .. import palette as apal
W, H = 320, 200
def _perr(final_rgb, img_rgb):
a = _box_blur(c64pal.srgb_to_lab(final_rgb.astype(np.float64)))
b = _box_blur(c64pal.srgb_to_lab(img_rgb.astype(np.float64)))
return float(np.sqrt(((a - b) ** 2).sum(-1)).mean())
def planar_split(codes, nplanes):
"""codes (H,W) -> nplanes contiguous bitplanes (40 bytes/line, MSB left)."""
out = bytearray()
for p in range(nplanes):
bit = ((codes >> p) & 1).astype(np.uint8)
out += np.packbits(bit, axis=1).reshape(-1).tobytes() # (H,40)
return bytes(out)
def _kmeans_keys(img_lab, plab, k, iters=12):
rng = np.random.default_rng(0)
flat = img_lab.reshape(-1, 3)
pts = flat[rng.choice(len(flat), min(6000, len(flat)), replace=False)]
# k-means++ style seeding so every centroid is a real, distinct colour
cen = pts[rng.integers(len(pts))][None].copy()
for _ in range(k - 1):
d = np.min(((pts[:, None, :] - cen[None]) ** 2).sum(2), 1)
cen = np.vstack([cen, pts[int(d.argmax())]]) # farthest-point seed
for _ in range(iters):
lab = np.argmin(((pts[:, None, :] - cen[None]) ** 2).sum(2), 1)
for j in range(k):
m = pts[lab == j]
cen[j] = m.mean(0) if len(m) else pts[rng.integers(len(pts))]
# snap each centroid to the nearest 4096 palette key
keys = [int(np.argmin(((plab - c) ** 2).sum(1))) for c in cen]
return keys
def ham_encode(img_rgb, dither_mode):
plab = apal.palette_lab() # (4096,3)
img_lab = c64pal.srgb_to_lab(img_rgb)
base_keys = _kmeans_keys(img_lab, plab, 16)
base_lab = plab[base_keys]
base_rgb4 = [(k >> 8 & 15, k >> 4 & 15, k & 15) for k in base_keys]
t4 = np.clip(np.rint(img_rgb / 17.0), 0, 15).astype(np.int64) # 4-bit targets
# base option (independent of the held colour) precomputed for all pixels
bd = ((img_lab[:, :, None, :] - base_lab[None, None]) ** 2).sum(3) # (H,W,16)
base_best = bd.argmin(2)
base_cost = bd.min(2)
codes = np.zeros((H, W), np.uint8)
final = np.zeros((H, W, 3), np.uint8)
P = plab
for y in range(H):
tl = img_lab[y]; t4y = t4[y]
bb = base_best[y]; bc = base_cost[y]
pr = pg = pb = 0 # hardware holds black at line start
for x in range(W):
t0, t1, t2 = tl[x, 0], tl[x, 1], tl[x, 2]
bi = int(bb[x]); best = bc[x]
ctrl = 0; data = bi; nr, ng, nb = base_rgb4[bi]
# force an absolute "set" on the first pixel so the line establishes a
# colour regardless of the held-colour start (an all-modify run from
# the wrong start would otherwise stay dark -- HAM left-edge bug).
if x > 0:
tr, tg, tb = int(t4y[x, 0]), int(t4y[x, 1]), int(t4y[x, 2])
for c_ctrl, c_data, kr, kg, kb in (
(2, tr, tr, pg, pb), (3, tg, pr, tg, pb), (1, tb, pr, pg, tb)):
pk = P[(kr << 8) | (kg << 4) | kb]
dr = pk[0] - t0; dg = pk[1] - t1; db = pk[2] - t2
cc = dr * dr + dg * dg + db * db
if cc < best:
best = cc; ctrl = c_ctrl; data = c_data; nr, ng, nb = kr, kg, kb
codes[y, x] = (ctrl << 4) | data
pr, pg, pb = nr, ng, nb
final[y, x, 0] = pr * 17; final[y, x, 1] = pg * 17; final[y, x, 2] = pb * 17
planes = planar_split(codes, 6)
colors = [apal.color_word(k) for k in base_keys] # 16 base registers
return planes, colors, final, _perr(final, img_rgb)
def flat_encode(img_rgb, n_colors, dither_mode, mono=False, base_color=None):
plab = apal.palette_lab()
prgb = apal.get_palette().astype(np.uint8)
img_lab = c64pal.srgb_to_lab(img_rgb)
nplanes = (n_colors - 1).bit_length() # 32->5, 16->4
if mono:
keys = list(apal.GREYS) # 16 greys
if base_color in range(4096):
keys = sorted({keys[0], int(base_color), keys[-1]}, key=lambda i: plab[i, 0])
keys = (keys + keys)[:n_colors]
work = np.zeros_like(img_lab); work[..., 0] = img_lab[..., 0]
pw = np.zeros_like(plab); pw[:, 0] = plab[:, 0]
else:
keys = _kmeans_keys(img_lab, plab, n_colors)
work, pw = img_lab, plab
allowed = np.tile(np.array(keys[:n_colors]), (H, W, 1))
qidx = dither.quantize(work, allowed, pw, dither_mode).astype(np.int64)
# map palette key -> pen index
lut = {k: i for i, k in enumerate(keys[:n_colors])}
pen = np.vectorize(lut.get)(qidx).astype(np.uint8)
planes = planar_split(pen, nplanes)
colors = [apal.color_word(k) for k in keys[:n_colors]]
final = prgb[qidx]
if mono: # measure greyscale against luminance
g = img_rgb.mean(2, keepdims=True).repeat(3, 2)
err = _perr(final, g.astype(np.uint8))
else:
err = _perr(final, img_rgb)
return planes, colors, final, err

View file

@ -0,0 +1,21 @@
"""Amiga low resolution: 320x200, 32 colours from the 4096-colour palette."""
from __future__ import annotations
from ...convert.base import Conversion
from . import _common
WIDTH, HEIGHT = 320, 200
PIXEL_ASPECT = 1.0
NCOL = 32
def convert(img_rgb, palette_name="amiga", dither_mode="floyd",
intensive=False, base_color=None):
planes, colors, preview, err = _common.flat_encode(img_rgb, NCOL, dither_mode)
return Conversion(
mode="lowres", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=None,
data={"planes": planes, "colors": colors, "nplanes": 5, "ham": False},
data_addr=0, viewer="amiga", preview_rgb=preview, error=err,
meta={"palette": "amiga", "dither": dither_mode},
)

View file

@ -0,0 +1,22 @@
"""Amiga monochrome: 320x200, 16 grey levels (the Amiga has true 16 greys)."""
from __future__ import annotations
from ...convert.base import Conversion
from . import _common
WIDTH, HEIGHT = 320, 200
PIXEL_ASPECT = 1.0
NCOL = 16
def convert(img_rgb, palette_name="amiga", dither_mode="floyd",
intensive=False, base_color=None):
planes, colors, preview, err = _common.flat_encode(
img_rgb, NCOL, dither_mode, mono=True, base_color=base_color)
return Conversion(
mode="mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=None,
data={"planes": planes, "colors": colors, "nplanes": 4, "ham": False},
data_addr=0, viewer="amiga", preview_rgb=preview, error=err,
meta={"palette": "amiga", "dither": dither_mode},
)

41
lenser/amiga/copper.py Normal file
View file

@ -0,0 +1,41 @@
"""Build an Amiga Copper list that displays a static 320x200 low-res screen.
The Copper runs every frame and re-loads the bitplane pointers + registers, so
the picture stays stable. Each instruction is a MOVE (register offset, value);
the list ends with WAIT $FFFF,$FFFE.
"""
from __future__ import annotations
import struct
DIWSTRT, DIWSTOP, DDFSTRT, DDFSTOP = 0x08E, 0x090, 0x092, 0x094
BPLCON0, BPLCON1, BPLCON2 = 0x100, 0x102, 0x104
BPL1MOD, BPL2MOD = 0x108, 0x10A
BPLPT = 0x0E0 # BPL1PTH; +4 per plane
COLOR0 = 0x180
def list_len(nplanes, ncolors) -> int:
pairs = 9 + nplanes * 2 + ncolors # DIW*2, DDF*2, BPLCON0/1/2, BPLMOD*2 = 9
return pairs * 4 + 4 # + end WAIT
def build(nplanes, ham, plane_addrs, colors) -> bytes:
bplcon0 = (nplanes << 12) | (0x0800 if ham else 0) | 0x0200
moves = [
(DIWSTRT, 0x2C81), (DIWSTOP, 0xF4C1),
(DDFSTRT, 0x0038), (DDFSTOP, 0x00D0),
(BPLCON0, bplcon0), (BPLCON1, 0x0000), (BPLCON2, 0x0024),
(BPL1MOD, 0x0000), (BPL2MOD, 0x0000),
]
for p, addr in enumerate(plane_addrs):
moves.append((BPLPT + p * 4, (addr >> 16) & 0xFFFF))
moves.append((BPLPT + p * 4 + 2, addr & 0xFFFF))
for i, c in enumerate(colors):
moves.append((COLOR0 + i * 2, c & 0x0FFF))
out = bytearray()
for reg, val in moves:
out += struct.pack(">HH", reg & 0x1FE, val & 0xFFFF)
out += struct.pack(">HH", 0xFFFF, 0xFFFE) # end of copper list
return bytes(out)

13
lenser/amiga/exporter.py Normal file
View file

@ -0,0 +1,13 @@
"""Build a bootable Amiga .adf floppy from a conversion."""
from __future__ import annotations
from . import viewer
def export_adf(conv, output_path, source_path=None, display="forever",
seconds=0, video="ntsc"):
if not output_path.lower().endswith(".adf"):
output_path += ".adf"
d = conv.data
adf = viewer.build_adf(d["planes"], d["colors"], d["nplanes"], d["ham"])
return viewer.write_adf(adf, output_path)

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

@ -0,0 +1,30 @@
"""Commodore Amiga (OCS/ECS) colour palette.
12-bit colour: 4 bits per channel (x17 -> 0..255), 4096 colours. A colour
register value is %0000 RRRR GGGG BBBB. We index the master palette by the
packed 12-bit key (R<<8)|(G<<4)|B, which is also the register word.
"""
from __future__ import annotations
import numpy as np
from ..palette import srgb_to_lab
_r = (np.arange(4096) >> 8) & 0xF
_g = (np.arange(4096) >> 4) & 0xF
_b = np.arange(4096) & 0xF
PALETTE = (np.stack([_r, _g, _b], axis=1) * 17).astype(np.float64) # 4096 x 3
GREYS = [(v << 8) | (v << 4) | v for v in range(16)] # 16 grey keys
def color_word(key: int) -> int:
return key & 0x0FFF
def get_palette() -> np.ndarray:
return PALETTE
def palette_lab() -> np.ndarray:
return srgb_to_lab(PALETTE)

242
lenser/amiga/viewer.py Normal file
View file

@ -0,0 +1,242 @@
"""Build a bootable Amiga .adf that displays a low-res / HAM image.
The boot block (first 1024 bytes) holds a 68000 routine that Kickstart runs at
boot: it reuses the boot trackdisk IORequest to read the Copper list + bitplanes
from the floppy into chip RAM at $20000, points the Copper there, enables
bitplane DMA, kills interrupts (so the OS can't reclaim the display), and idles.
"""
from __future__ import annotations
import struct
from . import copper
LOAD = 0x20000 # chip-RAM load address for copper list + bitplanes
PLANE_SIZE = 40 * 200 # 8000 bytes per bitplane
ADF_SIZE = 901120 # 880K (80*2*11*512)
def _boot_code(datalen: int) -> bytes:
"""68000 boot routine (entry: a1 = trackdisk IORequest)."""
c = bytearray()
c += bytes.fromhex("2C780004") # movea.l $4.w,a6 (ExecBase)
c += bytes.fromhex("337C0002001C") # move.w #2,$1C(a1) CMD_READ
c += b"\x23\x7C" + struct.pack(">I", datalen) + b"\x00\x24" # move.l #len,$24(a1)
c += b"\x23\x7C" + struct.pack(">I", LOAD) + b"\x00\x28" # move.l #LOAD,$28(a1)
c += b"\x23\x7C" + struct.pack(">I", 1024) + b"\x00\x2C" # move.l #1024,$2C(a1)
c += bytes.fromhex("4EAEFE38") # jsr -456(a6) DoIO
c += bytes.fromhex("4BF900DFF000") # lea $dff000,a5
c += bytes.fromhex("3B7C7FFF009A") # move.w #$7FFF,$9A(a5) INTENA off
c += bytes.fromhex("3B7C7FFF009C") # move.w #$7FFF,$9C(a5) INTREQ clr
c += bytes.fromhex("3B7C7FFF0096") # move.w #$7FFF,$96(a5) DMACON off
c += b"\x2B\x7C" + struct.pack(">I", LOAD) + b"\x00\x80" # move.l #LOAD,$80(a5) COP1LC
c += bytes.fromhex("3B7C83800096") # move.w #$8380,$96(a5) DMACON on
c += bytes.fromhex("3B7C00000088") # move.w #0,$88(a5) COPJMP1 strobe
c += bytes.fromhex("60FE") # bra *
return bytes(c)
def _bootsum(block: bytes) -> int:
s = 0
for i in range(0, 1024, 4):
s += int.from_bytes(block[i:i + 4], "big")
if s > 0xFFFFFFFF:
s = (s + 1) & 0xFFFFFFFF
return (~s) & 0xFFFFFFFF
def build_adf(planes: bytes, colors, nplanes: int, ham: bool) -> bytes:
clen = copper.list_len(nplanes, len(colors))
plane_addrs = [LOAD + clen + p * PLANE_SIZE for p in range(nplanes)]
cop = copper.build(nplanes, ham, plane_addrs, colors)
assert len(cop) == clen
blob = cop + planes
datalen = (len(blob) + 511) // 512 * 512 # whole sectors for trackdisk
code = _boot_code(datalen)
boot = bytearray(1024)
boot[0:4] = b"DOS\x00"
boot[12:12 + len(code)] = code # Kickstart jumps to offset 12
boot[4:8] = b"\x00\x00\x00\x00"
struct.pack_into(">I", boot, 4, _bootsum(bytes(boot)))
adf = bytearray(ADF_SIZE)
adf[0:1024] = boot
adf[1024:1024 + len(blob)] = blob # copper + bitplanes follow
return bytes(adf)
def write_adf(adf: bytes, path: str) -> str:
with open(path, "wb") as f:
f.write(adf)
return path
# --------------------------------------------------------------------------- #
# slideshow
# --------------------------------------------------------------------------- #
SS_WAITMODE = {"key": 1, "seconds": 2, "both": 3}
class _M68k:
"""Tiny 68000 emitter with label/branch fixups (hand-encoded opcodes, but the
displacements are computed, not counted by hand)."""
def __init__(self):
self.code = bytearray()
self.labels: dict[str, int] = {}
self.fixups: list[tuple[int, str]] = [] # (disp-word position, label)
def hexs(self, h): self.code.extend(bytes.fromhex(h)); return self
def w(self, v): self.code.extend(struct.pack(">H", v & 0xFFFF)); return self
def l(self, v): self.code.extend(struct.pack(">I", v & 0xFFFFFFFF)); return self
def label(self, name): self.labels[name] = len(self.code); return self
def br(self, opc_hex, name): # Bcc.w / BSR.w / DBcc (opcode + disp16)
self.code.extend(bytes.fromhex(opc_hex))
self.fixups.append((len(self.code), name))
self.w(0)
return self
def resolve(self) -> bytes:
for pos, name in self.fixups:
disp = self.labels[name] - pos # PC base = the displacement word
struct.pack_into(">h", self.code, pos, disp)
return bytes(self.code)
def _ss_boot_code(slot: int, n_images: int, waitmode: int, waitsecs: int,
speed: int, loop: bool) -> bytes:
"""68000 slideshow boot routine (entry: a1 = boot trackdisk IORequest).
Reads image i (slot bytes from floppy offset 1024 + i*slot) into chip RAM at
$20000, points the Copper there and strobes it, waits, then advances.
"""
m = _M68k()
m.hexs("2C780004") # movea.l $4.w,a6 (ExecBase)
m.hexs("4BF900DFF000") # lea $dff000,a5
# ---- read ALL images into chip RAM (interrupts still on, so DoIO works) ----
# image i: slot bytes from floppy offset 1024+i*slot -> RAM $20000 + i*slot
m.hexs("7E00") # moveq #0,d7 (image index)
m.label("read")
m.hexs("2007") # move.l d7,d0
m.hexs("C0FC").w(slot) # mulu #slot,d0 d0 = i*slot
m.hexs("2A00") # move.l d0,d5 save i*slot
m.hexs("0680").l(LOAD) # add.l #$20000,d0
m.hexs("23400028") # move.l d0,$28(a1) dest = $20000+i*slot
m.hexs("2005") # move.l d5,d0
m.hexs("0680").l(1024) # add.l #1024,d0
m.hexs("2340002C") # move.l d0,$2C(a1) offset = 1024+i*slot
m.hexs("337C0002001C") # move.w #2,$1C(a1) CMD_READ
m.hexs("237C").l(slot).hexs("0024") # move.l #slot,$24(a1) length
m.hexs("4EAEFE38") # jsr -456(a6) DoIO
m.hexs("5287") # addq.l #1,d7
m.hexs("0C87").l(n_images) # cmpi.l #n,d7
m.br("6D00", "read") # blt.w read
# ---- take over the display -- kill interrupts/DMA so the OS can't reclaim it
m.hexs("3B7C7FFF009A") # INTENA off
m.hexs("3B7C7FFF009C") # INTREQ clear
m.hexs("3B7C7FFF0096") # DMACON off
m.hexs("7E00") # moveq #0,d7
m.label("main")
m.hexs("2007") # move.l d7,d0
m.hexs("C0FC").w(slot) # mulu #slot,d0
m.hexs("0680").l(LOAD) # add.l #$20000,d0 COP list = $20000+i*slot
m.hexs("2B400080") # move.l d0,$80(a5) COP1LC
m.hexs("3B7C83800096") # move.w #$8380,$96(a5) DMACON on
m.hexs("3B7C00000088") # move.w #0,$88(a5) COPJMP1 strobe
m.br("6100", "wait") # bsr.w wait
m.hexs("5287") # addq.l #1,d7
m.hexs("0C87").l(n_images) # cmpi.l #n,d7
m.br("6D00", "main") # blt.w main
if loop:
m.hexs("7E00") # moveq #0,d7
m.br("6000", "main") # bra.w main
else:
m.label("idle")
m.br("6000", "idle") # bra *
# ---- wait ----
m.label("wait")
if waitmode == 1: # key = left mouse button ($BFE001 bit6, 0=down)
m.label("wk")
m.hexs("123900BFE001") # move.b $bfe001,d1
m.hexs("08010006") # btst #6,d1
m.br("6600", "wk") # bne.w wk (loop while not pressed)
m.hexs("4E75") # rts
elif waitmode == 2: # seconds = delay loop
m.hexs("243C").l(waitsecs) # move.l #secs,d2
m.label("wso")
m.hexs("363C").w(speed) # move.w #speed,d3
m.label("wsm")
m.hexs("323CFFFF") # move.w #$FFFF,d1
m.label("wsi")
m.br("51C9", "wsi") # dbra d1,wsi
m.br("51CB", "wsm") # dbra d3,wsm
m.hexs("5382") # subq.l #1,d2
m.br("6600", "wso") # bne.w wso
m.hexs("4E75") # rts
else: # both = delay loop + mouse poll
m.hexs("243C").l(waitsecs)
m.label("wbo")
m.hexs("363C").w(speed)
m.label("wbm")
m.hexs("123900BFE001") # move.b $bfe001,d1
m.hexs("08010006") # btst #6,d1
m.br("6700", "wbd") # beq.w wbd (pressed -> done)
m.hexs("323CFFFF") # move.w #$FFFF,d1
m.label("wbi")
m.br("51C9", "wbi") # dbra d1,wbi
m.br("51CB", "wbm") # dbra d3,wbm
m.hexs("5382") # subq.l #1,d2
m.br("6600", "wbo") # bne.w wbo
m.label("wbd")
m.hexs("4E75") # rts
return m.resolve()
def image_blob(planes: bytes, colors, nplanes: int, ham: bool,
base: int = LOAD) -> bytes:
"""The data for one image: copper list (with bitplane pointers and colours)
followed by the bitplanes -- loaded verbatim to ``base``. The bitplane
pointers in the copper are absolute, so each slide is built for the RAM
address it will live at."""
clen = copper.list_len(nplanes, len(colors))
plane_addrs = [base + clen + p * PLANE_SIZE for p in range(nplanes)]
return copper.build(nplanes, ham, plane_addrs, colors) + planes
def build_slideshow_adf(images, advance="both", seconds=10, loop=True,
video="ntsc") -> bytes:
"""Build a bootable slideshow ADF. ``images`` is a list of conv.data dicts
(planes/colors/nplanes/ham); each is laid out in its own sector-aligned slot
so the boot can read image i from a fixed floppy offset."""
# blob length is base-independent, so size first, then rebuild each blob for
# the RAM address ($20000 + i*slot) it will be loaded to and cycled from.
sizes = [len(image_blob(im["planes"], im["colors"], im["nplanes"], im["ham"]))
for im in images]
slot = (max(sizes) + 511) // 512 * 512
if 1024 + len(images) * slot > ADF_SIZE:
raise ValueError("slideshow exceeds the 880K floppy")
if LOAD + len(images) * slot > 0x80000:
raise ValueError("slideshow exceeds Amiga chip RAM")
blobs = [image_blob(im["planes"], im["colors"], im["nplanes"], im["ham"],
base=LOAD + i * slot)
for i, im in enumerate(images)]
speed = 11 if video != "pal" else 13 # delay outer ~1s at 7MHz
code = _ss_boot_code(slot, len(blobs), SS_WAITMODE[advance],
max(0, int(seconds)), speed, loop)
if len(code) > 1024 - 12:
raise ValueError("slideshow boot code overruns the 1024-byte boot block")
boot = bytearray(1024)
boot[0:4] = b"DOS\x00"
boot[12:12 + len(code)] = code # Kickstart jumps to offset 12
struct.pack_into(">I", boot, 4, _bootsum(bytes(boot)))
adf = bytearray(ADF_SIZE)
adf[0:1024] = boot
for i, b in enumerate(blobs):
off = 1024 + i * slot
adf[off:off + len(b)] = b
return bytes(adf)

10
lenser/ansi/__init__.py Normal file
View file

@ -0,0 +1,10 @@
"""ANSI / CP437 "BBS art" output.
Not a real machine -- this renders the image as classic 16-colour ANSI text art
suitable for display on a bulletin board system (or any ANSI/CP437 viewer). Each
character cell is the CP437 upper-half-block (``0xDF``) with the foreground colour
painting the top pixel and the background colour the bottom pixel, so one 80-column
row of text is two rows of freely-coloured pixels. Sixteen EGA/VGA colours are
available for both halves (bright backgrounds use "iCE colours"), so the picture is
just a free 16-colour image at 80 x (2*rows).
"""

307
lenser/ansi/convert.py Normal file
View file

@ -0,0 +1,307 @@
"""Convert an image to 16-colour ANSI/CP437 BBS art.
Two encoders share this module:
* **half-block** (fast, the default when ``intensive`` is off) -- every cell is the
CP437 upper-half-block ``0xDF`` with fg = top pixel, bg = bottom pixel, so the
picture is a free 16-colour dither on an 80 x (2*rows) grid (no cell clash).
* **full glyph** (``intensive`` on) -- every 8x16 cell is matched to the best of the
whole CP437 repertoire (letters, punctuation, line- and block-drawing) together
with an optimal foreground/background colour pair, minimising perceptual (CIELAB)
reproduction error. Using the actual glyph shapes -- not just blocks -- reproduces
edges, texture and gradients far better, at the cost of a per-cell search.
Both choose their two colours per cell from the 16 EGA/VGA colours (bright
backgrounds via iCE colours); "mono" restricts them to a grey/tinted ramp.
"""
from __future__ import annotations
import os
import numpy as np
from .. import dither, imageprep, palette as pal
from ..convert import base
# Standard 16-colour EGA/VGA text palette (indices 0..15 = the ANSI colour order:
# black, blue, green, cyan, red, magenta, brown, light-grey, then their bright
# variants). Foreground index -> SGR 30..37 (+bold for 8..15); background index ->
# SGR 40..47 (+"iCE" blink for 8..15).
VGA = np.array([
(0x00, 0x00, 0x00), (0x00, 0x00, 0xAA), (0x00, 0xAA, 0x00), (0x00, 0xAA, 0xAA),
(0xAA, 0x00, 0x00), (0xAA, 0x00, 0xAA), (0xAA, 0x55, 0x00), (0xAA, 0xAA, 0xAA),
(0x55, 0x55, 0x55), (0x55, 0x55, 0xFF), (0x55, 0xFF, 0x55), (0x55, 0xFF, 0xFF),
(0xFF, 0x55, 0x55), (0xFF, 0x55, 0xFF), (0xFF, 0xFF, 0x55), (0xFF, 0xFF, 0xFF),
], dtype=np.uint8)
# Canvas sizes, keyed "COLSxROWS" (character cells). 80x25 is the classic one-screen
# BBS canvas, 80x50 is a taller (scrolling) canvas with twice the vertical detail.
# "mono" is a greyscale (or hue-tinted) 80x25 canvas -- the monochrome mode every
# platform provides.
_DIMS = {"80x25": (80, 25), "80x50": (80, 50), "mono": (80, 25)}
MODES = list(_DIMS.keys())
# The four VGA greys, darkest-to-lightest, used as the monochrome ramp.
_GREY_RAMP = [0, 8, 7, 15] # black, dark grey, light grey, white
UPPER_HALF_BLOCK = 0xDF # CP437 "▀": top half = fg colour, bottom half = bg
PREVIEW_ZOOM = 4 # widen the 80-wide half-block preview for the GUI
GLYPH_W, GLYPH_H = 8, 16 # CP437 text cell (pixels)
_FONT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cp437_8x16.bin")
def _sgr(fg: int, bg: int) -> bytes:
"""SGR escape selecting foreground ``fg`` and background ``bg`` (0..15 each).
Bright foregrounds (8..15) use bold (``1``); bright backgrounds use the blink
bit (``5``) interpreted as high-intensity ("iCE colours"), which every modern
ANSI viewer and most BBS terminals honour.
"""
parts = ["0"]
if fg >= 8:
parts.append("1")
if bg >= 8:
parts.append("5")
parts.append(str(30 + (fg & 7)))
parts.append(str(40 + (bg & 7)))
return b"\x1b[" + ";".join(parts).encode("ascii") + b"m"
def _mono_ramp(base_color) -> list[int]:
"""Luminance-sorted VGA indices for the mono ramp. With no base colour this is
the four greys; with one it is black + the nearest VGA hue (+ white), so the
picture becomes that colour's shades -- the app's tinted-mono behaviour."""
plab = pal.srgb_to_lab(VGA)
if base_color is None:
ramp = set(_GREY_RAMP)
else:
# base_color is a colodore palette index; tint toward its nearest VGA hue.
rgb = pal.get_palette("colodore")[int(base_color)].astype(np.int64)
hue = int(np.argmin(((VGA.astype(np.int64) - rgb) ** 2).sum(axis=1)))
ramp = {0, hue, 15}
return sorted(ramp, key=lambda i: plab[i, 0])
# --------------------------------------------------------------------------- #
# half-block encoder (fast path)
# --------------------------------------------------------------------------- #
def encode_ansi(index_image: np.ndarray, cols: int, rows: int) -> bytes:
"""Encode a (2*rows, cols) index image as a CP437 half-block ANSI byte stream.
Each output row pairs two pixel rows into one text row of ``0xDF`` cells,
emitting a colour escape only when the (fg, bg) pair changes, and resetting at
each line end so a coloured background never bleeds past column 80. Lines end
in CRLF -- on the deferred-wrap ("magic margin") terminals BBSes use, an exact
80-column line plus CRLF advances one row with no blank line.
"""
out = bytearray(b"\x1b[0m")
for r in range(rows):
top = index_image[2 * r]
bot = index_image[2 * r + 1]
last = None
for c in range(cols):
pair = (int(top[c]), int(bot[c]))
if pair != last:
out += _sgr(*pair)
last = pair
out.append(UPPER_HALF_BLOCK)
out += b"\x1b[0m\r\n"
return bytes(out)
def _convert_halfblock(rgb, mode, cols, rows, dither_mode, ramp):
W, H = cols, rows * 2
img_lab = pal.srgb_to_lab(rgb)
plab = pal.srgb_to_lab(VGA)
allowed = np.tile(np.asarray(ramp, np.int64), (H, W, 1))
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
preview = np.repeat(np.repeat(VGA[idx], PREVIEW_ZOOM, 0), PREVIEW_ZOOM, 1)
return base.Conversion(
mode=mode, width=W, height=H, pixel_aspect=1.0, index_image=idx,
data=encode_ansi(idx, cols, rows), data_addr=0, preview_rgb=preview,
viewer="", error=base.mean_error(idx, img_lab, plab),
meta={"palette": "vga", "dither": dither_mode, "cols": cols, "rows": rows,
"encoding": "halfblock"})
# --------------------------------------------------------------------------- #
# full-glyph encoder (intensive path)
# --------------------------------------------------------------------------- #
_glyph_cache = None
def _glyphs():
"""Load the bundled CP437 8x16 font once and return (masks, codes).
``masks`` is (Ng, 128) float32 -- one row per usable, de-duplicated glyph, a
pixel being 1.0 where the glyph is foreground (pixel order y*8+x, x MSB-first,
matching how cells are flattened). ``codes`` is the CP437 byte to emit for each.
Control bytes (0x00-0x1F, 0x7F) are excluded so the stream is always safe to
send to a terminal, and glyphs with identical bitmaps collapse to one entry.
"""
global _glyph_cache
if _glyph_cache is not None:
return _glyph_cache
font = np.frombuffer(open(_FONT_PATH, "rb").read(), np.uint8).reshape(256, GLYPH_H)
bits = np.unpackbits(font, axis=1).astype(np.float32) # (256, 128)
masks, codes, seen = [], [], set()
for code in range(0x20, 0x100):
if code == 0x7F:
continue
key = bits[code].tobytes()
if key in seen:
continue
seen.add(key)
masks.append(bits[code])
codes.append(code)
_glyph_cache = (np.stack(masks), np.array(codes, np.uint8))
return _glyph_cache
def _match_cells(cells_lab, csub_lab, csub_idx):
"""Best (glyph, fg, bg) per cell by minimum summed CIELAB error.
``cells_lab`` is (n, 128, 3); ``csub_lab``/``csub_idx`` are the allowed colours
in CIELAB and as VGA indices. For a glyph's fg (bg) pixel set the optimal
palette colour is the one nearest that set's mean, whose summed squared error is
``k*|c|^2 - 2 c.sum + sum|p|^2`` -- computed here for every glyph and colour at
once, then reduced. Returns (glyph_row, fg_idx, bg_idx, err) arrays, length n.
"""
G, _ = _glyphs()
Ng = G.shape[0]
k1 = G.sum(1) # (Ng,) fg pixel counts
k0 = float(G.shape[1]) - k1
Csq = (csub_lab ** 2).sum(1) # (m,)
grow = np.empty(len(cells_lab), np.int64)
fgi = np.empty(len(cells_lab), np.int64)
bgi = np.empty(len(cells_lab), np.int64)
err = np.empty(len(cells_lab), np.float64)
# Batch cells so the (Ng, batch, m) error tensors stay modest in memory.
step = max(1, 2_000_000 // (Ng * max(1, len(csub_idx))))
for s in range(0, len(cells_lab), step):
P = cells_lab[s:s + step] # (nb, 128, 3)
nb = P.shape[0]
sq = (P ** 2).sum(2) # (nb, 128)
tot = P.sum(1) # (nb, 3)
totsq = sq.sum(1) # (nb,)
sum_fg = np.einsum("gp,npc->gnc", G, P) # (Ng, nb, 3)
msq_fg = G @ sq.T # (Ng, nb)
# error of assigning each glyph's fg pixels to each candidate colour
efg = (k1[:, None, None] * Csq[None, None, :]
- 2 * np.einsum("gnc,mc->gnm", sum_fg, csub_lab)
+ msq_fg[:, :, None]) # (Ng, nb, m)
best_fg = efg.min(2)
sel_fg = efg.argmin(2)
ebg = (k0[:, None, None] * Csq[None, None, :]
- 2 * np.einsum("gnc,mc->gnm", tot[None] - sum_fg, csub_lab)
+ (totsq[None, :] - msq_fg)[:, :, None])
best_bg = ebg.min(2)
sel_bg = ebg.argmin(2)
total = best_fg + best_bg # (Ng, nb)
g = total.argmin(0) # (nb,)
r = np.arange(nb)
grow[s:s + nb] = g
fgi[s:s + nb] = csub_idx[sel_fg[g, r]]
bgi[s:s + nb] = csub_idx[sel_bg[g, r]]
err[s:s + nb] = total[g, r]
return grow, fgi, bgi, err
def encode_ansi_glyph(codes, fg, bg, cols, rows):
"""Encode per-cell CP437 ``codes`` with ``fg``/``bg`` (all (rows, cols)) as ANSI.
Same colour-run and line-reset discipline as the half-block encoder, but each
cell emits its matched glyph byte instead of a fixed half-block.
"""
out = bytearray(b"\x1b[0m")
for r in range(rows):
last = None
for c in range(cols):
pair = (int(fg[r, c]), int(bg[r, c]))
if pair != last:
out += _sgr(*pair)
last = pair
out.append(int(codes[r, c]))
out += b"\x1b[0m\r\n"
return bytes(out)
def _convert_glyph(rgb, mode, cols, rows, ramp, dither_mode):
G, gcodes = _glyphs()
img_lab = pal.srgb_to_lab(rgb) # (rows*16, cols*8, 3)
plab = pal.srgb_to_lab(VGA)
csub_idx = np.asarray(ramp, np.int64)
csub_lab = plab[csub_idx]
# Match against a pre-dithered copy so smooth gradients become shade characters
# (two blended colours) instead of banding into flat cells. A FAST vectorised
# ordered dither is used -- error-diffusion dithers are far too slow at
# 8x16-per-cell resolution and the glyph matcher re-approximates the local mix
# anyway. "none" matches the continuous image (crispest flats, but visible
# gradient bands); every diffusion choice maps to blue-noise (the best-looking).
pd = {"none": None, "bayer": "bayer", "bluenoise": "bluenoise"}.get(
dither_mode, "bluenoise")
if pd is not None:
allowed = np.tile(csub_idx, (img_lab.shape[0], img_lab.shape[1], 1))
target = plab[dither.quantize(img_lab, allowed, plab, pd)]
else:
target = img_lab
cells = (target.reshape(rows, GLYPH_H, cols, GLYPH_W, 3)
.transpose(0, 2, 1, 3, 4).reshape(rows * cols, GLYPH_H * GLYPH_W, 3))
grow, fg, bg, _ = _match_cells(cells, csub_lab, csub_idx)
# render the matched glyphs to an RGB preview at full 8x16 cell resolution
masks = G[grow] # (n, 128)
fg_rgb = VGA[fg].astype(np.float32)
bg_rgb = VGA[bg].astype(np.float32)
cellpix = masks[:, :, None] * fg_rgb[:, None, :] + (1 - masks[:, :, None]) * bg_rgb[:, None, :]
preview = (cellpix.reshape(rows, cols, GLYPH_H, GLYPH_W, 3)
.transpose(0, 2, 1, 3, 4).reshape(rows * GLYPH_H, cols * GLYPH_W, 3)
.astype(np.uint8))
codes = gcodes[grow].reshape(rows, cols)
fg2d, bg2d = fg.reshape(rows, cols), bg.reshape(rows, cols)
# perceptual error of the rendered result against the ORIGINAL (undithered) image
rms = float(np.sqrt(((pal.srgb_to_lab(preview) - img_lab) ** 2).sum(-1)).mean())
return base.Conversion(
mode=mode, width=cols * GLYPH_W, height=rows * GLYPH_H, pixel_aspect=1.0,
index_image=None, data=encode_ansi_glyph(codes, fg2d, bg2d, cols, rows),
data_addr=0, preview_rgb=preview, viewer="", error=rms,
meta={"palette": "vga", "dither": dither_mode, "cols": cols, "rows": rows,
"encoding": "glyph"})
# --------------------------------------------------------------------------- #
def convert_image(path_or_img, mode="80x25", palette_name="vga",
dither_mode="floyd", intensive=False, prep_opt=None,
base_color=None):
"""Convert an image to ANSI BBS art.
``mode`` picks the canvas ("80x25" / "80x50", full 16-colour) or "mono"
(greyscale, or a hue tint via ``base_color``). With ``intensive`` set, every
8x16 cell is matched to the best CP437 glyph + colour pair (highest quality);
otherwise the fast half-block encoder runs and ``dither_mode`` chooses its
dither. The returned Conversion's ``data`` is the ready-to-write ``.ANS`` byte
stream and ``preview_rgb`` the rendered picture for the GUI.
"""
if prep_opt is None:
prep_opt = imageprep.PrepOptions()
cols, rows = _DIMS.get(mode, _DIMS["80x25"])
ramp = _mono_ramp(base_color) if mode == "mono" else list(range(16))
if intensive:
rgb = imageprep.prepare(path_or_img, cols * GLYPH_W, rows * GLYPH_H, 1.0,
prep_opt, border_rgb=(0, 0, 0))
return _convert_glyph(rgb, mode, cols, rows, ramp, dither_mode)
rgb = imageprep.prepare(path_or_img, cols, rows * 2, 1.0, prep_opt,
border_rgb=(0, 0, 0))
return _convert_halfblock(rgb, mode, cols, rows, dither_mode, ramp)

BIN
lenser/ansi/cp437_8x16.bin Normal file

Binary file not shown.

19
lenser/ansi/exporter.py Normal file
View file

@ -0,0 +1,19 @@
"""Write an ANSI/CP437 conversion to a ``.ANS`` file."""
from __future__ import annotations
import os
def export_ans(conv, path, source_path=None, display="key", seconds=0,
video="pal", layout="unified"):
"""Write the conversion's ANSI byte stream to ``path`` (forcing a .ans suffix).
The extra keyword arguments (display / seconds / video / layout) exist only so
ANSI shares the platform export interface; a static text file ignores them.
"""
if not str(path).lower().endswith(".ans"):
path = os.path.splitext(path)[0] + ".ans"
with open(path, "wb") as f:
f.write(conv.data)
return path

1
lenser/apple/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Apple II (Apple II+/IIe) image conversion and bootable disk export."""

View file

@ -0,0 +1,20 @@
"""Apple II conversion dispatch."""
from __future__ import annotations
from ... import imageprep
from . import hgr_mono
_MODULES = {"hgr_mono": hgr_mono}
for _name in ("dhgr", "hgr_color", "mono"):
try:
_MODULES[_name] = __import__(f"lenser.apple.convert.{_name}", fromlist=[_name])
except Exception:
pass
MODES = list(_MODULES.keys())
def convert_image(path_or_img, mode="hgr_mono", palette_name="mono",
dither_mode="floyd", intensive=False, prep_opt=None, base_color=None):
prep_opt = prep_opt or imageprep.PrepOptions()
module = _MODULES[mode]
img_rgb = imageprep.prepare(path_or_img, module.WIDTH, module.HEIGHT,
module.PIXEL_ASPECT, prep_opt, border_rgb=(0, 0, 0))
return module.convert(img_rgb, palette_name, dither_mode, intensive, base_color=base_color)

View file

@ -0,0 +1,60 @@
"""Apple //e Double Hi-Res: 140x192, 16 colours (no per-cell limit).
Each line is 560 bits = 140 four-bit colour groups, stored 7 bits per byte with
the bytes interleaved between auxiliary and main memory (aux holds the even display
bytes, main the odd). Needs a //e (auxiliary memory).
"""
from __future__ import annotations
import numpy as np
from ... import dither, palette as c64pal
from ...convert.base import Conversion, perceptual_error
from .. import palette as apal
WIDTH, HEIGHT = 140, 192
PIXEL_ASPECT = 2.0
DATA_ADDR = 0x2000
def convert(img_rgb, palette_name="dhgr", dither_mode="floyd",
intensive=False, base_color=None):
plab = apal.dhgr_lab()
prgb = apal.DHGR16.astype(np.uint8)
img_lab = c64pal.srgb_to_lab(img_rgb) # (192,140,3)
allowed = np.tile(np.arange(16), (HEIGHT, WIDTH, 1))
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
aux = bytearray(0x2000)
main = bytearray(0x2000)
for y in range(HEIGHT):
stream = np.zeros(560, dtype=np.uint8)
row = idx[y]
for n in range(WIDTH):
c = int(row[n])
stream[4 * n + 0] = c & 1
stream[4 * n + 1] = (c >> 1) & 1
stream[4 * n + 2] = (c >> 2) & 1
stream[4 * n + 3] = (c >> 3) & 1
base = apal.hgr_row_addr(y)
for col in range(40):
ab = 0
mb = 0
for i in range(7):
ab |= int(stream[7 * (2 * col) + i]) << i
mb |= int(stream[7 * (2 * col + 1) + i]) << i
aux[base + col] = ab
main[base + col] = mb
data = bytes(main) + bytes(aux) # main half at $2000, aux at $4000
preview = np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1)
return Conversion(
mode="dhgr", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=DATA_ADDR,
viewer="dhgr", preview_rgb=preview,
error=perceptual_error(idx, img_lab, plab),
meta={"palette": "dhgr", "dither": dither_mode},
)

View file

@ -0,0 +1,72 @@
"""Apple II HGR artifact colour: 140x192 colour pixels (280x192 mono), ~6 colours.
Each 2-mono-pixel "colour pixel" can be black, white, or one of two chroma colours
set by its byte's palette bit (violet/green for palette 0, blue/orange for palette 1).
We pick the palette bit per byte, then dither each colour pixel to its 4 reachable
colours. Works on the II+ and //e, and reuses the HGR boot loader.
"""
from __future__ import annotations
import numpy as np
from ... import dither, palette as c64pal
from ...convert.base import Conversion, perceptual_error
from .. import palette as apal
WIDTH, HEIGHT = 140, 192 # colour-pixel resolution
PIXEL_ASPECT = 2.0
DATA_ADDR = 0x2000
N_BYTES = 40
# index sets reachable in a byte for each palette bit: {black, even-chroma, odd-chroma, white}
_SET = {
0: [apal.HGR_BLACK, apal.HGR_VIOLET, apal.HGR_GREEN, apal.HGR_WHITE],
1: [apal.HGR_BLACK, apal.HGR_BLUE, apal.HGR_ORANGE, apal.HGR_WHITE],
}
def convert(img_rgb, palette_name="hgr", dither_mode="floyd",
intensive=False, base_color=None):
plab = apal.hgr6_lab()
prgb = apal.HGR6.astype(np.uint8)
img_lab = c64pal.srgb_to_lab(img_rgb) # (192,140,3)
# choose a palette bit per byte (per line), by nearest-colour error.
pal_byte = np.zeros((HEIGHT, N_BYTES), dtype=np.uint8)
allowed = np.zeros((HEIGHT, WIDTH, 4), dtype=np.int64)
for y in range(HEIGHT):
for b in range(N_BYTES):
ks = [k for k in range(WIDTH) if (2 * k) // 7 == b]
if not ks:
continue
best_p, best_e = 0, None
for p in (0, 1):
cols = np.array(_SET[p])
d = np.sum((img_lab[y, ks][:, None, :] - plab[cols][None, :, :]) ** 2, axis=-1)
e = d.min(axis=1).sum()
if best_e is None or e < best_e:
best_e, best_p = e, p
pal_byte[y, b] = best_p
for k in ks:
allowed[y, k] = _SET[best_p]
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
# colour-pixel index -> two mono bits at (2k, 2k+1)
bits = np.zeros((HEIGHT, WIDTH * 2), dtype=np.uint8)
even_on = np.isin(idx, [apal.HGR_VIOLET, apal.HGR_BLUE, apal.HGR_WHITE])
odd_on = np.isin(idx, [apal.HGR_GREEN, apal.HGR_ORANGE, apal.HGR_WHITE])
bits[:, 0::2] = even_on
bits[:, 1::2] = odd_on
data = apal.pack_hgr_color(bits, pal_byte)
preview = np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1)
return Conversion(
mode="hgr_color", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=DATA_ADDR,
viewer="hgr", preview_rgb=preview,
error=perceptual_error(idx, img_lab, plab),
meta={"palette": "hgr", "dither": dither_mode},
)

View file

@ -0,0 +1,41 @@
"""Apple II HGR monochrome: 280x192, 1 bit/pixel, black & white.
Universal across the Apple II+ and //e. Tone is carried entirely by dithering.
"""
from __future__ import annotations
import numpy as np
from ... import dither
from ...convert.base import Conversion, perceptual_error
from .. import palette as apal
WIDTH, HEIGHT = 280, 192
PIXEL_ASPECT = 1.0
DATA_ADDR = 0x2000 # HGR page 1
def convert(img_rgb, palette_name="mono", dither_mode="floyd",
intensive=False, base_color=None):
from ...palette import srgb_to_lab
plab = apal.mono_lab() # 2 entries: black, white
L = srgb_to_lab(img_rgb)[..., 0]
img_mono = np.zeros((HEIGHT, WIDTH, 3))
img_mono[..., 0] = L
plab_mono = np.zeros((2, 3))
plab_mono[:, 0] = plab[:, 0]
allowed = np.tile(np.array([0, 1]), (HEIGHT, WIDTH, 1))
idx = dither.quantize(img_mono, allowed, plab_mono, dither_mode).astype(np.uint8)
data = apal.pack_hgr_mono(idx) # 8192-byte HGR buffer
preview = (apal.MONO.astype(np.uint8))[idx]
return Conversion(
mode="hgr_mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=DATA_ADDR,
viewer="hgr", preview_rgb=preview,
error=perceptual_error(idx, img_mono, plab_mono),
meta={"palette": "mono", "dither": dither_mode},
)

View file

@ -0,0 +1,15 @@
"""Apple monochrome -- HGR's 280x192 black & white, exposed as the standard
``mono`` mode for cross-platform parity (tone carried by dithering)."""
from __future__ import annotations
from . import hgr_mono
WIDTH, HEIGHT, PIXEL_ASPECT = hgr_mono.WIDTH, hgr_mono.HEIGHT, hgr_mono.PIXEL_ASPECT
def convert(img_rgb, palette_name="mono", dither_mode="floyd",
intensive=False, base_color=None):
conv = hgr_mono.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color)
conv.mode = "mono"
return conv

53
lenser/apple/dsk.py Normal file
View file

@ -0,0 +1,53 @@
"""Write a bootable Apple II .dsk (DOS 3.3 sector order, 143360 bytes).
The .dsk stores sectors in DOS *logical* order, but the Disk II boot ROM reads
*physical* sectors, so we place each chunk of the bitmap at the logical slot that
maps to the physical sector our loader will read -- making the loader's
physical-sequential reads come out contiguous in memory.
"""
from __future__ import annotations
# DOS 3.3 physical-sector -> logical-sector (the .dsk read interleave).
PHYS2LOG = [0, 7, 14, 6, 13, 5, 12, 4, 11, 3, 10, 2, 9, 1, 8, 15]
SECTOR = 256
SPT = 16
TRACKS = 35
DISK_SIZE = TRACKS * SPT * SECTOR # 143360
class DskError(RuntimeError):
pass
def _offset(track: int, phys_sector: int) -> int:
return (track * SPT + PHYS2LOG[phys_sector]) * SECTOR
def build_boot_dsk(boot: bytes, payload: bytes) -> bytes:
"""boot = assembled boot0 (origin $0800); payload = data pages the loader reads
in order: track 0 sectors 1-15, then whole tracks 1, 2, ... (physical order)."""
if len(payload) % SECTOR:
raise DskError("payload must be a whole number of 256-byte sectors")
npages = len(payload) // SECTOR
disk = bytearray(DISK_SIZE)
disk[0:SECTOR] = (bytes(boot) + bytes(SECTOR))[:SECTOR] # boot0 at T0 phys 0
assigns = [(0, p) for p in range(1, 16)] # track 0 (skip boot)
track = 1
while len(assigns) < npages:
assigns += [(track, p) for p in range(16)]
track += 1
if track > TRACKS:
raise DskError("payload exceeds disk capacity")
for page, (trk, phys) in enumerate(assigns[:npages]):
off = _offset(trk, phys)
disk[off:off + SECTOR] = payload[page * SECTOR:(page + 1) * SECTOR]
return bytes(disk)
def write_dsk(path: str, data: bytes) -> str:
with open(path, "wb") as f:
f.write(data)
return path

10
lenser/apple/exporter.py Normal file
View file

@ -0,0 +1,10 @@
"""Build a bootable Apple II .dsk from a conversion."""
from __future__ import annotations
from . import dsk
from .viewer.assemble import assemble_stub
def export_dsk(conv, output_path, source_path=None, display="forever", seconds=0):
if not output_path.lower().endswith((".dsk", ".do")):
output_path += ".dsk"
boot = assemble_stub(conv.viewer, display=display, seconds=seconds)
return dsk.write_dsk(output_path, dsk.build_boot_dsk(boot, conv.data))

107
lenser/apple/palette.py Normal file
View file

@ -0,0 +1,107 @@
"""Apple II colour palettes and the HGR memory-layout helper.
- HGR mono: black/white (the 280x192 1-bit bitmap displayed as monochrome).
- HGR colour: 6 NTSC "artifact" colours (added later).
- DHGR: 16 colours (added later).
"""
from __future__ import annotations
import numpy as np
from ..palette import srgb_to_lab
# Monochrome (white phosphor) pair.
MONO = np.array([(0, 0, 0), (255, 255, 255)], dtype=np.float64)
# Double-Hi-Res 16-colour palette, indexed by the 4-bit value -- measured from
# MAME's apple2ee DHGR output so the encoder's nibble values map to exactly what
# the //e displays.
DHGR16 = np.array([
(0x00, 0x00, 0x00), # 0 black
(0x40, 0x1c, 0xf7), # 1 blue
(0x00, 0x74, 0x40), # 2 dark green
(0x19, 0x90, 0xff), # 3 medium blue
(0x40, 0x63, 0x00), # 4 olive / dark green
(0x80, 0x80, 0x80), # 5 grey
(0x19, 0xd7, 0x00), # 6 green
(0x58, 0xf4, 0xbf), # 7 aqua
(0xa7, 0x0b, 0x40), # 8 dark red / magenta
(0xe6, 0x28, 0xff), # 9 magenta / violet
(0x80, 0x80, 0x80), # 10 grey
(0xbf, 0x9c, 0xff), # 11 lavender
(0xe6, 0x6f, 0x00), # 12 orange
(0xff, 0x8b, 0xbf), # 13 pink
(0xbf, 0xe3, 0x08), # 14 yellow-green
(0xff, 0xff, 0xff), # 15 white
], dtype=np.float64)
# HGR NTSC "artifact" colours. Per 7-pixel byte a palette bit selects one of two
# colour pairs; the displayed colour of an "on" pixel also depends on its column
# parity (and two adjacent on-pixels read as white).
# palette 0: even column -> violet, odd column -> green
# palette 1: even column -> blue, odd column -> orange
HGR_BLACK, HGR_VIOLET, HGR_GREEN, HGR_WHITE, HGR_BLUE, HGR_ORANGE = 0, 1, 2, 3, 4, 5
HGR6 = np.array([
(0x00, 0x00, 0x00), # black
(0xd0, 0x3a, 0xff), # violet
(0x20, 0xc8, 0x00), # green
(0xff, 0xff, 0xff), # white
(0x20, 0x9a, 0xff), # blue
(0xff, 0x6a, 0x20), # orange
], dtype=np.float64)
def mono_lab() -> np.ndarray:
return srgb_to_lab(MONO)
def hgr6_lab() -> np.ndarray:
return srgb_to_lab(HGR6)
def pack_hgr_color(bits280: np.ndarray, pal_byte: np.ndarray) -> bytes:
"""280x192 mono bits + (192x40) per-byte palette bit -> 8192 HGR buffer."""
buf = bytearray(0x2000)
H = bits280.shape[0]
for y in range(H):
base = hgr_row_addr(y)
row = bits280[y]
for bx in range(40):
b = 0
for i in range(7):
if row[bx * 7 + i]:
b |= (1 << i)
if pal_byte[y, bx]:
b |= 0x80
buf[base + bx] = b
return bytes(buf)
def dhgr_lab() -> np.ndarray:
return srgb_to_lab(DHGR16)
def hgr_row_addr(y: int) -> int:
"""Offset (from $2000) of HGR row ``y`` (0..191) in the interleaved layout."""
return (y & 7) * 0x400 + ((y >> 3) & 7) * 0x80 + (y >> 6) * 0x28
def pack_hgr_mono(val_image: np.ndarray) -> bytes:
"""280x192 1-bit image -> 8192-byte HGR page 1 buffer.
7 pixels per byte, bit 0 = leftmost, bit 7 (palette bit) = 0 for mono.
"""
buf = bytearray(0x2000)
H, W = val_image.shape
for y in range(H):
base = hgr_row_addr(y)
row = val_image[y]
for bx in range(40):
b = 0
for i in range(7):
if row[bx * 7 + i]:
b |= (1 << i)
buf[base + bx] = b
return bytes(buf)

View file

@ -0,0 +1 @@
from .assemble import AssemblerError, SOURCES, assemble_stub, have_xa # noqa: F401

View file

@ -0,0 +1,97 @@
"""Assemble the Apple II boot/viewer with `xa` (origin $0800, raw bytes)."""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
SOURCES = {"hgr": "hgr.s", "dhgr": "dhgr.s"}
_cache: dict[tuple, bytes] = {}
# How long the viewer holds the picture (see apple/viewer/awyt.i).
WAIT_MODES = {"forever": 0, "key": 1, "seconds": 2}
SS_WAITMODE = {"key": 1, "seconds": 2, "both": 3}
def build_slideshow_stub(n_images: int, advance: str = "both", seconds: int = 10,
loop: bool = True) -> bytes:
"""Assemble the Apple HGR slideshow boot loader (one 256-byte boot sector).
Reads NIMAGES * 32 sectors into the $4000 buffer and cycles them; must fit a
single boot sector since the Disk II ROM only loads sector 0.
"""
import shutil as _sh
if not _sh.which("xa"):
raise AssemblerError("The 'xa' assembler was not found on PATH.")
end_page = 0x40 + n_images * 0x20
wrapper = (f"#define WAITMODE {SS_WAITMODE[advance]}\n"
f"#define WAITSECS {max(0, min(255, int(seconds)))}\n"
f"#define NIMAGES {n_images}\n"
f"#define LOOPFLAG {1 if loop else 0}\n"
f"#define ENDPAGE ${end_page:02X}\n"
'#include "slideshow.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:
raw = f.read()
finally:
os.unlink(wrap)
if len(raw) > 256:
raise AssemblerError(
f"Apple slideshow boot loader is {len(raw)} bytes, over the 256-byte "
"boot sector")
return raw
class AssemblerError(RuntimeError):
pass
def have_xa() -> bool:
return shutil.which("xa") is not None
def assemble_stub(viewer_key: str, display: str = "forever", seconds: int = 0) -> bytes:
waitmode = WAIT_MODES.get(display, 0)
secs = max(0, min(255, int(seconds))) # 8-bit delay counter
key = (viewer_key, waitmode, secs)
if key in _cache:
return _cache[key]
if not have_xa():
raise AssemblerError("The 'xa' assembler was not found on PATH.")
if not os.path.exists(os.path.join(VIEWER_DIR, SOURCES[viewer_key])):
raise AssemblerError(f"viewer source missing: {SOURCES[viewer_key]}")
# Wrapper sets options then includes the real source; run from VIEWER_DIR so
# the source's #include "awyt.i" resolves (xa looks relative to cwd).
wrapper = (f"#define WAITMODE {waitmode}\n"
f"#define WAITSECS {secs}\n"
f'#include "{SOURCES[viewer_key]}"\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 for {viewer_key}:\n{proc.stdout}{proc.stderr}")
with open(out, "rb") as f:
raw = f.read()
finally:
os.unlink(wrap)
_cache[key] = raw
return raw

View file

@ -0,0 +1,44 @@
; Shared display-duration epilogue for the Apple II viewers (HGR already on).
; WAITMODE 0 forever, 1 until a key, 2 about WAITSECS seconds.
; key and seconds exit to Applesoft BASIC ($E000 cold start), a real prompt with
; no DOS needed. The II and II+ have no timer, so seconds is a calibrated delay
; loop near 1 MHz. WAITSECS is clamped to 255 by the assembler.
#if WAITMODE == 0
awhang:
jmp awhang
#endif
#if WAITMODE == 1
awhang:
lda $c000 ; keyboard; bit7 set = a key is down
bpl awhang
bit $c010 ; clear strobe
lda $c051 ; switch to text so the BASIC prompt is visible
lda $c054 ; page 1
jmp $e000 ; Applesoft cold start
#endif
#if WAITMODE == 2
lda #WAITSECS
sta $fd ; seconds remaining
aw_o:
lda #$03 ; ~1 second = 3 * (256*256 inner) at ~1 MHz
sta $fc
aw_m:
ldx #$00
aw_x:
ldy #$00
aw_y:
dey
bne aw_y
dex
bne aw_x
dec $fc
bne aw_m
dec $fd
bne aw_o
lda $c051 ; switch to text so the BASIC prompt is visible
lda $c054 ; page 1
jmp $e000 ; Applesoft cold start
#endif

128
lenser/apple/viewer/dhgr.s Normal file
View file

@ -0,0 +1,128 @@
; lenser -- Apple //e Double Hi-Res boot loader + viewer (self-contained)
;
; Loads 16K to main $2000-$5FFF with ordinary reads (main half at $2000, aux half
; at $4000), then block-copies $4000-$5FFF into auxiliary $2000-$3FFF (RAMWRT on
; only for that clean copy, never during the boot-ROM reads), then turns on DHGR.
; ROM read at $C65C reads sector $3D into page $27 and re-enters $0801.
;
; assembled by apple/viewer/assemble.py via xa
* = $0800
.byte $01
entry: ; $0801, re-entered after each ROM read
lda dpage
cmp #$60 ; loaded $2000-$5FFF (64 pages)?
bcs done
lda psec
cmp #$10
bcc readit
jsr seeknext
lda #$00
sta psec
readit:
lda psec
sta $3d ; desired sector
lda curtrk
sta $41 ; desired track (the ROM read verifies BOTH)
lda #$00
sta $26
lda dpage
sta $27
inc psec
inc dpage
ldx $2b
jmp $c65c
done:
; copy main $4000-$5FFF -> aux $2000-$3FFF
lda #$00
sta $06
lda #$40
sta $07 ; src = $4000
lda #$00
sta $08
lda #$20
sta $09 ; dst = $2000
sta $c005 ; RAMWRT on (writes go to aux)
ldx #$20 ; 32 pages
cpl:
ldy #$00
cp1:
lda ($06),y
sta ($08),y
iny
bne cp1
inc $07
inc $09
dex
bne cpl
sta $c004 ; RAMWRT off
; turn on Double Hi-Res
lda $c050 ; graphics
lda $c052 ; full screen
lda $c054 ; page 1
lda $c057 ; hi-res
sta $c00d ; SET80VID (write-triggered switch -- must STA)
sta $c05e ; SETDHIRES (write-triggered)
#include "awyt.i"
; advance the head one track (two half-steps) with the standard phase-overlap
; seek. energize the NEXT phase while the current one is still on (this pulls
; the head smoothly), then release the old phase, using on/off settle delays
; from an acceleration table indexed by step number ($0a). the final phase is
; released before reading.
seeknext:
inc curtrk ; now on the next track
lda #$00
sta $0a ; step index for the timing table
jsr onestep
jsr onestep
lda halftrk ; release final phase before the read
and #$03
asl
ora $2b
tax
lda $c080,x
rts
onestep:
lda halftrk ; energize NEXT phase (halftrk+1), old still on
clc
adc #$01
and #$03
asl
ora $2b
tax
lda $c081,x
ldx $0a
lda ontable,x
jsr wait
lda halftrk ; release OLD phase (halftrk)
and #$03
asl
ora $2b
tax
lda $c080,x
ldx $0a
lda offtable,x
jsr wait
inc halftrk
inc $0a
rts
; Apple seek timing tables (head accelerates over a move, slow first step).
ontable: .byte $13,$0a,$08,$06,$05,$04,$04,$03
offtable: .byte $46,$1a,$10,$0c,$0a,$09,$08,$08
wait: ; delay loop ~ proportional to A
tay
w1:
ldx #$00
w2:
dex
bne w2
dey
bne w1
rts
psec: .byte $01
dpage: .byte $20
halftrk: .byte $00
curtrk: .byte $00

102
lenser/apple/viewer/hgr.s Normal file
View file

@ -0,0 +1,102 @@
; lenser -- Apple II HGR boot loader + viewer (self-contained, no DOS)
;
; The Disk II boot ROM loads track 0 sector 0 to $0800 and JMPs $0801. This
; re-entrant loader reads the 32 sectors of the 8K HGR bitmap into $2000-$3FFF
; (track 0 sectors 1-15, then tracks 1 and 2), seeking the head between tracks
; with the standard phase-overlap step, then switches on HGR and loops. The ROM
; read at $Cn5C verifies the address field against BOTH the sector ($3D) and the
; track ($41), then reads into page $27 and JMPs back to $0801.
;
; assembled by apple/viewer/assemble.py via xa
* = $0800
.byte $01 ; ROM scratch (boot sector byte 0)
entry: ; $0801, (re)entered after every ROM read
lda dpage
cmp #$40
bcs done ; loaded $2000..$3FFF -> show it
lda psec
cmp #$10
bcc readit ; still sectors left on this track
jsr seeknext ; finished a track -> step to the next
lda #$00
sta psec
readit:
lda psec
sta $3d ; desired sector
lda curtrk
sta $41 ; desired track (ROM read verifies both)
lda #$00
sta $26 ; buffer lo
lda dpage
sta $27 ; buffer hi
inc psec
inc dpage
ldx $2b ; slot*16 (set by boot ROM)
jmp $c65c ; slot 6 ROM read; reads sector then JMP $0801
done:
lda $c050 ; graphics
lda $c052 ; full screen (not mixed)
lda $c054 ; page 1
lda $c057 ; hi-res
#include "awyt.i"
; advance the head one track (two half-steps) with the standard phase-overlap
; step. energize the next phase while the current one is still on, then release
; the old phase, using on/off settle delays from an acceleration table.
seeknext:
inc curtrk
lda #$00
sta $0a
jsr onestep
jsr onestep
lda halftrk ; release final phase before reading
and #$03
asl
ora $2b
tax
lda $c080,x
rts
onestep:
lda halftrk ; energize NEXT phase (old still on -> overlap)
clc
adc #$01
and #$03
asl
ora $2b
tax
lda $c081,x
ldx $0a
lda ontable,x
jsr wait
lda halftrk ; release OLD phase
and #$03
asl
ora $2b
tax
lda $c080,x
ldx $0a
lda offtable,x
jsr wait
inc halftrk
inc $0a
rts
ontable: .byte $13,$0a,$08,$06
offtable: .byte $46,$1a,$10,$0c
wait:
tay
w1:
ldx #$00
w2:
dex
bne w2
dey
bne w1
rts
; ---- loader state (initial values; updated in place during the load) ----
psec: .byte $01 ; next physical sector (track 0 starts at 1)
dpage: .byte $20 ; next destination page ($2000)
halftrk: .byte $00 ; current half-track
curtrk: .byte $00 ; current track

View file

@ -0,0 +1,204 @@
; lenser -- Apple II HGR slideshow loader + viewer (self-contained, no DOS).
;
; The Disk II boot ROM loads track 0 sector 0 to $0800 and JMPs $0801. This
; loader reads ALL the slideshow's HGR images (NIMAGES * 32 sectors) contiguously
; into a RAM buffer from $4000 up (image i at $4000 + i*$2000), then cycles --
; copying each image into HGR page 1 ($2000), switching on graphics, and waiting
; (key / seconds / both) before the next. Looping is RAM-only (no re-seek).
;
; #defines from the wrapper --
; WAITMODE 1 key / 2 seconds / 3 both WAITSECS seconds (~) NIMAGES count
; LOOPFLAG 1 wrap / 0 stop ENDPAGE one past the last buffer page ($40+N*$20)
;
; assembled by apple/viewer/assemble.py via xa
src = $06 ; zero-page copy pointers
dst = $08
ssidx = $19
* = $0800
.byte $01 ; ROM scratch (boot sector byte 0)
entry: ; $0801, (re)entered after every ROM read
lda dpage
cmp #ENDPAGE
bcs loaded ; whole buffer read -> start the show
lda psec
cmp #$10
bcc readit
jsr seeknext
lda #$00
sta psec
readit:
lda psec
sta $3d
lda curtrk
sta $41
lda #$00
sta $26
lda dpage
sta $27
inc psec
inc dpage
ldx $2b
jmp $c65c ; slot-6 ROM read; reads a sector then JMP $0801
loaded:
lda #$00
sta ssidx
cyc:
; ---- copy image ssidx ($4000 + ssidx*$2000) -> HGR page 1 ($2000) ----
lda ssidx
asl
asl
asl
asl
asl ; ssidx * $20 pages (carry clear, <=4 images)
adc #$40
sta src+1 ; source hi = $40 + ssidx*$20
lda #$00
sta src
sta dst
lda #$20
sta dst+1 ; dest = $2000
ldx #$20 ; 32 pages
ldy #$00
cpyl:
lda (src),y
sta (dst),y
iny
bne cpyl
inc src+1
inc dst+1
dex
bne cpyl
lda $c050 ; graphics
lda $c054 ; page 1
lda $c057 ; hi-res
jsr sswait
inc ssidx
lda ssidx
cmp #NIMAGES
bcc cyc
#if LOOPFLAG == 1
jmp loaded ; wrap (re-uses the ssidx=0 init at loaded)
#else
lda $c051 ; text
lda $c054
jmp $e000 ; Applesoft cold start
#endif
; ---- wait (returns); clears the key strobe first ----
sswait:
bit $c010
#if WAITMODE == 1
swk:
lda $c000
bpl swk
bit $c010
rts
#endif
#if WAITMODE == 2
lda #WAITSECS
sta $fd
so:
lda #$03
sta $fc
sm:
ldx #$00
sx:
ldy #$00
sy:
dey
bne sy
dex
bne sx
dec $fc
bne sm
dec $fd
bne so
rts
#endif
#if WAITMODE == 3
lda #WAITSECS
sta $fd
bo:
lda #$03
sta $fc
bm:
ldx #$00
bx:
lda $c000
bmi bdone ; a key ends the slide
ldy #$00
by:
dey
bne by
dex
bne bx
dec $fc
bne bm
dec $fd
bne bo
bdone:
bit $c010 ; (also harmless on timeout)
rts
#endif
; advance the head one track (two half-steps), phase-overlap step (from hgr.s)
seeknext:
inc curtrk
lda #$00
sta $0a
jsr onestep
jsr onestep
lda halftrk
and #$03
asl
ora $2b
tax
lda $c080,x
rts
onestep:
lda halftrk
clc
adc #$01
and #$03
asl
ora $2b
tax
lda $c081,x
ldx $0a
lda ontable,x
jsr wait
lda halftrk
and #$03
asl
ora $2b
tax
lda $c080,x
ldx $0a
lda offtable,x
jsr wait
inc halftrk
inc $0a
rts
ontable: .byte $13,$0a,$08,$06
offtable: .byte $46,$1a,$10,$0c
wait:
tay
w1:
ldx #$00
w2:
dex
bne w2
dey
bne w1
rts
psec: .byte $01 ; next physical sector (track 0 starts at 1)
dpage: .byte $40 ; next destination page ($4000)
halftrk: .byte $00
curtrk: .byte $00

1
lenser/atari/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Atari 8-bit (Atari 400/800/XL/XE) image conversion and bootable disk export."""

101
lenser/atari/atr.py Normal file
View file

@ -0,0 +1,101 @@
"""Write a bootable Atari ``.atr`` disk image natively (no external tools).
A self-booting Atari disk needs no DOS: sector 1 begins with a 6-byte boot header
(flags, sector-count, load-address, init-address); the OS loads ``count`` 128-byte
sectors to the load address and JSRs ``load+6``. We pack the whole viewer+picture
blob that way, so inserting the disk shows the picture.
"""
from __future__ import annotations
SECTOR_SIZE = 128
TOTAL_SECTORS = 720 # single density, ~90K
LOAD_ADDR = 0x2000 # boot load address (blob origin)
DATA_ADDR = 0x4000 # where the bitmap must land (4K-aligned for ANTIC)
class AtrError(RuntimeError):
pass
def build_blob(stub: bytes, data: bytes) -> bytes:
"""Combine the assembled viewer ``stub`` (origin $2000, starts with the 6-byte
boot header) + padding + picture ``data`` (which must reside from $4000)."""
pad = (DATA_ADDR - LOAD_ADDR) - len(stub)
if pad < 0:
raise AtrError(f"viewer stub {len(stub)} bytes exceeds "
f"{DATA_ADDR - LOAD_ADDR} before $4000")
return stub + bytes(pad) + bytes(data)
def _write_atr_sectors(path: str, data: bytes) -> str:
"""Write ``data`` (already laid out as raw sectors) as a single-density ATR,
padded to the full 720-sector disk with the 16-byte ATR header."""
data = bytearray(data)
if len(data) > TOTAL_SECTORS * SECTOR_SIZE:
raise AtrError("slideshow exceeds the 720-sector disk capacity")
data += bytes(TOTAL_SECTORS * SECTOR_SIZE - len(data))
paragraphs = (TOTAL_SECTORS * SECTOR_SIZE) // 16
header = bytes([
0x96, 0x02,
paragraphs & 0xFF, (paragraphs >> 8) & 0xFF,
SECTOR_SIZE & 0xFF, (SECTOR_SIZE >> 8) & 0xFF,
(paragraphs >> 16) & 0xFF, (paragraphs >> 24) & 0xFF,
0, 0, 0, 0, 0, 0, 0, 0,
])
with open(path, "wb") as f:
f.write(header)
f.write(data)
return path
def write_slideshow_atr(path: str, stub: bytes, images: list[bytes],
boot_sectors: int, spi: int) -> str:
"""Write a self-booting slideshow ATR.
Sectors 1..boot_sectors hold ``stub`` (boot header byte 1 patched to
boot_sectors so the OS loads it all); each image then occupies ``spi``
consecutive 128-byte sectors (image i at sector boot_sectors + 1 + i*spi),
matching what the viewer SIO-reads.
"""
if len(stub) > boot_sectors * SECTOR_SIZE:
raise AtrError(f"slideshow viewer {len(stub)} bytes exceeds "
f"{boot_sectors} boot sectors")
blob = bytearray(stub)
blob[1] = boot_sectors # OS loads this many sectors
blob += bytes(boot_sectors * SECTOR_SIZE - len(blob))
for img in images:
if len(img) > spi * SECTOR_SIZE:
raise AtrError("image larger than its sector allotment")
blob += bytes(img) + bytes(spi * SECTOR_SIZE - len(img))
return _write_atr_sectors(path, bytes(blob))
def write_boot_atr(path: str, blob: bytes) -> str:
"""Write ``blob`` as a bootable single-density ATR. Patches the boot sector
count (byte 1) from the blob length."""
nsec = (len(blob) + SECTOR_SIZE - 1) // SECTOR_SIZE
if nsec > 255:
raise AtrError(f"boot blob needs {nsec} sectors (max 255)")
if nsec > TOTAL_SECTORS:
raise AtrError("boot blob exceeds disk capacity")
blob = bytearray(blob)
blob[1] = nsec # boot header sector count
blob += bytes((-len(blob)) % SECTOR_SIZE) # pad to whole sectors
data = bytearray(blob)
data += bytes(TOTAL_SECTORS * SECTOR_SIZE - len(data)) # pad disk to 720 sectors
paragraphs = (TOTAL_SECTORS * SECTOR_SIZE) // 16
header = bytes([
0x96, 0x02, # magic
paragraphs & 0xFF, (paragraphs >> 8) & 0xFF, # size (paragraphs) low
SECTOR_SIZE & 0xFF, (SECTOR_SIZE >> 8) & 0xFF,
(paragraphs >> 16) & 0xFF, (paragraphs >> 24) & 0xFF,
0, 0, 0, 0, 0, 0, 0, 0,
])
with open(path, "wb") as f:
f.write(header)
f.write(data)
return path

20
lenser/atari/car.py Normal file
View file

@ -0,0 +1,20 @@
"""Write an Atari .car cartridge image (CART header + ROM)."""
from __future__ import annotations
import struct
TYPE_STD_16K = 2 # "Standard 16 KB cartridge"
def write_car(rom: bytes, path: str, cart_type: int = TYPE_STD_16K) -> str:
"""Wrap a cart `rom` in the .car container (16-byte CART header + ROM).
The header holds the cartridge type and a checksum (sum of all ROM bytes)."""
header = bytearray(16)
header[0:4] = b"CART"
struct.pack_into(">I", header, 4, cart_type)
struct.pack_into(">I", header, 8, sum(rom) & 0xFFFFFFFF)
with open(path, "wb") as f:
f.write(header)
f.write(rom)
return path

View file

@ -0,0 +1,31 @@
"""Atari conversion dispatch."""
from __future__ import annotations
from ... import imageprep
from .. import palette as apal
from . import gr15
_MODULES = {"gr15": gr15}
for _name in ("gr9", "gr8", "gr15dli", "mono"):
try:
_mod = __import__(f"lenser.atari.convert.{_name}", fromlist=[_name])
_MODULES[_name] = _mod
except Exception:
pass
MODES = list(_MODULES.keys())
def convert_image(path_or_img, mode="gr15", palette_name="ntsc",
dither_mode="floyd", intensive=False,
prep_opt: imageprep.PrepOptions | None = None, base_color=None):
prep_opt = prep_opt or imageprep.PrepOptions()
module = _MODULES[mode]
border_rgb = apal.get_palette(palette_name)[0]
img_rgb = imageprep.prepare(
path_or_img, module.WIDTH, module.HEIGHT, module.PIXEL_ASPECT,
prep_opt, border_rgb=border_rgb,
)
return module.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color)

View file

@ -0,0 +1,176 @@
"""Shared helpers for the Atari encoders."""
from __future__ import annotations
import numpy as np
DATA_ADDR = 0x4000 # bitmap base
COLOR_ADDR = 0x6000 # colour data base (fixed, after the bitmap)
SPLIT_LINE = 102 # lines that fit in the first 4K ($4000-$4FEF)
BYTES_PER_LINE = 40
LINES = 192
def split_screen(line_bytes: list[bytes]) -> bytes:
"""Lay out 192 screen lines with the 16-byte gap that pushes line 102 onto
the $5000 boundary (so no ANTIC line crosses a 4K boundary), then pad up to
COLOR_ADDR so colour data can follow at a fixed address."""
first = b"".join(line_bytes[:SPLIT_LINE]) # 4080 bytes -> $4000
second = b"".join(line_bytes[SPLIT_LINE:]) # 3600 bytes -> $5000
body = first + bytes(0x1000 - len(first)) + second # gap fills to $5000
pad = (COLOR_ADDR - DATA_ADDR) - len(body)
return body + bytes(pad)
def luminance_lab(img_rgb, plab):
"""Return (image, palette) recast into luminance-only CIELAB (L, 0, 0), so
matching is by brightness alone -- used by the single-hue modes."""
from ...palette import srgb_to_lab
L = srgb_to_lab(img_rgb)[..., 0]
img_mono = np.zeros(img_rgb.shape[:2] + (3,))
img_mono[..., 0] = L
plab_mono = np.zeros_like(plab)
plab_mono[:, 0] = plab[:, 0]
return img_mono, plab_mono
def choose_palette(img_lab: np.ndarray, plab: np.ndarray, k: int,
iters: int = 12) -> list[int]:
"""Pick the ``k`` palette register values (0..255) that best represent the
image, by palette-constrained k-means in CIELAB."""
flat = img_lab.reshape(-1, 3).astype(np.float32)
D = np.sum((flat[:, None, :] - plab[None, :, :].astype(np.float32)) ** 2, axis=-1) # (N,256)
# k-means++-ish greedy init.
chosen = [int(np.argmin(np.sum((plab - flat.mean(0)) ** 2, axis=-1)))]
for _ in range(k - 1):
md = D[:, chosen].min(axis=1)
improv = np.maximum(0.0, md[:, None] - D).sum(axis=0)
improv[chosen] = -1.0
chosen.append(int(np.argmax(improv)))
# Lloyd refinement, each centroid snapped to its best palette colour.
for _ in range(iters):
assign = np.argmin(D[:, chosen], axis=1)
new = []
for j in range(k):
mask = assign == j
if not mask.any():
new.append(chosen[j])
else:
new.append(int(np.argmin(D[mask].sum(axis=0))))
# keep distinct where possible
if new == chosen:
break
chosen = new
return chosen
def _seg_all(sub, c1all, c2):
"""Distance from each ``sub`` pixel to the segment between every palette colour
(c1all, shape (256,3)) and a fixed endpoint c2. Returns (256, Nsub)."""
seg = c2 - c1all # (256,3)
L = np.sum(seg * seg, axis=1) + 1e-9 # (256,)
rel = sub[None, :, :] - c1all[:, None, :] # (256,Nsub,3)
t = np.clip(np.sum(rel * seg[:, None, :], axis=2) / L[:, None], 0.0, 1.0)
proj = c1all[:, None, :] + t[:, :, None] * seg[:, None, :]
return np.sum((sub[None, :, :] - proj) ** 2, axis=2)
def relevant_candidates(img_lab, plab):
"""Palette colours that are the nearest match to some image pixel -- a small
set (the image's own gamut) to restrict the dither-aware search to."""
flat = img_lab.reshape(-1, 3).astype(np.float32)
if len(flat) > 4000:
flat = flat[::len(flat) // 4000]
d = np.sum((flat[:, None, :] - plab[None, :, :].astype(np.float32)) ** 2, axis=-1)
return np.unique(np.argmin(d, axis=1)).astype(np.int64)
def choose_palette_dither(img_lab, plab, k, init=None, n_sample=900, iters=5,
candidates=None):
"""Dither-aware palette: pick the ``k`` colours whose pairwise *segment* blends
(what error diffusion can reproduce) best cover the image -- so the colours
span the gamut instead of sitting at k-means centroids. Vectorised local
search (all candidates per slot at once) from a k-means start."""
from itertools import combinations
flat = img_lab.reshape(-1, 3)
sub = flat[::max(1, len(flat) // n_sample)] if len(flat) > n_sample else flat
colors = list(init) if init is not None else choose_palette(img_lab, plab, k)
cand = np.asarray(candidates if candidates is not None else range(256), np.int64)
cand_lab = plab[cand].astype(np.float64) # (C,3)
for _ in range(iters):
changed = False
for i in range(k):
others = [colors[j] for j in range(k) if j != i]
fixed = None
for x, y in combinations(others, 2):
s = _seg_all(sub, plab[x][None], plab[y])[0]
fixed = s if fixed is None else np.minimum(fixed, s)
m = None
for o in others:
d = _seg_all(sub, cand_lab, plab[o]) # (C, Nsub)
m = d if m is None else np.minimum(m, d)
if fixed is not None:
m = np.minimum(m, fixed[None, :])
err = m.sum(axis=1) # (C,)
for ci, c in enumerate(cand):
if c in others:
err[ci] = np.inf # avoid duplicate colours
best = int(cand[np.argmin(err)])
if best != colors[i]:
colors[i] = best
changed = True
if not changed:
break
return colors
def quantize_global(img_lab, plab, colors, dither_mode):
"""Dither the whole image to a fixed global set of palette indices."""
from ... import dither
H, W, _ = img_lab.shape
allowed = np.tile(np.array(colors, dtype=np.int64), (H, W, 1))
return dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
def pack_2bpp(val_image: np.ndarray) -> list[bytes]:
"""160-wide 2-bits-per-pixel -> list of 192 x 40-byte lines."""
H, W = val_image.shape
lines = []
for y in range(H):
row = val_image[y]
out = bytearray()
for x in range(0, W, 4):
out.append((row[x] << 6) | (row[x + 1] << 4) | (row[x + 2] << 2) | row[x + 3])
lines.append(bytes(out))
return lines
def pack_4bpp(val_image: np.ndarray) -> list[bytes]:
"""80-wide 4-bits-per-pixel -> list of 192 x 40-byte lines."""
H, W = val_image.shape
lines = []
for y in range(H):
row = val_image[y]
out = bytearray()
for x in range(0, W, 2):
out.append((row[x] << 4) | row[x + 1])
lines.append(bytes(out))
return lines
def pack_1bpp(val_image: np.ndarray) -> list[bytes]:
"""320-wide 1-bit-per-pixel -> list of 192 x 40-byte lines."""
H, W = val_image.shape
lines = []
for y in range(H):
row = val_image[y]
out = bytearray()
for x in range(0, W, 8):
b = 0
for i in range(8):
b = (b << 1) | int(row[x + i])
out.append(b)
lines.append(bytes(out))
return lines

View file

@ -0,0 +1,51 @@
"""Atari GR.15 (ANTIC mode E): 160x192, 4 colours chosen globally from 256.
No per-cell colour limit, so this is a clean 4-colour dithered image.
"""
from __future__ import annotations
import numpy as np
from ... import palette as c64pal # for srgb_to_lab (shared)
from ...convert.base import (Conversion, mean_error, perceptual_error,
DIFFUSION_DITHERS)
from .. import palette as apal
from . import _common
WIDTH, HEIGHT = 160, 192
PIXEL_ASPECT = 2.0
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None):
plab = apal.palette_lab(palette_name)
prgb = apal.get_palette(palette_name).astype(np.uint8)
img_lab = c64pal.srgb_to_lab(img_rgb)
# Dither-aware palette for error-diffusion modes: pick 4 colours whose blends
# span the image gamut (so dithering reproduces saturated/intermediate shades)
# instead of k-means centroids the dither can't reach.
if dither_mode in DIFFUSION_DITHERS:
colors = _common.choose_palette_dither(img_lab, plab, k=4)
else:
colors = _common.choose_palette(img_lab, plab, k=4)
colors.sort(key=lambda c: plab[c, 0]) # value 0 = darkest (background)
idx = _common.quantize_global(img_lab, plab, colors, dither_mode)
value_of = {c: v for v, c in enumerate(colors)}
val_image = np.vectorize(value_of.get)(idx).astype(np.uint8)
lines = _common.pack_2bpp(val_image)
data = _common.split_screen(lines) + bytes(colors) # 4 colour regs at $6000
preview = np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1)
err = (perceptual_error if dither_mode in DIFFUSION_DITHERS else mean_error)
return Conversion(
mode="gr15", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=_common.DATA_ADDR,
viewer="gr15", preview_rgb=preview,
error=err(idx, img_lab, plab),
meta={"palette": palette_name, "dither": dither_mode, "colors": colors},
)

View file

@ -0,0 +1,84 @@
"""Atari GR.15 + DLI: 160x192, a fresh set of 4 colours every 2 scanlines.
A display-list interrupt rewrites the four colour registers for each 2-line band
(96 bands). Every *single* scanline is impossible -- four register writes don't
fit the inter-DLI window -- but every 2 lines leaves a comfortable budget, and
96x4 colours is still far beyond flat GR.15. The display list (with DLI bits on
the right lines) is generated here and shipped in the data block.
"""
from __future__ import annotations
import numpy as np
from ... import dither, palette as c64pal
from ...convert.base import (Conversion, mean_error, perceptual_error,
DIFFUSION_DITHERS)
from .. import palette as apal
from . import _common
WIDTH, HEIGHT = 160, 192
PIXEL_ASPECT = 2.0
BAND_H = 2
N_BANDS = HEIGHT // BAND_H # 96
COLOR_ADDR = 0x6000
DL_ADDR = 0x6400 # display list, after the colour table
def make_dlist() -> bytes:
"""ANTIC mode-E display list, 4K-split, DLI bit on the last line of each
2-line band (odd lines 1..189) so the handler sets up the next band."""
dl = bytearray([0x70, 0x70, 0x70]) # 24 blank lines
dl += bytes([0x4e, 0x00, 0x40]) # line 0: LMS $4000 (no DLI)
for ln in range(1, 102): # lines 1..101
dl.append(0x8e if ln % 2 == 1 else 0x0e)
dl += bytes([0x4e, 0x00, 0x50]) # line 102: LMS $5000 (no DLI)
for ln in range(103, 192): # lines 103..191
dl.append(0x8e if (ln % 2 == 1 and ln != 191) else 0x0e)
dl += bytes([0x41, DL_ADDR & 0xFF, DL_ADDR >> 8]) # JVB -> start
return bytes(dl)
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None):
plab = apal.palette_lab(palette_name)
prgb = apal.get_palette(palette_name).astype(np.uint8)
img_lab = c64pal.srgb_to_lab(img_rgb)
band_sets = np.zeros((N_BANDS, 4), dtype=np.int64)
aware = dither_mode in DIFFUSION_DITHERS
iters = 10 if intensive else 5
# restrict the per-band dither-aware search to the image's own gamut (fast).
cand = _common.relevant_candidates(img_lab, plab) if aware else None
for b in range(N_BANDS):
block = img_lab[b * BAND_H:(b + 1) * BAND_H].reshape(-1, 3)
cols = _common.choose_palette(block, plab, k=4, iters=iters)
if aware: # span each band's gamut so dithering blends to the true shade
cols = _common.choose_palette_dither(block, plab, k=4, init=cols,
iters=4 if intensive else 3,
candidates=cand)
cols.sort(key=lambda c: plab[c, 0])
band_sets[b] = cols
allowed = np.repeat((band_sets[np.arange(HEIGHT) // BAND_H])[:, None, :],
WIDTH, axis=1)
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
val = np.zeros((HEIGHT, WIDTH), dtype=np.uint8)
for y in range(HEIGHT):
lut = {int(c): v for v, c in enumerate(band_sets[y // BAND_H])}
val[y] = [lut.get(int(p), 0) for p in idx[y]]
bitmap = _common.split_screen(_common.pack_2bpp(val)) # 8192 -> $4000..$5FFF
coltab = band_sets.astype(np.uint8).tobytes() # 384 -> $6000
region = coltab + bytes((DL_ADDR - COLOR_ADDR) - len(coltab)) + make_dlist()
data = bitmap + region
preview = np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1)
return Conversion(
mode="gr15dli", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=_common.DATA_ADDR,
viewer="gr15dli", preview_rgb=preview,
error=(perceptual_error if aware else mean_error)(idx, img_lab, plab),
meta={"palette": palette_name, "dither": dither_mode},
)

View file

@ -0,0 +1,40 @@
"""Atari GR.8 (ANTIC mode F): 320x192 hi-res, two tones of one hue.
Highest spatial resolution; carries tone by dithering between background and a
foreground luminance. ``base_color`` picks the hue (None = greyscale).
"""
from __future__ import annotations
import numpy as np
from ...convert.base import Conversion, perceptual_error
from .. import palette as apal
from . import _common
WIDTH, HEIGHT = 320, 192
PIXEL_ASPECT = 1.0
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None):
hue = 0 if base_color is None else (int(base_color) & 0x0F)
bg_reg = (hue << 4) | 0x00 # darkest of the hue
fg_reg = (hue << 4) | 0x0E # brightest of the hue
plab = apal.palette_lab(palette_name)
prgb = apal.get_palette(palette_name).astype(np.uint8)
img_mono, plab_mono = _common.luminance_lab(img_rgb, plab)
idx = _common.quantize_global(img_mono, plab_mono, [bg_reg, fg_reg], dither_mode)
val = (idx == fg_reg).astype(np.uint8)
data = _common.split_screen(_common.pack_1bpp(val)) + bytes([bg_reg, fg_reg])
preview = prgb[idx] # already 320 wide
return Conversion(
mode="gr8", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=_common.DATA_ADDR,
viewer="gr8", preview_rgb=preview,
error=perceptual_error(idx, img_mono, plab_mono),
meta={"palette": palette_name, "dither": dither_mode, "hue": hue},
)

View file

@ -0,0 +1,48 @@
"""Atari GR.9 (GTIA): 80x192, 16 luminance shades of one hue.
Excellent greyscale (hue 0) or tinted monochrome (any of 16 hues) -- 16 real
shades, not just dithered. ``base_color`` selects the hue (0..15); None = grey.
"""
from __future__ import annotations
import numpy as np
from ...convert.base import Conversion, perceptual_error
from .. import palette as apal
from . import _common
WIDTH, HEIGHT = 80, 192
PIXEL_ASPECT = 4.0
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None):
hue = 0 if base_color is None else (int(base_color) & 0x0F)
plab = apal.palette_lab(palette_name)
prgb = apal.get_palette(palette_name).astype(np.uint8)
img_mono, plab_mono = _common.luminance_lab(img_rgb, plab)
ramp = apal.hue_ramp(hue) # 16 register values of this hue
idx = _common.quantize_global(img_mono, plab_mono, ramp, dither_mode)
val = (idx & 0x0F).astype(np.uint8) # GR.9 pixel = 4-bit luminance
# GTIA mode 9 takes the hue from COLBK and the luminance from each pixel. A
# COLBK of exactly $00, though, blanks the whole playfield to black -- the
# register must be non-zero to enable the display. For a tinted hue that is
# automatic ((hue<<4) != 0); for greyscale (hue 0) force a non-zero luminance
# nibble, which the mode ignores for output (luminance still comes from the
# pixels) but which switches the 16-shade display on.
colbk = (hue & 0x0F) << 4
if colbk == 0:
colbk = 0x0E
data = _common.split_screen(_common.pack_4bpp(val)) + bytes([colbk])
preview = np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1)
return Conversion(
mode="gr9", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=_common.DATA_ADDR,
viewer="gr9", preview_rgb=preview,
error=perceptual_error(idx, img_mono, plab_mono),
meta={"palette": palette_name, "dither": dither_mode, "hue": hue},
)

View file

@ -0,0 +1,16 @@
"""Atari monochrome -- GR.9's 16 luminance shades, exposed as the standard
``mono`` mode for cross-platform parity. Greyscale by default; ``--mono-base``
tints it to one of the GTIA hues (16 real shades of that hue)."""
from __future__ import annotations
from . import gr9
WIDTH, HEIGHT, PIXEL_ASPECT = gr9.WIDTH, gr9.HEIGHT, gr9.PIXEL_ASPECT
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None):
conv = gr9.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color)
conv.mode = "mono"
return conv

32
lenser/atari/exporter.py Normal file
View file

@ -0,0 +1,32 @@
"""Build a bootable Atari .atr from a conversion."""
from __future__ import annotations
from ..convert.base import Conversion
from . import atr, car
from .viewer.assemble import assemble_stub, build_cart_rom
def export_atr(conv: Conversion, output_path: str, source_path: str | None = None,
display: str = "forever", seconds: int = 0, video: str = "ntsc") -> str:
"""Write ``conv`` as a self-booting .atr at ``output_path``.
``display`` (forever/key/seconds) + ``seconds`` choose how long the viewer
holds the picture; on key/seconds it warm-starts the OS. ``video`` sets the
frame rate the seconds timer counts (50 PAL / 60 NTSC)."""
if not output_path.lower().endswith(".atr"):
output_path += ".atr"
stub = assemble_stub(conv.viewer, display=display, seconds=seconds, video=video)
blob = atr.build_blob(stub, conv.data)
return atr.write_boot_atr(output_path, blob)
def export_car(conv: Conversion, output_path: str, source_path: str | None = None,
display: str = "forever", seconds: int = 0, video: str = "ntsc") -> str:
"""Write ``conv`` as an autostarting 16K Atari .car cartridge (reuses the
disk viewer, so display-duration works the same)."""
if not output_path.lower().endswith(".car"):
output_path += ".car"
rom = build_cart_rom(conv.viewer, conv.data, display=display,
seconds=seconds, video=video)
return car.write_car(rom, output_path)

96
lenser/atari/palette.py Normal file
View file

@ -0,0 +1,96 @@
"""Atari 8-bit (GTIA) colour palette.
The GTIA produces 256 colour-register values: the high nibble is the hue (0 =
grey, 1..15 = colours around the NTSC wheel) and the low nibble is the luminance.
We generate an NTSC palette with a standard YIQ formula, but if the atari800
emulator's palette file is present we load that instead so the preview matches
exactly what the emulator displays.
"""
from __future__ import annotations
import math
import os
import numpy as np
from ..palette import srgb_to_lab # reuse the CIELAB conversion
# Candidate locations for atari800's bundled NTSC palette (768 raw RGB bytes).
_PAL_FILES = [
"/usr/share/atari800/Palettes/Real.act",
"/usr/share/atari800/default.pal",
"/usr/local/share/atari800/default.pal",
]
def _generate_ntsc() -> np.ndarray:
"""Generate a 256x3 (uint8) NTSC palette via a YIQ approximation."""
pal = np.zeros((256, 3), dtype=np.float64)
# Calibration roughly matching the common Atari NTSC look.
sat = 0.30
hue0 = -58.0 # phase of hue 1, degrees
for reg in range(256):
hue = (reg >> 4) & 0x0F
lum = reg & 0x0F
y = lum / 15.0
if hue == 0:
i = q = 0.0
else:
angle = math.radians(hue0 + (hue - 1) * (360.0 / 15.0))
i = sat * math.cos(angle)
q = sat * math.sin(angle)
r = y + 0.956 * i + 0.621 * q
g = y - 0.272 * i - 0.647 * q
b = y - 1.106 * i + 1.703 * q
pal[reg] = [r, g, b]
return np.clip(pal * 255.0 + 0.5, 0, 255).astype(np.uint8).astype(np.float64)
def _load_pal_file() -> np.ndarray | None:
for path in _PAL_FILES:
try:
with open(path, "rb") as f:
data = f.read()
if len(data) >= 768:
return np.frombuffer(data[:768], dtype=np.uint8).reshape(256, 3).astype(np.float64)
except OSError:
continue
return None
_CACHE: dict[str, np.ndarray] = {}
def get_palette(name: str = "ntsc") -> np.ndarray:
"""Return the 256x3 sRGB palette (float64, 0..255)."""
if "rgb" not in _CACHE:
_CACHE["rgb"] = _load_pal_file()
if _CACHE["rgb"] is None:
_CACHE["rgb"] = _generate_ntsc()
return _CACHE["rgb"]
def palette_lab(name: str = "ntsc") -> np.ndarray:
"""Return the 256 palette colours in CIELAB (256x3)."""
if "lab" not in _CACHE:
_CACHE["lab"] = srgb_to_lab(get_palette(name))
return _CACHE["lab"]
def hue_ramp(hue: int) -> list[int]:
"""The 16 register values of one hue (luminance 0..15) -- for GR.9 / mono."""
return [((hue & 0x0F) << 4) | lum for lum in range(16)]
def nearest_hue(rgb) -> int:
"""Atari hue (0..15) whose mid-luminance colour best matches ``rgb``."""
from ..palette import srgb_to_lab
lab = srgb_to_lab(np.asarray(rgb, dtype=np.float64))
pl = palette_lab()
best, best_d = 0, float("inf")
for h in range(16):
d = float(np.sum((pl[(h << 4) | 8] - lab) ** 2))
if d < best_d:
best, best_d = h, d
return best

View file

@ -0,0 +1 @@
from .assemble import AssemblerError, SOURCES, assemble_stub, have_xa # noqa: F401

View file

@ -0,0 +1,156 @@
"""Assemble the Atari 6502 boot viewers with `xa` (origin $2000, no load prefix)."""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
SOURCES = {
"gr15": "gr15.s",
"gr9": "gr9.s",
"gr8": "gr8.s",
"gr15dli": "gr15dli.s",
}
_cache: dict[tuple, bytes] = {}
# How long the viewer holds the picture (see atari/viewer/awyt.i).
WAIT_MODES = {"forever": 0, "key": 1, "seconds": 2}
# Slideshow advance behaviour and per-mode multi-image viewer parameters
# (source, ANTIC mode byte, GPRIOR, colour-register layout).
SS_WAITMODE = {"key": 1, "seconds": 2, "both": 3}
SLIDESHOW_PARAMS = {
"gr15": (0x0E, 0x00, 0),
"gr9": (0x0F, 0x40, 1),
"gr8": (0x0F, 0x00, 2),
}
SLIDESHOW_SOURCES = dict.fromkeys(SLIDESHOW_PARAMS, "slideshow_static.s")
SLIDESHOW_SOURCES["gr15dli"] = "slideshow_dli.s" # DLI mode, its own engine
class AssemblerError(RuntimeError):
pass
def have_xa() -> bool:
return shutil.which("xa") is not None
def assemble_stub(viewer_key: str, display: str = "forever", seconds: int = 0,
video: str = "ntsc") -> bytes:
waitmode = WAIT_MODES.get(display, 0)
rate = 50 if video == "pal" else 60
key = (viewer_key, waitmode, int(seconds), rate)
if key in _cache:
return _cache[key]
if not have_xa():
raise AssemblerError(
"The 'xa' (xa65) assembler was not found on PATH.\n"
"Install it with: sudo apt install xa65")
if not os.path.exists(os.path.join(VIEWER_DIR, SOURCES[viewer_key])):
raise AssemblerError(f"viewer source missing: {SOURCES[viewer_key]}")
# Wrapper sets the options then includes the real source; runs from VIEWER_DIR
# so the source's #include "awyt.i" resolves (xa looks relative to cwd).
wrapper = (
f"#define WAITMODE {waitmode}\n"
f"#define WAITSECS {max(0, int(seconds))}\n"
f"#define RATE {rate}\n"
f'#include "{SOURCES[viewer_key]}"\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 for {viewer_key}:\n{proc.stdout}{proc.stderr}")
with open(out, "rb") as f:
raw = f.read()
finally:
os.unlink(wrap)
_cache[key] = raw
return raw
def _xa(wrapper: str, what: str) -> bytes:
"""Assemble a generated wrapper with xa (run from VIEWER_DIR so #includes
resolve); return raw bytes."""
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 for {what}:\n{proc.stdout}{proc.stderr}")
with open(out, "rb") as f:
return f.read()
finally:
os.unlink(wrap)
def build_slideshow_stub(viewer_key: str, n_images: int, base_sec: int, spi: int,
advance: str = "both", seconds: int = 10,
loop: bool = True, video: str = "ntsc") -> bytes:
"""Assemble the multi-image slideshow viewer (origin $2000, no load prefix).
``base_sec`` is the disk sector of image 0 and ``spi`` the sectors per image
(both fixed by the ATR layout); the viewer SIO-reads image i from
base_sec + i*spi. ``advance``/``seconds`` set the per-slide dwell, ``loop``
whether it wraps.
"""
if viewer_key not in SLIDESHOW_SOURCES:
raise AssemblerError(f"no Atari slideshow viewer for mode {viewer_key}")
rate = 50 if video == "pal" else 60
common = (f"#define WAITMODE {SS_WAITMODE[advance]}\n"
f"#define WAITSECS {max(0, int(seconds))}\n"
f"#define RATE {rate}\n"
f"#define NIMAGES {n_images}\n"
f"#define LOOPFLAG {1 if loop else 0}\n"
f"#define BASESEC {base_sec}\n"
f"#define SPI {spi}\n")
if viewer_key in SLIDESHOW_PARAMS: # static gr15/gr9/gr8
dlmode, gprior, colormode = SLIDESHOW_PARAMS[viewer_key]
common += (f"#define DLMODE ${dlmode:02X}\n"
f"#define GPRIOR ${gprior:02X}\n"
f"#define COLORMODE {colormode}\n")
return _xa(common + f'#include "{SLIDESHOW_SOURCES[viewer_key]}"\n',
f"slideshow_{viewer_key}")
CART_SIZE = 0x4000 # 16K Atari cartridge ROM at $8000-$BFFF
def build_cart_rom(viewer_key: str, data: bytes, display: str = "forever",
seconds: int = 0, video: str = "ntsc") -> bytes:
"""Assemble the loader + the disk viewer stub + picture data into a 16K
cartridge ROM with the Atari run/init footer at $BFFA."""
stub = assemble_stub(viewer_key, display, seconds, video)
wrapper = (
f"#define STUB_PAGES {(len(stub) + 255) // 256}\n"
f"#define DATA_PAGES {(len(data) + 255) // 256}\n"
f"#define STUB_LEN {len(stub)}\n"
'#include "cart.s"\n')
loader = _xa(wrapper, "cart")
rom = bytearray(loader + stub + bytes(data))
if len(rom) > CART_SIZE:
raise AssemblerError(
f"viewer + image = {len(rom)} bytes, over the 16K cartridge limit")
rom += bytes(CART_SIZE - len(rom))
# Atari cartridge footer at $BFFA (ROM offset $3FFA).
rom[0x3FFA] = 0x00; rom[0x3FFB] = 0x80 # CARTCS run address = $8000
rom[0x3FFC] = 0x00 # cart present
rom[0x3FFD] = 0x04 # option byte: start the cartridge
rom[0x3FFE] = 0x00; rom[0x3FFF] = 0x80 # CARTAD init address = $8000
return bytes(rom)

View file

@ -0,0 +1,48 @@
; Shared "how long to show the picture" epilogue for the Atari viewers.
; Selected at assembly time by WAITMODE (set by atari/viewer/assemble.py):
; 0 forever -- loop, just defeating attract mode
; 1 until a key -- poll CH ($2FC), then warm-start the OS
; 2 WAITSECS secs -- count RTCLOK frames (RATE per second), then warm-start
; "Exit" warm-starts the OS ($E474); on the XL that brings back a usable system.
#if WAITMODE == 0
awloop:
lda #$00
sta $4d ; defeat attract mode
jmp awloop
#endif
#if WAITMODE == 1
lda #$ff
sta $2fc ; clear CH (last-key register; $FF = no key)
awloop:
lda #$00
sta $4d
lda $2fc
cmp #$ff
beq awloop
lda #$00
sta $09 ; clear BOOT? so warmstart enters BASIC, not re-boot
jmp $e474 ; warm-start
#endif
#if WAITMODE == 2
lda #$00
sta $12
sta $13
sta $14 ; reset RTCLOK frame counter (16-bit in $13,$14)
awloop:
lda #$00
sta $4d
lda $13
cmp #>(WAITSECS*RATE)
bcc awloop
bne awdone
lda $14
cmp #<(WAITSECS*RATE)
bcc awloop
awdone:
lda #$00
sta $09 ; clear BOOT? so warmstart enters BASIC, not re-boot
jmp $e474 ; warm-start
#endif

View file

@ -0,0 +1,67 @@
; lenser -- Atari 16K cartridge loader ($8000-$BFFF).
;
; Reuses the disk viewer unchanged. The disk viewer "stub" (origin $2000, which
; begins with the 6-byte boot header and contains cont + the display list) and
; the picture data (origin $4000) are stored back-to-back in the cartridge ROM
; after this loader. We copy the stub to $2000 and the data to $4000, then jump
; to cont ($2006) -- exactly the state a disk boot would have produced.
;
; #defines set by viewer/assemble.py --
; STUB_PAGES / DATA_PAGES 256-byte page counts to copy
; STUB_LEN exact stub length (data follows it in ROM)
;
; assembled by atari/viewer/assemble.py via xa
* = $8000
S = $80
D = $82
entry:
; ---- copy the viewer stub to $2000 ----
lda #<stubsrc
sta S
lda #>stubsrc
sta S+1
lda #$00
sta D
lda #$20
sta D+1
ldx #STUB_PAGES
ldy #$00
sc:
lda (S),y
sta (D),y
iny
bne sc
inc S+1
inc D+1
dex
bne sc
; ---- copy the picture data to $4000 ----
lda #<datasrc
sta S
lda #>datasrc
sta S+1
lda #$00
sta D
lda #$40
sta D+1
ldx #DATA_PAGES
ldy #$00
dc:
lda (S),y
sta (D),y
iny
bne dc
inc S+1
inc D+1
dex
bne dc
jmp $2006 ; enter the disk viewer (cont)
stubsrc:
; viewer stub + picture data appended here by the packager
datasrc = stubsrc + STUB_LEN

View file

@ -0,0 +1,50 @@
; lenser -- Atari GR.15 (ANTIC mode E) viewer, self-booting
;
; 160x192, 4 colours chosen globally (no per-cell limit). Boots from sector 1:
; the OS loads this blob to $2000 and JSRs $2006. Appended data (from $4000):
; $4000 bitmap lines 0-101 (4080 bytes)
; $5000 bitmap lines 102-191 (3600 bytes) [split at the 4K ANTIC boundary]
; $6000 4 colour register values (value 0..3)
;
; assembled by atari/viewer/assemble.py via xa
* = $2000
boot:
.byte 0 ; flags
.byte 0 ; sector count (patched by the ATR writer)
.word $2000 ; load address
.word binit ; init address (DOSINI)
cont: ; $2006 -- OS JSRs here after loading
lda #$22
sta $22f ; SDMCTL = normal playfield + DL DMA
lda #<dlist
sta $230 ; SDLSTL
lda #>dlist
sta $231 ; SDLSTH
lda #$00
sta $26f ; GPRIOR = 0 (no GTIA mode)
; copy the 4 colour registers from $6000
lda $6000
sta $2c8 ; COLBAK (pixel value 0)
lda $6001
sta $2c4 ; COLPF0 (value 1)
lda $6002
sta $2c5 ; COLPF1 (value 2)
lda $6003
sta $2c6 ; COLPF2 (value 3)
#include "awyt.i"
binit:
rts
dlist:
.byte $70,$70,$70 ; 24 blank scan lines
.byte $4e ; LMS + mode E
.word $4000
.dsb 101,$0e ; 101 more mode E lines (102 total from $4000)
.byte $4e ; LMS + mode E
.word $5000
.dsb 89,$0e ; 89 more mode E lines (90 total from $5000)
.byte $41 ; JVB
.word dlist

Some files were not shown because too many files have changed in this diff Show more