From 4bac9d83ed8fed3dad8e11b2e4d5ab427f062c68 Mon Sep 17 00:00:00 2001 From: The Dust Council Date: Fri, 3 Jul 2026 19:35:35 -0700 Subject: [PATCH] First public commit. --- .gitignore | 25 + README.md | 483 ++++++- c64view/__init__.py | 3 - c64view/__pycache__/__init__.cpython-313.pyc | Bin 261 -> 0 bytes c64view/__pycache__/basicgen.cpython-313.pyc | Bin 6675 -> 0 bytes c64view/__pycache__/cli.cpython-313.pyc | Bin 5140 -> 0 bytes c64view/__pycache__/diskimage.cpython-313.pyc | Bin 7419 -> 0 bytes c64view/__pycache__/dither.cpython-313.pyc | Bin 7238 -> 0 bytes c64view/__pycache__/exporter.cpython-313.pyc | Bin 3171 -> 0 bytes c64view/__pycache__/gallery.cpython-313.pyc | Bin 1503 -> 0 bytes c64view/__pycache__/gui.cpython-313.pyc | Bin 33544 -> 0 bytes c64view/__pycache__/imageprep.cpython-313.pyc | Bin 4667 -> 0 bytes c64view/__pycache__/imginfo.cpython-313.pyc | Bin 6951 -> 0 bytes c64view/__pycache__/palette.cpython-313.pyc | Bin 4123 -> 0 bytes c64view/cli.py | 80 -- .../__pycache__/__init__.cpython-313.pyc | Bin 3507 -> 0 bytes .../convert/__pycache__/base.cpython-313.pyc | Bin 7197 -> 0 bytes .../convert/__pycache__/fli.cpython-313.pyc | Bin 8141 -> 0 bytes .../convert/__pycache__/hires.cpython-313.pyc | Bin 3572 -> 0 bytes .../convert/__pycache__/ifli.cpython-313.pyc | Bin 9635 -> 0 bytes .../convert/__pycache__/mono.cpython-313.pyc | Bin 4127 -> 0 bytes .../__pycache__/multicolor.cpython-313.pyc | Bin 4487 -> 0 bytes c64view/convert/base.py | 124 -- c64view/dither.py | 136 -- c64view/gallery.py | 29 - c64view/gui.py | 490 ------- .../__pycache__/__init__.cpython-313.pyc | Bin 366 -> 0 bytes .../__pycache__/assemble.cpython-313.pyc | Bin 4399 -> 0 bytes c64view/viewer/assemble.py | 82 -- docs/gui_atari.png | Bin 0 -> 151613 bytes lenser.sh | 2 + lenser/__init__.py | 3 + lenser/a2600/__init__.py | 1 + lenser/a2600/convert/__init__.py | 19 + lenser/a2600/convert/pf.py | 144 +++ lenser/a2600/exporter.py | 12 + lenser/a2600/palette.py | 81 ++ lenser/a2600/viewer/__init__.py | 1 + lenser/a2600/viewer/a2600.s | 138 ++ lenser/a2600/viewer/assemble.py | 92 ++ lenser/a5200/__init__.py | 1 + lenser/a5200/convert/__init__.py | 23 + lenser/a5200/exporter.py | 17 + lenser/a5200/viewer/__init__.py | 1 + lenser/a5200/viewer/assemble.py | 131 ++ lenser/a5200/viewer/awyt5200.i | 53 + lenser/a5200/viewer/viewer.s | 51 + lenser/a7800/__init__.py | 1 + lenser/a7800/convert/__init__.py | 30 + lenser/a7800/convert/c160.py | 127 ++ lenser/a7800/convert/mono.py | 36 + lenser/a7800/exporter.py | 19 + lenser/a7800/viewer/__init__.py | 1 + lenser/a7800/viewer/assemble.py | 139 ++ lenser/a7800/viewer/viewer.s | 47 + lenser/amiga/__init__.py | 0 lenser/amiga/convert/__init__.py | 23 + lenser/amiga/convert/_common.py | 130 ++ lenser/amiga/convert/lowres.py | 21 + lenser/amiga/convert/mono.py | 22 + lenser/amiga/copper.py | 41 + lenser/amiga/exporter.py | 13 + lenser/amiga/palette.py | 30 + lenser/amiga/viewer.py | 242 ++++ lenser/ansi/__init__.py | 10 + lenser/ansi/convert.py | 307 +++++ lenser/ansi/cp437_8x16.bin | Bin 0 -> 4096 bytes lenser/ansi/exporter.py | 19 + lenser/apple/__init__.py | 1 + lenser/apple/convert/__init__.py | 20 + lenser/apple/convert/dhgr.py | 60 + lenser/apple/convert/hgr_color.py | 72 ++ lenser/apple/convert/hgr_mono.py | 41 + lenser/apple/convert/mono.py | 15 + lenser/apple/dsk.py | 53 + lenser/apple/exporter.py | 10 + lenser/apple/palette.py | 107 ++ lenser/apple/viewer/__init__.py | 1 + lenser/apple/viewer/assemble.py | 97 ++ lenser/apple/viewer/awyt.i | 44 + lenser/apple/viewer/dhgr.s | 128 ++ lenser/apple/viewer/hgr.s | 102 ++ lenser/apple/viewer/slideshow.s | 204 +++ lenser/atari/__init__.py | 1 + lenser/atari/atr.py | 101 ++ lenser/atari/car.py | 20 + lenser/atari/convert/__init__.py | 31 + lenser/atari/convert/_common.py | 176 +++ lenser/atari/convert/gr15.py | 51 + lenser/atari/convert/gr15dli.py | 84 ++ lenser/atari/convert/gr8.py | 40 + lenser/atari/convert/gr9.py | 48 + lenser/atari/convert/mono.py | 16 + lenser/atari/exporter.py | 32 + lenser/atari/palette.py | 96 ++ lenser/atari/viewer/__init__.py | 1 + lenser/atari/viewer/assemble.py | 156 +++ lenser/atari/viewer/awyt.i | 48 + lenser/atari/viewer/cart.s | 67 + lenser/atari/viewer/gr15.s | 50 + lenser/atari/viewer/gr15dli.s | 100 ++ lenser/atari/viewer/gr8.s | 42 + lenser/atari/viewer/gr9.s | 39 + lenser/atari/viewer/slideshow_dli.s | 248 ++++ lenser/atari/viewer/slideshow_static.s | 272 ++++ {c64view => lenser}/basicgen.py | 2 +- lenser/bbc/__init__.py | 1 + lenser/bbc/convert/__init__.py | 17 + lenser/bbc/convert/_common.py | 72 ++ lenser/bbc/convert/mode0.py | 9 + lenser/bbc/convert/mode1.py | 8 + lenser/bbc/convert/mode2.py | 8 + lenser/bbc/convert/mode5.py | 8 + lenser/bbc/convert/mono.py | 15 + lenser/bbc/exporter.py | 24 + lenser/bbc/palette.py | 70 + lenser/bbc/ssd.py | 72 ++ lenser/bbc/viewer/__init__.py | 0 lenser/bbc/viewer/assemble.py | 102 ++ lenser/bbc/viewer/bbc.s | 97 ++ lenser/bbc/viewer/slideshow.s | 174 +++ lenser/c128/__init__.py | 1 + lenser/c128/convert/__init__.py | 21 + lenser/c128/convert/color.py | 44 + lenser/c128/convert/hicolor.py | 142 +++ lenser/c128/convert/mono.py | 29 + lenser/c128/exporter.py | 23 + lenser/c128/palette.py | 38 + lenser/c128/viewer/__init__.py | 1 + lenser/c128/viewer/assemble.py | 122 ++ lenser/c128/viewer/color.s | 173 +++ lenser/c128/viewer/hicolor.s | 92 ++ lenser/c128/viewer/slideshow.s | 211 ++++ lenser/c16/__init__.py | 0 lenser/c16/convert/__init__.py | 19 + lenser/c16/convert/hires.py | 93 ++ lenser/c16/convert/mono.py | 35 + lenser/c16/exporter.py | 16 + lenser/c16/palette.py | 62 + lenser/c16/viewer/__init__.py | 0 lenser/c16/viewer/assemble.py | 62 + lenser/c16/viewer/viewer.s | 26 + lenser/cli.py | 138 ++ lenser/coco/__init__.py | 1 + lenser/coco/cartridge.py | 31 + lenser/coco/convert/__init__.py | 16 + lenser/coco/convert/mono.py | 15 + lenser/coco/convert/pmode3.py | 44 + lenser/coco/convert/pmode4.py | 41 + lenser/coco/exporter.py | 11 + lenser/coco/mc6809.py | 99 ++ lenser/coco/palette.py | 69 + lenser/coco/viewer.py | 71 ++ lenser/coco3/__init__.py | 0 lenser/coco3/cartridge.py | 29 + lenser/coco3/convert/__init__.py | 19 + lenser/coco3/convert/_common.py | 85 ++ lenser/coco3/convert/gr16.py | 24 + lenser/coco3/convert/gr4.py | 24 + lenser/coco3/convert/mono.py | 45 + lenser/coco3/exporter.py | 13 + lenser/coco3/palette.py | 34 + lenser/coco3/viewer.py | 70 + lenser/coleco/__init__.py | 1 + lenser/coleco/cartridge.py | 49 + lenser/coleco/convert/__init__.py | 25 + lenser/coleco/exporter.py | 10 + lenser/coleco/viewer.py | 114 ++ lenser/coleco/z80.py | 112 ++ {c64view => lenser}/convert/__init__.py | 5 + lenser/convert/base.py | 361 ++++++ {c64view => lenser}/convert/fli.py | 44 +- {c64view => lenser}/convert/hires.py | 12 +- {c64view => lenser}/convert/ifli.py | 62 +- {c64view => lenser}/convert/mono.py | 11 +- {c64view => lenser}/convert/multicolor.py | 17 +- lenser/cpc/__init__.py | 0 lenser/cpc/convert/__init__.py | 19 + lenser/cpc/convert/_common.py | 97 ++ lenser/cpc/convert/mode0.py | 22 + lenser/cpc/convert/mode1.py | 22 + lenser/cpc/convert/mono.py | 47 + lenser/cpc/exporter.py | 18 + lenser/cpc/palette.py | 60 + lenser/cpc/snapshot.py | 77 ++ lenser/crt.py | 68 + {c64view => lenser}/diskimage.py | 43 +- lenser/dither.py | 385 ++++++ {c64view => lenser}/exporter.py | 33 +- lenser/gallery.py | 140 ++ lenser/gui.py | 1122 +++++++++++++++++ lenser/iigs/__init__.py | 0 lenser/iigs/convert/__init__.py | 19 + lenser/iigs/convert/_common.py | 130 ++ lenser/iigs/convert/mono.py | 25 + lenser/iigs/convert/shr.py | 19 + lenser/iigs/exporter.py | 13 + lenser/iigs/palette.py | 31 + lenser/iigs/viewer/__init__.py | 0 lenser/iigs/viewer/assemble.py | 82 ++ lenser/iigs/viewer/iigs.s | 116 ++ lenser/iigs/viewer/slideshow_boot.s | 115 ++ lenser/iigs/viewer/slideshow_stage2.s | 147 +++ {c64view => lenser}/imageprep.py | 15 + {c64view => lenser}/imginfo.py | 0 lenser/intv/__init__.py | 2 + lenser/intv/cartridge.py | 172 +++ lenser/intv/convert/__init__.py | 19 + lenser/intv/convert/mono.py | 87 ++ lenser/intv/convert/stic.py | 235 ++++ lenser/intv/cp1610.py | 125 ++ lenser/intv/exporter.py | 20 + lenser/intv/palette.py | 45 + lenser/mame.py | 188 +++ lenser/nes/__init__.py | 0 lenser/nes/cartridge.py | 65 + lenser/nes/convert/__init__.py | 19 + lenser/nes/convert/_common.py | 206 +++ lenser/nes/convert/bg.py | 21 + lenser/nes/convert/mono.py | 22 + lenser/nes/exporter.py | 13 + lenser/nes/palette.py | 58 + lenser/nes/viewer.s | 77 ++ {c64view => lenser}/palette.py | 0 lenser/pet/__init__.py | 0 lenser/pet/convert.py | 61 + lenser/pet/exporter.py | 16 + lenser/pet/palette.py | 30 + lenser/pet/viewer/__init__.py | 0 lenser/pet/viewer/assemble.py | 68 + lenser/pet/viewer/viewer.s | 34 + lenser/platforms.py | 563 +++++++++ lenser/plus4/__init__.py | 0 lenser/plus4/convert/__init__.py | 11 + lenser/plus4/exporter.py | 4 + lenser/slideshow.py | 588 +++++++++ lenser/slideshow_cli.py | 119 ++ lenser/sms/__init__.py | 0 lenser/sms/convert/__init__.py | 19 + lenser/sms/convert/_common.py | 150 +++ lenser/sms/convert/bg.py | 19 + lenser/sms/convert/mono.py | 21 + lenser/sms/exporter.py | 13 + lenser/sms/palette.py | 33 + lenser/sms/viewer.py | 109 ++ lenser/sms/z80.py | 77 ++ lenser/spectrum/__init__.py | 1 + lenser/spectrum/convert/__init__.py | 19 + lenser/spectrum/convert/hires.py | 90 ++ lenser/spectrum/convert/mono.py | 47 + lenser/spectrum/exporter.py | 32 + lenser/spectrum/palette.py | 47 + lenser/spectrum/snapshot.py | 50 + lenser/ti99/__init__.py | 1 + lenser/ti99/cartridge.py | 92 ++ lenser/ti99/convert/__init__.py | 16 + lenser/ti99/convert/gm2.py | 72 ++ lenser/ti99/convert/mono.py | 44 + lenser/ti99/exporter.py | 15 + lenser/ti99/palette.py | 39 + lenser/ti99/tms9900.py | 87 ++ lenser/ti99/viewer.py | 116 ++ lenser/vic20/__init__.py | 1 + lenser/vic20/convert/__init__.py | 19 + lenser/vic20/convert/hires.py | 171 +++ lenser/vic20/convert/mono.py | 83 ++ lenser/vic20/convert/multicolor.py | 241 ++++ lenser/vic20/exporter.py | 18 + lenser/vic20/palette.py | 49 + lenser/vic20/viewer/__init__.py | 1 + lenser/vic20/viewer/assemble.py | 82 ++ lenser/vic20/viewer/viewer.s | 95 ++ {c64view => lenser}/viewer/__init__.py | 0 lenser/viewer/assemble.py | 206 +++ lenser/viewer/cart.s | 177 +++ {c64view => lenser}/viewer/fli.s | 2 +- {c64view => lenser}/viewer/fli_ntsc.s | 2 +- {c64view => lenser}/viewer/hires.s | 11 +- {c64view => lenser}/viewer/interlace.s | 8 +- lenser/viewer/loaddata.i | 20 + {c64view => lenser}/viewer/multicolor.s | 11 +- lenser/viewer/slideshow.s | 238 ++++ lenser/viewer/slideshow_fli.s | 247 ++++ lenser/viewer/slideshow_interlace.s | 204 +++ lenser/viewer/wait.i | 77 ++ pyproject.toml | 19 +- tests/test_roundtrip.py | 443 ++++++- tests/test_slideshow.py | 381 ++++++ 288 files changed, 18417 insertions(+), 1076 deletions(-) create mode 100644 .gitignore delete mode 100644 c64view/__init__.py delete mode 100644 c64view/__pycache__/__init__.cpython-313.pyc delete mode 100644 c64view/__pycache__/basicgen.cpython-313.pyc delete mode 100644 c64view/__pycache__/cli.cpython-313.pyc delete mode 100644 c64view/__pycache__/diskimage.cpython-313.pyc delete mode 100644 c64view/__pycache__/dither.cpython-313.pyc delete mode 100644 c64view/__pycache__/exporter.cpython-313.pyc delete mode 100644 c64view/__pycache__/gallery.cpython-313.pyc delete mode 100644 c64view/__pycache__/gui.cpython-313.pyc delete mode 100644 c64view/__pycache__/imageprep.cpython-313.pyc delete mode 100644 c64view/__pycache__/imginfo.cpython-313.pyc delete mode 100644 c64view/__pycache__/palette.cpython-313.pyc delete mode 100644 c64view/cli.py delete mode 100644 c64view/convert/__pycache__/__init__.cpython-313.pyc delete mode 100644 c64view/convert/__pycache__/base.cpython-313.pyc delete mode 100644 c64view/convert/__pycache__/fli.cpython-313.pyc delete mode 100644 c64view/convert/__pycache__/hires.cpython-313.pyc delete mode 100644 c64view/convert/__pycache__/ifli.cpython-313.pyc delete mode 100644 c64view/convert/__pycache__/mono.cpython-313.pyc delete mode 100644 c64view/convert/__pycache__/multicolor.cpython-313.pyc delete mode 100644 c64view/convert/base.py delete mode 100644 c64view/dither.py delete mode 100644 c64view/gallery.py delete mode 100644 c64view/gui.py delete mode 100644 c64view/viewer/__pycache__/__init__.cpython-313.pyc delete mode 100644 c64view/viewer/__pycache__/assemble.cpython-313.pyc delete mode 100644 c64view/viewer/assemble.py create mode 100644 docs/gui_atari.png create mode 100755 lenser.sh create mode 100644 lenser/__init__.py create mode 100644 lenser/a2600/__init__.py create mode 100644 lenser/a2600/convert/__init__.py create mode 100644 lenser/a2600/convert/pf.py create mode 100644 lenser/a2600/exporter.py create mode 100644 lenser/a2600/palette.py create mode 100644 lenser/a2600/viewer/__init__.py create mode 100644 lenser/a2600/viewer/a2600.s create mode 100644 lenser/a2600/viewer/assemble.py create mode 100644 lenser/a5200/__init__.py create mode 100644 lenser/a5200/convert/__init__.py create mode 100644 lenser/a5200/exporter.py create mode 100644 lenser/a5200/viewer/__init__.py create mode 100644 lenser/a5200/viewer/assemble.py create mode 100644 lenser/a5200/viewer/awyt5200.i create mode 100644 lenser/a5200/viewer/viewer.s create mode 100644 lenser/a7800/__init__.py create mode 100644 lenser/a7800/convert/__init__.py create mode 100644 lenser/a7800/convert/c160.py create mode 100644 lenser/a7800/convert/mono.py create mode 100644 lenser/a7800/exporter.py create mode 100644 lenser/a7800/viewer/__init__.py create mode 100644 lenser/a7800/viewer/assemble.py create mode 100644 lenser/a7800/viewer/viewer.s create mode 100644 lenser/amiga/__init__.py create mode 100644 lenser/amiga/convert/__init__.py create mode 100644 lenser/amiga/convert/_common.py create mode 100644 lenser/amiga/convert/lowres.py create mode 100644 lenser/amiga/convert/mono.py create mode 100644 lenser/amiga/copper.py create mode 100644 lenser/amiga/exporter.py create mode 100644 lenser/amiga/palette.py create mode 100644 lenser/amiga/viewer.py create mode 100644 lenser/ansi/__init__.py create mode 100644 lenser/ansi/convert.py create mode 100644 lenser/ansi/cp437_8x16.bin create mode 100644 lenser/ansi/exporter.py create mode 100644 lenser/apple/__init__.py create mode 100644 lenser/apple/convert/__init__.py create mode 100644 lenser/apple/convert/dhgr.py create mode 100644 lenser/apple/convert/hgr_color.py create mode 100644 lenser/apple/convert/hgr_mono.py create mode 100644 lenser/apple/convert/mono.py create mode 100644 lenser/apple/dsk.py create mode 100644 lenser/apple/exporter.py create mode 100644 lenser/apple/palette.py create mode 100644 lenser/apple/viewer/__init__.py create mode 100644 lenser/apple/viewer/assemble.py create mode 100644 lenser/apple/viewer/awyt.i create mode 100644 lenser/apple/viewer/dhgr.s create mode 100644 lenser/apple/viewer/hgr.s create mode 100644 lenser/apple/viewer/slideshow.s create mode 100644 lenser/atari/__init__.py create mode 100644 lenser/atari/atr.py create mode 100644 lenser/atari/car.py create mode 100644 lenser/atari/convert/__init__.py create mode 100644 lenser/atari/convert/_common.py create mode 100644 lenser/atari/convert/gr15.py create mode 100644 lenser/atari/convert/gr15dli.py create mode 100644 lenser/atari/convert/gr8.py create mode 100644 lenser/atari/convert/gr9.py create mode 100644 lenser/atari/convert/mono.py create mode 100644 lenser/atari/exporter.py create mode 100644 lenser/atari/palette.py create mode 100644 lenser/atari/viewer/__init__.py create mode 100644 lenser/atari/viewer/assemble.py create mode 100644 lenser/atari/viewer/awyt.i create mode 100644 lenser/atari/viewer/cart.s create mode 100644 lenser/atari/viewer/gr15.s create mode 100644 lenser/atari/viewer/gr15dli.s create mode 100644 lenser/atari/viewer/gr8.s create mode 100644 lenser/atari/viewer/gr9.s create mode 100644 lenser/atari/viewer/slideshow_dli.s create mode 100644 lenser/atari/viewer/slideshow_static.s rename {c64view => lenser}/basicgen.py (98%) create mode 100644 lenser/bbc/__init__.py create mode 100644 lenser/bbc/convert/__init__.py create mode 100644 lenser/bbc/convert/_common.py create mode 100644 lenser/bbc/convert/mode0.py create mode 100644 lenser/bbc/convert/mode1.py create mode 100644 lenser/bbc/convert/mode2.py create mode 100644 lenser/bbc/convert/mode5.py create mode 100644 lenser/bbc/convert/mono.py create mode 100644 lenser/bbc/exporter.py create mode 100644 lenser/bbc/palette.py create mode 100644 lenser/bbc/ssd.py create mode 100644 lenser/bbc/viewer/__init__.py create mode 100644 lenser/bbc/viewer/assemble.py create mode 100644 lenser/bbc/viewer/bbc.s create mode 100644 lenser/bbc/viewer/slideshow.s create mode 100644 lenser/c128/__init__.py create mode 100644 lenser/c128/convert/__init__.py create mode 100644 lenser/c128/convert/color.py create mode 100644 lenser/c128/convert/hicolor.py create mode 100644 lenser/c128/convert/mono.py create mode 100644 lenser/c128/exporter.py create mode 100644 lenser/c128/palette.py create mode 100644 lenser/c128/viewer/__init__.py create mode 100644 lenser/c128/viewer/assemble.py create mode 100644 lenser/c128/viewer/color.s create mode 100644 lenser/c128/viewer/hicolor.s create mode 100644 lenser/c128/viewer/slideshow.s create mode 100644 lenser/c16/__init__.py create mode 100644 lenser/c16/convert/__init__.py create mode 100644 lenser/c16/convert/hires.py create mode 100644 lenser/c16/convert/mono.py create mode 100644 lenser/c16/exporter.py create mode 100644 lenser/c16/palette.py create mode 100644 lenser/c16/viewer/__init__.py create mode 100644 lenser/c16/viewer/assemble.py create mode 100644 lenser/c16/viewer/viewer.s create mode 100644 lenser/cli.py create mode 100644 lenser/coco/__init__.py create mode 100644 lenser/coco/cartridge.py create mode 100644 lenser/coco/convert/__init__.py create mode 100644 lenser/coco/convert/mono.py create mode 100644 lenser/coco/convert/pmode3.py create mode 100644 lenser/coco/convert/pmode4.py create mode 100644 lenser/coco/exporter.py create mode 100644 lenser/coco/mc6809.py create mode 100644 lenser/coco/palette.py create mode 100644 lenser/coco/viewer.py create mode 100644 lenser/coco3/__init__.py create mode 100644 lenser/coco3/cartridge.py create mode 100644 lenser/coco3/convert/__init__.py create mode 100644 lenser/coco3/convert/_common.py create mode 100644 lenser/coco3/convert/gr16.py create mode 100644 lenser/coco3/convert/gr4.py create mode 100644 lenser/coco3/convert/mono.py create mode 100644 lenser/coco3/exporter.py create mode 100644 lenser/coco3/palette.py create mode 100644 lenser/coco3/viewer.py create mode 100644 lenser/coleco/__init__.py create mode 100644 lenser/coleco/cartridge.py create mode 100644 lenser/coleco/convert/__init__.py create mode 100644 lenser/coleco/exporter.py create mode 100644 lenser/coleco/viewer.py create mode 100644 lenser/coleco/z80.py rename {c64view => lenser}/convert/__init__.py (89%) create mode 100644 lenser/convert/base.py rename {c64view => lenser}/convert/fli.py (73%) rename {c64view => lenser}/convert/hires.py (81%) rename {c64view => lenser}/convert/ifli.py (70%) rename {c64view => lenser}/convert/mono.py (84%) rename {c64view => lenser}/convert/multicolor.py (80%) create mode 100644 lenser/cpc/__init__.py create mode 100644 lenser/cpc/convert/__init__.py create mode 100644 lenser/cpc/convert/_common.py create mode 100644 lenser/cpc/convert/mode0.py create mode 100644 lenser/cpc/convert/mode1.py create mode 100644 lenser/cpc/convert/mono.py create mode 100644 lenser/cpc/exporter.py create mode 100644 lenser/cpc/palette.py create mode 100644 lenser/cpc/snapshot.py create mode 100644 lenser/crt.py rename {c64view => lenser}/diskimage.py (68%) create mode 100644 lenser/dither.py rename {c64view => lenser}/exporter.py (58%) create mode 100644 lenser/gallery.py create mode 100644 lenser/gui.py create mode 100644 lenser/iigs/__init__.py create mode 100644 lenser/iigs/convert/__init__.py create mode 100644 lenser/iigs/convert/_common.py create mode 100644 lenser/iigs/convert/mono.py create mode 100644 lenser/iigs/convert/shr.py create mode 100644 lenser/iigs/exporter.py create mode 100644 lenser/iigs/palette.py create mode 100644 lenser/iigs/viewer/__init__.py create mode 100644 lenser/iigs/viewer/assemble.py create mode 100644 lenser/iigs/viewer/iigs.s create mode 100644 lenser/iigs/viewer/slideshow_boot.s create mode 100644 lenser/iigs/viewer/slideshow_stage2.s rename {c64view => lenser}/imageprep.py (78%) rename {c64view => lenser}/imginfo.py (100%) create mode 100644 lenser/intv/__init__.py create mode 100644 lenser/intv/cartridge.py create mode 100644 lenser/intv/convert/__init__.py create mode 100644 lenser/intv/convert/mono.py create mode 100644 lenser/intv/convert/stic.py create mode 100644 lenser/intv/cp1610.py create mode 100644 lenser/intv/exporter.py create mode 100644 lenser/intv/palette.py create mode 100644 lenser/mame.py create mode 100644 lenser/nes/__init__.py create mode 100644 lenser/nes/cartridge.py create mode 100644 lenser/nes/convert/__init__.py create mode 100644 lenser/nes/convert/_common.py create mode 100644 lenser/nes/convert/bg.py create mode 100644 lenser/nes/convert/mono.py create mode 100644 lenser/nes/exporter.py create mode 100644 lenser/nes/palette.py create mode 100644 lenser/nes/viewer.s rename {c64view => lenser}/palette.py (100%) create mode 100644 lenser/pet/__init__.py create mode 100644 lenser/pet/convert.py create mode 100644 lenser/pet/exporter.py create mode 100644 lenser/pet/palette.py create mode 100644 lenser/pet/viewer/__init__.py create mode 100644 lenser/pet/viewer/assemble.py create mode 100644 lenser/pet/viewer/viewer.s create mode 100644 lenser/platforms.py create mode 100644 lenser/plus4/__init__.py create mode 100644 lenser/plus4/convert/__init__.py create mode 100644 lenser/plus4/exporter.py create mode 100644 lenser/slideshow.py create mode 100644 lenser/slideshow_cli.py create mode 100644 lenser/sms/__init__.py create mode 100644 lenser/sms/convert/__init__.py create mode 100644 lenser/sms/convert/_common.py create mode 100644 lenser/sms/convert/bg.py create mode 100644 lenser/sms/convert/mono.py create mode 100644 lenser/sms/exporter.py create mode 100644 lenser/sms/palette.py create mode 100644 lenser/sms/viewer.py create mode 100644 lenser/sms/z80.py create mode 100644 lenser/spectrum/__init__.py create mode 100644 lenser/spectrum/convert/__init__.py create mode 100644 lenser/spectrum/convert/hires.py create mode 100644 lenser/spectrum/convert/mono.py create mode 100644 lenser/spectrum/exporter.py create mode 100644 lenser/spectrum/palette.py create mode 100644 lenser/spectrum/snapshot.py create mode 100644 lenser/ti99/__init__.py create mode 100644 lenser/ti99/cartridge.py create mode 100644 lenser/ti99/convert/__init__.py create mode 100644 lenser/ti99/convert/gm2.py create mode 100644 lenser/ti99/convert/mono.py create mode 100644 lenser/ti99/exporter.py create mode 100644 lenser/ti99/palette.py create mode 100644 lenser/ti99/tms9900.py create mode 100644 lenser/ti99/viewer.py create mode 100644 lenser/vic20/__init__.py create mode 100644 lenser/vic20/convert/__init__.py create mode 100644 lenser/vic20/convert/hires.py create mode 100644 lenser/vic20/convert/mono.py create mode 100644 lenser/vic20/convert/multicolor.py create mode 100644 lenser/vic20/exporter.py create mode 100644 lenser/vic20/palette.py create mode 100644 lenser/vic20/viewer/__init__.py create mode 100644 lenser/vic20/viewer/assemble.py create mode 100644 lenser/vic20/viewer/viewer.s rename {c64view => lenser}/viewer/__init__.py (100%) create mode 100644 lenser/viewer/assemble.py create mode 100644 lenser/viewer/cart.s rename {c64view => lenser}/viewer/fli.s (98%) rename {c64view => lenser}/viewer/fli_ntsc.s (98%) rename {c64view => lenser}/viewer/hires.s (85%) rename {c64view => lenser}/viewer/interlace.s (93%) create mode 100644 lenser/viewer/loaddata.i rename {c64view => lenser}/viewer/multicolor.s (88%) create mode 100644 lenser/viewer/slideshow.s create mode 100644 lenser/viewer/slideshow_fli.s create mode 100644 lenser/viewer/slideshow_interlace.s create mode 100644 lenser/viewer/wait.i create mode 100644 tests/test_slideshow.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa08412 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 3170657..d05b9ab 100644 --- a/README.md +++ b/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. -![c64view GUI](docs/gui.png) +The Commodore 64 is the reference target, but **25+ other retro machines** are +supported too — Atari 8-bit, Apple II / IIgs, BBC Micro, ZX Spectrum, Amstrad CPC, +TI-99/4A, NES, Sega Master System, Commodore 128 / 16 / Plus/4 / VIC-20 / PET, +Amiga, and more — each writing a self-contained, emulator-verified cartridge or +disk. There's also an **ANSI / CP437** text-art export for BBSes. Pick the target +with `--platform` (CLI) or the **Platform** selector (GUI). See the sections below. + +![8 Bit Lenser GUI](docs/gui.png) ## Highlights - **Five display modes** (auto-selectable): + - **Hires** — 320×200, 2 colours per 8×8 cell. Best for sharp line art. + - **Multicolor** — 160×200, 1 shared background + 3 colours per 4×8 cell (the classic "Koala" format). Best general-purpose photo mode. + - **FLI** — re-points the video matrix every scanline for per-line (4×1) colour. + - **Interlace** — two multicolor frames blended at 50 Hz for ~136 apparent colours. + - **Mono** — highest-resolution path: 320×200 matched by *luminance* to a colour ramp, so detail is carried by intense dithering. Greyscale by default, or pick any palette colour as the base for a tinted monochrome (black → blue → light blue → white, etc.). -- **Perceptual conversion.** All colour decisions are made in CIELAB. Each screen - cell's colour set is chosen by an exhaustive, vectorised search that minimises - reproduction error; an *Intensive* mode additionally searches the global - background colour. -- **Selectable dithering** — ordered Bayer (default, artifact-free), 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. + +![8 Bit Lenser targeting the Atari 8-bit](docs/gui_atari.png) + +Both GR.15 modes pick their colours with the same **dither-aware** search as the +C64 (for error-diffusion dithers): the 4 register colours are chosen so their +*blends* span the image gamut, so floyd dithering reproduces a rich, smooth image +instead of the muddy result of plain k-means centroids (perceptual ΔE ~17 → ~2.7 +on a portrait). Defaults to 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,}`, `--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 diff --git a/c64view/__init__.py b/c64view/__init__.py deleted file mode 100644 index 55275d4..0000000 --- a/c64view/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""c64view -- convert modern images into Commodore 64 disk images with a viewer.""" - -__version__ = "0.1.0" diff --git a/c64view/__pycache__/__init__.cpython-313.pyc b/c64view/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 4ccefeefa7833a395cd0d1046f5f042432b76f96..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 261 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 diff --git a/c64view/__pycache__/cli.cpython-313.pyc b/c64view/__pycache__/cli.cpython-313.pyc deleted file mode 100644 index 1ce970d42a843c638af787b3b9d714a66c3598cb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5140 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 diff --git a/c64view/__pycache__/diskimage.cpython-313.pyc b/c64view/__pycache__/diskimage.cpython-313.pyc deleted file mode 100644 index ba3e2e0454fa51d77b548994604da1ee1f2b3a85..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7419 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 m&@NxeE2-G z%$ySjyHR@K}ft}=IwiL-uvF) z9(8uQ2q%*PAJtNgpclnL)lql+Cj)e1Wk7z-_N&iX|N2#3<)pDokOCd=1RbHB3(tkck{sE7mO4jR4a;6N%8LjA0 zCzJywtB2tuU~Z!_YMKbe-SH4JmriWZT;`NLWqMIy7E#$O;bF|IC3w}Q#qM&pYy`M$ z1(^|x*-Q4RM(WWT$ueiLZ*hDM2H?L`c81Isk#F7n?s~gulqJyOnAaIJVr3-~HMz1v z-X#5Tl&s~+2J}8p;&$uvq?&df1W>(jeM?*W1Fkzqlcr#j^#6}J6@rUA5w#mW*@Drb znk99a&PlZ{;|3J7dchS`@#qEp5qOW7=o{&hAh%^D`Yh+^k`t~4+got(q z+%ex&h7PPcj&X)8+Mvp7gAG$v4&G5F>zh?y0@hX!i76|P-#z4;Lf2S%vyk0P ztYJu@YbbPqm6>MY=aiw-R)#K+fZapNK8~xbc1Y#@t3F@|?v$Y?N@XE$sRx3bkb0%C z!IDCiZWbzys9B|j%J7iR_w=qsjRvgYK)j>6j>q(L^F%NBg8fEv0ge`W(t(OY2S&bC z`qbWg?vZ!)Cy#vZkpr)=_c|P{(j)KL-Lv<%9=`PI{pN$@FpE2;ajW#33(~vMfm>`{ zdDzjlJACKT7nknJeQ$8z7(4>c=v}{4*=_1_sJ9_44G;)AOe(896*WUL?%TbVseQ2Q<`hLuf zeO}(hUry|O@NMnuGapBv_}|oS`S&Jn`Ja`4$c?qEBg?}>fBj)2cN|OI|2yX|pK<;M DI^wBj diff --git a/c64view/__pycache__/gui.cpython-313.pyc b/c64view/__pycache__/gui.cpython-313.pyc deleted file mode 100644 index 6f55ef75d1ee6ec91ad2d9653db9d882a0ba6595..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33544 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 diff --git a/c64view/__pycache__/imginfo.cpython-313.pyc b/c64view/__pycache__/imginfo.cpython-313.pyc deleted file mode 100644 index 0741b58b229fb0fee427cec6454ca9da10068846..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6951 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 diff --git a/c64view/cli.py b/c64view/cli.py deleted file mode 100644 index 60e6ce0..0000000 --- a/c64view/cli.py +++ /dev/null @@ -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()) diff --git a/c64view/convert/__pycache__/__init__.cpython-313.pyc b/c64view/convert/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 6712ebab88352378332eff77b53a5c1622a6e05f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3507 zcmcImU2GHC6~5yc|IOGF=a1h&hCoTYEKUf75|#h~yd(hvwXtd`p~J);%&^XkxiiiW zS|L}wnB7WH)QWV41br&|)KprjecWC3p--!N(^b5;sfx7QR!Ds?MXjpsOV7O?Cny`# zr(Rok?wot>Ip>}^=R4*&$eTRo>Ob4JIu zVVO$SunU@#xvuO}3Rs^p^jQV#W>&|BnTtKAhDA9dX{Kp8nga*y@#h{&^pK!2t>tt$ zR3QF>me(Cee@^cb6^JmU**fuFH?VFKVcIe+B2E|cj*+qQ7LM{b2z*$g6a9*yfxnhF zaHzf(40r+YrlaG$meDhQpf?6R33ugQ{(*hlly`{hard(A)CUs{PL!aAt{Yvb;dj%U z;*#9aG(y^22qitHKgqYEkpl>sfeCNpSUQm2osJLq>hESS<)7+6C>2b&lm;Jtru-XG zkG3F`1xqgU(sQQ&!a;;2uruY~gC+uv^C^z1G{p%P)s&i`-dH!fP|DMYmy|aluiJ)K zCPjM12a#{{bfL7E0ISxIl$dB+PXn99#HMdk(-TQy&}W9ghhFBxX2!X71I|y=hf=>P}G0xi%Sqx8rBufR9KKH*DS1Dy*h1W^{ZEbnpH9^f*r+hV(!LJ zw^nqlZrFFQq1%cM5k4>44sdHYtI)ITB-WiGHf_bZt}A(KR=1t~I>sO$ViiJ^ilfo1 zvQPzSJ#K7^24uzDn+kA_Tc*xT%3*EZ&Oq!(70b-eD_WtDr@9@BiKiBu7RaE=V?>|^ zjz>N9z^8!>fiF&37_v>pxv54jh->G*kdIC&|4`ZDpm zTB2bh0YY3?Eer~CG@Y|L3Av(GQ=8U_lr_MeSY=x*aZTHp(Mf=6Qmul6X}AF_fVT-Z zO}Gs40dFy{V~86>{2-Wmzz)Nk^6DT5d#6pOH>=O|X7XAwtM_IOAEGF=_N+%juc{iR z;izh?Fpp^%vq^!4y#iAS)npXevfR}FXmELZ-{;%=mbUkoMxObl^30vv)uz_6@W|89(D|fesX6v^Zn^hN1^&(P(%6c;d0FoKT<)v}S|3jSc=E~N zUkv?xXmRgyf4tHkuLdG7ynK_dbQS~#LUnuE@7=z8dm*s~@hGyhhPcQ8Ou>O? zQuDpzcaJZ0FK%CwMt(1hyoCFq41nV5ps>O~^6zz!M2Xd{wZ+sBr0j+}CZF;EH2wsj zF^bT{DvT!FBwdq%P}l$5PxmK1Nq&&Yq)(8}1N5x|Xkrxv*W?1waSfnL&^obhykgS^ zFxfb13h9%t1xbE_h8`Oet&J7B1IP^arTs=VZgPRD4w@+2B4c# zFy{2U4P;%JHL|*?XO*dWrhq0qh-M6mW*78~qx9I8vR3Tf!y-NG%o-U0VVg;bzNzGl z8R#;=lA>t1>ytz(KQEerp#MlB0kM_<_plA6hYzhb6Yqfau6H!B^3zZv-WW~F7c ze7f4yQWn3Kw?Rs_Mis$znqgqH`McIv~LOR1N@g;t1TVXP~?SQ2zg3@nk;a^KMC#COL3pB&-ne9U*s%YQ0xF#i`H5;v^? diff --git a/c64view/convert/__pycache__/base.cpython-313.pyc b/c64view/convert/__pycache__/base.cpython-313.pyc deleted file mode 100644 index f21e43f6337f34ec97846b4c67280bc303c4d4e2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7197 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

5cwn(Q$>iYMne=GAe8Vb*Od(H zFworgr72uQv~9K3y*;>J2#m3thbw4Y&hl}H4_jW2ouDJQ>=9=sb;A;KmXlE3v*b>& z1{zLcNkn;r#FvzRYq1SM$WXJ0qqwh`AY2~VkPl#tLmG?1!;lc{T)DD*r5s+)6;Evh zLaW{1pDdo(Xb-F0wXQzyTolUNDqVX%?}}EsqIa*YcSYB` z_LscB4)>N^8y%6=gWtbea@GQ&Tj5*dtH$b<$v~P6v+&*>d)JI1@K7Hr(TKs4IKOgwXK;@Yu_vb&ocK>p9}{) zsYz!q)b+5m8|RSm)7*1HR9brHSXzf8#C4g0TmbwY#3VfBqa?4+GrgcS5>E0!4k37k zyCfqgUq6GS3}u}fdd%>dNV^~@L+Kb>y$nK`kw-)av>O+x8r&KnxlSp5##K;H0etsJC;HA0HMGA4*B+$L~qh^%+6b41}~mo zHJhUk+_uWHTpZ6J=dtC@*-x%h&zn%nOKM`ad3y#!E&K_7hKUO9w_AvG-R^m>r#Jyg zDjxrJM_+T!H@{8zgWK46gB z^Eck{eeR#SQ6-x`GKR@d;~xAQ0KQFHVddV0O{~i$m#G`mCPmwE2akl`C3pxcY2Ils zIco5n#ifZ8H}W9%Hzf0PN*^IQmM;#H=!`m=O zh9v6a%waEJFZLo;2dH|UD)M+43Ag1~T(lfFZde{})u2{6MV@f4?l@!XL_Gs#Z_O6* zbrwH^yl&3Aj?l_mH{U9U%kFCX&Ua20kJdtw_X2BDCAj;pV zNW!Uo5&)=OrzGfycy$Opw2mRXQM)(Nq;(5s)7K?BgTBtC*Df+8FEeEUX;z4{tl*z~ z?C4qf{M0FVAe%AGtU9EEU?4Q|Y$=&?WU7H2GgD!8kYe})mU=*n3(tr=KFWI%CeNnv z1l3dVr8Cdw+1tP)i8i9Bio$>RL#xqpsuJm6`|iD6KlsyoW@Y!0_3%qo|I7dA`ZV=R zf5kt!E=}4r0S%gkG!m!D`v5)=rez)nIG^0OC6Xg)-BjT1LFVi-MaF(pfz0mbaiYlM zM8uVcnpFM&v|&TaL1Ag>_*n#B%@lx|HI<%%gfc{-dA|sw#-4?vohTn8}qCBhf&dM~S$WV9#yuuqoE_QBauRiiv#>ACSBn zl&C?F*WOoY-vhJl54|6`-FvI|qsYfScY4-(D!caIQ!4Tc_h&0x#;f6@pNCIX!l$a? zGgbe@x-`KVgjkfvnPEBve^C?{+0Tj~%#YImpFi4wEVr$$`S4RqG*Q^Qgv^%^nG6^= z;E6Kgz2!{c=Bq_HbBL572?ApX`kgE`GOi)@|cdCAT{zrspDCUs)0qI&2Q zMez$qn<)K0B8boaP8j%Cq5ro+1ip;J|KE;wap<9d>X8%Ohtz#6yTvV!BQ9}Td>oR* zeP4F$6t{kHTojIpr^U}4zl?q<1jNw89WHTeZO5*MPHLYUw%g~&?Dohjc6;pXW14>) Ra)_^szxO%CNs*uU{{Z?3nk)bS diff --git a/c64view/convert/__pycache__/fli.cpython-313.pyc b/c64view/convert/__pycache__/fli.cpython-313.pyc deleted file mode 100644 index 4fa900179250a39d6b0ee00022e8166c519becb0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8141 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 diff --git a/c64view/convert/__pycache__/ifli.cpython-313.pyc b/c64view/convert/__pycache__/ifli.cpython-313.pyc deleted file mode 100644 index a9d190a56e8c7c84e090478efcdceb8ab4c4d40b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9635 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 T7(25#A-2|KhJiNmd zb}36PkkaX)igK`;CJ2!js17~2MS$v%gAYFRkV6hVn4tj^4+3f+1`6GrxB+@7(9S$+ zN!BpX0e0TczBg~)H#2YMQ8*mrfap;A(pMpl`yK}l1sWax(He9<<0Ou75-+*V^XFW| zC1T%w-c1BakUZx-=e)#w&PV((@0ENo4v@gPAPK_QUvhCA36TydzKE)FXBGahG_~wct+mDiIL)hH03& zMPe3IF)c0-WyQ)Vx+)gTyejthi}s=_E@}&ls%7^R)iU*xt(k^cRP4opU~pPB@+z@J z#fC-jTRix}vFDeMJ^y@4oGaO4L9uhNlL^rZ%7QA+t%!&8Qb98mBc~n`ZBtZ4tDxw* zNR&b`XwHLVUA1jh%$d4bBGwVnGR3@VE1E89mY7or(bT-C8aAA#ub@B+l+hGj%xj=5 z(Ts%wQG6TvcxL%@Y? zg`uh0B2i1$j$ImdThi1kDj5iFqpzlT8d40yv;kMsur6$an{X)?^)?Wuz8xEF^No1{ z?@T>RQp%;h2GiT$vR~VNXb1!Zw*Vi|!_(WG2*NxBWp3l2?G9v7V8n+#45aNaG7qu{ zGA}ZWBPJ{5Cj$0k$P&on$hweqB15qx2`q=FID~uL9LGQU20FjsKjSjo90<-p1fGN$ z1;l0XMvSt}oyT|P4A;~#$tAfn+)=mT%5Yhf$B6Cj1xc=HyuoACT9@JQMvgqO?X--` znFYzN>75s&T~LM}_@JN49ozE901KfLhy$G$r&C|9CYFR zKrhz?X5K%c)9@MN z{ik%LFqc<`=1FU~!J3G`?8 zWpyRxp@ObiHVuqT55FT%55IAd`lX52Cnqk9Nz|?6^He~vQf?YVkCyQXBBT!_Pyr9M z@ZRDtMe;M83bwQ%c=#UptapKd0{4CKs&7~2=XVajT6^l%t!U?`6CeNjBjJ85zJ7H5 z(w*quRiPeA-sr7`_SQRM*T+8^|8%x;shUjP?KlLh*GE3S3M&NvZzrxz+)X@Jo~Q~3 z>Y-@)Ji$|P2(v>N1k9_w;E$hiS!bS`ZhCNYirdF!_@<|M+SWFG`G3|neO2P|NW+`O zKx8zHk^LC$c|OEu55?g&R00F|<03uIy1~O{UY4)i&4V%=N=E zBiV-0F=t<^-N#9RmM^#AGU%+yBr?hDi_S=D_okPf*;dPRUUqzEHZeOPg$4wv<3!Nt zmfC&jmDV1o2f9QuNhykYJH9mNO7od+z3D}nZg_uV7gDZ)MutTvO}VyNszgJuS(ZNG zRrDJ2JW*Blz{5~bmNknC`0x@;K`DV?yc3Ll@+>kmcPfZW@{sVaXnA{)dKXo!nrKH+ zTUK>hv5IQWrXk2g>axr-3>C2UpaJAEJhOy)8}$bDsDzjVba^C)1uz-(TTC~8R~g^Vq|8Vz01@M zlWMSJu^BvDZfn{?KlnNFF6l60?Shxvs4}KBC}abwJjRTnMO{r zRRca6heA-^xwNjESJXU1iu&I#DNvqXRjF68?3E%QRf2l$WrivB$*PfK@Xf8*szrlm zSiZBM(xM#~CuT<{@7fAQ?}eWFF7#AIt%cICEt0qwdFH#wGaDnDOV!BGwXyQZ>fX99 zydM9}uTZ-W);_4%6@4>ajlQrdJiz7qzl-eOnAz<5dg=DkH%osj)gqbl*jA$V^Yka_ zje|GGHs7fwChsMrYC^g@^VYqYcdIk+))McP&p$}^eg57j?`@pi6mE^wlEc?7l+Ui7 z-HLRspZ&OSL)|$0WuY25TpoK6iXw3JaOaKVUv=Ns>s?Q8EZpAp?MtBTb#u+ET)z2o zEpocP>)HD5=YDYWkr#gmcsso1k%tkk@4#2WU;KaeZ+746`Rhy76X$9N&)?~tywmeW zdE~Cp)hbI;0`+6bls5^u{Jd_i+e^lH{QH?Z1d=?_^tP=q2aRsM{kgiJ?!I7zRuSV9{I_=kB>1e F{tHVY#Z&+Q diff --git a/c64view/convert/__pycache__/multicolor.cpython-313.pyc b/c64view/convert/__pycache__/multicolor.cpython-313.pyc deleted file mode 100644 index 9c9fa62c7dc6fe676a6345d18fb3e57b89493867..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4487 zcmbUkTWlN0@s2zmUm_(^l&ps(pJh1~{m{#jVmU8Mre2m5J328TKoplFd8W>yj`ZG9 zuc810^g~I3Mh=ifPJ@�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 (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()) diff --git a/c64view/dither.py b/c64view/dither.py deleted file mode 100644 index 8b80b92..0000000 --- a/c64view/dither.py +++ /dev/null @@ -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) diff --git a/c64view/gallery.py b/c64view/gallery.py deleted file mode 100644 index 997b525..0000000 --- a/c64view/gallery.py +++ /dev/null @@ -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) diff --git a/c64view/gui.py b/c64view/gui.py deleted file mode 100644 index e2ae173..0000000 --- a/c64view/gui.py +++ /dev/null @@ -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()) diff --git a/c64view/viewer/__pycache__/__init__.cpython-313.pyc b/c64view/viewer/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 93c49dde581ce94240bad61489940f3e30bf790a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 366 zcmX|+%SyyB6o!*t2c;rDK^ATbGLb8Al>lp;r@CNN^cZlP20tCKkl-ev$YkgA(ie^)C=|+-TJO{8RJ#Ln4FAT zU@CpEG;9q=!&P`kgCdeMT9hs2qNhn7bzIP7exlX4d2K}*%gU@}OdTyJ=S9W4TwH*s aB=irO1BD+GWBiFO|11X^zeoXg)%YK7{$;uV diff --git a/c64view/viewer/__pycache__/assemble.cpython-313.pyc b/c64view/viewer/__pycache__/assemble.cpython-313.pyc deleted file mode 100644 index 7031250a2b5ad98d76b5c3fd837f6af6462762e6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4399 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 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 diff --git a/docs/gui_atari.png b/docs/gui_atari.png new file mode 100644 index 0000000000000000000000000000000000000000..e5870c45d4b26095be80ad1451c8ec17836269ed GIT binary patch literal 151613 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

    t7hKf{*sdu%p;Bj+(>@4i|$~baV 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%0DbUSR)y?Q2NW0*6oqQ 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 literal 0 HcmV?d00001 diff --git a/lenser.sh b/lenser.sh new file mode 100755 index 0000000..4c9c4c5 --- /dev/null +++ b/lenser.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +python3 -m lenser.gui diff --git a/lenser/__init__.py b/lenser/__init__.py new file mode 100644 index 0000000..bf637ba --- /dev/null +++ b/lenser/__init__.py @@ -0,0 +1,3 @@ +"""8 Bit Lenser -- convert modern images into Commodore 64 disk images with a viewer.""" + +__version__ = "0.1.0" diff --git a/lenser/a2600/__init__.py b/lenser/a2600/__init__.py new file mode 100644 index 0000000..34dbef6 --- /dev/null +++ b/lenser/a2600/__init__.py @@ -0,0 +1 @@ +"""Atari 2600 (VCS) image conversion and cartridge export.""" diff --git a/lenser/a2600/convert/__init__.py b/lenser/a2600/convert/__init__.py new file mode 100644 index 0000000..a07dd85 --- /dev/null +++ b/lenser/a2600/convert/__init__.py @@ -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")) diff --git a/lenser/a2600/convert/pf.py b/lenser/a2600/convert/pf.py new file mode 100644 index 0000000..f92a854 --- /dev/null +++ b/lenser/a2600/convert/pf.py @@ -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}, + ) diff --git a/lenser/a2600/exporter.py b/lenser/a2600/exporter.py new file mode 100644 index 0000000..da4a80e --- /dev/null +++ b/lenser/a2600/exporter.py @@ -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 diff --git a/lenser/a2600/palette.py b/lenser/a2600/palette.py new file mode 100644 index 0000000..c982b37 --- /dev/null +++ b/lenser/a2600/palette.py @@ -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 diff --git a/lenser/a2600/viewer/__init__.py b/lenser/a2600/viewer/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lenser/a2600/viewer/__init__.py @@ -0,0 +1 @@ + diff --git a/lenser/a2600/viewer/a2600.s b/lenser/a2600/viewer/a2600.s new file mode 100644 index 0000000..b2814a0 --- /dev/null +++ b/lenser/a2600/viewer/a2600.s @@ -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 diff --git a/lenser/a2600/viewer/assemble.py b/lenser/a2600/viewer/assemble.py new file mode 100644 index 0000000..e1fdc23 --- /dev/null +++ b/lenser/a2600/viewer/assemble.py @@ -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)}") diff --git a/lenser/a5200/__init__.py b/lenser/a5200/__init__.py new file mode 100644 index 0000000..2506f82 --- /dev/null +++ b/lenser/a5200/__init__.py @@ -0,0 +1 @@ +"""Atari 5200 SuperSystem target for lenser.""" diff --git a/lenser/a5200/convert/__init__.py b/lenser/a5200/convert/__init__.py new file mode 100644 index 0000000..1c052c8 --- /dev/null +++ b/lenser/a5200/convert/__init__.py @@ -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) diff --git a/lenser/a5200/exporter.py b/lenser/a5200/exporter.py new file mode 100644 index 0000000..337d451 --- /dev/null +++ b/lenser/a5200/exporter.py @@ -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 diff --git a/lenser/a5200/viewer/__init__.py b/lenser/a5200/viewer/__init__.py new file mode 100644 index 0000000..4e78e16 --- /dev/null +++ b/lenser/a5200/viewer/__init__.py @@ -0,0 +1 @@ +"""Atari 5200 6502 viewer (assembled by xa).""" diff --git a/lenser/a5200/viewer/assemble.py b/lenser/a5200/viewer/assemble.py new file mode 100644 index 0000000..3549dfc --- /dev/null +++ b/lenser/a5200/viewer/assemble.py @@ -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) diff --git a/lenser/a5200/viewer/awyt5200.i b/lenser/a5200/viewer/awyt5200.i new file mode 100644 index 0000000..b4526e9 --- /dev/null +++ b/lenser/a5200/viewer/awyt5200.i @@ -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 diff --git a/lenser/a5200/viewer/viewer.s b/lenser/a5200/viewer/viewer.s new file mode 100644 index 0000000..d4d419c --- /dev/null +++ b/lenser/a5200/viewer/viewer.s @@ -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 $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" diff --git a/lenser/a7800/__init__.py b/lenser/a7800/__init__.py new file mode 100644 index 0000000..226cb03 --- /dev/null +++ b/lenser/a7800/__init__.py @@ -0,0 +1 @@ +"""Atari 7800 ProSystem target for lenser (MARIA display processor).""" diff --git a/lenser/a7800/convert/__init__.py b/lenser/a7800/convert/__init__.py new file mode 100644 index 0000000..e6b3f60 --- /dev/null +++ b/lenser/a7800/convert/__init__.py @@ -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) diff --git a/lenser/a7800/convert/c160.py b/lenser/a7800/convert/c160.py new file mode 100644 index 0000000..58c78e9 --- /dev/null +++ b/lenser/a7800/convert/c160.py @@ -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 diff --git a/lenser/a7800/convert/mono.py b/lenser/a7800/convert/mono.py new file mode 100644 index 0000000..e0046dd --- /dev/null +++ b/lenser/a7800/convert/mono.py @@ -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 diff --git a/lenser/a7800/exporter.py b/lenser/a7800/exporter.py new file mode 100644 index 0000000..caf03d8 --- /dev/null +++ b/lenser/a7800/exporter.py @@ -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 diff --git a/lenser/a7800/viewer/__init__.py b/lenser/a7800/viewer/__init__.py new file mode 100644 index 0000000..79eaa21 --- /dev/null +++ b/lenser/a7800/viewer/__init__.py @@ -0,0 +1 @@ +"""Atari 7800 6502/MARIA viewer (assembled by xa).""" diff --git a/lenser/a7800/viewer/assemble.py b/lenser/a7800/viewer/assemble.py new file mode 100644 index 0000000..d7f42b1 --- /dev/null +++ b/lenser/a7800/viewer/assemble.py @@ -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) diff --git a/lenser/a7800/viewer/viewer.s b/lenser/a7800/viewer/viewer.s new file mode 100644 index 0000000..2b71ac4 --- /dev/null +++ b/lenser/a7800/viewer/viewer.s @@ -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 # 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 diff --git a/lenser/amiga/convert/lowres.py b/lenser/amiga/convert/lowres.py new file mode 100644 index 0000000..e7d854f --- /dev/null +++ b/lenser/amiga/convert/lowres.py @@ -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}, + ) diff --git a/lenser/amiga/convert/mono.py b/lenser/amiga/convert/mono.py new file mode 100644 index 0000000..8013bdf --- /dev/null +++ b/lenser/amiga/convert/mono.py @@ -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}, + ) diff --git a/lenser/amiga/copper.py b/lenser/amiga/copper.py new file mode 100644 index 0000000..918e503 --- /dev/null +++ b/lenser/amiga/copper.py @@ -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) diff --git a/lenser/amiga/exporter.py b/lenser/amiga/exporter.py new file mode 100644 index 0000000..4eabbc1 --- /dev/null +++ b/lenser/amiga/exporter.py @@ -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) diff --git a/lenser/amiga/palette.py b/lenser/amiga/palette.py new file mode 100644 index 0000000..f6888e6 --- /dev/null +++ b/lenser/amiga/palette.py @@ -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) diff --git a/lenser/amiga/viewer.py b/lenser/amiga/viewer.py new file mode 100644 index 0000000..7a54afc --- /dev/null +++ b/lenser/amiga/viewer.py @@ -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) diff --git a/lenser/ansi/__init__.py b/lenser/ansi/__init__.py new file mode 100644 index 0000000..39e9f3f --- /dev/null +++ b/lenser/ansi/__init__.py @@ -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). +""" diff --git a/lenser/ansi/convert.py b/lenser/ansi/convert.py new file mode 100644 index 0000000..94d4ce1 --- /dev/null +++ b/lenser/ansi/convert.py @@ -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) diff --git a/lenser/ansi/cp437_8x16.bin b/lenser/ansi/cp437_8x16.bin new file mode 100644 index 0000000000000000000000000000000000000000..4b100263a751ed4feb66115e1c0355b68f32531f GIT binary patch literal 4096 zcmeHKy=o*!5N_^ZqG2s(11C-n`~ZUk(U?JzU%`o^L73)(##Xd!Ff148G5i3IoI4pd zXdDa*mxWsvMT!m~FdQ2!BY)LZ)$?QN4X*UF^L71pb#>LOF<+BIH5A^l3dk3>t4Tim(01EbBwqZbR^vabZ`m1zIz10*eQ+ z37l0~mW77-`FZ8L9wEPH+_!Ce8u4un96%!9L!bAo_Q4wQ>3I$Pe8qg)E}n+)73+3m zc*t6&@&=Jh@U&DDk9N#_*w3-M!5|h6hr_;=_|7OHJq5(_CsaU#KX+nWxea#MZd}Fq z=!xl^KQiv!^@r7m`r1jp1lRS`NdFLm`;jquitfZ86<;K=|2|d&>PPbN09UIOoIbGr zQynS!3y*z|;{=KQhcEr%Zoo0u1ZBT*e`G@96aMzlrQ z*71xazu+%Pyle7!q_FRoWMA@&(zN6A&oZ;XbpRnRP=-7gfXK5jG&CQ^eD0Cp?Xd4X znRkvti^NAf?1%YHDglj8uk?QDhWdbohAKe!cUG1sI+DwCv2IM<-Y6f@do?7Q3qZ~v zTIfA?d`?_4>nztR)lol{`?SDTZzw^rI;k-zv47%`OmV|_xleJu_%Ph(BDav}x@r7* zF*#2Zl$q3rpW(Wk@Gbi4L>?}rexg6JH>nrcn_$?RV4S0+?a6tQ{umn+^a#TmU4htP zX!{6fg0VYpP^o`V3YM4a-R?J%H|ENBT~8-^7a_+>CnWU~;~n7`?|?~uQV-xyF!V{9 znm*1uSrhw-tNyTWDn@D%F`jh29!?QsU{@HA7?4tL=wHpX``x*JnSXxyL%EV3a*rj9 z&B^&7BcuEo6VIRGll?+|5|Fw)hg03|#u1zf8y&Airb$?N8I6pZ}cxJpMiX zL0Vs;<81L&EKwx>9E+XA7o0v`lb3bv2FKU%EBY5Rc}#{0J*NQp;X<)UC3#tYRbKc> zU-877F>`*hum04(>MK^h+LQHpJjtH&RbKVy_7uzKh04#dtf%j-+P&-eYh_1?tQKE30mve zAXdo-_lqy-PE&JA^XsgkVZCk6*2v5?;J}H#> 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}, + ) diff --git a/lenser/apple/convert/hgr_color.py b/lenser/apple/convert/hgr_color.py new file mode 100644 index 0000000..628bb2d --- /dev/null +++ b/lenser/apple/convert/hgr_color.py @@ -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}, + ) diff --git a/lenser/apple/convert/hgr_mono.py b/lenser/apple/convert/hgr_mono.py new file mode 100644 index 0000000..7ba1eb7 --- /dev/null +++ b/lenser/apple/convert/hgr_mono.py @@ -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}, + ) diff --git a/lenser/apple/convert/mono.py b/lenser/apple/convert/mono.py new file mode 100644 index 0000000..c1f44ca --- /dev/null +++ b/lenser/apple/convert/mono.py @@ -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 diff --git a/lenser/apple/dsk.py b/lenser/apple/dsk.py new file mode 100644 index 0000000..b3526ae --- /dev/null +++ b/lenser/apple/dsk.py @@ -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 diff --git a/lenser/apple/exporter.py b/lenser/apple/exporter.py new file mode 100644 index 0000000..c175ddc --- /dev/null +++ b/lenser/apple/exporter.py @@ -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)) diff --git a/lenser/apple/palette.py b/lenser/apple/palette.py new file mode 100644 index 0000000..c31483a --- /dev/null +++ b/lenser/apple/palette.py @@ -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) diff --git a/lenser/apple/viewer/__init__.py b/lenser/apple/viewer/__init__.py new file mode 100644 index 0000000..cdd85d2 --- /dev/null +++ b/lenser/apple/viewer/__init__.py @@ -0,0 +1 @@ +from .assemble import AssemblerError, SOURCES, assemble_stub, have_xa # noqa: F401 diff --git a/lenser/apple/viewer/assemble.py b/lenser/apple/viewer/assemble.py new file mode 100644 index 0000000..ffb1847 --- /dev/null +++ b/lenser/apple/viewer/assemble.py @@ -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 diff --git a/lenser/apple/viewer/awyt.i b/lenser/apple/viewer/awyt.i new file mode 100644 index 0000000..76e7155 --- /dev/null +++ b/lenser/apple/viewer/awyt.i @@ -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 diff --git a/lenser/apple/viewer/dhgr.s b/lenser/apple/viewer/dhgr.s new file mode 100644 index 0000000..3a7d4f1 --- /dev/null +++ b/lenser/apple/viewer/dhgr.s @@ -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 diff --git a/lenser/apple/viewer/hgr.s b/lenser/apple/viewer/hgr.s new file mode 100644 index 0000000..9ed61ab --- /dev/null +++ b/lenser/apple/viewer/hgr.s @@ -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 diff --git a/lenser/apple/viewer/slideshow.s b/lenser/apple/viewer/slideshow.s new file mode 100644 index 0000000..5f2e917 --- /dev/null +++ b/lenser/apple/viewer/slideshow.s @@ -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 diff --git a/lenser/atari/__init__.py b/lenser/atari/__init__.py new file mode 100644 index 0000000..11c36ec --- /dev/null +++ b/lenser/atari/__init__.py @@ -0,0 +1 @@ +"""Atari 8-bit (Atari 400/800/XL/XE) image conversion and bootable disk export.""" diff --git a/lenser/atari/atr.py b/lenser/atari/atr.py new file mode 100644 index 0000000..e5a4a35 --- /dev/null +++ b/lenser/atari/atr.py @@ -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 diff --git a/lenser/atari/car.py b/lenser/atari/car.py new file mode 100644 index 0000000..ce2233c --- /dev/null +++ b/lenser/atari/car.py @@ -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 diff --git a/lenser/atari/convert/__init__.py b/lenser/atari/convert/__init__.py new file mode 100644 index 0000000..8a5ad67 --- /dev/null +++ b/lenser/atari/convert/__init__.py @@ -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) diff --git a/lenser/atari/convert/_common.py b/lenser/atari/convert/_common.py new file mode 100644 index 0000000..4ed3e55 --- /dev/null +++ b/lenser/atari/convert/_common.py @@ -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 diff --git a/lenser/atari/convert/gr15.py b/lenser/atari/convert/gr15.py new file mode 100644 index 0000000..19c00c1 --- /dev/null +++ b/lenser/atari/convert/gr15.py @@ -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}, + ) diff --git a/lenser/atari/convert/gr15dli.py b/lenser/atari/convert/gr15dli.py new file mode 100644 index 0000000..7f69cb1 --- /dev/null +++ b/lenser/atari/convert/gr15dli.py @@ -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}, + ) diff --git a/lenser/atari/convert/gr8.py b/lenser/atari/convert/gr8.py new file mode 100644 index 0000000..dc2d240 --- /dev/null +++ b/lenser/atari/convert/gr8.py @@ -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}, + ) diff --git a/lenser/atari/convert/gr9.py b/lenser/atari/convert/gr9.py new file mode 100644 index 0000000..bdba446 --- /dev/null +++ b/lenser/atari/convert/gr9.py @@ -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}, + ) diff --git a/lenser/atari/convert/mono.py b/lenser/atari/convert/mono.py new file mode 100644 index 0000000..f3001c5 --- /dev/null +++ b/lenser/atari/convert/mono.py @@ -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 diff --git a/lenser/atari/exporter.py b/lenser/atari/exporter.py new file mode 100644 index 0000000..d52ecbd --- /dev/null +++ b/lenser/atari/exporter.py @@ -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) diff --git a/lenser/atari/palette.py b/lenser/atari/palette.py new file mode 100644 index 0000000..d5ecb51 --- /dev/null +++ b/lenser/atari/palette.py @@ -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 diff --git a/lenser/atari/viewer/__init__.py b/lenser/atari/viewer/__init__.py new file mode 100644 index 0000000..cdd85d2 --- /dev/null +++ b/lenser/atari/viewer/__init__.py @@ -0,0 +1 @@ +from .assemble import AssemblerError, SOURCES, assemble_stub, have_xa # noqa: F401 diff --git a/lenser/atari/viewer/assemble.py b/lenser/atari/viewer/assemble.py new file mode 100644 index 0000000..0ff9f51 --- /dev/null +++ b/lenser/atari/viewer/assemble.py @@ -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) diff --git a/lenser/atari/viewer/awyt.i b/lenser/atari/viewer/awyt.i new file mode 100644 index 0000000..c9b4bb5 --- /dev/null +++ b/lenser/atari/viewer/awyt.i @@ -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 diff --git a/lenser/atari/viewer/cart.s b/lenser/atari/viewer/cart.s new file mode 100644 index 0000000..753a0e8 --- /dev/null +++ b/lenser/atari/viewer/cart.s @@ -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+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+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 diff --git a/lenser/atari/viewer/gr15.s b/lenser/atari/viewer/gr15.s new file mode 100644 index 0000000..45a7de9 --- /dev/null +++ b/lenser/atari/viewer/gr15.s @@ -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 $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 diff --git a/lenser/atari/viewer/gr15dli.s b/lenser/atari/viewer/gr15dli.s new file mode 100644 index 0000000..8eca63b --- /dev/null +++ b/lenser/atari/viewer/gr15dli.s @@ -0,0 +1,100 @@ +; lenser -- Atari GR.15 + DLI viewer, self-booting +; 160x192, 4 colours rewritten every 2 scanlines by a display-list interrupt. +; Appended data from $4000 ... +; $4000/$5000 bitmap (2 bits per pixel, 4K split) +; $6000 colour table, 96 bands x 4 register values +; $6400 display list (DLI bit on the last line of each 2-line band) +; +; assembled by atari/viewer/assemble.py via xa + +CP = $cb ; zero-page pointer into the colour table + + * = $2000 +boot: + .byte 0 + .byte 0 ; sector count (patched) + .word $2000 + .word binit + +cont: + sei + lda #$22 + sta $22f ; SDMCTL + lda #$00 + sta $26f ; GPRIOR = 0 + lda #$00 + sta $230 ; SDLSTL = $6400 (DL shipped in the data) + lda #$64 + sta $231 + ; DLI vector + lda #dli + sta $201 + ; colour pointer starts at band 1 (band 0 set by the VBI) + lda #$04 + sta CP + lda #$60 + sta CP+1 + lda $6000 + sta $2c8 + lda $6001 + sta $2c4 + lda $6002 + sta $2c5 + lda $6003 + sta $2c6 + ldy #vbi + lda #$07 + jsr $e45c ; SETVBV (deferred) + lda #$c0 + sta $d40e ; NMIEN = DLI + VBI + cli +#include "awyt.i" +binit: + rts + +vbi: + lda #$04 + sta CP + lda #$60 + sta CP+1 + lda $6000 + sta $d01a + lda $6001 + sta $d016 + lda $6002 + sta $d017 + lda $6003 + sta $d018 + jmp $e462 ; XITVBV + +dli: + pha + tya + pha + ldy #$00 + sta $d40a ; WSYNC + lda (CP),y + sta $d01a + iny + lda (CP),y + sta $d016 + iny + lda (CP),y + sta $d017 + iny + lda (CP),y + sta $d018 + lda CP + clc + adc #$04 + sta CP + bcc nocarry + inc CP+1 +nocarry: + pla + tay + pla + rti diff --git a/lenser/atari/viewer/gr8.s b/lenser/atari/viewer/gr8.s new file mode 100644 index 0000000..9ba5e59 --- /dev/null +++ b/lenser/atari/viewer/gr8.s @@ -0,0 +1,42 @@ +; lenser -- Atari GR.8 (ANTIC mode F) hi-res viewer, self-booting +; 320x192, two tones. Appended data from $4000 ... +; $4000/$5000 bitmap (1 bit per pixel, 4K split) +; $6000 two bytes, background reg then foreground reg +; +; assembled by atari/viewer/assemble.py via xa + + * = $2000 +boot: + .byte 0 + .byte 0 ; sector count (patched) + .word $2000 + .word binit + +cont: + lda #$22 + sta $22f ; SDMCTL + lda #dlist + sta $231 + lda #$00 + sta $26f ; GPRIOR = 0 + lda $6000 + sta $2c6 ; COLPF2 (background) + sta $2c8 ; COLBAK (border) = background + lda $6001 + sta $2c5 ; COLPF1 (foreground luminance) +#include "awyt.i" +binit: + rts + +dlist: + .byte $70,$70,$70 + .byte $4f ; LMS + mode F + .word $4000 + .dsb 101,$0f + .byte $4f + .word $5000 + .dsb 89,$0f + .byte $41 + .word dlist diff --git a/lenser/atari/viewer/gr9.s b/lenser/atari/viewer/gr9.s new file mode 100644 index 0000000..b56107d --- /dev/null +++ b/lenser/atari/viewer/gr9.s @@ -0,0 +1,39 @@ +; lenser -- Atari GR.9 (GTIA 16-luminance) viewer, self-booting +; 80x192, 16 shades of one hue. Appended data from $4000 ... +; $4000/$5000 bitmap (4 bits per pixel, 4K split) +; $6000 one byte, COLBAK = hue times 16 +; +; assembled by atari/viewer/assemble.py via xa + + * = $2000 +boot: + .byte 0 + .byte 0 ; sector count (patched) + .word $2000 + .word binit + +cont: + lda #$22 + sta $22f ; SDMCTL + lda #dlist + sta $231 + lda #$40 + sta $26f ; GPRIOR = GTIA mode 1 (GR.9) + lda $6000 + sta $2c8 ; COLBAK sets the hue +#include "awyt.i" +binit: + rts + +dlist: + .byte $70,$70,$70 + .byte $4f ; LMS + mode F + .word $4000 + .dsb 101,$0f + .byte $4f + .word $5000 + .dsb 89,$0f + .byte $41 + .word dlist diff --git a/lenser/atari/viewer/slideshow_dli.s b/lenser/atari/viewer/slideshow_dli.s new file mode 100644 index 0000000..fa196cb --- /dev/null +++ b/lenser/atari/viewer/slideshow_dli.s @@ -0,0 +1,248 @@ +; lenser -- Atari GR.15 + DLI slideshow viewer (per-line colour). +; +; Self-booting (OS loads this to $2000, JSRs $2006). Steps through NIMAGES +; pictures stored as raw sectors -- image i at sectors BASESEC + i*SPI -- SIO- +; reading SPI sectors into $4000 (bitmap $4000/$5000, colour table $6000 of +; 96 bands x 4, display list $6400) and showing each with a display-list +; interrupt that rewrites the four colour registers every two scanlines. +; +; build-time #defines -- WAITMODE WAITSECS RATE NIMAGES LOOPFLAG BASESEC SPI. + +CP = $cb ; zero-page pointer into the colour table + + * = $2000 +boot: + .byte 0 ; flags + .byte 0 ; sector count (patched) + .word $2000 + .word binit + +cont: ; $2006 -- OS JSRs here after loading the stub + sei + lda #$00 + sta $26f ; GPRIOR = 0 + lda #$00 + sta $230 ; SDLSTL = $6400 (the loaded display list) + lda #$64 + sta $231 + lda #dli + sta $201 + ldy #vbi + lda #$07 + jsr $e45c ; SETVBV (deferred VBI) + lda #$00 + sta ssidx + cli + +ssmain: + lda #$00 + sta $d40e ; NMIEN off (no DLI/VBI while loading) + sta $22f ; SDMCTL off (blank) + jsr readimg ; SIO-load image ssidx (IRQ stays on for SIOV) + ; re-init the colour pointer + band-0 colours for the new image + lda #$04 + sta CP + lda #$60 + sta CP+1 + lda $6000 + sta $2c8 + lda $6001 + sta $2c4 + lda $6002 + sta $2c5 + lda $6003 + sta $2c6 + lda #$c0 + sta $d40e ; NMIEN = DLI + VBI + lda #$22 + sta $22f ; SDMCTL = playfield + DL DMA + jsr sswait + inc ssidx + lda ssidx + cmp #NIMAGES + bcc ssmain +#if LOOPFLAG == 1 + lda #$00 + sta ssidx + jmp ssmain +#else + sei + lda #$00 + sta $d40e ; NMIEN off + lda #$40 + sta $d40e ; restore VBI only (OS housekeeping) + lda #$00 + sta $09 + cli + jmp $e474 ; warm-start (exit) +#endif + +binit: + rts + +; ---- SIO read SPI sectors of image ssidx into $4000 ---- +readimg: + lda #BASESEC + sta secn+1 + ldx ssidx + beq rsbuf +radd: + clc + lda secn + adc #SPI + sta secn + bcc ra1 + inc secn+1 +ra1: + dex + bne radd +rsbuf: + lda #$00 + sta $0304 + lda #$40 + sta $0305 + lda #SPI + sta cnt +rloop: + lda #$31 + sta $0300 + lda #$01 + sta $0301 + lda #$52 + sta $0302 + lda #$40 + sta $0303 + lda #$1f + sta $0306 + lda #$80 + sta $0308 + lda #$00 + sta $0309 + lda secn + sta $030a + lda secn+1 + sta $030b + jsr $e459 ; SIOV + clc + lda $0304 + adc #$80 + sta $0304 + bcc rb1 + inc $0305 +rb1: + inc secn + bne rb2 + inc secn+1 +rb2: + dec cnt + bne rloop + rts + +; ---- wait (returns; defeats attract mode via $4d) ---- +sswait: +#if WAITMODE == 1 + lda #$ff + sta $2fc +sw1: + lda #$00 + sta $4d + lda $2fc + cmp #$ff + beq sw1 + rts +#endif +#if WAITMODE == 2 + lda #$00 + sta $12 + sta $13 + sta $14 +sw2: + lda #$00 + sta $4d + lda $13 + cmp #>(WAITSECS*RATE) + bcc sw2 + bne sw2d + lda $14 + cmp #<(WAITSECS*RATE) + bcc sw2 +sw2d: + rts +#endif +#if WAITMODE == 3 + lda #$ff + sta $2fc + lda #$00 + sta $12 + sta $13 + sta $14 +sw3: + lda #$00 + sta $4d + lda $2fc + cmp #$ff + bne sw3d + lda $13 + cmp #>(WAITSECS*RATE) + bcc sw3 + bne sw3d + lda $14 + cmp #<(WAITSECS*RATE) + bcc sw3 +sw3d: + rts +#endif + +ssidx: .byte 0 +secn: .word 0 +cnt: .byte 0 + +; reset colour pointer + band-0 colours each frame (from the loaded $6000 table) +vbi: + lda #$04 + sta CP + lda #$60 + sta CP+1 + lda $6000 + sta $d01a + lda $6001 + sta $d016 + lda $6002 + sta $d017 + lda $6003 + sta $d018 + jmp $e462 ; XITVBV + +dli: + pha + tya + pha + ldy #$00 + sta $d40a ; WSYNC + lda (CP),y + sta $d01a + iny + lda (CP),y + sta $d016 + iny + lda (CP),y + sta $d017 + iny + lda (CP),y + sta $d018 + lda CP + clc + adc #$04 + sta CP + bcc nocarry + inc CP+1 +nocarry: + pla + tay + pla + rti diff --git a/lenser/atari/viewer/slideshow_static.s b/lenser/atari/viewer/slideshow_static.s new file mode 100644 index 0000000..f923a7d --- /dev/null +++ b/lenser/atari/viewer/slideshow_static.s @@ -0,0 +1,272 @@ +; lenser -- Atari static-playfield slideshow viewer (GR.15 / GR.9 / GR.8). +; +; Self-booting (OS loads this to $2000, JSRs $2006). Steps through NIMAGES +; pictures stored as raw sectors -- image i at sectors BASESEC + i*SPI -- SIO- +; reading SPI sectors and showing each before advancing (key / seconds / both). +; +; DOUBLE BUFFERED so the previous slide stays on screen while the next loads (no +; blank between slides). Two RAM buffers alternate as front/back -- +; buffer 0 -- bitmap $4000/$5000, colour bytes $6000 +; buffer 1 -- bitmap $7000/$8000, colour bytes $9000 +; both kept below $A000 so they are RAM even when the BASIC ROM is enabled. The +; display list's two LMS addresses point at the front buffer; each slide is SIO- +; read into the *back* buffer while the front (the previous picture) keeps +; displaying, then -- during a vertical blank so the change isn't torn -- the LMS +; addresses and colour registers are switched to it. ss_hi = ssbuf*$30 is the +; high-byte offset ($00 buffer 0, $30 buffer 1) added to every buffer address. +; +; The mode is fixed for the whole slideshow, chosen by build-time #defines -- +; DLMODE ANTIC mode byte ($0e GR.15, $0f GR.9/GR.8) +; GPRIOR GTIA priority/mode ($00, or $40 for GR.9) +; COLORMODE colour-register layout (0 GR.15 / 1 GR.9 / 2 GR.8) +; plus WAITMODE/WAITSECS/RATE, NIMAGES, LOOPFLAG, BASESEC, SPI (see assemble.py). + +colptr = $cb ; zero-page pointer to the back buffer's colours + + * = $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 the stub + lda #dlist + sta $231 ; SDLSTH + lda #GPRIOR + sta $26f ; GPRIOR + lda #$00 + sta $22f ; SDMCTL = 0 (blank before the first image only) + sta ssidx + sta ssbuf ; first slide loads into buffer 0 + +ssmain: + jsr sethi ; ss_hi = ssbuf * $30 + jsr readimg ; SIO-load image ssidx into the back buffer + + ; ---- wait for a vertical blank, then flip to the back buffer ---- + ; (the front buffer -- the previous slide -- has stayed on screen) + lda $14 +vbwait: + cmp $14 + beq vbwait ; RTCLOK ticked -> we are in the vertical blank + + ; display-list LMS -- region 1 = base, region 2 = base + $1000 + lda #$00 + sta lms1 + sta lms2 + lda #$40 + clc + adc ss_hi + sta lms1+1 ; $40 / $70 + lda #$50 + clc + adc ss_hi + sta lms2+1 ; $50 / $80 + + ; colour bytes are at base + $2000 + lda #$00 + sta colptr + lda #$60 + clc + adc ss_hi + sta colptr+1 ; $60 / $90 +#if COLORMODE == 0 + ldy #$00 + lda (colptr),y + sta $2c8 ; COLBAK (value 0) + ldy #$01 + lda (colptr),y + sta $2c4 ; COLPF0 (value 1) + ldy #$02 + lda (colptr),y + sta $2c5 ; COLPF1 (value 2) + ldy #$03 + lda (colptr),y + sta $2c6 ; COLPF2 (value 3) +#endif +#if COLORMODE == 1 + ldy #$00 + lda (colptr),y + sta $2c8 ; COLBAK = hue (GR.9) +#endif +#if COLORMODE == 2 + ldy #$00 + lda (colptr),y + sta $2c6 ; COLPF2 background + sta $2c8 ; COLBAK border = background + ldy #$01 + lda (colptr),y + sta $2c5 ; COLPF1 foreground +#endif + lda #$22 + sta $22f ; SDMCTL on (a no-op after the first slide) + + jsr sswait + lda ssbuf + eor #$01 + sta ssbuf ; next slide loads into the other buffer + inc ssidx + lda ssidx + cmp #NIMAGES + bcc ssmain +#if LOOPFLAG == 1 + lda #$00 + sta ssidx + jmp ssmain +#else + lda #$00 + sta $09 ; clear BOOT? so warmstart enters BASIC + jmp $e474 ; warm-start (exit) +#endif + +binit: + rts + +; ss_hi = ssbuf * $30 (back-buffer high-byte offset -- $00 buffer 0, $30 buffer 1) +sethi: + ldx #$00 + lda ssbuf + beq sh0 + ldx #$30 +sh0: + stx ss_hi + rts + +; ---- SIO read SPI sectors of image ssidx into the back buffer (base+$0000) ---- +readimg: + lda #BASESEC + sta secn+1 + ldx ssidx + beq rsbuf +radd: + clc + lda secn + adc #SPI + sta secn + bcc ra1 + inc secn+1 +ra1: + dex + bne radd +rsbuf: + lda #$00 + sta $0304 ; DBUFLO = $00 + lda #$40 + clc + adc ss_hi + sta $0305 ; DBUFHI = $40 / $70 (back buffer base) + lda #SPI + sta cnt +rloop: + lda #$31 + sta $0300 ; DDEVIC = disk + lda #$01 + sta $0301 ; DUNIT 1 + lda #$52 + sta $0302 ; DCOMND R (read) + lda #$40 + sta $0303 ; DSTATS = read direction + lda #$1f + sta $0306 ; DTIMLO + lda #$80 + sta $0308 ; DBYTLO = 128 + lda #$00 + sta $0309 ; DBYTHI + lda secn + sta $030a ; DAUX1 sector low + lda secn+1 + sta $030b ; DAUX2 sector high + jsr $e459 ; SIOV + clc + lda $0304 + adc #$80 + sta $0304 ; buffer += 128 + bcc rb1 + inc $0305 +rb1: + inc secn + bne rb2 + inc secn+1 +rb2: + dec cnt + bne rloop + rts + +; ---- wait (returns; defeats attract mode via $4d) ---- +sswait: +#if WAITMODE == 1 + lda #$ff + sta $2fc ; clear CH +sw1: + lda #$00 + sta $4d + lda $2fc + cmp #$ff + beq sw1 + rts +#endif +#if WAITMODE == 2 + lda #$00 + sta $12 + sta $13 + sta $14 ; reset RTCLOK +sw2: + lda #$00 + sta $4d + lda $13 + cmp #>(WAITSECS*RATE) + bcc sw2 + bne sw2d + lda $14 + cmp #<(WAITSECS*RATE) + bcc sw2 +sw2d: + rts +#endif +#if WAITMODE == 3 + lda #$ff + sta $2fc + lda #$00 + sta $12 + sta $13 + sta $14 +sw3: + lda #$00 + sta $4d + lda $2fc + cmp #$ff + bne sw3d ; any key ends the slide + lda $13 + cmp #>(WAITSECS*RATE) + bcc sw3 + bne sw3d + lda $14 + cmp #<(WAITSECS*RATE) + bcc sw3 +sw3d: + rts +#endif + +ssidx: .byte 0 +ssbuf: .byte 0 ; 0 or 1 -- which buffer the next slide loads into +ss_hi: .byte 0 ; ssbuf * $30 (back-buffer high-byte offset) +secn: .word 0 +cnt: .byte 0 + +dlist: + .byte $70,$70,$70 ; 24 blank scan lines + .byte DLMODE+$40 ; LMS + mode +lms1: + .word $4000 ; region 1 base (patched to the front buffer) + .dsb 101,DLMODE + .byte DLMODE+$40 ; LMS + mode +lms2: + .word $5000 ; region 2 base (patched to the front buffer) + .dsb 89,DLMODE + .byte $41 ; JVB + .word dlist diff --git a/c64view/basicgen.py b/lenser/basicgen.py similarity index 98% rename from c64view/basicgen.py rename to lenser/basicgen.py index db6aabb..db246b7 100644 --- a/c64view/basicgen.py +++ b/lenser/basicgen.py @@ -99,7 +99,7 @@ def build_info_prg(fields: list[tuple[str, str]]) -> bytes: # border and background both black add(bytes([_POKE]) + b"53280,0:" + bytes([_POKE]) + b"53281,0") - add(_rainbow_title("c64view picture info")) + add(_rainbow_title("8 Bit Lenser picture info")) add(bytes([_PRINT])) # blank line for i, (label, value) in enumerate(fields): diff --git a/lenser/bbc/__init__.py b/lenser/bbc/__init__.py new file mode 100644 index 0000000..19d2d1a --- /dev/null +++ b/lenser/bbc/__init__.py @@ -0,0 +1 @@ +"""BBC Micro (Model B) image conversion and DFS disk / sideways-ROM export.""" diff --git a/lenser/bbc/convert/__init__.py b/lenser/bbc/convert/__init__.py new file mode 100644 index 0000000..a4e7efe --- /dev/null +++ b/lenser/bbc/convert/__init__.py @@ -0,0 +1,17 @@ +"""BBC Micro conversion dispatch.""" +from __future__ import annotations +from ... import imageprep +from . import mode0, mode1, mode2, mode5, mono + +_MODULES = {"mode0": mode0, "mode1": mode1, "mode2": mode2, "mode5": mode5, + "mono": mono} +MODES = list(_MODULES.keys()) + + +def convert_image(path_or_img, mode="mode2", palette_name="bbc", + 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) diff --git a/lenser/bbc/convert/_common.py b/lenser/bbc/convert/_common.py new file mode 100644 index 0000000..d8972ee --- /dev/null +++ b/lenser/bbc/convert/_common.py @@ -0,0 +1,72 @@ +"""Shared helpers for the BBC Micro converters.""" + +from __future__ import annotations + +import itertools + +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 bpal + + +def _choose_physicals(img_lab, n, dither_mode): + """Pick the n physical colours (of 8) that best reproduce the image, then + dither once with that palette. Candidates are ranked by a fast vectorised + proxy -- the perceptual error of the nearest-colour (un-dithered) + reconstruction -- so we do NOT run the slow Floyd dither for all C(8,n) + combinations (doing so made the 4-colour modes hang for a minute+); Floyd runs + only on the winning palette. Returns (physical_indices, logical_idx, error).""" + plab_all = bpal.phys_lab() + H, W, _ = img_lab.shape + target_blur = _box_blur(img_lab) + best_combo, best_score = None, np.inf + for combo in itertools.combinations(range(8), n): + sub = plab_all[list(combo)] + nidx = ((img_lab[:, :, None, :] - sub[None, None]) ** 2).sum(-1).argmin(-1) + diff = _box_blur(sub[nidx]) - target_blur + score = float(np.sqrt((diff ** 2).sum(-1)).mean()) + if score < best_score: + best_score, best_combo = score, list(combo) + sub = plab_all[best_combo] + allowed = np.tile(np.arange(n), (H, W, 1)) + idx = dither.quantize(img_lab, allowed, sub, dither_mode).astype(np.uint8) + return best_combo, idx, perceptual_error(idx, img_lab, sub) + + +def build(img_rgb, *, mode, bbc_mode, ncol, bpp, width, height, base, + dither_mode, mono=False): + if mono: + L = srgb_to_lab(img_rgb)[..., 0] + img_lab = np.zeros((height, width, 3)) + img_lab[..., 0] = L + plab = np.zeros((2, 3)); plab[:, 0] = bpal.mono_lab()[:, 0] + allowed = np.tile(np.array([0, 1]), (height, width, 1)) + idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8) + physicals = [0, 7] # black, white + err = perceptual_error(idx, img_lab, plab) + prgb = bpal.PHYS[[0, 7]].astype(np.uint8) + else: + img_lab = srgb_to_lab(img_rgb) + if ncol >= 8: + physicals = list(range(8)) + plab = bpal.phys_lab() + allowed = np.tile(np.arange(8), (height, width, 1)) + idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8) + err = perceptual_error(idx, img_lab, plab) + else: + physicals, idx, err = _choose_physicals(img_lab, ncol, dither_mode) + prgb = bpal.PHYS[physicals].astype(np.uint8) + + data = bpal.pack(idx, width, bpp) + preview = prgb[idx] + return Conversion( + mode=mode, width=width, height=height, + pixel_aspect=(4 / 3) / (width / height), + index_image=idx.astype(np.uint16), data=data, data_addr=base, + viewer="bbc", preview_rgb=preview, error=err, + meta={"palette": "bbc", "dither": dither_mode, "bbc_mode": bbc_mode, + "ncol": ncol, "physicals": physicals, "base": base}, + ) diff --git a/lenser/bbc/convert/mode0.py b/lenser/bbc/convert/mode0.py new file mode 100644 index 0000000..e01a166 --- /dev/null +++ b/lenser/bbc/convert/mode0.py @@ -0,0 +1,9 @@ +"""BBC MODE 0: 640x256, 2-colour (black & white). 20K screen at &3000.""" +from __future__ import annotations +from . import _common +WIDTH, HEIGHT = 640, 256 +PIXEL_ASPECT = (4 / 3) / (WIDTH / HEIGHT) +def convert(img_rgb, palette_name="bbc", dither_mode="floyd", intensive=False, base_color=None): + return _common.build(img_rgb, mode="mode0", bbc_mode=0, ncol=2, bpp=1, + width=WIDTH, height=HEIGHT, base=0x3000, + dither_mode=dither_mode, mono=True) diff --git a/lenser/bbc/convert/mode1.py b/lenser/bbc/convert/mode1.py new file mode 100644 index 0000000..d3200ee --- /dev/null +++ b/lenser/bbc/convert/mode1.py @@ -0,0 +1,8 @@ +"""BBC MODE 1: 320x256, 4-colour (best 4 of 8). 20K screen at &3000.""" +from __future__ import annotations +from . import _common +WIDTH, HEIGHT = 320, 256 +PIXEL_ASPECT = (4 / 3) / (WIDTH / HEIGHT) +def convert(img_rgb, palette_name="bbc", dither_mode="floyd", intensive=False, base_color=None): + return _common.build(img_rgb, mode="mode1", bbc_mode=1, ncol=4, bpp=2, + width=WIDTH, height=HEIGHT, base=0x3000, dither_mode=dither_mode) diff --git a/lenser/bbc/convert/mode2.py b/lenser/bbc/convert/mode2.py new file mode 100644 index 0000000..3451d35 --- /dev/null +++ b/lenser/bbc/convert/mode2.py @@ -0,0 +1,8 @@ +"""BBC MODE 2: 160x256, 8-colour. 20K screen at &3000.""" +from __future__ import annotations +from . import _common +WIDTH, HEIGHT = 160, 256 +PIXEL_ASPECT = (4 / 3) / (WIDTH / HEIGHT) +def convert(img_rgb, palette_name="bbc", dither_mode="floyd", intensive=False, base_color=None): + return _common.build(img_rgb, mode="mode2", bbc_mode=2, ncol=8, bpp=4, + width=WIDTH, height=HEIGHT, base=0x3000, dither_mode=dither_mode) diff --git a/lenser/bbc/convert/mode5.py b/lenser/bbc/convert/mode5.py new file mode 100644 index 0000000..587eefc --- /dev/null +++ b/lenser/bbc/convert/mode5.py @@ -0,0 +1,8 @@ +"""BBC MODE 5: 160x256, 4-colour (best 4 of 8). 10K screen at &5800 (fits a ROM).""" +from __future__ import annotations +from . import _common +WIDTH, HEIGHT = 160, 256 +PIXEL_ASPECT = (4 / 3) / (WIDTH / HEIGHT) +def convert(img_rgb, palette_name="bbc", dither_mode="floyd", intensive=False, base_color=None): + return _common.build(img_rgb, mode="mode5", bbc_mode=5, ncol=4, bpp=2, + width=WIDTH, height=HEIGHT, base=0x5800, dither_mode=dither_mode) diff --git a/lenser/bbc/convert/mono.py b/lenser/bbc/convert/mono.py new file mode 100644 index 0000000..710f597 --- /dev/null +++ b/lenser/bbc/convert/mono.py @@ -0,0 +1,15 @@ +"""BBC monochrome -- MODE 0's 640x256 black & white, exposed as the standard +``mono`` mode for cross-platform parity (tone carried by dithering).""" +from __future__ import annotations + +from . import mode0 + +WIDTH, HEIGHT, PIXEL_ASPECT = mode0.WIDTH, mode0.HEIGHT, mode0.PIXEL_ASPECT + + +def convert(img_rgb, palette_name="bbc", dither_mode="floyd", + intensive=False, base_color=None): + conv = mode0.convert(img_rgb, palette_name, dither_mode, intensive, + base_color=base_color) + conv.mode = "mono" + return conv diff --git a/lenser/bbc/exporter.py b/lenser/bbc/exporter.py new file mode 100644 index 0000000..8dace95 --- /dev/null +++ b/lenser/bbc/exporter.py @@ -0,0 +1,24 @@ +"""Build a BBC Micro DFS disk (.ssd) from a conversion.""" +from __future__ import annotations + +from . import ssd +from .viewer.assemble import LOAD_ADDR, build_viewer + + +def export_ssd(conv, output_path, source_path=None, display="forever", + seconds=0, video="pal") -> str: + """Two DFS files: the !BOOT loader (sets mode + palette, *LOADs IMG) and the + IMG screen data. Boot option 2 (*RUN !BOOT) so SHIFT+BREAK autostarts it.""" + if not output_path.lower().endswith(".ssd"): + output_path += ".ssd" + m = conv.meta + viewer = build_viewer(m["bbc_mode"], m["ncol"], m["physicals"], m["base"], + display=display, seconds=seconds, video=video) + # !BOOT autostarts on SHIFT+BREAK (real hardware); PIC is the same loader + # under a name with no '!' so it can be *RUN from a command line / emulator. + files = [ + ("!BOOT", LOAD_ADDR, LOAD_ADDR, viewer), + ("PIC", LOAD_ADDR, LOAD_ADDR, viewer), + ("IMG", m["base"], m["base"], conv.data), + ] + return ssd.write_ssd(output_path, files, title="8BITLENSER", boot_option=2) diff --git a/lenser/bbc/palette.py b/lenser/bbc/palette.py new file mode 100644 index 0000000..ee56f33 --- /dev/null +++ b/lenser/bbc/palette.py @@ -0,0 +1,70 @@ +"""BBC Micro (Video ULA + 6845) palette and screen packing. + +8 physical colours -- pure digital RGB. Modes map 1/2/4 bits-per-pixel of +*logical* colour through a programmable palette (VDU 19) to these physicals. + +Screen memory is character-cell interleaved: 8x8 cells ordered left-to-right then +top-to-bottom; within a cell the bytes go by scanline, and each scanline spans +1/2/4 bytes (2/4/8-colour). Pixel bits within a byte are interleaved, leftmost +pixel in the high bits. +""" + +from __future__ import annotations + +import numpy as np + +from ..palette import srgb_to_lab + +# Physical colours 0..7. +PHYS = np.array([ + (0, 0, 0), # 0 black + (255, 0, 0), # 1 red + (0, 255, 0), # 2 green + (255, 255, 0), # 3 yellow + (0, 0, 255), # 4 blue + (255, 0, 255), # 5 magenta + (0, 255, 255), # 6 cyan + (255, 255, 255), # 7 white +], dtype=np.float64) + + +def phys_lab() -> np.ndarray: + return srgb_to_lab(PHYS) + + +def mono_lab() -> np.ndarray: + return srgb_to_lab(PHYS[[0, 7]]) # black + white + + +def _byte_for_pixels(vals, bits_per_pixel): + """Encode the pixels covering one byte into the BBC interleaved layout. + Leftmost pixel uses the highest bit of each bit-plane group.""" + n = len(vals) # pixels per byte (8/4/2) + b = 0 + for bit in range(bits_per_pixel - 1, -1, -1): # high plane first + for i, v in enumerate(vals): # left pixel first + b = (b << 1) | ((v >> bit) & 1) + return b + + +def pack(idx: np.ndarray, width: int, bits_per_pixel: int) -> bytes: + """Pack a (height, width) logical-colour array into BBC screen bytes. + + The BBC layout is universal: one byte holds ``ppb`` horizontally-adjacent + pixels, and 8 consecutive bytes step down the 8 raster lines of that + byte-column before moving one byte-column to the right; whole character rows + (8 raster lines) then follow top-to-bottom. So + addr = char_row*(num_byte_cols*8) + byte_col*8 + raster + """ + h, w = idx.shape + ppb = 8 // bits_per_pixel # pixels per byte + num_byte_cols = w // ppb + row_stride = num_byte_cols * 8 # bytes per character row + out = bytearray((w * h) // ppb) + for y in range(h): + base = (y // 8) * row_stride + (y % 8) + for bc in range(num_byte_cols): + x0 = bc * ppb + vals = [int(idx[y, x0 + p]) for p in range(ppb)] + out[base + bc * 8] = _byte_for_pixels(vals, bits_per_pixel) + return bytes(out) diff --git a/lenser/bbc/ssd.py b/lenser/bbc/ssd.py new file mode 100644 index 0000000..4d91a46 --- /dev/null +++ b/lenser/bbc/ssd.py @@ -0,0 +1,72 @@ +"""Write an Acorn DFS single-sided disk image (.ssd) natively. + +DFS layout: sectors 0-1 are the catalogue; files follow from sector 2, each +starting on a 256-byte sector boundary. Addresses are 18-bit (load/exec/length +high bits packed into the per-file flag byte). Boot option (*OPT 4,n) lives in +sector 1 byte 6 bits 4-5. +""" + +from __future__ import annotations + +SECTOR = 256 +TRACKS = 80 +SECTORS = TRACKS * 10 # 800 sectors = 200K, single sided + + +class DfsError(RuntimeError): + pass + + +def _name7(name: str) -> bytes: + n = "".join(c for c in name.upper() if 32 <= ord(c) < 127)[:7] + return n.ljust(7).encode("ascii") + + +def build_ssd(files, title="8BITLENSER", boot_option=0) -> bytes: + """files: list of (name, load_addr, exec_addr, data). boot_option 0-3 + (*OPT 4,n): 0 none, 1 *LOAD, 2 *RUN, 3 *EXEC of the first matching $.!BOOT.""" + if len(files) > 31: + raise DfsError("DFS holds at most 31 files") + + cat0 = bytearray(SECTOR) + cat1 = bytearray(SECTOR) + t = title.ljust(12)[:12].encode("ascii", "replace") + cat0[0:8] = t[0:8] + cat1[0:4] = t[8:12] + cat1[4] = 0 # cycle number + cat1[5] = len(files) * 8 # (#files) * 8 + cat1[6] = ((SECTORS >> 8) & 0x03) | ((boot_option & 0x03) << 4) + cat1[7] = SECTORS & 0xFF + + body = bytearray() + start_sector = 2 + for i, (name, load, exec_, data) in enumerate(files): + e = 8 + i * 8 + cat0[e:e + 7] = _name7(name) + cat0[e + 7] = ord("$") # directory '$' (unlocked) + length = len(data) + cat1[e + 0] = load & 0xFF + cat1[e + 1] = (load >> 8) & 0xFF + cat1[e + 2] = exec_ & 0xFF + cat1[e + 3] = (exec_ >> 8) & 0xFF + cat1[e + 4] = length & 0xFF + cat1[e + 5] = (length >> 8) & 0xFF + cat1[e + 6] = (((exec_ >> 16) & 0x03) << 6 | + ((length >> 16) & 0x03) << 4 | + ((load >> 16) & 0x03) << 2 | + ((start_sector >> 8) & 0x03)) + cat1[e + 7] = start_sector & 0xFF + nsec = (length + SECTOR - 1) // SECTOR + body += bytes(data) + bytes((-length) % SECTOR) + start_sector += nsec + + img = bytes(cat0) + bytes(cat1) + bytes(body) + if len(img) > SECTORS * SECTOR: + raise DfsError("files exceed disk capacity") + return img + bytes(SECTORS * SECTOR - len(img)) + + +def write_ssd(path: str, files, title="8BITLENSER", boot_option=0) -> str: + with open(path, "wb") as f: + f.write(build_ssd(files, title, boot_option)) + return path diff --git a/lenser/bbc/viewer/__init__.py b/lenser/bbc/viewer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lenser/bbc/viewer/assemble.py b/lenser/bbc/viewer/assemble.py new file mode 100644 index 0000000..ab7f50b --- /dev/null +++ b/lenser/bbc/viewer/assemble.py @@ -0,0 +1,102 @@ +"""Assemble the BBC 6502 viewer with `xa` and patch in per-image parameters.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile + +VIEWER_DIR = os.path.dirname(os.path.abspath(__file__)) +LOAD_ADDR = 0x1900 # DFS PAGE +WAIT_MODES = {"forever": 0, "key": 1, "seconds": 2} + + +class AssemblerError(RuntimeError): + pass + + +def have_xa() -> bool: + return shutil.which("xa") is not None + + +def _xa(wrapper: str) -> bytes: + if not have_xa(): + raise AssemblerError("The 'xa' (xa65) assembler was not found on PATH.\n" + "Install it with: sudo apt install xa65") + 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) + + +SS_WAITMODE = {"key": 1, "seconds": 2, "both": 3} + + +def build_slideshow_viewer(bbc_mode: int, ncol: int, base: int, palettes, + advance: str = "both", seconds: int = 10, + loop: bool = True, video: str = "pal") -> bytes: + """Return the multi-image BBC loader (origin $1900). + + ``palettes`` is one physical-colour list per image (each truncated/padded to + ncol) -- emitted as the ss_pal table the loader indexes by slide; the screen + base hex is patched into the OSCLI *LOAD string. + """ + if not palettes: + raise AssemblerError("a slideshow needs at least one image") + rate = 60 if video == "ntsc" else 50 + flat = [] + for p in palettes: + row = list(p[:ncol]) + flat += row + [0] * (ncol - len(row)) + table = ",".join(str(b & 0xFF) for b in flat) + wrapper = (f"#define WAITMODE {SS_WAITMODE[advance]}\n" + f"#define WAITSECS {max(0, int(seconds))}\n" + f"#define RATE {rate}\n" + f"#define NIMAGES {len(palettes)}\n" + f"#define LOOPFLAG {1 if loop else 0}\n" + f"#define MODE {bbc_mode}\n" + f"#define NCOL {ncol}\n" + '#include "slideshow.s"\n' + "ss_pal:\n" + f" .byte {table}\n") + raw = bytearray(_xa(wrapper)) + off = raw.find(b"LOAD 00 ") + if off < 0: + raise AssemblerError("could not locate the OSCLI string to patch") + raw[off + 8:off + 12] = f"{base:04X}".encode("ascii") # screen-base hex + return bytes(raw) + + +def build_viewer(bbc_mode: int, ncol: int, physicals, base: int, + display: str = "forever", seconds: int = 0, + video: str = "pal") -> bytes: + """Return the assembled loader (origin $1900) with the mode/palette/screen-base + parameters patched in.""" + waitmode = WAIT_MODES.get(display, 0) + rate = 60 if video == "ntsc" else 50 # bbcb is PAL (50 Hz) + wrapper = (f"#define WAITMODE {waitmode}\n" + f"#define WAITSECS {max(0, int(seconds))}\n" + f"#define RATE {rate}\n" + '#include "bbc.s"\n') + raw = bytearray(_xa(wrapper)) + + off = raw.find(b"LOAD IMG ") + if off < 0: + raise AssemblerError("could not locate the OSCLI string to patch") + raw[off + 9:off + 13] = f"{base:04X}".encode("ascii") # screen-base hex + raw[off + 14] = bbc_mode & 0xFF # p_mode + raw[off + 15] = ncol & 0xFF # p_ncol + for i, p in enumerate(physicals[:8]): # p_pal + raw[off + 16 + i] = p & 0xFF + return bytes(raw) diff --git a/lenser/bbc/viewer/bbc.s b/lenser/bbc/viewer/bbc.s new file mode 100644 index 0000000..e3f0a1d --- /dev/null +++ b/lenser/bbc/viewer/bbc.s @@ -0,0 +1,97 @@ +; lenser -- BBC Micro viewer (6502), loaded at PAGE (&1900) and *RUN. +; +; Sets the screen MODE, programmes the logical->physical palette, then *LOADs the +; image file "IMG" straight into screen memory, and holds it per the display +; option. Parameters come from a fixed block the packager fills in (so the code +; is constant); see build_viewer() in assemble.py. +; +; #defines from the wrapper -- WAITMODE (0 forever, 1 key, 2 seconds), WAITSECS, RATE. +; +; assembled by bbc/viewer/assemble.py via xa + +OSWRCH = $FFEE +OSRDCH = $FFE0 +OSBYTE = $FFF4 +OSCLI = $FFF7 + + * = $1900 +start: + ; ---- VDU 22, mode ---- + lda #22 + jsr OSWRCH + lda p_mode + jsr OSWRCH + + ; ---- palette via VDU 19, logical, physical, 0,0,0 for each logical ---- + ldx #0 +palloop: + cpx p_ncol + beq paldone + lda #19 + jsr OSWRCH + txa + jsr OSWRCH ; logical colour = X + lda p_pal,x + jsr OSWRCH ; physical + lda #0 + jsr OSWRCH + jsr OSWRCH + jsr OSWRCH + inx + bne palloop +paldone: + + ; ---- hide the text cursor (VDU 23,1,0;0;0;0;) ---- + lda #23 + jsr OSWRCH + lda #1 + jsr OSWRCH + ldx #8 +curz: + lda #0 + jsr OSWRCH + dex + bne curz + + ; ---- *LOAD the image straight into screen memory ---- + ldx #cmd + jsr OSCLI + + ; ---- hold the picture ---- +#if WAITMODE == 0 +hang: + jmp hang +#endif +#if WAITMODE == 1 + jsr OSRDCH ; block until a key + rts ; back to BASIC +#endif +#if WAITMODE == 2 + lda #<(WAITSECS*RATE) + sta cnt + lda #>(WAITSECS*RATE) + sta cnt+1 +swait: + lda #19 + jsr OSBYTE ; OSBYTE 19 = wait for vertical sync + lda cnt + bne sdec + dec cnt+1 +sdec: + dec cnt + lda cnt + ora cnt+1 + bne swait + rts ; back to BASIC +#endif + +cnt: .byte 0,0 + +; OSCLI string -- the packager patches in the right screen-base hex +cmd: .byte "LOAD IMG ", "0000", 13 + +; parameter block -- the packager fills these in +p_mode: .byte 0 +p_ncol: .byte 0 +p_pal: .byte 0,0,0,0,0,0,0,0 diff --git a/lenser/bbc/viewer/slideshow.s b/lenser/bbc/viewer/slideshow.s new file mode 100644 index 0000000..59ca817 --- /dev/null +++ b/lenser/bbc/viewer/slideshow.s @@ -0,0 +1,174 @@ +; lenser -- BBC Micro slideshow loader (6502), loaded at PAGE (&1900) and *RUN. +; +; Sets the screen MODE once, then steps through NIMAGES pictures stored as DFS +; files "00".."NN". For each slide it programmes that image's logical->physical +; palette (from the ss_pal table), *LOADs the file straight into screen memory, +; and waits (key / seconds / both) before advancing. +; +; #defines from the build wrapper -- +; WAITMODE 1 key / 2 seconds / 3 both WAITSECS timeout RATE 50/60 fps +; NIMAGES image count LOOPFLAG 1 wrap / 0 stop MODE screen mode NCOL colours +; The packager patches the screen-base hex into the OSCLI string and appends the +; ss_pal table (NCOL physical-colour bytes per image). + +OSWRCH = $FFEE +OSRDCH = $FFE0 +OSBYTE = $FFF4 +OSCLI = $FFF7 + +palptr = $70 +cnt = $72 + + * = $1900 +start: + ; ---- VDU 22, mode (once) ---- + lda #22 + jsr OSWRCH + lda #MODE + jsr OSWRCH + + ; ---- hide the text cursor (VDU 23,1,0;0;0;0;) ---- + lda #23 + jsr OSWRCH + lda #1 + jsr OSWRCH + ldx #8 +curz: + lda #0 + jsr OSWRCH + dex + bne curz + + lda #0 + sta ssidx + +ssmain: + ; ---- palptr = ss_pal + ssidx*NCOL ---- + lda #ss_pal + sta palptr+1 + ldx ssidx + beq setpal +pmul: + clc + lda palptr + adc #NCOL + sta palptr + bcc pm1 + inc palptr+1 +pm1: + dex + bne pmul +setpal: + ; ---- VDU 19, logical, physical, 0,0,0 for each logical colour ---- + ldx #0 +pl: + cpx #NCOL + beq pldone + lda #19 + jsr OSWRCH + txa + jsr OSWRCH ; logical colour = X + txa + tay + lda (palptr),y + jsr OSWRCH ; physical colour + lda #0 + jsr OSWRCH + jsr OSWRCH + jsr OSWRCH + inx + bne pl +pldone: + + ; ---- build the filename digits "NN" into the OSCLI string ---- + lda ssidx + ldx #$2f + sec +nten: + inx + sbc #10 + bcs nten + adc #10 + ora #$30 + sta cmd+6 ; ones digit + txa + sta cmd+5 ; tens digit + + ; ---- *LOAD NN straight into screen memory ---- + ldx #cmd + jsr OSCLI + + jsr sswait + + inc ssidx + lda ssidx + cmp #NIMAGES + bcc ssmain +#if LOOPFLAG == 1 + lda #0 + sta ssidx + jmp ssmain +#else + rts ; back to BASIC +#endif + +; ---- wait (returns), flushing the keyboard buffer first ---- +sswait: + lda #15 + ldx #1 + jsr OSBYTE ; flush input buffers +#if WAITMODE == 1 + jsr OSRDCH ; block until a key + rts +#endif +#if WAITMODE == 2 + lda #<(WAITSECS*RATE) + sta cnt + lda #>(WAITSECS*RATE) + sta cnt+1 +sw2: + lda #19 + jsr OSBYTE ; wait for vertical sync (1 frame) + lda cnt + bne sd2 + dec cnt+1 +sd2: + dec cnt + lda cnt + ora cnt+1 + bne sw2 + rts +#endif +#if WAITMODE == 3 + lda #<(WAITSECS*RATE) + sta cnt + lda #>(WAITSECS*RATE) + sta cnt+1 +sw3: + lda #129 + ldx #0 + ldy #0 + jsr OSBYTE ; INKEY(0) -- poll keyboard, no wait + cpy #$ff + bne sw3d ; Y != $FF -> a key was pressed + lda #19 + jsr OSBYTE ; wait one frame + lda cnt + bne sd3 + dec cnt+1 +sd3: + dec cnt + lda cnt + ora cnt+1 + bne sw3 +sw3d: + rts +#endif + +ssidx: .byte 0 +; OSCLI string -- packager patches the 4-hex screen base; loader patches "NN" +cmd: .byte "LOAD 00 0000", 13 +; ss_pal table (NCOL bytes per image) appended by the build wrapper diff --git a/lenser/c128/__init__.py b/lenser/c128/__init__.py new file mode 100644 index 0000000..4ba013c --- /dev/null +++ b/lenser/c128/__init__.py @@ -0,0 +1 @@ +"""Commodore 128 target for lenser (VDC 8563 80-column display).""" diff --git a/lenser/c128/convert/__init__.py b/lenser/c128/convert/__init__.py new file mode 100644 index 0000000..49de04c --- /dev/null +++ b/lenser/c128/convert/__init__.py @@ -0,0 +1,21 @@ +"""Commodore 128 conversion dispatch (VDC 80-column).""" +from __future__ import annotations + +from ... import imageprep +from . import mono +from . import color +from . import hicolor + +_MODULES = {"mono": mono, "color": color, "hicolor": hicolor} +MODES = list(_MODULES.keys()) + + +def convert_image(path_or_img, mode="mono", palette_name="vdc", + dither_mode="floyd", intensive=False, prep_opt=None, + base_color=None): + prep_opt = prep_opt or imageprep.PrepOptions() + module = _MODULES.get(mode, mono) + 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) diff --git a/lenser/c128/convert/color.py b/lenser/c128/convert/color.py new file mode 100644 index 0000000..bc17cff --- /dev/null +++ b/lenser/c128/convert/color.py @@ -0,0 +1,44 @@ +"""C128 VDC chunky 80x100 16-colour mode. + +MAME's 8563 draws the "bitmap" through the character/font path, so true +per-pixel bitmap colour isn't possible -- but each 8x2 cell can carry its own +colour via the attribute byte (fg = high nibble). Filling the character matrix +with a solid glyph turns that into a per-cell SOLID colour image: a chunky +80x100 picture using all 16 VDC colours. Lower resolution than `mono`'s +640x200, but full colour (or smooth multi-level greyscale on the four greys). +""" +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 vdcpal + +# 8x2 device-pixel cells over the 640x200 display = 80x100 colour cells. Each +# cell is ~8*0.42 wide x 2 tall, so the 80x100 grid is ~4:3 -> aspect ~1.68. +WIDTH, HEIGHT = 80, 100 +PIXEL_ASPECT = 1.68 + + +def convert(img_rgb, palette_name="vdc", dither_mode="floyd", + intensive=False, base_color=None): + prgb = vdcpal.get_palette().astype(np.uint8) + plab = vdcpal.palette_lab() + + lab = c64pal.srgb_to_lab(img_rgb) + allowed = np.tile(np.arange(16), (HEIGHT, WIDTH, 1)) + idx = dither.quantize(lab, allowed, plab, dither_mode).astype(np.uint8) + + # one attribute byte per cell; colour in the HIGH nibble (VDC fg = attr>>4), + # low nibble (bg) left 0 -- the solid glyph means only fg shows. + attr = ((idx.reshape(-1).astype(np.uint8) & 0x0F) << 4) + data = bytes(attr.tolist()) # 80*100 = 8000 + + return Conversion( + mode="color", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=idx.astype(np.uint16), data=data, data_addr=0, viewer="c128", + preview_rgb=prgb[idx], error=perceptual_error(idx, lab, plab), + meta={"palette": "vdc", "dither": dither_mode, "fgbg": 0x0F, + "vdc_mode": "color"}, + ) diff --git a/lenser/c128/convert/hicolor.py b/lenser/c128/convert/hicolor.py new file mode 100644 index 0000000..5ca6f31 --- /dev/null +++ b/lenser/c128/convert/hicolor.py @@ -0,0 +1,142 @@ +"""C128 VDC high-resolution colour mode (640x200 via a custom character set). + +MAME's 8563 (and real hardware) renders 80-column *character* mode with a custom +font + per-cell attributes. That gives genuine per-pixel detail (8x8 glyph per +cell) at 640x200, with one freely chosen INK colour per cell (attribute low +nibble) over a single GLOBAL background (VDC register 26) -- a ZX-Spectrum-like +colour model, but at double the Spectrum's horizontal resolution. + +The picture is built by, for each 8x8 cell, choosing the ink colour that best +represents it against the global background, dithering the cell to ink/background +(a 1bpp glyph), then vector-quantising the ~2000 cell glyphs down to a 512-entry +character set. The VDC addresses 256 glyphs per bank; a second bank is selected +per cell by the attribute's ALTERNATE_CHARSET bit (bit 7), giving 512 glyphs. +""" +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 vdcpal + +WIDTH, HEIGHT = 640, 200 +PIXEL_ASPECT = 0.42 # same fine pixels as the mono bitmap +CW, CH = 8, 8 # 8x8 character cells +COLS, ROWS = WIDTH // CW, HEIGHT // CH # 80 x 25 = 2000 cells +BANK = 256 # glyphs per VDC charset bank +NGLYPH = 512 # two banks (selected per cell by attr bit 7) + +# VDC RAM layout (matches the C128 default so registers stay default). +CODES_ADDR = 0x0000 +ATTR_ADDR = 0x0800 +CHAR_ADDR = 0x2000 # bank 0 at $2000, bank 1 at $3000 (+$1000) +VDC_LEN = 0x4000 # full 16K, copied to VDC RAM by the viewer + + +def _cell_view(a): + """(ROWS*CH, COLS*CW, ...) -> (ROWS, COLS, CH, CW, ...) cell blocks.""" + r = a.reshape(ROWS, CH, COLS, CW, *a.shape[2:]) + return r.transpose(0, 2, 1, 3, *range(4, a.ndim + 2)) + + +def _kmeans_binary(patterns, k, iters=10, seed=0): + """Cluster 0/1 patterns (N, D) into k binary centroids (Hamming / majority).""" + n = len(patterns) + rng = np.random.default_rng(seed) + uniq = np.unique(patterns, axis=0) + if len(uniq) <= k: + cb = np.zeros((k, patterns.shape[1]), np.uint8) + cb[:len(uniq)] = uniq + d = (patterns[:, None, :] != cb[None, :, :]).sum(2) + return cb, d.argmin(1) + cb = uniq[rng.choice(len(uniq), k, replace=False)].astype(np.uint8) + assign = np.zeros(n, np.int32) + for _ in range(iters): + # nearest centroid by Hamming distance, in chunks to bound memory + for s in range(0, n, 4096): + blk = patterns[s:s + 4096] + d = (blk[:, None, :] != cb[None, :, :]).sum(2) + assign[s:s + 4096] = d.argmin(1) + for j in range(k): + members = patterns[assign == j] + if len(members): + cb[j] = (members.mean(0) >= 0.5).astype(np.uint8) + return cb, assign + + +def convert(img_rgb, palette_name="vdc", dither_mode="floyd", + intensive=False, base_color=None): + """Full 16-colour high-resolution mode.""" + return build(img_rgb, dither_mode, inks=list(range(16)), + bg_list=list(range(16)), mode_name="hicolor") + + +def build(img_rgb, dither_mode, inks, bg_list, mode_name): + """Shared 640x200 custom-charset encoder. + + inks palette indices a cell's foreground may use (per-cell ink choice) + bg_list palette indices to try as the single global background + mode_name value for Conversion.mode (the VDC viewer is the same either way) + """ + prgb = vdcpal.get_palette().astype(np.uint8) + plab = vdcpal.palette_lab() + lab = c64pal.srgb_to_lab(img_rgb) # (H, W, 3) + inks = np.asarray(inks) + + # distance from every pixel to every palette colour + dist = np.linalg.norm(lab[:, :, None, :] - plab[None, None, :, :], axis=3) + dist_cells = _cell_view(dist) # (R,C,CH,CW,16) + dist_inks = dist_cells[..., inks] # (R,C,CH,CW,len) + + # choose the global background: the colour that, as the shared second tone, + # lets each cell's best ink reproduce the image with least total error. + best_bg, best_err, best_ink = bg_list[0], None, None + for bg in bg_list: + d_bg = dist_cells[..., bg:bg + 1] # (R,C,CH,CW,1) + per_ink = np.minimum(d_bg, dist_inks).sum((2, 3)) # (R,C,len) + sel = per_ink.argmin(2) # (R,C) index into inks + err = np.take_along_axis(per_ink, sel[..., None], 2).sum() + if best_err is None or err < best_err: + best_bg, best_err, best_ink = bg, err, inks[sel] + bg, ink_cell = best_bg, best_ink # ink_cell (R,C) palette + + # dither each pixel between the global bg and its cell's ink + ink_px = np.repeat(np.repeat(ink_cell, CH, 0), CW, 1) # (H,W) + allowed = np.stack([np.full((HEIGHT, WIDTH), bg), ink_px], axis=2) + idx = dither.quantize(lab, allowed, plab, dither_mode).astype(np.uint8) + + # 1bpp glyph per cell (1 = ink), then vector-quantise to a 256-glyph charset + bits = (idx == ink_px).astype(np.uint8) # (H,W) + glyph_cells = _cell_view(bits).reshape(ROWS * COLS, CH * CW) + codebook, assign = _kmeans_binary(glyph_cells, NGLYPH) # assign 0..NGLYPH-1 + code_map = assign.reshape(ROWS, COLS) + + # rebuild the actually-displayed image from the quantised glyphs + colours + glyphs_img = codebook[assign].reshape(ROWS, COLS, CH, CW) + shown_ink = (glyphs_img == 1) + final_idx = np.where( + shown_ink, ink_cell[:, :, None, None], bg).astype(np.uint16) + final_idx = final_idx.transpose(0, 2, 1, 3).reshape(HEIGHT, WIDTH) + + # ---- assemble the VDC RAM image ---- + vdc = bytearray(VDC_LEN) + glyph = code_map.reshape(-1) + codes = (glyph & 0xFF).astype(np.uint8) # char code within bank + # ink in the low nibble; bit 7 (ALTERNATE_CHARSET) selects bank 1 + attr = ((ink_cell.reshape(-1) & 0x0F) | ((glyph >> 8) << 7)).astype(np.uint8) + vdc[CODES_ADDR:CODES_ADDR + codes.size] = codes.tobytes() + vdc[ATTR_ADDR:ATTR_ADDR + attr.size] = attr.tobytes() + for g in range(NGLYPH): + rows = np.packbits(codebook[g].reshape(CH, CW), axis=1).reshape(-1) + off = CHAR_ADDR + (g >> 8) * 0x1000 + ((g & 0xFF) << 4) + vdc[off:off + CH] = rows.tobytes() + + return Conversion( + mode=mode_name, width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=final_idx, data=bytes(vdc), data_addr=0, viewer="c128", + preview_rgb=prgb[final_idx], + error=perceptual_error(final_idx, lab, plab), + meta={"palette": "vdc", "dither": dither_mode, "fgbg": bg & 0x0F, + "vdc_mode": "hicolor", "bg": bg}, + ) diff --git a/lenser/c128/convert/mono.py b/lenser/c128/convert/mono.py new file mode 100644 index 0000000..f2d3421 --- /dev/null +++ b/lenser/c128/convert/mono.py @@ -0,0 +1,29 @@ +"""C128 VDC high-resolution greyscale / black-and-white (640x200). + +MAME's 8563 has no true linear bitmap renderer (its R25-bit7 "bitmap" path only +emits one bit per 8-pixel cell), so genuine 640x200 detail has to come through +the character/font path -- exactly as the `hicolor` mode does. This mode reuses +that machinery restricted to the VDC's four greys (black, dark grey, light grey, +white): each 8x8 cell picks the grey that best matches it over a global grey +background and dithers to a 1bpp glyph, giving smooth multi-level greyscale at +full resolution. `--mono-base` swaps the grey ramp for a black->colour->white +ramp to tint the result. +""" +from __future__ import annotations + +from . import hicolor + +WIDTH, HEIGHT = hicolor.WIDTH, hicolor.HEIGHT +PIXEL_ASPECT = hicolor.PIXEL_ASPECT + +GREYS = [0, 1, 14, 15] # VDC black, dark grey, light grey, white + + +def convert(img_rgb, palette_name="vdc", dither_mode="floyd", + intensive=False, base_color=None): + if base_color in range(1, 16): + inks = sorted({0, int(base_color), 15}) # black -> tint -> white + else: + inks = GREYS + return hicolor.build(img_rgb, dither_mode, inks=inks, bg_list=inks, + mode_name="mono") diff --git a/lenser/c128/exporter.py b/lenser/c128/exporter.py new file mode 100644 index 0000000..bb47173 --- /dev/null +++ b/lenser/c128/exporter.py @@ -0,0 +1,23 @@ +"""Build a C128 autobooting .d64 (the VDC viewer PRG, loaded with RUN"PIC").""" +from __future__ import annotations + +import os + +from .. import diskimage +from .viewer import assemble + + +def export_d64(conv, output_path, source_path=None, display="forever", + seconds=0, video="ntsc"): + if not output_path.lower().endswith(".d64"): + output_path += ".d64" + # "color" = 80x100 chunky solid cells; "hicolor" (also used by mono) = + # 640x200 custom-charset font mode. + if conv.meta.get("vdc_mode") == "color": + prg = assemble.build_prg_color(bytes(conv.data), conv.meta.get("fgbg", 0x0F)) + else: + prg = assemble.build_prg_hicolor(bytes(conv.data), conv.meta.get("fgbg", 0x00)) + name = os.path.splitext(os.path.basename(source_path or output_path))[0] + diskimage.build_disk(output_path, "d64", name[:16] or "8bitlenser", "cv", + [("pic", prg)]) + return output_path diff --git a/lenser/c128/palette.py b/lenser/c128/palette.py new file mode 100644 index 0000000..6ac5656 --- /dev/null +++ b/lenser/c128/palette.py @@ -0,0 +1,38 @@ +"""Commodore 128 VDC (8563) 16-colour RGBI palette. + +The VDC outputs digital RGBI, giving a CGA-like 16-colour set (quite different +from the VIC-II's colours). Index 0 = black, 15 = white -- the only two the mono +mode needs; the rest are provided for tinted-mono and future colour modes. +""" +from __future__ import annotations + +import numpy as np + +from ..palette import srgb_to_lab + +VDC = np.array([ + (0x00, 0x00, 0x00), # 0 black + (0x55, 0x55, 0x55), # 1 dark grey + (0x00, 0x00, 0xAA), # 2 blue + (0x55, 0x55, 0xFF), # 3 light blue + (0x00, 0xAA, 0x00), # 4 green + (0x55, 0xFF, 0x55), # 5 light green + (0x00, 0xAA, 0xAA), # 6 cyan + (0x55, 0xFF, 0xFF), # 7 light cyan + (0xAA, 0x00, 0x00), # 8 red + (0xFF, 0x55, 0x55), # 9 light red + (0xAA, 0x00, 0xAA), # 10 purple + (0xFF, 0x55, 0xFF), # 11 light purple + (0xAA, 0x55, 0x00), # 12 brown + (0xFF, 0xFF, 0x55), # 13 light yellow + (0xAA, 0xAA, 0xAA), # 14 light grey + (0xFF, 0xFF, 0xFF), # 15 white +], dtype=np.float64) + + +def get_palette() -> np.ndarray: + return VDC + + +def palette_lab() -> np.ndarray: + return srgb_to_lab(VDC) diff --git a/lenser/c128/viewer/__init__.py b/lenser/c128/viewer/__init__.py new file mode 100644 index 0000000..a55b0f5 --- /dev/null +++ b/lenser/c128/viewer/__init__.py @@ -0,0 +1 @@ +"""C128 8502/VDC viewer (assembled by xa).""" diff --git a/lenser/c128/viewer/assemble.py b/lenser/c128/viewer/assemble.py new file mode 100644 index 0000000..cc20bdc --- /dev/null +++ b/lenser/c128/viewer/assemble.py @@ -0,0 +1,122 @@ +"""Assemble the C128 VDC viewer with `xa` and build the loadable PRG. + +The PRG loads at the C128 BASIC start ($1C01): a tiny BASIC stub (`10 SYS7200`) +followed by the 8502 viewer (at $1C20) and, from $2000, the 640x200 bitmap. +Running it (RUN"PIC") executes the stub, which SYSes the viewer. +""" +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile + +VIEWER_DIR = os.path.dirname(os.path.abspath(__file__)) + +BASIC_START = 0x1C01 +ML_ORG = 0x1C20 +DATA_ORG = 0x2000 # bitmap goes here (viewer copies it to the VDC) + +# BASIC: 10 SYS7200 ($1C20 = 7200) -- bytes as they sit from $1C01 +_STUB = bytes([0x0B, 0x1C, 0x0A, 0x00, 0x9E, + 0x37, 0x32, 0x30, 0x30, 0x00, 0x00, 0x00]) + + +class AssemblerError(RuntimeError): + pass + + +def have_xa() -> bool: + return shutil.which("xa") is not None + + +def _xa(wrapper: str) -> bytes: + """Assemble a generated wrapper (xa runs in VIEWER_DIR so #includes resolve).""" + if not have_xa(): + raise AssemblerError("The 'xa' (xa65) assembler was not found on PATH.\n" + "Install it with: sudo apt install xa65") + 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 _assemble(fgbg: int, source: str) -> bytes: + return _xa(f"#define SRC ${DATA_ORG:04X}\n" + f"#define FGBG ${fgbg & 0xFF:02X}\n" + f'#include "{source}"\n') + + +def _wrap_prg(code: bytes, data: bytes) -> bytes: + """Lay out load-address prefix + BASIC stub + viewer code + data @ $2000.""" + mem = bytearray() + mem += _STUB # $1C01.. + mem += b"\x00" * (ML_ORG - BASIC_START - len(_STUB)) + mem += code # $1C20.. + if len(mem) > DATA_ORG - BASIC_START: + raise AssemblerError("viewer code overruns the $2000 data area") + mem += b"\x00" * (DATA_ORG - BASIC_START - len(mem)) + mem += data # $2000.. + return bytes([BASIC_START & 0xFF, BASIC_START >> 8]) + bytes(mem) + + +def build_prg_color(attributes: bytes, fgbg: int = 0x0F) -> bytes: + """Return the loadable PRG for the 80x100 chunky-colour viewer. + + `attributes` is the 8000-byte per-cell colour map (colour in the high + nibble); the viewer fills the character matrix with a solid glyph itself. + """ + return _wrap_prg(_assemble(fgbg, "color.s"), attributes) + + +def build_prg_hicolor(vdc_image: bytes, fgbg: int = 0x00) -> bytes: + """Return the loadable PRG for the 640x200 custom-charset viewer (font mode). + + Used by both the `hicolor` and `mono` modes. `vdc_image` is the full VDC RAM + image (character codes, attributes and the custom character set already laid + out); `fgbg` carries the global background in its low nibble. The viewer + copies the image verbatim into VDC RAM. + """ + return _wrap_prg(_assemble(fgbg, "hicolor.s"), vdc_image) + + +_SS_WAITMODE = {"key": 1, "seconds": 2, "both": 3} + + +def build_slideshow_prg(fgbg_list, advance: str = "both", seconds: int = 10, + loop: bool = True, video: str = "pal") -> bytes: + """Return the bootable C128 slideshow viewer PRG (RUN\"PIC\"). + + ``fgbg_list`` is one VDC R26 background byte per image (conv.meta["fgbg"]); + the per-image pictures are separate "00".."NN" files the viewer loads. The + viewer is code-only (no appended image) -- just the BASIC stub + the 8502 + loop + the ss_fgbg table. + """ + if not fgbg_list: + raise AssemblerError("a slideshow needs at least one image") + jiffyps = 60 if video == "ntsc" else 50 + table = ",".join(str(int(b) & 0xFF) for b in fgbg_list) + code = _xa( + f"#define WAITMODE {_SS_WAITMODE[advance]}\n" + f"#define WAITSECS {max(0, int(seconds))}\n" + f"#define JIFFYPS {jiffyps}\n" + f"#define NIMAGES {len(fgbg_list)}\n" + f"#define LOOPFLAG {1 if loop else 0}\n" + '#include "slideshow.s"\n' + "ss_fgbg:\n" + f" .byte {table}\n") + mem = bytearray() + mem += _STUB # $1C01 BASIC stub (10 SYS7200) + mem += b"\x00" * (ML_ORG - BASIC_START - len(_STUB)) + mem += code # $1C20 viewer + ss_fgbg table + return bytes([BASIC_START & 0xFF, BASIC_START >> 8]) + bytes(mem) diff --git a/lenser/c128/viewer/color.s b/lenser/c128/viewer/color.s new file mode 100644 index 0000000..13a00b9 --- /dev/null +++ b/lenser/c128/viewer/color.s @@ -0,0 +1,173 @@ +; Commodore 128 VDC (8563) 80x100 chunky-colour image viewer. +; +; MAME's 8563 renders the bitmap through the character/font path- each display +; byte is a CHARACTER CODE whose glyph is drawn with fg = attribute high nibble, +; bg = attribute low nibble. So a per-cell SOLID colour image is produced by +; filling the whole character matrix with $FF (a solid glyph) and giving every +; cell its colour in the high nibble of its attribute byte. +; +; R9=1 (2 scan lines per char row) makes 80x100 cells- 8000 char bytes + 8000 +; attribute bytes = 16000, which fits the stock 16K VDC RAM. +; +; #defines from viewer/assemble.py -- +; SRC main-RAM address of the 8000 attribute bytes ($2000) +; FGBG VDC register 26 default value (unused cells / background) + + * = $1C20 + +src = $fb +adlo = $fd +adhi = $fe +cntl = $02 +cnth = $03 +fill = $04 + +start: + sei + lda #$0e + sta $ff00 ; bank in RAM $4000-$BFFF, keep I/O for the VDC + lda #nmi + sta $0319 ; neutralise NMI (not masked by SEI) + + jsr setregs + + ; fill the character matrix VDC $0000 with $FF (solid glyph), 8000 bytes + lda #$00 + sta adlo + sta adhi + lda #$ff + sta fill + lda #<8000 + sta cntl + lda #>8000 + sta cnth + jsr fillblk + + ; copy 8000 attribute bytes main SRC -> VDC $2000 + lda #SRC + sta src+1 + lda #$00 + sta adlo + lda #$20 + sta adhi + lda #<8000 + sta cntl + lda #>8000 + sta cnth + jsr copyblk + + ; enable bitmap + attributes (display the picture) + ldx #25 + lda #$c0 + jsr vdcw + +loop: + jmp loop + +nmi: + rti + +; program the VDC display geometry + attribute base (everything except R25) +setregs: + ldx #9 + lda #$01 + jsr vdcw ; R9 = 1 -> 2 scan lines per char row (80x100) + ldx #4 + lda #131 + jsr vdcw ; R4 vertical total -> (131+1)*2 = 264 lines + ldx #5 + lda #$00 + jsr vdcw ; R5 vertical total adjust + ldx #6 + lda #100 + jsr vdcw ; R6 vertical displayed = 100 rows (*2 = 200) + ldx #7 + lda #116 + jsr vdcw ; R7 vsync position + ldx #20 + lda #$20 + jsr vdcw ; R20 attribute base high = $2000 + ldx #21 + lda #$00 + jsr vdcw ; R21 attribute base low + ldx #26 + lda #FGBG + jsr vdcw ; R26 default fg/bg + ldx #10 + lda #$20 + jsr vdcw ; R10 cursor off (bit5) - hide the blinking cursor + ldx #12 + lda #$00 + jsr vdcw ; R12 display start high + ldx #13 + lda #$00 + jsr vdcw ; R13 display start low + rts + +; fill cnt bytes = (fill) into VDC RAM from adhi/adlo (explicit address per byte) +fillblk: + ldx #18 + lda adhi + jsr vdcw + ldx #19 + lda adlo + jsr vdcw + ldx #31 + lda fill + jsr vdcw + inc adlo + bne fb1 + inc adhi +fb1: + lda cntl + bne fb2 + dec cnth +fb2: + dec cntl + lda cntl + ora cnth + bne fillblk + rts + +; copy cnt bytes from (src) into VDC RAM from adhi/adlo (explicit address per byte) +copyblk: + ldx #18 + lda adhi + jsr vdcw + ldx #19 + lda adlo + jsr vdcw + ldx #31 + ldy #$00 + lda (src),y + jsr vdcw + inc src + bne cb1 + inc src+1 +cb1: + inc adlo + bne cb2 + inc adhi +cb2: + lda cntl + bne cb3 + dec cnth +cb3: + dec cntl + lda cntl + ora cnth + bne copyblk + rts + +; write A to VDC register X +vdcw: + stx $d600 +vw: + bit $d600 + bpl vw + sta $d601 + rts diff --git a/lenser/c128/viewer/hicolor.s b/lenser/c128/viewer/hicolor.s new file mode 100644 index 0000000..2343ccd --- /dev/null +++ b/lenser/c128/viewer/hicolor.s @@ -0,0 +1,92 @@ +; Commodore 128 VDC (8563) 640x200 high-colour viewer (custom character set). +; +; Uses the VDC's normal 80-column CHARACTER mode (R25 left at the C128 default: +; font + attribute, bit7=0) with a per-image custom font. Each 8x8 cell draws +; its glyph in a per-cell INK colour (attribute low nibble) over a single GLOBAL +; background (VDC register 26 low nibble) -> 640x200 detail with colour. +; +; Python lays out the whole VDC RAM image in main RAM from $2000 (codes @ $0000, +; attributes @ $0800, character set bank 0 @ $2000, bank 1 @ $3000); this copies +; the full 16K verbatim into the VDC's own RAM with an explicit address per byte. +; +; #defines from viewer/assemble.py -- +; SRC main-RAM address of the VDC image ($2000) +; FGBG VDC register 26 value (global background in the low nibble) + + * = $1C20 +src = $fb +adlo = $fd +adhi = $fe +cntl = $02 +cnth = $03 + +start: + sei + lda #$0e + sta $ff00 ; bank in RAM $4000-$BFFF, keep I/O for the VDC + lda #nmi + sta $0319 ; neutralise NMI (not masked by SEI) + + lda # VDC $0000 + sta src + lda #>SRC + sta src+1 + lda #$00 + sta adlo + sta adhi + lda #$00 + sta cntl + lda #$40 + sta cnth + jsr copyblk + + ldx #26 + lda #FGBG + jsr vdcw ; R26 global background (low nibble) + ldx #10 + lda #$20 + jsr vdcw ; cursor off + ; R25 deliberately untouched (C128 default = font + attribute mode) +loop: + jmp loop +nmi: + rti + +copyblk: + ldx #18 + lda adhi + jsr vdcw + ldx #19 + lda adlo + jsr vdcw + ldx #31 + ldy #$00 + lda (src),y + jsr vdcw + inc src + bne cb1 + inc src+1 +cb1: + inc adlo + bne cb2 + inc adhi +cb2: + lda cntl + bne cb3 + dec cnth +cb3: + dec cntl + lda cntl + ora cnth + bne copyblk + rts + +vdcw: + stx $d600 +vw: + bit $d600 + bpl vw + sta $d601 + rts diff --git a/lenser/c128/viewer/slideshow.s b/lenser/c128/viewer/slideshow.s new file mode 100644 index 0000000..c80c164 --- /dev/null +++ b/lenser/c128/viewer/slideshow.s @@ -0,0 +1,211 @@ +; Commodore 128 VDC (8563) slideshow viewer -- 640x200 high-colour mode. +; +; Steps through NIMAGES VDC images named "00".."NN" on the disk, each a 16K VDC +; RAM image (codes/attributes/font, the same body the single hicolor/mono viewer +; copies). For each slide it KERNAL-loads the file into RAM bank 0 at $4000, +; copies the 16K verbatim into the VDC's own RAM, sets the global background +; (R26) from the per-image ss_fgbg table, then waits (key / seconds / both) +; before advancing. Boots via RUN"PIC" -> the BASIC stub SYSes here. +; +; #defines from the build wrapper -- +; WAITMODE 1 key / 2 seconds / 3 both WAITSECS timeout seconds +; JIFFYPS 50 PAL / 60 NTSC NIMAGES image count +; LOOPFLAG 1 wrap at end, 0 stop +; ss_fgbg (one byte per image, VDC R26 background) is appended by the wrapper. + + * = $1C20 + +src = $fb +adlo = $fd +adhi = $fe +cntl = $02 +cnth = $03 + +start: + lda #$0e + sta $ff00 ; KERNAL + I/O + RAM $4000-$BFFF all visible + lda #$00 + sta $9d ; suppress KERNAL LOAD messages + sta ssidx + +mainloop: + jsr namebuild + + ; ---- C128 LOAD "NN",8,1 into RAM bank 0 at $4000 ---- + lda #$00 + ldx #$00 + jsr $ff68 ; SETBNK (load bank 0, name bank 0) + lda #2 + ldx #ssname + jsr $ffbd ; SETNAM + lda #1 + ldx #8 + ldy #1 + jsr $ffba ; SETLFS (secondary 1 = file's own address) + lda #0 + jsr $ffd5 ; LOAD + + ; ---- copy 16384 bytes from $4000 -> VDC $0000 ---- + lda #$00 + sta src + lda #$40 + sta src+1 + lda #$00 + sta adlo + sta adhi + sta cntl + lda #$40 + sta cnth + jsr copyblk + + ; ---- per-image global background (R26) + cursor off (R10) ---- + ldx ssidx + lda ss_fgbg,x + ldx #26 + jsr vdcw + ldx #10 + lda #$20 + jsr vdcw + + ; ---- wait for the slide's dwell ---- + jsr ss_wait + + ; ---- advance ---- + inc ssidx + lda ssidx + cmp #NIMAGES + bcc mainloop +#if LOOPFLAG == 1 + lda #$00 + sta ssidx + jmp mainloop +#else + rts +#endif + +; --------------------------------------------------------------------------- ; +; wait -- key / seconds / both, KERNAL GETIN + jiffy clock ($a0-$a2, $a2 = LSB) +; (reuses $fb-$fe as 16-bit scratch now the copy is done) +cv_t0 = $fb +cv_el = $fd + +ss_wait: +#if WAITMODE == 1 +ss_drain: + jsr $ffe4 + bne ss_drain ; flush leftover keys +ss_k: + jsr $ffe4 + beq ss_k + rts +#endif +#if WAITMODE == 2 + lda $a2 + sta cv_t0 + lda $a1 + sta cv_t0+1 +ss_s: + sec + lda $a2 + sbc cv_t0 + sta cv_el + lda $a1 + sbc cv_t0+1 + sta cv_el+1 + lda cv_el+1 + cmp #>(WAITSECS*JIFFYPS) + bcc ss_s + bne ss_sd + lda cv_el + cmp #<(WAITSECS*JIFFYPS) + bcc ss_s +ss_sd: + rts +#endif +#if WAITMODE == 3 +ss_drain: + jsr $ffe4 + bne ss_drain + lda $a2 + sta cv_t0 + lda $a1 + sta cv_t0+1 +ss_b: + jsr $ffe4 + bne ss_bd ; any key ends the slide + sec + lda $a2 + sbc cv_t0 + sta cv_el + lda $a1 + sbc cv_t0+1 + sta cv_el+1 + lda cv_el+1 + cmp #>(WAITSECS*JIFFYPS) + bcc ss_b + bne ss_bd + lda cv_el + cmp #<(WAITSECS*JIFFYPS) + bcc ss_b +ss_bd: + rts +#endif + +; build the 2-char filename "NN" (PETSCII) from ssidx (0..99) +namebuild: + lda ssidx + ldx #$2f + sec +nb_ten: + inx + sbc #10 + bcs nb_ten + adc #10 + ora #$30 + sta ssname+1 + txa + sta ssname + rts + +; copy cnth/cntl bytes from (src) to VDC RAM starting at adhi/adlo +copyblk: + ldx #18 + lda adhi + jsr vdcw + ldx #19 + lda adlo + jsr vdcw + ldx #31 + ldy #$00 + lda (src),y + jsr vdcw + inc src + bne cb1 + inc src+1 +cb1: + inc adlo + bne cb2 + inc adhi +cb2: + lda cntl + bne cb3 + dec cnth +cb3: + dec cntl + lda cntl + ora cnth + bne copyblk + rts + +vdcw: + stx $d600 +vw: + bit $d600 + bpl vw + sta $d601 + rts + +ssidx: .byte 0 +ssname: .byte $30,$30 +; ss_fgbg table (one byte per image) appended by the build wrapper diff --git a/lenser/c16/__init__.py b/lenser/c16/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lenser/c16/convert/__init__.py b/lenser/c16/convert/__init__.py new file mode 100644 index 0000000..3a75986 --- /dev/null +++ b/lenser/c16/convert/__init__.py @@ -0,0 +1,19 @@ +"""Commodore 16 (TED) conversion dispatch.""" +from __future__ import annotations + +from ... import imageprep +from . import hires, mono + +_MODULES = {"hires": hires, "mono": mono} +MODES = list(_MODULES.keys()) + + +def convert_image(path_or_img, mode="hires", palette_name="ted", + dither_mode="floyd", intensive=False, prep_opt=None, + base_color=None): + prep_opt = prep_opt or imageprep.PrepOptions() + module = _MODULES.get(mode, hires) + 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) diff --git a/lenser/c16/convert/hires.py b/lenser/c16/convert/hires.py new file mode 100644 index 0000000..47535d4 --- /dev/null +++ b/lenser/c16/convert/hires.py @@ -0,0 +1,93 @@ +"""C16 TED hires bitmap mode: 320x200, two colours per 8x8 cell. + +Unlike the VIC-II, each of the two per-cell colours may be any of the TED's 128 +colours. The colours are stored across two 1K matrices (see MAME's mos7360 +draw_bitmap): + ch byte (video base + $400): high nibble = fg hue, low nibble = bg hue + attr byte (video base): bits 0-2 = fg luminance, bits 4-6 = bg lum +A bitmap bit of 1 selects the foreground colour, 0 the background. +""" +from __future__ import annotations + +import numpy as np + +from ... import dither, palette as c64pal +from ...convert import base +from .. import palette as tedpal + +WIDTH, HEIGHT = 320, 200 +CELL_W, CELL_H = 8, 8 +PIXEL_ASPECT = 1.0 + + +def convert(img_rgb, palette_name="ted", dither_mode="floyd", + intensive=False, base_color=None): + plab = tedpal.palette_lab() + img_lab = c64pal.srgb_to_lab(img_rgb) + + cells, rows, cols = base.cells_lab(img_lab, CELL_W, CELL_H) + dist = base.cell_distance(cells, plab) + sets = _select_pairs(dist) + + allowed = base.per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH) + index_image = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint16) + + bitmap, attr, ch = _encode(index_image, sets, rows, cols) + payload = bytes(bitmap) + bytes(attr) + bytes(ch) + + prgb = tedpal.get_palette().astype(np.uint8) + return base.Conversion( + mode="hires", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=index_image, data=payload, viewer="c16", + preview_rgb=prgb[index_image], + error=base.perceptual_error(index_image, img_lab, plab), + meta={"palette": "ted", "dither": dither_mode, "border": 0}, + ) + + +def _select_pairs(dist, k=16): + """Per-cell best 2 colours from the 128-colour TED palette. + + A full C(128,2) search is 8128 combos/cell; instead we restrict each cell to + its ``k`` best single colours (the optimal pair is almost always among them) + and search only C(k,2) pairs -- ~100x faster with effectively identical + results. dist: (n_cells, P, 128) squared CIELAB distances. + """ + n_cells = dist.shape[0] + single = dist.sum(1) # (n_cells, 128) + cand = np.argsort(single, axis=1)[:, :k] # (n_cells, k) + dist_c = np.take_along_axis(dist, cand[:, None, :], axis=2) # (n_cells, P, k) + best_err = np.full(n_cells, np.inf) + best = np.zeros((n_cells, 2), dtype=np.int64) + for i in range(k): + for j in range(i + 1, k): + m = np.minimum(dist_c[:, :, i], dist_c[:, :, j]).sum(1) + upd = m < best_err + best_err[upd] = m[upd] + best[upd, 0] = cand[upd, i] + best[upd, 1] = cand[upd, j] + return best + + +def _encode(index_image, sets, rows, cols): + """Return (bitmap 8000, attr 1000, ch 1000) for the TED hires layout.""" + bitmap = np.zeros(8000, dtype=np.uint8) + attr = np.zeros(1000, dtype=np.uint8) + ch = np.zeros(1000, dtype=np.uint8) + for cr in range(rows): + for cc in range(cols): + ci = cr * cols + cc + bg, fg = int(sets[ci, 0]), int(sets[ci, 1]) + fg_hue, fg_lum = fg & 0x0F, (fg >> 4) & 0x07 + bg_hue, bg_lum = bg & 0x0F, (bg >> 4) & 0x07 + ch[ci] = (fg_hue << 4) | bg_hue + attr[ci] = (bg_lum << 4) | fg_lum + base_addr = cr * 320 + cc * 8 + block = index_image[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8] + for r in range(8): + row = block[r] + byte = 0 + for x in range(8): + byte = (byte << 1) | (1 if int(row[x]) == fg else 0) + bitmap[base_addr + r] = byte + return bitmap, attr, ch diff --git a/lenser/c16/convert/mono.py b/lenser/c16/convert/mono.py new file mode 100644 index 0000000..1d79012 --- /dev/null +++ b/lenser/c16/convert/mono.py @@ -0,0 +1,35 @@ +"""C16 TED monochrome: 320x200, restricted to the TED's neutral grey ramp (8 +luminance levels of hue 1, plus black) for smooth greyscale. ``--mono-base`` +tints it toward one hue (black -> hue -> white). Two greys per 8x8 cell, packed +into the same TED hires layout as the colour mode.""" +from __future__ import annotations + +import numpy as np + +from ...convert import base +from .. import palette as tedpal +from . import hires + +WIDTH, HEIGHT = hires.WIDTH, hires.HEIGHT +CELL_W, CELL_H = hires.CELL_W, hires.CELL_H +PIXEL_ASPECT = hires.PIXEL_ASPECT + + +def convert(img_rgb, palette_name="ted", dither_mode="floyd", + intensive=False, base_color=None): + plab = tedpal.palette_lab() + prgb = tedpal.get_palette().astype(np.uint8) + + ramp = base.luminance_ramp(plab, tedpal.GREYS, base_color) + idx, sets, rows, cols, err = base.mono_render( + img_rgb, plab, ramp, WIDTH, HEIGHT, CELL_W, CELL_H, dither_mode, n_free=2) + + bitmap, attr, ch = hires._encode(idx, sets, rows, cols) + payload = bytes(bitmap) + bytes(attr) + bytes(ch) + + return base.Conversion( + mode="mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=idx.astype(np.uint16), data=payload, viewer="c16", + preview_rgb=prgb[idx], error=err, + meta={"palette": "ted", "dither": dither_mode, "border": 0}, + ) diff --git a/lenser/c16/exporter.py b/lenser/c16/exporter.py new file mode 100644 index 0000000..d73bf30 --- /dev/null +++ b/lenser/c16/exporter.py @@ -0,0 +1,16 @@ +"""Build a Commodore 16 .prg (loaded + run via MAME quickload / on real HW).""" +from __future__ import annotations + +from .viewer import assemble + +_EXTS = (".prg", ".p00") + + +def export_prg(conv, output_path, source_path=None, display="forever", + seconds=0, video="ntsc"): + if not output_path.lower().endswith(_EXTS): + output_path += ".prg" + prg = assemble.build_prg(bytes(conv.data)) + with open(output_path, "wb") as f: + f.write(prg) + return output_path diff --git a/lenser/c16/palette.py b/lenser/c16/palette.py new file mode 100644 index 0000000..378b142 --- /dev/null +++ b/lenser/c16/palette.py @@ -0,0 +1,62 @@ +"""Commodore 16 / Plus4 TED (7360/8360) colour palette. + +The TED produces 128 colours arranged as 8 luminance levels x 16 hues. A colour +value is 7-bit: bits 0-3 = hue (0-15), bits 4-6 = luminance (0-7), so the palette +index is ``(luminance << 4) | hue``. Hue 0 is (near) black at every luminance and +hue 1 is the neutral grey ramp, so true greys are available too. + +RGB values are taken verbatim from MAME's ``mos7360`` PALETTE_MOS table so the +encoder matches what the emulator renders. +""" +from __future__ import annotations + +import numpy as np + +from ..palette import srgb_to_lab + +# 128 entries in (luminance<<4)|hue order, copied from MAME's mos7360.cpp. +TED = np.array([ + (0x06, 0x01, 0x03), (0x2b, 0x2b, 0x2b), (0x67, 0x0e, 0x0f), (0x00, 0x3f, 0x42), + (0x57, 0x00, 0x6d), (0x00, 0x4e, 0x00), (0x19, 0x1c, 0x94), (0x38, 0x38, 0x00), + (0x56, 0x20, 0x00), (0x4b, 0x28, 0x00), (0x16, 0x48, 0x00), (0x69, 0x07, 0x2f), + (0x00, 0x46, 0x26), (0x06, 0x2a, 0x80), (0x2a, 0x14, 0x9b), (0x0b, 0x49, 0x00), + (0x00, 0x03, 0x02), (0x3d, 0x3d, 0x3d), (0x75, 0x1e, 0x20), (0x00, 0x50, 0x4f), + (0x6a, 0x10, 0x78), (0x04, 0x5c, 0x00), (0x2a, 0x2a, 0xa3), (0x4c, 0x47, 0x00), + (0x69, 0x2f, 0x00), (0x59, 0x38, 0x00), (0x26, 0x56, 0x00), (0x75, 0x15, 0x41), + (0x00, 0x58, 0x3d), (0x15, 0x3d, 0x8f), (0x39, 0x22, 0xae), (0x19, 0x59, 0x00), + (0x00, 0x03, 0x04), (0x42, 0x42, 0x42), (0x7b, 0x28, 0x20), (0x02, 0x56, 0x59), + (0x6f, 0x1a, 0x82), (0x0a, 0x65, 0x09), (0x30, 0x34, 0xa7), (0x50, 0x51, 0x00), + (0x6e, 0x36, 0x00), (0x65, 0x40, 0x00), (0x2c, 0x5c, 0x00), (0x7d, 0x1e, 0x45), + (0x01, 0x61, 0x45), (0x1c, 0x45, 0x99), (0x42, 0x2d, 0xad), (0x1d, 0x62, 0x00), + (0x05, 0x00, 0x02), (0x56, 0x55, 0x5a), (0x90, 0x3c, 0x3b), (0x17, 0x6d, 0x72), + (0x87, 0x2d, 0x99), (0x1f, 0x7b, 0x15), (0x46, 0x49, 0xc1), (0x66, 0x63, 0x00), + (0x84, 0x4c, 0x0d), (0x73, 0x55, 0x00), (0x40, 0x72, 0x00), (0x91, 0x33, 0x5e), + (0x19, 0x74, 0x5c), (0x32, 0x59, 0xae), (0x59, 0x3f, 0xc3), (0x32, 0x76, 0x00), + (0x02, 0x01, 0x06), (0x84, 0x7e, 0x85), (0xbb, 0x67, 0x68), (0x45, 0x96, 0x96), + (0xaf, 0x58, 0xc3), (0x4a, 0xa7, 0x3e), (0x73, 0x73, 0xec), (0x92, 0x8d, 0x11), + (0xaf, 0x78, 0x32), (0xa1, 0x80, 0x20), (0x6c, 0x9e, 0x12), (0xba, 0x5f, 0x89), + (0x46, 0x9f, 0x83), (0x61, 0x85, 0xdd), (0x84, 0x6c, 0xef), (0x5d, 0xa3, 0x29), + (0x02, 0x00, 0x0a), (0xb2, 0xac, 0xb3), (0xe9, 0x92, 0x92), (0x6c, 0xc3, 0xc1), + (0xd9, 0x86, 0xf0), (0x79, 0xd1, 0x76), (0x9d, 0xa1, 0xff), (0xbd, 0xbe, 0x40), + (0xdc, 0xa2, 0x61), (0xd1, 0xa9, 0x4c), (0x93, 0xc8, 0x3d), (0xe9, 0x8a, 0xb1), + (0x6f, 0xcd, 0xab), (0x8a, 0xb4, 0xff), (0xb2, 0x9a, 0xff), (0x88, 0xcb, 0x59), + (0x02, 0x00, 0x0a), (0xc7, 0xca, 0xc9), (0xff, 0xac, 0xac), (0x85, 0xd8, 0xe0), + (0xf3, 0x9c, 0xff), (0x92, 0xea, 0x8a), (0xb7, 0xba, 0xff), (0xd6, 0xd3, 0x5b), + (0xf3, 0xbe, 0x79), (0xe6, 0xc5, 0x65), (0xb0, 0xe0, 0x57), (0xff, 0xa4, 0xcf), + (0x89, 0xe5, 0xc8), (0xa4, 0xca, 0xff), (0xca, 0xb3, 0xff), (0xa2, 0xe5, 0x7a), + (0x01, 0x01, 0x01), (0xff, 0xff, 0xff), (0xff, 0xf6, 0xf2), (0xd1, 0xff, 0xff), + (0xff, 0xe9, 0xff), (0xdb, 0xff, 0xd3), (0xfd, 0xff, 0xff), (0xff, 0xff, 0xa3), + (0xff, 0xff, 0xc1), (0xff, 0xff, 0xb2), (0xfc, 0xff, 0xa2), (0xff, 0xee, 0xff), + (0xd1, 0xff, 0xff), (0xeb, 0xff, 0xff), (0xff, 0xf8, 0xff), (0xed, 0xff, 0xbc), +], dtype=np.float64) + +# Neutral grey ramp (hue 1) plus black -- used by the monochrome mode. +GREYS = [0, 0x11, 0x21, 0x31, 0x41, 0x51, 0x61, 0x71] # (lum<<4)|1, lum 0..7 + + +def get_palette() -> np.ndarray: + return TED + + +def palette_lab() -> np.ndarray: + return srgb_to_lab(TED) diff --git a/lenser/c16/viewer/__init__.py b/lenser/c16/viewer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lenser/c16/viewer/assemble.py b/lenser/c16/viewer/assemble.py new file mode 100644 index 0000000..e3b80ff --- /dev/null +++ b/lenser/c16/viewer/assemble.py @@ -0,0 +1,62 @@ +"""Assemble the C16 TED viewer with `xa` and build the loadable PRG. + +The PRG loads at the C16 BASIC start ($1001): a BASIC stub (`10 SYS4128`) +followed by the 7501 viewer (at $1020), the two colour matrices ($1800 / $1C00) +and the bitmap ($2000). MAME's quickload runs it, the stub SYSes the viewer. +""" +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile + +VIEWER_DIR = os.path.dirname(os.path.abspath(__file__)) + +BASIC_START = 0x1001 +ML_ORG = 0x1020 +ATTR_ORG = 0x1800 # luminance matrix (video matrix base) +CH_ORG = 0x1C00 # hue matrix (video base | $400) +BITMAP_ORG = 0x2000 + +# BASIC: 10 SYS4128 ($1020 = 4128) -- bytes as they sit from $1001 +_STUB = bytes([0x0B, 0x10, 0x0A, 0x00, 0x9E, + 0x34, 0x31, 0x32, 0x38, 0x00, 0x00, 0x00]) + + +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") + proc = subprocess.run(["xa", "-o", "/dev/stdout", "viewer.s"], + capture_output=True, cwd=VIEWER_DIR) + if proc.returncode != 0: + raise AssemblerError(f"xa failed:\n{proc.stderr.decode()}") + return proc.stdout + + +def build_prg(payload: bytes) -> bytes: + """payload = bitmap(8000) + attr(1000) + ch(1000); returns the loadable PRG.""" + bitmap, attr, ch = payload[:8000], payload[8000:9000], payload[9000:10000] + code = _assemble() + mem = bytearray() + mem += _STUB # $1001.. + mem += b"\x00" * (ML_ORG - BASIC_START - len(mem)) + mem += code # $1020.. + if len(mem) > ATTR_ORG - BASIC_START: + raise AssemblerError("viewer code overruns the $1800 matrix area") + mem += b"\x00" * (ATTR_ORG - BASIC_START - len(mem)) + mem += attr # $1800.. + mem += b"\x00" * (CH_ORG - BASIC_START - len(mem)) + mem += ch # $1C00.. + mem += b"\x00" * (BITMAP_ORG - BASIC_START - len(mem)) + mem += bitmap # $2000.. + return bytes([BASIC_START & 0xFF, BASIC_START >> 8]) + bytes(mem) diff --git a/lenser/c16/viewer/viewer.s b/lenser/c16/viewer/viewer.s new file mode 100644 index 0000000..92cc4d1 --- /dev/null +++ b/lenser/c16/viewer/viewer.s @@ -0,0 +1,26 @@ +; Commodore 16 (TED 7360/8360) hires bitmap viewer. +; +; The whole picture is already laid out in RAM by the PRG (loaded via quickload): +; $1800 attribute matrix (luminance) 1000 bytes +; $1C00 colour matrix (hue) 1000 bytes +; $2000 bitmap 8000 bytes +; This just programs the TED for 320x200 hires bitmap mode and holds the display. + + * = $1020 + +start: + sei + lda #$08 + sta $ff12 ; bitmap base = $2000 + lda #$18 + sta $ff14 ; video matrix base = $1800 (hue matrix at $1C00) + lda #$00 + sta $ff15 ; background colour (per-cell in hires; black) + lda #$00 + sta $ff19 ; border / frame colour = black + lda #$08 + sta $ff07 ; MCM off (hires), 40 columns + lda #$3b + sta $ff06 ; bitmap mode on, display on, 25 rows (set last) +loop: + jmp loop diff --git a/lenser/cli.py b/lenser/cli.py new file mode 100644 index 0000000..eef4b76 --- /dev/null +++ b/lenser/cli.py @@ -0,0 +1,138 @@ +"""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="8bitlenser", 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/.atr)") + p.add_argument("--platform", default="c64", + choices=["c64", "atari", "apple", "ti99", "coco", "bbc", + "coleco", "a2600", "intv", "vic20", "spectrum", "a5200", + "a7800", "c128", "c16", "plus4", "cpc", "coco3", + "nes", "iigs", "pet2001", "pet4032", "pet8032", + "superpet", "sms", "amiga", "ansi"], + help="target machine (coleco = ColecoVision/Adam, " + "a2600 = Atari 2600/VCS, intv = Mattel Intellivision, " + "vic20 = Commodore VIC-20, spectrum = Sinclair ZX Spectrum, " + "a5200 = Atari 5200, a7800 = Atari 7800, " + "c128 = Commodore 128 VDC 80-column, " + "c16 = Commodore 16 TED hires, " + "plus4 = Commodore Plus/4 TED hires, " + "cpc = Amstrad CPC, " + "coco3 = Tandy CoCo 3 GIME, " + "nes = Nintendo NES/Famicom, " + "iigs = Apple IIGS Super Hi-Res, " + "pet2001/pet4032 = 40-col PET/CBM, " + "pet8032/superpet = 80-col PET/CBM, " + "sms = Sega Master System, " + "amiga = Commodore Amiga)") + p.add_argument("-m", "--mode", default="auto", + choices=["auto", *MODES, "gr15", "gr9", "gr8", "gr15dli", + "hgr_mono", "dhgr", "hgr_color", "gm2", + "pmode4", "pmode3", + "mode0", "mode1", "mode2", "mode5", "stic", "pf_il", + "c160", "color", "hicolor", "gr16", "gr4", "bg", + "shr", "lowres", "80x25", "80x50"], + help="display mode (c64: hires/multicolor/fli/interlace/mono; " + "atari: gr15/gr9/gr8/gr15dli; apple: hgr_mono/dhgr; " + "ti99: gm2; coco: pmode4/pmode3; bbc: mode0/1/2/5; " + "intv: stic; ansi: 80x25/80x50)") + p.add_argument("-f", "--format", default=None, + choices=["d64", "d71", "d81", "crt", "atr", "car", "ccc", + "ssd", "col", "a26", "int", "a0", "sna", "a52", "a78", + "prg", "nes", "sms", "adf", "ans"], + help="output format: c64 disk d64/d71/d81 or cartridge crt; " + "atari disk atr or cartridge car (default: from -o ext)") + p.add_argument("-p", "--palette", default="colodore", + choices=["colodore", "pepto"]) + p.add_argument("-d", "--dither", default=None, + choices=["bayer", "bluenoise", "yliluoma", "floyd", + "atkinson", "stucki", "jarvis", "sierra", + "sierra_lite", "burkes", "riemersma", + "ostromoukhov", "none"], + help="dithering (default: atkinson; none for intv -- " + "per-platform defaults that suit each format)") + 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("--tint", type=float, default=0.0, + help="hue rotation in degrees (-180..180)") + p.add_argument("--red", type=float, default=1.0, help="red level (gain)") + p.add_argument("--green", type=float, default=1.0, help="green level (gain)") + p.add_argument("--blue", type=float, default=1.0, help="blue level (gain)") + 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)") + p.add_argument("--display", default="key", + choices=["forever", "key", "seconds"], + help="how long the viewer holds the picture (C64): forever, " + "until a key, or --seconds N then exit to BASIC") + p.add_argument("--seconds", type=int, default=10, + help="seconds to display when --display seconds") + p.add_argument("--viewer", default="unified", + choices=["unified", "separate"], + help="unified = one self-contained file; separate = viewer " + "binary + a standalone 'data' image file (C64 disk)") + 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, + tint=args.tint, red=args.red, green=args.green, blue=args.blue, + ) + from . import platforms + # Atkinson is the default dither (only used when -d is not given): its lighter + # error diffusion bleeds less across constrained colour cells and keeps clean + # local contrast. The one exception is the Intellivision, whose 64-tile GRAM + # dictionary bloats under any diffusion, so it defaults to no dithering. + _def_dither = {"intv": "none"} + dither = args.dither or _def_dither.get(args.platform, "atkinson") + conv = platforms.convert(args.platform, args.image, args.mode, args.palette, + dither, args.intensive, prep, args.mono_base) + _dsz = len(conv.data) if isinstance(conv.data, (bytes, bytearray)) else None + print(f"platform={args.platform} mode={conv.mode} mean dE={conv.error:.2f}" + + (f" data={_dsz}B" if _dsz is not None else "")) + + 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: + path = platforms.export(args.platform, conv, args.output, args.format, + args.image, args.video, display=args.display, + seconds=args.seconds, layout=args.viewer) + kind = "ANSI art" if args.platform == "ansi" else "disk image" + print(f"wrote {kind} {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()) diff --git a/lenser/coco/__init__.py b/lenser/coco/__init__.py new file mode 100644 index 0000000..dcba189 --- /dev/null +++ b/lenser/coco/__init__.py @@ -0,0 +1 @@ +"""TRS-80 Color Computer (MC6847 VDG) image conversion and cartridge export.""" diff --git a/lenser/coco/cartridge.py b/lenser/coco/cartridge.py new file mode 100644 index 0000000..d7f5ea1 --- /dev/null +++ b/lenser/coco/cartridge.py @@ -0,0 +1,31 @@ +"""Build a TRS-80 CoCo Program Pak cartridge ROM (.ccc) holding viewer + image. + +The CoCo autostarts the cartridge at $C000. Layout: viewer code, then the +6144-byte PMODE 4 image, padded to an 8KB ROM ($C000-$DFFF).""" + +from __future__ import annotations + +from . import viewer + +CART_BASE = 0xC000 +CART_SIZE = 0x2000 # 8 KB + + +def build_rom(data: bytes, vdg: int = 0xF8, display: str = "forever", + seconds: int = 0) -> bytes: + if len(data) != viewer.SCREEN_BYTES: + raise ValueError(f"unexpected image length {len(data)}") + vk = dict(vdg=vdg, display=display, seconds=seconds) + code = viewer.build(0, **vk) # pass 1: measure code length + data_src = CART_BASE + len(code) + code = viewer.build(data_src, **vk) # pass 2: real data address + rom = code + bytes(data) + if len(rom) > CART_SIZE: + raise ValueError("viewer + image exceed the 8KB cartridge") + return rom + bytes(CART_SIZE - len(rom)) + + +def write_ccc(rom: bytes, path: str) -> str: + with open(path, "wb") as f: + f.write(rom) + return path diff --git a/lenser/coco/convert/__init__.py b/lenser/coco/convert/__init__.py new file mode 100644 index 0000000..e8c3225 --- /dev/null +++ b/lenser/coco/convert/__init__.py @@ -0,0 +1,16 @@ +"""TRS-80 Color Computer conversion dispatch.""" +from __future__ import annotations +from ... import imageprep +from . import pmode3, pmode4, mono + +_MODULES = {"pmode4": pmode4, "pmode3": pmode3, "mono": mono} +MODES = list(_MODULES.keys()) + + +def convert_image(path_or_img, mode="pmode4", palette_name="mc6847", + 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) diff --git a/lenser/coco/convert/mono.py b/lenser/coco/convert/mono.py new file mode 100644 index 0000000..c186c01 --- /dev/null +++ b/lenser/coco/convert/mono.py @@ -0,0 +1,15 @@ +"""CoCo monochrome -- PMODE 4's 256x192 black & white, exposed as the standard +``mono`` mode for cross-platform parity (tone carried by dithering).""" +from __future__ import annotations + +from . import pmode4 + +WIDTH, HEIGHT, PIXEL_ASPECT = pmode4.WIDTH, pmode4.HEIGHT, pmode4.PIXEL_ASPECT + + +def convert(img_rgb, palette_name="mc6847", dither_mode="floyd", + intensive=False, base_color=None): + conv = pmode4.convert(img_rgb, palette_name, dither_mode, intensive, + base_color=base_color) + conv.mode = "mono" + return conv diff --git a/lenser/coco/convert/pmode3.py b/lenser/coco/convert/pmode3.py new file mode 100644 index 0000000..8604b9e --- /dev/null +++ b/lenser/coco/convert/pmode3.py @@ -0,0 +1,44 @@ +"""CoCo PMODE 3: 128x192, 4 colours from one MC6847 colour set. + +128 wide on a 4:3 screen -> 2:1 pixels (like C64 multicolor). The 4 colours are +fixed per CSS set (green/yellow/blue/red or buff/cyan/magenta/orange); we dither +to whichever set reproduces the image with lower error and tell the viewer which. +""" + +from __future__ import annotations + +import numpy as np + +from ... import dither +from ...convert.base import Conversion, perceptual_error +from ...palette import srgb_to_lab +from .. import palette as cpal + +WIDTH, HEIGHT = 128, 192 +PIXEL_ASPECT = 2.0 + + +def convert(img_rgb, palette_name="mc6847", dither_mode="floyd", + intensive=False, base_color=None): + img_lab = srgb_to_lab(img_rgb) + allowed = np.tile(np.array([0, 1, 2, 3]), (HEIGHT, WIDTH, 1)) + + best = None + for vdg, rgb in cpal.PMODE3_SETS.items(): + plab = srgb_to_lab(rgb) + idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8) + err = perceptual_error(idx, img_lab, plab) + if best is None or err < best[0]: + best = (err, vdg, rgb, plab, idx) + + err, vdg, rgb, plab, idx = best + data = cpal.pack_pmode3(idx) # 6144-byte video buffer + preview = rgb.astype(np.uint8)[idx] + + return Conversion( + mode="pmode3", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=idx.astype(np.uint16), data=data, data_addr=0, + viewer="pmode3", preview_rgb=preview, + error=err, + meta={"palette": "mc6847", "dither": dither_mode, "vdg": vdg}, + ) diff --git a/lenser/coco/convert/pmode4.py b/lenser/coco/convert/pmode4.py new file mode 100644 index 0000000..bc7a3f0 --- /dev/null +++ b/lenser/coco/convert/pmode4.py @@ -0,0 +1,41 @@ +"""CoCo PMODE 4: 256x192, 1 bit/pixel, black & white (buff on black). + +The CoCo's high-resolution monochrome mode -- 256x192 is exactly 4:3, so square +pixels. Tone is carried by dithering, like Apple HGR mono. +""" + +from __future__ import annotations + +import numpy as np + +from ... import dither +from ...convert.base import Conversion, perceptual_error +from .. import palette as cpal + +WIDTH, HEIGHT = 256, 192 +PIXEL_ASPECT = 1.0 + + +def convert(img_rgb, palette_name="mc6847", dither_mode="floyd", + intensive=False, base_color=None): + from ...palette import srgb_to_lab + plab = cpal.mono_lab() + 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 = cpal.pack_pmode4(idx) # 6144-byte video buffer + preview = cpal.MONO.astype(np.uint8)[idx] + + return Conversion( + mode="pmode4", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=idx.astype(np.uint16), data=data, data_addr=0, + viewer="pmode4", preview_rgb=preview, + error=perceptual_error(idx, img_mono, plab_mono), + meta={"palette": "mc6847", "dither": dither_mode, "vdg": 0xF8}, + ) diff --git a/lenser/coco/exporter.py b/lenser/coco/exporter.py new file mode 100644 index 0000000..4274eb6 --- /dev/null +++ b/lenser/coco/exporter.py @@ -0,0 +1,11 @@ +"""Build a TRS-80 CoCo cartridge (.ccc) from a conversion.""" +from __future__ import annotations +from . import cartridge + + +def export_ccc(conv, output_path, source_path=None, display="forever", seconds=0): + if not output_path.lower().endswith((".ccc", ".rom")): + output_path += ".ccc" + vdg = conv.meta.get("vdg", 0xF8) if conv.meta else 0xF8 + rom = cartridge.build_rom(conv.data, vdg=vdg, display=display, seconds=seconds) + return cartridge.write_ccc(rom, output_path) diff --git a/lenser/coco/mc6809.py b/lenser/coco/mc6809.py new file mode 100644 index 0000000..4ed64b5 --- /dev/null +++ b/lenser/coco/mc6809.py @@ -0,0 +1,99 @@ +"""A tiny Motorola 6809 machine-code emitter (just what the CoCo viewer needs). + +No 6809 assembler is installed, so -- as with the TI's TMS9900 emitter -- we emit +opcodes directly. Supports labels + 8-bit relative-branch backpatching. +Index-register codes for ,R+ postbytes: X=0x80, Y=0xA0, U=0xC0, S=0xE0. +""" + +from __future__ import annotations + +_IDX = {"x": 0x80, "y": 0xA0, "u": 0xC0, "s": 0xE0} + + +class Asm: + def __init__(self, base: int): + self.base = base + self.code = bytearray() + self.labels: dict[str, int] = {} + self._fix: list[tuple[int, str]] = [] # (pos of rel byte, label) + + def pos(self) -> int: + return self.base + len(self.code) + + def label(self, name: str): + self.labels[name] = self.pos() + + def _b(self, *bs): + self.code += bytes(bs) + + def _w(self, v): # 16-bit, big-endian + self.code += bytes([(v >> 8) & 0xFF, v & 0xFF]) + + # ---- inherent ---- + def nop(self): self._b(0x12) + def clra(self): self._b(0x4F) + def clrb(self): self._b(0x5F) + def deca(self): self._b(0x4A) + def decb(self): self._b(0x5A) + def inca(self): self._b(0x4C) + def incb(self): self._b(0x5C) + def rts(self): self._b(0x39) + def sync(self): self._b(0x13) + + # ---- immediate ---- + def lda_imm(self, v): self._b(0x86, v & 0xFF) + def ldb_imm(self, v): self._b(0xC6, v & 0xFF) + def anda_imm(self, v): self._b(0x84, v & 0xFF) + def ora_imm(self, v): self._b(0x8A, v & 0xFF) + def cmpa_imm(self, v): self._b(0x81, v & 0xFF) + def cmpb_imm(self, v): self._b(0xC1, v & 0xFF) + def orcc(self, v): self._b(0x1A, v & 0xFF) + def andcc(self, v): self._b(0x1C, v & 0xFF) + + def ldd_imm(self, v): self._b(0xCC); self._w(v) + def subd_imm(self, v): self._b(0x83); self._w(v) + def ldx_imm(self, v): self._b(0x8E); self._w(v) + def ldu_imm(self, v): self._b(0xCE); self._w(v) + def ldy_imm(self, v): self._b(0x10, 0x8E); self._w(v) + def lds_imm(self, v): self._b(0x10, 0xCE); self._w(v) + def cmpx_imm(self, v): self._b(0x8C); self._w(v) + def cmpu_imm(self, v): self._b(0x11, 0x83); self._w(v) + + # ---- extended (16-bit address) ---- + def lda_ext(self, a): self._b(0xB6); self._w(a) + def sta_ext(self, a): self._b(0xB7); self._w(a) + def ldb_ext(self, a): self._b(0xF6); self._w(a) + def stb_ext(self, a): self._b(0xF7); self._w(a) + def std_ext(self, a): self._b(0xFD); self._w(a) + def jmp_ext(self, a): self._b(0x7E); self._w(a) + def jsr_ext(self, a): self._b(0xBD); self._w(a) + def jmp_ind(self, a): self._b(0x6E, 0x9F); self._w(a) # JMP [addr] (indirect) + + # ---- indexed auto-increment: LDA ,R+ / STA ,R+ ---- + def lda_postinc(self, r): self._b(0xA6, _IDX[r]) + def sta_postinc(self, r): self._b(0xA7, _IDX[r]) + def ldb_postinc(self, r): self._b(0xE6, _IDX[r]) + def stb_postinc(self, r): self._b(0xE7, _IDX[r]) + + # ---- 8-bit relative branches ---- + def _branch(self, op, label): + self._b(op) + self._fix.append((len(self.code), label)) + self._b(0x00) # placeholder + + def bra(self, label): self._branch(0x20, label) + def bne(self, label): self._branch(0x26, label) + def beq(self, label): self._branch(0x27, label) + def bpl(self, label): self._branch(0x2A, label) + def bmi(self, label): self._branch(0x2B, label) + def bcc(self, label): self._branch(0x24, label) + def bcs(self, label): self._branch(0x25, label) + + def resolve(self) -> bytes: + for pos, label in self._fix: + target = self.labels[label] + rel = target - (self.base + pos + 1) + if not -128 <= rel <= 127: + raise ValueError(f"branch to {label} out of range ({rel})") + self.code[pos] = rel & 0xFF + return bytes(self.code) diff --git a/lenser/coco/palette.py b/lenser/coco/palette.py new file mode 100644 index 0000000..c675be3 --- /dev/null +++ b/lenser/coco/palette.py @@ -0,0 +1,69 @@ +"""TRS-80 Color Computer MC6847 VDG palette + pixel packing. + +PMODE 4 (256x192) is 2-colour: black + the foreground of the selected colour set +(CSS=0 green, CSS=1 "buff" ~ off-white). We use CSS=1 (buff on black) for clean +monochrome, like Apple HGR mono. +""" + +from __future__ import annotations + +import numpy as np + +from ..palette import srgb_to_lab + +# Approximate sRGB for the MC6847 colours. +BLACK = (0, 0, 0) +BUFF = (255, 255, 255) +GREEN = (38, 194, 64) +YELLOW = (255, 240, 112) +BLUE = (40, 62, 211) +RED = (180, 38, 40) +CYAN = (52, 198, 160) +MAGENTA = (200, 70, 180) +ORANGE = (224, 116, 36) + +# PMODE 4 monochrome (CSS=1): index 0 = black, 1 = buff. +MONO = np.array([BLACK, BUFF], dtype=np.float64) + +# PMODE 3 (CG6) 4-colour sets, selected by the CSS bit. The 2-bit pixel value +# 0..3 indexes the set. VDG byte ($FF22) high nibble = E (A/G,GM2,GM1=1, GM0=0). +PMODE3_SETS = { + 0xE0: np.array([GREEN, YELLOW, BLUE, RED], dtype=np.float64), # CSS=0 + 0xE8: np.array([BUFF, CYAN, MAGENTA, ORANGE], dtype=np.float64), # CSS=1 +} + + +def mono_lab() -> np.ndarray: + return srgb_to_lab(MONO) + + +def pack_pmode3(val: np.ndarray) -> bytes: + """Pack a (192,128) 0..3 array into 6144 bytes, 4 pixels/byte (2bpp), + leftmost pixel in the high bits.""" + h, w = val.shape + out = bytearray(w // 4 * h) + k = 0 + for y in range(h): + row = val[y] + for x in range(0, w, 4): + out[k] = ((row[x] & 3) << 6) | ((row[x + 1] & 3) << 4) | \ + ((row[x + 2] & 3) << 2) | (row[x + 3] & 3) + k += 1 + return bytes(out) + + +def pack_pmode4(idx: np.ndarray) -> bytes: + """Pack a (192,256) 0/1 array into 6144 bytes, 8 pixels/byte, bit7 leftmost, + 1 = foreground (the VDG reads this straight from video RAM).""" + h, w = idx.shape + out = bytearray(w // 8 * h) + k = 0 + for y in range(h): + row = idx[y] + for x in range(0, w, 8): + b = 0 + for i in range(8): + b = (b << 1) | (1 if row[x + i] else 0) + out[k] = b + k += 1 + return bytes(out) diff --git a/lenser/coco/viewer.py b/lenser/coco/viewer.py new file mode 100644 index 0000000..e930d5b --- /dev/null +++ b/lenser/coco/viewer.py @@ -0,0 +1,71 @@ +"""Generates the CoCo cartridge viewer (Motorola 6809 machine code). + +Runs from the Program Pak ROM at $C000 (the CoCo autostarts it). Sets the VDG +graphics mode via PIA1 $FF22 and the SAM video registers (PMODE 3 and 4 are both +6144-byte SAM mode 6; only the $FF22 byte differs), copies the 6144-byte image +from cart ROM down to the video page at $0E00, then holds it. + +display: forever (hold), key (poll the keyboard then reset), seconds (count 60 Hz +field-syncs then reset). A Program Pak can't cleanly return to BASIC, so key and +seconds reset the machine (which re-displays the picture). +""" + +from __future__ import annotations + +from .mc6809 import Asm + +CART_BASE = 0xC000 +VIDEO = 0x0E00 +SCREEN_BYTES = 6144 +RATE = 60 # NTSC field-syncs per second + + +def build(data_src: int, vdg: int = 0xF8, display: str = "forever", + seconds: int = 0) -> bytes: + a = Asm(CART_BASE) + a.orcc(0x50) # mask IRQ + FIRQ; we run standalone + + a.lda_imm(vdg) + a.sta_ext(0xFF22) # VDG graphics mode (PMODE 3 = $E0/$E8, PMODE 4 = $F8) + + # SAM video mode 6 (V2=1, V1=1, V0=0). SAM regs toggle by address; data ignored. + a.sta_ext(0xFFC5) # V2 set + a.sta_ext(0xFFC3) # V1 set + a.sta_ext(0xFFC0) # V0 clear + + # SAM video offset = 7 ($0E00): F0,F1,F2 set; F3..F6 clear. + a.sta_ext(0xFFC7); a.sta_ext(0xFFC9); a.sta_ext(0xFFCB) + a.sta_ext(0xFFCC); a.sta_ext(0xFFCE); a.sta_ext(0xFFD0); a.sta_ext(0xFFD2) + + # copy SCREEN_BYTES from cart ROM (data_src) to the video page + a.ldx_imm(data_src) + a.ldu_imm(VIDEO) + a.label("copy") + a.lda_postinc("x") + a.sta_postinc("u") + a.cmpu_imm(VIDEO + SCREEN_BYTES) + a.bne("copy") + + # ---- hold the picture ---- + if display == "key": + a.clra() + a.sta_ext(0xFF02) # drive all keyboard columns low + a.label("kwait") + a.lda_ext(0xFF00) # row sense + a.ora_imm(0x80) # ignore the joystick-compare bit 7 + a.cmpa_imm(0xFF) + a.beq("kwait") # all rows high -> no key, keep waiting + a.jmp_ind(0xFFFE) # reset + elif display == "seconds": + a.ldd_imm(max(1, min(0xFFFF, int(seconds) * RATE))) + a.label("swait") + a.lda_ext(0xFF03) # PIA0 CB1 (field-sync) status + a.bpl("swait") # bit 7 clear -> no new field yet + a.lda_ext(0xFF02) # read PB data to clear the field-sync flag + a.subd_imm(1) + a.bne("swait") + a.jmp_ind(0xFFFE) # reset + else: # forever + a.label("hang") + a.bra("hang") + return a.resolve() diff --git a/lenser/coco3/__init__.py b/lenser/coco3/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lenser/coco3/cartridge.py b/lenser/coco3/cartridge.py new file mode 100644 index 0000000..780d2e1 --- /dev/null +++ b/lenser/coco3/cartridge.py @@ -0,0 +1,29 @@ +"""Build a CoCo 3 Program Pak cartridge ROM (.ccc): GIME viewer + 15360-byte image. + +The CoCo 3 autostarts the cartridge at $C000. Layout: viewer code, then the +linear GIME image, padded to a 16KB ROM.""" +from __future__ import annotations + +from . import viewer + +CART_BASE = 0xC000 +CART_SIZE = 0x4000 # 16 KB + + +def build_rom(data: bytes, cres: int, inks, border: int) -> bytes: + if len(data) != viewer.IMG_LEN: + raise ValueError(f"unexpected image length {len(data)}") + kw = dict(cres=cres, inks=inks, border=border) + code = viewer.build(0, **kw) # pass 1: measure code length + data_src = CART_BASE + len(code) + code = viewer.build(data_src, **kw) # pass 2: real data address + rom = code + bytes(data) + if len(rom) > 0x3F00: # keep clear of the $FF00 I/O page + raise ValueError("viewer + image exceed the cartridge ROM window") + return rom + bytes(CART_SIZE - len(rom)) + + +def write_ccc(rom: bytes, path: str) -> str: + with open(path, "wb") as f: + f.write(rom) + return path diff --git a/lenser/coco3/convert/__init__.py b/lenser/coco3/convert/__init__.py new file mode 100644 index 0000000..26cb8b4 --- /dev/null +++ b/lenser/coco3/convert/__init__.py @@ -0,0 +1,19 @@ +"""Tandy CoCo 3 (GIME) conversion dispatch.""" +from __future__ import annotations + +from ... import imageprep +from . import gr16, gr4, mono + +_MODULES = {"gr16": gr16, "gr4": gr4, "mono": mono} +MODES = list(_MODULES.keys()) + + +def convert_image(path_or_img, mode="gr16", palette_name="gime", + dither_mode="floyd", intensive=False, prep_opt=None, + base_color=None): + prep_opt = prep_opt or imageprep.PrepOptions() + module = _MODULES.get(mode, gr16) + 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) diff --git a/lenser/coco3/convert/_common.py b/lenser/coco3/convert/_common.py new file mode 100644 index 0000000..45e68cd --- /dev/null +++ b/lenser/coco3/convert/_common.py @@ -0,0 +1,85 @@ +"""Shared CoCo 3 GIME encoder helpers: global palette selection + linear packing. + +GIME native graphics modes have no per-cell colour limit: a flat palette of pens +(16/4/2), each pen any of the 64 colours. Pick the best N-colour sub-palette, +dither to it, then pack pens into the LINEAR 80-byte x 192-row screen. +""" +from __future__ import annotations + +import numpy as np + +from ... import dither, palette as c64pal +from .. import palette as g + +BYTES_PER_ROW, ROWS = 80, 192 + + +def choose_inks(img_lab, plab, n): + """Greedy forward selection of the ``n`` palette colours (0-63) minimising + nearest-colour error over the image.""" + flat = img_lab.reshape(-1, 3) + d = np.sum((flat[:, None, :] - plab[None, :, :]) ** 2, axis=-1) + chosen, best = [], np.full(flat.shape[0], np.inf) + for _ in range(n): + cand = np.minimum(best[:, None], d).sum(0) + for c in chosen: + cand[c] = np.inf + c = int(cand.argmin()) + chosen.append(c) + best = np.minimum(best, d[:, c]) + return sorted(chosen) + + +def render(img_rgb, n, dither_mode, ramp=None): + """Return (pen (H,W) 0..n-1, inks (n 6-bit colours), idx (H,W) palette + indices, img_lab, plab, prgb).""" + plab = g.palette_lab() + prgb = g.get_palette().astype(np.uint8) + img_lab = c64pal.srgb_to_lab(img_rgb) + + pal_idx = list(ramp) if ramp is not None else choose_inks(img_lab, plab, n) + allowed = np.tile(np.array(pal_idx), (*img_lab.shape[:2], 1)) + idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64) + lut = {p: k for k, p in enumerate(pal_idx)} + pen = np.vectorize(lut.get)(idx).astype(np.uint8) + return pen, list(pal_idx), idx.astype(np.uint16), img_lab, plab, prgb + + +def _pack_16(pen): + """160x192 pens (0-15) -> 15360 bytes. 2 px/byte, high nibble = left.""" + scr = bytearray(BYTES_PER_ROW * ROWS) + for y in range(ROWS): + row = pen[y] + base = y * BYTES_PER_ROW + for bx in range(BYTES_PER_ROW): + scr[base + bx] = (int(row[bx * 2]) << 4) | (int(row[bx * 2 + 1]) & 0x0F) + return bytes(scr) + + +def _pack_4(pen): + """320x192 pens (0-3) -> 15360 bytes. 4 px/byte, MSB = left.""" + scr = bytearray(BYTES_PER_ROW * ROWS) + for y in range(ROWS): + row = pen[y] + base = y * BYTES_PER_ROW + for bx in range(BYTES_PER_ROW): + scr[base + bx] = ((int(row[bx * 4]) << 6) | (int(row[bx * 4 + 1]) << 4) | + (int(row[bx * 4 + 2]) << 2) | int(row[bx * 4 + 3])) + return bytes(scr) + + +def _pack_2(pen): + """640x192 pens (0-1) -> 15360 bytes. 8 px/byte, MSB = left.""" + scr = bytearray(BYTES_PER_ROW * ROWS) + for y in range(ROWS): + row = pen[y] + base = y * BYTES_PER_ROW + for bx in range(BYTES_PER_ROW): + byte = 0 + for k in range(8): + byte |= (int(row[bx * 8 + k]) & 1) << (7 - k) + scr[base + bx] = byte + return bytes(scr) + + +PACK = {0: _pack_2, 1: _pack_4, 2: _pack_16} # keyed by CRES diff --git a/lenser/coco3/convert/gr16.py b/lenser/coco3/convert/gr16.py new file mode 100644 index 0000000..bd62be1 --- /dev/null +++ b/lenser/coco3/convert/gr16.py @@ -0,0 +1,24 @@ +"""CoCo 3 GIME 160x192, 16 colours from the 64-colour palette (flagship photo +mode -- wide pixels but the most colour).""" +from __future__ import annotations + +from ...convert.base import Conversion, perceptual_error +from . import _common + +WIDTH, HEIGHT = 160, 192 +PIXEL_ASPECT = 2.0 # 160 wide on a 4:3 screen -> pixels twice as wide +NCOL = 16 +CRES = 2 + + +def convert(img_rgb, palette_name="gime", dither_mode="floyd", + intensive=False, base_color=None): + pen, inks, idx, img_lab, plab, prgb = _common.render(img_rgb, NCOL, dither_mode) + screen = _common.PACK[CRES](pen) + return Conversion( + mode="gr16", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=idx, data=screen, data_addr=0x4000, viewer="coco3", + preview_rgb=prgb[idx], error=perceptual_error(idx, img_lab, plab), + meta={"palette": "gime", "dither": dither_mode, "cres": CRES, + "inks": inks, "border": inks[0] if inks else 0}, + ) diff --git a/lenser/coco3/convert/gr4.py b/lenser/coco3/convert/gr4.py new file mode 100644 index 0000000..acd4034 --- /dev/null +++ b/lenser/coco3/convert/gr4.py @@ -0,0 +1,24 @@ +"""CoCo 3 GIME 320x192, 4 colours from the 64-colour palette (more resolution, +fewer colours than gr16).""" +from __future__ import annotations + +from ...convert.base import Conversion, perceptual_error +from . import _common + +WIDTH, HEIGHT = 320, 192 +PIXEL_ASPECT = 1.0 +NCOL = 4 +CRES = 1 + + +def convert(img_rgb, palette_name="gime", dither_mode="floyd", + intensive=False, base_color=None): + pen, inks, idx, img_lab, plab, prgb = _common.render(img_rgb, NCOL, dither_mode) + screen = _common.PACK[CRES](pen) + return Conversion( + mode="gr4", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=idx, data=screen, data_addr=0x4000, viewer="coco3", + preview_rgb=prgb[idx], error=perceptual_error(idx, img_lab, plab), + meta={"palette": "gime", "dither": dither_mode, "cres": CRES, + "inks": inks, "border": inks[0] if inks else 0}, + ) diff --git a/lenser/coco3/convert/mono.py b/lenser/coco3/convert/mono.py new file mode 100644 index 0000000..daee28c --- /dev/null +++ b/lenser/coco3/convert/mono.py @@ -0,0 +1,45 @@ +"""CoCo 3 GIME monochrome: 640x192, 2 colours (black + white) -- the highest- +resolution CoCo 3 mode, tone carried by dithering. ``--mono-base`` tints the +second tone.""" +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 g +from . import _common + +WIDTH, HEIGHT = 640, 192 +PIXEL_ASPECT = 0.5 +CRES = 0 + + +def convert(img_rgb, palette_name="gime", dither_mode="floyd", + intensive=False, base_color=None): + plab = g.palette_lab() + black = min(g.GREYS, key=lambda i: plab[i, 0]) + if base_color in range(64) and base_color != black: + ramp = sorted({black, int(base_color)}, key=lambda i: plab[i, 0]) + else: + ramp = sorted(g.GREYS, key=lambda i: plab[i, 0]) + ramp = [ramp[0], ramp[-1]] # black + white + + img_lab = c64pal.srgb_to_lab(img_rgb) + mono_lab = np.zeros_like(img_lab); mono_lab[..., 0] = img_lab[..., 0] + plab_mono = np.zeros_like(plab); plab_mono[:, 0] = plab[:, 0] + allowed = np.tile(np.array(ramp), (*mono_lab.shape[:2], 1)) + idx = dither.quantize(mono_lab, allowed, plab_mono, dither_mode).astype(np.int64) + lut = {p: k for k, p in enumerate(ramp)} + pen = np.vectorize(lut.get)(idx).astype(np.uint8) + + screen = _common.PACK[CRES](pen) + prgb = g.get_palette().astype(np.uint8) + return Conversion( + mode="mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=idx.astype(np.uint16), data=screen, data_addr=0x4000, + viewer="coco3", preview_rgb=prgb[idx.astype(np.uint16)], + error=perceptual_error(idx, mono_lab, plab_mono), + meta={"palette": "gime", "dither": dither_mode, "cres": CRES, + "inks": ramp, "border": ramp[0]}, + ) diff --git a/lenser/coco3/exporter.py b/lenser/coco3/exporter.py new file mode 100644 index 0000000..236a0f5 --- /dev/null +++ b/lenser/coco3/exporter.py @@ -0,0 +1,13 @@ +"""Build a CoCo 3 cartridge (.ccc) from a conversion.""" +from __future__ import annotations + +from . import cartridge + + +def export_ccc(conv, output_path, source_path=None, display="forever", + seconds=0, video="ntsc"): + if not output_path.lower().endswith((".ccc", ".rom")): + output_path += ".ccc" + rom = cartridge.build_rom(bytes(conv.data), conv.meta["cres"], + conv.meta["inks"], conv.meta["border"]) + return cartridge.write_ccc(rom, output_path) diff --git a/lenser/coco3/palette.py b/lenser/coco3/palette.py new file mode 100644 index 0000000..5277d7c --- /dev/null +++ b/lenser/coco3/palette.py @@ -0,0 +1,34 @@ +"""Tandy CoCo 3 GIME RGB palette. + +The GIME produces 64 colours: a 6-bit palette value, bits ``R1 G1 B1 R0 G0 B0``, +so each of R/G/B is a 2-bit level (x0x55 -> 0, 0x55, 0xAA, 0xFF). RGB is computed +exactly as MAME's gime_device::get_rgb_color, so the encoder matches the emulator. +A palette register ($FFB0-$FFBF) holds this 6-bit value, so the colour index IS +the byte written to hardware. +""" +from __future__ import annotations + +import numpy as np + +from ..palette import srgb_to_lab + + +def _rgb(c: int): + r = (((c >> 4) & 2) | ((c >> 2) & 1)) * 0x55 + g = (((c >> 3) & 2) | ((c >> 1) & 1)) * 0x55 + b = (((c >> 2) & 2) | ((c >> 0) & 1)) * 0x55 + return (r, g, b) + + +PALETTE = np.array([_rgb(c) for c in range(64)], dtype=np.float64) + +# Neutral grey ramp (R==G==B): 6-bit values 0, 7, 56, 63. +GREYS = [c for c in range(64) if PALETTE[c, 0] == PALETTE[c, 1] == PALETTE[c, 2]] + + +def get_palette() -> np.ndarray: + return PALETTE + + +def palette_lab() -> np.ndarray: + return srgb_to_lab(PALETTE) diff --git a/lenser/coco3/viewer.py b/lenser/coco3/viewer.py new file mode 100644 index 0000000..850e362 --- /dev/null +++ b/lenser/coco3/viewer.py @@ -0,0 +1,70 @@ +"""CoCo 3 GIME hi-res viewer (Motorola 6809 machine code, Program Pak ROM). + +Autostarts from the cartridge at $C000 in CoCo-compatible mode, then switches the +GIME into a native graphics mode. Because turning on GIME mode (writing $FF90) +can change the memory map under the running code, the setup runs in two stages: + + 1. At $C000 (cart ROM): mask interrupts, copy the 15360-byte image down to RAM + at $4000, program the GIME palette + mode/geometry/video-base registers + (these are safe while still in legacy mode), then plant a tiny stub in RAM. + 2. Jump to the RAM stub, which writes $FF90 (COCO=0 -> GIME graphics on) and + idles. Running from RAM means the map change can't pull the code out from + under us. + +The GIME shows a LINEAR 80-byte x 192-row screen from physical video base +$14000, which is exactly where CPU $4000 lives with the MMU disabled. +""" +from __future__ import annotations + +from ..coco.mc6809 import Asm + +CART_BASE = 0xC000 +IMG_CPU = 0x4000 # image copied here (RAM, visible to GIME video) +IMG_LEN = 15360 # 80 bytes/row * 192 rows +IMG_END = IMG_CPU + IMG_LEN # $7C00 +STUB_CPU = 0x3E00 # tiny "enable GIME + idle" stub (RAM) + +# physical video base for CPU $4000 with the MMU disabled. CoCo 3 maps the 64K +# CPU space to blocks $38-$3F (bank+0x38); on the 512K machine bank 2 ($4000) is +# block $3A = physical $74000. base = FF9D<<11 | FF9E<<3. +FF9D = 0xE8 # $74000 >> 11 +FF9E = 0x00 + + +def build(data_src: int, cres: int, inks, border: int) -> bytes: + """data_src: cart address of the image bytes; cres: GIME colour-resolution + bits (0=2col, 1=4col, 2=16col); inks: pen->6-bit-colour list; border: 6-bit.""" + a = Asm(CART_BASE) + a.orcc(0x50) # mask IRQ + FIRQ + + # copy the image from cart ROM down to RAM at $4000 + a.ldx_imm(data_src) + a.ldu_imm(IMG_CPU) + a.label("copy") + a.lda_postinc("x") + a.sta_postinc("u") + a.cmpu_imm(IMG_END) + a.bne("copy") + + # GIME palette: 16 pen registers $FFB0-$FFBF (unused pens -> 0) + for pen in range(16): + a.lda_imm(inks[pen] if pen < len(inks) else 0) + a.sta_ext(0xFFB0 + pen) + a.lda_imm(border & 0x3F) + a.sta_ext(0xFF9A) # border colour + + a.lda_imm(0x80) + a.sta_ext(0xFF98) # VMODE: graphics, 1 line/row + a.lda_imm(0x14 | (cres & 0x03)) + a.sta_ext(0xFF99) # VRES: 192 lines, 80 bytes/row, CRES + a.lda_imm(FF9D) + a.sta_ext(0xFF9D) # video base hi + a.lda_imm(FF9E) + a.sta_ext(0xFF9E) # video base lo + + # plant the RAM stub: lda #0 ; sta $FF90 ; bra * (enable GIME, then idle) + for off, byte in enumerate((0x86, 0x00, 0xB7, 0xFF, 0x90, 0x20, 0xFE)): + a.lda_imm(byte) + a.sta_ext(STUB_CPU + off) + a.jmp_ext(STUB_CPU) + return a.resolve() diff --git a/lenser/coleco/__init__.py b/lenser/coleco/__init__.py new file mode 100644 index 0000000..4b76e7d --- /dev/null +++ b/lenser/coleco/__init__.py @@ -0,0 +1 @@ +"""ColecoVision / Coleco Adam (TMS9918A, Z80) image conversion and cartridge export.""" diff --git a/lenser/coleco/cartridge.py b/lenser/coleco/cartridge.py new file mode 100644 index 0000000..a7805bb --- /dev/null +++ b/lenser/coleco/cartridge.py @@ -0,0 +1,49 @@ +"""Build a ColecoVision/Adam cartridge ROM (.col) holding the Z80 viewer + image. + +Cartridge maps at $8000. Header: magic $55 $AA (skip the Coleco title screen and +run immediately), eight pointer words, then a table of JP vectors -- the BIOS +jumps to the one at $800A (the game entry). We point that at our viewer; the RST +and interrupt vectors go to a stub (we run with interrupts disabled). +""" + +from __future__ import annotations + +import struct + +from . import viewer + +CART_BASE = 0x8000 +CART_SIZE = 0x2000 # 8 KB (viewer + 6912-byte image fit comfortably) +VECTORS = 8 # entry + 7 RST/INT stubs +CODE_BASE = CART_BASE + 10 + VECTORS * 3 # after magic(2)+ptrs(8)+vectors + + +def build_rom(data: bytes, display: str = "forever", seconds: int = 0) -> bytes: + if len(data) != viewer.PATTERN_BYTES + viewer.NCELLS: + raise ValueError(f"unexpected image length {len(data)}") + + vk = dict(display=display, seconds=seconds) + code = viewer.build(CODE_BASE, 0, **vk) # pass 1: measure code length + data_addr = CODE_BASE + len(code) + 1 # +1 for the stub RET + code = viewer.build(CODE_BASE, data_addr, **vk) # pass 2: real data address + stub_addr = CODE_BASE + len(code) + data_addr = stub_addr + 1 + + if data_addr - CART_BASE + len(data) > CART_SIZE: + raise ValueError("viewer + image exceed the 8KB cartridge") + + header = bytearray([0x55, 0xAA]) + bytes(8) # magic + 8 pointer bytes + vecs = bytearray() + targets = [CODE_BASE] + [stub_addr] * (VECTORS - 1) + for t in targets: + vecs += bytes([0xC3]) + struct.pack(" str: + with open(path, "wb") as f: + f.write(rom) + return path diff --git a/lenser/coleco/convert/__init__.py b/lenser/coleco/convert/__init__.py new file mode 100644 index 0000000..4338ba0 --- /dev/null +++ b/lenser/coleco/convert/__init__.py @@ -0,0 +1,25 @@ +"""ColecoVision / Adam conversion dispatch. + +Both machines use the same TMS9918A VDP as the TI-99/4A, so the Graphics Mode 2 +image encoding (palette + 6144-byte pattern + 768 per-cell colours) is identical +-- we reuse the TI-99 GM2 encoder unchanged. +""" +from __future__ import annotations + +from ... import imageprep +from ...ti99.convert import gm2 as _gm2, mono as _mono + +_MODULES = {"gm2": _gm2, "mono": _mono} +MODES = ["gm2", "mono"] + + +def convert_image(path_or_img, mode="gm2", palette_name="tms9918", + dither_mode="floyd", intensive=False, prep_opt=None, base_color=None): + prep_opt = prep_opt or imageprep.PrepOptions() + module = _MODULES.get(mode, _gm2) + img_rgb = imageprep.prepare(path_or_img, module.WIDTH, module.HEIGHT, + module.PIXEL_ASPECT, prep_opt, border_rgb=(0, 0, 0)) + conv = module.convert(img_rgb, palette_name, dither_mode, intensive, + base_color=base_color) + conv.viewer = "coleco" + return conv diff --git a/lenser/coleco/exporter.py b/lenser/coleco/exporter.py new file mode 100644 index 0000000..2b22429 --- /dev/null +++ b/lenser/coleco/exporter.py @@ -0,0 +1,10 @@ +"""Build a ColecoVision/Adam cartridge (.col) from a conversion.""" +from __future__ import annotations +from . import cartridge + + +def export_col(conv, output_path, source_path=None, display="forever", seconds=0): + if not output_path.lower().endswith((".col", ".rom")): + output_path += ".col" + rom = cartridge.build_rom(conv.data, display=display, seconds=seconds) + return cartridge.write_col(rom, output_path) diff --git a/lenser/coleco/viewer.py b/lenser/coleco/viewer.py new file mode 100644 index 0000000..8d26a61 --- /dev/null +++ b/lenser/coleco/viewer.py @@ -0,0 +1,114 @@ +"""Generates the ColecoVision/Adam cartridge viewer (Z80 machine code). + +Sets the TMS9918A to Graphics Mode 2, builds the name table, copies the +6144-byte pattern from cartridge ROM to VRAM >0000, and expands the 768 per-cell +colour bytes x8 into the 6144-byte colour table at VRAM >2000 -- the same picture +the TI-99 viewer makes, but in Z80 with the ColecoVision VDP ports. + +display: forever (hold), key (poll the controller's fire button then reset), +seconds (count VDP frame flags then reset). A cartridge has no OS to return to, +so key/seconds reset the machine (JP 0), which re-runs the cart and re-displays. +""" + +from __future__ import annotations + +from .z80 import Asm + +VDP_DATA = 0xBE # data port (VRAM, auto-increment) +VDP_CTRL = 0xBF # control port (address / register / status) +VDP_REGS = [0x02, 0xC0, 0x0E, 0xFF, 0x03, 0x36, 0x07, 0x01] # Graphics Mode 2 +PATTERN_BYTES = 6144 +NCELLS = 768 +RATE = 60 # NTSC frames/second +JOY_SEG = 0xC0 # OUT (>C0) selects the joystick / left-fire segment +JOY_PORT = 0xFC # IN (>FC) reads controller 1 +SOUND_PORT = 0xFF # SN76489 sound chip +# SN76489 "attenuation = 15 (off)" latch byte for each of the 4 channels. +SOUND_OFF = [0x9F, 0xBF, 0xDF, 0xFF] + + +def _set_write_addr(a: Asm, addr: int): + a.ld_a(addr & 0xFF); a.out_n_a(VDP_CTRL) + a.ld_a(((addr >> 8) & 0x3F) | 0x40); a.out_n_a(VDP_CTRL) + + +def build(code_base: int, data_addr: int, display: str = "forever", + seconds: int = 0) -> bytes: + a = Asm(code_base) + a.di() + + # ---- silence the SN76489 (our >55AA header skips the BIOS sound init, so the + # chip powers up holding a tone); set all 4 channels to attenuation off ---- + for v in SOUND_OFF: + a.ld_a(v) + a.out_n_a(SOUND_PORT) + + # ---- programme the 8 VDP registers ---- + for reg, val in enumerate(VDP_REGS): + a.ld_a(val); a.out_n_a(VDP_CTRL) + a.ld_a(0x80 | reg); a.out_n_a(VDP_CTRL) + + # ---- name table >3800 = 0,1,...,255 repeated 3 times (768 bytes) ---- + _set_write_addr(a, 0x3800) + a.ld_a(0) + a.ld_c(3) + a.label("name_o") + a.ld_b(0) # B=0 -> DJNZ runs 256 times + a.label("name_i") + a.out_n_a(VDP_DATA) + a.inc_a() + a.djnz("name_i") + a.dec_c() + a.jr_nz("name_o") + + # ---- pattern table >0000 = 6144 bytes from ROM (24 * 256) ---- + _set_write_addr(a, 0x0000) + a.ld_hl(data_addr) + a.ld_c(PATTERN_BYTES // 256) + a.label("pat_o") + a.ld_b(0) + a.label("pat_i") + a.ld_a_hl() + a.out_n_a(VDP_DATA) + a.inc_hl() + a.djnz("pat_i") + a.dec_c() + a.jr_nz("pat_o") + + # ---- colour table >2000 = each cell colour written 8x (768 -> 6144) ---- + _set_write_addr(a, 0x2000) + a.ld_hl(data_addr + PATTERN_BYTES) + a.ld_c(NCELLS // 256) + a.label("col_o") + a.ld_b(0) + a.label("col_i") + a.ld_a_hl() + for _ in range(8): + a.out_n_a(VDP_DATA) + a.inc_hl() + a.djnz("col_i") + a.dec_c() + a.jr_nz("col_o") + + # ---- hold the picture ---- + if display == "key": + a.label("kwait") + a.ld_a(0); a.out_n_a(JOY_SEG) # select joystick / left-fire segment + a.in_a_n(JOY_PORT) + a.and_n(0x40) # bit 6 = fire button (active low) + a.jr_nz("kwait") # still high -> not pressed + a.jp(0x0000) # reset -> re-display + elif display == "seconds": + a.ld_de(max(1, min(0xFFFF, int(seconds) * RATE))) + a.label("swait") + a.in_a_n(VDP_CTRL) # read VDP status (clears frame flag) + a.and_n(0x80) + a.jr_z("swait") # no new frame yet + a.dec_de() + a.ld_a_d(); a.or_e() + a.jr_nz("swait") + a.jp(0x0000) # reset -> re-display + else: # forever + a.label("hang") + a.jr("hang") + return a.resolve() diff --git a/lenser/coleco/z80.py b/lenser/coleco/z80.py new file mode 100644 index 0000000..81432aa --- /dev/null +++ b/lenser/coleco/z80.py @@ -0,0 +1,112 @@ +"""A tiny Z80 machine-code emitter (just what the ColecoVision/Adam viewer needs). + +No Z80 assembler is installed, so -- as with the TMS9900 and 6809 emitters -- we +emit opcodes directly. Little-endian 16-bit operands; 8-bit relative jumps are +backpatched via labels. +""" + +from __future__ import annotations + + +class Asm: + def __init__(self, base: int): + self.base = base + self.code = bytearray() + self.labels: dict[str, int] = {} + self._fix: list[tuple[int, str]] = [] # (pos of rel byte, label) + + def pos(self) -> int: + return self.base + len(self.code) + + def label(self, name: str): + self.labels[name] = self.pos() + + def _b(self, *bs): + self.code += bytes(b & 0xFF for b in bs) + + def _w(self, v): # 16-bit, little-endian + self.code += bytes([v & 0xFF, (v >> 8) & 0xFF]) + + # ---- 8-bit loads (immediate) ---- + def ld_a(self, n): self._b(0x3E, n) + def ld_b(self, n): self._b(0x06, n) + def ld_c(self, n): self._b(0x0E, n) + def ld_d(self, n): self._b(0x16, n) + def ld_e(self, n): self._b(0x1E, n) + + # ---- 16-bit loads (immediate) ---- + def ld_hl(self, nn): self._b(0x21); self._w(nn) + def ld_de(self, nn): self._b(0x11); self._w(nn) + def ld_bc(self, nn): self._b(0x01); self._w(nn) + + # ---- memory <-> A (extended addressing) ---- + def ld_a_mem(self, addr): self._b(0x3A); self._w(addr) # LD A,(nn) + def ld_mem_a(self, addr): self._b(0x32); self._w(addr) # LD (nn),A + + # ---- register moves ---- + def ld_a_b(self): self._b(0x78) + def ld_a_c(self): self._b(0x79) + def ld_a_d(self): self._b(0x7A) + def ld_a_e(self): self._b(0x7B) + def ld_a_h(self): self._b(0x7C) + def ld_a_l(self): self._b(0x7D) + def ld_b_a(self): self._b(0x47) + def ld_c_a(self): self._b(0x4F) + def ld_a_hl(self): self._b(0x7E) # LD A,(HL) + def ld_hl_a(self): self._b(0x77) # LD (HL),A + + # ---- I/O ---- + def out_n_a(self, n): self._b(0xD3, n) # OUT (n),A + def in_a_n(self, n): self._b(0xDB, n) # IN A,(n) + + # ---- arithmetic / logic ---- + def inc_a(self): self._b(0x3C) + def dec_a(self): self._b(0x3D) + def dec_c(self): self._b(0x0D) + def inc_hl(self): self._b(0x23) + def dec_hl(self): self._b(0x2B) + def inc_de(self): self._b(0x13) + def dec_de(self): self._b(0x1B) + def dec_bc(self): self._b(0x0B) + def or_a(self): self._b(0xB7) # OR A (set flags from A) + def or_c(self): self._b(0xB1) + def or_e(self): self._b(0xB3) + def and_n(self, n): self._b(0xE6, n) + def cp_n(self, n): self._b(0xFE, n) + def add_hl_de(self): self._b(0x19) + + # ---- block ops ---- + def ldir(self): self._b(0xED, 0xB0) # (HL)->(DE), BC times + + # ---- control ---- + def di(self): self._b(0xF3) + def ei(self): self._b(0xFB) + def ret(self): self._b(0xC9) + def retn(self): self._b(0xED, 0x45) + def nop(self): self._b(0x00) + + def jp(self, addr): self._b(0xC3); self._w(addr) + def jp_label(self, lbl): + self._b(0xC3); self._fix.append((len(self.code), lbl)); self._w(0) + + def _jr(self, op, lbl): + self._b(op); self._fix.append((len(self.code), lbl)); self._b(0) + + def jr(self, lbl): self._jr(0x18, lbl) + def jr_nz(self, lbl): self._jr(0x20, lbl) + def jr_z(self, lbl): self._jr(0x28, lbl) + def djnz(self, lbl): self._jr(0x10, lbl) + + def resolve(self) -> bytes: + for pos, lbl in self._fix: + target = self.labels[lbl] + opcode = self.code[pos - 1] # the byte just before the operand + if opcode == 0xC3: # JP nn (absolute, 2 bytes) + self.code[pos] = target & 0xFF + self.code[pos + 1] = (target >> 8) & 0xFF + else: # JR / DJNZ (relative, 1 byte) + rel = target - (self.base + pos + 1) + if not -128 <= rel <= 127: + raise ValueError(f"relative jump to {lbl} out of range ({rel})") + self.code[pos] = rel & 0xFF + return bytes(self.code) diff --git a/c64view/convert/__init__.py b/lenser/convert/__init__.py similarity index 89% rename from c64view/convert/__init__.py rename to lenser/convert/__init__.py index 1e84eeb..6079418 100644 --- a/c64view/convert/__init__.py +++ b/lenser/convert/__init__.py @@ -64,6 +64,11 @@ def render_preview(conv: base.Conversion, palette_name="colodore", Logical pixels are widened by the mode's pixel aspect (so multicolor pixels are twice as wide), giving a uniform 320x200 base which is then integer-scaled. + + NOTE: ``pixel_aspect`` is applied here only for the index-image fallback path. + A converter that supplies ``preview_rgb`` MUST pre-widen it to display + resolution itself (e.g. repeat columns by the pixel aspect); otherwise modes + with wide pixels render as a narrow sliver. atari/apple/a2600/intv do this. """ if conv.preview_rgb is not None: rgb = conv.preview_rgb diff --git a/lenser/convert/base.py b/lenser/convert/base.py new file mode 100644 index 0000000..75bdea0 --- /dev/null +++ b/lenser/convert/base.py @@ -0,0 +1,361 @@ +"""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 segment_distances(cells: np.ndarray, palette_lab: np.ndarray) -> np.ndarray: + """Squared CIELAB distance from each cell pixel to every colour-pair *segment*. + + Unlike :func:`cell_distance` (distance to the nearest palette vertex), this + credits what error-diffusion dithering actually achieves: blending two + colours reproduces any shade on the line between them. Returns a + (Q, Q, n_cells, P) array (Q = palette size) where ``[a, b]`` is the distance + to segment a-b. + """ + n_cells, P, _ = cells.shape + Q = palette_lab.shape[0] + D = np.empty((Q, Q, n_cells, P), dtype=np.float64) + for a in range(Q): + ca = palette_lab[a] + for b in range(a, Q): + seg = palette_lab[b] - ca + L = float(seg @ seg) + 1e-9 + t = np.clip(((cells - ca) @ seg) / L, 0.0, 1.0) # (n,P) + proj = ca + t[..., None] * seg + d = np.sum((cells - proj) ** 2, axis=-1) # (n,P) + D[a, b] = d + D[b, a] = d + return D + + +def select_cell_sets_dither(cells, palette_lab, available, n_free, fixed=(), + seg=None): + """Like :func:`select_cell_sets` but scores each colour combination by the + distance to the nearest pairwise *segment* among its colours, so the chosen + colours best support error-diffusion dithering (smoother gradients). + """ + n_cells = cells.shape[0] + fixed = list(fixed) + if seg is None: + seg = segment_distances(cells, palette_lab) + 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) + for combo in combos: + colors = fixed + list(combo) + pairs = list(combinations(colors, 2)) or [(colors[0], colors[0])] + m = seg[pairs[0][0], pairs[0][1]] + for a, b in pairs[1:]: + m = np.minimum(m, seg[a, b]) + err = m.sum(axis=1) + better = err < best_err + best_err = np.where(better, err, best_err) + best_combo[better] = combo + 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_dither(cells, palette_lab, n_free, candidates=range(16)): + """Dither-aware background search (see :func:`optimize_background`).""" + seg = segment_distances(cells, palette_lab) + 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_dither(cells, palette_lab, avail, n_free, + fixed=(bg,), seg=seg) + total = errors.sum() + if total < best_total: + best_total = total + best = (bg, sets, errors) + return best + + +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()) + + +# error-diffusion dithers benefit from dither-aware (segment) colour selection; +# ordered ("bayer") and "none" must use plain nearest-colour selection. +# "yliluoma" is ordered, not diffusion, but it likewise reproduces a cell's +# *average* colour by mixing >2 entries, so it wants the same dither-aware +# (segment) colour selection and perceptual (blurred) scoring as the diffusers. +DIFFUSION_DITHERS = {"floyd", "atkinson", "stucki", "jarvis", "sierra", + "sierra_lite", "burkes", "riemersma", "ostromoukhov", + "yliluoma"} + + +def _box_blur(a: np.ndarray, passes: int = 2) -> np.ndarray: + """Cheap separable 3x3 box blur (approximates the eye averaging a dither).""" + for _ in range(passes): + p = np.pad(a, ((1, 1), (1, 1), (0, 0)), mode="edge") + a = (p[:-2, :-2] + p[:-2, 1:-1] + p[:-2, 2:] + + p[1:-1, :-2] + p[1:-1, 1:-1] + p[1:-1, 2:] + + p[2:, :-2] + p[2:, 1:-1] + p[2:, 2:]) / 9.0 + return a + + +def luminance_ramp(plab: np.ndarray, neutral, base_color, siblings=None): + """Build a luminance-sorted ramp of palette indices for monochrome rendering. + + With no/neutral base colour -> the platform's neutral (grey) ramp; otherwise a + tinted ramp of black + the base colour (+ its lighter sibling, if any) + white, + so the image becomes that hue's shades. ``neutral`` is the platform's grey + ramp (e.g. [black, grey, white]); ``siblings`` maps a colour to a lighter + variant. All indices are returned sorted by CIELAB lightness. + """ + neutral = list(neutral) + if base_color is None or base_color in neutral: + ramp = list(neutral) + else: + black, white = neutral[0], neutral[-1] + ramp = {black, white, base_color} + if siblings and base_color in siblings: + ramp.add(siblings[base_color]) + ramp = list(ramp) + ramp.sort(key=lambda i: plab[i, 0]) + return ramp + + +def mono_render(img_rgb, plab, ramp, W, H, cell_w, cell_h, dither_mode, n_free=2): + """Luminance-matched monochrome render shared by every 2-colour-per-cell + platform: collapse image and palette to pure lightness, pick (per cell) the + ramp levels that bracket the cell's luminance (dither-aware for error + diffusion), then dither. Returns (index_image, sets, rows, cols, error).""" + from .. import dither, palette as pal + L = pal.srgb_to_lab(img_rgb)[..., 0] + img_mono = np.zeros((H, W, 3)) + img_mono[..., 0] = L + plab_mono = np.zeros_like(plab) + plab_mono[:, 0] = plab[:, 0] + + # The per-cell search runs on a COMPACT luminance-only sub-palette of just the + # ramp colours (indices 0..len(ramp)-1), then maps back to real palette + # indices. This stays small and correct even when the real palette has far + # more than 16 colours (e.g. the TED's 128) -- segment_distances assumes a + # small palette -- and is faster since only the ramp is considered. + ramp = list(ramp) + real = np.array(ramp, dtype=np.int64) + cpal = np.zeros((len(ramp), 3)) + cpal[:, 0] = plab[real, 0] + + cells, rows, cols = cells_lab(img_mono, cell_w, cell_h) + avail = range(len(ramp)) + n_free = min(n_free, len(ramp)) + if n_free >= 2 and dither_mode in DIFFUSION_DITHERS: + sets, _ = select_cell_sets_dither(cells, cpal, avail, n_free=n_free) + else: + dist = cell_distance(cells, cpal) + sets, _ = select_cell_sets(dist, avail, n_free=max(n_free, 1)) + sets = real[sets] # compact -> real palette indices + if sets.shape[1] == 1: + sets = np.concatenate([sets, sets], axis=1) + + allowed = per_pixel_allowed(sets, rows, cols, cell_w, cell_h, H, W) + idx = dither.quantize(img_mono, allowed, plab_mono, dither_mode).astype(np.uint8) + err = perceptual_error(idx, img_mono, plab_mono) + return idx, sets, rows, cols, err + + +def mono_codebook(bitmaps, k, iters=8): + """Reduce per-cell binary patterns to a k-entry dictionary of REAL patterns + (k-medoids under Hamming distance). Every dictionary entry is a genuine + dithered pattern -- unlike k-means centroids, which threshold a cluster mean + and can invent a near-solid 'average' pattern (block artefacts). Initialised + from the k most frequent patterns, then refined by alternating nearest-Hamming + assignment and medoid update so the entries spread to cover the pattern space. + Returns (codebook (k, P) uint8, labels).""" + P = bitmaps.shape[1] + uniq, counts = np.unique(bitmaps, axis=0, return_counts=True) + if len(uniq) <= k: + code = np.zeros((k, P), np.uint8) + code[:len(uniq)] = uniq + lut = {tuple(p): i for i, p in enumerate(uniq)} + labels = np.array([lut[tuple(b)] for b in bitmaps]) + return code, labels + order = np.argsort(-counts)[:k] + code = uniq[order].copy() + bm = bitmaps.astype(np.int16) + labels = np.zeros(len(bitmaps), np.int64) + for _ in range(iters): + labels = (bm[:, None, :] ^ code[None].astype(np.int16)).sum(-1).argmin(1) + moved = False + for j in range(k): + members = bm[labels == j] + if len(members) > 1: + intra = (members[:, None, :] ^ members[None, :, :]).sum(-1).sum(1) + med = members[intra.argmin()].astype(np.uint8) + if not np.array_equal(med, code[j]): + code[j] = med + moved = True + if not moved: + break + labels = (bm[:, None, :] ^ code[None].astype(np.int16)).sum(-1).argmin(1) + return code, labels + + +def refine_mono_tiles(distL, tiles, labels, fg, bg, n_tiles, iters=25): + """Fixed-colour vector quantisation of a two-tone tile dictionary (shared by + the VIC-20 and Intellivision mono modes). With ink/paper fixed, alternately + re-assign each cell to its best tile and re-cut every tile's shape (a pixel is + ink where that lowers summed luminance error across the cells using the tile); + empty tiles are reseeded from the highest-contrast cells so the whole budget is + used. Returns (tiles, labels).""" + n, P, _ = distL.shape + dfg = distL[:, :, fg] # (n,P) error if pixel = ink + dbg = distL[:, :, bg] # (n,P) error if pixel = paper + worst = np.argsort(-(np.abs(dfg - dbg).sum(1))) + best = (tiles, labels) + best_err = np.inf + for _ in range(iters): + M = tiles.astype(np.float64) + cost = np.einsum('tp,np->nt', M, dfg) + np.einsum('tp,np->nt', 1.0 - M, dbg) + labels = cost.argmin(1) + newt = np.zeros((n_tiles, P), np.uint8) + wi = 0 + for t in range(n_tiles): + msk = labels == t + if msk.any(): + newt[t] = (dfg[msk].sum(0) < dbg[msk].sum(0)).astype(np.uint8) + else: + c = int(worst[wi % n]); wi += 1 + newt[t] = (dfg[c] < dbg[c]).astype(np.uint8) + tiles = newt + err = float(cost[np.arange(n), labels].sum()) + if err < best_err - 1e-6: + best_err = err + best = (tiles.copy(), labels.copy()) + else: + break + return best + + +def perceptual_error(index_image: np.ndarray, img_lab: np.ndarray, + palette_lab: np.ndarray) -> float: + """Delta-E after a light blur of both images -- credits dithering (the eye/CRT + averages the dither) so dithered results are ranked by how they actually look, + not penalised for per-pixel dither noise.""" + chosen = _box_blur(palette_lab[index_image]) + target = _box_blur(img_lab) + return float(np.sqrt(np.sum((chosen - target) ** 2, axis=-1)).mean()) diff --git a/c64view/convert/fli.py b/lenser/convert/fli.py similarity index 73% rename from c64view/convert/fli.py rename to lenser/convert/fli.py index 7aad0a2..f43f39e 100644 --- a/c64view/convert/fli.py +++ b/lenser/convert/fli.py @@ -42,10 +42,12 @@ def convert(img_rgb, palette_name="colodore", dither_mode="bayer", intensive=Fal dist = np.sum((a[:, :, :, None, :] - plab[None, None, None, :, :]) ** 2, axis=-1) # dist: (n_cells, 8, 4, 16) + aware = dither_mode in base.DIFFUSION_DITHERS # dither-aware (segment) scoring + strip = a if aware else None bg_candidates = range(16) if intensive else [base.best_global_color(img_lab, plab)] best = None for bg in bg_candidates: - c11, c01, c10, err = _solve(dist, bg) + c11, c01, c10, err = _solve(dist, bg, plab, strip) if best is None or err < best[-1]: best = (bg, c11, c01, c10, err) bg, c11, c01, c10, _ = best @@ -56,35 +58,61 @@ def convert(img_rgb, palette_name="colodore", dither_mode="bayer", intensive=Fal conv = base.Conversion( mode="fli", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, index_image=index_image, data=data, data_addr=DATA_ADDR, viewer="fli", - error=base.mean_error(index_image, img_lab, plab), + error=base.perceptual_error(index_image, img_lab, plab), meta={"palette": palette_name, "dither": dither_mode, "background": int(bg)}, ) return conv -def _solve(dist, bg): - """Pick per-cell colour-RAM colour c11 and per-line free colours c01,c10.""" +def _seg(strip, ca, cb): + """Squared distance from each strip pixel to segment ca-cb (Lab points); + ca may be (3,) or (n,1,1,3) for a per-cell endpoint, cb is (3,).""" + seg = cb - ca + L = np.sum(seg * seg, axis=-1, keepdims=True) + 1e-9 + t = np.clip(np.sum((strip - ca) * seg, axis=-1, keepdims=True) / L, 0.0, 1.0) + proj = ca + t * seg + return np.sum((strip - proj) ** 2, axis=-1) # (n,8,4) + + +def _solve(dist, bg, plab=None, strip=None): + """Pick per-cell colour-RAM colour c11 and per-line free colours c01,c10. + If ``strip`` (the Lab pixels) is given, score by segment distance, crediting + error-diffusion dithering (smoother gradients).""" n = dist.shape[0] dbg = dist[:, :, :, bg] # (n,8,4) + aware = strip is not None + cbg = plab[bg] if aware else None # c11: the single shared colour that best complements bg across the whole cell. cell_err = np.empty((16, n)) for c in range(16): - m = np.minimum(dbg, dist[:, :, :, c]) + m = _seg(strip, cbg, plab[c]) if aware else np.minimum(dbg, dist[:, :, :, c]) cell_err[c] = m.sum(axis=(1, 2)) cell_err[bg] = np.inf c11 = np.argmin(cell_err, axis=0) # (n,) # base error per strip using {bg, c11}. - dc11 = np.take_along_axis(dist, c11[:, None, None, None], axis=3)[..., 0] # (n,8,4) - sbase = np.minimum(dbg, dc11) # (n,8,4) + if aware: + c11c = plab[c11][:, None, None, :] # (n,1,1,3) per-cell endpoint + sbase = _seg(strip, cbg, c11c) # bg-c11 segment + else: + c11c = None + dc11 = np.take_along_axis(dist, c11[:, None, None, None], axis=3)[..., 0] + sbase = np.minimum(dbg, dc11) # (n,8,4) # per strip (cell,line) choose the best 2 free colours. best_err = np.full((n, 8), np.inf) c01 = np.zeros((n, 8), dtype=np.int64) c10 = np.zeros((n, 8), dtype=np.int64) for x, y in combinations(range(16), 2): - e = np.minimum(np.minimum(sbase, dist[:, :, :, x]), dist[:, :, :, y]).sum(axis=2) + if aware: + e = np.minimum(sbase, _seg(strip, plab[x], plab[y])) # c01-c10 blend + e = np.minimum(e, _seg(strip, c11c, plab[x])) # c11-c0x blends + e = np.minimum(e, _seg(strip, c11c, plab[y])) + e = e.sum(axis=2) + else: + e = np.minimum(np.minimum(sbase, dist[:, :, :, x]), + dist[:, :, :, y]).sum(axis=2) better = e < best_err best_err = np.where(better, e, best_err) c01 = np.where(better, x, c01) diff --git a/c64view/convert/hires.py b/lenser/convert/hires.py similarity index 81% rename from c64view/convert/hires.py rename to lenser/convert/hires.py index 1a28a3e..b9a647e 100644 --- a/c64view/convert/hires.py +++ b/lenser/convert/hires.py @@ -24,8 +24,14 @@ def convert(img_rgb: np.ndarray, palette_name="colodore", img_lab = pal.srgb_to_lab(img_rgb) cells, rows, cols = base.cells_lab(img_lab, CELL_W, CELL_H) - dist = base.cell_distance(cells, plab) - sets, _ = base.select_cell_sets(dist, range(16), n_free=2) + # Dither-aware colour selection for error-diffusion modes (the chosen pair + # brackets the cell so dithering blends to the true shade); plain + # nearest-colour for ordered/none. + if dither_mode in base.DIFFUSION_DITHERS: + sets, _ = base.select_cell_sets_dither(cells, plab, range(16), n_free=2) + else: + dist = base.cell_distance(cells, plab) + sets, _ = base.select_cell_sets(dist, range(16), n_free=2) allowed = base.per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH) index_image = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8) @@ -36,7 +42,7 @@ def convert(img_rgb: np.ndarray, palette_name="colodore", conv = base.Conversion( mode="hires", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, index_image=index_image, data=payload, viewer="hires", - error=base.mean_error(index_image, img_lab, plab), + error=base.perceptual_error(index_image, img_lab, plab), meta={"palette": palette_name, "dither": dither_mode}, ) # Standard OCP Art Studio hires file (load $2000): bitmap, screen, border. diff --git a/c64view/convert/ifli.py b/lenser/convert/ifli.py similarity index 70% rename from c64view/convert/ifli.py rename to lenser/convert/ifli.py index f15d432..558018c 100644 --- a/c64view/convert/ifli.py +++ b/lenser/convert/ifli.py @@ -38,15 +38,25 @@ def convert(img_rgb, palette_name="colodore", dither_mode="bayer", intensive=Fal prgb = pal.get_palette(palette_name) img_lab = pal.srgb_to_lab(img_rgb) + aware = dither_mode in base.DIFFUSION_DITHERS # dither-aware (segment) scoring + # ---- frame A: ordinary multicolor (bg + 3 free per cell) ---- cellsA, _, _ = base.cells_lab(img_lab, CELL_W, CELL_H) - distA = base.cell_distance(cellsA, plab) if intensive: - bg, setsA, _ = base.optimize_background(distA, n_free=3) + if aware: + bg, setsA, _ = base.optimize_background_dither(cellsA, plab, n_free=3) + else: + bg, setsA, _ = base.optimize_background(base.cell_distance(cellsA, plab), + n_free=3) else: bg = base.best_global_color(img_lab, plab) avail = [i for i in range(16) if i != bg] - setsA, _ = base.select_cell_sets(distA, avail, n_free=3, fixed=(bg,)) + if aware: + setsA, _ = base.select_cell_sets_dither(cellsA, plab, avail, n_free=3, + fixed=(bg,)) + else: + setsA, _ = base.select_cell_sets(base.cell_distance(cellsA, plab), + avail, n_free=3, fixed=(bg,)) # colour-RAM colour (shared by both frames) = third free colour of A. c11 = setsA[:, 3].astype(np.int64) @@ -60,7 +70,7 @@ def convert(img_rgb, palette_name="colodore", dither_mode="bayer", intensive=Fal resid_srgb = pal.linear_to_srgb(resid) resid_lab = pal.srgb_to_lab(resid_srgb) - setsB = _solve_frameB(resid_lab, plab, bg, c11) + setsB = _solve_frameB(resid_lab, plab, bg, c11, aware) allowedB = base.per_pixel_allowed(setsB, N_ROWS, N_COLS, CELL_W, CELL_H, HEIGHT, WIDTH) idxB = dither.quantize(resid_lab, allowedB, plab, dither_mode).astype(np.uint8) @@ -68,8 +78,9 @@ def convert(img_rgb, palette_name="colodore", dither_mode="bayer", intensive=Fal blend_lin = (pal.srgb_to_linear(prgb[idxA]) + pal.srgb_to_linear(prgb[idxB])) / 2.0 blend = pal.linear_to_srgb(blend_lin) preview = np.repeat(blend, int(round(PIXEL_ASPECT)), axis=1) - blend_lab = pal.srgb_to_lab(blend) - error = float(np.sqrt(np.sum((blend_lab - img_lab) ** 2, axis=-1)).mean()) + # perceptual error of the blend (blur credits the dither the eye averages). + bl = base._box_blur(pal.srgb_to_lab(blend)); tg = base._box_blur(img_lab) + error = float(np.sqrt(np.sum((bl - tg) ** 2, axis=-1)).mean()) data = _encode(idxA, idxB, setsA, setsB, bg, c11) @@ -81,20 +92,41 @@ def convert(img_rgb, palette_name="colodore", dither_mode="bayer", intensive=Fal ) -def _solve_frameB(resid_lab, plab, bg, c11): - """Per cell, pick the 2 free colours for frame B given shared {bg, c11[cell]}.""" - cells, _, _ = base.cells_lab(resid_lab, CELL_W, CELL_H) - dist = base.cell_distance(cells, plab) # (n, P, 16) - dbg = dist[:, :, bg] # (n, P) - dc11 = np.take_along_axis(dist, c11[:, None, None], axis=2)[:, :, 0] - sbase = np.minimum(dbg, dc11) +def _segc(cells, ca, cb): + """Per-pixel squared distance to segment ca-cb; ca may be (n,1,3), cb (3,).""" + seg = cb - ca + L = np.sum(seg * seg, axis=-1, keepdims=True) + 1e-9 + t = np.clip(np.sum((cells - ca) * seg, axis=-1, keepdims=True) / L, 0.0, 1.0) + return np.sum((cells - (ca + t * seg)) ** 2, axis=-1) # (n, P) + + +def _solve_frameB(resid_lab, plab, bg, c11, aware=False): + """Per cell, pick the 2 free colours for frame B given shared {bg, c11[cell]}. + With ``aware`` the colours are scored by segment distance (dither-aware).""" + cells, _, _ = base.cells_lab(resid_lab, CELL_W, CELL_H) + n = cells.shape[0] + cbg = plab[bg] + c11c = plab[c11][:, None, :] # (n,1,3) per-cell c11 + if aware: + sbase = _segc(cells, cbg[None, None, :], c11c) # bg-c11 segment + else: + dist = base.cell_distance(cells, plab) + dbg = dist[:, :, bg] + dc11 = np.take_along_axis(dist, c11[:, None, None], axis=2)[:, :, 0] + sbase = np.minimum(dbg, dc11) - n = dist.shape[0] best = np.full(n, np.inf) b1 = np.zeros(n, dtype=np.int64) b2 = np.zeros(n, dtype=np.int64) for x, y in combinations(range(16), 2): - e = np.minimum(np.minimum(sbase, dist[:, :, x]), dist[:, :, y]).sum(axis=1) + if aware: + e = np.minimum(sbase, _segc(cells, plab[x], plab[y])) # b1-b2 blend + e = np.minimum(e, _segc(cells, c11c, plab[x])) # c11-bx blends + e = np.minimum(e, _segc(cells, c11c, plab[y])) + e = e.sum(axis=1) + else: + e = np.minimum(np.minimum(sbase, dist[:, :, x]), + dist[:, :, y]).sum(axis=1) better = e < best best = np.where(better, e, best) b1 = np.where(better, x, b1) diff --git a/c64view/convert/mono.py b/lenser/convert/mono.py similarity index 84% rename from c64view/convert/mono.py rename to lenser/convert/mono.py index 0dac911..63cb8e1 100644 --- a/c64view/convert/mono.py +++ b/lenser/convert/mono.py @@ -55,8 +55,13 @@ def convert(img_rgb, palette_name="colodore", dither_mode="floyd", n_free = min(2, len(ramp)) cells, rows, cols = base.cells_lab(img_mono, CELL_W, CELL_H) - dist = base.cell_distance(cells, plab_mono) - sets, _ = base.select_cell_sets(dist, ramp, n_free=n_free) + # Dither-aware selection picks the two ramp levels that bracket each cell's + # luminance so dithering blends to the true shade (smoother greys). + if n_free >= 2 and dither_mode in base.DIFFUSION_DITHERS: + sets, _ = base.select_cell_sets_dither(cells, plab_mono, ramp, n_free=n_free) + else: + dist = base.cell_distance(cells, plab_mono) + sets, _ = base.select_cell_sets(dist, ramp, n_free=n_free) if n_free == 1: # pad to 2 colours per cell for hires sets = np.concatenate([sets, sets], axis=1) @@ -69,7 +74,7 @@ def convert(img_rgb, palette_name="colodore", dither_mode="floyd", conv = base.Conversion( mode="mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, index_image=index_image, data=payload, data_addr=DATA_LOAD, viewer="hires", - error=base.mean_error(index_image, img_mono, plab_mono), + error=base.perceptual_error(index_image, img_mono, plab_mono), meta={"palette": palette_name, "dither": dither_mode, "base_color": base_color, "ramp": ramp}, ) diff --git a/c64view/convert/multicolor.py b/lenser/convert/multicolor.py similarity index 80% rename from c64view/convert/multicolor.py rename to lenser/convert/multicolor.py index 0abdc70..edd86f9 100644 --- a/c64view/convert/multicolor.py +++ b/lenser/convert/multicolor.py @@ -30,14 +30,23 @@ def convert(img_rgb: np.ndarray, palette_name="colodore", img_lab = pal.srgb_to_lab(img_rgb) cells, rows, cols = base.cells_lab(img_lab, CELL_W, CELL_H) - dist = base.cell_distance(cells, plab) + aware = dither_mode in base.DIFFUSION_DITHERS # dither-aware colour selection if intensive: - bg, sets, _ = base.optimize_background(dist, n_free=3) + if aware: + bg, sets, _ = base.optimize_background_dither(cells, plab, n_free=3) + else: + bg, sets, _ = base.optimize_background(base.cell_distance(cells, plab), + n_free=3) else: bg = base.best_global_color(img_lab, plab) avail = [i for i in range(16) if i != bg] - sets, _ = base.select_cell_sets(dist, avail, n_free=3, fixed=(bg,)) + if aware: + sets, _ = base.select_cell_sets_dither(cells, plab, avail, n_free=3, + fixed=(bg,)) + else: + dist = base.cell_distance(cells, plab) + sets, _ = base.select_cell_sets(dist, avail, n_free=3, fixed=(bg,)) allowed = base.per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH) index_image = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8) @@ -49,7 +58,7 @@ def convert(img_rgb: np.ndarray, palette_name="colodore", conv = base.Conversion( mode="multicolor", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, index_image=index_image, data=payload, viewer="multicolor", - error=base.mean_error(index_image, img_lab, plab), + error=base.perceptual_error(index_image, img_lab, plab), meta={"palette": palette_name, "dither": dither_mode, "background": bg}, ) # Standard "Koala Painter" file (load $6000) for use in other C64 art tools. diff --git a/lenser/cpc/__init__.py b/lenser/cpc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lenser/cpc/convert/__init__.py b/lenser/cpc/convert/__init__.py new file mode 100644 index 0000000..d3391cd --- /dev/null +++ b/lenser/cpc/convert/__init__.py @@ -0,0 +1,19 @@ +"""Amstrad CPC conversion dispatch.""" +from __future__ import annotations + +from ... import imageprep +from . import mode0, mode1, mono + +_MODULES = {"mode0": mode0, "mode1": mode1, "mono": mono} +MODES = list(_MODULES.keys()) + + +def convert_image(path_or_img, mode="mode0", palette_name="cpc", + dither_mode="floyd", intensive=False, prep_opt=None, + base_color=None): + prep_opt = prep_opt or imageprep.PrepOptions() + module = _MODULES.get(mode, mode0) + 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) diff --git a/lenser/cpc/convert/_common.py b/lenser/cpc/convert/_common.py new file mode 100644 index 0000000..96dba2a --- /dev/null +++ b/lenser/cpc/convert/_common.py @@ -0,0 +1,97 @@ +"""Shared CPC encoder helpers: global palette selection + per-mode bit packing. + +The CPC has no per-cell colour limit -- a mode draws a flat palette of pens +(16 in mode 0, 4 in mode 1, 2 in mode 2), each pen any of the 27 colours. So the +job is: choose the best N-colour sub-palette for the whole image, dither to it, +then pack pens into the CPC's (scrambled) screen bytes. +""" +from __future__ import annotations + +import numpy as np + +from ... import dither, palette as c64pal +from .. import palette as cpcpal +from ..snapshot import screen_offset + + +def choose_inks(img_lab, plab, n): + """Greedy forward selection of the ``n`` palette colours (indices into the + 27-colour PALETTE) that minimise nearest-colour error over the image.""" + flat = img_lab.reshape(-1, 3) + d = np.sum((flat[:, None, :] - plab[None, :, :]) ** 2, axis=-1) # (px, 27) + chosen = [] + best = np.full(flat.shape[0], np.inf) + for _ in range(n): + # pick the colour that most reduces the running per-pixel min distance + cand_err = np.minimum(best[:, None], d).sum(0) # (27,) + for c in chosen: + cand_err[c] = np.inf + c = int(cand_err.argmin()) + chosen.append(c) + best = np.minimum(best, d[:, c]) + return sorted(chosen) + + +def render(img_rgb, n, dither_mode, ramp=None): + """Pick (or use ``ramp``) an n-colour palette, dither the image to it, and + return (pen_image (H,W) of 0..n-1, inks (n hardware-ink numbers), idx_image + (H,W) palette indices, plab, prgb).""" + plab = cpcpal.palette_lab() + prgb = cpcpal.get_palette().astype(np.uint8) + img_lab = c64pal.srgb_to_lab(img_rgb) + + pal_idx = ramp if ramp is not None else choose_inks(img_lab, plab, n) + pal_idx = list(pal_idx) + allowed = np.tile(np.array(pal_idx), (*img_lab.shape[:2], 1)) + idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64) + + # map palette index -> pen (0..n-1) + lut = {p: k for k, p in enumerate(pal_idx)} + pen = np.vectorize(lut.get)(idx).astype(np.uint8) + inks = [cpcpal.ink_byte(p) for p in pal_idx] + return pen, inks, idx.astype(np.uint16), img_lab, plab, prgb + + +def _pack_mode0(pen): + """160x200 pens (0-15) -> 16K screen. 2 pixels/byte, scrambled bits.""" + scr = bytearray(0x4000) + for y in range(200): + row = pen[y] + for bx in range(80): + L = int(row[bx * 2]); R = int(row[bx * 2 + 1]) + byte = (((L & 1) << 7) | ((R & 1) << 6) | (((L >> 2) & 1) << 5) | + (((R >> 2) & 1) << 4) | (((L >> 1) & 1) << 3) | + (((R >> 1) & 1) << 2) | (((L >> 3) & 1) << 1) | ((R >> 3) & 1)) + scr[screen_offset(y, bx)] = byte + return bytes(scr) + + +def _pack_mode1(pen): + """320x200 pens (0-3) -> 16K screen. 4 pixels/byte.""" + scr = bytearray(0x4000) + for y in range(200): + row = pen[y] + for bx in range(80): + p = [int(row[bx * 4 + k]) for k in range(4)] + byte = 0 + for k in range(4): + byte |= (p[k] & 1) << (7 - k) # bit0 of each pen + byte |= ((p[k] >> 1) & 1) << (3 - k) # bit1 of each pen + scr[screen_offset(y, bx)] = byte + return bytes(scr) + + +def _pack_mode2(pen): + """640x200 pens (0-1) -> 16K screen. 8 pixels/byte, MSB leftmost.""" + scr = bytearray(0x4000) + for y in range(200): + row = pen[y] + for bx in range(80): + byte = 0 + for k in range(8): + byte |= (int(row[bx * 8 + k]) & 1) << (7 - k) + scr[screen_offset(y, bx)] = byte + return bytes(scr) + + +PACK = {0: _pack_mode0, 1: _pack_mode1, 2: _pack_mode2} diff --git a/lenser/cpc/convert/mode0.py b/lenser/cpc/convert/mode0.py new file mode 100644 index 0000000..97fd5ce --- /dev/null +++ b/lenser/cpc/convert/mode0.py @@ -0,0 +1,22 @@ +"""CPC Mode 0: 160x200, 16 colours from the 27-colour palette (the flagship +photo mode -- wide pixels but the most colour).""" +from __future__ import annotations + +from ...convert.base import Conversion, perceptual_error +from . import _common + +WIDTH, HEIGHT = 160, 200 +PIXEL_ASPECT = 2.0 # 160x200 on a 4:3 screen -> pixels twice as wide +NCOL = 16 + + +def convert(img_rgb, palette_name="cpc", dither_mode="floyd", + intensive=False, base_color=None): + pen, inks, idx, img_lab, plab, prgb = _common.render(img_rgb, NCOL, dither_mode) + screen = _common.PACK[0](pen) + return Conversion( + mode="mode0", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=idx, data=screen, data_addr=0xC000, viewer="cpc", + preview_rgb=prgb[idx], error=perceptual_error(idx, img_lab, plab), + meta={"palette": "cpc", "dither": dither_mode, "cpc_mode": 0, "inks": inks}, + ) diff --git a/lenser/cpc/convert/mode1.py b/lenser/cpc/convert/mode1.py new file mode 100644 index 0000000..9d5efa7 --- /dev/null +++ b/lenser/cpc/convert/mode1.py @@ -0,0 +1,22 @@ +"""CPC Mode 1: 320x200, 4 colours from the 27-colour palette (more resolution, +fewer colours than mode 0).""" +from __future__ import annotations + +from ...convert.base import Conversion, perceptual_error +from . import _common + +WIDTH, HEIGHT = 320, 200 +PIXEL_ASPECT = 1.0 +NCOL = 4 + + +def convert(img_rgb, palette_name="cpc", dither_mode="floyd", + intensive=False, base_color=None): + pen, inks, idx, img_lab, plab, prgb = _common.render(img_rgb, NCOL, dither_mode) + screen = _common.PACK[1](pen) + return Conversion( + mode="mode1", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=idx, data=screen, data_addr=0xC000, viewer="cpc", + preview_rgb=prgb[idx], error=perceptual_error(idx, img_lab, plab), + meta={"palette": "cpc", "dither": dither_mode, "cpc_mode": 1, "inks": inks}, + ) diff --git a/lenser/cpc/convert/mono.py b/lenser/cpc/convert/mono.py new file mode 100644 index 0000000..9fc0365 --- /dev/null +++ b/lenser/cpc/convert/mono.py @@ -0,0 +1,47 @@ +"""CPC monochrome: Mode 2, 640x200, 2 colours (black + bright white) -- the +highest-resolution CPC mode, tone carried by dithering. ``--mono-base`` tints +the second tone to a chosen hue.""" +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 cpcpal +from . import _common + +WIDTH, HEIGHT = 640, 200 +PIXEL_ASPECT = 0.5 # 640x200 on a 4:3 screen -> pixels half as wide +NCOL = 2 + + +def convert(img_rgb, palette_name="cpc", dither_mode="floyd", + intensive=False, base_color=None): + plab = cpcpal.palette_lab() + # black + the lightest tone (bright white), or black + a tint + black = min(cpcpal.GREYS, key=lambda i: plab[i, 0]) + if base_color in range(len(cpcpal.PALETTE)) and base_color != black: + ramp = sorted({black, int(base_color)}, key=lambda i: plab[i, 0]) + else: + ramp = sorted(cpcpal.GREYS, key=lambda i: plab[i, 0]) + ramp = [ramp[0], ramp[-1]] # black + brightest grey + # collapse to luminance so the two tones bracket the image's lightness + img_lab = c64pal.srgb_to_lab(img_rgb) + mono_lab = np.zeros_like(img_lab); mono_lab[..., 0] = img_lab[..., 0] + plab_mono = np.zeros_like(plab); plab_mono[:, 0] = plab[:, 0] + + allowed = np.tile(np.array(ramp), (*mono_lab.shape[:2], 1)) + idx = dither.quantize(mono_lab, allowed, plab_mono, dither_mode).astype(np.int64) + lut = {p: k for k, p in enumerate(ramp)} + pen = np.vectorize(lut.get)(idx).astype(np.uint8) + inks = [cpcpal.ink_byte(p) for p in ramp] + + screen = _common.PACK[2](pen) + prgb = cpcpal.get_palette().astype(np.uint8) + return Conversion( + mode="mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=idx.astype(np.uint16), data=screen, data_addr=0xC000, + viewer="cpc", preview_rgb=prgb[idx.astype(np.uint16)], + error=perceptual_error(idx, mono_lab, plab_mono), + meta={"palette": "cpc", "dither": dither_mode, "cpc_mode": 2, "inks": inks}, + ) diff --git a/lenser/cpc/exporter.py b/lenser/cpc/exporter.py new file mode 100644 index 0000000..2a45aea --- /dev/null +++ b/lenser/cpc/exporter.py @@ -0,0 +1,18 @@ +"""Build an Amstrad CPC .sna snapshot from a conversion.""" +from __future__ import annotations + +from . import snapshot + +_EXTS = (".sna",) + + +def export_sna(conv, output_path, source_path=None, display="forever", + seconds=0, video="ntsc"): + if not output_path.lower().endswith(_EXTS): + output_path += ".sna" + inks = conv.meta["inks"] + mode = conv.meta["cpc_mode"] + sna = snapshot.build_sna(bytes(conv.data), inks, mode, border=inks[0]) + with open(output_path, "wb") as f: + f.write(sna) + return output_path diff --git a/lenser/cpc/palette.py b/lenser/cpc/palette.py new file mode 100644 index 0000000..628dec9 --- /dev/null +++ b/lenser/cpc/palette.py @@ -0,0 +1,60 @@ +"""Amstrad CPC (Gate Array) colour palette. + +The CPC has a fixed palette of 27 colours (3 levels -- 0x00, 0x60, 0xFF -- of +R, G and B). The Gate Array selects them by a 5-bit "hardware ink" number 0-31; +those 32 numbers map onto the 27 colours (a few duplicates). RGB values and the +hardware-ink ordering are taken verbatim from MAME's ``amstrad_palette[32]`` so +the encoder matches exactly what the emulator renders. + +A pen (the value stored per pixel: 0-15 in mode 0, 0-3 in mode 1, 0-1 in mode 2) +is assigned an ink via the Gate Array palette; the .sna stores those 17 ink +numbers (16 pens + border). We therefore index colours by HARDWARE INK NUMBER, +so ``HW_INK[k]`` is both this palette's RGB and the byte written to the snapshot. +""" +from __future__ import annotations + +import numpy as np + +from ..palette import srgb_to_lab + +# amstrad_palette[32] from MAME (src/mame/amstrad/amstrad_m.cpp), indexed by the +# Gate Array hardware ink number. +HW_INK = np.array([ + (96, 96, 96), (96, 96, 96), (0, 255, 96), (255, 255, 96), + (0, 0, 96), (255, 0, 96), (0, 96, 96), (255, 96, 96), + (255, 0, 96), (255, 255, 96), (255, 255, 0), (255, 255, 255), + (255, 0, 0), (255, 0, 255), (255, 96, 0), (255, 96, 255), + (0, 0, 96), (0, 255, 96), (0, 255, 0), (0, 255, 255), + (0, 0, 0), (0, 0, 255), (0, 96, 0), (0, 96, 255), + (96, 0, 96), (96, 255, 96), (96, 255, 0), (96, 255, 255), + (96, 0, 0), (96, 0, 255), (96, 96, 0), (96, 96, 255), +], dtype=np.float64) + +# The 27 unique colours, each as the FIRST hardware ink number that produces it +# (so encoders work with a clean 27-entry palette, and INK[i] gives the snapshot +# byte for unique colour i). +INK = [] +_seen = {} +for _k, _rgb in enumerate(map(tuple, HW_INK.astype(int))): + if _rgb not in _seen: + _seen[_rgb] = _k + INK.append(_k) +INK = np.array(INK, dtype=np.int64) # 27 hardware ink numbers +PALETTE = HW_INK[INK] # 27 unique RGB colours + +# Neutral grey ramp (for the monochrome mode): black, grey, bright white. +GREYS = [i for i, (r, g, b) in enumerate(map(tuple, PALETTE.astype(int))) + if r == g == b] # indices into PALETTE + + +def get_palette() -> np.ndarray: + return PALETTE + + +def palette_lab() -> np.ndarray: + return srgb_to_lab(PALETTE) + + +def ink_byte(unique_index: int) -> int: + """Hardware ink number (snapshot byte) for a unique-palette colour index.""" + return int(INK[unique_index]) diff --git a/lenser/cpc/snapshot.py b/lenser/cpc/snapshot.py new file mode 100644 index 0000000..feff450 --- /dev/null +++ b/lenser/cpc/snapshot.py @@ -0,0 +1,77 @@ +"""Build an Amstrad CPC .SNA snapshot (CPCEMU v1) from a screen image. + +MAME loads a .sna by restoring the Z80, Gate Array (mode + 16-pen palette), +CRTC and 64K RAM, so the picture appears instantly with no loader. We bake the +16K screen at &C000, set the Gate Array palette + mode, program a standard 80x200 +CRTC screen, and point the CPU at a tiny ``DI: JR $`` idle stub -- the CRTC then +DMAs the screen forever while the Z80 idles. + +Header layout (offsets) per MAME's amstrad_handle_snapshot: + 0x00 "MV - SNA" signature 0x10 version + 0x11 AF 0x13 BC 0x15 DE 0x17 HL 0x19 R 0x1a I 0x1b IFF1 0x1c IFF2 + 0x1d IX 0x1f IY 0x21 SP 0x23 PC 0x25 IM 0x26 AF' .. 0x2c HL' + 0x2e GA selected pen 0x2f..0x3f 17 ink numbers (16 pens + border) + 0x40 GA multi-config (mode/rom) 0x41 RAM config + 0x42 CRTC selected reg 0x43..0x54 18 CRTC regs 0x55 upper ROM + 0x56..0x58 PPI A/B/C 0x59 PPI control 0x5a PSG reg 0x5b..0x6a PSG regs + 0x6b memory size (KB) 0x100 RAM dump +""" +from __future__ import annotations + +import struct + +SCREEN_BASE = 0xC000 +SCREEN_LEN = 0x4000 # 16K +STUB_ADDR = 0x8000 # idle loop DI; JR $ (central RAM, always present) +STUB = bytes([0xF3, 0x18, 0xFE]) +RAM_SIZE = 0x10000 # 64K + +# Standard CPC 50Hz CRTC register set for a 40x25-char (80x200 byte) screen at +# &C000 (R12/R13 = 0x30/0x00). +CRTC = [0x3F, 0x28, 0x2E, 0x8E, 0x26, 0x00, 0x19, 0x1E, + 0x00, 0x07, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00] + + +def screen_offset(y: int, bx: int) -> int: + """Offset within the 16K screen for scan line ``y`` (0-199), byte column + ``bx`` (0-79) -- the CPC's CRTC interleave (8 lines per char row).""" + return (y % 8) * 0x800 + (y // 8) * 80 + bx + + +def build_sna(screen: bytes, inks, mode: int, border: int = 20) -> bytes: + """screen: 16384 bytes (&C000 RAM); inks: 16 hardware ink numbers (pens); + mode: 0/1/2; border: hardware ink number for the border pen.""" + if len(screen) != SCREEN_LEN: + raise ValueError(f"screen must be {SCREEN_LEN} bytes, got {len(screen)}") + if not 1 <= len(inks) <= 16: + raise ValueError(f"need 1-16 ink numbers, got {len(inks)}") + # the Gate Array always has 16 pen slots; modes 1/2 use only the first 4/2. + inks = list(inks) + [border] * (16 - len(inks)) + + ram = bytearray(RAM_SIZE) + ram[STUB_ADDR:STUB_ADDR + len(STUB)] = STUB + ram[SCREEN_BASE:SCREEN_BASE + SCREEN_LEN] = screen + + h = bytearray(0x100) + h[0x00:0x10] = b"MV - SNAPSHOT V1" # signature (MAME checks "MV - SNA") + h[0x10] = 1 # version + # Z80: everything 0 except SP/PC/IM; DI (IFF=0) and HALT-free idle stub. + struct.pack_into(" bytes: + c = bytearray(16) + c[0:4] = b"CHIP" + struct.pack_into(">I", c, 4, 16 + len(payload)) # packet length + struct.pack_into(">H", c, 8, 0x0000) # chip type: ROM + struct.pack_into(">H", c, 10, 0x0000) # bank + struct.pack_into(">H", c, 12, load_addr) + struct.pack_into(">H", c, 14, len(payload)) + return bytes(c) + payload + + +def write_crt(rom: bytes, path: str, name: str = "8bitlenser", + split: bool = False) -> str: + """Wrap a 16K cart `rom` ($8000-$BFFF) in a .crt (16K config: EXROM & GAME + both 0). ``split`` False = one 16K CHIP (VICE); True = two 8K CHIPs (MAME).""" + if len(rom) != CART_SIZE: + raise ValueError(f"expected a 16K cart ROM, got {len(rom)} bytes") + + header = bytearray(64) + header[0:16] = b"C64 CARTRIDGE " + struct.pack_into(">I", header, 16, 64) # header length + struct.pack_into(">H", header, 20, 0x0100) # version 1.0 + struct.pack_into(">H", header, 22, 0x0000) # hardware type: normal/generic + header[24] = 0 # EXROM line (asserted) + header[25] = 0 # GAME line (asserted) -> 16K + nm = name.encode("ascii", "replace")[:32] + header[32:32 + len(nm)] = nm + + with open(path, "wb") as f: + f.write(header) + if split: + f.write(_chip(0x8000, rom[:0x2000])) # ROML + f.write(_chip(0xA000, rom[0x2000:])) # ROMH + else: + f.write(_chip(0x8000, rom)) # one 16K CHIP + return path + + +def read_rom(path: str) -> bytes: + """Extract the raw 16K cart ROM from a .crt (handles one- or two-CHIP).""" + with open(path, "rb") as f: + data = f.read() + hdr_len = struct.unpack_from(">I", data, 16)[0] + rom = bytearray() + pos = hdr_len + while pos + 16 <= len(data) and data[pos:pos + 4] == b"CHIP": + plen = struct.unpack_from(">I", data, pos + 4)[0] + size = struct.unpack_from(">H", data, pos + 14)[0] + rom += data[pos + 16:pos + 16 + size] + pos += plen + return bytes(rom) diff --git a/c64view/diskimage.py b/lenser/diskimage.py similarity index 68% rename from c64view/diskimage.py rename to lenser/diskimage.py index af53125..80206cc 100644 --- a/c64view/diskimage.py +++ b/lenser/diskimage.py @@ -29,47 +29,6 @@ def have_c1541() -> bool: return shutil.which("c1541") is not None -# x64sc is the accurate C64 emulator; x64 is the faster fallback. -VICE_EMULATORS = ["x64sc", "x64"] - - -def vice_emulator() -> str | None: - for exe in VICE_EMULATORS: - path = shutil.which(exe) - if path: - return path - return None - - -def have_vice() -> bool: - return vice_emulator() is not None - - -def launch_in_vice(disk_path: str, warp: bool = True, standard: str = "pal"): - """Open VICE on ``disk_path`` (drive 8), list the directory, then run the viewer. - - Types ``LOAD"$",8`` / ``LIST`` / ``LOAD"*",8,1`` / ``RUN`` via the keyboard - buffer. BASIC commands must be *lower* case here: VICE maps lower-case ASCII - to the PETSCII keyword range, and "\\n" is the RETURN key. Runs detached so - the GUI stays responsive. ``warp`` should be False for the interlace mode, - whose 50 Hz field-flip flickers too fast under warp. - """ - exe = vice_emulator() - if not exe: - raise DiskError( - "VICE (x64sc) was not found on PATH.\n" - "Install it with: sudo apt install vice (Debian/Ubuntu)") - keys = 'load"$",8\nlist\nload"*",8,1\nrun\n' - # -default keeps the device config predictable so LOAD"*" reads the attached - # image rather than a host-filesystem virtual device; -warp runs full speed. - cmd = [exe, "-default", "-ntsc" if standard == "ntsc" else "-pal"] - if warp: - cmd.append("-warp") - cmd += ["-8", os.path.abspath(disk_path), "-keybuf", keys] - return subprocess.Popen(cmd, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, start_new_session=True) - - def petscii_name(text: str, maxlen: int = 16) -> str: """Sanitise a host string into a legal CBM filename. @@ -82,7 +41,7 @@ def petscii_name(text: str, maxlen: int = 16) -> str: for ch in text.lower(): if ch.isalnum() or ch in " -+.": out.append(ch) - name = "".join(out).strip() or "c64view" + name = "".join(out).strip() or "8bitlenser" return name[:maxlen] diff --git a/lenser/dither.py b/lenser/dither.py new file mode 100644 index 0000000..24c3fec --- /dev/null +++ b/lenser/dither.py @@ -0,0 +1,385 @@ +"""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", "bluenoise", "yliluoma", "floyd", "atkinson", "stucki", + "jarvis", "sierra", "sierra_lite", "burkes", "riemersma", + "ostromoukhov", "none"] + + +def _bayer_int(n: int) -> np.ndarray: + """Integer Bayer matrix with values 0..n*n-1 (n a power of two).""" + if n == 1: + return np.array([[0]]) + s = _bayer_int(n // 2) + return np.block([ + [4 * s + 0, 4 * s + 2], + [4 * s + 3, 4 * s + 1], + ]) + + +def bayer_matrix(n: int) -> np.ndarray: + """Normalised (0..1) Bayer threshold matrix of size n x n (n power of two). + + Thresholds are centred at (i + 0.5) / n^2 so they span (0,1) symmetrically -- + normalising only once (not at every recursion level, which would compress the + range and collapse the dither toward a single threshold).""" + return (_bayer_int(n) + 0.5) / (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 _ordered_core(img_lab, allowed, palette_lab, thr_full, strength=1.0): + """Ordered dithering between each pixel's two best colours, using a supplied + (H,W) threshold map (Bayer, blue-noise, ...). For every pixel we take its + nearest and second-nearest allowed colour, project the pixel onto the segment + between them, and the threshold decides which of the two to emit -- smooth + 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) + + chosen = np.where(t > thr_full, i2, i1) + return np.take_along_axis(allowed, chosen[..., None], axis=-1)[..., 0] + + +def quantize_ordered(img_lab, allowed, palette_lab, strength=1.0, n=8): + """Ordered (Bayer) dithering.""" + H, W, _ = img_lab.shape + yy, xx = np.indices((H, W)) + thr = bayer_matrix(n) + return _ordered_core(img_lab, allowed, palette_lab, thr[yy % n, xx % n], strength) + + +_BLUENOISE = {} # cached void-and-cluster matrices, keyed by size + + +def bluenoise_matrix(n: int = 64, sigma: float = 1.9) -> np.ndarray: + """Tileable n x n blue-noise threshold matrix (0..1), via void-and-cluster + (Ulichney). Like Bayer but with no regular cross-hatch grid -- the dot + pattern is isotropic and organic, so gradients look far cleaner.""" + if n in _BLUENOISE: + return _BLUENOISE[n] + rng = np.random.default_rng(12345) + # toroidal Gaussian centred at (0,0); rolling it centres it on any pixel + ax = np.minimum(np.arange(n), n - np.arange(n)) + g = np.exp(-(ax[:, None] ** 2 + ax[None, :] ** 2) / (2 * sigma ** 2)) + + def roll(p): + r, c = divmod(p, n) + return np.roll(np.roll(g, r, axis=0), c, axis=1) + + binary = np.zeros((n, n), bool) + ones = max(1, (n * n) // 10) + for p in rng.choice(n * n, ones, replace=False): + binary.flat[p] = True + e = np.zeros((n, n)) + for p in np.flatnonzero(binary): + e += roll(p) + + # phase 1: even out the initial pattern (move tightest cluster -> largest void) + while True: + tc = int(np.argmax(np.where(binary.flat, e.flat, -np.inf))) + binary.flat[tc] = False; e -= roll(tc) + lv = int(np.argmin(np.where(binary.flat, np.inf, e.flat))) + binary.flat[lv] = True; e += roll(lv) + if lv == tc: + break + + rank = np.full(n * n, -1, np.int64) + proto = binary.copy(); ep = e.copy() + # phase 2a: remove tightest clusters, ranking downward + b = proto.copy(); e = ep.copy(); cnt = int(b.sum()) + for r in range(cnt - 1, -1, -1): + tc = int(np.argmax(np.where(b.flat, e.flat, -np.inf))) + b.flat[tc] = False; e -= roll(tc); rank[tc] = r + # phase 2b: fill largest voids, ranking upward + b = proto.copy(); e = ep.copy() + for r in range(cnt, n * n): + lv = int(np.argmin(np.where(b.flat, np.inf, e.flat))) + b.flat[lv] = True; e += roll(lv); rank[lv] = r + + m = (rank.reshape(n, n) + 0.5) / (n * n) + _BLUENOISE[n] = m + return m + + +def quantize_bluenoise(img_lab, allowed, palette_lab, n=64): + """Ordered dithering with a blue-noise mask instead of Bayer (no grid).""" + H, W, _ = img_lab.shape + yy, xx = np.indices((H, W)) + thr = bluenoise_matrix(n) + return _ordered_core(img_lab, allowed, palette_lab, thr[yy % n, xx % n]) + + +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)] +# Sierra-3: like Jarvis but lighter third row -> a touch less smearing. +_SIERRA = [(1, 0, 5), (2, 0, 3), + (-2, 1, 2), (-1, 1, 4), (0, 1, 5), (1, 1, 4), (2, 1, 2), + (-1, 2, 2), (0, 2, 3), (1, 2, 2)] +# Sierra-Lite: tiny 3-tap kernel -> crisp, fast, minimal bleed across cells. +_SIERRA_LITE = [(1, 0, 2), (-1, 1, 1), (0, 1, 1)] +# Burkes: two-row Stucki relative -> smooth gradients, cheaper than Stucki. +_BURKES = [(1, 0, 8), (2, 0, 4), + (-2, 1, 2), (-1, 1, 4), (0, 1, 8), (1, 1, 4), (2, 1, 2)] + + +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_sierra(img_lab, allowed, palette_lab): + return _quantize_diffusion(img_lab, allowed, palette_lab, _SIERRA, 32) + + +def quantize_sierra_lite(img_lab, allowed, palette_lab): + return _quantize_diffusion(img_lab, allowed, palette_lab, _SIERRA_LITE, 4) + + +def quantize_burkes(img_lab, allowed, palette_lab): + return _quantize_diffusion(img_lab, allowed, palette_lab, _BURKES, 32) + + +def _hilbert_path(W: int, H: int): + """Sequence of (x,y) covering a WxH grid along a Hilbert space-filling curve + (points outside the grid are skipped). Used by Riemersma dithering so the + 1-D error trail stays spatially compact in 2-D.""" + order = 1 + while (1 << order) < max(W, H): + order += 1 + side = 1 << order + pts = [] + for d in range(side * side): + rx = ry = 0 + t = d + x = y = 0 + s = 1 + while s < side: + rx = 1 & (t // 2) + ry = 1 & (t ^ rx) + if ry == 0: # rotate quadrant + if rx == 1: + x = s - 1 - x + y = s - 1 - y + x, y = y, x + x += s * rx + y += s * ry + t //= 4 + s <<= 1 + if x < W and y < H: + pts.append((x, y)) + return pts + + +def quantize_riemersma(img_lab, allowed, palette_lab, qlen=16, ratio=1.0 / 16): + """Riemersma dithering: error diffusion along a Hilbert curve with a decaying + memory of recent errors (geometric weights). Spreads error in every + direction without the directional grain of raster diffusion.""" + H, W, _ = img_lab.shape + pal = palette_lab + out = np.zeros((H, W), dtype=np.int64) + # geometric weights, most-recent first, summing to 1 (error-conserving) + w = ratio ** (np.arange(qlen) / max(1, qlen - 1)) + w = w / w.sum() + hist = np.zeros((qlen, 3), dtype=np.float64) + for x, y in _hilbert_path(W, H): + target = img_lab[y, x] + w @ hist + allow = allowed[y, x] + cand = pal[allow] + diff = cand - target + k = int(allow[np.argmin(np.sum(diff * diff, axis=-1))]) + out[y, x] = k + hist[1:] = hist[:-1] + hist[0] = img_lab[y, x] - pal[k] + return out + + +# Ostromoukhov variable-coefficient error diffusion ("A Simple and Efficient +# Error-Diffusion Algorithm", SIGGRAPH 2001). The three forward coefficients +# (right, below-left, below) vary with the *tone* being quantised, which breaks +# up the worm artefacts of fixed-kernel diffusion. Table given at breakpoints +# (luminance 0..255) and linearly interpolated; symmetric about 127.5. +_OSTRO_KNOTS = np.array([ + (0, 13, 0, 5), (1, 13, 0, 5), (2, 21, 0, 10), (3, 7, 0, 4), + (4, 8, 0, 5), (10, 47, 3, 28), (15, 23, 3, 13), (16, 15, 3, 11), + (32, 43, 7, 36), (64, 21, 8, 21), (96, 39, 7, 35), (112, 19, 7, 17), + (127, 38, 8, 35), +], dtype=np.float64) + + +def _ostro_coeffs(): + """Build the 256x3 (normalised) Ostromoukhov coefficient table by + interpolating the knot table and mirroring it about the midpoint.""" + if "tab" in _BLUENOISE: # reuse the module cache dict + return _BLUENOISE["tab"] + xs = _OSTRO_KNOTS[:, 0] + c = np.empty((256, 3)) + half = np.arange(128) + for j in range(3): + c[:128, j] = np.interp(half, xs, _OSTRO_KNOTS[:, j + 1]) + c[128:] = c[:128][::-1] # symmetric for the upper half + c /= c.sum(axis=1, keepdims=True) + _BLUENOISE["tab"] = c + return c + + +def quantize_ostromoukhov(img_lab, allowed, palette_lab): + H, W, _ = img_lab.shape + work = img_lab.astype(np.float64).copy() + out = np.zeros((H, W), dtype=np.int64) + pal = palette_lab + coeffs = _ostro_coeffs() + for y in range(H): + ltr = (y % 2 == 0) + cols = range(W) if ltr else range(W - 1, -1, -1) + flip = 1 if ltr 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] + tone = int(np.clip(work[y, x, 0] * 2.55, 0, 255)) # L* (0..100) -> 0..255 + cr, cdl, cd = coeffs[tone] + for dx, dy, wgt in ((1, 0, cr), (-1, 1, cdl), (0, 1, cd)): + nx, ny = x + dx * flip, y + dy + if 0 <= nx < W and 0 <= ny < H: + work[ny, nx] += err * wgt + return out + + +def quantize_yliluoma(img_lab, allowed, palette_lab, plan=16, n=8): + """Yliluoma-style ordered dithering. For each pixel we greedily build a + 'mixing plan' -- a list of `plan` palette entries (with repeats) from its + allowed set whose running average best matches the target -- then index into + the plan, sorted by lightness, with the Bayer threshold. Mixes more than two + colours per cell, so flat-palette platforms get far richer apparent colour.""" + H, W, _ = img_lab.shape + colors = _gather_colors(palette_lab, allowed) # (H,W,K,3) + yy, xx = np.indices((H, W)) + + running = np.zeros((H, W, 3)) # sum of chosen Lab + chosen = np.empty((H, W, plan), dtype=np.int64) # local index into allowed + for t in range(plan): + # average if we appended each candidate next + avg = (running[:, :, None, :] + colors) / (t + 1) + d = np.sum((avg - img_lab[:, :, None, :]) ** 2, axis=-1) # (H,W,K) + pick = np.argmin(d, axis=-1) # (H,W) + chosen[:, :, t] = pick + running = running + colors[yy, xx, pick] + + # sort each pixel's plan by the lightness (L*) of its colours + plan_L = colors[yy[..., None], xx[..., None], chosen, 0] # (H,W,plan) + order = np.argsort(plan_L, axis=-1) + sorted_plan = np.take_along_axis(chosen, order, axis=-1) + + thr = bayer_matrix(n) + idx = np.clip((thr[yy % n, xx % n] * plan).astype(np.int64), 0, plan - 1) + local = np.take_along_axis(sorted_plan, idx[..., None], axis=-1)[..., 0] + return np.take_along_axis(allowed, local[..., None], axis=-1)[..., 0] + + +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 == "bluenoise": + return quantize_bluenoise(img_lab, allowed, palette_lab) + if mode == "yliluoma": + return quantize_yliluoma(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) + if mode == "sierra": + return quantize_sierra(img_lab, allowed, palette_lab) + if mode == "sierra_lite": + return quantize_sierra_lite(img_lab, allowed, palette_lab) + if mode == "burkes": + return quantize_burkes(img_lab, allowed, palette_lab) + if mode == "riemersma": + return quantize_riemersma(img_lab, allowed, palette_lab) + if mode == "ostromoukhov": + return quantize_ostromoukhov(img_lab, allowed, palette_lab) + return quantize_none(img_lab, allowed, palette_lab) diff --git a/c64view/exporter.py b/lenser/exporter.py similarity index 58% rename from c64view/exporter.py rename to lenser/exporter.py index e816e31..955e1e8 100644 --- a/c64view/exporter.py +++ b/lenser/exporter.py @@ -4,9 +4,10 @@ from __future__ import annotations import os -from . import basicgen, diskimage, imginfo +from . import basicgen, crt, diskimage, imginfo from .convert.base import Conversion -from .viewer.assemble import SOURCES, build_viewer_prg +from .viewer.assemble import (SOURCES, build_cart_rom, build_data_prg, + build_viewer_prg) def export_disk(conv: Conversion, output_path: str, @@ -14,7 +15,9 @@ def export_disk(conv: Conversion, output_path: str, disk_name: str | None = None, include_graphic_file: bool = True, source_path: str | None = None, - video: str = "pal") -> str: + video: str = "pal", + display: str = "key", seconds: int = 0, + layout: str = "unified") -> str: """Write ``conv`` to a disk image at ``output_path``. The disk's first (bootable) file is a self-contained viewer that already @@ -26,14 +29,21 @@ def export_disk(conv: Conversion, output_path: str, """ fmt = diskimage.fmt_from_path(output_path, disk_format) stem = os.path.splitext(os.path.basename(output_path))[0] - name = diskimage.petscii_name(disk_name or stem or "c64view") + name = diskimage.petscii_name(disk_name or stem or "8bitlenser") # Timing-sensitive viewers (FLI) have an NTSC variant; others run on both. vkey = conv.viewer if video == "ntsc" and f"{conv.viewer}_ntsc" in SOURCES: vkey = f"{conv.viewer}_ntsc" - viewer_prg = build_viewer_prg(vkey, conv.data, conv.data_addr) + + # Separate layout (viewer code + a standalone "data" file the viewer loads) is + # supported by the simple viewers; FLI/interlace fall back to unified. + separate = layout == "separate" and vkey in ("hires", "multicolor") + viewer_prg = build_viewer_prg(vkey, conv.data, conv.data_addr, display=display, + seconds=seconds, video=video, separate=separate) files: list[tuple[str, bytes]] = [(name, viewer_prg)] + if separate: + files.append(("data", build_data_prg(conv.data, conv.data_addr))) if include_graphic_file: files.extend(conv.extra_files) @@ -49,3 +59,16 @@ def export_disk(conv: Conversion, output_path: str, pass return diskimage.build_disk(output_path, fmt, name, "01", files) + + +def export_cart(conv: Conversion, output_path: str, source_path: str | None = None, + video: str = "pal", display: str = "forever", seconds: int = 0) -> str: + """Write ``conv`` as an autostarting 16K C64 .crt cartridge. + + Only hires/multicolor/mono fit a 16K cart; FLI/interlace must use a disk.""" + if not output_path.lower().endswith(".crt"): + output_path += ".crt" + name = os.path.splitext(os.path.basename(output_path))[0] + rom = build_cart_rom(conv.viewer, conv.data, display=display, + seconds=seconds, video=video) + return crt.write_crt(rom, output_path, name) diff --git a/lenser/gallery.py b/lenser/gallery.py new file mode 100644 index 0000000..e85e0e7 --- /dev/null +++ b/lenser/gallery.py @@ -0,0 +1,140 @@ +"""Render every Mode x Palette x Dither variation of an image, for the GUI's +"Explore variations" contact sheet. Kept import-light enough to run in +ProcessPoolExecutor worker processes; platform-aware (C64 or Atari). +""" + +from __future__ import annotations + +from . import imageprep +from .convert import render_preview + +DITHERS = ["bayer", "floyd", "atkinson", "none"] + + +def combos(platform: str): + """(mode, palette, dither) triples for a platform.""" + if platform == "apple": + from .apple.convert import MODES as PMODES + return [(m, "mono", d) for m in PMODES for d in DITHERS] + if platform == "atari": + from .atari.convert import MODES as AMODES + # floyd first: gr15/gr15dli use dither-aware palette selection + adith = ["floyd", "atkinson", "bayer", "none"] + return [(m, "ntsc", d) for m in AMODES for d in adith] + if platform == "ti99": + from .ti99.convert import MODES as TMODES + # atkinson first: gm2 selection is dither-aware, and atkinson's lighter + # diffusion bleeds less across the tight 8x8 two-colour cells than floyd + tdith = ["atkinson", "floyd", "bayer", "none"] + return [(m, "tms9918", d) for m in TMODES for d in tdith] + if platform == "coco": + from .coco.convert import MODES as COMODES + return [(m, "mc6847", d) for m in COMODES for d in DITHERS] + if platform == "bbc": + from .bbc.convert import MODES as BMODES + return [(m, "bbc", d) for m in BMODES for d in DITHERS] + if platform == "coleco": + from .coleco.convert import MODES as CVMODES + # same TMS9918A GM2 as the TI-99 -> dither-aware, atkinson first + cvdith = ["atkinson", "floyd", "bayer", "none"] + return [(m, "tms9918", d) for m in CVMODES for d in cvdith] + if platform == "a2600": + from .a2600.convert import MODES as VMODES + # atkinson/none first: bayer fares poorly on the 40px playfield + adith = ["atkinson", "none", "floyd", "bayer"] + return [(m, "tia", d) for m in VMODES for d in adith] + if platform == "intv": + from .intv.convert import MODES as IMODES + # none first: the 64-tile GRAM limit makes error-diffusion counterproductive + idith = ["none", "atkinson", "bayer", "floyd"] + return [(m, "stic", d) for m in IMODES for d in idith] + if platform == "vic20": + from .vic20.convert import MODES as VMODES + # floyd first: the colour selection is dither-aware (segment metric) + vdith = ["floyd", "atkinson", "bayer", "none"] + return [(m, "vic", d) for m in VMODES for d in vdith] + if platform == "spectrum": + from .spectrum.convert import MODES as ZMODES + # atkinson first: 2-colour-per-cell (attribute clash), lighter diffusion + # bleeds less across cells (as on the TI-99) + zdith = ["atkinson", "floyd", "bayer", "none"] + return [(m, "spectrum", d) for m in ZMODES for d in zdith] + if platform == "a5200": + from .a5200.convert import MODES as XMODES + # floyd first: reuses the dither-aware Atari GTIA encoders + xdith = ["floyd", "atkinson", "bayer", "none"] + return [(m, "ntsc", d) for m in XMODES for d in xdith] + if platform == "a7800": + from .a7800.convert import MODES as SMODES + sdith = ["floyd", "atkinson", "bayer", "none"] + return [(m, "ntsc", d) for m in SMODES for d in sdith] + if platform == "c128": + from .c128.convert import MODES as C128_MODES + # floyd first: the 640x200 two-tone is pure luminance dither, so error + # diffusion yields the smoothest tonal gradients at this high resolution. + c128dith = ["floyd", "atkinson", "bayer", "none"] + return [(m, "vdc", d) for m in C128_MODES for d in c128dith] + if platform in ("c16", "plus4"): + from .c16.convert import MODES as C16_MODES + # floyd first: TED hires picks 2 of 128 colours per cell, so error + # diffusion brackets each cell and blends to the true shade. + c16dith = ["floyd", "atkinson", "bayer", "none"] + return [(m, "ted", d) for m in C16_MODES for d in c16dith] + if platform == "cpc": + from .cpc.convert import MODES as CPC_MODES + # floyd first: a flat N-colour palette dithered -> error diffusion gives + # the smoothest gradients. + cpcdith = ["floyd", "atkinson", "bayer", "none"] + return [(m, "cpc", d) for m in CPC_MODES for d in cpcdith] + if platform == "coco3": + from .coco3.convert import MODES as COCO3_MODES + # floyd first: flat palette chosen from 64 colours, dithered. + c3dith = ["floyd", "atkinson", "bayer", "none"] + return [(m, "gime", d) for m in COCO3_MODES for d in c3dith] + if platform == "nes": + from .nes.convert import MODES as NES_MODES + # atkinson first: lighter diffusion bleeds less across the NES's tight + # 16x16 attribute-palette regions (like the TI/Spectrum cell platforms). + nesdith = ["atkinson", "floyd", "bayer", "none"] + return [(m, "nes", d) for m in NES_MODES for d in nesdith] + if platform == "iigs": + from .iigs.convert import MODES as IIGS_MODES + # floyd first: per-line 16-of-4096 palette, dithered -> smoothest result. + gsdith = ["floyd", "atkinson", "bayer", "none"] + return [(m, "iigs", d) for m in IIGS_MODES for d in gsdith] + if platform in ("pet2001", "pet4032", "pet8032", "superpet"): + # one-bit quadrant-block mono; floyd carries the most tone at low res. + return [("mono", "pet", d) for d in ("floyd", "atkinson", "bayer", "none")] + if platform == "sms": + from .sms.convert import MODES as SMS_MODES + # floyd first: 16 colours/tile from 2 palettes -> error diffusion blends + # to the smoothest result. + smsdith = ["floyd", "atkinson", "bayer", "none"] + return [(m, "sms", d) for m in SMS_MODES for d in smsdith] + if platform == "amiga": + from .amiga.convert import MODES as AMIGA_MODES + # floyd first: flat 32-of-4096 palette dithered -> smoothest gradients. + amdith = ["floyd", "atkinson", "bayer", "none"] + return [(m, "amiga", d) for m in AMIGA_MODES for d in amdith] + if platform == "ansi": + from .ansi.convert import MODES as ANSI_MODES + # floyd first: a free 16-colour quantise, so error diffusion blends best. + ansidith = ["floyd", "atkinson", "bayer", "none"] + return [(m, "vga", d) for m in ANSI_MODES for d in ansidith] + from .convert import MODES as CMODES + # floyd first: C64 colour selection is dither-aware, so error diffusion gives + # the smoothest, most accurate result. + cdith = ["floyd", "atkinson", "bayer", "none"] + return [(m, p, d) for m in CMODES for p in ["colodore", "pepto"] for d in cdith] + + +def render_variation(args): + """Worker entry. args = (path, platform, mode, palette, dither, prep_kwargs, + base_name). Returns (mode, palette, dither, error, rgb).""" + path, platform, mode, palette, dither, prep_kwargs, base_name = args + from . import platforms + prep = imageprep.PrepOptions(**prep_kwargs) + conv = platforms.convert(platform, path, mode, palette, dither, + False, prep, base_name) + rgb = render_preview(conv, palette, scale=1) + return (mode, palette, dither, conv.error, rgb) diff --git a/lenser/gui.py b/lenser/gui.py new file mode 100644 index 0000000..212b91a --- /dev/null +++ b/lenser/gui.py @@ -0,0 +1,1122 @@ +"""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, platforms, slideshow +from .convert import render_preview +from .diskimage import have_c1541 +from .palette import COLOR_NAMES +from .viewer.assemble import have_xa + +DITHER_CHOICES = ["bayer", "bluenoise", "yliluoma", "floyd", "atkinson", + "stucki", "jarvis", "sierra", "sierra_lite", "burkes", + "riemersma", "ostromoukhov", "none"] +ASPECT_CHOICES = ["fit", "fill", "stretch"] +BASE_CHOICES = ["grayscale", *COLOR_NAMES] # mono base / Atari hue + +# Full machine names shown in the platform selector (value stays the short code). +PLATFORM_NAMES = { + "c64": "Commodore 64", "atari": "Atari 800/XL/XE", "apple": "Apple II", + "ti99": "Texas Instruments TI-99/4A", "coco": "Tandy Color Computer 2", + "bbc": "BBC Micro", "coleco": "ColecoVision", "a2600": "Atari 2600", + "intv": "Mattel Intellivision", "vic20": "Commodore VIC-20", + "spectrum": "Sinclair ZX Spectrum", "a5200": "Atari 5200", + "a7800": "Atari 7800", "c128": "Commodore 128", "c16": "Commodore 16", + "plus4": "Commodore Plus/4", "cpc": "Amstrad CPC", + "coco3": "Tandy Color Computer 3", "nes": "Nintendo Entertainment System", + "iigs": "Apple IIGS", "pet2001": "Commodore PET 2001", + "pet4032": "Commodore PET 4032", "pet8032": "Commodore PET 8032", + "superpet": "Commodore SuperPET", "sms": "Sega Master System", + "amiga": "Commodore Amiga", "ansi": "ANSI / BBS art (CP437)", +} + + +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()) + + +# Small, original brand-evocative chips for the platform selector: a coloured +# rounded square + a short monogram in a colour associated with the maker. These +# are drawn from scratch to *evoke* each brand, not reproductions of any logo. +# (bg, fg, label) keyed by platform code; grouped by manufacturer. +_BRAND_ICONS = { + # Commodore (indigo "C=") + **{c: ("#3f51b5", "#ffffff", "C=") for c in + ("c64", "c128", "c16", "plus4", "vic20", + "pet2001", "pet4032", "pet8032", "superpet")}, + "amiga": ("#111111", "#e53935", "A"), # Commodore Amiga (red on black) + # Atari (red) + **{c: ("#c62828", "#ffffff", "A") for c in ("atari", "a2600", "a5200", "a7800")}, + # Apple (green era) + "apple": ("#7ab648", "#ffffff", "II"), + "iigs": ("#7ab648", "#ffffff", "GS"), + # everyone else + "ti99": ("#cc0000", "#ffffff", "TI"), # Texas Instruments + "coco": ("#c8102e", "#ffffff", "TRS"), # Tandy / Radio Shack + "coco3": ("#c8102e", "#ffffff", "TRS"), + "bbc": ("#111111", "#ffffff", "BBC"), # Acorn / BBC + "coleco": ("#e53935", "#ffffff", "CV"), # ColecoVision + "intv": ("#1565c0", "#ffffff", "M"), # Mattel + "spectrum": ("#111111", "#ffffff", "ZX"), # Sinclair + "nes": ("#e60012", "#ffffff", "N"), # Nintendo + "sms": ("#0060a8", "#ffffff", "S"), # Sega + "cpc": ("#1b3a6b", "#ffffff", "CPC"), # Amstrad + "ansi": ("#0a0a0a", "#33ff33", "▒"), # ANSI/BBS terminal (CP437 shade) +} + + +def _brand_icon(code: str, px: int = 18) -> QtGui.QIcon: + """A tiny brand-evocative chip icon for one platform (empty icon if unknown).""" + spec = _BRAND_ICONS.get(code) + if spec is None: + return QtGui.QIcon() + bg, fg, label = spec + dpr = 2 # render at 2x for a crisp downscale + size = px * dpr + pm = QtGui.QPixmap(size, size) + pm.fill(QtCore.Qt.transparent) + p = QtGui.QPainter(pm) + p.setRenderHint(QtGui.QPainter.Antialiasing, True) + p.setRenderHint(QtGui.QPainter.TextAntialiasing, True) + rect = QtCore.QRectF(dpr, dpr, size - 2 * dpr, size - 2 * dpr) + p.setPen(QtCore.Qt.NoPen) + p.setBrush(QtGui.QColor(bg)) + p.drawRoundedRect(rect, 4 * dpr, 4 * dpr) + # shrink the (bold) monogram until it fits the chip width + font = QtGui.QFont() + font.setBold(True) + fs = int(size * 0.64) + while fs > 4: + font.setPixelSize(fs) + if QtGui.QFontMetrics(font).horizontalAdvance(label) <= rect.width() * 0.82: + break + fs -= 1 + p.setFont(font) + p.setPen(QtGui.QColor(fg)) + p.drawText(rect, QtCore.Qt.AlignCenter, label) + p.end() + pm.setDevicePixelRatio(dpr) + return QtGui.QIcon(pm) + + +def _home(name: str) -> str: + """A suggested save path: the user's home directory + ``name`` (so every Save + dialog opens in $HOME by default).""" + return os.path.join(os.path.expanduser("~"), name) + + +def _load_bold_font(size: int): + """A bold TrueType font at ``size`` px (for the contact-sheet title), falling + back to PIL's built-in font if no system bold font is available.""" + from PIL import ImageFont + for path in ("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", + "DejaVuSans-Bold.ttf"): + try: + return ImageFont.truetype(path, size) + except OSError: + continue + try: + return ImageFont.load_default(size) # Pillow >= 10.1 + except Exception: + return ImageFont.load_default() + + +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"], + tint=p["tint"], red=p["red"], green=p["green"], blue=p["blue"], + ) + conv = platforms.convert( + p["platform"], self.path, p["mode"], p["palette"], p["dither"], + p["intensive"], prep, p["base_name"], + ) + 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, platform, base_name): + super().__init__() + self.path = path + self.prep_kwargs = prep_kwargs + self.platform = platform + self.base_name = base_name + self._cancel = False + + def cancel(self): + self._cancel = True + + def run(self): + from concurrent.futures import ProcessPoolExecutor, as_completed + combos = gallery.combos(self.platform) + args = [(self.path, self.platform, m, p, d, self.prep_kwargs, self.base_name) + 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, platform="c64", base_name="grayscale", + parent=None, cached=None): + super().__init__(parent) + self.setWindowTitle("Explore variations -- pick the best looking one") + self.resize(940, 680) + self.path = path + self.choice = None + self.cells = {} + self.best = (float("inf"), None) + self.collected = {} # index -> (m, p, d, err, rgb) + self.worker = None + self._combos = gallery.combos(platform) + + 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) + + # floating 2x magnifier shown while hovering a thumbnail (closer inspection) + self._zoom = QtWidgets.QLabel(self) + self._zoom.setWindowFlags(QtCore.Qt.ToolTip) + self._zoom.setAlignment(QtCore.Qt.AlignCenter) + self._zoom.setStyleSheet("border: 1px solid #888; background: #111;") + self._zoom.hide() + + cols = 4 + for i, (m, p, d) in enumerate(self._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)) + btn._zoom_pm = None # 2x pixmap, set once rendered + btn.installEventFilter(self) # hover -> show/hide magnifier + self.cells[i] = btn + self.grid.addWidget(btn, i // cols, i % cols) + + btns = QtWidgets.QHBoxLayout() + self.sheet_btn = QtWidgets.QPushButton("Contact Sheet...") + self.sheet_btn.setToolTip("Save one PNG with every variation at full size, " + "for side-by-side comparison.") + self.sheet_btn.clicked.connect(self._save_contact_sheet) + self.sheet_btn.setEnabled(False) + btns.addWidget(self.sheet_btn) + btns.addStretch(1) + cancel = QtWidgets.QPushButton("Cancel") + cancel.clicked.connect(self.reject) + btns.addWidget(cancel) + outer.addLayout(btns) + + if cached and len(cached) == len(self._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, platform, base_name) + 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)) + # 2x version for the hover magnifier (twice the thumbnail's box) + btn._zoom_pm = numpy_to_pixmap(rgb).scaled( + 400, 250, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation) + 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) + self.sheet_btn.setEnabled(bool(self.collected)) + + def _save_contact_sheet(self): + """Compose every collected variation (at full preview size) into one PNG + grid -- same order as the on-screen sheet -- for side-by-side comparison.""" + if not self.collected: + return + stem = os.path.splitext(os.path.basename(self.path or "image"))[0] + path, _ = QtWidgets.QFileDialog.getSaveFileName( + self, "Save contact sheet", _home(f"8bitlenser_{stem}.png"), + "PNG image (*.png)") + if not path: + return + if not path.lower().endswith(".png"): + path += ".png" + try: + from PIL import Image, ImageDraw, ImageFont + titlefont = _load_bold_font(26) + items = [self.collected[i] for i in sorted(self.collected)] + best_i = self.best[1] + best_pos = sorted(self.collected).index(best_i) if best_i is not None else -1 + cw = max(rgb.shape[1] for *_, rgb in items) + ch = max(rgb.shape[0] for *_, rgb in items) + pad, lblh, cols, titleh = 10, 18, 4, 44 + cellw, cellh = cw + 2 * pad, ch + lblh + 2 * pad + rows = (len(items) + cols - 1) // cols + sheet = Image.new("RGB", (cols * cellw, titleh + rows * cellh), (24, 24, 24)) + draw = ImageDraw.Draw(sheet) + draw.text((pad, (titleh - 26) // 2), os.path.abspath(self.path or ""), + fill=(255, 255, 255), font=titlefont) + for pos, (m, p, d, err, rgb) in enumerate(items): + r, c = divmod(pos, cols) + x0, y0 = c * cellw + pad, titleh + r * cellh + pad + star = " *best*" if pos == best_pos else "" + draw.text((x0, y0), f"{m}/{p}/{d} dE {err:.1f}{star}", + fill=(255, 230, 120) if star else (220, 220, 220)) + tile = Image.fromarray(np.ascontiguousarray(rgb), "RGB") + ox = x0 + (cw - rgb.shape[1]) // 2 + oy = y0 + lblh + (ch - rgb.shape[0]) // 2 + sheet.paste(tile, (ox, oy)) + sheet.save(path) + self.info.setText(f"Saved contact sheet ({len(items)} variations) to {path}") + except Exception: + QtWidgets.QMessageBox.critical(self, "Contact sheet failed", + traceback.format_exc()) + + def eventFilter(self, obj, event): + """Show a 2x magnifier when the pointer enters a rendered thumbnail.""" + et = event.type() + if et == QtCore.QEvent.Enter and getattr(obj, "_zoom_pm", None) is not None: + self._show_zoom(obj) + elif et == QtCore.QEvent.Leave and obj is not self._zoom: + self._zoom.hide() + return super().eventFilter(obj, event) + + def _show_zoom(self, btn): + """Pop the magnified pixmap just above ``btn`` (below if there's no room), + clamped to the screen so it never lands under the pointer.""" + pm = btn._zoom_pm + self._zoom.setPixmap(pm) + self._zoom.resize(pm.size()) + screen = QtWidgets.QApplication.desktop().availableGeometry(btn) + cx = btn.mapToGlobal(btn.rect().center()).x() + x = max(screen.left(), min(cx - pm.width() // 2, screen.right() - pm.width())) + y = btn.mapToGlobal(btn.rect().topLeft()).y() - pm.height() - 6 + if y < screen.top(): # no room above -> drop below + y = btn.mapToGlobal(btn.rect().bottomLeft()).y() + 6 + self._zoom.move(x, y) + self._zoom.show() + self._zoom.raise_() + + 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 SlideshowDialog(QtWidgets.QDialog): + """Arrange a queue of slides, size them against a disk format (refusing to + overfill), set the advance behaviour, and export/run one drive image. + + Operates on the parent's live ``slides`` list (reorder/remove happen in + place), where each entry is {"item": SlideItem, "conv": Conversion, + "thumb": rgb}. + """ + + def __init__(self, slides, platform, parent=None): + super().__init__(parent) + self.setWindowTitle("Slideshow") + self.resize(580, 540) + self.slides = slides + self.platform = platform + self._build() + self._refresh() + + def _build(self): + lay = QtWidgets.QVBoxLayout(self) + lay.addWidget(QtWidgets.QLabel( + f"Platform: {PLATFORM_NAMES.get(self.platform, self.platform)} " + "(one platform per slideshow)")) + + self.listw = QtWidgets.QListWidget() + self.listw.setIconSize(QtCore.QSize(96, 60)) + lay.addWidget(self.listw, 1) + + rowbtns = QtWidgets.QHBoxLayout() + for text, fn in (("Move up", lambda: self._move(-1)), + ("Move down", lambda: self._move(1)), + ("Remove", self._remove)): + b = QtWidgets.QPushButton(text) + b.clicked.connect(fn) + rowbtns.addWidget(b) + lay.addLayout(rowbtns) + + form = QtWidgets.QFormLayout() + self.advance_cb = QtWidgets.QComboBox() + self.advance_cb.addItems(list(slideshow.ADVANCE_MODES)) + self.advance_cb.setCurrentText("both") + self.advance_cb.setToolTip("key = wait for a keypress; seconds = auto after " + "N s; both = either (keys still work).") + self.seconds_sb = QtWidgets.QSpinBox() + self.seconds_sb.setRange(1, 3600) + self.seconds_sb.setValue(10) + self.seconds_sb.setSuffix(" s") + adv_row = QtWidgets.QHBoxLayout() + adv_row.addWidget(self.advance_cb) + adv_row.addWidget(self.seconds_sb) + self.format_cb = QtWidgets.QComboBox() + self.format_cb.addItems(slideshow.disk_formats(self.platform)) + self.loop_cb = QtWidgets.QCheckBox("Loop back to the first image") + self.loop_cb.setChecked(True) + form.addRow("Advance", adv_row) + form.addRow("Disk format", self.format_cb) + form.addRow("", self.loop_cb) + lay.addLayout(form) + self.advance_cb.currentTextChanged.connect(self._adv_changed) + self.format_cb.currentTextChanged.connect(self._refresh) + self.loop_cb.stateChanged.connect(self._refresh) + # set the spinbox enable directly (meter/_refresh aren't built yet) + self.seconds_sb.setEnabled(self.advance_cb.currentText() in ("seconds", "both")) + + self.meter = QtWidgets.QLabel("") + lay.addWidget(self.meter) + + btns = QtWidgets.QHBoxLayout() + self.run_btn = QtWidgets.QPushButton("Run in emulator") + self.run_btn.clicked.connect(self._run) + self.export_btn = QtWidgets.QPushButton("Export slideshow disk...") + self.export_btn.clicked.connect(self._export) + close = QtWidgets.QPushButton("Close") + close.clicked.connect(self.accept) + btns.addWidget(self.run_btn) + btns.addStretch(1) + btns.addWidget(self.export_btn) + btns.addWidget(close) + lay.addLayout(btns) + + def _adv_changed(self, text): + self.seconds_sb.setEnabled(text in ("seconds", "both")) + self._refresh() + + def _selected(self): + row = self.listw.currentRow() + return row if 0 <= row < len(self.slides) else -1 + + def _move(self, delta): + i = self._selected() + j = i + delta + if i < 0 or not (0 <= j < len(self.slides)): + return + self.slides[i], self.slides[j] = self.slides[j], self.slides[i] + self._refresh() + self.listw.setCurrentRow(j) + + def _remove(self): + i = self._selected() + if i >= 0: + del self.slides[i] + self._refresh() + + def _viewer_len(self): + if not self.slides: + return 0 + try: + return slideshow.viewer_length(self._show(), + [s["conv"] for s in self.slides], + self._video()) + except Exception: + return 320 # xa unavailable; a safe over-estimate for sizing + + def _show(self): + return slideshow.Slideshow( + platform=self.platform, disk_format=self.format_cb.currentText(), + advance=self.advance_cb.currentText(), seconds=self.seconds_sb.value(), + loop=self.loop_cb.isChecked(), items=[s["item"] for s in self.slides]) + + def _video(self): + p = self.parent() + return p.video_cb.currentText() if p is not None else "pal" + + def _refresh(self): + fmt = self.format_cb.currentText() + self.listw.clear() + for i, s in enumerate(self.slides): + it, conv = s["item"], s["conv"] + blk = slideshow.item_blocks(self.platform, fmt, len(conv.data)) + item = QtWidgets.QListWidgetItem( + f"{i + 1:02d}. {it.mode} / {it.dither} {blk} blocks " + f"{os.path.basename(it.source_path)}") + if s.get("thumb") is not None: + item.setIcon(QtGui.QIcon(numpy_to_pixmap(s["thumb"]))) + self.listw.addItem(item) + + if not self.slides: + self.meter.setText("No images yet -- add them from the main window.") + self.meter.setStyleSheet("") + self.export_btn.setEnabled(False) + self.run_btn.setEnabled(False) + return + try: + slideshow.check_modes(self.platform, [s["conv"] for s in self.slides]) + except ValueError as e: + self.meter.setText(str(e)) + self.meter.setStyleSheet("font-weight:bold;color:#c02020;") + self.export_btn.setEnabled(False) + self.run_btn.setEnabled(False) + return + b = slideshow.budget(self.platform, fmt, + [slideshow.image_nbytes(s["conv"]) for s in self.slides], + self._viewer_len()) + verdict = "fits" if b.fits else f"OVER -- {b.reason}" + self.meter.setText( + f"{len(self.slides)} images {b.used_blocks}/{b.total_blocks} blocks " + f"{b.files}/{b.file_cap} files {verdict}") + self.meter.setStyleSheet( + "font-weight:bold;color:%s;" % ("#2a8a2a" if b.fits else "#c02020")) + self.export_btn.setEnabled(b.fits) + self.run_btn.setEnabled(b.fits and platforms.has_emulator(self.platform)) + + def _build_disk_to(self, path): + show = self._show() + return slideshow.build_disk(show, path, convs=[s["conv"] for s in self.slides], + video=self._video()) + + def _export(self): + fmt = self.format_cb.currentText() + path, _ = QtWidgets.QFileDialog.getSaveFileName( + self, "Export slideshow disk", _home(f"slideshow.{fmt}"), + f"Disk image (*.{fmt})") + if not path: + return + try: + out = self._build_disk_to(path) + QtWidgets.QMessageBox.information( + self, "Exported", + f"Wrote {out}\n\n{len(self.slides)} images.\n" + 'On the C64 / emulator:\n LOAD"*",8,1 then RUN') + except Exception: + QtWidgets.QMessageBox.critical(self, "Export failed", traceback.format_exc()) + + def _run(self): + from . import mame + p = self.parent() + try: + import tempfile + fmt = self.format_cb.currentText() + fd, path = tempfile.mkstemp(suffix=f".{fmt}", prefix="slideshow_") + os.close(fd) + self._build_disk_to(path) + if p is not None: + p._temp_disks.append(path) + mame.kill(p._emu_proc) + p._emu_proc = platforms.run_emulator( + self.platform, path, self.slides[0]["conv"].mode, self._video()) + self.parent().status.showMessage("Launched slideshow in emulator.") + except Exception: + QtWidgets.QMessageBox.critical(self, "Run failed", traceback.format_exc()) + + +class MainWindow(QtWidgets.QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("8 Bit Lenser -- modern images to retro disks") + 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._slides = [] # slideshow queue (see SlideshowDialog) + self._slideshow_platform = None # locked to the first added slide + + self._build_ui() + self.setAcceptDrops(True) # allow dragging an image onto the window + self.src_label["label"].setText("(no image)\n\nclick “Open image…” " + "or drag an image here") + 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("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.platform_cb = QtWidgets.QComboBox() + self.platform_cb.setIconSize(QtCore.QSize(18, 18)) + # list alphabetically by the name shown (code kept as the item data), + # each prefixed by a small brand-evocative icon + for code in sorted(platforms.PLATFORMS, + key=lambda c: PLATFORM_NAMES.get(c, c).lower()): + self.platform_cb.addItem(_brand_icon(code), + PLATFORM_NAMES.get(code, code), code) + self.platform_cb.setCurrentIndex(self.platform_cb.findData("c64")) + self.mode_cb = self._combo(platforms.modes("c64"), "multicolor") + self.format_cb = self._combo(platforms.disk_formats("c64"), "d64") + self.palette_cb = self._combo(platforms.palettes("c64"), "colodore") + self.dither_cb = self._combo(DITHER_CHOICES, "atkinson") + self.base_cb = self._combo(BASE_CHOICES, "grayscale") + self.video_cb = self._combo(["pal", "ntsc"], "pal") + self.aspect_cb = self._combo(ASPECT_CHOICES, "fit") + self.layout_cb = self._combo(["unified", "separate"], "unified") + self.display_cb = self._combo(["key", "forever", "seconds"], "key") + self.seconds_sb = QtWidgets.QSpinBox() + self.seconds_sb.setRange(1, 3600) + self.seconds_sb.setValue(10) + self.seconds_sb.setSuffix(" s") + self.seconds_sb.setEnabled(False) + self.display_cb.currentTextChanged.connect( + lambda t: self.seconds_sb.setEnabled(t == "seconds")) + disp_row = QtWidgets.QHBoxLayout() + disp_row.addWidget(self.display_cb) + disp_row.addWidget(self.seconds_sb) + form.addRow("Platform", self.platform_cb) + 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/hue base", self.base_cb) + form.addRow("Video", self.video_cb) + form.addRow("Aspect", self.aspect_cb) + form.addRow("Display", disp_row) + form.addRow("Viewer", self.layout_cb) + panel.addLayout(form) + self.platform_cb.currentIndexChanged.connect(self._on_platform_changed) + + self.intensive_cb = QtWidgets.QCheckBox("Intensive analysis (slower, best quality)") + self.intensive_cb.setChecked(True) # default on -- best quality + self.intensive_cb.stateChanged.connect(self.schedule_convert) + panel.addWidget(self.intensive_cb) + + self.sliders = {} + self._slider_defaults = {} + for key, lo, hi, default, label in [ + ("brightness", 50, 200, 100, "bri"), ("contrast", 50, 200, 100, "con"), + ("saturation", 0, 200, 100, "sat"), ("gamma", 50, 200, 100, "gam"), + ("tint", -180, 180, 0, "tint"), + ("red", 0, 200, 100, "red"), ("green", 0, 200, 100, "grn"), + ("blue", 0, 200, 100, "blue")]: + self._slider_defaults[key] = default + panel.addLayout(self._slider(key, lo, hi, default, label)) + + reset_btn = QtWidgets.QPushButton("Reset adjustments") + reset_btn.setToolTip("Reset brightness/contrast/tint/levels to defaults.") + reset_btn.clicked.connect(self.reset_sliders) + panel.addWidget(reset_btn) + + # ---- slideshow queue ---- + self.add_slide_btn = QtWidgets.QPushButton("Add to slideshow") + self.add_slide_btn.setToolTip("Add the current image + its settings as a " + "slide in the slideshow queue.") + self.add_slide_btn.clicked.connect(self.add_to_slideshow) + self.add_slide_btn.setEnabled(False) + panel.addWidget(self.add_slide_btn) + self.slideshow_btn = QtWidgets.QPushButton("Slideshow (0)...") + self.slideshow_btn.setToolTip("Arrange the queued images, size them against " + "a disk, set advancing, and export one drive image.") + self.slideshow_btn.clicked.connect(self.open_slideshow) + panel.addWidget(self.slideshow_btn) + + 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 emulator") + self.vice_btn.setToolTip("Build a temporary disk/cartridge and boot it " + "in MAME.") + 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 = [] + self._emu_proc = None + + 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, default=100, label=None): + row = QtWidgets.QHBoxLayout() + lab = QtWidgets.QLabel(label or key[:4]) + lab.setMinimumWidth(34) + row.addWidget(lab) + s = QtWidgets.QSlider(QtCore.Qt.Horizontal) + s.setRange(lo, hi) + s.setValue(default) + s.sliderReleased.connect(self.schedule_convert) + self.sliders[key] = s + row.addWidget(s) + return row + + def reset_sliders(self): + """Reset every adjustment slider to its default and re-render once.""" + for key, slider in self.sliders.items(): + slider.blockSignals(True) + slider.setValue(self._slider_defaults[key]) + slider.blockSignals(False) + self.schedule_convert() + + def _check_tools(self): + from . import mame + missing = [] + if not have_xa(): + missing.append("xa (xa65 assembler, for the 6502 viewers)") + if not have_c1541(): + missing.append("c1541 (VICE package, builds C64 disks)") + if not mame.have_mame(): + missing.append("mame (runs every platform's image)") + if missing: + QtWidgets.QMessageBox.warning( + self, "Missing tools", + "Export / Run need these tools on PATH:\n " + "\n ".join(missing) + + "\n\nInstall: sudo apt install xa65 vice mame") + + def _on_platform_changed(self): + """Repopulate platform-specific choices and re-render.""" + plat = self.platform_cb.currentData() + for cb, items in ((self.mode_cb, platforms.modes(plat)), + (self.palette_cb, platforms.palettes(plat)), + (self.format_cb, platforms.disk_formats(plat))): + cb.blockSignals(True) + cb.clear() + cb.addItems(items) + cb.blockSignals(False) + self._gallery_key = None # invalidate variations cache + self._retarget_slideshow(plat) # keep the slideshow on the selected platform + self.schedule_convert() + + # ---- params ---- + def params(self): + return { + "platform": self.platform_cb.currentData(), + "mode": self.mode_cb.currentText(), + "palette": self.palette_cb.currentText(), + "dither": self.dither_cb.currentText(), + "base_name": self.base_cb.currentText(), + "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, + "tint": float(self.sliders["tint"].value()), # degrees + "red": self.sliders["red"].value() / 100.0, + "green": self.sliders["green"].value() / 100.0, + "blue": self.sliders["blue"].value() / 100.0, + } + + # ---- actions ---- + def open_image(self): + path, _ = QtWidgets.QFileDialog.getOpenFileName( + self, "Open image", "", + "Images (*.png *.jpg *.jpeg *.gif *.bmp *.webp *.tif *.tiff);;" + "All files (*)") + if path: + self.load_path(path) + + def load_path(self, path): + """Load an image (from the file chooser or a drag-and-drop) and convert.""" + # Load via Pillow, not QPixmap: Qt's bundled plugins can't read webp/tiff + # (and ignore EXIF orientation), so those originals showed up blank. + try: + from PIL import Image, ImageOps + im = ImageOps.exif_transpose(Image.open(path)).convert("RGB") + pm = numpy_to_pixmap(np.asarray(im, dtype=np.uint8)) + except Exception: + QtWidgets.QMessageBox.warning( + self, "Cannot open image", + f"Could not read this image:\n{path}\n\n{traceback.format_exc()}") + return + self.source_path = path + self.explore_btn.setEnabled(True) + self.src_label["label"].setPixmap( + pm.scaled(self.src_label["label"].size(), QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation)) + self.schedule_convert() + + # ---- drag and drop ---- + @staticmethod + def _dropped_image_path(mime): + """Return the first local image-file path in a drag, or None.""" + exts = (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tif", ".tiff") + if not mime.hasUrls(): + return None + for url in mime.urls(): + if url.isLocalFile() and url.toLocalFile().lower().endswith(exts): + return url.toLocalFile() + return None + + def dragEnterEvent(self, event): + if self._dropped_image_path(event.mimeData()): + event.acceptProposedAction() + + def dragMoveEvent(self, event): + if self._dropped_image_path(event.mimeData()): + event.acceptProposedAction() + + def dropEvent(self, event): + path = self._dropped_image_path(event.mimeData()) + if path: + event.acceptProposedAction() + self.load_path(path) + + 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"], tint=p["tint"], red=p["red"], + green=p["green"], blue=p["blue"]) + plat, base = p["platform"], p["base_name"] + # Reuse cached results when source, platform, base and prep are unchanged. + key = (self.source_path, plat, base, tuple(sorted(prep_kwargs.items()))) + cached = self._gallery_results if self._gallery_key == key else None + dlg = VariationsDialog(self.source_path, prep_kwargs, plat, base, self, + cached=cached) + result = dlg.exec_() + if len(dlg.collected) == len(gallery.combos(plat)): + 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() + + # ---- slideshow ---- + def _update_slideshow_btn(self): + self.slideshow_btn.setText(f"Slideshow ({len(self._slides)})...") + + def _retarget_slideshow(self, new_plat): + """Re-convert the queued slides so the slideshow follows the main platform. + + Called when the platform changes: each slide keeps its source image, image + adjustments (prep) and dither, but its mode/palette fall back to the new + platform's defaults when the old ones don't exist there. Converts every + slide up front and only commits if all succeed, so a failure leaves the + queue untouched. No-op unless there is a queue and the new platform can + host a slideshow (switching to a non-slideshow platform leaves it as-is).""" + if (not self._slides or new_plat == self._slideshow_platform + or not slideshow.supports_slideshow(new_plat)): + return + modes, palettes = platforms.modes(new_plat), platforms.palettes(new_plat) + intensive = self.intensive_cb.isChecked() + QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) + try: + rebuilt = [] + for s in self._slides: + old = s["item"] + mode = old.mode if old.mode in modes else modes[0] + palette = old.palette if old.palette in palettes else palettes[0] + conv = platforms.convert(new_plat, old.source_path, mode, palette, + old.dither, intensive, old.prep, old.mono_base) + item = slideshow.SlideItem( + source_path=old.source_path, mode=mode, palette=palette, + dither=old.dither, mono_base=old.mono_base, prep=old.prep) + thumb = render_preview(conv, conv.meta.get("palette", palette), scale=1) + rebuilt.append({"item": item, "conv": conv, "thumb": thumb}) + except Exception: + QtWidgets.QApplication.restoreOverrideCursor() + QtWidgets.QMessageBox.warning( + self, "Could not retarget slideshow", + "The queued images could not all be converted for " + f"{PLATFORM_NAMES.get(new_plat, new_plat)}, so the slideshow was " + "left on the previous platform.\n\n" + traceback.format_exc()) + return + QtWidgets.QApplication.restoreOverrideCursor() + self._slides[:] = rebuilt # mutate in place (shared reference) + self._slideshow_platform = new_plat + self.status.showMessage( + f"Slideshow retargeted to {PLATFORM_NAMES.get(new_plat, new_plat)} " + f"({len(rebuilt)} slide(s) re-converted).") + + def add_to_slideshow(self): + if not self.last_conv or not self.source_path: + return + plat = self.platform_cb.currentData() + if not slideshow.supports_slideshow(plat): + QtWidgets.QMessageBox.warning( + self, "Not supported", + "Slideshows are only supported on disk-based platforms " + "(C64, C128, Atari, BBC, Apple, IIGS, Amiga).") + return + if self._slides and plat != self._slideshow_platform: + QtWidgets.QMessageBox.warning( + self, "One platform per slideshow", + f"This slideshow is for " + f"{PLATFORM_NAMES.get(self._slideshow_platform, self._slideshow_platform)}." + "\nRemove all its images before switching platforms.") + return + p = self.params() + prep = imageprep.PrepOptions( + aspect=p["aspect"], brightness=p["brightness"], contrast=p["contrast"], + saturation=p["saturation"], gamma=p["gamma"], tint=p["tint"], + red=p["red"], green=p["green"], blue=p["blue"]) + item = slideshow.SlideItem( + source_path=self.source_path, mode=p["mode"], palette=p["palette"], + dither=p["dither"], mono_base=p["base_name"], prep=prep) + thumb = render_preview(self.last_conv, + self.last_conv.meta.get("palette", "colodore"), scale=1) + self._slides.append({"item": item, "conv": self.last_conv, "thumb": thumb}) + self._slideshow_platform = plat + self._update_slideshow_btn() + self.status.showMessage( + f"Added slide {len(self._slides)} ({item.mode}/{item.dither}). " + f"{len(self._slides)} image(s) in the slideshow.") + + def open_slideshow(self): + if not self._slides: + QtWidgets.QMessageBox.information( + self, "Slideshow empty", + "Add images first with the “Add to slideshow” button.") + return + dlg = SlideshowDialog(self._slides, self._slideshow_platform, self) + dlg.exec_() + if not self._slides: + self._slideshow_platform = None + self._update_slideshow_btn() + + 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(platforms.has_emulator(self.platform_cb.currentData())) + self.add_slide_btn.setEnabled( + slideshow.supports_slideshow(self.platform_cb.currentData())) + 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 + plat = self.platform_cb.currentData() + 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", _home(f"{suggested}.{fmt}"), + f"Disk image (*.{fmt})") + if not path: + return + try: + out = platforms.export(plat, self.last_conv, path, fmt, + self.source_path, self.video_cb.currentText(), + display=self.display_cb.currentText(), + seconds=self.seconds_sb.value(), + layout=self.layout_cb.currentText()) + self.status.showMessage(f"Wrote {out}") + hint = {"c64": ' LOAD"*",8,1 then RUN', + "ti99": " pick it from the TI menu (item 2)", + "ansi": " open it in an ANSI viewer, or display/upload it " + "on your BBS"}.get( + plat, " (self-booting -- just insert the disk/cartridge)") + QtWidgets.QMessageBox.information( + self, "Exported", f"Wrote {out}\n\nOn the machine / emulator:\n{hint}") + except Exception: + QtWidgets.QMessageBox.critical(self, "Export failed", traceback.format_exc()) + + def run_in_vice(self): + if not self.last_conv: + return + plat = self.platform_cb.currentData() + if not platforms.has_emulator(plat): + QtWidgets.QMessageBox.warning( + self, "Emulator not found", + "MAME was not found on PATH.\n" + "Install it with: sudo apt install mame") + 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() + platforms.export(plat, self.last_conv, path, fmt, self.source_path, standard, + display=self.display_cb.currentText(), + seconds=self.seconds_sb.value(), + layout=self.layout_cb.currentText()) + self._temp_disks.append(path) + from . import mame + mame.kill(self._emu_proc) # close any previous MAME window + self._emu_proc = platforms.run_emulator( + plat, path, self.last_conv.mode, standard) + self.status.showMessage(f"Launched {plat} emulator ({standard}).") + except Exception: + QtWidgets.QMessageBox.critical(self, "Run in emulator failed", + traceback.format_exc()) + + def closeEvent(self, event): + from . import mame + mame.kill(self._emu_proc) # SIGKILL the emulator on exit + 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()) diff --git a/lenser/iigs/__init__.py b/lenser/iigs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lenser/iigs/convert/__init__.py b/lenser/iigs/convert/__init__.py new file mode 100644 index 0000000..545da1c --- /dev/null +++ b/lenser/iigs/convert/__init__.py @@ -0,0 +1,19 @@ +"""Apple IIGS conversion dispatch.""" +from __future__ import annotations + +from ... import imageprep +from . import shr, mono + +_MODULES = {"shr": shr, "mono": mono} +MODES = list(_MODULES.keys()) + + +def convert_image(path_or_img, mode="shr", palette_name="iigs", + dither_mode="floyd", intensive=False, prep_opt=None, + base_color=None): + prep_opt = prep_opt or imageprep.PrepOptions() + module = _MODULES.get(mode, shr) + 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) diff --git a/lenser/iigs/convert/_common.py b/lenser/iigs/convert/_common.py new file mode 100644 index 0000000..1f4fcc3 --- /dev/null +++ b/lenser/iigs/convert/_common.py @@ -0,0 +1,130 @@ +"""Apple IIGS Super Hi-Res encoder: 320x200, 16 palettes x 16 colours (of 4096), +one palette per scanline (the SCB). + +Lines are clustered into 16 palette groups; each group's 16 colours come from a +k-means of its pixels (quantised to 12-bit); each line is then assigned its best +palette and dithered to that palette's 16 colours. Mono uses a single 16-grey +palette for all lines. +""" +from __future__ import annotations + +import numpy as np + +from ... import dither, palette as c64pal +from ...convert.base import perceptual_error +from .. import palette as gs + +W, H = 320, 200 +NPAL = 16 +PALSIZE = 16 +# SHR RAM block ($2000-$9FFF) offsets, relative to the 32K block base ($2000): +PIX_OFF, SCB_OFF, PAL_OFF = 0x0000, 0x7D00, 0x7E00 +BLOCK_LEN = 0x8000 + + +def _kmeans(pts, k, iters=12, seed=0): + rng = np.random.default_rng(seed) + if len(pts) <= k: + c = np.zeros((k, pts.shape[1])); c[:len(pts)] = pts; return c + cen = pts[rng.choice(len(pts), k, replace=False)].copy() + for _ in range(iters): + lab = np.argmin(((pts[:, None, :] - cen[None]) ** 2).sum(2), 1) + for j in range(k): + m = pts[lab == j] + if len(m): + cen[j] = m.mean(0) + return cen + + +def _build_palettes(img_lab, img_rgb, mono, base_color): + """Return (pal_rgb (NPAL,16,3) float 0-255, pal_q4 (NPAL,16,3) 4-bit, + line_pal (H,) palette index per scanline).""" + if mono: + rgb, q4 = gs.greys12(PALSIZE) + if base_color is not None: + # tint: black -> base hue -> white ramp + base = np.array(base_color, float) + t = np.linspace(0, 1, PALSIZE)[:, None] + ramp = np.where(t < 0.5, base * (t / 0.5), + base + (255 - base) * ((t - 0.5) / 0.5)) + rgb, q4 = gs.quantize12(ramp) + pal_rgb = np.repeat(rgb[None], NPAL, 0) + pal_q4 = np.repeat(q4[None], NPAL, 0) + return pal_rgb, pal_q4, np.zeros(H, np.int64) + + # cluster the 200 lines into NPAL groups by mean colour + line_mean = img_lab.mean(1) # (H,3) + gc = _kmeans(line_mean, NPAL) + grp = np.argmin(((line_mean[:, None, :] - gc[None]) ** 2).sum(2), 1) + + pal_rgb = np.zeros((NPAL, PALSIZE, 3)) + pal_q4 = np.zeros((NPAL, PALSIZE, 3), np.int64) + rng = np.random.default_rng(0) + for g in range(NPAL): + lines = np.where(grp == g)[0] + if len(lines) == 0: + lines = np.array([g % H]) + pool = img_rgb[lines].reshape(-1, 3) + if len(pool) > 4000: + pool = pool[rng.choice(len(pool), 4000, replace=False)] + cen = _kmeans(c64pal.srgb_to_lab(pool.reshape(1, -1, 3))[0], PALSIZE) + # k-means in Lab -> map centroids back to RGB via nearest pool pixel + plab_pool = c64pal.srgb_to_lab(pool.reshape(1, -1, 3))[0] + rgb16 = np.zeros((PALSIZE, 3)) + for i in range(PALSIZE): + rgb16[i] = pool[np.argmin(((plab_pool - cen[i]) ** 2).sum(1))] + rgb_q, q4 = gs.quantize12(rgb16) + pal_rgb[g], pal_q4[g] = rgb_q, q4 + + # assign each line its best palette (min nearest-colour error) + pal_lab = c64pal.srgb_to_lab(pal_rgb.reshape(1, -1, 3))[0].reshape(NPAL, PALSIZE, 3) + line_pal = np.zeros(H, np.int64) + for y in range(H): + row = img_lab[y] # (W,3) + best, berr = 0, np.inf + for g in range(NPAL): + e = np.min(((row[:, None, :] - pal_lab[g][None]) ** 2).sum(2), 1).sum() + if e < berr: + berr, best = e, g + line_pal[y] = best + return pal_rgb, pal_q4, line_pal + + +def encode(img_rgb, dither_mode, mono=False, base_color=None): + img_lab = c64pal.srgb_to_lab(img_rgb) + pal_rgb, pal_q4, line_pal = _build_palettes(img_lab, img_rgb, mono, base_color) + + # combined colour table (NPAL*16) for the dither; per-pixel allowed = its + # line's 16 palette colours + rgb_all = pal_rgb.reshape(-1, 3) + plab_all = c64pal.srgb_to_lab(rgb_all.reshape(1, -1, 3))[0] + allowed = np.zeros((H, W, PALSIZE), np.int64) + for y in range(H): + base = line_pal[y] * PALSIZE + allowed[y, :, :] = np.arange(base, base + PALSIZE) + idx = dither.quantize(img_lab, allowed, plab_all, dither_mode).astype(np.int64) + nib = (idx - line_pal[:, None] * PALSIZE).astype(np.uint8) # 0-15 within palette + + # ---- emit the 32K SHR block ($2000-$9FFF) ---- + block = bytearray(BLOCK_LEN) + for y in range(H): + row = nib[y] + off = PIX_OFF + y * 160 + for bx in range(160): + block[off + bx] = (int(row[bx * 2]) << 4) | (int(row[bx * 2 + 1]) & 0x0F) + block[SCB_OFF + y] = line_pal[y] & 0x0F # 320 mode, palette n + for p in range(NPAL): + for c in range(PALSIZE): + r4, g4, b4 = pal_q4[p, c] + block[PAL_OFF + p * 32 + c * 2:PAL_OFF + p * 32 + c * 2 + 2] = \ + gs.color_word(int(r4), int(g4), int(b4)) + + preview = rgb_all[idx].astype(np.uint8) + if mono: + # measure against luminance (the greys have no hue), like other mono modes + lum = img_lab.copy(); lum[..., 1:] = 0.0 + plum = plab_all.copy(); plum[:, 1:] = 0.0 + err = perceptual_error(idx, lum, plum) + else: + err = perceptual_error(idx, img_lab, plab_all) + return bytes(block), preview, err diff --git a/lenser/iigs/convert/mono.py b/lenser/iigs/convert/mono.py new file mode 100644 index 0000000..7957191 --- /dev/null +++ b/lenser/iigs/convert/mono.py @@ -0,0 +1,25 @@ +"""Apple IIGS SHR monochrome: 320x200 with a single 16-level grey palette (the +richest greyscale in lenser). ``--mono-base`` tints the ramp toward a colour.""" +from __future__ import annotations + +from ...convert.base import Conversion +from ...palette import get_palette +from . import _common + +WIDTH, HEIGHT = 320, 200 +PIXEL_ASPECT = 1.0 + + +def convert(img_rgb, palette_name="iigs", dither_mode="floyd", + intensive=False, base_color=None): + base_rgb = None + if isinstance(base_color, int) and 0 <= base_color < 16: + base_rgb = get_palette("colodore")[base_color] # tint hue from C64 pal + block, preview, err = _common.encode(img_rgb, dither_mode, mono=True, + base_color=base_rgb) + return Conversion( + mode="mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=None, data=block, data_addr=0x2000, viewer="iigs", + preview_rgb=preview, error=err, + meta={"palette": "iigs", "dither": dither_mode}, + ) diff --git a/lenser/iigs/convert/shr.py b/lenser/iigs/convert/shr.py new file mode 100644 index 0000000..5cce4df --- /dev/null +++ b/lenser/iigs/convert/shr.py @@ -0,0 +1,19 @@ +"""Apple IIGS Super Hi-Res: 320x200, 16 colours/line from 16 palettes of 4096.""" +from __future__ import annotations + +from ...convert.base import Conversion +from . import _common + +WIDTH, HEIGHT = 320, 200 +PIXEL_ASPECT = 1.0 + + +def convert(img_rgb, palette_name="iigs", dither_mode="floyd", + intensive=False, base_color=None): + block, preview, err = _common.encode(img_rgb, dither_mode, mono=False) + return Conversion( + mode="shr", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=None, data=block, data_addr=0x2000, viewer="iigs", + preview_rgb=preview, error=err, + meta={"palette": "iigs", "dither": dither_mode}, + ) diff --git a/lenser/iigs/exporter.py b/lenser/iigs/exporter.py new file mode 100644 index 0000000..add81e5 --- /dev/null +++ b/lenser/iigs/exporter.py @@ -0,0 +1,13 @@ +"""Build a bootable Apple IIGS 5.25" .dsk (boots via slot 6 like an Apple II).""" +from __future__ import annotations + +from ..apple import dsk +from .viewer.assemble import assemble_boot + + +def export_dsk(conv, output_path, source_path=None, display="forever", + seconds=0, video="ntsc"): + if not output_path.lower().endswith((".dsk", ".do")): + output_path += ".dsk" + boot = assemble_boot() + return dsk.write_dsk(output_path, dsk.build_boot_dsk(boot, bytes(conv.data))) diff --git a/lenser/iigs/palette.py b/lenser/iigs/palette.py new file mode 100644 index 0000000..a3d21f7 --- /dev/null +++ b/lenser/iigs/palette.py @@ -0,0 +1,31 @@ +"""Apple IIGS Super Hi-Res colour helpers. + +SHR colours are 12-bit ``$0RGB`` -- 4 bits per channel, each level scaled x17 +(0->0, 15->255), exactly as MAME's apple2gs palette. There is no fixed master +palette: each of the 16 line-palettes holds 16 freely chosen 12-bit colours, so +the encoder works in the continuous 4096-colour space and quantises to 12-bit. +""" +from __future__ import annotations + +import numpy as np + + +def quantize12(rgb): + """Snap an RGB array (..,3) float 0-255 to the nearest 12-bit SHR colour.""" + q4 = np.clip(np.rint(np.asarray(rgb) / 17.0), 0, 15).astype(np.int64) + return q4 * 17, q4 # (rgb 0..255 in x17 steps, 4-bit) + + +def color_word(r4: int, g4: int, b4: int) -> bytes: + """16-bit little-endian SHR palette entry: low = (G<<4)|B, high = $0R.""" + lo = ((g4 & 0x0F) << 4) | (b4 & 0x0F) + hi = r4 & 0x0F + return bytes([lo, hi]) + + +def greys12(n: int = 16): + """``n`` evenly spaced 12-bit greys, black..white (RGB array, 4-bit array).""" + lv4 = np.clip(np.rint(np.linspace(0, 15, n)), 0, 15).astype(np.int64) + rgb = np.stack([lv4 * 17] * 3, axis=1).astype(np.float64) + q4 = np.stack([lv4] * 3, axis=1) + return rgb, q4 diff --git a/lenser/iigs/viewer/__init__.py b/lenser/iigs/viewer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lenser/iigs/viewer/assemble.py b/lenser/iigs/viewer/assemble.py new file mode 100644 index 0000000..21f67ad --- /dev/null +++ b/lenser/iigs/viewer/assemble.py @@ -0,0 +1,82 @@ +"""Assemble the IIGS SHR 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__)) + + +class AssemblerError(RuntimeError): + pass + + +def have_xa() -> bool: + return shutil.which("xa") is not None + + +def assemble_boot() -> bytes: + """Return the boot0 sector (<=256 bytes) that loads the SHR block + shows it.""" + if not have_xa(): + raise AssemblerError("The 'xa' assembler was not found on PATH.") + with tempfile.TemporaryDirectory() as td: + out = os.path.join(td, "v.bin") + proc = subprocess.run(["xa", "-w", "-o", out, "iigs.s"], + 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: + boot = f.read() + if len(boot) > 256: + raise AssemblerError(f"boot sector is {len(boot)} bytes (>256)") + return boot + + +SS_WAITMODE = {"key": 1, "seconds": 2, "both": 3} + + +def _xa_wrapper(wrapper: str, what: str) -> bytes: + if not have_xa(): + raise AssemblerError("The 'xa' assembler was not found on PATH.") + 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", "-w", "-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(n_images: int, advance: str = "both", seconds: int = 10, + loop: bool = True, video: str = "ntsc"): + """Assemble the two-stage IIGS SHR slideshow. + + Returns (boot_sector<=256B, stage2_bytes, stage2_pages). The boot reads + stage2 (stage2_pages sectors) into $0900, then the images into bank-0 $2000 + and banks them; stage2 cycles them onto the SHR screen. + """ + speed = 8 if video != "pal" else 7 # ~1s delay outer count (fast IIGS) + stage2 = _xa_wrapper( + f"#define WAITMODE {SS_WAITMODE[advance]}\n" + f"#define WAITSECS {max(0, min(255, int(seconds)))}\n" + f"#define SPEED {speed}\n" + f"#define NIMAGES {n_images}\n" + f"#define LOOPFLAG {1 if loop else 0}\n" + '#include "slideshow_stage2.s"\n', "iigs stage2") + s2_pages = max(1, (len(stage2) + 255) // 256) + boot = _xa_wrapper( + f"#define NIMAGES {n_images}\n" + f"#define S2END ${0x09 + s2_pages:02X}\n" + '#include "slideshow_boot.s"\n', "iigs boot") + if len(boot) > 256: + raise AssemblerError(f"IIGS slideshow boot is {len(boot)} bytes (>256)") + return boot, stage2, s2_pages diff --git a/lenser/iigs/viewer/iigs.s b/lenser/iigs/viewer/iigs.s new file mode 100644 index 0000000..84eb793 --- /dev/null +++ b/lenser/iigs/viewer/iigs.s @@ -0,0 +1,116 @@ +; lenser -- Apple IIGS Super Hi-Res boot loader + viewer (8-bit, no GS/OS). +; +; The Disk II boot ROM (slot 6) loads track 0 sector 0 to $0800 and JMPs $0801. +; This re-entrant loader reads the 32K SHR block (pixels $2000-$9CFF, SCBs +; $9D00, palettes $9E00) into main RAM $2000-$9FFF, then copies it to AUX RAM +; with SHR shadowing enabled -- so the writes mirror into bank $E1 (the SHR +; screen) -- and turns SHR on (NEWVIDEO bit 7). All 8-bit, no 65C816 needed. + + * = $0800 + .byte $01 ; ROM scratch (boot sector byte 0) +entry: ; $0801, re-entered after every ROM read + lda dpage + cmp #$a0 + bcs done ; loaded $2000..$9FFF -> show it + lda psec + cmp #$10 + bcc readit ; 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 + 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: + ; Turn on Super Hi-Res (bit 7) AND linearize (bit 6) FIRST. With these + ; set, CPU writes to bank $E1 $2000-$9FFF are de-interleaved by hardware + ; into the megaII layout the video reads -- so we can copy our linear SHR + ; block straight in and it displays correctly. + lda $c029 + ora #$c0 + sta $c029 + ; copy 32K from bank $00 $2000 -> bank $E1 $2000 (65C816 block move) + clc + xce ; -> native mode + rep #$30 ; 16-bit A/X/Y +.al +.xl + lda #$7fff ; count-1 (32768 bytes) + ldx #$2000 ; source offset + ldy #$2000 ; dest offset + .byte $54, $e1, $00 ; MVN src bank $00 -> dest bank $E1 + sep #$30 ; 8-bit +.as +.xs + sec + xce ; -> emulation mode + lda $c034 ; border colour is the low nibble of $C034 + and #$f0 + sta $c034 ; black border +hang: + jmp hang + +; advance the head one track (two half-steps), phase-overlap step. +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 +dpage: .byte $20 +halftrk: .byte $00 +curtrk: .byte $00 diff --git a/lenser/iigs/viewer/slideshow_boot.s b/lenser/iigs/viewer/slideshow_boot.s new file mode 100644 index 0000000..911d625 --- /dev/null +++ b/lenser/iigs/viewer/slideshow_boot.s @@ -0,0 +1,115 @@ +; lenser -- Apple IIGS SHR slideshow, stage 1 boot sector (<=256 bytes). +; +; The Disk II boot ROM loads track 0 sector 0 to $0800 and JMPs $0801; every ROM +; sector read JMPs back to $0801, so this is the master read driver. It first +; reads stage 2 into $0900 (S2END-$09 sectors), then reads each 32K SHR image +; into bank-0 $2000-$9FFF and calls stage 2 (stash, $0900) to bank it; after the +; last image it JMPs the stage-2 cycle viewer ($0903). +; +; #defines from the wrapper -- NIMAGES, S2END (one past the last stage-2 page). + + * = $0800 + .byte $01 ; ROM scratch (boot sector byte 0) +entry: ; $0801, re-entered after every ROM read + lda dpage + cmp endpg + bcc rdcont ; not at the current region's end yet + lda phase + bne imgdone + ; stage 2 finished loading -> start reading images into $2000 + inc phase + lda #$20 + sta dpage + lda #$a0 + sta endpg + bne rdcont ; always (endpg = $a0) +imgdone: + jsr $0900 ; stash bank0 $2000 -> next bank + inc imgcnt + lda imgcnt + cmp #NIMAGES + bcs allloaded + lda #$20 + sta dpage ; reset for the next image + bne rdcont ; always +allloaded: + jmp $0903 ; stage-2 cycle viewer (never returns) +rdcont: + 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 + +; advance the head one track (two half-steps), phase-overlap step (from iigs.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 + +phase: .byte 0 ; 0 = loading stage 2, 1 = loading images +endpg: .byte S2END ; stage-2 ends one past this page +imgcnt: .byte 0 +psec: .byte $01 ; next physical sector (track 0 starts at 1) +dpage: .byte $09 ; stage 2 loads from $0900 +halftrk: .byte $00 +curtrk: .byte $00 diff --git a/lenser/iigs/viewer/slideshow_stage2.s b/lenser/iigs/viewer/slideshow_stage2.s new file mode 100644 index 0000000..7658d00 --- /dev/null +++ b/lenser/iigs/viewer/slideshow_stage2.s @@ -0,0 +1,147 @@ +; lenser -- Apple IIGS SHR slideshow, stage 2 (loaded at $0900 by the boot). +; +; The boot reads each 32K SHR image into bank-0 $2000-$9FFF and calls stash to +; block-move it up into a free RAM bank (image i -> bank 1+i, offset $2000). +; Once all images are stashed the boot JMPs cycle, which block-moves each banked +; image into the SHR screen (bank $E1 $2000) in turn, waiting (key/seconds/both) +; between slides. Self-modifying MVN bank bytes select the bank per image. +; +; #defines from the wrapper -- WAITMODE 1 key/2 seconds/3 both, WAITSECS, SPEED +; (delay outer count ~1s at the IIGS clock), NIMAGES, LOOPFLAG. + + * = $0900 + jmp stash ; $0900 entry (called per loaded image) + jmp cycle ; $0903 entry (called once, all images loaded) + +; ---- MVN bank0 $2000 -> curbank $2000 (32K), then advance curbank ---- +stash: + clc + xce + rep #$30 +.al +.xl + lda #$7fff + ldx #$2000 + ldy #$2000 + .byte $54 ; MVN dest, src +mvndst: .byte $01 ; dest bank (patched each call) + .byte $00 ; src bank 0 + .byte $4b ; phk + .byte $ab ; plb -- restore DBR = 0 (MVN left it = dest bank) + sep #$30 +.as +.xs + sec + xce + inc mvndst ; next image -> next bank + rts + +; ---- show each banked image on the SHR screen, forever ---- +cycle: + lda $c029 + ora #$c0 + sta $c029 ; SHR on + linearise + lda $c034 + and #$f0 + sta $c034 ; black border + lda #$01 + sta mvnsrc + lda #$00 + sta cyci +cloop: + clc + xce + rep #$30 +.al +.xl + lda #$7fff + ldx #$2000 + ldy #$2000 + .byte $54, $e1 ; MVN dest $E1, src ... +mvnsrc: .byte $01 ; src bank (patched per slide) + .byte $4b ; phk + .byte $ab ; plb -- restore DBR = 0 + sep #$30 +.as +.xs + sec + xce + jsr sswait + inc cyci + lda cyci + cmp #NIMAGES + bcc cnext +#if LOOPFLAG == 1 + lda #$01 + sta mvnsrc + lda #$00 + sta cyci + jmp cloop +#else + jmp $e000 ; (no loop) cold start to firmware +#endif +cnext: + inc mvnsrc + jmp cloop + +; ---- wait (returns); $C000 bit7 = key, delay loop for the timer ---- +sswait: + bit $c010 +#if WAITMODE == 1 +swk: + lda $c000 + bpl swk + bit $c010 + rts +#endif +#if WAITMODE == 2 + lda #WAITSECS + sta wsec +wso: + lda #SPEED + sta wmid +wsm: + ldx #$00 +wsx: + ldy #$00 +wsy: + dey + bne wsy + dex + bne wsx + dec wmid + bne wsm + dec wsec + bne wso + rts +#endif +#if WAITMODE == 3 + lda #WAITSECS + sta wsec +wbo: + lda #SPEED + sta wmid +wbm: + lda $c000 + bmi wbk ; a key ends the slide + ldx #$00 +wbx: + ldy #$00 +wby: + dey + bne wby + dex + bne wbx + dec wmid + bne wbm + dec wsec + bne wbo + rts +wbk: + bit $c010 + rts +#endif + +wsec: .byte 0 +wmid: .byte 0 +cyci: .byte 0 diff --git a/c64view/imageprep.py b/lenser/imageprep.py similarity index 78% rename from c64view/imageprep.py rename to lenser/imageprep.py index b7ec9be..4a88caa 100644 --- a/c64view/imageprep.py +++ b/lenser/imageprep.py @@ -22,6 +22,10 @@ class PrepOptions: contrast: float = 1.0 saturation: float = 1.0 gamma: float = 1.0 # <1 brightens midtones, >1 darkens + tint: float = 0.0 # hue rotation in degrees (-180..180), 0 = none + red: float = 1.0 # per-channel level (gain), 1.0 = unchanged + green: float = 1.0 + blue: float = 1.0 border_index: int = 0 # palette index used for letterbox bars @@ -35,6 +39,17 @@ def _apply_enhancements(img: Image.Image, opt: PrepOptions) -> Image.Image: if opt.gamma != 1.0: lut = [min(255, int((i / 255.0) ** opt.gamma * 255 + 0.5)) for i in range(256)] img = img.point(lut * 3) + # per-channel colour levels (RGB gain / colour balance) + if opt.red != 1.0 or opt.green != 1.0 or opt.blue != 1.0: + def _gain(g): + return [min(255, max(0, int(i * g + 0.5))) for i in range(256)] + img = img.point(_gain(opt.red) + _gain(opt.green) + _gain(opt.blue)) + # tint = rotate every hue around the colour wheel + if opt.tint != 0.0: + h, s, v = img.convert("HSV").split() + shift = int(round(opt.tint / 360.0 * 255)) + h = h.point(lambda i, sh=shift: (i + sh) % 256) + img = Image.merge("HSV", (h, s, v)).convert("RGB") return img diff --git a/c64view/imginfo.py b/lenser/imginfo.py similarity index 100% rename from c64view/imginfo.py rename to lenser/imginfo.py diff --git a/lenser/intv/__init__.py b/lenser/intv/__init__.py new file mode 100644 index 0000000..15cb18f --- /dev/null +++ b/lenser/intv/__init__.py @@ -0,0 +1,2 @@ +"""Mattel Intellivision target: STIC Color-Stack image viewer on a clean +CP1610 cartridge (no copyrighted EXEC/game data).""" diff --git a/lenser/intv/cartridge.py b/lenser/intv/cartridge.py new file mode 100644 index 0000000..7222249 --- /dev/null +++ b/lenser/intv/cartridge.py @@ -0,0 +1,172 @@ +"""Builds a clean Mattel Intellivision cartridge ROM (maps at $5000) holding a +CP1610 viewer plus the encoded image, with NO copyrighted EXEC/game data. + +ROM layout (4096 16-bit words, big-endian; loose-file extension ``.int`` / +``.bin`` / ``.rom`` -- MAME maps a headerless dump at $5000): + + $5000 six BIDECLE header pointers + flags word + MOB / process / bkgnd -> a $0000 word (empty, tolerated by the EXEC) + start -> MAIN + GRAM -> [$0001, 0x8] (1 blank card -- minimal list + that does NOT crash the EXEC's loader; the + ISR re-uploads the real tiles anyway) + title -> (year-1900) word, ASCII, 0 + $5040 MAIN (install ISR, fill BACKTAB from the card table, then idle/duration) + .... ISR (display handshake $0020, colour stack, GRAM upload) + .... card table (240 words) and GRAM tile table (512 words) +""" + +from __future__ import annotations + +import struct + +from .cp1610 import Asm + +ROM_WORDS = 4096 +EMPTY = 0x500D +TITLE = 0x500E +GRAMLIST = 0x5018 +MAIN = 0x5040 +CARDROM = 0x5100 +GRAMROM = 0x5200 +# 8-bit Scratch RAM ($0100-$01EF) is separate from the EXEC stack (which roams +# 16-bit System RAM $02F0+), so it is safe for our persistent ISR state. +CHUNKCTR = 0x0108 # GRAM-upload progress (0..8 chunks of 64 words) +FRAMELO = 0x0109 # display-duration frame counter (lo byte) +FRAMEHI = 0x010A # ... hi byte +N_CHUNKS = 8 # 8 x 64 = 512 GRAM words +HAND_R = 0x01FF # right hand controller (PSG I/O port) + + +def _bidecle(put, addr, target): + put(addr, target & 0xFF) + put(addr + 1, (target >> 8) & 0xFF) + + +def build_rom(data: dict, title: str = "PHOTO", display: str = "forever", + seconds: int = 0, video: str = "ntsc") -> bytes: + gram = [int(w) & 0xFFFF for w in data["gram"]] # 512 words + cards = [int(w) & 0xFFFF for w in data["cards"]] # 240 words + + rom = [0] * ROM_WORDS + + def put(a, w): + rom[a - 0x5000] = w & 0xFFFF + + # ---- header ---- + _bidecle(put, 0x5000, EMPTY) # MOB + _bidecle(put, 0x5002, EMPTY) # process + _bidecle(put, 0x5004, MAIN) # program start + _bidecle(put, 0x5006, EMPTY) # background + _bidecle(put, 0x5008, GRAMLIST) # GRAM + _bidecle(put, 0x500A, TITLE) # title + put(0x500C, 0x0000) # flags + put(EMPTY, 0x0000) + for i, w in enumerate([0x0001, 0, 0, 0, 0, 0, 0, 0, 0]): + put(GRAMLIST + i, w) + + # ---- title block ---- + a = TITLE + put(a, 84) # year - 1900 (cosmetic) + a += 1 + name = "".join(c for c in title.upper() if 32 <= ord(c) < 127)[:14] or "PHOTO" + for ch in name: + put(a, ord(ch)) + a += 1 + put(a, 0) + + # ---- data tables ---- + for i, w in enumerate(cards): + put(CARDROM + i, w) + for i, w in enumerate(gram): + put(GRAMROM + i, w) + + # ---- code ---- + words, isr = _assemble(display, seconds, video) + for i, w in enumerate(words): + put(MAIN + i, w) + + return b"".join(struct.pack(">H", w) for w in rom) + + +def _duration_loop(z, display, seconds, fps): + """Emit the post-setup idle/duration loop.""" + nframes = max(1, int(seconds) * fps) + if display == "key": + z.label('loop') + z.mvi(HAND_R, 0); z.cmpi(0xFF, 0); z.bnze('redisplay') + z.b('loop') + z.label('redisplay'); z.j(MAIN) + elif display == "seconds": + hi, lo = (nframes >> 8) & 0xFF, nframes & 0xFF + z.label('loop') + z.mvi(FRAMEHI, 0); z.cmpi(hi, 0); z.bnze('chkhi') + z.mvi(FRAMELO, 0); z.cmpi(lo, 0); z.bc('redisplay') + z.b('loop') + z.label('chkhi'); z.bc('redisplay') + z.b('loop') + z.label('redisplay'); z.j(MAIN) + else: + z.label('loop'); z.b('loop') + + +def _seconds_tick(z): + z.mvi(FRAMELO, 1); z.incr(1); z.andi(0xFF, 1); z.mvo(1, FRAMELO) + z.bnze('noco'); z.mvi(FRAMEHI, 1); z.incr(1); z.mvo(1, FRAMEHI) + z.label('noco') + + +def _gram_chunk(z, gramrom): + """ISR fragment: upload one 64-word GRAM chunk/frame over 8 frames.""" + z.mvi(CHUNKCTR, 2) + z.cmpi(N_CHUNKS, 2); z.bc('grdone') + z.movr(2, 3); z.sll(3, 2); z.sll(3, 2); z.sll(3, 2) # R3 = chunk * 64 + z.movr(3, 4); z.addi(0x3800, 4) + z.movr(3, 5); z.addi(gramrom, 5) + z.mvii(64, 1) + z.label('up'); z.mvi_at(5, 0); z.mvo_at(0, 4); z.decr(1); z.bnze('up') + z.incr(2); z.mvo(2, CHUNKCTR) + z.label('grdone') + + +def _patch_isr(z): + """Patch the two MVII immediates that install the ISR vector at $0100/$0101.""" + words = z.resolve() + isr = z.labels['isr'] + words[2] = isr & 0xFF + words[6] = (isr >> 8) & 0xFF + return words, isr + + +def _assemble(display, seconds, video): + """Emit MAIN + ISR for the flicker-free FGBG viewer. Returns (words, isr). + + GRAM (512 words) is far too big to upload in one VBLANK (~3500 cycles), so + the ISR uploads it 64 words at a time over 8 frames, tracking progress in + Scratch RAM; once done the cards reference the fully-loaded tiles. + """ + fps = 50 if video == "pal" else 60 + z = Asm(MAIN) + z.dis() + z.mvii(0, 0); z.mvo(0, 0x0100) # install ISR (words 2,6 = imm lo/hi) + z.mvii(0, 0); z.mvo(0, 0x0101) + z.mvii(0, 0) + z.mvo(0, CHUNKCTR); z.mvo(0, FRAMELO); z.mvo(0, FRAMEHI) + z.mvii(0x0200, 4); z.mvii(CARDROM, 5); z.mvii(240, 1) + z.label('fill'); z.mvi_at(5, 0); z.mvo_at(0, 4); z.decr(1); z.bnze('fill') + z.eis() + _duration_loop(z, display, seconds, fps) + + # ---- ISR ---- + # GRAM writes are VBLANK-only and writing $0020 (display enable) closes that + # window, so upload FIRST and handshake LAST. Writing $0021 selects FGBG mode + # (each card carries its own fg+bg); never READ $0021 (-> Color-Stack mode). + z.label('isr') + z.pshr(5) + _gram_chunk(z, GRAMROM) + if display == "seconds": + _seconds_tick(z) + z.mvii(0, 0); z.mvo(0, 0x0021) # select FGBG mode + z.mvii(1, 0); z.mvo(0, 0x0020) # display handshake LAST + z.pulr(5); z.jr(5) + return _patch_isr(z) diff --git a/lenser/intv/convert/__init__.py b/lenser/intv/convert/__init__.py new file mode 100644 index 0000000..06cd7e5 --- /dev/null +++ b/lenser/intv/convert/__init__.py @@ -0,0 +1,19 @@ +"""Mattel Intellivision conversion dispatch.""" +from __future__ import annotations + +from ... import imageprep +from . import mono, stic + +_MODULES = {"stic": stic, "mono": mono} +MODES = list(_MODULES.keys()) + + +def convert_image(path_or_img, mode="stic", palette_name="stic", + dither_mode="floyd", intensive=False, prep_opt=None, + base_color=None): + prep_opt = prep_opt or imageprep.PrepOptions() + module = _MODULES.get(mode, stic) + img_rgb = imageprep.prepare(path_or_img, stic.WIDTH, stic.HEIGHT, + stic.PIXEL_ASPECT, prep_opt, border_rgb=(0, 0, 0)) + return module.convert(img_rgb, palette_name, dither_mode, intensive, + base_color=base_color) diff --git a/lenser/intv/convert/mono.py b/lenser/intv/convert/mono.py new file mode 100644 index 0000000..47e4578 --- /dev/null +++ b/lenser/intv/convert/mono.py @@ -0,0 +1,87 @@ +"""Mattel Intellivision monochrome / tinted-mono mode. + +160x96 matched by luminance to two greys (black + white, or black + a tinted base +colour). With the colours fixed, the whole image budget goes into the 64-tile +GRAM shapes, which are optimised by the same vector-quantisation reshape/reassign +used by the colour encoder -- a clean two-tone Intellivision picture. +""" + +from __future__ import annotations + +import numpy as np + +from ... import dither, palette as c64pal +from ...convert import base +from .. import palette as ipal +from . import stic + +BLACK = 0 +WHITE = 7 # white is a foreground-legal colour (0-7) + + +def convert(img_rgb, palette_name="stic", dither_mode="none", + intensive=False, base_color=None): + # mono is carried entirely by dithering, so it needs error diffusion -- the + # colour mode's "none" default would give a stark, high-contrast result. + if dither_mode not in base.DIFFUSION_DITHERS: + dither_mode = "floyd" + plab = ipal.palette_lab() + prgb = ipal.get_palette().astype(np.uint8) + fg = base_color if base_color in range(1, 8) else WHITE + bg = BLACK + + # luminance image + luminance-only palette + L = c64pal.srgb_to_lab(img_rgb)[..., 0] + img_mono = np.zeros((stic.HEIGHT, stic.WIDTH, 3)) + img_mono[..., 0] = L + plab_mono = np.zeros_like(plab) + plab_mono[:, 0] = plab[:, 0] + + cells, rows, cols = base.cells_lab(img_mono, stic.CELL_W, stic.CELL_H) + + sets = np.tile(np.array([bg, fg], np.int64), (stic.N_ROWS * stic.N_COLS, 1)) + allowed = base.per_pixel_allowed(sets, rows, cols, stic.CELL_W, stic.CELL_H, + stic.HEIGHT, stic.WIDTH) + idx = dither.quantize(img_mono, allowed, plab_mono, dither_mode).astype(np.uint8) + + bitmaps = np.zeros((stic.N_ROWS * stic.N_COLS, 64), np.uint8) + for cr in range(rows): + for cc in range(cols): + ci = cr * cols + cc + block = idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8] + bitmaps[ci] = (block == fg).astype(np.uint8).reshape(-1) + + tiles, labels = base.mono_codebook(bitmaps, stic.N_TILES) + + prev_idx = np.empty((stic.HEIGHT, stic.WIDTH), np.uint8) + cards = np.zeros(stic.N_ROWS * stic.N_COLS, np.uint16) + for ci in range(stic.N_ROWS * stic.N_COLS): + cr, cc = divmod(ci, cols) + tile = tiles[labels[ci]].reshape(8, 8) + prev_idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8] = np.where(tile == 1, fg, bg) + cards[ci] = (fg & 0x07) | stic.GRAM_BIT | ((labels[ci] & 0x3F) << 3) \ + | stic._bg_bits(bg) + + gram = np.zeros(stic.N_TILES * 8, np.uint16) + for t in range(stic.N_TILES): + rb = tiles[t].reshape(8, 8) + for r in range(8): + byte = 0 + for x in range(8): + byte = (byte << 1) | int(rb[r, x]) + gram[t * 8 + r] = byte + + data = {"gram": gram, "cards": cards} + preview = prgb[prev_idx] + disp_w = int(round(stic.WIDTH * stic.PIXEL_ASPECT)) + xs = (np.arange(disp_w) * stic.WIDTH) // disp_w + preview = preview[:, xs] + + return base.Conversion( + mode="mono", width=stic.WIDTH, height=stic.HEIGHT, + pixel_aspect=stic.PIXEL_ASPECT, index_image=prev_idx.astype(np.uint16), + data=data, data_addr=0, viewer="stic", preview_rgb=preview, + error=base.perceptual_error(prev_idx, img_mono, plab_mono), + meta={"palette": "stic", "dither": dither_mode, "base_color": base_color, + "mode": "fgbg"}, + ) diff --git a/lenser/intv/convert/stic.py b/lenser/intv/convert/stic.py new file mode 100644 index 0000000..1594adb --- /dev/null +++ b/lenser/intv/convert/stic.py @@ -0,0 +1,235 @@ +"""Intellivision STIC Foreground/Background image encoder. + +160x96 = 20x12 cells of 8x8. FGBG mode gives each cell TWO independent colours +(like C64 hires): a foreground from the 8 primary colours (0-7) and a background +from all 16. Each cell's 8x8 bitmap is drawn from a dictionary of 64 user tiles +(GRAM), so cell bitmaps are clustered to 64 patterns. + +Card word (FGBG): FG(bits 0-2) | GRAMbit(0x800) | (tile<<3) | BG(bits 9,10,12,13). +""" + +from __future__ import annotations + +import numpy as np + +from ... import dither, palette as c64pal +from ...convert.base import (Conversion, cell_distance, cells_lab, + per_pixel_allowed, perceptual_error) +from .. import palette as ipal + +WIDTH, HEIGHT = 160, 96 +PIXEL_ASPECT = 1.2 # 160x96 shown on a ~4:3 screen -> pixels a touch tall +CELL_W, CELL_H = 8, 8 +N_COLS, N_ROWS = 20, 12 +N_TILES = 64 +GRAM_BIT = 0x0800 +N_FG = 8 # foreground limited to primary colours 0-7 + + +def _bg_bits(c: int) -> int: + """Pack a 0-15 background colour into FGBG card bits 9,10,12,13.""" + return (((c >> 0) & 1) << 9) | (((c >> 1) & 1) << 10) | \ + (((c >> 2) & 1) << 12) | (((c >> 3) & 1) << 13) + + +def _best_pairs(dist): + """Per cell choose (fg in 0-7, bg in 0-15) minimising nearest-colour error. + dist: (n_cells, P, 16) squared distances. Returns (fg, bg) int arrays.""" + n = dist.shape[0] + best_err = np.full(n, np.inf) + best_fg = np.zeros(n, np.int64) + best_bg = np.zeros(n, np.int64) + for fg in range(N_FG): + dfg = dist[:, :, fg] + for bg in range(16): + m = np.minimum(dfg, dist[:, :, bg]) + err = m.sum(axis=1) + better = err < best_err + best_err[better] = err[better] + best_fg[better] = fg + best_bg[better] = bg + return best_fg, best_bg + + +def _cluster_tiles(bitmaps, k=N_TILES, iters=16, seed=0): + """k-means on 64-dim {0,1} cell bitmaps -> k binary representative tiles.""" + pats = bitmaps.astype(np.float64) + uniq, counts = np.unique(bitmaps, axis=0, return_counts=True) + if len(uniq) <= k: + tiles = np.zeros((k, 64), np.uint8) + tiles[:len(uniq)] = uniq + lut = {tuple(p): i for i, p in enumerate(uniq)} + labels = np.array([lut[tuple(b)] for b in bitmaps]) + return tiles, labels + order = np.argsort(-counts)[:k] + cent = uniq[order].astype(np.float64) + rng = np.random.default_rng(seed) + for _ in range(iters): + d = ((pats[:, None, :] - cent[None, :, :]) ** 2).sum(-1) + labels = d.argmin(1) + for j in range(k): + msk = labels == j + if msk.any(): + cent[j] = pats[msk].mean(0) + else: + cent[j] = pats[rng.integers(len(pats))] + tiles = (cent >= 0.5).astype(np.uint8) + d = ((pats[:, None, :] - tiles[None, :, :].astype(np.float64)) ** 2).sum(-1) + labels = d.argmin(1) + return tiles, labels + + +def _recolor(dist, tiles, labels): + """Given each cell's tile (shape), pick the (fg in 0-7, bg in 0-15) that + minimise reconstruction error. Returns (fg, bg, total_error).""" + n = dist.shape[0] + M = tiles[labels].astype(np.float64) # (n, P) 1=fg + fg_cost = np.einsum('np,npk->nk', M, dist) # (n,16) error on fg pixels + bg_cost = np.einsum('np,npk->nk', 1.0 - M, dist) # (n,16) error on bg pixels + fg = fg_cost[:, :N_FG].argmin(1) + bg = bg_cost.argmin(1) + rows = np.arange(n) + total = fg_cost[rows, fg].sum() + bg_cost[rows, bg].sum() + return fg.astype(np.int64), bg.astype(np.int64), float(total) + + +def _reassign(dist, tiles): + """Assign each cell to the tile that (after optimal recolouring) minimises its + error. Returns labels (n,).""" + M = tiles.astype(np.float64) # (T, P) + fg_sum = np.einsum('tp,npk->ntk', M, dist) # (n,T,16) + bg_sum = np.einsum('tp,npk->ntk', 1.0 - M, dist) + cost = fg_sum[:, :, :N_FG].min(2) + bg_sum.min(2) # (n,T) + return cost.argmin(1) + + +def _reshape(dist, labels, fg, bg, n_tiles=N_TILES): + """Recompute each tile's 8x8 mask from all cells using it: a pixel is fg where + that lowers total error across those cells (block-truncation coding).""" + n, P, _ = dist.shape + rows = np.arange(n)[:, None] + cols = np.arange(P)[None, :] + dfg = dist[rows, cols, fg[:, None]] # (n,P) error if pixel = fg + dbg = dist[rows, cols, bg[:, None]] # (n,P) error if pixel = bg + tiles = np.zeros((n_tiles, P), np.uint8) + # cells with the largest fg-vs-bg disagreement seed any empty tiles + worst = np.argsort(-(np.abs(dfg - dbg).sum(1))) + wi = 0 + for t in range(n_tiles): + msk = labels == t + if msk.any(): + tiles[t] = (dfg[msk].sum(0) < dbg[msk].sum(0)).astype(np.uint8) + else: + # reseed from an unused high-contrast cell so all 64 tiles get used + c = int(worst[wi % n]); wi += 1 + tiles[t] = (dfg[c] < dbg[c]).astype(np.uint8) + return tiles + + +def _refine_once(dist, tiles, max_iters=40): + """One Lloyd run from a given tile set to convergence. Returns + (tiles, labels, fg, bg, total_error).""" + best = None + best_err = np.inf + for _ in range(max_iters): + labels = _reassign(dist, tiles) + fg, bg, _ = _recolor(dist, tiles, labels) + tiles = _reshape(dist, labels, fg, bg) + fg, bg, err = _recolor(dist, tiles, labels) + if err < best_err - 1e-6: + best_err = err + best = (tiles.copy(), labels.copy(), fg.copy(), bg.copy()) + else: + break # converged + return (*best, best_err) + + +def _refine(dist, tiles0, n_restarts): + """Joint optimisation of the 64 tile shapes and the per-cell (tile, fg, bg). + + Lloyd iteration has local minima, so run it from the clustered initialisation + plus several random tile sets and keep the globally lowest-error result -- the + one that reproduces the picture most faithfully within the 64-tile budget. + """ + P = dist.shape[1] + best = None + best_err = np.inf + rng = np.random.default_rng(0) + inits = [tiles0] + [rng.integers(0, 2, (N_TILES, P)).astype(np.uint8) + for _ in range(n_restarts)] + for t0 in inits: + tiles, labels, fg, bg, err = _refine_once(dist, t0) + if err < best_err: + best_err = err + best = (tiles, labels, fg, bg) + return best + + +def convert(img_rgb, palette_name="stic", dither_mode="floyd", + intensive=False, base_color=None): + plab = ipal.palette_lab() + prgb = ipal.get_palette().astype(np.uint8) + img_lab = c64pal.srgb_to_lab(img_rgb) + + cells, rows, cols = cells_lab(img_lab, CELL_W, CELL_H) + dist = cell_distance(cells, plab) + fg, bg = _best_pairs(dist) # (240,) each + + # dither each cell between its two colours (order [bg, fg] for per_pixel_allowed) + sets = np.stack([bg, fg], axis=1) + allowed = per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH) + idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8) + + bitmaps = np.zeros((N_ROWS * N_COLS, 64), np.uint8) + for cr in range(rows): + for cc in range(cols): + ci = cr * cols + cc + block = idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8] + bitmaps[ci] = (block == fg[ci]).astype(np.uint8).reshape(-1) + + tiles, labels = _cluster_tiles(bitmaps) + + # Joint vector-quantisation refinement: the one-shot select->cluster->recolour + # leaves the 64 shared tile shapes and the per-cell (tile, fg, bg) far from + # optimal. Alternately re-assign each cell to its best tile, re-pick the two + # colours, and re-cut every tile's shape (block-truncation coding) -- each step + # provably lowers total CIELAB error, so the picture converges much closer to + # the original within the 64-tile budget. + n_restarts = 16 if intensive else 4 + refined = _refine(dist, tiles, n_restarts) + if refined is not None: + tiles, labels, fg, bg = refined + + prev_idx = np.empty((HEIGHT, WIDTH), np.uint8) + cards = np.zeros(N_ROWS * N_COLS, np.uint16) + for ci in range(N_ROWS * N_COLS): + cr, cc = divmod(ci, cols) + tile = tiles[labels[ci]].reshape(8, 8) + f, b = int(fg[ci]), int(bg[ci]) + prev_idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8] = np.where(tile == 1, f, b) + cards[ci] = (f & 0x07) | GRAM_BIT | ((labels[ci] & 0x3F) << 3) | _bg_bits(b) + + gram = np.zeros(N_TILES * 8, np.uint16) + for t in range(N_TILES): + rb = tiles[t].reshape(8, 8) + for r in range(8): + byte = 0 + for x in range(8): + byte = (byte << 1) | int(rb[r, x]) + gram[t * 8 + r] = byte + + data = {"gram": gram, "cards": cards} + + # pre-widen preview to display resolution (matches atari/apple/a2600 convention) + preview = prgb[prev_idx] + disp_w = int(round(WIDTH * PIXEL_ASPECT)) + xs = (np.arange(disp_w) * WIDTH) // disp_w + preview = preview[:, xs] + + return Conversion( + mode="stic", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=prev_idx.astype(np.uint16), data=data, data_addr=0, + viewer="stic", preview_rgb=preview, + error=perceptual_error(prev_idx, img_lab, plab), + meta={"palette": "stic", "dither": dither_mode, "mode": "fgbg"}, + ) diff --git a/lenser/intv/cp1610.py b/lenser/intv/cp1610.py new file mode 100644 index 0000000..9763ee9 --- /dev/null +++ b/lenser/intv/cp1610.py @@ -0,0 +1,125 @@ +"""A tiny General Instrument CP1610 machine-code emitter (Intellivision). + +No CP1610 assembler is installed, so -- as with the TMS9900/Z80/6809 emitters -- +we emit opcodes directly. Encodings validated by disassembling a real cart +(Astrosmash). Output is a list of 16-bit words (the CP1610 fetches 10-bit +"decles" from the low bits; the Intellivision ROM is 16-bit big-endian at $5000). +""" + +from __future__ import annotations + + +class Asm: + def __init__(self, base: int): + self.base = base + self.words: list[int] = [] + self.labels: dict[str, int] = {} + self._fix: list[tuple[int, str, str]] = [] # (word index, label, kind) + + def pos(self) -> int: + return self.base + len(self.words) + + def label(self, name: str): + self.labels[name] = self.pos() + + def _w(self, *ws): + self.words.extend(w & 0xFFFF for w in ws) + + # ---- implied ---- + def sdbd(self): self._w(0x0001) + def eis(self): self._w(0x0002) + def dis(self): self._w(0x0003) + def tci(self): self._w(0x0005) + def clrc(self): self._w(0x0006) + def setc(self): self._w(0x0007) + def nop(self): self._w(0x0034) + + # ---- single register ---- + def incr(self, r): self._w(0x0008 | r) + def decr(self, r): self._w(0x0010 | r) + def comr(self, r): self._w(0x0018 | r) + def negr(self, r): self._w(0x0020 | r) + def adcr(self, r): self._w(0x0028 | r) + def tstr(self, r): self._w(0x0080 | (r << 3) | r) # MOVR r,r sets flags + + # ---- register-register ---- + # ---- shifts/rotates (R0-R3 only; n = 1 or 2 bits) ---- + def sll(self, r, n=1): self._w(0x0048 | ((n - 1) << 2) | r) # shift left logical + def slr(self, r, n=1): self._w(0x0060 | ((n - 1) << 2) | r) # shift right logical + + def movr(self, s, d): self._w(0x0080 | (s << 3) | d) + def addr(self, s, d): self._w(0x00C0 | (s << 3) | d) + def subr(self, s, d): self._w(0x0100 | (s << 3) | d) + def cmpr(self, s, d): self._w(0x0140 | (s << 3) | d) + def andr(self, s, d): self._w(0x0180 | (s << 3) | d) + def xorr(self, s, d): self._w(0x01C0 | (s << 3) | d) + def clrr(self, r): self._w(0x01C0 | (r << 3) | r) # XORR r,r + def jr(self, r): self._w(0x0080 | (r << 3) | 7) # MOVR r,R7 = jump + + # ---- external reference (direct addr / immediate / indirect) ---- + def mvi(self, addr, d): self._w(0x0280 | d, addr) # load (addr)->Rd + def mvo(self, s, addr): self._w(0x0240 | s, addr) # store Rs->(addr) + def mvii(self, imm, d): self._w(0x0280 | (7 << 3) | d, imm) + def addi(self, imm, d): self._w(0x02C0 | (7 << 3) | d, imm) + def subi(self, imm, d): self._w(0x0300 | (7 << 3) | d, imm) + def cmpi(self, imm, d): self._w(0x0340 | (7 << 3) | d, imm) + def andi(self, imm, d): self._w(0x0380 | (7 << 3) | d, imm) + def xori(self, imm, d): self._w(0x03C0 | (7 << 3) | d, imm) + + def mvi_at(self, m, d): self._w(0x0280 | (m << 3) | d) # load (Rm)->Rd + def mvo_at(self, s, m): self._w(0x0240 | (m << 3) | s) # store Rs->(Rm) + def pshr(self, s): self._w(0x0240 | (6 << 3) | s) # MVO@ Rs,R6 + def pulr(self, d): self._w(0x0280 | (6 << 3) | d) # MVI@ R6,Rd + + # ---- jumps (absolute, 3 words) ---- + def _jword(self, addr, reg): + self._w(0x0004, (reg << 8) | (((addr >> 10) & 0x3F) << 2), addr & 0x3FF) + + def j(self, addr): self._jword(addr, 3) # J (no return) + def jsr(self, addr): self._jword(addr, 1) # JSR R5,addr + + def j_label(self, label): + i = len(self.words) + self._w(0x0004, 0, 0) + self._fix.append((i, label, "j")) + + def jsr_label(self, label): + i = len(self.words) + self._w(0x0004, 0, 0) + self._fix.append((i, label, "jsr")) + + # ---- branches (opcode + displacement word) ---- + def _branch(self, cond, label): + i = len(self.words) + self._w(0x0200 | cond, 0) # direction/disp filled by resolve + self._fix.append((i, label, "b")) + + def b(self, label): self._branch(0x0, label) + def beq(self, label): self._branch(0x4, label) + def bnze(self, label): self._branch(0xC, label) + def bc(self, label): self._branch(0x1, label) + def bnc(self, label): self._branch(0x9, label) + + def decle(self, *vals): + self._w(*vals) + + def resolve(self) -> list[int]: + for i, label, kind in self._fix: + target = self.labels[label] + if kind == "j": + self.words[i + 1] = (3 << 8) | (((target >> 10) & 0x3F) << 2) + self.words[i + 2] = target & 0x3FF + elif kind == "jsr": + self.words[i + 1] = (1 << 8) | (((target >> 10) & 0x3F) << 2) + self.words[i + 2] = target & 0x3FF + else: # branch + a = self.base + i # address of the branch opcode + if target <= a: # backward: CPU computes target = a + 1 - disp + disp = a + 1 - target + self.words[i] |= (1 << 5) + else: # forward: target = a + 2 + disp + disp = target - a - 2 + if not 0 <= disp <= 0xFFFF: + raise ValueError(f"branch to {label} out of range") + self.words[i + 1] = disp & 0xFFFF + return list(self.words) diff --git a/lenser/intv/exporter.py b/lenser/intv/exporter.py new file mode 100644 index 0000000..e0ccfbd --- /dev/null +++ b/lenser/intv/exporter.py @@ -0,0 +1,20 @@ +"""Build a Mattel Intellivision cartridge from a conversion.""" +from __future__ import annotations + +import os + +from . import cartridge + +_EXTS = (".int", ".bin", ".rom") + + +def export_int(conv, output_path, source_path=None, display="forever", + seconds=0, video="ntsc"): + if not output_path.lower().endswith(_EXTS): + output_path += ".int" + title = os.path.splitext(os.path.basename(source_path or output_path))[0] + rom = cartridge.build_rom(conv.data, title, display=display, + seconds=seconds, video=video) + with open(output_path, "wb") as f: + f.write(rom) + return output_path diff --git a/lenser/intv/palette.py b/lenser/intv/palette.py new file mode 100644 index 0000000..1c86bff --- /dev/null +++ b/lenser/intv/palette.py @@ -0,0 +1,45 @@ +"""Mattel Intellivision STIC 16-colour palette (MAME `intv` values). + +Verified against MAME pixel reads (white=7 #FFFCFF, brown/olive=11 #546E00, +magenta=15 #B51A58). In Color-Stack mode the per-card FOREGROUND is limited to +the first 8 colours (3 bits); the BACKGROUND (colour-stack entry) may be any of +the 16. +""" + +from __future__ import annotations + +import numpy as np + +from ..palette import srgb_to_lab + +STIC = np.array([ + (0x00, 0x00, 0x00), # 0 black + (0x00, 0x2D, 0xFF), # 1 blue + (0xFF, 0x3D, 0x10), # 2 red + (0xC9, 0xCF, 0xAB), # 3 tan + (0x38, 0x6B, 0x3F), # 4 dark green + (0x00, 0xA7, 0x56), # 5 green + (0xFA, 0xEA, 0x50), # 6 yellow + (0xFF, 0xFC, 0xFF), # 7 white + (0xBD, 0xAC, 0xC8), # 8 grey + (0x24, 0xB8, 0xFF), # 9 cyan + (0xFF, 0xB4, 0x1F), # 10 orange + (0x54, 0x6E, 0x00), # 11 brown / olive + (0xFF, 0x4E, 0x57), # 12 pink + (0xA4, 0x96, 0xFF), # 13 light blue + (0x75, 0xCC, 0x80), # 14 yellow-green + (0xB5, 0x1A, 0x58), # 15 magenta +], dtype=np.float64) + +# Foreground colours usable per card in Color-Stack mode (3-bit field). +FG_USABLE = list(range(8)) +# Background (colour-stack) may be any of the 16. +BG_USABLE = list(range(16)) + + +def get_palette() -> np.ndarray: + return STIC + + +def palette_lab() -> np.ndarray: + return srgb_to_lab(STIC) diff --git a/lenser/mame.py b/lenser/mame.py new file mode 100644 index 0000000..0f77cab --- /dev/null +++ b/lenser/mame.py @@ -0,0 +1,188 @@ +"""Shared MAME launcher used by every platform. + +All targets (C64, Atari, Apple, TI-99/4A, and any future machine) run under MAME, +so the launch flags, autoboot handling, and a reliable hard-kill live here in one +place. + +Note on MAME's startup warning screen: machines whose ROMs MAME flags as imperfect +or bad dumps (e.g. the Atari OS) show a one-key "known problems with this system" +notice that `-skip_gameinfo` does NOT remove (it only skips the system-info +screen). That notice is dismissed with a single keypress by whoever is watching the +window; we cannot and do not try to defeat it programmatically. +""" + +from __future__ import annotations + +import os +import shutil +import signal +import subprocess +import tempfile + +# Common flags: windowed, cropped to the screen (no cabinet bezel), no info screen. +BASE_FLAGS = ["-window", "-nomaximize", "-artwork_crop", "-skip_gameinfo"] + + +def have_mame() -> bool: + return shutil.which("mame") is not None + + +def _default_screen() -> str | None: + """Which host monitor MAME's window should open on (MAME's -screen value). + + MAME (SDL) names monitors screen0, screen1, ... in host display order. We + prefer to throw the test window onto a *secondary* monitor so it doesn't + cover the user's primary display. Override with LENSER_MAME_SCREEN (e.g. + "screen0" to force the primary, or "" to let MAME choose); unset auto-picks + the second monitor when one is connected, else leaves it to MAME. + """ + env = os.environ.get("LENSER_MAME_SCREEN") + if env is not None: + return env or None + try: + out = subprocess.run(["xrandr", "--query"], capture_output=True, + text=True, timeout=5).stdout + connected = sum(1 for ln in out.splitlines() if " connected" in ln) + if connected >= 2: + return "screen1" + except (OSError, subprocess.SubprocessError): + pass + return None + + +def c64_autorun_lua(load_cmd: str = 'load "*",8,1') -> str: + """Lua autoboot script that types ``load_cmd``, waits for the (slow, stock + 1541) load to finish, then types RUN. + + A single -autoboot_command "load ...\\nrun\\n" doesn't work: the RUN is typed + up front and lost while the drive loads. Here we post the LOAD, watch the + cursor-blink flag $CC (0 at the READY prompt, non-zero while BASIC is busy) + go busy then idle, and only then post RUN. ($CC read via the C64 CPU ":u7".) + """ + return f'''local mem = manager.machine.devices[":u7"].spaces["program"] +local nk = manager.machine.natkeyboard +local phase, t = 0, 0 +emu.register_periodic(function() + local now = manager.machine.time.seconds + if phase == 0 then + if now > 2.0 then nk:post('{load_cmd}\\n'); phase, t = 1, now end + elseif phase == 1 then + if now > t + 0.5 and mem:read_u8(0xCC) ~= 0 then phase = 2 end + elseif phase == 2 then + if mem:read_u8(0xCC) == 0 then nk:post('run\\n'); phase = 3 end + end +end) +''' + + +def pet_autorun_lua(addr: int = 1056) -> str: + """Lua autoboot for the PET: SYS the viewer (at $0420 = 1056) once BASIC is + ready. Quickload puts the program in RAM but doesn't set BASIC's pointers, so + SYS (not RUN) reliably starts the machine-language viewer.""" + return f'''local nk = manager.machine.natkeyboard +local done = false +emu.register_periodic(function() + if (not done) and manager.machine.time.seconds > 3.0 then + nk:post("sys{addr}\\n"); done = true + end +end) +''' + + +def bbc_autorun_lua(command: str = "*RUN PIC") -> str: + """Lua autoboot for the BBC: type ``command`` (CR-terminated) once the OS has + booted. natkeyboard:post is reliable where -autoboot_command isn't (the '!' + in !BOOT needs SHIFT and a single typed command races the warning screen).""" + return f'''local done = false +emu.register_periodic(function() + if (not done) and manager.machine.time.seconds > 3.0 then + manager.machine.natkeyboard:post("{command}\\r"); done = true + end +end) +''' + + +def c16_autorun_lua(addr: int = 4128) -> str: + """Lua autoboot for the C16: SYS the viewer once BASIC is ready. MAME's c16 + quickload copies the PRG into RAM but doesn't set BASIC's program pointers, so + RUN finds nothing -- but the machine-language viewer is in RAM, so SYS to its + entry ($1020 = 4128) starts it.""" + return f'''local nk = manager.machine.natkeyboard +local done = false +emu.register_periodic(function() + if (not done) and manager.machine.time.seconds > 3.0 then + nk:post("sys{addr}\\n"); done = true + end +end) +''' + + +def coco3_rgb_lua() -> str: + """Lua that flips the CoCo 3's 'Monitor Type' config from Composite (the MAME + default, an artifact-colour palette) to RGB, so the GIME shows the clean + digital RGB palette the encoder targets. (The cartridge still autostarts.)""" + return '''local done = false +emu.register_periodic(function() + if not done then + local p = manager.machine.ioport.ports[":screen_config"] + if p and p.fields["Monitor Type"] then + p.fields["Monitor Type"]:set_value(1); done = true + end + end +end) +''' + + +def launch(machine, media, autoboot=None, autoboot_script_text=None, + autoboot_delay=3, throttle=True, screen="auto"): + """Start MAME on `machine`. + + media list of (slot, path) pairs, e.g. [("flop", disk)]. + autoboot keystrokes typed after `autoboot_delay` seconds. MAME wants + the literal escape "\\n" (backslash+n) for RETURN. + autoboot_script_text Lua driven startup (used for the C64 LOAD-then-RUN dance); + written to a temp file and passed as -autoboot_script. + throttle False adds -nothrottle so slow emulated disk loads finish + quickly (fine for a static image; leave True for real-time). + screen host monitor for MAME's window. "auto" (default) puts it on + a secondary monitor when present (see _default_screen / the + LENSER_MAME_SCREEN env); a literal "screenN" forces one; + None lets MAME decide. + """ + exe = shutil.which("mame") + if not exe: + raise RuntimeError("MAME was not found on PATH (sudo apt install mame).") + cmd = [exe, machine, *BASE_FLAGS] + if screen == "auto": + screen = _default_screen() + if screen: + cmd += ["-screen", screen] + for slot, path in media: + cmd += [f"-{slot}", os.path.abspath(path)] + if not throttle: + cmd.append("-nothrottle") + if autoboot_script_text: + fd, sp = tempfile.mkstemp(suffix=".lua", prefix="lenser_") + with os.fdopen(fd, "w") as f: + f.write(autoboot_script_text) + cmd += ["-autoboot_delay", str(autoboot_delay), "-autoboot_script", sp] + elif autoboot: + cmd += ["-autoboot_delay", str(autoboot_delay), "-autoboot_command", autoboot] + # start_new_session => MAME leads its own process group, so kill() can take the + # whole group (MAME spawns helper threads/children) with one signal. + return subprocess.Popen(cmd, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True) + + +def kill(proc): + """Force a running MAME to close. MAME can ignore a polite SIGTERM while it + owns the display, so go straight to SIGKILL on its process group.""" + if proc is None or proc.poll() is not None: + return + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError): + try: + proc.kill() + except ProcessLookupError: + pass diff --git a/lenser/nes/__init__.py b/lenser/nes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lenser/nes/cartridge.py b/lenser/nes/cartridge.py new file mode 100644 index 0000000..b3dc944 --- /dev/null +++ b/lenser/nes/cartridge.py @@ -0,0 +1,65 @@ +"""Build an iNES (.nes) NROM cartridge: 6502 PPU-init viewer + image data + CHR. + +Layout: 16-byte iNES header, 16K PRG-ROM (viewer at $C000, 32-byte palette at +$F000, 1024-byte nametable+attributes at $F020, vectors at $FFFA), then 8K +CHR-ROM (256 background tiles in pattern table 0). +""" +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile + +VIEWER_DIR = os.path.dirname(os.path.abspath(__file__)) + +PRG_BASE = 0xC000 +PRG_SIZE = 0x4000 # 16K +PAL_ADDR = 0xF000 # 32-byte palette +NT_ADDR = 0xF020 # 1024-byte nametable + attribute + + +def have_xa() -> bool: + return shutil.which("xa") is not None + + +def _assemble() -> bytes: + if not have_xa(): + raise RuntimeError("The 'xa' (xa65) assembler was not found (apt install xa65).") + with tempfile.TemporaryDirectory() as td: + out = os.path.join(td, "v.bin") + proc = subprocess.run(["xa", "-o", out, "viewer.s"], + capture_output=True, text=True, cwd=VIEWER_DIR) + if proc.returncode != 0: + raise RuntimeError(f"xa failed:\n{proc.stdout}{proc.stderr}") + with open(out, "rb") as f: + return f.read() + + +def build_rom(palette: bytes, nametable: bytes, chr_rom: bytes) -> bytes: + """palette: 32 bytes; nametable: 1024 (960 names + 64 attr); chr_rom: 8192.""" + if len(palette) != 32 or len(nametable) != 1024 or len(chr_rom) != 8192: + raise ValueError("bad NES data block sizes") + code = _assemble() # 6502 viewer, org $C000 + if len(code) > PAL_ADDR - PRG_BASE: + raise RuntimeError("viewer code overruns the $F000 data area") + prg = bytearray(PRG_SIZE) + prg[0:len(code)] = code # start == $C000 + prg[PAL_ADDR - PRG_BASE:PAL_ADDR - PRG_BASE + 32] = palette + prg[NT_ADDR - PRG_BASE:NT_ADDR - PRG_BASE + 1024] = nametable + # CPU vectors $FFFA/$FFFC/$FFFE all point at start ($C000) + for off in (0x3FFA, 0x3FFC, 0x3FFE): + prg[off] = PRG_BASE & 0xFF + prg[off + 1] = PRG_BASE >> 8 + + header = bytes([0x4E, 0x45, 0x53, 0x1A, # "NES\x1A" + 1, # 16K PRG units + 1, # 8K CHR units + 0x00, 0x00] + [0] * 8) # NROM (mapper 0) + return header + bytes(prg) + bytes(chr_rom) + + +def write_nes(rom: bytes, path: str) -> str: + with open(path, "wb") as f: + f.write(rom) + return path diff --git a/lenser/nes/convert/__init__.py b/lenser/nes/convert/__init__.py new file mode 100644 index 0000000..884dbc2 --- /dev/null +++ b/lenser/nes/convert/__init__.py @@ -0,0 +1,19 @@ +"""Nintendo Entertainment System conversion dispatch.""" +from __future__ import annotations + +from ... import imageprep +from . import bg, mono + +_MODULES = {"bg": bg, "mono": mono} +MODES = list(_MODULES.keys()) + + +def convert_image(path_or_img, mode="bg", palette_name="nes", + dither_mode="floyd", intensive=False, prep_opt=None, + base_color=None): + prep_opt = prep_opt or imageprep.PrepOptions() + module = _MODULES.get(mode, bg) + 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) diff --git a/lenser/nes/convert/_common.py b/lenser/nes/convert/_common.py new file mode 100644 index 0000000..98c62a7 --- /dev/null +++ b/lenser/nes/convert/_common.py @@ -0,0 +1,206 @@ +"""NES background encoder: 4 sub-palettes + per-16x16 attribute + 256-tile CHR. + +The NES PPU draws a 32x30 grid of 8x8 tiles (pattern table, <=256 unique), each +tile 2bpp. Colour comes from 4 background sub-palettes (each = a shared universal +background + 3 colours), one chosen per 16x16 region via the attribute table. So +the pipeline is: choose the universal bg, cluster the image into 4 sub-palettes, +assign each region its best one, dither, then vector-quantise the 8x8 tile +patterns down to 256 CHR tiles. +""" +from __future__ import annotations + +import numpy as np + +from ... import dither, palette as c64pal +from ...convert import base +from .. import palette as npal + +W, H = 256, 240 +RCOLS, RROWS = 16, 15 # 16x16-pixel regions (2x2 tiles) +TCOLS, TROWS = 32, 30 # 8x8 tiles +NTILES = 256 + + +def _best_colors(pix_lab, plab, bg0, n, k_cand=20): + """Best ``n`` NES colours (besides fixed bg0) for a pool of pixels.""" + mean_d = np.sum((pix_lab.mean(0)[None, :] - plab) ** 2, 1) + cand = [c for c in np.argsort(mean_d)[:k_cand] if c != bg0] + dist = np.sum((pix_lab[:, None, :] - plab[None, cand, :]) ** 2, 2) # (px, k) + d_bg = np.sum((pix_lab - plab[bg0]) ** 2, 1) # (px,) + from itertools import combinations + best, best_err = None, np.inf + for combo in combinations(range(len(cand)), n): + m = np.minimum(d_bg, dist[:, combo].min(1)).sum() + if m < best_err: + best_err, best = m, [cand[i] for i in combo] + return best + + +def _tile_codebook(patterns, k, iters=8): + """k-medoids over 8x8 pen patterns (values 0-3); distance = differing pixels.""" + uniq, counts = np.unique(patterns, axis=0, return_counts=True) + if len(uniq) <= k: + code = np.zeros((k, patterns.shape[1]), patterns.dtype) + code[:len(uniq)] = uniq + lut = {tuple(p): i for i, p in enumerate(uniq)} + return code, np.array([lut[tuple(p)] for p in patterns]) + code = uniq[np.argsort(-counts)[:k]].copy() + labels = np.zeros(len(patterns), np.int64) + for _ in range(iters): + for s in range(0, len(patterns), 2048): + blk = patterns[s:s + 2048] + labels[s:s + 2048] = (blk[:, None, :] != code[None]).sum(2).argmin(1) + moved = False + for j in range(k): + mem = patterns[labels == j] + if len(mem): + med = np.array([np.bincount(mem[:, p], minlength=4).argmax() + for p in range(mem.shape[1])], patterns.dtype) + if not np.array_equal(med, code[j]): + code[j] = med; moved = True + if not moved: + break + for s in range(0, len(patterns), 2048): + blk = patterns[s:s + 2048] + labels[s:s + 2048] = (blk[:, None, :] != code[None]).sum(2).argmin(1) + return code, labels + + +def encode(img_rgb, dither_mode, subpalettes): + """subpalettes: list of 4 lists [bg0,c1,c2,c3] of NES colour indices (the + universal bg = subpalettes[*][0], identical across all four).""" + plab = npal.palette_lab() + prgb = npal.get_palette().astype(np.uint8) + img_lab = c64pal.srgb_to_lab(img_rgb) + bg0 = subpalettes[0][0] + sp = np.array(subpalettes) # (4,4) NES indices + + # assign each 16x16 region to the sub-palette giving least nearest-colour error + region_pal = np.zeros((RROWS, RCOLS), np.int64) + for ry in range(RROWS): + for rx in range(RCOLS): + blk = img_lab[ry * 16:ry * 16 + 16, rx * 16:rx * 16 + 16].reshape(-1, 3) + errs = [] + for s in range(4): + d = np.sum((blk[:, None, :] - plab[sp[s]][None, :, :]) ** 2, 2) + errs.append(d.min(1).sum()) + region_pal[ry, rx] = int(np.argmin(errs)) + + # per-pixel allowed colours = the pixel's region sub-palette; dither + allowed = np.zeros((H, W, 4), np.int64) + for ry in range(RROWS): + for rx in range(RCOLS): + allowed[ry * 16:ry * 16 + 16, rx * 16:rx * 16 + 16] = sp[region_pal[ry, rx]] + idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64) + + # pen per pixel = position of its colour within its region's sub-palette + pen = np.zeros((H, W), np.uint8) + for ry in range(RROWS): + for rx in range(RCOLS): + ys, xs = slice(ry * 16, ry * 16 + 16), slice(rx * 16, rx * 16 + 16) + pal = sp[region_pal[ry, rx]] + block = idx[ys, xs] + pmap = np.zeros(block.shape, np.uint8) + for k, col in enumerate(pal): + pmap[block == col] = k + pen[ys, xs] = pmap + + # 8x8 tiles -> patterns; vector-quantise to <=256 CHR tiles + tiles = pen.reshape(TROWS, 8, TCOLS, 8).transpose(0, 2, 1, 3).reshape(TROWS * TCOLS, 64) + code, labels = _tile_codebook(tiles, NTILES) + nametable = labels.astype(np.uint8).reshape(TROWS, TCOLS) + + # ---- emit NES data ---- + chr_rom = bytearray(8192) + for t in range(NTILES): + pat = code[t].reshape(8, 8) + for r in range(8): + p0 = p1 = 0 + for x in range(8): + v = int(pat[r, x]) + p0 |= (v & 1) << (7 - x) + p1 |= ((v >> 1) & 1) << (7 - x) + chr_rom[t * 16 + r] = p0 + chr_rom[t * 16 + 8 + r] = p1 + + attr = bytearray(64) + for ar in range(8): + for ac in range(8): + b = 0 + for q, (dy, dx) in enumerate(((0, 0), (0, 1), (1, 0), (1, 1))): + ry, rx = ar * 2 + dy, ac * 2 + dx + s = int(region_pal[ry, rx]) if ry < RROWS and rx < RCOLS else 0 + b |= (s & 3) << (q * 2) + attr[ar * 8 + ac] = b + + pal32 = bytearray(32) + for s in range(4): + pal32[s * 4] = bg0 + pal32[s * 4 + 1] = int(sp[s][1]) + pal32[s * 4 + 2] = int(sp[s][2]) + pal32[s * 4 + 3] = int(sp[s][3]) + pal32[16:32] = pal32[0:16] # sprite palette = mirror + nametable_full = bytes(nametable.reshape(-1)) + bytes(attr) # 960 + 64 + + # rebuild the displayed image (clustered tiles + region palettes) for preview + disp = code[labels].reshape(TROWS, TCOLS, 8, 8).transpose(0, 2, 1, 3).reshape(H, W) + final_idx = np.zeros((H, W), np.uint16) + for ry in range(RROWS): + for rx in range(RCOLS): + ys, xs = slice(ry * 16, ry * 16 + 16), slice(rx * 16, rx * 16 + 16) + pal = sp[region_pal[ry, rx]] + final_idx[ys, xs] = pal[disp[ys, xs]] + + err = base.perceptual_error(final_idx, img_lab, plab) + return bytes(pal32), nametable_full, bytes(chr_rom), final_idx, err, plab, prgb + + +def pick_subpalettes(img_rgb, n_groups=4, mono=False, base_color=None): + """Choose the universal bg + ``n_groups`` sub-palettes (each bg + 3 colours).""" + plab = npal.palette_lab() + img_lab = c64pal.srgb_to_lab(img_rgb) + + if mono: + greys = sorted(npal.GREYS, key=lambda i: plab[i, 0]) + if base_color in range(64): + ramp = sorted({greys[0], int(base_color), greys[-1]}, key=lambda i: plab[i, 0]) + else: + # 4 greys spanning black->white (include the lightest so highlights + # actually reach white -- otherwise the image comes out muddy/dark) + lums = np.array([plab[i, 0] for i in greys]) + ramp = [greys[int(np.argmin(np.abs(lums - t)))] + for t in np.linspace(lums.min(), lums.max(), 4)] + bg0 = ramp[0] + others = [c for c in ramp if c != bg0][:3] + while len(others) < 3: + others.append(others[-1]) + return [[bg0] + others] * 4 + + bg0 = base.best_global_color(img_lab, plab) + # cluster regions by mean colour into n_groups, then pick 3 colours per group + feats = [] + for ry in range(RROWS): + for rx in range(RCOLS): + feats.append(img_lab[ry * 16:ry * 16 + 16, rx * 16:rx * 16 + 16] + .reshape(-1, 3).mean(0)) + feats = np.array(feats) + rng = np.random.default_rng(0) + cen = feats[rng.choice(len(feats), n_groups, replace=False)] + for _ in range(12): + lab = np.argmin(np.sum((feats[:, None, :] - cen[None]) ** 2, 2), 1) + for g in range(n_groups): + if (lab == g).any(): + cen[g] = feats[lab == g].mean(0) + subs = [] + for g in range(n_groups): + members = np.where(lab == g)[0] + if len(members) == 0: + subs.append([bg0, bg0, bg0, bg0]); continue + pool = np.concatenate([ + img_lab[(m // RCOLS) * 16:(m // RCOLS) * 16 + 16, + (m % RCOLS) * 16:(m % RCOLS) * 16 + 16].reshape(-1, 3) + for m in members]) + if len(pool) > 4000: + pool = pool[rng.choice(len(pool), 4000, replace=False)] + subs.append([bg0] + _best_colors(pool, plab, bg0, 3)) + return subs diff --git a/lenser/nes/convert/bg.py b/lenser/nes/convert/bg.py new file mode 100644 index 0000000..93c8a54 --- /dev/null +++ b/lenser/nes/convert/bg.py @@ -0,0 +1,21 @@ +"""NES background image: 256x240, 4 sub-palettes (universal bg + 3 each), tiles.""" +from __future__ import annotations + +from ...convert.base import Conversion +from . import _common + +WIDTH, HEIGHT = 256, 240 +PIXEL_ASPECT = 1.0 # NES pixels are ~square (8:7 really; close enough) + + +def convert(img_rgb, palette_name="nes", dither_mode="floyd", + intensive=False, base_color=None): + subs = _common.pick_subpalettes(img_rgb, mono=False) + pal32, nt, chr_rom, idx, err, plab, prgb = _common.encode( + img_rgb, dither_mode, subs) + return Conversion( + mode="bg", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=idx, data={"palette": pal32, "nametable": nt, "chr": chr_rom}, + data_addr=0, viewer="nes", preview_rgb=prgb[idx], error=err, + meta={"palette": "nes", "dither": dither_mode}, + ) diff --git a/lenser/nes/convert/mono.py b/lenser/nes/convert/mono.py new file mode 100644 index 0000000..7a49509 --- /dev/null +++ b/lenser/nes/convert/mono.py @@ -0,0 +1,22 @@ +"""NES monochrome: 256x240 greyscale using the PPU's grey ramp (tone by +dithering). ``--mono-base`` tints the ramp toward a hue.""" +from __future__ import annotations + +from ...convert.base import Conversion +from . import _common + +WIDTH, HEIGHT = 256, 240 +PIXEL_ASPECT = 1.0 + + +def convert(img_rgb, palette_name="nes", dither_mode="floyd", + intensive=False, base_color=None): + subs = _common.pick_subpalettes(img_rgb, mono=True, base_color=base_color) + pal32, nt, chr_rom, idx, err, plab, prgb = _common.encode( + img_rgb, dither_mode, subs) + return Conversion( + mode="mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=idx, data={"palette": pal32, "nametable": nt, "chr": chr_rom}, + data_addr=0, viewer="nes", preview_rgb=prgb[idx], error=err, + meta={"palette": "nes", "dither": dither_mode}, + ) diff --git a/lenser/nes/exporter.py b/lenser/nes/exporter.py new file mode 100644 index 0000000..89675fb --- /dev/null +++ b/lenser/nes/exporter.py @@ -0,0 +1,13 @@ +"""Build an NES (.nes) cartridge from a conversion.""" +from __future__ import annotations + +from . import cartridge + + +def export_nes(conv, output_path, source_path=None, display="forever", + seconds=0, video="ntsc"): + if not output_path.lower().endswith((".nes", ".unf")): + output_path += ".nes" + d = conv.data + rom = cartridge.build_rom(d["palette"], d["nametable"], d["chr"]) + return cartridge.write_nes(rom, output_path) diff --git a/lenser/nes/palette.py b/lenser/nes/palette.py new file mode 100644 index 0000000..854e336 --- /dev/null +++ b/lenser/nes/palette.py @@ -0,0 +1,58 @@ +"""Nintendo Entertainment System (2C02 PPU) master palette. + +The NES has no fixed RGB palette -- the PPU generates an NTSC signal. We +reproduce MAME's exact ``nespal_to_RGB`` (YUV->RGB) formula so the encoder +matches what the emulator renders. A palette value is a 6-bit index 0-63: +high nibble = luminance (0-3), low nibble = hue (0-15, with 0/13/14/15 greyscale). +The byte written to PPU palette RAM IS this index, so ``PALETTE``/``GREYS`` are +indexed by the hardware value. +""" +from __future__ import annotations + +import math + +import numpy as np + +from ..palette import srgb_to_lab + +_TINT, _HUE = 0.22, 287.0 +_Kr, _Kb, _Ku, _Kv = 0.2989, 0.1145, 2.029, 1.140 +_BRIGHT = [[0.50, 0.75, 1.0, 1.0], + [0.29, 0.45, 0.73, 0.9], + [0.0, 0.24, 0.47, 0.77]] + + +def _rgb(intensity: int, num: int): + if num == 0: + sat = rad = 0.0; y = _BRIGHT[0][intensity] + elif num == 13: + sat = rad = 0.0; y = _BRIGHT[2][intensity] + elif num in (14, 15): + sat = rad = y = 0.0 + else: + sat = _TINT; rad = math.radians(num * 30 + _HUE); y = _BRIGHT[1][intensity] + u, v = sat * math.cos(rad), sat * math.sin(rad) + R = (y + _Kv * v) * 255.0 + G = (y - (_Kb * _Ku * u + _Kr * _Kv * v) / (1 - _Kb - _Kr)) * 255.0 + B = (y + _Ku * u) * 255.0 + cl = lambda x: max(0, min(255, int(math.floor(x + 0.5)))) + return (cl(R), cl(G), cl(B)) + + +PALETTE = np.array([_rgb(i >> 4, i & 0x0F) for i in range(64)], dtype=np.float64) + +# Distinct grey ramp (R==G==B), sorted dark->light, deduped by luminance. +_grey = {} +for _i in range(64): + r, g, b = PALETTE[_i] + if r == g == b: + _grey.setdefault(int(r), _i) +GREYS = [_grey[k] for k in sorted(_grey)] # e.g. $0F,$1D,$2D,$10,$20 + + +def get_palette() -> np.ndarray: + return PALETTE + + +def palette_lab() -> np.ndarray: + return srgb_to_lab(PALETTE) diff --git a/lenser/nes/viewer.s b/lenser/nes/viewer.s new file mode 100644 index 0000000..7f1e76f --- /dev/null +++ b/lenser/nes/viewer.s @@ -0,0 +1,77 @@ +; NES background image viewer (6502 / 2C02 PPU). NROM, 16K PRG at $C000. +; Waits for PPU warm-up, loads the palette ($3F00, 32 bytes), nametable + +; attribute table ($2000, 1024 bytes), enables background, then idles. The +; image-specific data lives at fixed PRG addresses written by the builder- +; $F000 32-byte palette $F020 1024-byte nametable + attributes +; Tiles come from CHR-ROM pattern table 0. + + * = $C000 + +PPUCTRL = $2000 +PPUMASK = $2001 +PPUSTATUS = $2002 +PPUSCROLL = $2005 +PPUADDR = $2006 +PPUDATA = $2007 + +start: + sei + cld + ldx #$ff + txs + lda #$00 + sta PPUCTRL ; NMI off + sta PPUMASK ; rendering off during setup + + bit PPUSTATUS ; clear latch +w1: bit PPUSTATUS + bpl w1 ; wait for first vblank +w2: bit PPUSTATUS + bpl w2 ; wait for second vblank (PPU warmed up) + + ; ---- palette- $3F00..$3F1F from $F000 ---- + lda #$3f + sta PPUADDR + lda #$00 + sta PPUADDR + ldx #$00 +pal: lda $f000,x + sta PPUDATA + inx + cpx #$20 + bne pal + + ; ---- nametable + attributes- $2000..$23FF (1024) from $F020 ---- + lda #$20 + sta PPUADDR + lda #$00 + sta PPUADDR + ldx #$00 +nt0: lda $f020,x + sta PPUDATA + inx + bne nt0 +nt1: lda $f120,x + sta PPUDATA + inx + bne nt1 +nt2: lda $f220,x + sta PPUDATA + inx + bne nt2 +nt3: lda $f320,x + sta PPUDATA + inx + bne nt3 + + ; ---- enable background ---- + lda #$00 + sta PPUSCROLL + sta PPUSCROLL + lda #$00 + sta PPUCTRL ; nametable $2000, bg pattern table $0000, NMI off + lda #$0a + sta PPUMASK ; show background, no left-column clip +loop: + jmp loop + ; the builder writes the $FFFA-$FFFF vectors (all -> start = $C000) diff --git a/c64view/palette.py b/lenser/palette.py similarity index 100% rename from c64view/palette.py rename to lenser/palette.py diff --git a/lenser/pet/__init__.py b/lenser/pet/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lenser/pet/convert.py b/lenser/pet/convert.py new file mode 100644 index 0000000..8f0c7ef --- /dev/null +++ b/lenser/pet/convert.py @@ -0,0 +1,61 @@ +"""PET image encoder: monochrome quadrant-block pseudo-bitmap. + +40-column models -> 80x50 pixels (40x25 chars); 80-column -> 160x50. The image +is dithered to one bit, then each 2x2 pixel block becomes the PETSCII quadrant +character with that pattern. Output is the screen-RAM byte array ($8000): one +screen code per character cell, row-major. +""" +from __future__ import annotations + +import numpy as np + +from .. import dither, palette as c64pal, imageprep +from ..convert.base import Conversion, perceptual_error +from . import palette as petpal + +ROWS = 25 # character rows (both 40- and 80-col PETs) + + +def _dims(cols): + return cols * 2, ROWS * 2, cols # pixel W, pixel H, char cols + + +def convert(img_rgb, cols=40, dither_mode="floyd", base_color=None): + W, H, _ = _dims(cols) + plab = petpal.palette_lab() + prgb = petpal.get_palette().astype(np.uint8) + + # one-bit luminance dither (1 = lit phosphor) + L = c64pal.srgb_to_lab(img_rgb)[..., 0] + mono = np.zeros((H, W, 3)); mono[..., 0] = L + pmono = np.zeros_like(plab); pmono[:, 0] = plab[:, 0] + allowed = np.tile(np.array([0, 1]), (H, W, 1)) + idx = dither.quantize(mono, allowed, pmono, dither_mode).astype(np.uint8) + + # each 2x2 block -> quadrant screen code + screen = bytearray(cols * ROWS) + for r in range(ROWS): + for c in range(cols): + tl = idx[r * 2, c * 2]; tr = idx[r * 2, c * 2 + 1] + bl = idx[r * 2 + 1, c * 2]; br = idx[r * 2 + 1, c * 2 + 1] + key = (tl << 3) | (tr << 2) | (bl << 1) | br + screen[r * cols + c] = petpal.QUAD[key] + + return Conversion( + mode="mono", width=W, height=H, + pixel_aspect=0.83 if cols == 40 else 0.42, + index_image=idx.astype(np.uint16), data=bytes(screen), data_addr=0x8000, + viewer="pet", preview_rgb=prgb[idx], + error=perceptual_error(idx, mono, pmono), + meta={"palette": "pet", "dither": dither_mode, "cols": cols}, + ) + + +def convert_image(path_or_img, cols=40, dither_mode="floyd", intensive=False, + prep_opt=None, base_color=None): + prep_opt = prep_opt or imageprep.PrepOptions() + W, H, _ = _dims(cols) + aspect = 0.83 if cols == 40 else 0.42 + img_rgb = imageprep.prepare(path_or_img, W, H, aspect, prep_opt, + border_rgb=(0, 0, 0)) + return convert(img_rgb, cols, dither_mode, base_color=base_color) diff --git a/lenser/pet/exporter.py b/lenser/pet/exporter.py new file mode 100644 index 0000000..2ac13a9 --- /dev/null +++ b/lenser/pet/exporter.py @@ -0,0 +1,16 @@ +"""Build a Commodore PET .prg (loaded + run via MAME quickload / on real HW).""" +from __future__ import annotations + +from .viewer import assemble + +_EXTS = (".prg", ".p00") + + +def export_prg(conv, output_path, source_path=None, display="forever", + seconds=0, video="ntsc"): + if not output_path.lower().endswith(_EXTS): + output_path += ".prg" + prg = assemble.build_prg(bytes(conv.data)) + with open(output_path, "wb") as f: + f.write(prg) + return output_path diff --git a/lenser/pet/palette.py b/lenser/pet/palette.py new file mode 100644 index 0000000..0e9058c --- /dev/null +++ b/lenser/pet/palette.py @@ -0,0 +1,30 @@ +"""Commodore PET / CBM monochrome display. + +The PET has no bitmap or colour -- a 40- or 80-column text screen of a fixed +character ROM on a monochrome (green P1 phosphor) monitor. Images are rendered +with the PETSCII 2x2 quadrant-block graphics characters, giving an effective +80x50 (40-col) or 160x50 (80-col) one-bit pixel grid. +""" +from __future__ import annotations + +import numpy as np + +from ..palette import srgb_to_lab + +# 0 = background (dark), 1 = foreground (lit phosphor). Green to match the +# classic PET monitor; the signal is one bit, so only luminance matters. +PALETTE = np.array([(0, 0, 0), (0x40, 0xE0, 0x40)], dtype=np.float64) + + +def get_palette() -> np.ndarray: + return PALETTE + + +def palette_lab() -> np.ndarray: + return srgb_to_lab(PALETTE) + + +# screen (poke) code for each 2x2 quadrant pattern, bits TL<<3|TR<<2|BL<<1|BR. +# Derived from the PET character ROM: 8 blocks exist directly, the other 8 are +# their reverse-video forms (screen code | $80). +QUAD = [32, 108, 123, 98, 124, 225, 255, 254, 126, 127, 97, 252, 226, 251, 236, 160] diff --git a/lenser/pet/viewer/__init__.py b/lenser/pet/viewer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lenser/pet/viewer/assemble.py b/lenser/pet/viewer/assemble.py new file mode 100644 index 0000000..2d06aa0 --- /dev/null +++ b/lenser/pet/viewer/assemble.py @@ -0,0 +1,68 @@ +"""Assemble the PET viewer with `xa` and build the loadable .prg. + +The PRG loads at the PET BASIC start ($0401): a BASIC stub ``10 SYS1056`` then +the 6502 viewer (at $0420 = 1056) then the screen data (at $0500). Running it +(or SYS 1056) copies the screen codes to $8000. +""" +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile + +VIEWER_DIR = os.path.dirname(os.path.abspath(__file__)) + +BASIC_START = 0x0401 +ML_ORG = 0x0420 # 1056 +DATA_ORG = 0x0500 + +# BASIC: 10 SYS1056 (bytes from $0401) +_STUB = bytes([0x0B, 0x04, 0x0A, 0x00, 0x9E, + 0x31, 0x30, 0x35, 0x36, 0x00, 0x00, 0x00]) + + +class AssemblerError(RuntimeError): + pass + + +def have_xa() -> bool: + return shutil.which("xa") is not None + + +def _assemble(pages: int) -> bytes: + if not have_xa(): + raise AssemblerError("The 'xa' (xa65) assembler was not found (apt install xa65).") + wrapper = (f"#define DATA ${DATA_ORG:04X}\n" + f"#define PAGES {pages}\n" + '#include "viewer.s"\n') + with tempfile.TemporaryDirectory() as td: + out = os.path.join(td, "v.bin") + fd, wrap = tempfile.mkstemp(suffix=".s", prefix="_wrap_", dir=VIEWER_DIR) + try: + with os.fdopen(fd, "w") as f: + f.write(wrapper) + proc = subprocess.run(["xa", "-o", out, os.path.basename(wrap)], + capture_output=True, text=True, cwd=VIEWER_DIR) + if proc.returncode != 0: + raise AssemblerError(f"xa failed:\n{proc.stdout}{proc.stderr}") + with open(out, "rb") as f: + return f.read() + finally: + os.unlink(wrap) + + +def build_prg(screen: bytes) -> bytes: + """screen = screen-RAM bytes (1000 for 40-col, 2000 for 80-col).""" + pages = (len(screen) + 255) // 256 + data = bytes(screen) + bytes(pages * 256 - len(screen)) # pad to whole pages + code = _assemble(pages) + mem = bytearray() + mem += _STUB + mem += b"\x00" * (ML_ORG - BASIC_START - len(mem)) + mem += code + if len(mem) > DATA_ORG - BASIC_START: + raise AssemblerError("viewer code overruns the data area") + mem += b"\x00" * (DATA_ORG - BASIC_START - len(mem)) + mem += data + return bytes([BASIC_START & 0xFF, BASIC_START >> 8]) + bytes(mem) diff --git a/lenser/pet/viewer/viewer.s b/lenser/pet/viewer/viewer.s new file mode 100644 index 0000000..ebcedaa --- /dev/null +++ b/lenser/pet/viewer/viewer.s @@ -0,0 +1,34 @@ +; Commodore PET screen viewer (6502). Copies PAGES*256 bytes of precomputed +; screen codes from DATA into the PET screen RAM at $8000, then loops. The PET +; video hardware continuously displays $8000 as text, so the picture stays up. +; +; #defines from viewer/assemble.py -- DATA = source address, PAGES = 256-byte +; pages to copy (4 for 40-col / 1000 bytes, 8 for 80-col / 2000 bytes). + + * = $0420 + +src = $fb +dst = $fd + +start: + lda #DATA + sta src+1 + lda #$00 + sta dst + lda #$80 + sta dst+1 ; dest = $8000 (screen RAM) + ldx #PAGES + ldy #$00 +cp: + lda (src),y + sta (dst),y + iny + bne cp + inc src+1 + inc dst+1 + dex + bne cp +hang: + jmp hang diff --git a/lenser/platforms.py b/lenser/platforms.py new file mode 100644 index 0000000..2e89f88 --- /dev/null +++ b/lenser/platforms.py @@ -0,0 +1,563 @@ +"""Platform abstraction so the GUI (and anything else) can treat C64 and Atari +uniformly: which modes/palettes/formats exist, and how to convert/export/run. +""" + +from __future__ import annotations + +import os +import tempfile + +from . import crt, imageprep, mame +from .apple.convert import MODES as APPLE_MODES, convert_image as _apple_convert +from .apple.exporter import export_dsk +from .atari.convert import MODES as ATARI_MODES, convert_image as _atari_convert +from .atari.exporter import export_atr, export_car +from .atari.palette import nearest_hue +from .bbc.convert import MODES as BBC_MODES, convert_image as _bbc_convert +from .bbc.exporter import export_ssd +from .coco.convert import MODES as COCO_MODES, convert_image as _coco_convert +from .coco.exporter import export_ccc +from .coleco.convert import MODES as COLECO_MODES, convert_image as _coleco_convert +from .coleco.exporter import export_col +from .a2600.convert import MODES as A2600_MODES, convert_image as _a2600_convert +from .a2600.exporter import export_a26 +from .convert import MODES as C64_MODES, convert_image as _c64_convert +from .diskimage import FORMATS +from .exporter import export_cart, export_disk +from .palette import COLOR_NAMES, get_palette +from .ti99.convert import MODES as TI99_MODES, convert_image as _ti99_convert +from .ti99.exporter import export_rpk +from .intv.convert import MODES as INTV_MODES, convert_image as _intv_convert +from .intv.exporter import export_int +from .vic20.convert import MODES as VIC20_MODES, convert_image as _vic20_convert +from .vic20.exporter import export_a0 +from .spectrum.convert import MODES as SPECTRUM_MODES, convert_image as _spectrum_convert +from .spectrum.exporter import export_sna +from .a5200.convert import MODES as A5200_MODES, convert_image as _a5200_convert +from .a5200.exporter import export_a52 +from .a7800.convert import MODES as A7800_MODES, convert_image as _a7800_convert +from .a7800.exporter import export_a78 +from .c128.convert import MODES as C128_MODES, convert_image as _c128_convert +from .c128.exporter import export_d64 as _c128_export_d64 +from .c16.convert import MODES as C16_MODES, convert_image as _c16_convert +from .c16.exporter import export_prg as _c16_export_prg +from .plus4.convert import MODES as PLUS4_MODES, convert_image as _plus4_convert +from .plus4.exporter import export_prg as _plus4_export_prg +from .cpc.convert import MODES as CPC_MODES, convert_image as _cpc_convert +from .cpc.exporter import export_sna as _cpc_export_sna +from .coco3.convert import MODES as COCO3_MODES, convert_image as _coco3_convert +from .coco3.exporter import export_ccc as _coco3_export_ccc +from .nes.convert import MODES as NES_MODES, convert_image as _nes_convert +from .nes.exporter import export_nes as _nes_export_nes +from .iigs.convert import MODES as IIGS_MODES, convert_image as _iigs_convert +from .iigs.exporter import export_dsk as _iigs_export_dsk +from .pet.convert import convert_image as _pet_convert +from .pet.exporter import export_prg as _pet_export_prg +from .sms.convert import MODES as SMS_MODES, convert_image as _sms_convert +from .sms.exporter import export_sms as _sms_export_sms +from .amiga.convert import MODES as AMIGA_MODES, convert_image as _amiga_convert +from .amiga.exporter import export_adf as _amiga_export_adf +from .ansi.convert import MODES as ANSI_MODES, convert_image as _ansi_convert +from .ansi.exporter import export_ans as _ansi_export_ans + +# PET/CBM families -> (MAME machine, screen columns). 40-col models share the +# 80x50 quadrant-block encoder; 80-col models share 160x50. +PET_CFG = {"pet2001": ("pet2001n", 40), "pet4032": ("pet4032", 40), + "pet8032": ("pet8032", 80), "superpet": ("cbm8032", 80)} +# (MAME's `superpet` driver is incomplete; the SuperPET's display is identical to +# the CBM 8032 it is built on, so the working cbm8032 machine is used.) + +PLATFORMS = ["c64", "atari", "apple", "ti99", "coco", "bbc", "coleco", "a2600", + "intv", "vic20", "spectrum", "a5200", "a7800", "c128", "c16", + "plus4", "cpc", "coco3", "nes", "iigs", + "pet2001", "pet4032", "pet8032", "superpet", "sms", "amiga", "ansi"] +# Apple modes needing a //e (auxiliary memory) rather than a II+. +_APPLE_IIE_MODES = {"dhgr"} + + +def modes(platform: str) -> list[str]: + if platform == "c64": + return ["auto", *C64_MODES] + if platform == "atari": + return list(ATARI_MODES) + if platform == "ti99": + return list(TI99_MODES) + if platform == "coco": + return list(COCO_MODES) + if platform == "bbc": + return list(BBC_MODES) + if platform == "coleco": + return list(COLECO_MODES) + if platform == "a2600": + return list(A2600_MODES) + if platform == "intv": + return list(INTV_MODES) + if platform == "vic20": + return list(VIC20_MODES) + if platform == "spectrum": + return list(SPECTRUM_MODES) + if platform == "a5200": + return list(A5200_MODES) + if platform == "a7800": + return list(A7800_MODES) + if platform == "c128": + return list(C128_MODES) + if platform == "c16": + return list(C16_MODES) + if platform == "plus4": + return list(PLUS4_MODES) + if platform == "cpc": + return list(CPC_MODES) + if platform == "coco3": + return list(COCO3_MODES) + if platform == "nes": + return list(NES_MODES) + if platform == "iigs": + return list(IIGS_MODES) + if platform in PET_CFG: + return ["mono"] + if platform == "sms": + return list(SMS_MODES) + if platform == "amiga": + return list(AMIGA_MODES) + if platform == "ansi": + return list(ANSI_MODES) + return list(APPLE_MODES) + + +def palettes(platform: str) -> list[str]: + if platform == "c64": + return ["colodore", "pepto"] + if platform == "atari": + return ["ntsc"] + if platform == "ti99": + return ["tms9918"] + if platform == "coco": + return ["mc6847"] + if platform == "bbc": + return ["bbc"] + if platform == "coleco": + return ["tms9918"] + if platform == "a2600": + return ["tia"] + if platform == "intv": + return ["stic"] + if platform == "vic20": + return ["vic"] + if platform == "spectrum": + return ["spectrum"] + if platform == "a5200": + return ["ntsc"] + if platform == "a7800": + return ["ntsc"] + if platform == "c128": + return ["vdc"] + if platform in ("c16", "plus4"): + return ["ted"] + if platform == "cpc": + return ["cpc"] + if platform == "coco3": + return ["gime"] + if platform == "nes": + return ["nes"] + if platform == "iigs": + return ["iigs"] + if platform in PET_CFG: + return ["pet"] + if platform == "sms": + return ["sms"] + if platform == "amiga": + return ["amiga"] + if platform == "ansi": + return ["vga"] + return ["mono"] + + +def disk_formats(platform: str) -> list[str]: + if platform == "c64": + return [*FORMATS.keys(), "crt"] # disks + 16K cartridge + if platform == "atari": + return ["atr", "car"] # disk + 16K cartridge + if platform == "ti99": + return ["rpk"] + if platform == "coco": + return ["ccc"] # Program Pak cartridge + if platform == "bbc": + return ["ssd"] # DFS disk + if platform == "coleco": + return ["col"] # ColecoVision cartridge (runs on Adam too) + if platform == "a2600": + return ["a26"] # Atari 2600 cartridge + if platform == "intv": + return ["int"] # Intellivision cartridge (.int/.bin/.rom) + if platform == "vic20": + return ["a0"] # VIC-20 autostart cartridge ($A000) + if platform == "spectrum": + return ["sna"] # 48K snapshot (also drops a .scr) + if platform == "a5200": + return ["a52"] # Atari 5200 cartridge + if platform == "a7800": + return ["a78"] # Atari 7800 cartridge + if platform == "c128": + return ["d64"] # C128 autobooting disk (VDC 80-col) + if platform in ("c16", "plus4"): + return ["prg"] # TED program (quickload / LOAD+RUN) + if platform == "cpc": + return ["sna"] # Amstrad CPC snapshot + if platform == "coco3": + return ["ccc"] # CoCo 3 cartridge + if platform == "nes": + return ["nes"] # iNES cartridge + if platform == "iigs": + return ["dsk"] # bootable 5.25" SHR disk + if platform in PET_CFG: + return ["prg"] # PET program (quickload / LOAD+RUN) + if platform == "sms": + return ["sms"] # Master System cartridge + if platform == "amiga": + return ["adf"] # Amiga bootable floppy + if platform == "ansi": + return ["ans"] # ANSI/CP437 BBS art (plain text file) + return ["dsk"] + + +def _base_color(platform: str, base_name: str): + if base_name == "grayscale": + return None + idx = COLOR_NAMES.index(base_name) + if platform == "atari": + return nearest_hue(get_palette("colodore")[idx]) + return idx + + +def convert(platform, path, mode, palette, dither, intensive, prep_opt, base_name): + base = _base_color(platform, base_name) + if platform == "ti99": + if mode not in TI99_MODES: + mode = "gm2" + return _ti99_convert(path, mode=mode, palette_name="tms9918", + dither_mode=dither, intensive=intensive, + prep_opt=prep_opt, base_color=base) + if platform == "apple": + if mode not in APPLE_MODES: + mode = "hgr_mono" + return _apple_convert(path, mode=mode, dither_mode=dither, + intensive=intensive, prep_opt=prep_opt, base_color=base) + if platform == "atari": + if mode not in ATARI_MODES: + mode = "gr15" + return _atari_convert(path, mode=mode, palette_name="ntsc", + dither_mode=dither, intensive=intensive, + prep_opt=prep_opt, base_color=base) + if platform == "coco": + if mode not in COCO_MODES: + mode = "pmode4" + return _coco_convert(path, mode=mode, palette_name="mc6847", + dither_mode=dither, intensive=intensive, + prep_opt=prep_opt, base_color=base) + if platform == "bbc": + if mode not in BBC_MODES: + mode = "mode2" + return _bbc_convert(path, mode=mode, palette_name="bbc", + dither_mode=dither, intensive=intensive, + prep_opt=prep_opt, base_color=base) + if platform == "coleco": + if mode not in COLECO_MODES: + mode = "gm2" + return _coleco_convert(path, mode=mode, palette_name="tms9918", + dither_mode=dither, intensive=intensive, + prep_opt=prep_opt, base_color=base) + if platform == "a2600": + if mode not in A2600_MODES: + mode = "pf" + return _a2600_convert(path, mode=mode, palette_name="tia", + dither_mode=dither, intensive=intensive, + prep_opt=prep_opt, base_color=base) + if platform == "intv": + if mode not in INTV_MODES: + mode = "stic" + return _intv_convert(path, mode=mode, palette_name="stic", + dither_mode=dither, intensive=intensive, + prep_opt=prep_opt, base_color=base) + if platform == "vic20": + if mode not in VIC20_MODES: + mode = "multicolor" + return _vic20_convert(path, mode=mode, palette_name="vic", + dither_mode=dither, intensive=intensive, + prep_opt=prep_opt, base_color=base) + if platform == "spectrum": + if mode not in SPECTRUM_MODES: + mode = "hires" + return _spectrum_convert(path, mode=mode, palette_name="spectrum", + dither_mode=dither, intensive=intensive, + prep_opt=prep_opt, base_color=base) + if platform == "a5200": + if mode not in A5200_MODES: + mode = "gr15" + return _a5200_convert(path, mode=mode, palette_name="ntsc", + dither_mode=dither, intensive=intensive, + prep_opt=prep_opt, base_color=base) + if platform == "a7800": + if mode not in A7800_MODES: + mode = "c160" + return _a7800_convert(path, mode=mode, palette_name="ntsc", + dither_mode=dither, intensive=intensive, + prep_opt=prep_opt, base_color=base) + if platform == "c128": + c128_mode = mode if mode in C128_MODES else "mono" + return _c128_convert(path, mode=c128_mode, palette_name="vdc", + dither_mode=dither, intensive=intensive, + prep_opt=prep_opt, base_color=base) + if platform == "c16": + c16_mode = mode if mode in C16_MODES else "hires" + return _c16_convert(path, mode=c16_mode, palette_name="ted", + dither_mode=dither, intensive=intensive, + prep_opt=prep_opt, base_color=base) + if platform == "plus4": + p4_mode = mode if mode in PLUS4_MODES else "hires" + return _plus4_convert(path, mode=p4_mode, palette_name="ted", + dither_mode=dither, intensive=intensive, + prep_opt=prep_opt, base_color=base) + if platform == "cpc": + cpc_mode = mode if mode in CPC_MODES else "mode0" + return _cpc_convert(path, mode=cpc_mode, palette_name="cpc", + dither_mode=dither, intensive=intensive, + prep_opt=prep_opt, base_color=base) + if platform == "coco3": + c3_mode = mode if mode in COCO3_MODES else "gr16" + return _coco3_convert(path, mode=c3_mode, palette_name="gime", + dither_mode=dither, intensive=intensive, + prep_opt=prep_opt, base_color=base) + if platform == "nes": + nes_mode = mode if mode in NES_MODES else "bg" + return _nes_convert(path, mode=nes_mode, palette_name="nes", + dither_mode=dither, intensive=intensive, + prep_opt=prep_opt, base_color=base) + if platform == "iigs": + gs_mode = mode if mode in IIGS_MODES else "shr" + return _iigs_convert(path, mode=gs_mode, palette_name="iigs", + dither_mode=dither, intensive=intensive, + prep_opt=prep_opt, base_color=base) + if platform in PET_CFG: + return _pet_convert(path, cols=PET_CFG[platform][1], dither_mode=dither, + intensive=intensive, prep_opt=prep_opt, base_color=base) + if platform == "sms": + sms_mode = mode if mode in SMS_MODES else "bg" + return _sms_convert(path, mode=sms_mode, palette_name="sms", + dither_mode=dither, intensive=intensive, + prep_opt=prep_opt, base_color=base) + if platform == "amiga": + am_mode = mode if mode in AMIGA_MODES else "lowres" + return _amiga_convert(path, mode=am_mode, palette_name="amiga", + dither_mode=dither, intensive=intensive, + prep_opt=prep_opt, base_color=base) + if platform == "ansi": + ansi_mode = mode if mode in ANSI_MODES else "80x25" + return _ansi_convert(path, mode=ansi_mode, palette_name="vga", + dither_mode=dither, intensive=intensive, + prep_opt=prep_opt, base_color=base) + return _c64_convert(path, mode=mode, palette_name=palette, dither_mode=dither, + intensive=intensive, prep_opt=prep_opt, base_color=base) + + +def export(platform, conv, path, fmt, source_path, video, + display="key", seconds=0, layout="unified"): + if platform == "ti99": + return export_rpk(conv, path, source_path=source_path, + display=display, seconds=seconds, video=video) + if platform == "coco": + return export_ccc(conv, path, source_path=source_path, + display=display, seconds=seconds) + if platform == "bbc": + return export_ssd(conv, path, source_path=source_path, + display=display, seconds=seconds, video=video) + if platform == "coleco": + return export_col(conv, path, source_path=source_path, + display=display, seconds=seconds) + if platform == "a2600": + return export_a26(conv, path, source_path=source_path, + display=display, seconds=seconds) + if platform == "intv": + return export_int(conv, path, source_path=source_path, + display=display, seconds=seconds, video=video) + if platform == "vic20": + return export_a0(conv, path, source_path=source_path, + display=display, seconds=seconds, video=video) + if platform == "spectrum": + return export_sna(conv, path, source_path=source_path, + display=display, seconds=seconds, video=video) + if platform == "a5200": + return export_a52(conv, path, source_path=source_path, + display=display, seconds=seconds, video=video) + if platform == "a7800": + return export_a78(conv, path, source_path=source_path, + display=display, seconds=seconds, video=video) + if platform == "c128": + return _c128_export_d64(conv, path, source_path=source_path, + display=display, seconds=seconds, video=video) + if platform == "c16": + return _c16_export_prg(conv, path, source_path=source_path, + display=display, seconds=seconds, video=video) + if platform == "plus4": + return _plus4_export_prg(conv, path, source_path=source_path, + display=display, seconds=seconds, video=video) + if platform == "cpc": + return _cpc_export_sna(conv, path, source_path=source_path, + display=display, seconds=seconds, video=video) + if platform == "coco3": + return _coco3_export_ccc(conv, path, source_path=source_path, + display=display, seconds=seconds, video=video) + if platform == "nes": + return _nes_export_nes(conv, path, source_path=source_path, + display=display, seconds=seconds, video=video) + if platform == "iigs": + return _iigs_export_dsk(conv, path, source_path=source_path, + display=display, seconds=seconds, video=video) + if platform in PET_CFG: + return _pet_export_prg(conv, path, source_path=source_path, + display=display, seconds=seconds, video=video) + if platform == "sms": + return _sms_export_sms(conv, path, source_path=source_path, + display=display, seconds=seconds, video=video) + if platform == "amiga": + return _amiga_export_adf(conv, path, source_path=source_path, + display=display, seconds=seconds, video=video) + if platform == "ansi": + return _ansi_export_ans(conv, path, source_path=source_path, + display=display, seconds=seconds, video=video) + if platform == "apple": + return export_dsk(conv, path, source_path=source_path, + display=display, seconds=seconds) + if platform == "atari": + if fmt == "car" or str(path).lower().endswith(".car"): + return export_car(conv, path, source_path=source_path, + display=display, seconds=seconds, video=video) + return export_atr(conv, path, source_path=source_path, + display=display, seconds=seconds, video=video) + if fmt == "crt" or str(path).lower().endswith(".crt"): + return export_cart(conv, path, source_path=source_path, video=video, + display=display, seconds=seconds) + return export_disk(conv, path, disk_format=fmt, source_path=source_path, + video=video, display=display, seconds=seconds, layout=layout) + + +def has_emulator(platform: str) -> bool: + if platform == "ansi": + return False # a text file has nothing to "run" + return mame.have_mame() + + +def run_emulator(platform, path, mode, standard): + """Launch the generated image under MAME (every platform uses MAME).""" + pal = standard != "ntsc" + if platform == "ti99": + # cartridge auto-listed on the TI menu as item 2. + return mame.launch("ti99_4a", [("cart", path)], autoboot="2", + autoboot_delay=5) + if platform == "apple": + machine = "apple2ee" if mode in _APPLE_IIE_MODES else "apple2p" + return mame.launch(machine, [("flop1", path)], throttle=False) + if platform == "coco": + # Color Computer 2; the Program Pak autostarts at $C000. + return mame.launch("coco2b", [("cart", path)]) + if platform == "bbc": + # BBC Model B; a Lua script types *RUN PIC once DFS has booted. + return mame.launch("bbcb", [("flop1", path)], + autoboot_script_text=mame.bbc_autorun_lua()) + if platform == "coleco": + # ColecoVision; the cartridge autostarts (the same .col runs on the Adam). + return mame.launch("coleco", [("cart", path)]) + if platform == "a2600": + # interlace flickers two banks at 60Hz -> needs real-time, not warp. + return mame.launch("a2600", [("cart", path)], throttle=(mode == "pf_il")) + if platform == "intv": + return mame.launch("intv", [("cart", path)]) + if platform == "vic20": + # The autostart cartridge programs the VIC itself. MAME raises an + # "imperfect emulation" warning the user dismisses with a key. + return mame.launch("vic20p" if pal else "vic20", [("cart", path)]) + if platform == "spectrum": + # The .sna snapshot bakes the picture into screen RAM + an idle loop, so + # it appears instantly; the ULA holds it with no further CPU work. + return mame.launch("spectrum", [("snapshot", path)]) + if platform == "a5200": + # The cartridge programs ANTIC/GTIA directly; the BIOS jumps straight in. + return mame.launch("a5200", [("cart", path)]) + if platform == "a7800": + # The cartridge programs MARIA directly; the BIOS validates + jumps in. + return mame.launch("a7800", [("cart", path)]) + if platform == "c128": + # Disk holds a viewer that drives the 80-column VDC; RUN"PIC" loads + runs + # it. The image appears on the VDC (80-col) screen. -nothrottle for the load. + return mame.launch("c128", [("flop", path)], autoboot='run"pic"\\n', + autoboot_delay=5, throttle=False) + if platform == "c16": + # quickload loads the PRG into RAM; a Lua script SYSes the viewer (MAME's + # c16 quickload doesn't set BASIC pointers, so RUN won't, but SYS does). + return mame.launch("c16", [("quik", path)], + autoboot_script_text=mame.c16_autorun_lua(), + autoboot_delay=4, throttle=False) + if platform == "plus4": + # Same TED + BASIC 3.5 as the C16, so the identical .prg + SYS autorun runs + # on the Plus/4 (just a different MAME machine, with 64K RAM). + return mame.launch("plus4", [("quik", path)], + autoboot_script_text=mame.c16_autorun_lua(), + autoboot_delay=4, throttle=False) + if platform == "cpc": + # The .sna snapshot bakes the screen, palette, mode and an idle CPU, so + # the picture appears instantly with no loader (like the ZX Spectrum). + return mame.launch("cpc6128", [("snapshot", path)]) + if platform == "coco3": + # The cartridge autostarts a 6809 viewer that programs the GIME; a Lua + # script forces the RGB monitor (MAME defaults to the composite palette). + return mame.launch("coco3", [("cart", path)], + autoboot_script_text=mame.coco3_rgb_lua()) + if platform == "nes": + # The cartridge resets into a 6502 viewer that programs the PPU; MAME + # shows an "imperfect graphics" warning the user dismisses with a key. + return mame.launch("nes", [("cart", path)]) + if platform == "iigs": + # Boots a 5.25" disk (slot 6) into a loader that fills the Super Hi-Res + # screen ($E1) and turns SHR on. The 32K load takes ~30s; -nothrottle + # makes it appear as fast as possible. + return mame.launch("apple2gs", [("flop1", path)], throttle=False) + if platform in PET_CFG: + # quickload the .prg; a Lua script SYSes the viewer, which pokes the + # quadrant-block screen codes into the PET screen RAM at $8000. + return mame.launch(PET_CFG[platform][0], [("quik", path)], + autoboot_script_text=mame.pet_autorun_lua(), + autoboot_delay=3) + if platform == "sms": + # The cartridge resets into a Z80 viewer that programs the VDP and + # uploads tiles/name table/palette, then idles. + return mame.launch("sms", [("cart", path)]) + if platform == "amiga": + # Bootable floppy: a 68000 boot block loads the bitplanes into chip RAM + # and points the Copper at them. MAME shows an "imperfect graphics" + # warning the user dismisses with a key. + return mame.launch("a500", [("flop", path)]) + if platform == "atari": + # a800xl(p): the XL ROMs only raise MAME's soft "imperfect" notice, not the + # bad-dump notice the plain Atari 800 OS-B ROM triggers. The .atr self-boots. + machine = "a800xlp" if pal else "a800xl" + if str(path).lower().endswith(".car"): + return mame.launch(machine, [("cart", path)], throttle=False) + return mame.launch(machine, [("flop1", path)], throttle=False) + # C64: attach the .d64 to the 1541, then LOAD"*",8,1 + RUN. Run unthrottled so + # the (cycle-accurate, slow) 1541 load finishes fast; keep real-time for the + # interlace mode whose 50 Hz field-flip must not run faster than the display. + machine = "c64p" if pal else "c64" + if str(path).lower().endswith(".crt"): + # The exported .crt is one 16K CHIP (VICE's format); MAME needs two 8K + # CHIPs, so re-pack the ROM into a temp .crt just for the preview. + rom = crt.read_rom(path) + fd, mpath = tempfile.mkstemp(suffix=".crt", prefix="lenser_mame_") + os.close(fd) + crt.write_crt(rom, mpath, split=True) + return mame.launch(machine, [("cart", mpath)], # cartridge autostarts + throttle=(mode == "interlace")) + # Disk: a Lua script types LOAD, waits for the stock-1541 load to finish, + # then RUN (a single autoboot command loses the RUN during the slow load). + return mame.launch(machine, [("flop", path)], + autoboot_script_text=mame.c64_autorun_lua(), + throttle=(mode == "interlace")) diff --git a/lenser/plus4/__init__.py b/lenser/plus4/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lenser/plus4/convert/__init__.py b/lenser/plus4/convert/__init__.py new file mode 100644 index 0000000..ef95c53 --- /dev/null +++ b/lenser/plus4/convert/__init__.py @@ -0,0 +1,11 @@ +"""Commodore Plus/4 conversion dispatch. + +The Plus/4 uses the same TED chip (7360/8360) and BASIC 3.5 as the C16 -- it just +has 64K RAM instead of 16K. The TED's bitmap hardware is identical (320x200 hires, +the 121-colour palette), so the image encoding, palette and .prg viewer are exactly +the same; we reuse the C16 implementation unchanged and only target a different MAME +machine (see platforms.run_emulator). +""" +from __future__ import annotations + +from ...c16.convert import MODES, convert_image # noqa: F401 (re-export) diff --git a/lenser/plus4/exporter.py b/lenser/plus4/exporter.py new file mode 100644 index 0000000..84db646 --- /dev/null +++ b/lenser/plus4/exporter.py @@ -0,0 +1,4 @@ +"""Plus/4 .prg export -- identical to the C16 (same TED, same BASIC 3.5).""" +from __future__ import annotations + +from ..c16.exporter import export_prg # noqa: F401 (re-export) diff --git a/lenser/slideshow.py b/lenser/slideshow.py new file mode 100644 index 0000000..5470155 --- /dev/null +++ b/lenser/slideshow.py @@ -0,0 +1,588 @@ +"""Slideshow: pack several converted images onto one drive image with an +on-machine viewer that steps through them. + +A slideshow is locked to a single platform. Each slide carries its own image +adjustments (the same PrepOptions as a normal conversion) plus its own mode / +palette / dither, so the queue can freely mix e.g. hires and multicolor on the +C64. The viewer advances on a keypress, after a number of seconds, or both. + +This module owns: + * the data model (SlideItem / Slideshow), + * conversion of an item to bytes (delegating to platforms.convert), + * the storage engine that sizes a slideshow against a disk format and refuses + to overfill it, + * the C64 build (assemble the slideshow viewer + write the data files). + +Only disk-image platforms can host a slideshow (a cartridge or snapshot is a +single fixed payload). C64 is implemented here as the reference; other disk +platforms register via SLIDESHOW_PLATFORMS as they gain viewers. +""" + +from __future__ import annotations + +import math +import os +from dataclasses import dataclass, field, replace + +from . import diskimage, imageprep, platforms +from .convert.base import Conversion + +# Platforms whose output is a real multi-file filesystem image AND that have a +# slideshow viewer. C64 first; others are added as their viewers land. +SLIDESHOW_PLATFORMS = ("c64", "c128", "atari", "bbc", "apple", "iigs", "amiga") + +# Advance behaviours (how the viewer leaves a slide). "forever" is intentionally +# absent -- a slideshow that never advances is just a single-image viewer. +ADVANCE_MODES = ("key", "seconds", "both") + + +@dataclass +class SlideItem: + """One picture in the queue, with everything needed to reproduce its bytes.""" + source_path: str + mode: str = "auto" + palette: str = "colodore" + dither: str = "atkinson" + mono_base: str = "grayscale" + prep: imageprep.PrepOptions = field(default_factory=imageprep.PrepOptions) + + +@dataclass +class Slideshow: + platform: str = "c64" + disk_format: str = "d64" + advance: str = "both" + seconds: int = 10 + loop: bool = True + items: list[SlideItem] = field(default_factory=list) + + +# --------------------------------------------------------------------------- # +# conversion +# --------------------------------------------------------------------------- # + +def convert_item(show: Slideshow, item: SlideItem, + intensive: bool = False) -> Conversion: + """Convert one slide to a Conversion (bytes + viewer key + load address).""" + return platforms.convert(show.platform, item.source_path, item.mode, + item.palette, item.dither, intensive, item.prep, + item.mono_base) + + +# --------------------------------------------------------------------------- # +# storage engine +# --------------------------------------------------------------------------- # +# Each disk platform maps to a "filesystem" capacity model. CBM-DOS (c1541 d64/ +# d71/d81, shared by the C64 and C128) counts 254-byte data blocks and caps the +# directory; other filesystems get their own model as they are added. +_FS_CBM = "cbm" +_FS_ATR = "atr" +_FS_DFS = "dfs" +_FS_APPLE = "apple" +_FS_IIGS = "iigs" +_FS_AMIGA = "amiga" +_PLATFORM_FS = { + "c64": _FS_CBM, + "c128": _FS_CBM, + "atari": _FS_ATR, + "bbc": _FS_DFS, + "apple": _FS_APPLE, + "iigs": _FS_IIGS, + "amiga": _FS_AMIGA, +} + +# Per-format usable capacity (254-byte blocks) and directory-entry cap for CBM-DOS. +_CBM_DIR_CAP = {"d64": 144, "d71": 144, "d81": 296} + +# Atari single-density ATR: 720 x 128-byte sectors. The self-booting viewer +# reserves the first few sectors (fixed) and the raw images follow it; there is +# no filesystem/directory, so the only cap is the sector count. +_ATR_TOTAL_SECTORS = 720 +_ATR_BOOT_SECTORS = 8 +_ATR_BLOCK = 128 + +# Acorn DFS single-sided: 800 x 256-byte sectors, sectors 0-1 are the catalogue, +# at most 31 files. +_DFS_TOTAL_SECTORS = 800 +_DFS_CATALOGUE_SECTORS = 2 +_DFS_BLOCK = 256 +_DFS_MAX_FILES = 31 + +# Apple II HGR slideshow loads every image into RAM at once ($4000-$BFFF), so the +# binding limit is that 128-page (32K) buffer -- 4 HGR images of 32 pages each -- +# not the disk. Measured in 256-byte pages. +_APPLE_BLOCK = 256 +_APPLE_BUFFER_PAGES = 0x80 # $4000-$BFFF + +# Apple IIgs: a 5.25" ProDOS-order .dsk (35 x 16 x 256 = 560 sectors). Each 32K +# SHR image is read into bank 0 then banked to extended RAM; the binding limit is +# the small disk (boot + stage2 + N*128 sectors). +_IIGS_TOTAL_SECTORS = 560 +_IIGS_VIEWER_SECTORS = 4 # boot + stage2 (+ slack) +_IIGS_BLOCK = 256 +_IIGS_MAX_IMAGES = 4 + +# Amiga: the slideshow loads every image into chip RAM ($20000-$7FFFF) at once +# and cycles them there, so the 384K RAM region (not the 880K floppy) is the +# binding limit. Measured in 512-byte sectors. +_AMIGA_BLOCK = 512 +_AMIGA_RAM_SECTORS = (0x80000 - 0x20000) // 512 # 768 + + +def image_nbytes(conv: Conversion) -> int: + """On-disk byte size of one slide's picture data (Amiga's conv.data is a dict + of planes/colours, so its size is the assembled copper+bitplanes blob).""" + if isinstance(conv.data, dict): + from .amiga.viewer import image_blob + d = conv.data + return len(image_blob(d["planes"], d["colors"], d["nplanes"], d["ham"])) + return len(conv.data) + + +def _platform_fs(platform: str) -> str: + fs = _PLATFORM_FS.get(platform) + if fs is None: + raise NotImplementedError(f"slideshow storage not implemented for {platform}") + return fs + + +@dataclass +class Budget: + used_blocks: int + total_blocks: int + files: int + file_cap: int + fits: bool + reason: str = "" + block_size: int = 254 # bytes per block/sector (CBM 254, ATR 128) + + @property + def used_bytes(self) -> int: + return self.used_blocks * self.block_size + + @property + def total_bytes(self) -> int: + return self.total_blocks * self.block_size + + +def _cbm_blocks(prg_len: int) -> int: + """CBM blocks a PRG of prg_len bytes (incl. its 2-byte load address) uses.""" + return max(1, math.ceil(prg_len / 254)) + + +def item_blocks(platform: str, fmt: str, data_len: int) -> int: + """Blocks/sectors one picture file occupies.""" + fs = _platform_fs(platform) + if fs == _FS_CBM: + return _cbm_blocks(data_len + 2) # + 2-byte PRG load address + if fs == _FS_ATR: + return max(1, math.ceil(data_len / _ATR_BLOCK)) + if fs == _FS_DFS: + return max(1, math.ceil(data_len / _DFS_BLOCK)) + if fs == _FS_APPLE: + return max(1, math.ceil(data_len / _APPLE_BLOCK)) + if fs == _FS_IIGS: + return max(1, math.ceil(data_len / _IIGS_BLOCK)) + if fs == _FS_AMIGA: + return max(1, math.ceil(data_len / _AMIGA_BLOCK)) + raise NotImplementedError(f"slideshow storage not implemented for {platform}") + + +def viewer_blocks(platform: str, fmt: str, viewer_len: int) -> int: + """Blocks/sectors the boot viewer itself occupies.""" + fs = _platform_fs(platform) + if fs == _FS_CBM: + return _cbm_blocks(viewer_len) + if fs == _FS_ATR: + return _ATR_BOOT_SECTORS # fixed boot-sector reservation + if fs == _FS_DFS: + return 2 * max(1, math.ceil(viewer_len / _DFS_BLOCK)) # !BOOT + PIC copies + if fs == _FS_APPLE: + return 0 # boot loader lives at $0800, not the buffer + if fs == _FS_IIGS: + return _IIGS_VIEWER_SECTORS + if fs == _FS_AMIGA: + return 0 # boot block isn't in the image RAM region + raise NotImplementedError(f"slideshow storage not implemented for {platform}") + + +def capacity_blocks(platform: str, fmt: str) -> int: + fs = _platform_fs(platform) + if fs == _FS_CBM: + if fmt not in diskimage.BLOCKS_FREE: + raise ValueError(f"{fmt} is not a slideshow disk format for {platform}") + return diskimage.BLOCKS_FREE[fmt] + if fs == _FS_ATR: + return _ATR_TOTAL_SECTORS + if fs == _FS_DFS: + return _DFS_TOTAL_SECTORS - _DFS_CATALOGUE_SECTORS + if fs == _FS_APPLE: + return _APPLE_BUFFER_PAGES # RAM buffer, not the disk + if fs == _FS_IIGS: + return _IIGS_TOTAL_SECTORS + if fs == _FS_AMIGA: + return _AMIGA_RAM_SECTORS + raise NotImplementedError(f"slideshow storage not implemented for {platform}") + + +def dir_cap(platform: str, fmt: str) -> int: + fs = _platform_fs(platform) + if fs == _FS_CBM: + return _CBM_DIR_CAP.get(fmt, 144) + if fs == _FS_ATR: + return _ATR_TOTAL_SECTORS # no directory; bound by sectors + if fs == _FS_DFS: + return _DFS_MAX_FILES + if fs == _FS_APPLE: + return _APPLE_BUFFER_PAGES # bound by RAM, not a directory + if fs == _FS_IIGS: + return _IIGS_TOTAL_SECTORS + if fs == _FS_AMIGA: + return _AMIGA_RAM_SECTORS + raise NotImplementedError(f"slideshow storage not implemented for {platform}") + + +def _block_size(platform: str) -> int: + fs = _platform_fs(platform) + if fs == _FS_ATR: + return _ATR_BLOCK + if fs == _FS_DFS: + return _DFS_BLOCK + if fs == _FS_APPLE: + return _APPLE_BLOCK + if fs == _FS_IIGS: + return _IIGS_BLOCK + if fs == _FS_AMIGA: + return _AMIGA_BLOCK + return 254 + + +def budget(platform: str, fmt: str, data_lens: list[int], + viewer_len: int) -> Budget: + """Size a slideshow against a disk format. + + ``data_lens`` are the per-image payload lengths (len(conv.data)); ``viewer_len`` + is the assembled viewer length. The viewer plus one file per image must fit + both the block/sector capacity and (where applicable) the directory cap. + """ + total = capacity_blocks(platform, fmt) + cap = dir_cap(platform, fmt) + used = (viewer_blocks(platform, fmt, viewer_len) + + sum(item_blocks(platform, fmt, n) for n in data_lens)) + files = 1 + len(data_lens) # viewer + one per image + fits = used <= total and files <= cap + reason = "" + if used > total: + reason = f"{used} blocks needed, {fmt} holds {total}" + elif files > cap: + reason = f"{files} files exceeds the {fmt} directory ({cap})" + return Budget(used, total, files, cap, fits, reason, _block_size(platform)) + + +# --------------------------------------------------------------------------- # +# build (C64 reference) +# --------------------------------------------------------------------------- # + +def _mode_byte(conv: Conversion) -> int: + """Per-image VIC setup selector for the simple slideshow viewer. + + 0 = hires/mono (single bitmap + screen), 1 = multicolor (+ colour RAM + bg). + """ + return 1 if conv.viewer == "multicolor" else 0 + + +# viewer keys that each slideshow engine flavor accepts +_SIMPLE_VIEWERS = {"hires", "multicolor"} # mono uses the "hires" viewer +_FLI_VIEWERS = {"fli", "fli_ntsc"} +_INTERLACE_VIEWERS = {"interlace"} + + +def slideshow_flavor(convs: list[Conversion]) -> str: + """Pick the engine for a queue, or raise if its modes can't share a viewer. + + A slideshow must be all simple (hires/multicolor/mono), all FLI, or all + interlace -- FLI/IFLI each need their own raster engine and memory map, so + they can't be mixed with the simple modes or each other. + """ + viewers = {c.viewer for c in convs} + if viewers <= _SIMPLE_VIEWERS: + return "simple" + if viewers <= _FLI_VIEWERS: + return "fli" + if viewers <= _INTERLACE_VIEWERS: + return "interlace" + raise ValueError( + "a slideshow must be all hires/multicolor/mono, all FLI, or all " + "interlace -- these modes cannot be mixed on one disc") + + +def data_filename(i: int) -> str: + """Disk filename for slide ``i`` (two PETSCII digits the viewer rebuilds).""" + return f"{i:02d}" + + +def supports_slideshow(platform: str) -> bool: + return platform in SLIDESHOW_PLATFORMS + + +# Disk formats offered for a slideshow on each platform. +_SS_FORMATS = { + "c64": ["d64", "d71", "d81"], + "c128": ["d64"], + "atari": ["atr"], + "bbc": ["ssd"], + "apple": ["dsk"], + "iigs": ["dsk"], + "amiga": ["adf"], +} + + +def disk_formats(platform: str) -> list[str]: + return _SS_FORMATS.get(platform, []) + + +def check_modes(platform: str, convs: list[Conversion]) -> None: + """Raise ValueError if the queue's modes can't share one slideshow viewer.""" + if platform == "c64": + slideshow_flavor(convs) # raises on an illegal mix + elif platform == "c128": + bad = [c.meta.get("vdc_mode") for c in convs + if c.meta.get("vdc_mode") != "hicolor"] + if bad: + raise ValueError( + "C128 slideshows support the 640x200 hicolor/mono VDC mode only; " + "the 80x100 'color' mode is not yet supported") + elif platform == "atari": + viewers = {c.viewer for c in convs} + if not viewers <= {"gr15", "gr9", "gr8", "gr15dli"} or len(viewers) != 1: + raise ValueError( + "Atari slideshows support gr15 / gr9 / gr8 / gr15dli -- all slides " + "must use the same one of these modes") + elif platform == "bbc": + modes = {c.meta.get("bbc_mode") for c in convs} + if len(modes) != 1: + raise ValueError( + "BBC slideshows must use a single screen mode -- all slides must " + "be the same BBC mode") + elif platform == "apple": + viewers = {c.viewer for c in convs} + if viewers != {"hgr"}: + raise ValueError( + "Apple slideshows currently support HGR (hgr_color/hgr_mono) only") + if len(convs) > _APPLE_BUFFER_PAGES // 0x20: + raise ValueError( + f"Apple HGR slideshows hold at most {_APPLE_BUFFER_PAGES // 0x20} " + "images (they load into RAM at once)") + elif platform == "iigs": + viewers = {c.viewer for c in convs} + if viewers != {"iigs"}: + raise ValueError("IIgs slideshows support the SHR mode only") + if len(convs) > _IIGS_MAX_IMAGES: + raise ValueError( + f"IIgs SHR slideshows hold at most {_IIGS_MAX_IMAGES} images " + "(32K each on a 140K 5.25\" disk)") + elif platform == "amiga": + # every Amiga image is a self-contained copper+bitplanes blob, so any + # lowres/HAM mix is fine; only the 880K floppy bounds the count. + b = budget("amiga", "adf", [image_nbytes(c) for c in convs], 0) + if not b.fits: + raise ValueError(f"Amiga slideshow does not fit an .adf: {b.reason}") + + +def viewer_length(show: Slideshow, convs: list[Conversion], + video: str = "pal") -> int: + """Assembled length of the slideshow viewer PRG (for the storage budget).""" + if show.platform == "c64": + from .viewer.assemble import build_slideshow_prg + return len(build_slideshow_prg( + [_mode_byte(c) for c in convs], advance=show.advance, + seconds=show.seconds, loop=show.loop, video=video, + flavor=slideshow_flavor(convs))) + if show.platform == "c128": + from .c128.viewer.assemble import build_slideshow_prg + return len(build_slideshow_prg( + [c.meta.get("fgbg", 0) for c in convs], advance=show.advance, + seconds=show.seconds, loop=show.loop, video=video)) + if show.platform == "atari": + # the boot viewer occupies the fixed boot-sector reservation regardless + # of its exact length, which is what budget() charges for it. + return _ATR_BOOT_SECTORS * _ATR_BLOCK + if show.platform == "bbc": + from .bbc.viewer.assemble import build_slideshow_viewer + m0 = convs[0].meta + return len(build_slideshow_viewer( + m0["bbc_mode"], m0["ncol"], m0["base"], + [c.meta["physicals"] for c in convs], advance=show.advance, + seconds=show.seconds, loop=show.loop, video=video)) + if show.platform == "apple": + return 0 # boot loader is at $0800, outside the image buffer + if show.platform == "iigs": + return 0 # budget charges a fixed boot+stage2 sector reservation + if show.platform == "amiga": + return 0 # budget charges the fixed 2-sector boot block + raise NotImplementedError(f"viewer_length missing for {show.platform}") + + +def _check_budget(show: Slideshow, data_lens: list[int], viewer_len: int) -> None: + b = budget(show.platform, show.disk_format, data_lens, viewer_len) + if not b.fits: + raise diskimage.DiskError( + f"slideshow does not fit a {show.disk_format}: {b.reason}; " + f"use a larger disk format or remove images") + + +def build_disk(show: Slideshow, output_path: str, *, intensive: bool = False, + disk_name: str | None = None, video: str = "pal", + convs: list[Conversion] | None = None) -> str: + """Convert every slide, assemble the slideshow viewer, and write the disk. + + Pass ``convs`` to reuse already-computed conversions (the GUI caches them); + otherwise each item is converted here. Raises diskimage.DiskError if the + chosen format cannot hold the show (the budget is checked up front so the + message names the offending limit). + """ + if not supports_slideshow(show.platform): + raise NotImplementedError( + f"slideshow is not implemented for platform {show.platform}") + if not show.items: + raise ValueError("slideshow has no images") + if show.advance not in ADVANCE_MODES: + raise ValueError(f"advance must be one of {ADVANCE_MODES}") + if convs is None: + convs = [convert_item(show, it, intensive) for it in show.items] + + if show.platform == "c64": + return _build_disk_c64(show, output_path, convs, disk_name, video) + if show.platform == "c128": + return _build_disk_c128(show, output_path, convs, disk_name, video) + if show.platform == "atari": + return _build_disk_atari(show, output_path, convs, disk_name, video) + if show.platform == "bbc": + return _build_disk_bbc(show, output_path, convs, disk_name, video) + if show.platform == "apple": + return _build_disk_apple(show, output_path, convs, disk_name, video) + if show.platform == "iigs": + return _build_disk_iigs(show, output_path, convs, disk_name, video) + if show.platform == "amiga": + return _build_disk_amiga(show, output_path, convs, disk_name, video) + raise NotImplementedError(f"slideshow build missing for {show.platform}") + + +def _build_disk_c64(show, output_path, convs, disk_name, video) -> str: + from .viewer.assemble import build_data_prg, build_slideshow_prg + flavor = slideshow_flavor(convs) # validates modes are uniform-enough + modes = [_mode_byte(c) for c in convs] + viewer = build_slideshow_prg(modes, advance=show.advance, seconds=show.seconds, + loop=show.loop, video=video, flavor=flavor) + _check_budget(show, [len(c.data) for c in convs], len(viewer)) + + stem = os.path.splitext(os.path.basename(output_path))[0] + name = diskimage.petscii_name(disk_name or stem or "slideshow") + files = [(name, viewer)] + for i, c in enumerate(convs): + files.append((data_filename(i), build_data_prg(c.data, c.data_addr))) + fmt = diskimage.fmt_from_path(output_path, show.disk_format) + return diskimage.build_disk(output_path, fmt, name, "01", files) + + +# C128 slideshow data files load into RAM bank 0 at $4000 (the viewer copies them +# to VDC RAM); only the 640x200 hicolor/mono VDC mode is supported. +_C128_DATA_ADDR = 0x4000 + + +def _build_disk_c128(show, output_path, convs, disk_name, video) -> str: + from .c128.viewer.assemble import build_slideshow_prg + from .viewer.assemble import build_data_prg + bad = [c.meta.get("vdc_mode") for c in convs if c.meta.get("vdc_mode") != "hicolor"] + if bad: + raise ValueError( + "C128 slideshows support the 640x200 hicolor/mono VDC mode only " + f"(got {bad[0]!r}); the 80x100 'color' mode is not yet supported") + fgbg = [c.meta.get("fgbg", 0) for c in convs] + viewer = build_slideshow_prg(fgbg, advance=show.advance, seconds=show.seconds, + loop=show.loop, video=video) + _check_budget(show, [len(c.data) for c in convs], len(viewer)) + + files = [("pic", viewer)] # boots via RUN"PIC" + for i, c in enumerate(convs): + files.append((data_filename(i), build_data_prg(c.data, _C128_DATA_ADDR))) + stem = os.path.splitext(os.path.basename(output_path))[0] + name = diskimage.petscii_name(disk_name or stem or "slideshow") + return diskimage.build_disk(output_path, "d64", name, "cv", files) + + +def _build_disk_atari(show, output_path, convs, disk_name, video) -> str: + from .atari import atr + from .atari.viewer.assemble import build_slideshow_stub + check_modes("atari", convs) # uniform gr15/gr9/gr8 (raises otherwise) + spi = max(item_blocks("atari", "atr", len(c.data)) for c in convs) + base = _ATR_BOOT_SECTORS + 1 + stub = build_slideshow_stub(convs[0].viewer, len(convs), base, spi, + advance=show.advance, seconds=show.seconds, + loop=show.loop, video=video) + _check_budget(show, [len(c.data) for c in convs], len(stub)) + if not output_path.lower().endswith(".atr"): + output_path += ".atr" + return atr.write_slideshow_atr(output_path, stub, [c.data for c in convs], + boot_sectors=_ATR_BOOT_SECTORS, spi=spi) + + +def _build_disk_bbc(show, output_path, convs, disk_name, video) -> str: + from .bbc import ssd + from .bbc.viewer.assemble import LOAD_ADDR, build_slideshow_viewer + check_modes("bbc", convs) # uniform BBC mode (raises otherwise) + m0 = convs[0].meta + viewer = build_slideshow_viewer( + m0["bbc_mode"], m0["ncol"], m0["base"], + [c.meta["physicals"] for c in convs], advance=show.advance, + seconds=show.seconds, loop=show.loop, video=video) + _check_budget(show, [len(c.data) for c in convs], len(viewer)) + if not output_path.lower().endswith(".ssd"): + output_path += ".ssd" + # !BOOT autostarts on SHIFT+BREAK; PIC is the same loader *RUN from a command. + files = [("!BOOT", LOAD_ADDR, LOAD_ADDR, viewer), + ("PIC", LOAD_ADDR, LOAD_ADDR, viewer)] + for i, c in enumerate(convs): + files.append((data_filename(i), c.meta["base"], c.meta["base"], c.data)) + title = diskimage.petscii_name(disk_name or "8bitlenser", 12).upper() or "8BITLENSER" + ssd.write_ssd(output_path, files, title=title, boot_option=2) + return output_path + + +def _build_disk_apple(show, output_path, convs, disk_name, video) -> str: + from .apple import dsk + from .apple.viewer.assemble import build_slideshow_stub + check_modes("apple", convs) # uniform HGR (raises otherwise) + _check_budget(show, [len(c.data) for c in convs], 0) + boot = build_slideshow_stub(len(convs), advance=show.advance, + seconds=show.seconds, loop=show.loop) + payload = b"".join(c.data for c in convs) # images read contiguously to $4000+ + if not output_path.lower().endswith((".dsk", ".do")): + output_path += ".dsk" + return dsk.write_dsk(output_path, dsk.build_boot_dsk(boot, payload)) + + +def _build_disk_iigs(show, output_path, convs, disk_name, video) -> str: + from .apple import dsk + from .iigs.viewer.assemble import build_slideshow + check_modes("iigs", convs) # uniform SHR, <=4 images + _check_budget(show, [len(c.data) for c in convs], 0) + boot, stage2, _ = build_slideshow(len(convs), advance=show.advance, + seconds=show.seconds, loop=show.loop, video=video) + # payload = stage2 (padded to whole sectors) then the SHR images, all read + # contiguously by the boot: stage2 -> $0900, each image -> bank-0 $2000. + payload = bytes(stage2) + bytes((-len(stage2)) % 256) + payload += b"".join(c.data for c in convs) + if not output_path.lower().endswith((".dsk", ".do")): + output_path += ".dsk" + return dsk.write_dsk(output_path, dsk.build_boot_dsk(boot, payload)) + + +def _build_disk_amiga(show, output_path, convs, disk_name, video) -> str: + from .amiga import viewer + check_modes("amiga", convs) # any blobs; bounded by the floppy + if not output_path.lower().endswith(".adf"): + output_path += ".adf" + adf = viewer.build_slideshow_adf([c.data for c in convs], advance=show.advance, + seconds=show.seconds, loop=show.loop, video=video) + return viewer.write_adf(adf, output_path) diff --git a/lenser/slideshow_cli.py b/lenser/slideshow_cli.py new file mode 100644 index 0000000..272e7d3 --- /dev/null +++ b/lenser/slideshow_cli.py @@ -0,0 +1,119 @@ +"""Headless slideshow builder: read a JSON manifest, write one drive image that +steps through several converted pictures. + +Manifest shape (all fields except ``items`` and each item's ``image`` optional):: + + { + "platform": "c64", + "format": "d64", + "advance": "both", # key | seconds | both + "seconds": 10, + "loop": true, + "video": "pal", # pal | ntsc (seconds timing) + "disk_name": "holiday", + "items": [ + {"image": "beach.jpg", "mode": "multicolor", "dither": "atkinson", + "palette": "colodore", "brightness": 1.1, "contrast": 1.2}, + {"image": "sunset.png", "mode": "hires", "tint": 20, "saturation": 1.3} + ] + } + +Per-item image adjustments are the same knobs as the single-image CLI: aspect, +brightness, contrast, saturation, gamma, tint, red, green, blue, plus mode / +palette / dither / mono_base. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys + +from . import imageprep, slideshow +from .diskimage import DiskError + +_PREP_FIELDS = ("aspect", "brightness", "contrast", "saturation", "gamma", + "tint", "red", "green", "blue", "border_index") +_ITEM_FIELDS = ("mode", "palette", "dither", "mono_base") + + +def _item_from(obj: dict, base_dir: str) -> slideshow.SlideItem: + if "image" not in obj: + raise ValueError("each slideshow item needs an \"image\" path") + image = obj["image"] + if not os.path.isabs(image): + image = os.path.join(base_dir, image) + prep = imageprep.PrepOptions(**{k: obj[k] for k in _PREP_FIELDS if k in obj}) + kw = {k: obj[k] for k in _ITEM_FIELDS if k in obj} + return slideshow.SlideItem(source_path=image, prep=prep, **kw) + + +def _show_from(manifest: dict, base_dir: str) -> slideshow.Slideshow: + items = [_item_from(it, base_dir) for it in manifest.get("items", [])] + if not items: + raise ValueError("manifest has no items") + return slideshow.Slideshow( + platform=manifest.get("platform", "c64"), + disk_format=manifest.get("format", "d64"), + advance=manifest.get("advance", "both"), + seconds=int(manifest.get("seconds", 10)), + loop=bool(manifest.get("loop", True)), + items=items, + ) + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="8bitlenser-slideshow", description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("manifest", help="JSON slideshow manifest") + p.add_argument("-o", "--output", required=True, + help="output drive image (e.g. show.d64/.d71/.d81)") + p.add_argument("--disk-name", default=None, help="disk + viewer name") + p.add_argument("--video", default=None, choices=["pal", "ntsc"], + help="seconds-timer rate (default from manifest, else pal)") + p.add_argument("--intensive", action="store_true", + help="slower, higher-quality conversion of each slide") + return p + + +def main(argv=None) -> int: + args = build_parser().parse_args(argv) + with open(args.manifest) as f: + manifest = json.load(f) + base_dir = os.path.dirname(os.path.abspath(args.manifest)) + show = _show_from(manifest, base_dir) + + if not slideshow.supports_slideshow(show.platform): + print(f"slideshow is not supported for platform '{show.platform}' " + f"(supported: {', '.join(slideshow.SLIDESHOW_PLATFORMS)})", + file=sys.stderr) + return 2 + + video = args.video or manifest.get("video", "pal") + convs = [slideshow.convert_item(show, it, args.intensive) for it in show.items] + for i, (it, c) in enumerate(zip(show.items, convs)): + print(f"slide {i:02d}: {os.path.basename(it.source_path)} " + f"mode={c.mode} data={slideshow.image_nbytes(c)}B dE={c.error:.2f}") + + b = slideshow.budget(show.platform, show.disk_format, + [slideshow.image_nbytes(c) for c in convs], + slideshow.viewer_length(show, convs, video)) + print(f"storage: {b.used_blocks}/{b.total_blocks} blocks, " + f"{b.files}/{b.file_cap} files -> {'fits' if b.fits else 'OVER: ' + b.reason}") + + try: + out = slideshow.build_disk(show, args.output, intensive=args.intensive, + disk_name=args.disk_name, video=video, + convs=convs) + except (DiskError, NotImplementedError, ValueError) as e: + print(f"error: {e}", file=sys.stderr) + return 1 + print(f"wrote slideshow {out} ({len(convs)} images, advance={show.advance}" + f"{'' if show.advance == 'key' else f'/{show.seconds}s'}, " + f"loop={'on' if show.loop else 'off'})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lenser/sms/__init__.py b/lenser/sms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lenser/sms/convert/__init__.py b/lenser/sms/convert/__init__.py new file mode 100644 index 0000000..63db75a --- /dev/null +++ b/lenser/sms/convert/__init__.py @@ -0,0 +1,19 @@ +"""Sega Master System conversion dispatch.""" +from __future__ import annotations + +from ... import imageprep +from . import bg, mono + +_MODULES = {"bg": bg, "mono": mono} +MODES = list(_MODULES.keys()) + + +def convert_image(path_or_img, mode="bg", palette_name="sms", + dither_mode="floyd", intensive=False, prep_opt=None, + base_color=None): + prep_opt = prep_opt or imageprep.PrepOptions() + module = _MODULES.get(mode, bg) + 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) diff --git a/lenser/sms/convert/_common.py b/lenser/sms/convert/_common.py new file mode 100644 index 0000000..8556a02 --- /dev/null +++ b/lenser/sms/convert/_common.py @@ -0,0 +1,150 @@ +"""Sega Master System background encoder: 256x192, 2 palettes of 16 (of 64), +per-8x8-tile palette select, <=512 tiles in VRAM. + +Each tile is 4bpp (16 colours) and picks one of two 16-colour palettes, so up to +32 colours on screen -- far less constrained than the NES. We pick palette 0 for +the whole image, palette 1 for the colours it serves worst, assign each tile its +better palette, dither, then vector-quantise the 8x8 patterns to 512 tiles. +""" +from __future__ import annotations + +import numpy as np + +from ... import dither, palette as c64pal +from ...convert.base import perceptual_error +from .. import palette as smspal + +W, H = 256, 192 +TCOLS, TROWS = 32, 24 +# VRAM is 16K: pattern table $0000-$37FF (448 tiles), name table $3800, sprite +# attribute table $3F00 -- so at most 448 unique background tiles. +NTILES = 448 + + +def _choose(img_lab, plab, n, weight=None): + flat = img_lab.reshape(-1, 3) + d = np.sum((flat[:, None, :] - plab[None, :, :]) ** 2, axis=-1) # (px,64) + if weight is not None: + d = d * weight[:, None] + chosen, best = [], np.full(len(flat), np.inf) + for _ in range(n): + cand = np.minimum(best[:, None], d).sum(0) + for c in chosen: + cand[c] = np.inf + c = int(cand.argmin()) + chosen.append(c) + best = np.minimum(best, d[:, c]) + return sorted(chosen) + + +def _tile_codebook(patterns, k, iters=8): + uniq, counts = np.unique(patterns, axis=0, return_counts=True) + if len(uniq) <= k: + code = np.zeros((k, patterns.shape[1]), patterns.dtype) + code[:len(uniq)] = uniq + lut = {tuple(p): i for i, p in enumerate(uniq)} + return code, np.array([lut[tuple(p)] for p in patterns]) + code = uniq[np.argsort(-counts)[:k]].copy() + labels = np.zeros(len(patterns), np.int64) + for _ in range(iters): + for s in range(0, len(patterns), 2048): + blk = patterns[s:s + 2048] + labels[s:s + 2048] = (blk[:, None, :] != code[None]).sum(2).argmin(1) + moved = False + for j in range(k): + mem = patterns[labels == j] + if len(mem): + med = np.array([np.bincount(mem[:, p], minlength=16).argmax() + for p in range(mem.shape[1])], patterns.dtype) + if not np.array_equal(med, code[j]): + code[j] = med; moved = True + if not moved: + break + for s in range(0, len(patterns), 2048): + blk = patterns[s:s + 2048] + labels[s:s + 2048] = (blk[:, None, :] != code[None]).sum(2).argmin(1) + return code, labels + + +def _palettes(img_lab, mono, base_color): + plab = smspal.palette_lab() + if mono: + greys = sorted(smspal.GREYS, key=lambda i: plab[i, 0]) + pal0 = (greys * 4)[:16] # 4 greys, padded to 16 + return [pal0, pal0] + pal0 = _choose(img_lab, plab, 16) + # palette 1 covers the colours palette 0 reproduces worst + flat = img_lab.reshape(-1, 3) + resid = np.min(np.sum((flat[:, None, :] - plab[pal0][None]) ** 2, 2), 1) + pal1 = _choose(img_lab, plab, 16, weight=resid) + return [pal0, pal1] + + +def encode(img_rgb, dither_mode, mono=False, base_color=None): + plab = smspal.palette_lab() + prgb = smspal.get_palette().astype(np.uint8) + img_lab = c64pal.srgb_to_lab(img_rgb) + pals = _palettes(img_lab, mono, base_color) # 2 x 16 indices + pal_idx = np.array(pals) # (2,16) + plab_pal = plab[pal_idx] # (2,16,3) + + # assign each tile the palette (0/1) with lower nearest-colour error + tile_pal = np.zeros((TROWS, TCOLS), np.int64) + for ty in range(TROWS): + for tx in range(TCOLS): + blk = img_lab[ty * 8:ty * 8 + 8, tx * 8:tx * 8 + 8].reshape(-1, 3) + e0 = np.min(np.sum((blk[:, None, :] - plab_pal[0][None]) ** 2, 2), 1).sum() + e1 = np.min(np.sum((blk[:, None, :] - plab_pal[1][None]) ** 2, 2), 1).sum() + tile_pal[ty, tx] = 0 if e0 <= e1 else 1 + + # per-pixel allowed = its tile's 16 palette colours (global index 0-31); dither + plab32 = plab[pal_idx.reshape(-1)] # (32,3) + allowed = np.zeros((H, W, 16), np.int64) + for ty in range(TROWS): + for tx in range(TCOLS): + base = tile_pal[ty, tx] * 16 + allowed[ty * 8:ty * 8 + 8, tx * 8:tx * 8 + 8] = np.arange(base, base + 16) + idx = dither.quantize(img_lab, allowed, plab32, dither_mode).astype(np.int64) + pen = (idx - np.repeat(np.repeat(tile_pal, 8, 0), 8, 1) * 16).astype(np.uint8) + + # 8x8 tiles -> patterns (pen 0-15); vector-quantise to <=512 + tiles = pen.reshape(TROWS, 8, TCOLS, 8).transpose(0, 2, 1, 3).reshape(TROWS * TCOLS, 64) + code, labels = _tile_codebook(tiles, NTILES) + name_pat = labels.reshape(TROWS, TCOLS) + + # ---- emit VDP data ---- + patterns = bytearray(NTILES * 32) + for t in range(NTILES): + pat = code[t].reshape(8, 8) + for r in range(8): + for k in range(4): + byte = 0 + for x in range(8): + byte |= ((int(pat[r, x]) >> k) & 1) << (7 - x) + patterns[t * 32 + r * 4 + k] = byte + + nametable = bytearray(TROWS * TCOLS * 2) + for ty in range(TROWS): + for tx in range(TCOLS): + entry = (int(name_pat[ty, tx]) & 0x1FF) | (int(tile_pal[ty, tx]) << 11) + o = (ty * TCOLS + tx) * 2 + nametable[o] = entry & 0xFF + nametable[o + 1] = (entry >> 8) & 0xFF + + palette = bytes(int(c) for c in pal_idx.reshape(-1)) # 32 colour indices (0-63) + + # rebuild displayed image (clustered tiles + per-tile palette) for preview + disp = code[labels].reshape(TROWS, TCOLS, 8, 8).transpose(0, 2, 1, 3).reshape(H, W) + final = np.zeros((H, W), np.uint16) + for ty in range(TROWS): + for tx in range(TCOLS): + ys, xs = slice(ty * 8, ty * 8 + 8), slice(tx * 8, tx * 8 + 8) + final[ys, xs] = pal_idx[tile_pal[ty, tx]][disp[ys, xs]] + + if mono: + lum = img_lab.copy(); lum[..., 1:] = 0.0 + pl = plab.copy(); pl[:, 1:] = 0.0 + err = perceptual_error(final, lum, pl) + else: + err = perceptual_error(final, img_lab, plab) + return bytes(patterns), bytes(nametable), palette, prgb[final], err diff --git a/lenser/sms/convert/bg.py b/lenser/sms/convert/bg.py new file mode 100644 index 0000000..b58c81d --- /dev/null +++ b/lenser/sms/convert/bg.py @@ -0,0 +1,19 @@ +"""SMS background image: 256x192, 2 palettes of 16 (of 64), <=512 tiles.""" +from __future__ import annotations + +from ...convert.base import Conversion +from . import _common + +WIDTH, HEIGHT = 256, 192 +PIXEL_ASPECT = 1.0 # 256x192 is exactly 4:3 -> square pixels + + +def convert(img_rgb, palette_name="sms", dither_mode="floyd", + intensive=False, base_color=None): + pat, nt, pal, preview, err = _common.encode(img_rgb, dither_mode, mono=False) + return Conversion( + mode="bg", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=None, data={"patterns": pat, "nametable": nt, "palette": pal}, + data_addr=0, viewer="sms", preview_rgb=preview, error=err, + meta={"palette": "sms", "dither": dither_mode}, + ) diff --git a/lenser/sms/convert/mono.py b/lenser/sms/convert/mono.py new file mode 100644 index 0000000..c6960ba --- /dev/null +++ b/lenser/sms/convert/mono.py @@ -0,0 +1,21 @@ +"""SMS monochrome: 256x192 using the VDP's 4 true greys (2-bit per channel), +tone carried by dithering.""" +from __future__ import annotations + +from ...convert.base import Conversion +from . import _common + +WIDTH, HEIGHT = 256, 192 +PIXEL_ASPECT = 1.0 + + +def convert(img_rgb, palette_name="sms", dither_mode="floyd", + intensive=False, base_color=None): + pat, nt, pal, preview, err = _common.encode(img_rgb, dither_mode, mono=True, + base_color=base_color) + return Conversion( + mode="mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=None, data={"patterns": pat, "nametable": nt, "palette": pal}, + data_addr=0, viewer="sms", preview_rgb=preview, error=err, + meta={"palette": "sms", "dither": dither_mode}, + ) diff --git a/lenser/sms/exporter.py b/lenser/sms/exporter.py new file mode 100644 index 0000000..471d7d5 --- /dev/null +++ b/lenser/sms/exporter.py @@ -0,0 +1,13 @@ +"""Build a Sega Master System .sms cartridge from a conversion.""" +from __future__ import annotations + +from . import viewer + + +def export_sms(conv, output_path, source_path=None, display="forever", + seconds=0, video="ntsc"): + if not output_path.lower().endswith((".sms", ".bin")): + output_path += ".sms" + d = conv.data + rom = viewer.build_rom(d["patterns"], d["nametable"], d["palette"]) + return viewer.write_sms(rom, output_path) diff --git a/lenser/sms/palette.py b/lenser/sms/palette.py new file mode 100644 index 0000000..20b162c --- /dev/null +++ b/lenser/sms/palette.py @@ -0,0 +1,33 @@ +"""Sega Master System VDP (315-5124) palette. + +The SMS palette is 64 colours: each CRAM byte is %00BBGGRR -- 2 bits per channel +(x85 -> 0,85,170,255). The colour index IS the byte written to CRAM, so PALETTE +is indexed by hardware value 0-63. Two 16-colour palettes (background + sprite) +are loaded; an image uses up to 32 colours on screen. +""" +from __future__ import annotations + +import numpy as np + +from ..palette import srgb_to_lab + + +def _rgb(c): + r = (c & 3) * 85 + g = ((c >> 2) & 3) * 85 + b = ((c >> 4) & 3) * 85 + return (r, g, b) + + +PALETTE = np.array([_rgb(c) for c in range(64)], dtype=np.float64) + +# greys: R==G==B -> byte where the three 2-bit fields are equal (0,21,42,63). +GREYS = [c for c in range(64) if PALETTE[c, 0] == PALETTE[c, 1] == PALETTE[c, 2]] + + +def get_palette() -> np.ndarray: + return PALETTE + + +def palette_lab() -> np.ndarray: + return srgb_to_lab(PALETTE) diff --git a/lenser/sms/viewer.py b/lenser/sms/viewer.py new file mode 100644 index 0000000..d14c1d3 --- /dev/null +++ b/lenser/sms/viewer.py @@ -0,0 +1,109 @@ +"""Build a Sega Master System .sms cartridge: a Z80 VDP-setup program + data. + +The Z80 code (org $0000) programs the VDP for mode 4 (256x192), uploads the tile +patterns to VRAM $0000, the name table to $3800 and the palette to CRAM, turns +the display on, then idles. A valid "TMR SEGA" header + checksum is written at +$7FF0 so the export BIOS accepts the cartridge. +""" +from __future__ import annotations + +from .z80 import Asm + +ROM_SIZE = 0x8000 # 32 KB (maps to $0000-$7FFF, no mapper needed) +PORT_CTRL = 0xBF +PORT_DATA = 0xBE + +# VDP registers 0-10 for a plain 256x192 mode-4 background screen. Reg 1 starts +# with the display OFF ($80); it is set to $C0 (display on) at the end. +VDP_REGS = [0x04, 0x80, 0xFF, 0xFF, 0xFF, 0xFF, 0xFB, 0x00, 0x00, 0x00, 0xFF] + + +def _code(reglen, patlen, ntlen) -> bytes: + a = Asm(0x0000) + a.di() + a.im1() + a.ld_sp(0xDFF0) + + # --- VDP register init: write VDP_REGS[i] then $80|i --- + a.ld_hl("REGDATA") + a.ld_b(len(VDP_REGS)) + a.ld_c(0x00) + a.label("rinit") + a.ld_a_hl(); a.out_a(PORT_CTRL) # value + a.ld_a_c(); a.or_n(0x80); a.out_a(PORT_CTRL) # $80 | reg + a.inc_hl(); a.inc_c() + a.djnz("rinit") + + # --- upload patterns to VRAM $0000 --- + a.xor_a(); a.out_a(PORT_CTRL) + a.ld_a(0x40); a.out_a(PORT_CTRL) # $0000 | write + a.ld_hl("PATDATA") + a.ld_bc(patlen) + a.label("ptile") + a.ld_a_hl(); a.out_a(PORT_DATA) + a.inc_hl(); a.dec_bc() + a.ld_a_b(); a.or_c(); a.jp_nz("ptile") + + # --- upload name table to VRAM $3800 --- + a.xor_a(); a.out_a(PORT_CTRL) + a.ld_a(0x78); a.out_a(PORT_CTRL) # $3800 | write ($38 | $40) + a.ld_hl("NTDATA") + a.ld_bc(ntlen) + a.label("pnt") + a.ld_a_hl(); a.out_a(PORT_DATA) + a.inc_hl(); a.dec_bc() + a.ld_a_b(); a.or_c(); a.jp_nz("pnt") + + # --- upload palette to CRAM 0 --- + a.xor_a(); a.out_a(PORT_CTRL) + a.ld_a(0xC0); a.out_a(PORT_CTRL) # CRAM write + a.ld_hl("PALDATA") + a.ld_b(0x20) # 32 colours + a.label("ppal") + a.ld_a_hl(); a.out_a(PORT_DATA) + a.inc_hl() + a.djnz("ppal") + + # --- disable sprites: write $D0 (list terminator) to SAT Y[0] at $3F00 --- + a.xor_a(); a.out_a(PORT_CTRL) + a.ld_a(0x7F); a.out_a(PORT_CTRL) # $3F00 | write + a.ld_a(0xD0); a.out_a(PORT_DATA) + + # --- display on (reg 1 = $C0) --- + a.ld_a(0xC0); a.out_a(PORT_CTRL) + a.ld_a(0x81); a.out_a(PORT_CTRL) + a.label("hang") + a.jp("hang") + + # data labels live right after the code, in this order + base = len(a.code) + a.set_label("REGDATA", base) + a.set_label("PATDATA", base + reglen) + a.set_label("NTDATA", base + reglen + patlen) + a.set_label("PALDATA", base + reglen + patlen + ntlen) + return a.resolve() + + +def build_rom(patterns: bytes, nametable: bytes, palette: bytes) -> bytes: + code = _code(len(VDP_REGS), len(patterns), len(nametable)) + rom = bytearray(ROM_SIZE) + blob = code + bytes(VDP_REGS) + bytes(patterns) + bytes(nametable) + bytes(palette) + if len(blob) > 0x7FF0: + raise ValueError("SMS image data overruns the 32KB cartridge") + rom[0:len(blob)] = blob + + # "TMR SEGA" header at $7FF0 (export region, 32KB) so the BIOS accepts it. + rom[0x7FF0:0x7FF8] = b"TMR SEGA" + rom[0x7FF8:0x7FFA] = bytes(2) # reserved + chk = sum(rom[0:0x7FF0]) & 0xFFFF # checksum of $0000-$7FEF + rom[0x7FFA] = chk & 0xFF + rom[0x7FFB] = (chk >> 8) & 0xFF + rom[0x7FFC:0x7FFF] = bytes(3) # product code / version + rom[0x7FFF] = 0x4C # region 4 (export) | size $C (32K) + return bytes(rom) + + +def write_sms(rom: bytes, path: str) -> str: + with open(path, "wb") as f: + f.write(rom) + return path diff --git a/lenser/sms/z80.py b/lenser/sms/z80.py new file mode 100644 index 0000000..1e0cac2 --- /dev/null +++ b/lenser/sms/z80.py @@ -0,0 +1,77 @@ +"""Tiny Z80 machine-code emitter for the SMS VDP-setup viewer. + +Only the handful of opcodes the viewer needs, with label support (two-pass: the +data blocks are appended after the code, so their addresses depend on the code +length). Same spirit as the project's other hand-rolled CPU emitters +(ti99/tms9900.py, coco/mc6809.py, intv/cp1610.py). +""" +from __future__ import annotations + + +class Asm: + def __init__(self, org: int = 0x0000): + self.org = org + self.code = bytearray() + self.labels: dict[str, int] = {} + self.fixups: list[tuple[int, str, str]] = [] # (pos, label, kind) + + def _b(self, *bs): + for b in bs: + self.code.append(b & 0xFF) + + def label(self, name): + self.labels[name] = self.org + len(self.code) + + def set_label(self, name, addr): + self.labels[name] = addr + + def _w(self, v): + """emit a 16-bit little-endian operand; v is int or a label name.""" + if isinstance(v, str): + self.fixups.append((len(self.code), v, "abs")) + self._b(0, 0) + else: + self._b(v & 0xFF, (v >> 8) & 0xFF) + + def _rel(self, label): + self.fixups.append((len(self.code), label, "rel")) + self._b(0) + + # --- instructions --- + def nop(self): self._b(0x00) + def di(self): self._b(0xF3) + def im1(self): self._b(0xED, 0x56) + def halt(self): self._b(0x76) + def xor_a(self): self._b(0xAF) + def ld_sp(self, n): self._b(0x31); self._w(n) + def ld_a(self, n): self._b(0x3E, n) + def ld_b(self, n): self._b(0x06, n) + def ld_c(self, n): self._b(0x0E, n) + def ld_hl(self, n): self._b(0x21); self._w(n) + def ld_bc(self, n): self._b(0x01); self._w(n) + def ld_a_hl(self): self._b(0x7E) # ld a,(hl) + def ld_a_b(self): self._b(0x78) + def ld_a_c(self): self._b(0x79) + def or_n(self, n): self._b(0xF6, n) + def or_c(self): self._b(0xB1) + def inc_hl(self): self._b(0x23) + def inc_c(self): self._b(0x0C) + def dec_bc(self): self._b(0x0B) + def out_a(self, port): self._b(0xD3, port) # out (port),a + def djnz(self, label): self._b(0x10); self._rel(label) + def jp(self, label): self._b(0xC3); self._w(label) + def jp_nz(self, label): self._b(0xC2); self._w(label) + + def resolve(self) -> bytes: + out = bytearray(self.code) + for pos, label, kind in self.fixups: + target = self.labels[label] + if kind == "abs": + out[pos] = target & 0xFF + out[pos + 1] = (target >> 8) & 0xFF + else: # rel: from the byte AFTER the operand + disp = target - (self.org + pos + 1) + if not -128 <= disp <= 127: + raise ValueError(f"rel jump out of range to {label}") + out[pos] = disp & 0xFF + return bytes(out) diff --git a/lenser/spectrum/__init__.py b/lenser/spectrum/__init__.py new file mode 100644 index 0000000..979aba2 --- /dev/null +++ b/lenser/spectrum/__init__.py @@ -0,0 +1 @@ +"""Sinclair ZX Spectrum target for lenser.""" diff --git a/lenser/spectrum/convert/__init__.py b/lenser/spectrum/convert/__init__.py new file mode 100644 index 0000000..b072cfd --- /dev/null +++ b/lenser/spectrum/convert/__init__.py @@ -0,0 +1,19 @@ +"""Sinclair ZX Spectrum conversion dispatch.""" +from __future__ import annotations + +from ... import imageprep +from . import hires, mono + +_MODULES = {"hires": hires, "mono": mono} +MODES = list(_MODULES.keys()) + + +def convert_image(path_or_img, mode="hires", palette_name="spectrum", + dither_mode="floyd", intensive=False, prep_opt=None, + base_color=None): + prep_opt = prep_opt or imageprep.PrepOptions() + module = _MODULES.get(mode, hires) + img_rgb = imageprep.prepare(path_or_img, hires.WIDTH, hires.HEIGHT, + hires.PIXEL_ASPECT, prep_opt, border_rgb=(0, 0, 0)) + return module.convert(img_rgb, palette_name, dither_mode, intensive, + base_color=base_color) diff --git a/lenser/spectrum/convert/hires.py b/lenser/spectrum/convert/hires.py new file mode 100644 index 0000000..e601725 --- /dev/null +++ b/lenser/spectrum/convert/hires.py @@ -0,0 +1,90 @@ +"""ZX Spectrum image encoder. + +256x192, two colours per 8x8 cell (ink + paper), like C64 hires -- but the two +colours of a cell must share the BRIGHT bit, so each cell's pair is chosen from +one brightness group (normal 0-7 or bright 8-15). For error-diffusion dithers +the pair is picked dither-aware (segment metric) so the two colours bracket the +cell and dithering blends to the true shade; ordered/none use nearest-colour. + +Produces the classic 6912-byte screen: 6144-byte interleaved bitmap + 768 +attribute bytes (FLASH BRIGHT PAPER INK). +""" + +from __future__ import annotations + +import numpy as np + +from ... import dither, palette as c64pal +from ...convert import base +from .. import palette as spal + +WIDTH, HEIGHT = 256, 192 +CELL_W, CELL_H = 8, 8 +PIXEL_ASPECT = 1.0 +N_COLS, N_ROWS = 32, 24 +BITMAP_BYTES = 6144 +ATTR_BYTES = 768 + + +def _select(cells, plab, dither_mode): + """Per cell, pick the best two-colour pair from whichever brightness group + (normal/bright) fits better -- both colours share the BRIGHT bit.""" + if dither_mode in base.DIFFUSION_DITHERS: + seg = base.segment_distances(cells, plab) + sets_n, err_n = base.select_cell_sets_dither( + cells, plab, spal.NORMAL_GROUP, n_free=2, seg=seg) + sets_b, err_b = base.select_cell_sets_dither( + cells, plab, spal.BRIGHT_GROUP, n_free=2, seg=seg) + else: + dist = base.cell_distance(cells, plab) + sets_n, err_n = base.select_cell_sets(dist, spal.NORMAL_GROUP, n_free=2) + sets_b, err_b = base.select_cell_sets(dist, spal.BRIGHT_GROUP, n_free=2) + use_b = err_b < err_n + return np.where(use_b[:, None], sets_b, sets_n) + + +def _bitmap_offset(y: int, cx: int) -> int: + """Offset within the 6144-byte bitmap for pixel row y, char column cx.""" + return ((y & 7) << 8) | ((y & 0x38) << 2) | ((y & 0xC0) << 5) | cx + + +def convert(img_rgb, palette_name="spectrum", dither_mode="floyd", + intensive=False, base_color=None): + plab = spal.palette_lab() + prgb = spal.get_palette().astype(np.uint8) + img_lab = c64pal.srgb_to_lab(img_rgb) + + cells, rows, cols = base.cells_lab(img_lab, CELL_W, CELL_H) + sets = _select(cells, plab, dither_mode) + + allowed = base.per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH) + idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8) + + scr = _encode(idx, sets, rows, cols) + + return base.Conversion( + mode="hires", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=idx.astype(np.uint16), data=bytes(scr), data_addr=0x4000, + viewer="spectrum", preview_rgb=prgb[idx], + error=base.perceptual_error(idx, img_lab, plab), + meta={"palette": "spectrum", "dither": dither_mode}, + ) + + +def _encode(idx, sets, rows, cols): + """Build the 6912-byte screen (interleaved bitmap + attributes).""" + scr = bytearray(BITMAP_BYTES + ATTR_BYTES) + for cy in range(rows): + for cx in range(cols): + ci = cy * cols + cx + paper, ink = int(sets[ci, 0]), int(sets[ci, 1]) + bright = ink >> 3 # both colours share the BRIGHT bit + scr[BITMAP_BYTES + ci] = (bright << 6) | ((paper & 7) << 3) | (ink & 7) + for r in range(8): + y = cy * 8 + r + row = idx[y, cx * 8:cx * 8 + 8] + byte = 0 + for px in range(8): + byte = (byte << 1) | (1 if row[px] == ink else 0) + scr[_bitmap_offset(y, cx)] = byte + return scr diff --git a/lenser/spectrum/convert/mono.py b/lenser/spectrum/convert/mono.py new file mode 100644 index 0000000..622de47 --- /dev/null +++ b/lenser/spectrum/convert/mono.py @@ -0,0 +1,47 @@ +"""ZX Spectrum monochrome / tinted-mono mode. + +256x192 matched by luminance. The Spectrum has no grey, so greyscale is a 2-level +black/white halftone (bright black + bright white) -- which at 256x192 with +dithering is a crisp, high-detail image free of attribute clash. A base colour +gives a 3-level tinted ramp (black -> colour -> white), all within one brightness +group so the per-cell BRIGHT constraint is satisfied. Reuses the hires packing. +""" + +from __future__ import annotations + +import numpy as np + +from ...convert import base +from .. import palette as spal +from . import hires + + +def _ramp(base_color): + """Luminance ramp kept inside ONE brightness group (shared BRIGHT bit).""" + if base_color is None: + return [8, 15] # bright black + bright white + c = base_color & 7 # base hue 0-7 + if c in (0, 7): + return [8, 15] + return [8, 8 | c, 15] # bright black, bright hue, bright white + + +def convert(img_rgb, palette_name="spectrum", dither_mode="atkinson", + intensive=False, base_color=None): + plab = spal.palette_lab() + prgb = spal.get_palette().astype(np.uint8) + ramp = _ramp(base_color) + + idx, sets, rows, cols, err = base.mono_render( + img_rgb, plab, ramp, hires.WIDTH, hires.HEIGHT, + hires.CELL_W, hires.CELL_H, dither_mode, n_free=2) + + scr = hires._encode(idx, sets, rows, cols) + + return base.Conversion( + mode="mono", width=hires.WIDTH, height=hires.HEIGHT, + pixel_aspect=hires.PIXEL_ASPECT, index_image=idx.astype(np.uint16), + data=bytes(scr), data_addr=0x4000, viewer="spectrum", preview_rgb=prgb[idx], + error=err, + meta={"palette": "spectrum", "dither": dither_mode, "base_color": base_color}, + ) diff --git a/lenser/spectrum/exporter.py b/lenser/spectrum/exporter.py new file mode 100644 index 0000000..0e9b331 --- /dev/null +++ b/lenser/spectrum/exporter.py @@ -0,0 +1,32 @@ +"""Export a ZX Spectrum image as a .SNA snapshot (+ a standard .SCR alongside).""" +from __future__ import annotations + +import os + +from . import snapshot + +_EXTS = (".sna", ".scr", ".z80") + + +def export_sna(conv, output_path, source_path=None, display="forever", + seconds=0, video="pal"): + if not output_path.lower().endswith(_EXTS): + output_path += ".sna" + screen = bytes(conv.data) + border = _dominant_paper(screen) + sna = snapshot.build_sna(screen, border=border) + with open(output_path, "wb") as f: + f.write(sna) + # also drop the standard raw-screen .scr next to it (interchange format) + scr_path = os.path.splitext(output_path)[0] + ".scr" + with open(scr_path, "wb") as f: + f.write(snapshot.build_scr(screen)) + return output_path + + +def _dominant_paper(screen: bytes) -> int: + """Most common paper colour across the 768 attribute cells -> border colour.""" + counts = [0] * 8 + for a in screen[6144:6912]: + counts[(a >> 3) & 7] += 1 + return max(range(8), key=lambda c: counts[c]) diff --git a/lenser/spectrum/palette.py b/lenser/spectrum/palette.py new file mode 100644 index 0000000..c171ba0 --- /dev/null +++ b/lenser/spectrum/palette.py @@ -0,0 +1,47 @@ +"""Sinclair ZX Spectrum colour palette. + +The ULA has 8 base colours, each in a normal and a BRIGHT version (component +value 0xD7 normal, 0xFF bright; black is the same in both). Crucially the BRIGHT +bit is per 8x8 *cell*, shared by that cell's ink and paper -- so a cell's two +colours must both be normal or both be bright (they can't mix). We model this as +a 16-entry palette: indices 0-7 = normal, 8-15 = bright, where index & 7 is the +ink/paper colour number and index >> 3 is the BRIGHT bit. +""" + +from __future__ import annotations + +import numpy as np + +from ..palette import srgb_to_lab + +_N = 0xD7 # normal-brightness component +_B = 0xFF # bright component + + +def _ramp(v): + # colour order matches the Spectrum's INK/PAPER numbering 0..7 + return [ + (0, 0, 0), # 0 black + (0, 0, v), # 1 blue + (v, 0, 0), # 2 red + (v, 0, v), # 3 magenta + (0, v, 0), # 4 green + (0, v, v), # 5 cyan + (v, v, 0), # 6 yellow + (v, v, v), # 7 white + ] + + +SPECTRUM = np.array(_ramp(_N) + _ramp(_B), dtype=np.float64) + +# Cell colour pairs must come from one brightness group (shared BRIGHT bit). +NORMAL_GROUP = list(range(0, 8)) +BRIGHT_GROUP = list(range(8, 16)) + + +def get_palette() -> np.ndarray: + return SPECTRUM + + +def palette_lab() -> np.ndarray: + return srgb_to_lab(SPECTRUM) diff --git a/lenser/spectrum/snapshot.py b/lenser/spectrum/snapshot.py new file mode 100644 index 0000000..764b28d --- /dev/null +++ b/lenser/spectrum/snapshot.py @@ -0,0 +1,50 @@ +"""Build a 48K ZX Spectrum .SNA snapshot (and raw .SCR) from a screen image. + +A .SNA bakes the whole 48K RAM plus the CPU state. We put the 6912-byte screen +at $4000, a 3-byte idle stub (DI; JR $) at $8000, and set SP so the loader's +RETN jumps to the stub -- the ULA then displays the screen forever while the CPU +idles, so the picture appears the instant MAME loads the file. + +.SNA 27-byte header: I, HL', DE', BC', AF', HL, DE, BC, IY, IX, IFF2, R, AF, SP, +IM, border; followed by 49152 bytes of RAM ($4000-$FFFF). +""" + +from __future__ import annotations + +import struct + +RAM_BASE = 0x4000 +RAM_SIZE = 0xC000 # 48K ($4000-$FFFF) +SCREEN_LEN = 6912 # 6144 bitmap + 768 attributes +STUB_ADDR = 0x8000 # idle loop DI; JR $ +STUB = bytes([0xF3, 0x18, 0xFE]) +SP_ADDR = 0xFF00 # stack holds the stub address for the loader's RETN + + +def build_sna(screen: bytes, border: int = 0) -> bytes: + if len(screen) != SCREEN_LEN: + raise ValueError(f"screen must be {SCREEN_LEN} bytes, got {len(screen)}") + ram = bytearray(RAM_SIZE) + ram[0x0000:SCREEN_LEN] = screen # $4000 screen + off = STUB_ADDR - RAM_BASE + ram[off:off + len(STUB)] = STUB # $8000 idle stub + sp = SP_ADDR - RAM_BASE # return address for RETN + ram[sp] = STUB_ADDR & 0xFF + ram[sp + 1] = (STUB_ADDR >> 8) & 0xFF + + header = bytearray(27) + header[0x00] = 0x3F # I + # HL',DE',BC',AF',HL,DE,BC,IY,IX all zero + header[0x13] = 0x00 # IFF2 = 0 (interrupts off) + header[0x14] = 0x00 # R + struct.pack_into(" bytes: + """The standard 6912-byte ZX Spectrum screen file (raw $4000 dump).""" + if len(screen) != SCREEN_LEN: + raise ValueError(f"screen must be {SCREEN_LEN} bytes, got {len(screen)}") + return bytes(screen) diff --git a/lenser/ti99/__init__.py b/lenser/ti99/__init__.py new file mode 100644 index 0000000..dc47ffa --- /dev/null +++ b/lenser/ti99/__init__.py @@ -0,0 +1 @@ +"""TI-99/4A (TMS9918A) image conversion and cartridge export.""" diff --git a/lenser/ti99/cartridge.py b/lenser/ti99/cartridge.py new file mode 100644 index 0000000..5c3f501 --- /dev/null +++ b/lenser/ti99/cartridge.py @@ -0,0 +1,92 @@ +"""Builds a TI-99/4A 8KB cartridge ROM (>6000-7FFF) holding the viewer + image +data, and packages it as an RPK (the cartridge container MAME loads). + +ROM layout + >6000 standard cartridge header (>AA magic, pointer to the program list) + >6010 program-list entry -> appears on the TI menu, points at the viewer + .... viewer machine code (TMS9900) + .... image data (6144-byte pattern + 768-byte cell colours) + pad to 8192 bytes +""" + +from __future__ import annotations + +import io +import zipfile + +from . import viewer + +CART_BASE = 0x6000 +ROM_SIZE = 0x2000 # 8 KB +PROG_LIST = 0x6010 + + +def _ascii(name: str, limit: int) -> bytes: + """TI menu names are uppercase ASCII; strip anything else.""" + out = "".join(c for c in name.upper() if 32 <= ord(c) < 127) + return out[:limit].encode("ascii") or b"PHOTO" + + +def _even(x: int) -> int: + return x + (x & 1) + + +def build_rom(data: bytes, title: str = "PHOTO", display: str = "forever", + seconds: int = 0, video: str = "ntsc") -> bytes: + if len(data) != viewer.PATTERN_BYTES + viewer.NCELLS: + raise ValueError(f"unexpected data length {len(data)}") + name = _ascii(title, 16) + + vk = dict(display=display, seconds=seconds, video=video) + entry = _even(PROG_LIST + 2 + 2 + 1 + len(name)) # code starts after the name + code = viewer.build(entry, 0, **vk) # pass 1: measure length + data_addr = _even(entry + len(code)) + code = viewer.build(entry, data_addr, **vk) # pass 2: real data address + + if data_addr - CART_BASE + len(data) > ROM_SIZE: + raise ValueError("image + viewer exceed 8KB cartridge") + + rom = bytearray(b"\x00" * ROM_SIZE) + + def put(addr, payload): + off = addr - CART_BASE + rom[off:off + len(payload)] = payload + + def putw(addr, word): + put(addr, bytes([(word >> 8) & 0xFF, word & 0xFF])) + + # cartridge header + rom[0] = 0xAA # valid + rom[1] = 0x01 # version + putw(0x6006, PROG_LIST) + + # program-list entry (single item) + putw(PROG_LIST + 0, 0x0000) # no next entry + putw(PROG_LIST + 2, entry) # viewer entry point + rom[PROG_LIST + 4 - CART_BASE] = len(name) + put(PROG_LIST + 5, name) + + put(entry, code) + put(data_addr, data) + return bytes(rom) + + +def write_rpk(rom: bytes, path: str, title: str = "photo"): + """Write an MAME RPK (zip with a standard single-ROM layout).""" + binname = "viewer.bin" + layout = ( + '\n' + '\n' + ' \n' + f' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + '\n' + ) + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr(binname, rom) + z.writestr("layout.xml", layout) diff --git a/lenser/ti99/convert/__init__.py b/lenser/ti99/convert/__init__.py new file mode 100644 index 0000000..4de7fef --- /dev/null +++ b/lenser/ti99/convert/__init__.py @@ -0,0 +1,16 @@ +"""TI-99/4A conversion dispatch.""" +from __future__ import annotations +from ... import imageprep +from . import gm2, mono + +_MODULES = {"gm2": gm2, "mono": mono} +MODES = list(_MODULES.keys()) + + +def convert_image(path_or_img, mode="gm2", palette_name="tms9918", + dither_mode="floyd", intensive=False, prep_opt=None, base_color=None): + prep_opt = prep_opt or imageprep.PrepOptions() + module = _MODULES.get(mode, gm2) + 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) diff --git a/lenser/ti99/convert/gm2.py b/lenser/ti99/convert/gm2.py new file mode 100644 index 0000000..2e020ef --- /dev/null +++ b/lenser/ti99/convert/gm2.py @@ -0,0 +1,72 @@ +"""TI-99/4A Graphics Mode 2: 256x192, 2 colours per 8x8 cell (15-colour palette). + +Like C64 hires but on the TMS9918A. Produces the bitmap pattern table (6144 B) +and one colour byte per cell (768 B); the cartridge viewer expands each cell's +colour across its 8 rows of the VDP colour table. +""" + +from __future__ import annotations + +import numpy as np + +from ... import dither, palette as c64pal +from ...convert import base +from .. import palette as tpal + +WIDTH, HEIGHT = 256, 192 +CELL_W, CELL_H = 8, 8 +PIXEL_ASPECT = 1.0 +N_COLS, N_ROWS = 32, 24 + + +def convert(img_rgb, palette_name="tms9918", dither_mode="floyd", + intensive=False, base_color=None): + plab = tpal.palette_lab() + prgb = tpal.get_palette().astype(np.uint8) + img_lab = c64pal.srgb_to_lab(img_rgb) + + cells, rows, cols = base.cells_lab(img_lab, CELL_W, CELL_H) + # Dither-aware colour selection for error-diffusion modes -- each cell's two + # colours are chosen so the segment between them brackets the cell, letting + # dithering blend to the true shade (far smoother than nearest-colour, which + # bands). Ordered/none keep plain nearest-colour selection. + if dither_mode in base.DIFFUSION_DITHERS: + sets, _ = base.select_cell_sets_dither(cells, plab, tpal.USABLE, n_free=2) + else: + dist = base.cell_distance(cells, plab) + sets, _ = base.select_cell_sets(dist, tpal.USABLE, n_free=2) + + allowed = base.per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH) + idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8) + + pattern, colors = _encode(idx, sets, rows, cols) + data = bytes(pattern) + bytes(colors) # 6144 + 768 + + return base.Conversion( + mode="gm2", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=idx.astype(np.uint16), data=data, data_addr=0, + viewer="gm2", preview_rgb=prgb[idx], + error=base.perceptual_error(idx, img_lab, plab), + meta={"palette": "tms9918", "dither": dither_mode}, + ) + + +def _encode(idx, sets, rows, cols): + pattern = np.zeros(6144, dtype=np.uint8) # 768 cells x 8 rows + colors = np.zeros(768, dtype=np.uint8) # 1 colour byte per cell + for cr in range(rows): + for cc in range(cols): + ci = cr * cols + cc + c0, c1 = int(sets[ci, 0]), int(sets[ci, 1]) + # brighter colour = foreground (bit 1); store fg in high nibble. + bg, fg = (c0, c1) + colors[ci] = ((fg & 0x0F) << 4) | (bg & 0x0F) + block = idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8] + base = ci * 8 + for r in range(8): + row = block[r] + byte = 0 + for x in range(8): + byte = (byte << 1) | (1 if row[x] == fg else 0) + pattern[base + r] = byte + return pattern, colors diff --git a/lenser/ti99/convert/mono.py b/lenser/ti99/convert/mono.py new file mode 100644 index 0000000..a6ad7db --- /dev/null +++ b/lenser/ti99/convert/mono.py @@ -0,0 +1,44 @@ +"""TI-99/4A (TMS9918A) monochrome / tinted-mono mode. + +Same 256x192, 2-colours-per-cell format as gm2, but matched by *luminance* to a +grey ramp (black -> grey -> white) so every cell is neutral -- no colour clash, +maximum perceived detail, a clean greyscale photo. Pick a base colour for a +tinted monochrome instead. Reuses the gm2 byte packing and viewer. +""" + +from __future__ import annotations + +import numpy as np + +from ... import palette as c64pal +from ...convert import base +from .. import palette as tpal +from . import gm2 + +WIDTH, HEIGHT = gm2.WIDTH, gm2.HEIGHT +CELL_W, CELL_H = gm2.CELL_W, gm2.CELL_H +PIXEL_ASPECT = gm2.PIXEL_ASPECT + +# Neutral ramp: black(1), grey(14), white(15). Lighter siblings for tinting. +NEUTRAL = [1, 14, 15] +SIBLINGS = {2: 3, 3: 2, 4: 5, 5: 4, 6: 9, 8: 9, 9: 8, 12: 14, 13: 9} + + +def convert(img_rgb, palette_name="tms9918", dither_mode="atkinson", + intensive=False, base_color=None): + plab = tpal.palette_lab() + prgb = tpal.get_palette().astype(np.uint8) + ramp = base.luminance_ramp(plab, NEUTRAL, base_color, SIBLINGS) + + idx, sets, rows, cols, err = base.mono_render( + img_rgb, plab, ramp, WIDTH, HEIGHT, CELL_W, CELL_H, dither_mode, n_free=2) + + pattern, colors = gm2._encode(idx, sets, rows, cols) + data = bytes(pattern) + bytes(colors) + + return base.Conversion( + mode="mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=idx.astype(np.uint16), data=data, data_addr=0, + viewer="gm2", preview_rgb=prgb[idx], error=err, + meta={"palette": "tms9918", "dither": dither_mode, "base_color": base_color}, + ) diff --git a/lenser/ti99/exporter.py b/lenser/ti99/exporter.py new file mode 100644 index 0000000..2952c30 --- /dev/null +++ b/lenser/ti99/exporter.py @@ -0,0 +1,15 @@ +"""Build a TI-99/4A cartridge (.rpk) from a conversion.""" +from __future__ import annotations +import os +from . import cartridge + + +def export_rpk(conv, output_path, source_path=None, display="forever", + seconds=0, video="ntsc"): + if not output_path.lower().endswith(".rpk"): + output_path += ".rpk" + title = os.path.splitext(os.path.basename(source_path or output_path))[0] + rom = cartridge.build_rom(conv.data, title, display=display, + seconds=seconds, video=video) + cartridge.write_rpk(rom, output_path, title) + return output_path diff --git a/lenser/ti99/palette.py b/lenser/ti99/palette.py new file mode 100644 index 0000000..0480b9b --- /dev/null +++ b/lenser/ti99/palette.py @@ -0,0 +1,39 @@ +"""TI-99/4A TMS9918A Video Display Processor palette (15 colours + transparent).""" + +from __future__ import annotations + +import numpy as np + +from ..palette import srgb_to_lab + +# TMS9918A colours, index 0 = transparent (we never use it for pixels). +# Common measured RGB values. +TMS9918 = np.array([ + (0x00, 0x00, 0x00), # 0 transparent (treated as black for matching) + (0x00, 0x00, 0x00), # 1 black + (0x21, 0xc8, 0x42), # 2 medium green + (0x5e, 0xdc, 0x78), # 3 light green + (0x54, 0x55, 0xed), # 4 dark blue + (0x7d, 0x76, 0xfc), # 5 light blue + (0xd4, 0x52, 0x4d), # 6 dark red + (0x42, 0xeb, 0xf5), # 7 cyan + (0xfc, 0x55, 0x54), # 8 medium red + (0xff, 0x79, 0x78), # 9 light red + (0xd4, 0xc1, 0x54), # 10 dark yellow + (0xe6, 0xce, 0x80), # 11 light yellow + (0x21, 0xb0, 0x3b), # 12 dark green + (0xc9, 0x5b, 0xba), # 13 magenta + (0xcc, 0xcc, 0xcc), # 14 grey + (0xff, 0xff, 0xff), # 15 white +], dtype=np.float64) + +# Palette indices usable as pixel colours (1..15). +USABLE = list(range(1, 16)) + + +def get_palette() -> np.ndarray: + return TMS9918 + + +def palette_lab() -> np.ndarray: + return srgb_to_lab(TMS9918) diff --git a/lenser/ti99/tms9900.py b/lenser/ti99/tms9900.py new file mode 100644 index 0000000..cf71313 --- /dev/null +++ b/lenser/ti99/tms9900.py @@ -0,0 +1,87 @@ +"""A tiny TMS9900 machine-code emitter (just the instructions the TI-99 viewer +needs). Words are big-endian. Supports labels + relative-jump backpatching so +the viewer's loops can be written readably. + +This is the TI analogue of using `xa` for the 6502 viewers, but since no TMS9900 +assembler is installed we emit the opcodes directly. +""" + +from __future__ import annotations + + +class Asm: + def __init__(self, base: int): + self.base = base + self.code = bytearray() + self.labels: dict[str, int] = {} + self._jfix: list[tuple[int, str]] = [] # (pos, label) for 8-bit jump disp + + # ---- low level ---- + def pos(self) -> int: + return self.base + len(self.code) + + def w(self, word: int): + self.code += bytes([(word >> 8) & 0xFF, word & 0xFF]) # big-endian + + def label(self, name: str): + self.labels[name] = self.pos() + + # ---- immediate-format (format VIII) ---- + def li(self, reg, imm): self.w(0x0200 | reg); self.w(imm & 0xFFFF) + def ai(self, reg, imm): self.w(0x0220 | reg); self.w(imm & 0xFFFF) + def ci(self, reg, imm): self.w(0x0280 | reg); self.w(imm & 0xFFFF) + def limi(self, imm): self.w(0x0300); self.w(imm & 0xFFFF) + def lwpi(self, imm): self.w(0x02E0); self.w(imm & 0xFFFF) + + # ---- single-register (format VI) ---- + def clr(self, reg): self.w(0x04C0 | reg) + def inc(self, reg): self.w(0x0580 | reg) + def inct(self, reg): self.w(0x05C0 | reg) + def dec(self, reg): self.w(0x0600 | reg) + def dect(self, reg): self.w(0x0640 | reg) + def swpb(self, reg): self.w(0x06C0 | reg) + + # ---- two-operand (format I); modes: 0=reg,1=*reg,2=@addr(reg),3=*reg+ ---- + def _fmt1(self, base, td, dreg, ts, sreg, saddr=None, daddr=None): + self.w(base | ((td & 3) << 10) | ((dreg & 15) << 6) + | ((ts & 3) << 4) | (sreg & 15)) + if ts == 2: + self.w(saddr & 0xFFFF) + if td == 2: + self.w(daddr & 0xFFFF) + + def mov_rr(self, s, d): self._fmt1(0xC000, 0, d, 0, s) + def movb_r_sym(self, s, addr): self._fmt1(0xD000, 2, 0, 0, s, daddr=addr) + def movb_sym_r(self, addr, d): self._fmt1(0xD000, 0, d, 2, 0, saddr=addr) + def movb_sinc_sym(self, s, addr): self._fmt1(0xD000, 2, 0, 3, s, daddr=addr) + def movb_sinc_r(self, s, d): self._fmt1(0xD000, 0, d, 3, s) + def movb_r_r(self, s, d): self._fmt1(0xD000, 0, d, 0, s) + + # ---- immediate logic / context switch ---- + def andi(self, reg, imm): self.w(0x0240 | reg); self.w(imm & 0xFFFF) + def ori(self, reg, imm): self.w(0x0260 | reg); self.w(imm & 0xFFFF) + def blwp_sym(self, addr): self.w(0x0420); self.w(addr & 0xFFFF) # BLWP @addr + + # ---- CRU (keyboard scan): R12 holds the CRU base ---- + def ldcr(self, reg, count): self.w(0x3000 | ((count & 15) << 6) | reg) + def stcr(self, reg, count): self.w(0x3400 | ((count & 15) << 6) | reg) + + # ---- jumps (format II), 8-bit signed displacement ---- + def _jump(self, opbase, label): + self._jfix.append((len(self.code), label)) + self.w(opbase) # disp filled in by resolve() + + def jmp(self, label): self._jump(0x1000, label) + def jeq(self, label): self._jump(0x1300, label) + def jne(self, label): self._jump(0x1600, label) + + # ---- finish ---- + def resolve(self) -> bytes: + for pos, label in self._jfix: + target = self.labels[label] + here = self.base + pos # address of the jump word + disp = (target - (here + 2)) // 2 + if not -128 <= disp <= 127: + raise ValueError(f"jump to {label} out of range ({disp})") + self.code[pos + 1] = disp & 0xFF + return bytes(self.code) diff --git a/lenser/ti99/viewer.py b/lenser/ti99/viewer.py new file mode 100644 index 0000000..fea6faa --- /dev/null +++ b/lenser/ti99/viewer.py @@ -0,0 +1,116 @@ +"""Generates the TI-99/4A cartridge viewer (TMS9900 machine code). + +Sets the TMS9918A to Graphics Mode 2 (256x192 bitmap), builds the name table, +copies the 6144-byte pattern from cartridge ROM to VRAM >0000, and expands the +768 per-cell colour bytes x8 into the 6144-byte colour table at VRAM >2000. +""" + +from __future__ import annotations + +from .tms9900 import Asm + +VDP_DATA = 0x8C00 # write VRAM data +VDP_CTRL = 0x8C02 # write address / register +VDP_STATUS = 0x8802 # read VDP status (bit 7 = frame flag, cleared on read) +WORKSPACE = 0x8300 # scratchpad RAM + +# VDP register values for Graphics Mode 2. +VDP_REGS = [ + 0x02, # R0 M3=1 (bitmap) + 0xC0, # R1 16K + display on, interrupts off + 0x0E, # R2 name table >3800 + 0xFF, # R3 colour table >2000 (full 768 rows) + 0x03, # R4 pattern table >0000 (full 768 rows) + 0x36, # R5 sprite attr >1B00 (parked) + 0x07, # R6 sprite patt >3800 (parked) + 0x01, # R7 backdrop = black +] + +PATTERN_BYTES = 6144 +NCELLS = 768 + + +def _set_write_addr(a: Asm, addr: int): + """Point the VDP write address at VRAM `addr` (low byte then high|>40).""" + a.li(0, (addr & 0xFF) << 8); a.movb_r_sym(0, VDP_CTRL) + a.li(0, (((addr >> 8) | 0x40) & 0xFF) << 8); a.movb_r_sym(0, VDP_CTRL) + + +def build(code_base: int, data_addr: int, display: str = "forever", + seconds: int = 0, video: str = "ntsc") -> bytes: + """Emit the viewer. `data_addr` = ROM address of the 6912-byte image data. + + `display` (forever/key/seconds) chooses how long the picture is held; on + key/seconds the console is reset (BLWP @>0000) back to the TI title screen. + """ + a = Asm(code_base) + a.limi(0x0000) # interrupts off + a.lwpi(WORKSPACE) # our register file in scratchpad + + # ---- programme the 8 VDP registers ---- + for reg, val in enumerate(VDP_REGS): + a.li(0, val << 8) + a.movb_r_sym(0, VDP_CTRL) + a.li(0, (0x80 | reg) << 8) + a.movb_r_sym(0, VDP_CTRL) + + # ---- name table >3800 = 0,1,...,255 repeated three times (768 bytes) ---- + _set_write_addr(a, 0x3800) + a.clr(3) # R3 high byte = current value + a.li(2, NCELLS) # 768 entries + a.label("nameloop") + a.movb_r_sym(3, VDP_DATA) + a.ai(3, 0x0100) # value += 1 (in high byte, wraps mod 256) + a.dec(2) + a.jne("nameloop") + + # ---- pattern table >0000 = 6144 bytes copied from ROM ---- + _set_write_addr(a, 0x0000) + a.li(1, data_addr) + a.li(2, PATTERN_BYTES) + a.label("patloop") + a.movb_sinc_sym(1, VDP_DATA) + a.dec(2) + a.jne("patloop") + + # ---- colour table >2000 = each cell colour written 8x (768 -> 6144) ---- + _set_write_addr(a, 0x2000) + a.li(1, data_addr + PATTERN_BYTES) + a.li(2, NCELLS) + a.label("colloop") + a.movb_sinc_r(1, 4) # R4 high byte = colour byte, advance ROM ptr + for _ in range(8): + a.movb_r_sym(4, VDP_DATA) + a.dec(2) + a.jne("colloop") + + # ---- how long to hold the picture ---- + if display == "seconds": + rate = 50 if video == "pal" else 60 + frames = max(1, min(0xFFFF, int(seconds) * rate)) + a.li(2, frames) # R2 = frames to wait + a.label("fwait") + a.movb_sym_r(VDP_STATUS, 1) # read status (clears frame flag) + a.andi(1, 0x8000) # bit 7 = a new frame elapsed + a.jeq("fwait") + a.dec(2) + a.jne("fwait") + a.blwp_sym(0x0000) # reset -> TI title screen + elif display == "key": + # scan keyboard columns 0..5; any pressed key (a row reads 0) -> reset. + a.label("kscan") + for col in range(6): + a.li(12, 0x0024) # CRU base for the column select + a.li(5, col << 8) # column in the low 3 bits of the high byte + a.ldcr(5, 3) # drive the 3 column-select lines + a.li(12, 0x0006) # CRU base for the 8 row inputs + a.stcr(6, 8) # read rows into R6 high byte (idle = >FF) + a.ci(6, 0xFF00) # any key down makes a row 0 + a.jne("kdone") + a.jmp("kscan") + a.label("kdone") + a.blwp_sym(0x0000) # reset -> TI title screen + else: # forever + a.label("halt") + a.jmp("halt") + return a.resolve() diff --git a/lenser/vic20/__init__.py b/lenser/vic20/__init__.py new file mode 100644 index 0000000..dd8802e --- /dev/null +++ b/lenser/vic20/__init__.py @@ -0,0 +1 @@ +"""Commodore VIC-20 target for lenser.""" diff --git a/lenser/vic20/convert/__init__.py b/lenser/vic20/convert/__init__.py new file mode 100644 index 0000000..3898f97 --- /dev/null +++ b/lenser/vic20/convert/__init__.py @@ -0,0 +1,19 @@ +"""Commodore VIC-20 conversion dispatch.""" +from __future__ import annotations + +from ... import imageprep +from . import hires, mono, multicolor + +_MODULES = {"multicolor": multicolor, "hires": hires, "mono": mono} +MODES = list(_MODULES.keys()) + + +def convert_image(path_or_img, mode="multicolor", palette_name="vic", + dither_mode="floyd", intensive=False, prep_opt=None, + base_color=None): + mod = _MODULES.get(mode, multicolor) + prep_opt = prep_opt or imageprep.PrepOptions() + img_rgb = imageprep.prepare(path_or_img, mod.WIDTH, mod.HEIGHT, + mod.PIXEL_ASPECT, prep_opt, border_rgb=(0, 0, 0)) + return mod.convert(img_rgb, palette_name, dither_mode, intensive, + base_color=base_color) diff --git a/lenser/vic20/convert/hires.py b/lenser/vic20/convert/hires.py new file mode 100644 index 0000000..9398a0a --- /dev/null +++ b/lenser/vic20/convert/hires.py @@ -0,0 +1,171 @@ +"""VIC-20 hi-res image encoder. + +The VIC-20 has no bitmap mode -- images are drawn from a programmable character +set. In hi-res text mode each 8x8 cell shows a GLOBAL background colour (the +0-bits) and one per-cell FOREGROUND colour (the 1-bits, from colour RAM, limited +to colours 0-7). A screen of 22x23 = 506 cells (176x184 px) needs at most 256 +distinct 8x8 patterns (a char index is one byte), so the cell bitmaps are +clustered to 256 representative characters (k-means), exactly like the +Intellivision GRAM dictionary. + +Model: global bg (0-15) + per-cell fg (0-7) + 256-char dictionary. For +error-diffusion dithers the colour choices are dither-aware (segment metric), so +bg and fg bracket each cell and dithering blends to the true shade. + +Conversion.data carries the bytes the cartridge builder needs: + chardata 2048 bytes (256 chars x 8 rows, copied to $1400) + screen 506 bytes (char indices, copied to $1E00) + color 506 bytes (colour RAM nibbles, fg in low 3 bits, copied to $9600) + bg, border, aux global colour registers +""" + +from __future__ import annotations + +import numpy as np + +from ... import dither, palette as c64pal +from ...convert import base +from .. import palette as vpal + +WIDTH, HEIGHT = 176, 184 +PIXEL_ASPECT = 1.2 # 176x184 on a ~4:3 screen -> pixels a touch tall +CELL_W, CELL_H = 8, 8 +N_COLS, N_ROWS = 22, 23 +N_CELLS = N_COLS * N_ROWS # 506 +N_CHARS = 256 +N_FG = 8 # foreground limited to colours 0-7 + + +def _select_global_bg(cells, plab, seg, candidates): + """Choose the global background (0-15) + per-cell fg (0-7) that minimise total + dither-aware error. Returns (bg, sets) where sets[ci] = [bg, fg].""" + best_total = np.inf + best = None + for bg in candidates: + avail = [c for c in range(N_FG) if c != bg] + sets, errors = base.select_cell_sets_dither( + cells, plab, avail, n_free=1, fixed=(bg,), seg=seg) + total = errors.sum() + if total < best_total: + best_total = total + best = (bg, sets) + return best + + +def _select_global_bg_nearest(dist, candidates): + """Nearest-colour variant (ordered/none dithers).""" + best_total = np.inf + best = None + for bg in candidates: + avail = [c for c in range(N_FG) if c != bg] + sets, errors = base.select_cell_sets(dist, avail, n_free=1, fixed=(bg,)) + total = errors.sum() + if total < best_total: + best_total = total + best = (bg, sets) + return best + + +def _cluster_chars(bitmaps, k=N_CHARS, iters=16, seed=0): + """k-means on 64-dim {0,1} cell bitmaps -> k binary representative chars.""" + pats = bitmaps.astype(np.float64) + uniq, counts = np.unique(bitmaps, axis=0, return_counts=True) + if len(uniq) <= k: + chars = np.zeros((k, 64), np.uint8) + chars[:len(uniq)] = uniq + lut = {tuple(p): i for i, p in enumerate(uniq)} + labels = np.array([lut[tuple(b)] for b in bitmaps]) + return chars, labels + order = np.argsort(-counts)[:k] + cent = uniq[order].astype(np.float64) + rng = np.random.default_rng(seed) + for _ in range(iters): + d = ((pats[:, None, :] - cent[None, :, :]) ** 2).sum(-1) + labels = d.argmin(1) + for j in range(k): + msk = labels == j + if msk.any(): + cent[j] = pats[msk].mean(0) + else: + cent[j] = pats[rng.integers(len(pats))] + chars = (cent >= 0.5).astype(np.uint8) + d = ((pats[:, None, :] - chars[None, :, :].astype(np.float64)) ** 2).sum(-1) + labels = d.argmin(1) + return chars, labels + + +def convert(img_rgb, palette_name="vic", dither_mode="floyd", + intensive=False, base_color=None): + plab = vpal.palette_lab() + prgb = vpal.get_palette().astype(np.uint8) + img_lab = c64pal.srgb_to_lab(img_rgb) + + cells, rows, cols = base.cells_lab(img_lab, CELL_W, CELL_H) + dist = base.cell_distance(cells, plab) + + bg_candidates = range(16) if intensive else vpal.BG_USABLE + if dither_mode in base.DIFFUSION_DITHERS: + seg = base.segment_distances(cells, plab) + bg, sets = _select_global_bg(cells, plab, seg, bg_candidates) + else: + bg, sets = _select_global_bg_nearest(dist, bg_candidates) + + fg = sets[:, 1].astype(np.int64) + + # dither each cell between the global bg and its fg (order [bg, fg]) + allowed = base.per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH) + idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8) + + bitmaps = np.zeros((N_CELLS, 64), np.uint8) + for cr in range(rows): + for cc in range(cols): + ci = cr * cols + cc + block = idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8] + bitmaps[ci] = (block == fg[ci]).astype(np.uint8).reshape(-1) + + chars, labels = _cluster_chars(bitmaps) + + # Re-optimise the per-cell fg for the FINAL (clustered) pattern -- the shared + # char's fg pixels may want a different colour than the pre-clustering pick. + for ci in range(N_CELLS): + P = chars[labels[ci]].astype(bool) # 64 px, True = fg + if P.any(): + fg[ci] = int(dist[ci][P][:, :N_FG].sum(0).argmin()) + + # build outputs + an exact preview + prev_idx = np.empty((HEIGHT, WIDTH), np.uint8) + screen = np.zeros(N_CELLS, np.uint8) + color = np.zeros(N_CELLS, np.uint8) + for ci in range(N_CELLS): + cr, cc = divmod(ci, cols) + ch = chars[labels[ci]].reshape(8, 8) + f = int(fg[ci]) + prev_idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8] = np.where(ch == 1, f, bg) + screen[ci] = labels[ci] & 0xFF + color[ci] = f & 0x07 + + chardata = np.zeros(N_CHARS * 8, np.uint8) + for t in range(N_CHARS): + rb = chars[t].reshape(8, 8) + for r in range(8): + byte = 0 + for x in range(8): + byte = (byte << 1) | int(rb[r, x]) + chardata[t * 8 + r] = byte + + border = bg if bg < 8 else 0 + data = {"chardata": chardata, "screen": screen, "color": color, + "bg": int(bg), "border": int(border), "aux": 0} + + preview = prgb[prev_idx] + disp_w = int(round(WIDTH * PIXEL_ASPECT)) + xs = (np.arange(disp_w) * WIDTH) // disp_w + preview = preview[:, xs] + + return base.Conversion( + mode="hires", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=prev_idx.astype(np.uint16), data=data, data_addr=0, + viewer="hires", preview_rgb=preview, + error=base.perceptual_error(prev_idx, img_lab, plab), + meta={"palette": "vic", "dither": dither_mode, "bg": int(bg)}, + ) diff --git a/lenser/vic20/convert/mono.py b/lenser/vic20/convert/mono.py new file mode 100644 index 0000000..d359e46 --- /dev/null +++ b/lenser/vic20/convert/mono.py @@ -0,0 +1,83 @@ +"""Commodore VIC-20 monochrome / tinted-mono mode. + +176x184 hi-res matched by luminance: a single global background (black) and one +foreground colour (white, or a tinted base) used by every cell, so the picture is +carried entirely by the custom character shapes + dithering -- a clean two-tone +image with no per-cell colour budget to spend. Reuses the hires char clustering, +data layout and viewer. +""" + +from __future__ import annotations + +import numpy as np + +from ... import palette as c64pal +from ...convert import base +from .. import palette as vpal +from . import hires + +WIDTH, HEIGHT, PIXEL_ASPECT = hires.WIDTH, hires.HEIGHT, hires.PIXEL_ASPECT + + +def convert(img_rgb, palette_name="vic", dither_mode="floyd", + intensive=False, base_color=None): + plab = vpal.palette_lab() + prgb = vpal.get_palette().astype(np.uint8) + + bg = 0 # black background + fg = base_color if base_color in range(1, 8) else 1 # white (or tinted) + ramp = sorted([bg, fg], key=lambda i: plab[i, 0]) + + idx, sets, rows, cols, err = base.mono_render( + img_rgb, plab, ramp, hires.WIDTH, hires.HEIGHT, + hires.CELL_W, hires.CELL_H, dither_mode, n_free=2) + + # cell bitmaps (1 = foreground) reduced to the 256-char dictionary by a + # frequency codebook -- keeps the dithered detail (k-means centroids would + # invent near-solid 'average' chars -> block artefacts). + bitmaps = np.zeros((hires.N_CELLS, 64), np.uint8) + for cr in range(rows): + for cc in range(cols): + ci = cr * cols + cc + block = idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8] + bitmaps[ci] = (block == fg).astype(np.uint8).reshape(-1) + chars, labels = base.mono_codebook(bitmaps, hires.N_CHARS) + + img_mono = np.zeros((hires.HEIGHT, hires.WIDTH, 3)) + img_mono[..., 0] = c64pal.srgb_to_lab(img_rgb)[..., 0] + plab_mono = np.zeros_like(plab) + plab_mono[:, 0] = plab[:, 0] + + prev_idx = np.empty((hires.HEIGHT, hires.WIDTH), np.uint8) + screen = np.zeros(hires.N_CELLS, np.uint8) + color = np.zeros(hires.N_CELLS, np.uint8) + for ci in range(hires.N_CELLS): + cr, cc = divmod(ci, cols) + ch = chars[labels[ci]].reshape(8, 8) + prev_idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8] = np.where(ch == 1, fg, bg) + screen[ci] = labels[ci] & 0xFF + color[ci] = fg & 0x07 + chardata = np.zeros(hires.N_CHARS * 8, np.uint8) + for t in range(hires.N_CHARS): + rb = chars[t].reshape(8, 8) + for r in range(8): + byte = 0 + for x in range(8): + byte = (byte << 1) | int(rb[r, x]) + chardata[t * 8 + r] = byte + + data = {"chardata": chardata, "screen": screen, "color": color, + "bg": int(bg), "border": 0, "aux": 0} + + preview = prgb[prev_idx] + disp_w = int(round(hires.WIDTH * hires.PIXEL_ASPECT)) + xs = (np.arange(disp_w) * hires.WIDTH) // disp_w + preview = preview[:, xs] + + return base.Conversion( + mode="mono", width=hires.WIDTH, height=hires.HEIGHT, + pixel_aspect=hires.PIXEL_ASPECT, index_image=prev_idx.astype(np.uint16), + data=data, data_addr=0, viewer="hires", preview_rgb=preview, + error=base.perceptual_error(prev_idx, img_mono, plab_mono), + meta={"palette": "vic", "dither": dither_mode, "base_color": base_color}, + ) diff --git a/lenser/vic20/convert/multicolor.py b/lenser/vic20/convert/multicolor.py new file mode 100644 index 0000000..04cc696 --- /dev/null +++ b/lenser/vic20/convert/multicolor.py @@ -0,0 +1,241 @@ +"""VIC-20 multicolor image encoder. + +Multicolor character mode (colour RAM bit 3 set) gives each 4x8 cell FOUR colours +at half horizontal resolution. Three are GLOBAL registers and one is per-cell: + + 00 -> background ($900F bits 4-7, any of 16) global + 01 -> border ($900F bits 0-2, colours 0-7) global + 10 -> foreground (colour RAM low 3 bits, 0-7) per cell + 11 -> auxiliary ($900E bits 4-7, any of 16) global + +So the warm tones (orange/pink, only available as colours 8-15) can be used as +the global background/auxiliary -- exactly what hi-res can't do -- which makes +this the strong photo mode. We pick the three globals by a dither-aware search +(restricted to the image's relevant colours for speed), then the best per-cell +foreground, dither each cell among its four colours, and cluster the resulting +4x8 role patterns to a 256-character set. + +Conversion.data: chardata(2048) screen(506) color(506) bg/border/aux globals. +""" + +from __future__ import annotations + +import numpy as np + +from ... import dither, palette as c64pal +from ...convert import base +from .. import palette as vpal + +WIDTH, HEIGHT = 88, 184 # 22x23 cells of 4x8 (double-wide pixels) +PIXEL_ASPECT = 2.4 # logical pixel is 2x wide x the 1.2 cell aspect +CELL_W, CELL_H = 4, 8 +N_COLS, N_ROWS = 22, 23 +N_CELLS = N_COLS * N_ROWS # 506 +N_CHARS = 256 +N_FG = 8 # per-cell foreground limited to colours 0-7 + + +def _relevant(cells, plab, k=8): + """The k palette colours nearest the most cell pixels (search-space prune).""" + d = base.cell_distance(cells, plab) # (n, P, 16) + nearest = d.reshape(-1, 16).argmin(1) + counts = np.bincount(nearest, minlength=16) + return list(np.argsort(-counts)[:k]) + + +def _global_shortlist(cells, plab, seg, dist, cand, dither_aware): + """Rank every (bg, aux, border) candidate by total per-cell selection error and + return them sorted best-first as (bg, border, aux, sets) tuples. The segment + metric is a fast proxy but mis-ranks the globals (it over-credits blends that + actually muddy), so the caller re-scores the top few by real perceptual error.""" + bcand = [c for c in cand if c < 8] or list(range(8)) + out = [] + seen = set() + for bg in cand: + for aux in cand: + for border in bcand: + key = (bg, aux, border) + if key in seen: + continue + seen.add(key) + if dither_aware: + sets, errors = base.select_cell_sets_dither( + cells, plab, range(N_FG), n_free=1, + fixed=(bg, border, aux), seg=seg) + else: + sets, errors = base.select_cell_sets( + dist, range(N_FG), n_free=1, fixed=(bg, border, aux)) + out.append((errors.sum(), int(bg), int(border), int(aux), sets)) + out.sort(key=lambda t: t[0]) + return [(bg, border, aux, sets) for _, bg, border, aux, sets in out] + + +def _role_sets(sets): + """Reorder selection [bg, border, aux, fg] -> role order [bg, border, fg, aux] + (roles 0=bg 1=border 2=fg 3=aux for the 2-bit pixel code).""" + return np.stack([sets[:, 0], sets[:, 1], sets[:, 3], sets[:, 2]], axis=1) + + +def _role_dither(sets, rows, cols, img_lab, plab, dither_mode): + """Dither the image among each cell's four colours; returns (idx, role_sets).""" + role_sets = _role_sets(sets) + allowed = base.per_pixel_allowed(role_sets, rows, cols, CELL_W, CELL_H, + HEIGHT, WIDTH) + idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8) + return idx, role_sets + + +def _nearest_perr(role_sets, rows, cols, img_lab, plab): + """Fast global-ranking proxy: perceptual error of the NEAREST-colour (no + dithering) reconstruction. Vectorised, so all candidate globals can be ranked + cheaply; floyd is too slow to run on every combo.""" + allowed = base.per_pixel_allowed(role_sets, rows, cols, CELL_W, CELL_H, + HEIGHT, WIDTH) + colors = plab[allowed] + d = ((img_lab[:, :, None, :] - colors) ** 2).sum(-1) + ch = d.argmin(-1) + idx = np.take_along_axis(allowed, ch[..., None], axis=-1)[..., 0] + return base.perceptual_error(idx, img_lab, plab) + + +def _onehot(patterns): + """(n, 32) role codes 0..3 -> (n, 128) one-hot, so clustering compares role + IDENTITY (0 vs 3 are equally different) rather than numeric code proximity.""" + n, m = patterns.shape + oh = np.zeros((n, m, 4), np.float64) + oh[np.arange(n)[:, None], np.arange(m)[None, :], patterns] = 1.0 + return oh.reshape(n, m * 4) + + +def _cluster_chars(patterns, k=N_CHARS, iters=16, seed=0): + """k-means on one-hot role patterns -> k representative chars (codes 0..3).""" + m = patterns.shape[1] + uniq, counts = np.unique(patterns, axis=0, return_counts=True) + if len(uniq) <= k: + chars = np.zeros((k, m), np.uint8) + chars[:len(uniq)] = uniq + lut = {tuple(p): i for i, p in enumerate(uniq)} + labels = np.array([lut[tuple(b)] for b in patterns]) + return chars, labels + oh = _onehot(patterns) # (n, 128) + oh_uniq = _onehot(uniq) + order = np.argsort(-counts)[:k] + cent = oh_uniq[order].copy() + rng = np.random.default_rng(seed) + for _ in range(iters): + d = ((oh[:, None, :] - cent[None, :, :]) ** 2).sum(-1) + labels = d.argmin(1) + for j in range(k): + msk = labels == j + if msk.any(): + cent[j] = oh[msk].mean(0) + else: + cent[j] = oh[rng.integers(len(oh))] + # representative char = argmax role per pixel + chars = cent.reshape(k, m, 4).argmax(-1).astype(np.uint8) + d = ((oh[:, None, :] - _onehot(chars)[None, :, :]) ** 2).sum(-1) + labels = d.argmin(1) + return chars, labels + + +def convert(img_rgb, palette_name="vic", dither_mode="floyd", + intensive=False, base_color=None): + plab = vpal.palette_lab() + prgb = vpal.get_palette().astype(np.uint8) + img_lab = c64pal.srgb_to_lab(img_rgb) + + cells, rows, cols = base.cells_lab(img_lab, CELL_W, CELL_H) + k = 12 if intensive else 8 + cand = _relevant(cells, plab, k) + dither_aware = dither_mode in base.DIFFUSION_DITHERS + + dist = base.cell_distance(cells, plab) + seg = base.segment_distances(cells, plab) if dither_aware else None + shortlist = _global_shortlist(cells, plab, seg, dist, cand, dither_aware) + + # Rank every candidate by a fast nearest-colour perceptual proxy (floyd is too + # slow to run on all ~hundreds of global combos), then run the FULL pipeline + # (floyd + char clustering + fg re-optimisation) on the most promising few and + # keep the lowest actual post-cluster perceptual error. + ranked = sorted( + ((_nearest_perr(_role_sets(s), rows, cols, img_lab, plab), bg, bo, au, s) + for bg, bo, au, s in shortlist), + key=lambda t: t[0]) + topn = 16 if intensive else 8 + best = None + best_err = np.inf + for _proxy, cbg, cborder, caux, csets in ranked[:topn]: + idx, role_sets = _role_dither(csets, rows, cols, img_lab, plab, dither_mode) + out = _build(idx, role_sets, cbg, cborder, caux, rows, cols, dist, + img_lab, plab, iters=8) + if out[-1] < best_err: + best_err = out[-1] + best = (cbg, cborder, caux, out) + bg, border, aux, (prev_idx, screen, color, chardata, _err) = best + + data = {"chardata": chardata, "screen": screen, "color": color, + "bg": int(bg), "border": int(border), "aux": int(aux), + "multicolor": True} + + preview = prgb[prev_idx] + disp_w = int(round(WIDTH * PIXEL_ASPECT)) + xs = (np.arange(disp_w) * WIDTH) // disp_w + preview = preview[:, xs] + + return base.Conversion( + mode="multicolor", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT, + index_image=prev_idx.astype(np.uint16), data=data, data_addr=0, + viewer="multicolor", preview_rgb=preview, error=best_err, + meta={"palette": "vic", "dither": dither_mode, + "bg": int(bg), "border": int(border), "aux": int(aux)}, + ) + + +def _build(idx, role_sets, bg, border, aux, rows, cols, dist, img_lab, plab, + iters=16): + """From a dithered image + per-cell colour roles, build the 256-char set and + the screen/colour/char data; returns (prev_idx, screen, color, chardata, + perceptual_error).""" + fg = role_sets[:, 2].astype(np.int64) + patterns = np.zeros((N_CELLS, CELL_W * CELL_H), np.uint8) + for cr in range(rows): + for cc in range(cols): + ci = cr * cols + cc + block = idx[cr * 8:cr * 8 + 8, cc * 4:cc * 4 + 4] # (8,4) + r = role_sets[ci] # 4 colours + code = np.zeros((8, 4), np.uint8) + # first-match role for each pixel colour (handles duplicate colours) + for role in (3, 2, 1, 0): + code[block == r[role]] = role + patterns[ci] = code.reshape(-1) + + chars, labels = _cluster_chars(patterns, iters=iters) + + # re-optimise per-cell fg for the final (clustered) pattern's fg pixels + for ci in range(N_CELLS): + P = (chars[labels[ci]] == 2) + if P.any(): + fg[ci] = int(dist[ci][P][:, :N_FG].sum(0).argmin()) + + prev_idx = np.empty((HEIGHT, WIDTH), np.uint8) + screen = np.zeros(N_CELLS, np.uint8) + color = np.zeros(N_CELLS, np.uint8) + chardata = np.zeros(N_CHARS * 8, np.uint8) + for ci in range(N_CELLS): + cr, cc = divmod(ci, cols) + pat = chars[labels[ci]].reshape(8, 4) + f = int(fg[ci]) + lut = np.array([bg, border, f, aux]) + prev_idx[cr * 8:cr * 8 + 8, cc * 4:cc * 4 + 4] = lut[pat] + screen[ci] = labels[ci] & 0xFF + color[ci] = (f & 0x07) | 0x08 # bit3 = multicolour + for t in range(N_CHARS): + rb = chars[t].reshape(8, 4) + for r in range(8): + byte = 0 + for x in range(4): + byte = (byte << 2) | int(rb[r, x] & 3) + chardata[t * 8 + r] = byte + + return (prev_idx, screen, color, chardata, + base.perceptual_error(prev_idx, img_lab, plab)) diff --git a/lenser/vic20/exporter.py b/lenser/vic20/exporter.py new file mode 100644 index 0000000..0ffd2a6 --- /dev/null +++ b/lenser/vic20/exporter.py @@ -0,0 +1,18 @@ +"""Build a Commodore VIC-20 autostart cartridge (.a0) from a conversion.""" +from __future__ import annotations + +import os + +from .viewer import assemble + +_EXTS = (".a0", ".crt", ".bin", ".rom") + + +def export_a0(conv, output_path, source_path=None, display="forever", + seconds=0, video="ntsc"): + if not output_path.lower().endswith(_EXTS): + output_path += ".a0" + rom = assemble.build_cart(conv.data) + with open(output_path, "wb") as f: + f.write(rom) + return output_path diff --git a/lenser/vic20/palette.py b/lenser/vic20/palette.py new file mode 100644 index 0000000..b5da155 --- /dev/null +++ b/lenser/vic20/palette.py @@ -0,0 +1,49 @@ +"""Commodore VIC-20 (VIC 6560/6561) 16-colour palette. + +Calibrated against MAME's `vic20`: a display-setup cartridge cycled the global +background ($900F bits 4-7) over all 16 values while a blank-character cell was +sampled with the emulator's screen pixel reader (NORMAL mode -- $900F bit 3 = 1; +with bit 3 = 0 the chip is in REVERSE mode and blank cells show the foreground). + +The VIC has 16 colours but the per-cell *foreground* (colour RAM, 3 bits) is +limited to the first 8; the global background / border / auxiliary may be any of +the 16. Colours 8-15 are lighter variants of 0-7. +""" + +from __future__ import annotations + +import numpy as np + +from ..palette import srgb_to_lab + +VIC = np.array([ + (0x00, 0x00, 0x00), # 0 black + (0xFF, 0xFF, 0xFF), # 1 white + (0xF0, 0x00, 0x00), # 2 red + (0x00, 0xF0, 0xF0), # 3 cyan + (0x60, 0x00, 0x60), # 4 purple + (0x00, 0xA0, 0x00), # 5 green + (0x00, 0x00, 0xF0), # 6 blue + (0xD0, 0xD0, 0x00), # 7 yellow + (0xC0, 0xA0, 0x00), # 8 orange + (0xFF, 0xA0, 0x00), # 9 light orange + (0xF0, 0x80, 0x80), # 10 pink + (0x00, 0xFF, 0xFF), # 11 light cyan + (0xFF, 0x00, 0xFF), # 12 light purple + (0x00, 0xFF, 0x00), # 13 light green + (0x00, 0xA0, 0xFF), # 14 light blue + (0xFF, 0xFF, 0x00), # 15 light yellow +], dtype=np.float64) + +# Foreground (colour RAM, 3-bit) is limited to the first 8 colours. +FG_USABLE = list(range(8)) +# Background / border / auxiliary may be any of the 16. +BG_USABLE = list(range(16)) + + +def get_palette() -> np.ndarray: + return VIC + + +def palette_lab() -> np.ndarray: + return srgb_to_lab(VIC) diff --git a/lenser/vic20/viewer/__init__.py b/lenser/vic20/viewer/__init__.py new file mode 100644 index 0000000..3d53b50 --- /dev/null +++ b/lenser/vic20/viewer/__init__.py @@ -0,0 +1 @@ +"""VIC-20 6502 viewer (assembled by xa).""" diff --git a/lenser/vic20/viewer/assemble.py b/lenser/vic20/viewer/assemble.py new file mode 100644 index 0000000..bfd6593 --- /dev/null +++ b/lenser/vic20/viewer/assemble.py @@ -0,0 +1,82 @@ +"""Assemble the VIC-20 viewer with `xa` and lay out the 8K autostart cartridge. + +The cartridge occupies $A000-$BFFF (8192 bytes). The KERNAL recognises the +"A0CBM" signature at $A004 and jumps through the cold vector at $A000, so no 6502 +reset vector is needed. MAME's `vic20 -cart` wants a full 8K image; smaller .a0 +files fail with an I/O error. + +ROM layout (fixed so the viewer can copy from constant addresses): + $A000 header + viewer code + $A800 CHARSRC character set (2048 bytes -> RAM $1400) + $B000 SCRSRC screen ( 506 bytes -> RAM $1E00) + $B200 COLSRC colour RAM ( 506 bytes -> RAM $9600) +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile + +VIEWER_DIR = os.path.dirname(os.path.abspath(__file__)) + +CART_BASE = 0xA000 +CART_SIZE = 0x2000 +CHARSRC = 0xA800 +SCRSRC = 0xB000 +COLSRC = 0xB200 + + +class AssemblerError(RuntimeError): + pass + + +def have_xa() -> bool: + return shutil.which("xa") is not None + + +def _assemble(bg: int, border: int, aux: int) -> 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"* = ${CART_BASE:04X}\n" + f"#define CHARSRC ${CHARSRC:04X}\n" + f"#define SCRSRC ${SCRSRC:04X}\n" + f"#define COLSRC ${COLSRC:04X}\n" + f"#define BG {bg & 15}\n" + f"#define BORDER {border & 7}\n" + f"#define AUX {aux & 15}\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: + code = f.read() + finally: + os.unlink(wrap) + return code + + +def build_cart(data: dict) -> bytes: + """data carries chardata(2048), screen(506), color(506) and bg/border/aux.""" + code = _assemble(data["bg"], data["border"], data["aux"]) + if len(code) > (CHARSRC - CART_BASE): + raise AssemblerError(f"viewer code is {len(code)} bytes, overruns CHARSRC") + chardata = bytes(bytearray(data["chardata"])) + screen = bytes(bytearray(data["screen"])) + color = bytes(bytearray(data["color"])) + + rom = bytearray(b"\x00" * CART_SIZE) + rom[0:len(code)] = code + rom[CHARSRC - CART_BASE:CHARSRC - CART_BASE + len(chardata)] = chardata + rom[SCRSRC - CART_BASE:SCRSRC - CART_BASE + len(screen)] = screen + rom[COLSRC - CART_BASE:COLSRC - CART_BASE + len(color)] = color + return bytes(rom) diff --git a/lenser/vic20/viewer/viewer.s b/lenser/vic20/viewer/viewer.s new file mode 100644 index 0000000..b294394 --- /dev/null +++ b/lenser/vic20/viewer/viewer.s @@ -0,0 +1,95 @@ +; VIC-20 image viewer (autostart 8K cartridge at $A000). +; +; The KERNAL does NOT initialise the VIC before launching an autostart cart, so +; this code programs every VIC register itself, copies the character set, screen +; and colour data from the cartridge ROM into RAM, then loops forever showing the +; picture. +; +; Layout in unexpanded RAM -- char set $1400-$1BFF (256 chars), screen $1E00, +; colour RAM $9600 (fixed). $9005 = $FD selects screen $1E00 + char base $1400. +; +; The build (assemble.py) appends the data blocks at the fixed ROM addresses +; CHARSRC / SCRSRC / COLSRC and fills in the BG / BORDER / AUX colour defines. + + .word cold ; $A000 cold-start vector + .word cold ; $A002 warm-start vector + .byte $41,$30,$C3,$C2,$CD ; "A0CBM" autostart signature + +; zero-page scratch (KERNAL-safe temporaries) +src = $fb ; $fb/$fc copy source pointer +dst = $fd ; $fd/$fe copy destination pointer + +cold: + sei + cld + ldx #$ff + txs + + ; --- copy the 256-char set (2048 bytes = 8 pages) ROM -> $1400 --- + lda #CHARSRC + sta src+1 + lda #$00 + sta dst + lda #$14 + sta dst+1 + ldx #8 ; pages to copy + jsr copypages + + ; --- copy the screen (506 bytes -> 2 pages) ROM -> $1E00 --- + lda #SCRSRC + sta src+1 + lda #$00 + sta dst + lda #$1e + sta dst+1 + ldx #2 + jsr copypages + + ; --- copy colour RAM (506 bytes -> 2 pages) ROM -> $9600 --- + lda #COLSRC + sta src+1 + lda #$00 + sta dst + lda #$96 + sta dst+1 + ldx #2 + jsr copypages + + ; --- program the VIC --- + lda #$05 + sta $9000 ; horizontal origin + lda #$19 + sta $9001 ; vertical origin + lda #$96 + sta $9002 ; 22 columns + screen address bit 9 + lda #$2e + sta $9003 ; 23 rows, 8x8 chars + lda #$fd + sta $9005 ; screen $1E00 + char base $1400 + lda #(AUX<<4) + sta $900e ; auxiliary colour (bits 4-7), volume 0 + lda #((BG<<4)|$08|BORDER) + sta $900f ; bg (bits 4-7) | normal mode (bit3) | border (0-2) + +loop: + jmp loop ; hold the picture forever + +; copy X pages of 256 bytes from (src) to (dst) +copypages: + ldy #$00 +cp1: + lda (src),y + sta (dst),y + iny + bne cp1 + inc src+1 + inc dst+1 + dex + bne cp1 + rts diff --git a/c64view/viewer/__init__.py b/lenser/viewer/__init__.py similarity index 100% rename from c64view/viewer/__init__.py rename to lenser/viewer/__init__.py diff --git a/lenser/viewer/assemble.py b/lenser/viewer/assemble.py new file mode 100644 index 0000000..676a333 --- /dev/null +++ b/lenser/viewer/assemble.py @@ -0,0 +1,206 @@ +"""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", + "slideshow": "slideshow.s", + "slideshow_fli": "slideshow_fli.s", + "slideshow_interlace": "slideshow_interlace.s", +} + +# slideshow advance behaviour -> WAITMODE (see viewer/wait.i) +SS_WAITMODE = {"key": 1, "seconds": 2, "both": 3} + +_cache: dict[tuple, bytes] = {} + +# How long the viewer holds the picture (see viewer/wait.i). +WAIT_MODES = {"forever": 0, "key": 1, "seconds": 2} + + +class AssemblerError(RuntimeError): + pass + + +def have_xa() -> bool: + return shutil.which("xa") is not None + + +def assemble_stub(viewer_key: str, display: str = "key", seconds: int = 0, + video: str = "pal", separate: bool = False) -> bytes: + """Assemble a viewer stub to raw bytes (origin $0801, no load-address prefix). + + ``display`` / ``seconds`` choose the wait behaviour (viewer/wait.i); ``video`` + sets the jiffy rate the seconds timer counts (50 PAL / 60 NTSC). ``separate`` + makes the viewer KERNAL-load the picture from a "data" file instead of having + it appended (viewer/loaddata.i). + """ + waitmode = WAIT_MODES.get(display, 1) + jiffyps = 60 if video == "ntsc" else 50 + sep = 1 if separate else 0 + key = (viewer_key, waitmode, int(seconds), jiffyps, sep) + 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 (Debian/Ubuntu)\n" + "or build from https://www.floodgap.com/retrotech/xa/") + + if not os.path.exists(os.path.join(VIEWER_DIR, SOURCES[viewer_key])): + raise AssemblerError(f"viewer source missing: {SOURCES[viewer_key]}") + + # A generated wrapper sets the options then includes the real source. + wrapper = ( + f"#define WAITMODE {waitmode}\n" + f"#define WAITSECS {max(0, int(seconds))}\n" + f"#define JIFFYPS {jiffyps}\n" + f"#define SEPARATE {sep}\n" + f'#include "{SOURCES[viewer_key]}"\n') + raw = _xa(wrapper, viewer_key) + _cache[key] = raw + return raw + + +def _xa(wrapper: str, what: str) -> bytes: + """Assemble a generated wrapper with xa; return raw bytes (no load prefix). + + The wrapper lives in VIEWER_DIR and xa runs there so its #include "...s" and + the source's #include "wait.i" both resolve (xa looks relative to cwd).""" + 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)") + with tempfile.TemporaryDirectory() as td: + out = os.path.join(td, "viewer.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) + + +CART_SIZE = 0x4000 # 16K C64 cartridge ROM at $8000-$BFFF + + +def build_cart_rom(viewer_key: str, data: bytes, display: str = "forever", + seconds: int = 0, video: str = "pal") -> bytes: + """Assemble cart.s for `viewer_key` (hires/multicolor), append the image + data, and pad to a 16K cart ROM. Raises if the mode/size can't be a cart.""" + if viewer_key not in ("hires", "multicolor"): + raise AssemblerError( + f"the {viewer_key} mode is too large for a 16K cartridge -- " + "use a disk image, or pick hires/multicolor/mono") + mcmode = 1 if viewer_key == "multicolor" else 0 + waitmode = WAIT_MODES.get(display, 0) + rate = 60 if video == "ntsc" else 50 + npages = (len(data) + 255) // 256 + wrapper = ( + f"#define MCMODE {mcmode}\n" + f"#define NPAGES {npages}\n" + f"#define WAITMODE {waitmode}\n" + f"#define WAITSECS {max(0, int(seconds))}\n" + f"#define RATE {rate}\n" + '#include "cart.s"\n') + rom = _xa(wrapper, "cart") + bytes(data) + if len(rom) > CART_SIZE: + raise AssemblerError( + f"image + cart viewer = {len(rom)} bytes, over the 16K cartridge " + "limit -- use a disk image for this mode") + return rom + bytes(CART_SIZE - len(rom)) + + +def build_viewer_prg(viewer_key: str, data: bytes, data_addr: int = DATA_ADDR, + display: str = "key", seconds: int = 0, + video: str = "pal", separate: bool = False) -> 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, ...). When ``separate`` is set the picture + is NOT appended -- the viewer KERNAL-loads it from a "data" file at run time + (use ``build_data_prg`` to write that file) -- so this returns just the code. + """ + stub = assemble_stub(viewer_key, display, seconds, video, separate) + if separate: + return bytes([LOAD_ADDR & 0xFF, (LOAD_ADDR >> 8) & 0xFF]) + stub + 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 + + +def build_data_prg(data: bytes, data_addr: int = DATA_ADDR) -> bytes: + """The picture as a standalone PRG (2-byte load address + data), for the + separate-binary layout's "data" file.""" + return bytes([data_addr & 0xFF, (data_addr >> 8) & 0xFF]) + bytes(data) + + +def build_slideshow_prg(mode_bytes, advance: str = "both", seconds: int = 10, + loop: bool = True, video: str = "pal", + flavor: str = "simple") -> bytes: + """Assemble a slideshow viewer PRG (code only; pictures are separate files). + + ``flavor`` selects the engine: "simple" (mixed hires/multicolor/mono, one + mode byte per image), "fli" (all FLI), or "interlace" (all IFLI). For the + simple flavor ``mode_bytes`` is one byte per image (0 hires/mono, 1 + multicolor), emitted as the ss_modes table; for fli/interlace only its + length (the image count) matters. ``advance`` picks the wait behaviour + (key/seconds/both), ``seconds`` the timeout, ``loop`` whether it wraps. + """ + if not mode_bytes: + raise AssemblerError("a slideshow needs at least one image") + waitmode = SS_WAITMODE[advance] + jiffyps = 60 if video == "ntsc" else 50 + defines = ( + f"#define WAITMODE {waitmode}\n" + f"#define WAITSECS {max(0, int(seconds))}\n" + f"#define JIFFYPS {jiffyps}\n" + f"#define NIMAGES {len(mode_bytes)}\n" + f"#define LOOPFLAG {1 if loop else 0}\n") + if flavor == "simple": + table = ",".join(str(int(b) & 1) for b in mode_bytes) + wrapper = (defines + '#include "slideshow.s"\n' + "ss_modes:\n" f" .byte {table}\n") + elif flavor == "fli": + wrapper = (defines + f"#define NTSC {1 if video == 'ntsc' else 0}\n" + '#include "slideshow_fli.s"\n') + elif flavor == "interlace": + wrapper = defines + '#include "slideshow_interlace.s"\n' + else: + raise AssemblerError(f"unknown slideshow flavor: {flavor}") + raw = _xa(wrapper, f"slideshow_{flavor}") + return bytes([LOAD_ADDR & 0xFF, (LOAD_ADDR >> 8) & 0xFF]) + raw diff --git a/lenser/viewer/cart.s b/lenser/viewer/cart.s new file mode 100644 index 0000000..7dd01f3 --- /dev/null +++ b/lenser/viewer/cart.s @@ -0,0 +1,177 @@ +; lenser -- C64 cartridge viewer (runs from ROM at $8000), fully self-contained. +; +; Unlike the disk viewer (a PRG at $0801 with the picture appended), this runs +; from a 16K cartridge. It copies the image data block -- appended after this +; code in ROM -- down to $2000 (same layout the disk viewer wants, bitmap at +; $2000, screen $3F40, and for multicolor colram $4328 + bg $4710), programs the +; VIC, and holds the picture. It uses NO KERNAL/IRQ services (a 16K cart hides +; BASIC and the autostart entry runs before the KERNAL fully initialises), so it +; polls the hardware directly -- CIA1 for the keyboard, the raster for timing. +; +; #defines set by viewer/assemble.py -- +; MCMODE 0 = hires, 1 = multicolor +; NPAGES number of 256-byte pages of image data to copy +; WAITMODE 0 forever / 1 until a key / 2 about WAITSECS seconds +; WAITSECS, RATE (frames per second, 50 PAL / 60 NTSC) +; +; assembled by viewer/assemble.py via xa + + * = $8000 + .word cold ; cold-start vector + .word cold ; NMI / warm vector + .byte $c3,$c2,$cd,$38,$30 ; CBM80 autostart signature + +SRC = $fb +DST = $fd +CNT = $02 ; 16-bit frame countdown (seconds mode) + +cold: + sei + ldx #$ff + txs + cld + + ; map the standard memory layout so BOTH halves of the 16K cart are + ; visible -- ROML $8000-$9FFF and ROMH $A000-$BFFF (needs HIRAM=1) -- plus + ; I/O at $D000. Set the data latch ($01) BEFORE the DDR ($00): on a cart + ; cold-start the latch may be low while the DDR is all-inputs (so the port + ; reads the correct map via pull-ups). Enabling the DDR first would drive + ; that low latch onto the port, bank ROML out, and crash the next fetch + ; (this is exactly what failed in VICE). + lda #$37 + sta $01 ; LORAM+HIRAM+CHAREN = ROML, ROMH, I/O, KERNAL + lda #$2f + sta $00 ; now enable the port outputs + + ; VIC bank 0 ($0000-$3FFF) so the VIC sees our bitmap at $2000 + lda #$3f + sta $dd02 ; CIA2 port A bits 0-5 = output + lda #$c7 + sta $dd00 ; bank 0 (bits 0-1 = 11) + + lda #$0b + sta $d011 ; blank during setup + + ; ---- copy NPAGES of image data from ROM (datasrc) to $2000 ---- + lda #datasrc + sta SRC+1 + lda #$00 + sta DST + lda #$20 + sta DST+1 + ldx #NPAGES + ldy #$00 +dcopy: + lda (SRC),y + sta (DST),y + iny + bne dcopy + inc SRC+1 + inc DST+1 + dex + bne dcopy + + ; ---- copy screen RAM $3F40 -> $0400 ---- + lda #$40 + sta SRC + lda #$3f + sta SRC+1 + lda #$00 + sta DST + lda #$04 + sta DST+1 + jsr copy1024 + +#if MCMODE == 1 + ; ---- copy colour RAM $4328 -> $D800 ---- + lda #$28 + sta SRC + lda #$43 + sta SRC+1 + lda #$00 + sta DST + lda #$d8 + sta DST+1 + jsr copy1024 +#endif + + ; ---- program the VIC-II ---- + lda #$00 + sta $d020 ; border black +#if MCMODE == 1 + lda $4710 + sta $d021 ; background colour + lda #$d8 + sta $d016 ; multicolor on +#endif +#if MCMODE == 0 + lda #$c8 + sta $d016 ; hires +#endif + lda #$18 + sta $d018 ; screen $0400, bitmap $2000 + lda #$3b + sta $d011 ; bitmap mode, display on + + ; ---- hold the picture ---- +#if WAITMODE == 0 +forever: + jmp forever +#endif + +#if WAITMODE == 1 + lda #$ff + sta $dc02 ; CIA1 port A = output (column select) + lda #$00 + sta $dc03 ; CIA1 port B = input (row read) + sta $dc00 ; drive all keyboard columns low +kwait: + lda $dc01 ; rows; $FF = no key, any key pulls a row low + cmp #$ff + beq kwait + jmp $fce2 ; reset (a 16K cart can't return to BASIC) +#endif + +#if WAITMODE == 2 + lda #<(WAITSECS*RATE) + sta CNT + lda #>(WAITSECS*RATE) + sta CNT+1 +sloop: +sw1: + lda $d012 + cmp #$fa + bne sw1 ; wait until raster reaches line 250 +sw2: + lda $d012 + cmp #$fa + beq sw2 ; wait until it leaves -> one frame elapsed + lda CNT + bne sdec + dec CNT+1 +sdec: + dec CNT + lda CNT + ora CNT+1 + bne sloop + jmp $fce2 ; reset +#endif + +copy1024: + ldx #4 + ldy #0 +c1k: + lda (SRC),y + sta (DST),y + iny + bne c1k + inc SRC+1 + inc DST+1 + dex + bne c1k + rts + +datasrc: + ; image data block appended here by the packager diff --git a/c64view/viewer/fli.s b/lenser/viewer/fli.s similarity index 98% rename from c64view/viewer/fli.s rename to lenser/viewer/fli.s index 7c8b3cd..8ba520d 100644 --- a/c64view/viewer/fli.s +++ b/lenser/viewer/fli.s @@ -1,4 +1,4 @@ -; c64view -- FLI multicolor viewer (self-contained) +; lenser -- FLI multicolor viewer (self-contained) ; ; Re-points the VIC video matrix ($D018) and forces a badline ($D011 yscroll) ; on every visible raster line via a cycle-timed loop, giving per-line colour. diff --git a/c64view/viewer/fli_ntsc.s b/lenser/viewer/fli_ntsc.s similarity index 98% rename from c64view/viewer/fli_ntsc.s rename to lenser/viewer/fli_ntsc.s index 3683b17..6cb2927 100644 --- a/c64view/viewer/fli_ntsc.s +++ b/lenser/viewer/fli_ntsc.s @@ -1,4 +1,4 @@ -; c64view -- FLI multicolor viewer, NTSC timing (self-contained) +; lenser -- FLI multicolor viewer, NTSC timing (self-contained) ; ; Same as fli.s but the inner loop is one NOP (2 cycles) longer so it self-syncs ; to the NTSC 65-cycle raster line (25 free CPU cycles per badline) vs PAL's 63. diff --git a/c64view/viewer/hires.s b/lenser/viewer/hires.s similarity index 85% rename from c64view/viewer/hires.s rename to lenser/viewer/hires.s index cc4080e..50a8fcc 100644 --- a/c64view/viewer/hires.s +++ b/lenser/viewer/hires.s @@ -1,4 +1,4 @@ -; c64view -- hires bitmap viewer (self-contained) +; lenser -- hires bitmap viewer (self-contained) ; ; The picture data is appended to this program by the exporter and loads in one ; pass. Fixed memory layout after load: @@ -22,6 +22,7 @@ SRC = $fb DST = $fd start: +#include "loaddata.i" lda #$0b sta $d011 ; blank during setup @@ -50,9 +51,7 @@ start: lda #$ff sta $cc -waitkey: - jsr $ffe4 - beq waitkey +#include "wait.i" lda #$1b sta $d011 @@ -60,6 +59,10 @@ waitkey: sta $d016 lda #$15 sta $d018 + lda #$0e + sta $d020 ; restore default border (light blue) + lda #$06 + sta $d021 ; and background (blue) for a clean BASIC screen lda #$00 sta $cc jsr $e544 diff --git a/c64view/viewer/interlace.s b/lenser/viewer/interlace.s similarity index 93% rename from c64view/viewer/interlace.s rename to lenser/viewer/interlace.s index e261f39..5fc1362 100644 --- a/c64view/viewer/interlace.s +++ b/lenser/viewer/interlace.s @@ -1,4 +1,4 @@ -; c64view -- multicolor interlace viewer (self-contained) +; lenser -- multicolor interlace viewer (self-contained) ; ; Shows two multicolor frames on alternating fields by flipping the VIC bank in a ; once-per-frame raster IRQ (no cycle-exact timing needed, so it is robust). @@ -88,9 +88,9 @@ start: asl $d019 cli -waitkey: - jsr $ffe4 ; GETIN (scanned via our IRQ -> $ea31) - beq waitkey + ; hold the picture per the display option (our IRQ chains to the KERNAL + ; $ea31 housekeeping, so GETIN and the jiffy clock both work here). +#include "wait.i" ; restore text mode + KERNAL IRQ sei diff --git a/lenser/viewer/loaddata.i b/lenser/viewer/loaddata.i new file mode 100644 index 0000000..17b7828 --- /dev/null +++ b/lenser/viewer/loaddata.i @@ -0,0 +1,20 @@ +; Separate-binary prologue. When SEPARATE=1 the picture is NOT appended to this +; viewer; it lives in its own disk file "data" (load address $2000). Pull it in +; with the KERNAL before displaying. When SEPARATE=0 this is nothing, and the +; data was appended after the viewer instead. +#if SEPARATE == 1 + lda #4 + ldx #cv_dataname + jsr $ffbd ; SETNAM + lda #1 + ldx #8 + ldy #1 + jsr $ffba ; SETLFS (secondary 1 = load to file's address) + lda #0 + jsr $ffd5 ; LOAD "data" -> $2000 + jmp cv_loaded +cv_dataname: + .byte $44,$41,$54,$41 ; "DATA" in PETSCII (the c1541 "data" file) +cv_loaded: +#endif diff --git a/c64view/viewer/multicolor.s b/lenser/viewer/multicolor.s similarity index 88% rename from c64view/viewer/multicolor.s rename to lenser/viewer/multicolor.s index a94d559..4ed5be7 100644 --- a/c64view/viewer/multicolor.s +++ b/lenser/viewer/multicolor.s @@ -1,4 +1,4 @@ -; c64view -- multicolor (Koala) bitmap viewer (self-contained) +; lenser -- multicolor (Koala) bitmap viewer (self-contained) ; ; The picture data is appended to this program by the exporter and loads in one ; pass, so no second disk access is needed. Fixed memory layout after load: @@ -24,6 +24,7 @@ SRC = $fb DST = $fd start: +#include "loaddata.i" lda #$0b sta $d011 ; blank screen during setup @@ -66,9 +67,7 @@ start: lda #$ff sta $cc ; disable cursor blink -waitkey: - jsr $ffe4 ; GETIN - beq waitkey +#include "wait.i" ; restore text mode and return to BASIC lda #$1b @@ -77,6 +76,10 @@ waitkey: sta $d016 lda #$15 sta $d018 + lda #$0e + sta $d020 ; restore default border (light blue) + lda #$06 + sta $d021 ; and background (blue) for a clean BASIC screen lda #$00 sta $cc jsr $e544 ; clear screen diff --git a/lenser/viewer/slideshow.s b/lenser/viewer/slideshow.s new file mode 100644 index 0000000..20f8426 --- /dev/null +++ b/lenser/viewer/slideshow.s @@ -0,0 +1,238 @@ +; lenser -- slideshow viewer (self-contained code; pictures are separate files) +; +; Boots first on the disk (LOAD"*",8,1 then RUN) and steps through NIMAGES +; picture files named "00".."NN", each a PRG that KERNAL-loads to $2000 -- +; $2000 bitmap 8000 (always) +; $3F40 screen 1000 (always -> copied to the buffer's video matrix) +; $4328 colram 1000 (multicolor only -> $D800) +; $4710 background 1 (multicolor only -> $D021) +; The per-image byte in ss_modes selects hires(0) vs multicolor(1) setup. mono +; uses the hires path. WAITMODE (viewer/wait.i) selects key / seconds / both. +; +; DOUBLE BUFFERED so the previous slide stays on screen while the next loads +; (no blank between slides). Two VIC banks alternate as front/back buffer: +; buffer 0 -- VIC bank 0, bitmap $2000, video matrix $0400 ($DD00 bits %11) +; buffer 1 -- VIC bank 1, bitmap $6000, video matrix $4400 ($DD00 bits %10) +; Both use $D018=$18 (matrix at bank+$0400, bitmap at bank+$2000); only the +; $DD00 bank bits differ, so the swap is a single write. Each slide is KERNAL- +; loaded into the *back* buffer (secondary address 0, so the file's $2000 header +; is ignored and it lands at the buffer's base) while the front buffer -- the +; previous picture -- stays displayed; then a bank flip makes it the front. +; ss_hi = ss_buf*$40 is the high-byte offset ($00 buffer 0, $40 buffer 1) added +; to every buffer-relative address. Colour RAM ($D800) is shared, so for a +; multicolor slide it is copied at the swap (a hires slide needs none, and swaps +; perfectly cleanly). +; +; assembled by viewer/assemble.py via xa; the ss_modes table is appended after +; this file by the generated wrapper, and NIMAGES / LOOPFLAG / WAITMODE etc. are +; #defined there. + + ; BASIC autostart, SYS 2061 + * = $0801 + .word basicend + .word 10 + .byte $9e + .byte "2061" + .byte 0 +basicend: + .word 0 ; ML begins at $080D + +SRC = $fb +DST = $fd + +start: + lda #$00 + sta $9d ; suppress KERNAL LOAD messages + sta ss_idx ; start at the first image + sta ss_buf ; first slide loads into buffer 0 + sta $d020 ; border black (once) + lda #$0b + sta $d011 ; display off -- the only blank, before slide 0 + +mainloop: + jsr name_build ; ss_name = "NN" from ss_idx + jsr setup_hi ; ss_hi = ss_buf * $40 + lda #$ff + sta $cc ; cursor off (blink can't corrupt $D800) + + ; ---- KERNAL LOAD "NN" into the BACK buffer ($2000 or $6000) ---- + ; secondary address 0 -> the file's $2000 header is ignored and the data + ; is loaded to the address passed in .X/.Y, so one data file can serve + ; either buffer. The front buffer stays displayed throughout. + lda #2 + ldx #ss_name + jsr $ffbd ; SETNAM + lda #1 + ldx #8 + ldy #0 ; SA 0 -> load to .X/.Y address + jsr $ffba ; SETLFS + ldx #$00 ; load address low = $00 + lda #$20 + clc + adc ss_hi + tay ; load address high = $20 + ss_hi + lda #0 ; 0 = load (not verify) + jsr $ffd5 ; LOAD + + ; ---- copy screen RAM (base+$1F40) -> this buffer's video matrix ---- + ; (into the still-hidden back buffer, so it is invisible) + lda #$40 + sta SRC + lda #$3f + clc + adc ss_hi + sta SRC+1 ; SRC = $3F40 / $7F40 + lda #$00 + sta DST + lda #$04 + clc + adc ss_hi + sta DST+1 ; DST = $0400 / $4400 + jsr copy1024 + + ; ---- per-image mode -- 0 = hires/mono, 1 = multicolor ---- + ldx ss_idx + lda ss_modes,x + beq ss_hires + + ; multicolor -- colour RAM (base+$2328) -> $D800, background from base+$2710 + lda #$28 + sta SRC + lda #$43 + clc + adc ss_hi + sta SRC+1 ; SRC = $4328 / $8328 + lda #$00 + sta DST + lda #$d8 + sta DST+1 + jsr copy1024 + lda #$10 + sta SRC + lda #$47 + clc + adc ss_hi + sta SRC+1 ; SRC = $4710 / $8710 + ldy #$00 + lda (SRC),y + sta $d021 ; background colour + jsr flip_bank ; make the back buffer the front + lda #$d8 + sta $d016 ; multicolor on + jmp ss_on + +ss_hires: + jsr flip_bank ; make the back buffer the front + lda #$c8 + sta $d016 ; hires (multicolor off) + +ss_on: + lda #$18 + sta $d018 ; matrix bank+$0400, bitmap bank+$2000 + lda #$3b + sta $d011 ; bitmap mode, display on + + lda ss_buf + eor #$01 + sta ss_buf ; next slide loads into the other buffer + +#include "wait.i" + + ; ---- advance to the next image ---- + inc ss_idx + lda ss_idx + cmp #NIMAGES + bcc ss_go ; still more images +#if LOOPFLAG == 1 + lda #$00 + sta ss_idx ; wrap around forever +ss_go: + jmp mainloop +#else + jmp ss_end ; done -> restore and return to BASIC +ss_go: + jmp mainloop +ss_end: +#endif + + ; ---- restore text mode and return to BASIC ---- + lda $dd00 + ora #$03 + sta $dd00 ; VIC bank 0 + lda #$1b + sta $d011 + lda #$c8 + sta $d016 + lda #$15 + sta $d018 + lda #$0e + sta $d020 ; default border (light blue) + lda #$06 + sta $d021 ; default background (blue) + lda #$00 + sta $cc + jsr $e544 ; clear screen + rts + +; ss_hi = ss_buf * $40 (high-byte offset for the current back buffer) +setup_hi: + ldy #$00 + lda ss_buf + beq sh_set + ldy #$40 +sh_set: + sty ss_hi + rts + +; point VIC at the current buffer's bank (buffer 0 -> bank 0, buffer 1 -> bank 1) +flip_bank: + lda ss_buf + bne fb_one + lda $dd00 + ora #$03 + sta $dd00 ; bank 0 ($0000-$3FFF) + rts +fb_one: + lda $dd00 + and #$fc + ora #$02 + sta $dd00 ; bank 1 ($4000-$7FFF) + rts + +; build the 2-char filename "NN" from ss_idx (0..99) +name_build: + lda ss_idx + ldx #$2f + sec +ss_ten: + inx + sbc #10 + bcs ss_ten + adc #10 ; remainder 0..9 (carry was clear on exit) + ora #$30 + sta ss_name+1 ; ones digit (PETSCII) + txa + sta ss_name ; tens digit (PETSCII) + rts + +; copy 1024 bytes from (SRC) to (DST) +copy1024: + ldx #4 + ldy #0 +cploop: + lda (SRC),y + sta (DST),y + iny + bne cploop + inc SRC+1 + inc DST+1 + dex + bne cploop + rts + +ss_idx: .byte 0 +ss_buf: .byte 0 ; 0 or 1 -- which buffer the next slide loads into +ss_hi: .byte 0 ; ss_buf * $40 (buffer high-byte offset) +ss_name: .byte $30,$30 ; "00", rebuilt each slide from ss_idx +; ss_modes table (.byte per image) appended by viewer/assemble.py's wrapper diff --git a/lenser/viewer/slideshow_fli.s b/lenser/viewer/slideshow_fli.s new file mode 100644 index 0000000..dfc4532 --- /dev/null +++ b/lenser/viewer/slideshow_fli.s @@ -0,0 +1,247 @@ +; lenser -- FLI slideshow viewer +; +; Steps through NIMAGES FLI pictures named "00".."NN", each a PRG that KERNAL- +; loads to $4000 with the layout from fli.s -- +; $4000+L*$400 screen RAM for line L (L=0..7), 1000 bytes each +; $6000 bitmap 8000 (offset $2000 in VIC bank 1) +; $8000 colour RAM 1000 (copied to $D800) +; $83E8 background 1 +; The cycle-timed raster loop re-points $D018/$D011 every line for per-line +; colour; at the bottom border it chains to the KERNAL IRQ ($ea31) so GETIN and +; the jiffy clock keep working, letting wait.i advance on key / seconds / both. +; All slides are FLI (uniform). NTSC=1 adds one NOP for the 65-cycle line. +; +; assembled by viewer/assemble.py via xa; NIMAGES / LOOPFLAG / WAITMODE / NTSC +; etc. are #defined by the generated wrapper. + + ; BASIC autostart, SYS 2061 + * = $0801 + .word basicend + .word 10 + .byte $9e + .byte "2061" + .byte 0 +basicend: + .word 0 + +start: + lda #$00 + sta $9d ; suppress KERNAL LOAD messages + sta ss_idx + jsr build_tables ; per-line $D018/$D011 tables (once) + +mainloop: + jsr ss_name_build + lda #$0b + sta $d011 ; blank while loading + jsr ss_load ; LOAD "NN",8,1 -> $4000 (KERNAL IRQ active) + + ; ---- FLI setup ---- + sei + lda #$ff + sta $cc ; cursor off (so blink can't corrupt $D800) + + ; copy colour RAM $8000 -> $D800 (1024 bytes) + ldx #0 +ccopy: + lda $8000,x + sta $d800,x + lda $8100,x + sta $d900,x + lda $8200,x + sta $da00,x + lda $8300,x + sta $db00,x + inx + bne ccopy + + lda $dd00 + and #$fc + ora #$02 + sta $dd00 ; VIC bank 1 ($4000-$7FFF) + lda $83e8 + sta $d021 ; background + lda #$00 + sta $d020 ; border black + lda #$d8 + sta $d016 ; multicolor on + + lda #irq1 + sta $0315 + lda #$7f + sta $dc0d ; disable CIA timer IRQs + sta $dd0d + lda $dc0d + lda $dd0d + lda #$01 + sta $d01a ; enable raster IRQ + lda #$30 + sta $d012 ; line 48 + lda $d011 + and #$7f + sta $d011 + asl $d019 + cli + +#include "wait.i" + + ; ---- stop the engine (KERNAL IRQ back on for the next LOAD) ---- + sei + lda #$00 + sta $d01a + lda #$81 + sta $dc0d + lda #$31 + sta $0314 + lda #$ea + sta $0315 + lda $dd00 + ora #$03 + sta $dd00 ; bank 0 + asl $d019 + cli + + ; ---- advance ---- + inc ss_idx + lda ss_idx + cmp #NIMAGES + bcc ssgo +#if LOOPFLAG == 1 + lda #$00 + sta ss_idx +ssgo: + jmp mainloop +#else + jmp ssend +ssgo: + jmp mainloop +ssend: +#endif + + ; ---- final restore to BASIC ---- + lda #$1b + sta $d011 + lda #$c8 + sta $d016 + lda #$15 + sta $d018 + lda #$0e + sta $d020 + lda #$06 + sta $d021 + lda #$00 + sta $cc + jsr $e544 + rts + +; build d018tab = ((line AND 7) << 4) OR $08, d011tab = $38 OR ((line+3) AND 7) +build_tables: + ldx #0 +btab: + txa + and #$07 + asl + asl + asl + asl + ora #$08 + sta d018tab,x + txa + clc + adc #$03 + and #$07 + ora #$38 + sta d011tab,x + inx + bne btab + rts + +; first IRQ, arrives with jitter on line 48, sets up the stabilised one +irq1: + lda #irq2 + sta $0315 + inc $d012 + asl $d019 + tsx + cli + .dsb 40,$ea ; 40 NOPs -- irq2 fires inside this slide + +; stabilised IRQ on line 49, runs the FLI loop then chains to the KERNAL IRQ +irq2: + txs + lda $d012 + cmp $d012 + beq jl +jl: + ldx #$0d +d0: dex + bne d0 + nop + nop +#if NTSC == 1 + nop ; NTSC extra 2 cycles for the 65-cycle line +#endif + + ldx #$00 +fliloop: + lda d011tab,x + sta $d011 + lda d018tab,x + sta $d018 + inx + cpx #200 + bne fliloop + + ; bottom border -- re-arm irq1, then KERNAL housekeeping (keys + jiffy) + lda #irq1 + sta $0315 + lda #$30 + sta $d012 + asl $d019 + jmp $ea31 ; KERNAL housekeeping (keys + jiffy) then RTI + +ss_load: + lda #2 + ldx #ss_name + jsr $ffbd ; SETNAM + lda #1 + ldx #8 + ldy #1 + jsr $ffba ; SETLFS (secondary 1 = file's own address) + lda #0 + jsr $ffd5 ; LOAD + rts + +ss_name_build: + lda ss_idx + ldx #$2f + sec +ssten: + inx + sbc #10 + bcs ssten + adc #10 + ora #$30 + sta ss_name+1 + txa + sta ss_name + rts + +ss_idx: .byte 0 +ss_name: .byte $30,$30 + +; The cycle-exact fliloop reads `lda d018tab,x` / `lda d011tab,x` for x=0..199, +; so each table MUST be page-aligned -- otherwise indices past the page boundary +; add a page-cross penalty cycle and tear the FLI display. + * = (* + $ff) & $ff00 +d018tab: + .dsb 256,0 +d011tab: + .dsb 256,0 diff --git a/lenser/viewer/slideshow_interlace.s b/lenser/viewer/slideshow_interlace.s new file mode 100644 index 0000000..4e590e0 --- /dev/null +++ b/lenser/viewer/slideshow_interlace.s @@ -0,0 +1,204 @@ +; lenser -- interlace (IFLI) slideshow viewer +; +; Steps through NIMAGES interlace pictures named "00".."NN", each a PRG that +; KERNAL-loads to $2000 with the layout from interlace.s -- +; $2000 bitmap A 8000 +; $3F40 screen A 1000 (copied to $0400) +; $4400 screen B 1000 (in place, bank 1 video matrix) +; $6000 bitmap B 8000 +; $8000 colour RAM 1000 (copied to $D800) +; $83E8 background 1 +; A once-per-frame raster IRQ flips VIC bank 0<->1 to blend the two fields; it +; chains to the KERNAL IRQ ($ea31) so GETIN and the jiffy clock work, letting +; wait.i advance on key / seconds / both. All slides are interlace (uniform). +; +; assembled by viewer/assemble.py via xa; NIMAGES / LOOPFLAG / WAITMODE etc. are +; #defined by the generated wrapper. + + ; BASIC autostart, SYS 2061 + * = $0801 + .word basicend + .word 10 + .byte $9e + .byte "2061" + .byte 0 +basicend: + .word 0 + +SRC = $fb +DST = $fd + +start: + lda #$00 + sta $9d ; suppress KERNAL LOAD messages + sta ss_idx + sta $d020 ; border black, once + +mainloop: + jsr ss_name_build + lda #$0b + sta $d011 ; blank while loading the next field pair + jsr ss_load ; LOAD "NN",8,1 -> $2000 (KERNAL IRQ active) + + ; ---- interlace setup ---- + sei + lda #$ff + sta $cc ; cursor off (so blink can't corrupt $D800) + + ; screen A $3F40 -> $0400 + lda #$40 + sta SRC + lda #$3f + sta SRC+1 + lda #$00 + sta DST + lda #$04 + sta DST+1 + jsr copy1024 + + ; colour RAM $8000 -> $D800 + lda #$00 + sta SRC + lda #$80 + sta SRC+1 + lda #$00 + sta DST + lda #$d8 + sta DST+1 + jsr copy1024 + + lda $83e8 + sta $d021 ; background + lda $dd00 + and #$fc + ora #$03 + sta $dd00 ; start on VIC bank 0 (frame A) + lda #$18 + sta $d018 + lda #$d8 + sta $d016 ; multicolor on + lda #$3b + sta $d011 ; bitmap mode, display on + + lda #irq + sta $0315 + lda #$7f + sta $dc0d + sta $dd0d + lda $dc0d + lda $dd0d + lda #$01 + sta $d01a + lda #$fa + sta $d012 ; line 250 + lda $d011 + and #$7f + sta $d011 + asl $d019 + cli + +#include "wait.i" + + ; ---- stop the engine (KERNAL IRQ back on for the next LOAD) ---- + sei + lda #$00 + sta $d01a ; disable raster IRQ + lda #$81 + sta $dc0d ; re-enable CIA timer IRQ + lda #$31 + sta $0314 + lda #$ea + sta $0315 + lda $dd00 + ora #$03 + sta $dd00 ; back to bank 0 + asl $d019 + cli + + ; ---- advance ---- + inc ss_idx + lda ss_idx + cmp #NIMAGES + bcc ssgo +#if LOOPFLAG == 1 + lda #$00 + sta ss_idx +ssgo: + jmp mainloop +#else + jmp ssend +ssgo: + jmp mainloop +ssend: +#endif + + ; ---- final restore to BASIC ---- + lda #$1b + sta $d011 + lda #$c8 + sta $d016 + lda #$15 + sta $d018 + lda #$0e + sta $d020 + lda #$06 + sta $d021 + lda #$00 + sta $cc + jsr $e544 + rts + +; once per frame, flip bank 0 <-> bank 1, then let the KERNAL IRQ finish +irq: + lda $dd00 + eor #$01 + sta $dd00 + asl $d019 + jmp $ea31 + +ss_load: + lda #2 + ldx #ss_name + jsr $ffbd ; SETNAM + lda #1 + ldx #8 + ldy #1 + jsr $ffba ; SETLFS (secondary 1 = file's own address) + lda #0 + jsr $ffd5 ; LOAD + rts + +ss_name_build: + lda ss_idx + ldx #$2f + sec +ssten: + inx + sbc #10 + bcs ssten + adc #10 + ora #$30 + sta ss_name+1 + txa + sta ss_name + rts + +copy1024: + ldx #4 + ldy #0 +cploop: + lda (SRC),y + sta (DST),y + iny + bne cploop + inc SRC+1 + inc DST+1 + dex + bne cploop + rts + +ss_idx: .byte 0 +ss_name: .byte $30,$30 diff --git a/lenser/viewer/wait.i b/lenser/viewer/wait.i new file mode 100644 index 0000000..5e5d11b --- /dev/null +++ b/lenser/viewer/wait.i @@ -0,0 +1,77 @@ +; Shared "how long to show the picture" epilogue for the simple C64 viewers. +; Selected at assembly time by WAITMODE (set by viewer/assemble.py): +; 0 forever -- never returns; the picture stays until reset +; 1 until a key -- jsr GETIN until a key is pressed, then fall through +; 2 WAITSECS secs -- count KERNAL jiffies (JIFFYPS per second), then fall through +; 3 key OR secs -- whichever comes first (keys still work, but it auto- +; advances after WAITSECS); used by the slideshow viewer +; On fall-through the caller restores the text screen and RTSes to BASIC. +; The KERNAL IRQ is left running by these viewers, so GETIN and the jiffy clock +; ($a0-$a2, big-endian, $a2 = least significant) both work. + +cv_t0 = $fb ; 16-bit jiffy snapshot (free after the copy) +cv_el = $fd ; 16-bit elapsed jiffies + +#if WAITMODE == 0 +cv_wait: + jmp cv_wait +#endif + +#if WAITMODE == 1 + lda #$00 + sta $c6 ; empty the keyboard buffer first, so a key left + ; over from RUN doesn't dismiss the picture at once +cv_wait: + jsr $ffe4 ; GETIN + beq cv_wait +#endif + +#if WAITMODE == 2 + lda $a2 + sta cv_t0 + lda $a1 + sta cv_t0+1 +cv_wait: + sec + lda $a2 + sbc cv_t0 + sta cv_el + lda $a1 + sbc cv_t0+1 + sta cv_el+1 + lda cv_el+1 + cmp #>(WAITSECS*JIFFYPS) + bcc cv_wait + bne cv_done + lda cv_el + cmp #<(WAITSECS*JIFFYPS) + bcc cv_wait +cv_done: +#endif + +#if WAITMODE == 3 + lda #$00 + sta $c6 ; empty the keyboard buffer first + lda $a2 + sta cv_t0 + lda $a1 + sta cv_t0+1 +cv_wait: + jsr $ffe4 ; GETIN -- any key ends the slide immediately + bne cv_done + sec ; else check the elapsed-jiffies timeout + lda $a2 + sbc cv_t0 + sta cv_el + lda $a1 + sbc cv_t0+1 + sta cv_el+1 + lda cv_el+1 + cmp #>(WAITSECS*JIFFYPS) + bcc cv_wait + bne cv_done + lda cv_el + cmp #<(WAITSECS*JIFFYPS) + bcc cv_wait +cv_done: +#endif diff --git a/pyproject.toml b/pyproject.toml index b567aad..78021c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,9 +3,9 @@ requires = ["setuptools>=61"] build-backend = "setuptools.build_meta" [project] -name = "c64view" +name = "8bitlenser" version = "0.1.0" -description = "Convert modern images into Commodore 64 disk images with a built-in viewer" +description = "8 Bit Lenser: convert modern images into retro-computer disk images with a built-in viewer" readme = "README.md" requires-python = ">=3.9" license = { text = "MIT" } @@ -19,13 +19,20 @@ dependencies = [ gui = ["PyQt5>=5.15"] [project.scripts] -c64view-cli = "c64view.cli:main" +8bitlenser-cli = "lenser.cli:main" +8bitlenser-slideshow = "lenser.slideshow_cli:main" [project.gui-scripts] -c64view = "c64view.gui:main" +8bitlenser = "lenser.gui:main" [tool.setuptools.packages.find] -include = ["c64view*"] +include = ["lenser*"] [tool.setuptools.package-data] -"c64view.viewer" = ["*.s"] +"lenser.viewer" = ["*.s", "*.i"] +"lenser.ansi" = ["*.bin"] +"lenser.c128.viewer" = ["*.s"] +"lenser.bbc.viewer" = ["*.s"] +"lenser.iigs.viewer" = ["*.s"] +"lenser.atari.viewer" = ["*.s"] +"lenser.apple.viewer" = ["*.s"] diff --git a/tests/test_roundtrip.py b/tests/test_roundtrip.py index a47c7c9..74bd5af 100644 --- a/tests/test_roundtrip.py +++ b/tests/test_roundtrip.py @@ -14,9 +14,9 @@ import numpy as np sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from c64view import imageprep, palette as pal # noqa: E402 -from c64view.convert import fli, hires, ifli, multicolor # noqa: E402 -from c64view.viewer.assemble import SOURCES, build_viewer_prg, have_xa # noqa: E402 +from lenser import imageprep, palette as pal # noqa: E402 +from lenser.convert import fli, hires, ifli, multicolor # noqa: E402 +from lenser.viewer.assemble import SOURCES, build_viewer_prg, have_xa # noqa: E402 def _gradient(w, h): @@ -94,12 +94,449 @@ def test_interlace_blend_better(): assert len(ifli.convert(img).data) == 25577 +def test_vic20_multicolor_roundtrip(): + from lenser.vic20.convert import multicolor as vmc + img = imageprep.prepare(_imgobj(vmc.WIDTH, vmc.HEIGHT), vmc.WIDTH, vmc.HEIGHT, + vmc.PIXEL_ASPECT, imageprep.PrepOptions()) + c = vmc.convert(img) + d = c.data + chardata, screen, color = d["chardata"], d["screen"], d["color"] + bg, border, aux = d["bg"], d["border"], d["aux"] + dec = np.zeros((vmc.HEIGHT, vmc.WIDTH), np.uint8) + for cr in range(vmc.N_ROWS): + for cc in range(vmc.N_COLS): + ci = cr * vmc.N_COLS + cc + f = color[ci] & 0x07 + lut = [bg, border, f, aux] + ch = int(screen[ci]) + for r in range(8): + byte = chardata[ch * 8 + r] + for x in range(4): + code = (byte >> (6 - 2 * x)) & 3 + dec[cr * 8 + r, cc * 4 + x] = lut[code] + assert np.array_equal(dec, c.index_image) + # colour RAM must flag multicolour (bit 3) and keep fg within 0-7 + assert np.all((color & 0x08) == 0x08) + assert np.all((color & 0x07) <= 7) + + +def test_vic20_hires_roundtrip(): + from lenser.vic20.convert import hires as vhi + img = imageprep.prepare(_imgobj(vhi.WIDTH, vhi.HEIGHT), vhi.WIDTH, vhi.HEIGHT, + vhi.PIXEL_ASPECT, imageprep.PrepOptions()) + c = vhi.convert(img) + d = c.data + chardata, screen, color = d["chardata"], d["screen"], d["color"] + bg = d["bg"] + dec = np.zeros((vhi.HEIGHT, vhi.WIDTH), np.uint8) + for cr in range(vhi.N_ROWS): + for cc in range(vhi.N_COLS): + ci = cr * vhi.N_COLS + cc + f = color[ci] & 0x07 + ch = int(screen[ci]) + for r in range(8): + byte = chardata[ch * 8 + r] + for x in range(8): + dec[cr * 8 + r, cc * 8 + x] = f if (byte >> (7 - x)) & 1 else bg + assert np.array_equal(dec, c.index_image) + + +def test_spectrum_roundtrip(): + from lenser.spectrum.convert import hires as zh + from lenser.spectrum import palette as zpal + img = imageprep.prepare(_imgobj(zh.WIDTH, zh.HEIGHT), zh.WIDTH, zh.HEIGHT, + zh.PIXEL_ASPECT, imageprep.PrepOptions()) + c = zh.convert(img) + scr = c.data + assert len(scr) == 6912 + dec = np.zeros((zh.HEIGHT, zh.WIDTH), np.uint8) + for cy in range(zh.N_ROWS): + for cx in range(zh.N_COLS): + attr = scr[6144 + cy * 32 + cx] + bright = (attr >> 6) & 1 + ink = (bright << 3) | (attr & 7) + paper = (bright << 3) | ((attr >> 3) & 7) + for r in range(8): + y = cy * 8 + r + byte = scr[zh._bitmap_offset(y, cx)] + for px in range(8): + bit = (byte >> (7 - px)) & 1 + dec[y, cx * 8 + px] = ink if bit else paper + assert np.array_equal(dec, c.index_image) + # every cell's two colours must share the BRIGHT bit (no normal/bright mix) + for cy in range(zh.N_ROWS): + for cx in range(zh.N_COLS): + attr = scr[6144 + cy * 32 + cx] + assert (attr & 0x80) == 0 # FLASH never set + + +def test_mono_modes_available_and_convert(): + """EVERY platform offers a monochrome mode that converts to a luminance- + matched image (indices restricted to its grey ramp).""" + from lenser import platforms + for p in platforms.PLATFORMS: + assert "mono" in platforms.modes(p), f"{p} missing mono mode" + # the mono mode actually converts (greyscale) on every platform + prep0 = imageprep.PrepOptions() + src = _imgobj(320, 200) + for p in platforms.PLATFORMS: + c = platforms.convert(p, src, "mono", platforms.palettes(p)[0], + "floyd", False, prep0, "grayscale") + assert c.mode == "mono", f"{p} mono did not report mode 'mono'" + prep = imageprep.PrepOptions() + img = _imgobj(256, 192) + # ti99 + spectrum mono decode exactly like their gm2 / hires colour formats + from lenser.ti99.convert import convert_image as ti + c = ti(img, mode="mono", dither_mode="atkinson") + assert len(c.data) == 6912 and c.mode == "mono" + from lenser.spectrum.convert import convert_image as zx + c = zx(img, mode="mono", dither_mode="atkinson") + assert len(c.data) == 6912 + # dictionary platforms: mono produces the same data dict their viewer expects + from lenser.vic20.convert import convert_image as v + d = v(img, mode="mono", dither_mode="floyd").data + assert d["bg"] == 0 and len(d["chardata"]) == 2048 and d["color"].max() <= 7 + from lenser.intv.convert import convert_image as iv + d = iv(img, mode="mono", dither_mode="none").data + assert len(d["gram"]) == 64 * 8 and len(d["cards"]) == 240 + + +def test_a5200_cart(): + """Atari 5200 reuses the Atari GTIA encoders and packs a 32K cartridge whose + display list + bitmap ANTIC reads straight from ROM.""" + from lenser.a5200.convert import convert_image + from lenser.a5200.viewer.assemble import build_cart, have_xa, BITMAP_ADDR + img = _imgobj(160, 192) + for mode in ("gr15", "gr8", "gr9"): + c = convert_image(img, mode=mode, dither_mode="floyd") + assert c.mode == mode + if not have_xa(): + continue + # all three display durations assemble + pack to 32K + for disp, secs in (("forever", 0), ("key", 0), ("seconds", 10)): + rom = build_cart(c.mode, bytes(c.data), display=disp, seconds=secs) + assert len(rom) == 0x8000 # 32K + # start vector at $BFFE-F points into the cart + start = rom[0xBFFE - 0x4000] | (rom[0xBFFF - 0x4000] << 8) + assert start == 0x4000 + # bitmap landed at $6000 (so the 4K split maps to $6000/$7000) + assert rom[BITMAP_ADDR - 0x4000:BITMAP_ADDR - 0x4000 + 8] == c.data[:8] + + +def test_a7800_cart(): + """Atari 7800 (MARIA) packs a 48K .a78 with the bitmap + display lists + DLL + that MARIA DMAs from ROM.""" + from lenser.a7800.convert import convert_image + from lenser.a7800.viewer.assemble import (build_cart, have_xa, BITMAP_ADDR, + DLL_ADDR, LINES, N_SEG) + img = _imgobj(160, 192) + for mode in ("c160", "mono"): + c = convert_image(img, mode=mode, dither_mode="floyd") + assert c.mode == mode + # data = bitmap(7680) + seg_palettes(192*4) + colours(25) + assert len(c.data) == 7680 + LINES * N_SEG + 25 + assert all(p < 8 for p in c.data[7680:7680 + LINES * N_SEG]) # palette 0-7 + if not have_xa(): + continue + rom = build_cart(bytes(c.data), title="t") + assert len(rom) == 128 + 0xC000 # header + 48K + assert rom[1:1 + 9] == b"ATARI7800" # .a78 signature + body = rom[128:] + # reset vector points at the viewer entry ($4000) + assert body[0xFFFC - 0x4000] | (body[0xFFFD - 0x4000] << 8) == 0x4000 + # bitmap landed at $8000 + assert body[BITMAP_ADDR - 0x4000:BITMAP_ADDR - 0x4000 + 8] == c.data[:8] + + +def test_spectrum_sna(): + from lenser.spectrum.convert import hires as zh + from lenser.spectrum import snapshot + img = imageprep.prepare(_imgobj(zh.WIDTH, zh.HEIGHT), zh.WIDTH, zh.HEIGHT, + zh.PIXEL_ASPECT, imageprep.PrepOptions()) + sna = snapshot.build_sna(zh.convert(img).data, border=0) + assert len(sna) == 27 + 49152 # header + 48K RAM + ram = sna[27:] + assert ram[0x8000 - 0x4000:0x8000 - 0x4000 + 3] == bytes([0xF3, 0x18, 0xFE]) + # SP (header offset 0x17) points at the stub return address + sp = sna[0x17] | (sna[0x18] << 8) + lo = ram[sp - 0x4000]; hi = ram[sp - 0x4000 + 1] + assert (lo | (hi << 8)) == 0x8000 + + +def test_vic20_cart_builds(): + from lenser.vic20.viewer.assemble import build_cart, have_xa + if not have_xa(): + return # xa not installed; skip + from lenser.vic20.convert import multicolor as vmc + img = imageprep.prepare(_imgobj(vmc.WIDTH, vmc.HEIGHT), vmc.WIDTH, vmc.HEIGHT, + vmc.PIXEL_ASPECT, imageprep.PrepOptions()) + rom = build_cart(vmc.convert(img).data) + assert len(rom) == 0x2000 # full 8K cart + assert rom[4:9] == bytes([0x41, 0x30, 0xC3, 0xC2, 0xCD]) # "A0CBM" signature + + +def test_c128_mono_prg(): + # mono is high-res greyscale via the custom-charset (font) path, like hicolor + from lenser.c128.convert import mono as c128mono, hicolor as hc + from lenser.c128.viewer.assemble import (build_prg_hicolor, have_xa, + BASIC_START, DATA_ORG, _STUB) + img = imageprep.prepare(_imgobj(c128mono.WIDTH, c128mono.HEIGHT), + c128mono.WIDTH, c128mono.HEIGHT, + c128mono.PIXEL_ASPECT, imageprep.PrepOptions()) + conv = c128mono.convert(img) + assert conv.mode == "mono" + assert len(conv.data) == hc.VDC_LEN # full VDC RAM image + assert conv.meta["vdc_mode"] == "hicolor" # uses the font-mode viewer + # mono only uses the four greys, so every cell's ink is one of them + greys = set(c128mono.GREYS) + attrs = conv.data[hc.ATTR_ADDR:hc.ATTR_ADDR + hc.ROWS * hc.COLS] + assert all((b & 0x0F) in greys for b in attrs) + if not have_xa(): + return # xa not installed; skip the assembly half + prg = build_prg_hicolor(bytes(conv.data), conv.meta["fgbg"]) + assert prg[:2] == bytes([BASIC_START & 0xFF, BASIC_START >> 8]) # load $1C01 + assert prg[2:2 + len(_STUB)] == _STUB # BASIC 10 SYS7200 stub + assert bytes(prg[-hc.VDC_LEN:]) == bytes(conv.data) + assert len(prg) == 2 + (DATA_ORG - BASIC_START) + hc.VDC_LEN + + +def test_c128_color_prg(): + from lenser.c128.convert import color as c128color + from lenser.c128.viewer.assemble import (build_prg_color, have_xa, + BASIC_START, DATA_ORG) + img = imageprep.prepare(_imgobj(c128color.WIDTH, c128color.HEIGHT), + c128color.WIDTH, c128color.HEIGHT, + c128color.PIXEL_ASPECT, imageprep.PrepOptions()) + conv = c128color.convert(img) + assert len(conv.data) == 8000 # 80x100 attribute bytes + assert conv.meta["vdc_mode"] == "color" + # colour lives in the high nibble; low nibble (bg) is 0 + assert all((b & 0x0F) == 0 for b in conv.data) + if not have_xa(): + return + prg = build_prg_color(bytes(conv.data), conv.meta["fgbg"]) + assert prg[:2] == bytes([BASIC_START & 0xFF, BASIC_START >> 8]) + assert bytes(prg[-8000:]) == bytes(conv.data) # attributes land at $2000 + assert len(prg) == 2 + (DATA_ORG - BASIC_START) + 8000 + + +def test_c128_hicolor_prg(): + from lenser.c128.convert import hicolor as hc + from lenser.c128.viewer.assemble import (build_prg_hicolor, have_xa, + BASIC_START, DATA_ORG) + img = imageprep.prepare(_imgobj(hc.WIDTH, hc.HEIGHT), hc.WIDTH, hc.HEIGHT, + hc.PIXEL_ASPECT, imageprep.PrepOptions()) + conv = hc.convert(img) + assert len(conv.data) == hc.VDC_LEN # full 16K VDC RAM image + assert conv.meta["vdc_mode"] == "hicolor" + # ink in the low nibble, bit 7 may select bank 1; blink/underline/reverse off + attrs = conv.data[hc.ATTR_ADDR:hc.ATTR_ADDR + hc.ROWS * hc.COLS] + assert all((b & 0x70) == 0 for b in attrs) + if not have_xa(): + return + prg = build_prg_hicolor(bytes(conv.data), conv.meta["fgbg"]) + assert prg[:2] == bytes([BASIC_START & 0xFF, BASIC_START >> 8]) + assert bytes(prg[-hc.VDC_LEN:]) == bytes(conv.data) + assert len(prg) == 2 + (DATA_ORG - BASIC_START) + hc.VDC_LEN + + +def test_c16_hires_prg(): + from lenser.c16.convert import hires as c16h + from lenser.c16.viewer.assemble import (build_prg, have_xa, BASIC_START, + BITMAP_ORG, _STUB) + img = imageprep.prepare(_imgobj(c16h.WIDTH, c16h.HEIGHT), c16h.WIDTH, + c16h.HEIGHT, c16h.PIXEL_ASPECT, imageprep.PrepOptions()) + conv = c16h.convert(img) + assert conv.mode == "hires" + assert len(conv.data) == 10000 # bitmap 8000 + attr 1000 + ch 1000 + if not have_xa(): + return + prg = build_prg(bytes(conv.data)) + assert prg[:2] == bytes([BASIC_START & 0xFF, BASIC_START >> 8]) # load $1001 + assert prg[2:2 + len(_STUB)] == _STUB # 10 SYS4128 + # the 8000-byte bitmap lands at $2000 + off = 2 + (BITMAP_ORG - BASIC_START) + assert bytes(prg[off:off + 8000]) == bytes(conv.data[:8000]) + + +def test_plus4_reuses_c16(): + # Plus/4 uses the same TED + BASIC 3.5 as the C16, so its encoder, modes and + # .prg are identical -- the plus4 package re-exports the C16 implementation. + from lenser.plus4.convert import MODES, convert_image + from lenser.c16.convert import MODES as C16_MODES, convert_image as c16_convert + from lenser.plus4.exporter import export_prg + from lenser.c16.exporter import export_prg as c16_export_prg + assert MODES == C16_MODES + assert convert_image is c16_convert and export_prg is c16_export_prg + img = _imgobj(320, 200) + conv = convert_image(img, mode="hires", dither_mode="floyd") + assert conv.mode == "hires" and len(conv.data) == 10000 + + +def test_cpc_sna(): + from lenser.cpc.convert import convert_image, MODES + from lenser.cpc.exporter import export_sna + from lenser.cpc import snapshot + import tempfile, os + img = _imgobj(320, 200) + assert MODES == ["mode0", "mode1", "mono"] + for m, ncol in (("mode0", 16), ("mode1", 4), ("mono", 2)): + conv = convert_image(img, mode=m, dither_mode="floyd") + assert len(conv.data) == 0x4000 # 16K screen at &C000 + assert conv.data_addr == 0xC000 + assert 1 <= len(conv.meta["inks"]) <= ncol + with tempfile.TemporaryDirectory() as td: + p = export_sna(conv, os.path.join(td, "x.sna")) + sna = open(p, "rb").read() + assert sna[:8] == b"MV - SNA" # CPC snapshot signature + assert len(sna) == 0x100 + 0x10000 # 256 header + 64K RAM + assert (sna[0x40] & 0x03) == conv.meta["cpc_mode"] # mode in RMR + + +def test_coco3_cart(): + from lenser.coco3.convert import convert_image, MODES + from lenser.coco3.exporter import export_ccc + import tempfile, os + img = _imgobj(320, 192) + assert MODES == ["gr16", "gr4", "mono"] + for m, ncol in (("gr16", 16), ("gr4", 4), ("mono", 2)): + conv = convert_image(img, mode=m, dither_mode="floyd") + assert len(conv.data) == 15360 # 80 bytes/row * 192, linear + assert conv.data_addr == 0x4000 + assert 1 <= len(conv.meta["inks"]) <= ncol + assert all(0 <= c < 64 for c in conv.meta["inks"]) # 6-bit GIME colours + with tempfile.TemporaryDirectory() as td: + p = export_ccc(conv, os.path.join(td, "x.ccc")) + rom = open(p, "rb").read() + assert len(rom) == 0x4000 # 16K cartridge + assert rom[:2] == bytes([0x1A, 0x50]) # viewer starts: ORCC #$50 + + +def test_nes_cart(): + from lenser.nes.convert import convert_image, MODES + from lenser.nes.exporter import export_nes + from lenser.nes.cartridge import have_xa + import tempfile, os + img = _imgobj(256, 240) + assert MODES == ["bg", "mono"] + for m in MODES: + conv = convert_image(img, mode=m, dither_mode="floyd") + d = conv.data + assert len(d["palette"]) == 32 + assert len(d["nametable"]) == 1024 # 960 names + 64 attribute + assert len(d["chr"]) == 8192 # 8K CHR-ROM (256 bg tiles) + assert all(0 <= b < 64 for b in d["palette"]) # 6-bit NES colours + if not have_xa(): + continue + with tempfile.TemporaryDirectory() as td: + p = export_nes(conv, os.path.join(td, "x.nes")) + rom = open(p, "rb").read() + assert rom[:4] == b"NES\x1a" # iNES signature + assert rom[4] == 1 and rom[5] == 1 # 16K PRG + 8K CHR (NROM) + assert len(rom) == 16 + 0x4000 + 0x2000 + # reset vector ($FFFC) -> $C000 (viewer start) + assert rom[16 + 0x3FFC] == 0x00 and rom[16 + 0x3FFD] == 0xC0 + + +def test_iigs_dsk(): + from lenser.iigs.convert import convert_image, MODES + from lenser.iigs.viewer.assemble import assemble_boot, have_xa + from lenser.iigs.exporter import export_dsk + import tempfile, os + img = _imgobj(320, 200) + assert MODES == ["shr", "mono"] + for m in MODES: + conv = convert_image(img, mode=m, dither_mode="floyd") + assert len(conv.data) == 0x8000 # 32K SHR block ($2000-$9FFF) + assert conv.data_addr == 0x2000 + if not have_xa(): + return + boot = assemble_boot() + assert len(boot) <= 256 # fits one boot sector + assert boot[1] == 0xAD # entry: LDA dpage (abs) + with tempfile.TemporaryDirectory() as td: + conv = convert_image(img, mode="shr", dither_mode="floyd") + p = export_dsk(conv, os.path.join(td, "x.dsk")) + dsk = open(p, "rb").read() + assert len(dsk) == 143360 # 140K 5.25" disk + assert dsk[:len(boot)] == boot # boot sector at track 0 sector 0 + + +def test_pet_prg(): + from lenser.pet.convert import convert_image + from lenser.pet import palette as petpal + from lenser.pet.viewer.assemble import build_prg, have_xa, BASIC_START, _STUB + # all 16 quadrant codes distinct -> a real one-to-one block mapping + assert len(set(petpal.QUAD)) == 16 + for cols, scr in ((40, 1000), (80, 2000)): + conv = convert_image(_imgobj(320, 200), cols=cols, dither_mode="floyd") + assert conv.mode == "mono" + assert len(conv.data) == scr # screen-RAM bytes + assert conv.data_addr == 0x8000 + assert all(b in petpal.QUAD for b in conv.data) # only quadrant codes + if not have_xa(): + continue + prg = build_prg(bytes(conv.data)) + assert prg[:2] == bytes([BASIC_START & 0xFF, BASIC_START >> 8]) # $0401 + assert prg[2:2 + len(_STUB)] == _STUB # 10 SYS1056 + + +def test_sms_cart(): + from lenser.sms.convert import convert_image, MODES + from lenser.sms.exporter import export_sms + from lenser.sms import viewer as smsv + import tempfile, os + img = _imgobj(256, 192) + assert MODES == ["bg", "mono"] + for m in MODES: + conv = convert_image(img, mode=m, dither_mode="floyd") + d = conv.data + assert len(d["patterns"]) == 448 * 32 # <=448 tiles, no name-table clash + assert len(d["nametable"]) == 32 * 24 * 2 + assert len(d["palette"]) == 32 # 2 x 16 colours + assert all(0 <= b < 64 for b in d["palette"]) + with tempfile.TemporaryDirectory() as td: + p = export_sms(conv, os.path.join(td, "x.sms")) + rom = open(p, "rb").read() + assert len(rom) == 0x8000 # 32K cartridge + assert rom[:1] == bytes([0xF3]) # Z80 viewer starts with DI + assert rom[0x7FF0:0x7FF8] == b"TMR SEGA" # SMS header signature + + +def test_amiga_adf(): + from lenser.amiga.convert import convert_image, MODES + from lenser.amiga.exporter import export_adf + import tempfile, os, struct + img = _imgobj(320, 200) + assert MODES == ["lowres", "mono"] + for m, nplanes in (("lowres", 5), ("mono", 4)): + conv = convert_image(img, mode=m, dither_mode="floyd") + d = conv.data + assert d["nplanes"] == nplanes and d["ham"] is False + assert len(d["planes"]) == nplanes * 40 * 200 # contiguous bitplanes + assert all(0 <= c < 4096 for c in d["colors"]) # 12-bit colours + with tempfile.TemporaryDirectory() as td: + p = export_adf(conv, os.path.join(td, "x.adf")) + adf = open(p, "rb").read() + assert len(adf) == 901120 # 880K floppy + assert adf[0:4] == b"DOS\x00" # boot block id + # boot block longword checksum must total $FFFFFFFF (else not bootable) + s = 0 + for i in range(0, 1024, 4): + s += struct.unpack(">I", adf[i:i + 4])[0] + if s > 0xFFFFFFFF: + s = (s + 1) & 0xFFFFFFFF + assert s == 0xFFFFFFFF + + def test_viewers_assemble_and_fit(): if not have_xa(): return # xa not installed; skip sizes = {"hires": 9000, "multicolor": 10001, "fli": 17385, "fli_ntsc": 17385, "interlace": 25577} for key in SOURCES: + if key not in sizes: + continue # e.g. "slideshow" is code-only (own builder/test) prg = build_viewer_prg(key, bytes(sizes[key]), 0x4000 if key.startswith("fli") else 0x2000) assert prg[:2] == bytes([0x01, 0x08]) # PRG load address $0801 diff --git a/tests/test_slideshow.py b/tests/test_slideshow.py new file mode 100644 index 0000000..e1d18b2 --- /dev/null +++ b/tests/test_slideshow.py @@ -0,0 +1,381 @@ +"""Slideshow tests: storage budget math, viewer assembly fit, and a full disk +round-trip (viewer + N data files written and read back). + +Run with `pytest` or directly: `python tests/test_slideshow.py`. +""" + +import os +import subprocess +import sys +import tempfile + +import numpy as np +from PIL import Image + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from lenser import slideshow as ss # noqa: E402 +from lenser import imageprep # noqa: E402 +from lenser.diskimage import DiskError, have_c1541 # noqa: E402 +from lenser.viewer.assemble import (build_slideshow_prg, # noqa: E402 + have_xa) + + +def _gradient_png(path, w=160, h=200): + yy, xx = np.mgrid[0:h, 0:w] + rgb = np.stack([(xx * 255 // w), (yy * 255 // h), + ((xx + yy) * 255 // (w + h))], axis=-1).astype(np.uint8) + Image.fromarray(rgb, "RGB").save(path) + + +def test_slideshow_budget(): + # CBM block rounding: a PRG of L bytes -> ceil((L+2)/254) blocks. + assert ss.item_blocks("c64", "d64", 10001) == 40 # multicolor + assert ss.item_blocks("c64", "d64", 9000) == 36 # hires/mono + assert ss.item_blocks("c64", "d64", 1) == 1 + + b = ss.budget("c64", "d64", [10001, 9000, 9000], viewer_len=283) + assert b.fits and b.files == 4 + assert b.used_blocks == ss._cbm_blocks(283) + 40 + 36 + 36 + + # too many multicolor images for a d64, but fine on a d81 + many = [10001] * 20 + assert not ss.budget("c64", "d64", many, 283).fits + assert ss.budget("c64", "d81", many, 283).fits + + # directory-entry cap (d81 = 296) is enforced even when blocks would fit: + # 300 one-block files use ~301 blocks (well under 3160) but 301 dir entries. + tiny = [10] * 300 + over = ss.budget("c64", "d81", tiny, 283) + assert over.used_blocks < over.total_blocks # blocks would fit + assert not over.fits and "directory" in over.reason + + +def test_slideshow_stub_assembles_and_fits(): + if not have_xa(): + return + for advance in ("key", "seconds", "both"): + for loop in (True, False): + prg = build_slideshow_prg([0, 1, 0], advance=advance, seconds=5, + loop=loop) + assert prg[:2] == bytes([0x01, 0x08]) # load address $0801 + end = 0x0801 + (len(prg) - 2) + assert end < 0x2000 # code clears picture RAM + + +def test_slideshow_disk_roundtrip(): + if not (have_xa() and have_c1541()): + return + with tempfile.TemporaryDirectory() as td: + src = os.path.join(td, "g.png") + _gradient_png(src) + items = [ + ss.SlideItem(src, mode="multicolor"), + ss.SlideItem(src, mode="hires", + prep=imageprep.PrepOptions(brightness=1.2)), + ss.SlideItem(src, mode="mono", mono_base="green"), + ] + show = ss.Slideshow(platform="c64", disk_format="d64", advance="both", + seconds=5, loop=True, items=items) + convs = [ss.convert_item(show, it) for it in items] + out = os.path.join(td, "show.d64") + ss.build_disk(show, out, convs=convs) + + # directory lists the viewer first, then 00, 01, 02 in order + dirout = subprocess.run(["c1541", "-attach", out, "-dir"], + capture_output=True, text=True).stdout + assert '"show"' in dirout + for i in range(3): + assert f'"{i:02d}"' in dirout + + # each data file reads back byte-identical to the conversion payload + for i, c in enumerate(convs): + host = os.path.join(td, f"r{i}.prg") + subprocess.run(["c1541", "-attach", out, "-read", f"{i:02d}", host], + capture_output=True) + raw = open(host, "rb").read() + assert raw[0] | (raw[1] << 8) == c.data_addr + assert raw[2:] == c.data + + +def test_slideshow_flavor_detection(): + class C: + def __init__(self, v): + self.viewer = v + assert ss.slideshow_flavor([C("hires"), C("multicolor"), C("hires")]) == "simple" + assert ss.slideshow_flavor([C("fli"), C("fli")]) == "fli" + assert ss.slideshow_flavor([C("interlace")]) == "interlace" + for bad in ([C("hires"), C("fli")], [C("fli"), C("interlace")]): + try: + ss.slideshow_flavor(bad) + assert False, "mixed flavors should be rejected" + except ValueError: + pass + + +def test_fli_interlace_stubs_fit(): + if not have_xa(): + return + # fli code must clear $4000, interlace must clear $2000 (their data regions) + for flavor, limit in (("fli", 0x4000), ("interlace", 0x2000)): + for video in ("pal", "ntsc"): + prg = build_slideshow_prg([0, 0, 0], advance="both", seconds=5, + loop=True, video=video, flavor=flavor) + assert prg[:2] == bytes([0x01, 0x08]) + assert 0x0801 + (len(prg) - 2) < limit + + +def test_fli_interlace_disk_roundtrip(): + if not (have_xa() and have_c1541()): + return + with tempfile.TemporaryDirectory() as td: + src = os.path.join(td, "g.png") + _gradient_png(src) + for flavor, mode in (("fli", "fli"), ("interlace", "interlace")): + items = [ss.SlideItem(src, mode=mode), ss.SlideItem(src, mode=mode)] + show = ss.Slideshow(platform="c64", disk_format="d64", advance="both", + seconds=5, loop=True, items=items) + convs = [ss.convert_item(show, it) for it in items] + assert ss.slideshow_flavor(convs) == flavor + out = os.path.join(td, f"{flavor}.d64") + ss.build_disk(show, out, convs=convs) + for i, c in enumerate(convs): + host = os.path.join(td, f"{flavor}{i}.prg") + subprocess.run(["c1541", "-attach", out, "-read", f"{i:02d}", host], + capture_output=True) + raw = open(host, "rb").read() + assert raw[0] | (raw[1] << 8) == c.data_addr # $4000 fli / $2000 ifli + assert raw[2:] == c.data + + +def test_c128_slideshow(): + assert ss.supports_slideshow("c128") + # CBM-DOS budget shared with the C64 + assert ss.item_blocks("c128", "d64", 16384) == ss.item_blocks("c64", "d64", 16384) + if not (have_xa() and have_c1541()): + return + with tempfile.TemporaryDirectory() as td: + src = os.path.join(td, "g.png") + _gradient_png(src) + items = [ss.SlideItem(src, mode="hicolor"), ss.SlideItem(src, mode="mono")] + show = ss.Slideshow(platform="c128", disk_format="d64", advance="both", + seconds=5, loop=True, items=items) + convs = [ss.convert_item(show, it) for it in items] + out = os.path.join(td, "c128.d64") + ss.build_disk(show, out, convs=convs) + dirout = subprocess.run(["c1541", "-attach", out, "-dir"], + capture_output=True, text=True).stdout + assert '"pic"' in dirout # boots via RUN"PIC" + for i in range(2): + assert f'"{i:02d}"' in dirout + host = os.path.join(td, f"r{i}.prg") + subprocess.run(["c1541", "-attach", out, "-read", f"{i:02d}", host], + capture_output=True) + raw = open(host, "rb").read() + assert raw[0] | (raw[1] << 8) == 0x4000 # VDC images load to $4000 + assert raw[2:] == convs[i].data + # the 80x100 'color' VDC mode is not yet supported in slideshows + cshow = ss.Slideshow(platform="c128", items=[ss.SlideItem(src, mode="color")]) + try: + ss.check_modes("c128", [ss.convert_item(cshow, cshow.items[0])]) + assert False, "color mode should be rejected" + except ValueError: + pass + + +def test_atari_slideshow(): + assert ss.supports_slideshow("atari") + assert ss.disk_formats("atari") == ["atr"] + # ATR sectors are 128 bytes, no 2-byte load-address overhead + assert ss.item_blocks("atari", "atr", 8196) == 65 + assert ss.item_blocks("atari", "atr", 256) == 2 + if not have_xa(): + return + with tempfile.TemporaryDirectory() as td: + src = os.path.join(td, "g.png") + _gradient_png(src, 160, 192) + items = [ss.SlideItem(src, mode="gr15") for _ in range(3)] + show = ss.Slideshow(platform="atari", disk_format="atr", advance="both", + seconds=5, loop=True, items=items) + convs = [ss.convert_item(show, it) for it in items] + out = os.path.join(td, "show.atr") + ss.build_disk(show, out, convs=convs) + raw = open(out, "rb").read() + assert raw[:2] == bytes([0x96, 0x02]) # ATR magic + assert raw[16 + 1] == 8 # boot loads 8 sectors + spi = ss.item_blocks("atari", "atr", len(convs[0].data)) + for i, c in enumerate(convs): + sector = 9 + i * spi # boot_sectors(8)+1 + i*spi + off = 16 + (sector - 1) * 128 + assert raw[off:off + len(c.data)] == c.data + # gr15 / gr9 / gr8 / gr15dli all build a disk + for mode in ("gr9", "gr8", "gr15dli"): + sh = ss.Slideshow(platform="atari", disk_format="atr", + items=[ss.SlideItem(src, mode=mode)] * 2) + ss.build_disk(sh, os.path.join(td, f"{mode}.atr")) + # but modes may not be MIXED in one slideshow + mix = [ss.convert_item(ss.Slideshow(platform="atari"), + ss.SlideItem(src, mode=m)) for m in ("gr15", "gr9")] + try: + ss.check_modes("atari", mix) + assert False, "mixed Atari modes should be rejected" + except ValueError: + pass + + +def test_bbc_slideshow(): + assert ss.supports_slideshow("bbc") + assert ss.disk_formats("bbc") == ["ssd"] + assert ss.item_blocks("bbc", "ssd", 20480) == 80 # 256-byte DFS sectors + if not have_xa(): + return + with tempfile.TemporaryDirectory() as td: + src = os.path.join(td, "g.png") + _gradient_png(src, 320, 256) + items = [ss.SlideItem(src, mode="mode1") for _ in range(3)] + show = ss.Slideshow(platform="bbc", disk_format="ssd", advance="both", + seconds=5, loop=True, items=items) + convs = [ss.convert_item(show, it) for it in items] + out = os.path.join(td, "show.ssd") + ss.build_disk(show, out, convs=convs) + d = open(out, "rb").read() + nfiles = d[0x105] // 8 + names = [d[8 + i * 8:8 + i * 8 + 7].decode("ascii").rstrip() + for i in range(nfiles)] + assert "!BOOT" in names and "PIC" in names + for i in range(3): + assert f"{i:02d}" in names + j = names.index(f"{i:02d}") + e = 0x100 + 8 + j * 8 + start = ((d[e + 6] & 3) << 8) | d[e + 7] + length = (((d[e + 6] >> 4) & 3) << 16) | (d[e + 5] << 8) | d[e + 4] + off = start * 256 + assert d[off:off + length] == convs[i].data + # a single BBC screen mode per slideshow + m2 = ss.convert_item(ss.Slideshow(platform="bbc"), + ss.SlideItem(src, mode="mode2")) + try: + ss.check_modes("bbc", [convs[0], m2]) + assert False, "mixed BBC modes should be rejected" + except ValueError: + pass + + +def test_apple_slideshow(): + assert ss.supports_slideshow("apple") + assert ss.disk_formats("apple") == ["dsk"] + assert ss.item_blocks("apple", "dsk", 8192) == 32 + assert ss.budget("apple", "dsk", [8192] * 4, 0).fits # RAM holds 4 HGR + assert not ss.budget("apple", "dsk", [8192] * 5, 0).fits + if not have_xa(): + return + with tempfile.TemporaryDirectory() as td: + src = os.path.join(td, "g.png") + _gradient_png(src, 280, 192) + items = [ss.SlideItem(src, mode="hgr_color") for _ in range(3)] + show = ss.Slideshow(platform="apple", disk_format="dsk", advance="both", + seconds=5, loop=True, items=items) + convs = [ss.convert_item(show, it) for it in items] + out = os.path.join(td, "show.dsk") + ss.build_disk(show, out, convs=convs) + d = open(out, "rb").read() + assert len(d) == 143360 # DOS 3.3 .dsk + assert d[0] == 0x01 # boot sector byte 0 + # too many images for RAM + try: + ss.check_modes("apple", convs * 2) # 6 > 4 + assert False, "over-RAM Apple slideshow should be rejected" + except ValueError: + pass + # DHGR is not supported for slideshows yet + dh = ss.convert_item(ss.Slideshow(platform="apple"), + ss.SlideItem(src, mode="dhgr")) + try: + ss.check_modes("apple", [dh]) + assert False, "DHGR should be rejected" + except ValueError: + pass + + +def test_iigs_slideshow(): + assert ss.supports_slideshow("iigs") + assert ss.disk_formats("iigs") == ["dsk"] + assert ss.item_blocks("iigs", "dsk", 32768) == 128 + assert ss.budget("iigs", "dsk", [32768] * 4, 0).fits + assert not ss.budget("iigs", "dsk", [32768] * 5, 0).fits + if not have_xa(): + return + # the two-stage loader: boot must fit one 256-byte sector + from lenser.iigs.viewer.assemble import build_slideshow + boot, stage2, pages = build_slideshow(3, advance="both", seconds=5, loop=True) + assert len(boot) <= 256 and len(stage2) > 0 and pages >= 1 + with tempfile.TemporaryDirectory() as td: + src = os.path.join(td, "g.png") + _gradient_png(src, 320, 200) + items = [ss.SlideItem(src, mode="shr") for _ in range(3)] + show = ss.Slideshow(platform="iigs", disk_format="dsk", advance="both", + seconds=5, loop=True, items=items) + convs = [ss.convert_item(show, it) for it in items] + out = os.path.join(td, "show.dsk") + ss.build_disk(show, out, convs=convs) + d = open(out, "rb").read() + assert len(d) == 143360 and d[0] == 0x01 # bootable .dsk + try: + ss.check_modes("iigs", convs * 2) # 6 > 4 + assert False, "over-capacity IIgs slideshow should be rejected" + except ValueError: + pass + + +def test_amiga_slideshow(): + assert ss.supports_slideshow("amiga") + assert ss.disk_formats("amiga") == ["adf"] + with tempfile.TemporaryDirectory() as td: + src = os.path.join(td, "g.png") + _gradient_png(src, 320, 256) + items = [ss.SlideItem(src, mode="lowres") for _ in range(3)] + show = ss.Slideshow(platform="amiga", disk_format="adf", advance="both", + seconds=4, loop=True, items=items) + convs = [ss.convert_item(show, it) for it in items] + # Amiga conv.data is a dict; its on-disk size comes from the blob + nb = ss.image_nbytes(convs[0]) + assert nb > 8000 and ss.item_blocks("amiga", "adf", nb) == -(-nb // 512) + out = os.path.join(td, "show.adf") + ss.build_disk(show, out, convs=convs) + d = open(out, "rb").read() + assert len(d) == 901120 and d[:4] == b"DOS\x00" # bootable 880K .adf + # chip-RAM bound rejects an oversized show + assert not ss.budget("amiga", "adf", [40000] * 20, 0).fits + + +def test_slideshow_overfill_raises(): + if not have_xa(): + return + with tempfile.TemporaryDirectory() as td: + src = os.path.join(td, "g.png") + _gradient_png(src) + # 25 multicolor images (~40 blocks each) cannot fit a 664-block d64 + items = [ss.SlideItem(src, mode="multicolor") for _ in range(25)] + show = ss.Slideshow(disk_format="d64", items=items) + # reuse one conversion for all to keep the test fast + c0 = ss.convert_item(show, items[0]) + convs = [c0] * 25 + out = os.path.join(td, "show.d64") + try: + ss.build_disk(show, out, convs=convs) + assert False, "expected DiskError for an over-budget slideshow" + except DiskError as e: + assert "does not fit" in str(e) + + +def _imgobj(w, h): + arr = np.zeros((h, w, 3), np.uint8) + return Image.fromarray(arr, "RGB") + + +if __name__ == "__main__": + fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + for fn in fns: + fn() + print(f"PASS {fn.__name__}") + print(f"\nAll {len(fns)} slideshow tests passed.")