"""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)