8bitlenser/lenser/atari/atr.py
2026-07-03 19:35:35 -07:00

101 lines
4.1 KiB
Python

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