First public commit.
This commit is contained in:
parent
2a48f52979
commit
4bac9d83ed
288 changed files with 18417 additions and 1076 deletions
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal 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
483
README.md
|
|
@ -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.
|
||||
|
||||

|
||||
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.
|
||||
|
||||

|
||||
|
||||
## 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), Floyd–Steinberg,
|
||||
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), Floyd–Steinberg, 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.
|
||||
|
||||

|
||||
|
||||
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 Floyd–Steinberg.
|
||||
|
||||
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 Floyd–Steinberg. 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 0–7) plus one
|
||||
per-cell colour (0–7). 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 Floyd–Steinberg.
|
||||
|
||||
- **Hires** — 176×184, one global background (any of 16) + one per-cell
|
||||
foreground (0–7); 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
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
"""c64view -- convert modern images into Commodore 64 disk images with a viewer."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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())
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
490
c64view/gui.py
490
c64view/gui.py
|
|
@ -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())
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -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
BIN
docs/gui_atari.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
2
lenser.sh
Executable file
2
lenser.sh
Executable file
|
|
@ -0,0 +1,2 @@
|
|||
#!/usr/bin/env bash
|
||||
python3 -m lenser.gui
|
||||
3
lenser/__init__.py
Normal file
3
lenser/__init__.py
Normal 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
1
lenser/a2600/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Atari 2600 (VCS) image conversion and cartridge export."""
|
||||
19
lenser/a2600/convert/__init__.py
Normal file
19
lenser/a2600/convert/__init__.py
Normal 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
144
lenser/a2600/convert/pf.py
Normal 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
12
lenser/a2600/exporter.py
Normal 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
81
lenser/a2600/palette.py
Normal 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
|
||||
1
lenser/a2600/viewer/__init__.py
Normal file
1
lenser/a2600/viewer/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
138
lenser/a2600/viewer/a2600.s
Normal file
138
lenser/a2600/viewer/a2600.s
Normal 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
|
||||
92
lenser/a2600/viewer/assemble.py
Normal file
92
lenser/a2600/viewer/assemble.py
Normal 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
1
lenser/a5200/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Atari 5200 SuperSystem target for lenser."""
|
||||
23
lenser/a5200/convert/__init__.py
Normal file
23
lenser/a5200/convert/__init__.py
Normal 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
17
lenser/a5200/exporter.py
Normal 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
|
||||
1
lenser/a5200/viewer/__init__.py
Normal file
1
lenser/a5200/viewer/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Atari 5200 6502 viewer (assembled by xa)."""
|
||||
131
lenser/a5200/viewer/assemble.py
Normal file
131
lenser/a5200/viewer/assemble.py
Normal 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)
|
||||
53
lenser/a5200/viewer/awyt5200.i
Normal file
53
lenser/a5200/viewer/awyt5200.i
Normal 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
|
||||
51
lenser/a5200/viewer/viewer.s
Normal file
51
lenser/a5200/viewer/viewer.s
Normal 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
1
lenser/a7800/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Atari 7800 ProSystem target for lenser (MARIA display processor)."""
|
||||
30
lenser/a7800/convert/__init__.py
Normal file
30
lenser/a7800/convert/__init__.py
Normal 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)
|
||||
127
lenser/a7800/convert/c160.py
Normal file
127
lenser/a7800/convert/c160.py
Normal 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
|
||||
36
lenser/a7800/convert/mono.py
Normal file
36
lenser/a7800/convert/mono.py
Normal 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
19
lenser/a7800/exporter.py
Normal 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
|
||||
1
lenser/a7800/viewer/__init__.py
Normal file
1
lenser/a7800/viewer/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Atari 7800 6502/MARIA viewer (assembled by xa)."""
|
||||
139
lenser/a7800/viewer/assemble.py
Normal file
139
lenser/a7800/viewer/assemble.py
Normal 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)
|
||||
47
lenser/a7800/viewer/viewer.s
Normal file
47
lenser/a7800/viewer/viewer.s
Normal 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
0
lenser/amiga/__init__.py
Normal file
23
lenser/amiga/convert/__init__.py
Normal file
23
lenser/amiga/convert/__init__.py
Normal 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)
|
||||
130
lenser/amiga/convert/_common.py
Normal file
130
lenser/amiga/convert/_common.py
Normal 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
|
||||
21
lenser/amiga/convert/lowres.py
Normal file
21
lenser/amiga/convert/lowres.py
Normal 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},
|
||||
)
|
||||
22
lenser/amiga/convert/mono.py
Normal file
22
lenser/amiga/convert/mono.py
Normal 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
41
lenser/amiga/copper.py
Normal 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
13
lenser/amiga/exporter.py
Normal 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
30
lenser/amiga/palette.py
Normal 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
242
lenser/amiga/viewer.py
Normal 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
10
lenser/ansi/__init__.py
Normal 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
307
lenser/ansi/convert.py
Normal 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
BIN
lenser/ansi/cp437_8x16.bin
Normal file
Binary file not shown.
19
lenser/ansi/exporter.py
Normal file
19
lenser/ansi/exporter.py
Normal 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
1
lenser/apple/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Apple II (Apple II+/IIe) image conversion and bootable disk export."""
|
||||
20
lenser/apple/convert/__init__.py
Normal file
20
lenser/apple/convert/__init__.py
Normal 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)
|
||||
60
lenser/apple/convert/dhgr.py
Normal file
60
lenser/apple/convert/dhgr.py
Normal 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},
|
||||
)
|
||||
72
lenser/apple/convert/hgr_color.py
Normal file
72
lenser/apple/convert/hgr_color.py
Normal 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},
|
||||
)
|
||||
41
lenser/apple/convert/hgr_mono.py
Normal file
41
lenser/apple/convert/hgr_mono.py
Normal 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},
|
||||
)
|
||||
15
lenser/apple/convert/mono.py
Normal file
15
lenser/apple/convert/mono.py
Normal 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
53
lenser/apple/dsk.py
Normal 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
10
lenser/apple/exporter.py
Normal 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
107
lenser/apple/palette.py
Normal 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)
|
||||
1
lenser/apple/viewer/__init__.py
Normal file
1
lenser/apple/viewer/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .assemble import AssemblerError, SOURCES, assemble_stub, have_xa # noqa: F401
|
||||
97
lenser/apple/viewer/assemble.py
Normal file
97
lenser/apple/viewer/assemble.py
Normal 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
|
||||
44
lenser/apple/viewer/awyt.i
Normal file
44
lenser/apple/viewer/awyt.i
Normal 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
128
lenser/apple/viewer/dhgr.s
Normal 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
102
lenser/apple/viewer/hgr.s
Normal 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
|
||||
204
lenser/apple/viewer/slideshow.s
Normal file
204
lenser/apple/viewer/slideshow.s
Normal 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
1
lenser/atari/__init__.py
Normal 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
101
lenser/atari/atr.py
Normal 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
20
lenser/atari/car.py
Normal 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
|
||||
31
lenser/atari/convert/__init__.py
Normal file
31
lenser/atari/convert/__init__.py
Normal 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)
|
||||
176
lenser/atari/convert/_common.py
Normal file
176
lenser/atari/convert/_common.py
Normal 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
|
||||
51
lenser/atari/convert/gr15.py
Normal file
51
lenser/atari/convert/gr15.py
Normal 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},
|
||||
)
|
||||
84
lenser/atari/convert/gr15dli.py
Normal file
84
lenser/atari/convert/gr15dli.py
Normal 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},
|
||||
)
|
||||
40
lenser/atari/convert/gr8.py
Normal file
40
lenser/atari/convert/gr8.py
Normal 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},
|
||||
)
|
||||
48
lenser/atari/convert/gr9.py
Normal file
48
lenser/atari/convert/gr9.py
Normal 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},
|
||||
)
|
||||
16
lenser/atari/convert/mono.py
Normal file
16
lenser/atari/convert/mono.py
Normal 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
32
lenser/atari/exporter.py
Normal 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
96
lenser/atari/palette.py
Normal 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
|
||||
1
lenser/atari/viewer/__init__.py
Normal file
1
lenser/atari/viewer/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .assemble import AssemblerError, SOURCES, assemble_stub, have_xa # noqa: F401
|
||||
156
lenser/atari/viewer/assemble.py
Normal file
156
lenser/atari/viewer/assemble.py
Normal 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)
|
||||
48
lenser/atari/viewer/awyt.i
Normal file
48
lenser/atari/viewer/awyt.i
Normal 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
|
||||
67
lenser/atari/viewer/cart.s
Normal file
67
lenser/atari/viewer/cart.s
Normal 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
|
||||
50
lenser/atari/viewer/gr15.s
Normal file
50
lenser/atari/viewer/gr15.s
Normal 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
Loading…
Add table
Add a link
Reference in a new issue