First public commit.
This commit is contained in:
parent
2a48f52979
commit
4bac9d83ed
288 changed files with 18417 additions and 1076 deletions
1
lenser/apple/__init__.py
Normal file
1
lenser/apple/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Apple II (Apple II+/IIe) image conversion and bootable disk export."""
|
||||
20
lenser/apple/convert/__init__.py
Normal file
20
lenser/apple/convert/__init__.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""Apple II conversion dispatch."""
|
||||
from __future__ import annotations
|
||||
from ... import imageprep
|
||||
from . import hgr_mono
|
||||
|
||||
_MODULES = {"hgr_mono": hgr_mono}
|
||||
for _name in ("dhgr", "hgr_color", "mono"):
|
||||
try:
|
||||
_MODULES[_name] = __import__(f"lenser.apple.convert.{_name}", fromlist=[_name])
|
||||
except Exception:
|
||||
pass
|
||||
MODES = list(_MODULES.keys())
|
||||
|
||||
def convert_image(path_or_img, mode="hgr_mono", palette_name="mono",
|
||||
dither_mode="floyd", intensive=False, prep_opt=None, base_color=None):
|
||||
prep_opt = prep_opt or imageprep.PrepOptions()
|
||||
module = _MODULES[mode]
|
||||
img_rgb = imageprep.prepare(path_or_img, module.WIDTH, module.HEIGHT,
|
||||
module.PIXEL_ASPECT, prep_opt, border_rgb=(0, 0, 0))
|
||||
return module.convert(img_rgb, palette_name, dither_mode, intensive, base_color=base_color)
|
||||
60
lenser/apple/convert/dhgr.py
Normal file
60
lenser/apple/convert/dhgr.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Apple //e Double Hi-Res: 140x192, 16 colours (no per-cell limit).
|
||||
|
||||
Each line is 560 bits = 140 four-bit colour groups, stored 7 bits per byte with
|
||||
the bytes interleaved between auxiliary and main memory (aux holds the even display
|
||||
bytes, main the odd). Needs a //e (auxiliary memory).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ... import dither, palette as c64pal
|
||||
from ...convert.base import Conversion, perceptual_error
|
||||
from .. import palette as apal
|
||||
|
||||
WIDTH, HEIGHT = 140, 192
|
||||
PIXEL_ASPECT = 2.0
|
||||
DATA_ADDR = 0x2000
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="dhgr", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
plab = apal.dhgr_lab()
|
||||
prgb = apal.DHGR16.astype(np.uint8)
|
||||
img_lab = c64pal.srgb_to_lab(img_rgb) # (192,140,3)
|
||||
|
||||
allowed = np.tile(np.arange(16), (HEIGHT, WIDTH, 1))
|
||||
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
|
||||
|
||||
aux = bytearray(0x2000)
|
||||
main = bytearray(0x2000)
|
||||
for y in range(HEIGHT):
|
||||
stream = np.zeros(560, dtype=np.uint8)
|
||||
row = idx[y]
|
||||
for n in range(WIDTH):
|
||||
c = int(row[n])
|
||||
stream[4 * n + 0] = c & 1
|
||||
stream[4 * n + 1] = (c >> 1) & 1
|
||||
stream[4 * n + 2] = (c >> 2) & 1
|
||||
stream[4 * n + 3] = (c >> 3) & 1
|
||||
base = apal.hgr_row_addr(y)
|
||||
for col in range(40):
|
||||
ab = 0
|
||||
mb = 0
|
||||
for i in range(7):
|
||||
ab |= int(stream[7 * (2 * col) + i]) << i
|
||||
mb |= int(stream[7 * (2 * col + 1) + i]) << i
|
||||
aux[base + col] = ab
|
||||
main[base + col] = mb
|
||||
|
||||
data = bytes(main) + bytes(aux) # main half at $2000, aux at $4000
|
||||
preview = np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1)
|
||||
|
||||
return Conversion(
|
||||
mode="dhgr", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=idx.astype(np.uint16), data=data, data_addr=DATA_ADDR,
|
||||
viewer="dhgr", preview_rgb=preview,
|
||||
error=perceptual_error(idx, img_lab, plab),
|
||||
meta={"palette": "dhgr", "dither": dither_mode},
|
||||
)
|
||||
72
lenser/apple/convert/hgr_color.py
Normal file
72
lenser/apple/convert/hgr_color.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""Apple II HGR artifact colour: 140x192 colour pixels (280x192 mono), ~6 colours.
|
||||
|
||||
Each 2-mono-pixel "colour pixel" can be black, white, or one of two chroma colours
|
||||
set by its byte's palette bit (violet/green for palette 0, blue/orange for palette 1).
|
||||
We pick the palette bit per byte, then dither each colour pixel to its 4 reachable
|
||||
colours. Works on the II+ and //e, and reuses the HGR boot loader.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ... import dither, palette as c64pal
|
||||
from ...convert.base import Conversion, perceptual_error
|
||||
from .. import palette as apal
|
||||
|
||||
WIDTH, HEIGHT = 140, 192 # colour-pixel resolution
|
||||
PIXEL_ASPECT = 2.0
|
||||
DATA_ADDR = 0x2000
|
||||
N_BYTES = 40
|
||||
|
||||
# index sets reachable in a byte for each palette bit: {black, even-chroma, odd-chroma, white}
|
||||
_SET = {
|
||||
0: [apal.HGR_BLACK, apal.HGR_VIOLET, apal.HGR_GREEN, apal.HGR_WHITE],
|
||||
1: [apal.HGR_BLACK, apal.HGR_BLUE, apal.HGR_ORANGE, apal.HGR_WHITE],
|
||||
}
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="hgr", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
plab = apal.hgr6_lab()
|
||||
prgb = apal.HGR6.astype(np.uint8)
|
||||
img_lab = c64pal.srgb_to_lab(img_rgb) # (192,140,3)
|
||||
|
||||
# choose a palette bit per byte (per line), by nearest-colour error.
|
||||
pal_byte = np.zeros((HEIGHT, N_BYTES), dtype=np.uint8)
|
||||
allowed = np.zeros((HEIGHT, WIDTH, 4), dtype=np.int64)
|
||||
for y in range(HEIGHT):
|
||||
for b in range(N_BYTES):
|
||||
ks = [k for k in range(WIDTH) if (2 * k) // 7 == b]
|
||||
if not ks:
|
||||
continue
|
||||
best_p, best_e = 0, None
|
||||
for p in (0, 1):
|
||||
cols = np.array(_SET[p])
|
||||
d = np.sum((img_lab[y, ks][:, None, :] - plab[cols][None, :, :]) ** 2, axis=-1)
|
||||
e = d.min(axis=1).sum()
|
||||
if best_e is None or e < best_e:
|
||||
best_e, best_p = e, p
|
||||
pal_byte[y, b] = best_p
|
||||
for k in ks:
|
||||
allowed[y, k] = _SET[best_p]
|
||||
|
||||
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
|
||||
|
||||
# colour-pixel index -> two mono bits at (2k, 2k+1)
|
||||
bits = np.zeros((HEIGHT, WIDTH * 2), dtype=np.uint8)
|
||||
even_on = np.isin(idx, [apal.HGR_VIOLET, apal.HGR_BLUE, apal.HGR_WHITE])
|
||||
odd_on = np.isin(idx, [apal.HGR_GREEN, apal.HGR_ORANGE, apal.HGR_WHITE])
|
||||
bits[:, 0::2] = even_on
|
||||
bits[:, 1::2] = odd_on
|
||||
|
||||
data = apal.pack_hgr_color(bits, pal_byte)
|
||||
preview = np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1)
|
||||
|
||||
return Conversion(
|
||||
mode="hgr_color", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=idx.astype(np.uint16), data=data, data_addr=DATA_ADDR,
|
||||
viewer="hgr", preview_rgb=preview,
|
||||
error=perceptual_error(idx, img_lab, plab),
|
||||
meta={"palette": "hgr", "dither": dither_mode},
|
||||
)
|
||||
41
lenser/apple/convert/hgr_mono.py
Normal file
41
lenser/apple/convert/hgr_mono.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""Apple II HGR monochrome: 280x192, 1 bit/pixel, black & white.
|
||||
|
||||
Universal across the Apple II+ and //e. Tone is carried entirely by dithering.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ... import dither
|
||||
from ...convert.base import Conversion, perceptual_error
|
||||
from .. import palette as apal
|
||||
|
||||
WIDTH, HEIGHT = 280, 192
|
||||
PIXEL_ASPECT = 1.0
|
||||
DATA_ADDR = 0x2000 # HGR page 1
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="mono", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
from ...palette import srgb_to_lab
|
||||
plab = apal.mono_lab() # 2 entries: black, white
|
||||
L = srgb_to_lab(img_rgb)[..., 0]
|
||||
img_mono = np.zeros((HEIGHT, WIDTH, 3))
|
||||
img_mono[..., 0] = L
|
||||
plab_mono = np.zeros((2, 3))
|
||||
plab_mono[:, 0] = plab[:, 0]
|
||||
|
||||
allowed = np.tile(np.array([0, 1]), (HEIGHT, WIDTH, 1))
|
||||
idx = dither.quantize(img_mono, allowed, plab_mono, dither_mode).astype(np.uint8)
|
||||
|
||||
data = apal.pack_hgr_mono(idx) # 8192-byte HGR buffer
|
||||
preview = (apal.MONO.astype(np.uint8))[idx]
|
||||
|
||||
return Conversion(
|
||||
mode="hgr_mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=idx.astype(np.uint16), data=data, data_addr=DATA_ADDR,
|
||||
viewer="hgr", preview_rgb=preview,
|
||||
error=perceptual_error(idx, img_mono, plab_mono),
|
||||
meta={"palette": "mono", "dither": dither_mode},
|
||||
)
|
||||
15
lenser/apple/convert/mono.py
Normal file
15
lenser/apple/convert/mono.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"""Apple monochrome -- HGR's 280x192 black & white, exposed as the standard
|
||||
``mono`` mode for cross-platform parity (tone carried by dithering)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import hgr_mono
|
||||
|
||||
WIDTH, HEIGHT, PIXEL_ASPECT = hgr_mono.WIDTH, hgr_mono.HEIGHT, hgr_mono.PIXEL_ASPECT
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="mono", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
conv = hgr_mono.convert(img_rgb, palette_name, dither_mode, intensive,
|
||||
base_color=base_color)
|
||||
conv.mode = "mono"
|
||||
return conv
|
||||
53
lenser/apple/dsk.py
Normal file
53
lenser/apple/dsk.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""Write a bootable Apple II .dsk (DOS 3.3 sector order, 143360 bytes).
|
||||
|
||||
The .dsk stores sectors in DOS *logical* order, but the Disk II boot ROM reads
|
||||
*physical* sectors, so we place each chunk of the bitmap at the logical slot that
|
||||
maps to the physical sector our loader will read -- making the loader's
|
||||
physical-sequential reads come out contiguous in memory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# DOS 3.3 physical-sector -> logical-sector (the .dsk read interleave).
|
||||
PHYS2LOG = [0, 7, 14, 6, 13, 5, 12, 4, 11, 3, 10, 2, 9, 1, 8, 15]
|
||||
|
||||
SECTOR = 256
|
||||
SPT = 16
|
||||
TRACKS = 35
|
||||
DISK_SIZE = TRACKS * SPT * SECTOR # 143360
|
||||
|
||||
|
||||
class DskError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _offset(track: int, phys_sector: int) -> int:
|
||||
return (track * SPT + PHYS2LOG[phys_sector]) * SECTOR
|
||||
|
||||
|
||||
def build_boot_dsk(boot: bytes, payload: bytes) -> bytes:
|
||||
"""boot = assembled boot0 (origin $0800); payload = data pages the loader reads
|
||||
in order: track 0 sectors 1-15, then whole tracks 1, 2, ... (physical order)."""
|
||||
if len(payload) % SECTOR:
|
||||
raise DskError("payload must be a whole number of 256-byte sectors")
|
||||
npages = len(payload) // SECTOR
|
||||
disk = bytearray(DISK_SIZE)
|
||||
disk[0:SECTOR] = (bytes(boot) + bytes(SECTOR))[:SECTOR] # boot0 at T0 phys 0
|
||||
|
||||
assigns = [(0, p) for p in range(1, 16)] # track 0 (skip boot)
|
||||
track = 1
|
||||
while len(assigns) < npages:
|
||||
assigns += [(track, p) for p in range(16)]
|
||||
track += 1
|
||||
if track > TRACKS:
|
||||
raise DskError("payload exceeds disk capacity")
|
||||
for page, (trk, phys) in enumerate(assigns[:npages]):
|
||||
off = _offset(trk, phys)
|
||||
disk[off:off + SECTOR] = payload[page * SECTOR:(page + 1) * SECTOR]
|
||||
return bytes(disk)
|
||||
|
||||
|
||||
def write_dsk(path: str, data: bytes) -> str:
|
||||
with open(path, "wb") as f:
|
||||
f.write(data)
|
||||
return path
|
||||
10
lenser/apple/exporter.py
Normal file
10
lenser/apple/exporter.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
"""Build a bootable Apple II .dsk from a conversion."""
|
||||
from __future__ import annotations
|
||||
from . import dsk
|
||||
from .viewer.assemble import assemble_stub
|
||||
|
||||
def export_dsk(conv, output_path, source_path=None, display="forever", seconds=0):
|
||||
if not output_path.lower().endswith((".dsk", ".do")):
|
||||
output_path += ".dsk"
|
||||
boot = assemble_stub(conv.viewer, display=display, seconds=seconds)
|
||||
return dsk.write_dsk(output_path, dsk.build_boot_dsk(boot, conv.data))
|
||||
107
lenser/apple/palette.py
Normal file
107
lenser/apple/palette.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""Apple II colour palettes and the HGR memory-layout helper.
|
||||
|
||||
- HGR mono: black/white (the 280x192 1-bit bitmap displayed as monochrome).
|
||||
- HGR colour: 6 NTSC "artifact" colours (added later).
|
||||
- DHGR: 16 colours (added later).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..palette import srgb_to_lab
|
||||
|
||||
# Monochrome (white phosphor) pair.
|
||||
MONO = np.array([(0, 0, 0), (255, 255, 255)], dtype=np.float64)
|
||||
|
||||
# Double-Hi-Res 16-colour palette, indexed by the 4-bit value -- measured from
|
||||
# MAME's apple2ee DHGR output so the encoder's nibble values map to exactly what
|
||||
# the //e displays.
|
||||
DHGR16 = np.array([
|
||||
(0x00, 0x00, 0x00), # 0 black
|
||||
(0x40, 0x1c, 0xf7), # 1 blue
|
||||
(0x00, 0x74, 0x40), # 2 dark green
|
||||
(0x19, 0x90, 0xff), # 3 medium blue
|
||||
(0x40, 0x63, 0x00), # 4 olive / dark green
|
||||
(0x80, 0x80, 0x80), # 5 grey
|
||||
(0x19, 0xd7, 0x00), # 6 green
|
||||
(0x58, 0xf4, 0xbf), # 7 aqua
|
||||
(0xa7, 0x0b, 0x40), # 8 dark red / magenta
|
||||
(0xe6, 0x28, 0xff), # 9 magenta / violet
|
||||
(0x80, 0x80, 0x80), # 10 grey
|
||||
(0xbf, 0x9c, 0xff), # 11 lavender
|
||||
(0xe6, 0x6f, 0x00), # 12 orange
|
||||
(0xff, 0x8b, 0xbf), # 13 pink
|
||||
(0xbf, 0xe3, 0x08), # 14 yellow-green
|
||||
(0xff, 0xff, 0xff), # 15 white
|
||||
], dtype=np.float64)
|
||||
|
||||
|
||||
# HGR NTSC "artifact" colours. Per 7-pixel byte a palette bit selects one of two
|
||||
# colour pairs; the displayed colour of an "on" pixel also depends on its column
|
||||
# parity (and two adjacent on-pixels read as white).
|
||||
# palette 0: even column -> violet, odd column -> green
|
||||
# palette 1: even column -> blue, odd column -> orange
|
||||
HGR_BLACK, HGR_VIOLET, HGR_GREEN, HGR_WHITE, HGR_BLUE, HGR_ORANGE = 0, 1, 2, 3, 4, 5
|
||||
HGR6 = np.array([
|
||||
(0x00, 0x00, 0x00), # black
|
||||
(0xd0, 0x3a, 0xff), # violet
|
||||
(0x20, 0xc8, 0x00), # green
|
||||
(0xff, 0xff, 0xff), # white
|
||||
(0x20, 0x9a, 0xff), # blue
|
||||
(0xff, 0x6a, 0x20), # orange
|
||||
], dtype=np.float64)
|
||||
|
||||
|
||||
def mono_lab() -> np.ndarray:
|
||||
return srgb_to_lab(MONO)
|
||||
|
||||
|
||||
def hgr6_lab() -> np.ndarray:
|
||||
return srgb_to_lab(HGR6)
|
||||
|
||||
|
||||
def pack_hgr_color(bits280: np.ndarray, pal_byte: np.ndarray) -> bytes:
|
||||
"""280x192 mono bits + (192x40) per-byte palette bit -> 8192 HGR buffer."""
|
||||
buf = bytearray(0x2000)
|
||||
H = bits280.shape[0]
|
||||
for y in range(H):
|
||||
base = hgr_row_addr(y)
|
||||
row = bits280[y]
|
||||
for bx in range(40):
|
||||
b = 0
|
||||
for i in range(7):
|
||||
if row[bx * 7 + i]:
|
||||
b |= (1 << i)
|
||||
if pal_byte[y, bx]:
|
||||
b |= 0x80
|
||||
buf[base + bx] = b
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def dhgr_lab() -> np.ndarray:
|
||||
return srgb_to_lab(DHGR16)
|
||||
|
||||
|
||||
def hgr_row_addr(y: int) -> int:
|
||||
"""Offset (from $2000) of HGR row ``y`` (0..191) in the interleaved layout."""
|
||||
return (y & 7) * 0x400 + ((y >> 3) & 7) * 0x80 + (y >> 6) * 0x28
|
||||
|
||||
|
||||
def pack_hgr_mono(val_image: np.ndarray) -> bytes:
|
||||
"""280x192 1-bit image -> 8192-byte HGR page 1 buffer.
|
||||
|
||||
7 pixels per byte, bit 0 = leftmost, bit 7 (palette bit) = 0 for mono.
|
||||
"""
|
||||
buf = bytearray(0x2000)
|
||||
H, W = val_image.shape
|
||||
for y in range(H):
|
||||
base = hgr_row_addr(y)
|
||||
row = val_image[y]
|
||||
for bx in range(40):
|
||||
b = 0
|
||||
for i in range(7):
|
||||
if row[bx * 7 + i]:
|
||||
b |= (1 << i)
|
||||
buf[base + bx] = b
|
||||
return bytes(buf)
|
||||
1
lenser/apple/viewer/__init__.py
Normal file
1
lenser/apple/viewer/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .assemble import AssemblerError, SOURCES, assemble_stub, have_xa # noqa: F401
|
||||
97
lenser/apple/viewer/assemble.py
Normal file
97
lenser/apple/viewer/assemble.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""Assemble the Apple II boot/viewer with `xa` (origin $0800, raw bytes)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
SOURCES = {"hgr": "hgr.s", "dhgr": "dhgr.s"}
|
||||
_cache: dict[tuple, bytes] = {}
|
||||
|
||||
# How long the viewer holds the picture (see apple/viewer/awyt.i).
|
||||
WAIT_MODES = {"forever": 0, "key": 1, "seconds": 2}
|
||||
SS_WAITMODE = {"key": 1, "seconds": 2, "both": 3}
|
||||
|
||||
|
||||
def build_slideshow_stub(n_images: int, advance: str = "both", seconds: int = 10,
|
||||
loop: bool = True) -> bytes:
|
||||
"""Assemble the Apple HGR slideshow boot loader (one 256-byte boot sector).
|
||||
|
||||
Reads NIMAGES * 32 sectors into the $4000 buffer and cycles them; must fit a
|
||||
single boot sector since the Disk II ROM only loads sector 0.
|
||||
"""
|
||||
import shutil as _sh
|
||||
if not _sh.which("xa"):
|
||||
raise AssemblerError("The 'xa' assembler was not found on PATH.")
|
||||
end_page = 0x40 + n_images * 0x20
|
||||
wrapper = (f"#define WAITMODE {SS_WAITMODE[advance]}\n"
|
||||
f"#define WAITSECS {max(0, min(255, int(seconds)))}\n"
|
||||
f"#define NIMAGES {n_images}\n"
|
||||
f"#define LOOPFLAG {1 if loop else 0}\n"
|
||||
f"#define ENDPAGE ${end_page:02X}\n"
|
||||
'#include "slideshow.s"\n')
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
out = os.path.join(td, "v.bin")
|
||||
fd, wrap = tempfile.mkstemp(suffix=".s", prefix="_wrap_", dir=VIEWER_DIR)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(wrapper)
|
||||
proc = subprocess.run(["xa", "-o", out, os.path.basename(wrap)],
|
||||
capture_output=True, text=True, cwd=VIEWER_DIR)
|
||||
if proc.returncode != 0:
|
||||
raise AssemblerError(f"xa failed:\n{proc.stdout}{proc.stderr}")
|
||||
with open(out, "rb") as f:
|
||||
raw = f.read()
|
||||
finally:
|
||||
os.unlink(wrap)
|
||||
if len(raw) > 256:
|
||||
raise AssemblerError(
|
||||
f"Apple slideshow boot loader is {len(raw)} bytes, over the 256-byte "
|
||||
"boot sector")
|
||||
return raw
|
||||
|
||||
|
||||
class AssemblerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def have_xa() -> bool:
|
||||
return shutil.which("xa") is not None
|
||||
|
||||
|
||||
def assemble_stub(viewer_key: str, display: str = "forever", seconds: int = 0) -> bytes:
|
||||
waitmode = WAIT_MODES.get(display, 0)
|
||||
secs = max(0, min(255, int(seconds))) # 8-bit delay counter
|
||||
key = (viewer_key, waitmode, secs)
|
||||
if key in _cache:
|
||||
return _cache[key]
|
||||
if not have_xa():
|
||||
raise AssemblerError("The 'xa' assembler was not found on PATH.")
|
||||
if not os.path.exists(os.path.join(VIEWER_DIR, SOURCES[viewer_key])):
|
||||
raise AssemblerError(f"viewer source missing: {SOURCES[viewer_key]}")
|
||||
|
||||
# Wrapper sets options then includes the real source; run from VIEWER_DIR so
|
||||
# the source's #include "awyt.i" resolves (xa looks relative to cwd).
|
||||
wrapper = (f"#define WAITMODE {waitmode}\n"
|
||||
f"#define WAITSECS {secs}\n"
|
||||
f'#include "{SOURCES[viewer_key]}"\n')
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
out = os.path.join(td, "v.bin")
|
||||
fd, wrap = tempfile.mkstemp(suffix=".s", prefix="_wrap_", dir=VIEWER_DIR)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(wrapper)
|
||||
proc = subprocess.run(["xa", "-o", out, os.path.basename(wrap)],
|
||||
capture_output=True, text=True, cwd=VIEWER_DIR)
|
||||
if proc.returncode != 0:
|
||||
raise AssemblerError(
|
||||
f"xa failed for {viewer_key}:\n{proc.stdout}{proc.stderr}")
|
||||
with open(out, "rb") as f:
|
||||
raw = f.read()
|
||||
finally:
|
||||
os.unlink(wrap)
|
||||
_cache[key] = raw
|
||||
return raw
|
||||
44
lenser/apple/viewer/awyt.i
Normal file
44
lenser/apple/viewer/awyt.i
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
; Shared display-duration epilogue for the Apple II viewers (HGR already on).
|
||||
; WAITMODE 0 forever, 1 until a key, 2 about WAITSECS seconds.
|
||||
; key and seconds exit to Applesoft BASIC ($E000 cold start), a real prompt with
|
||||
; no DOS needed. The II and II+ have no timer, so seconds is a calibrated delay
|
||||
; loop near 1 MHz. WAITSECS is clamped to 255 by the assembler.
|
||||
|
||||
#if WAITMODE == 0
|
||||
awhang:
|
||||
jmp awhang
|
||||
#endif
|
||||
|
||||
#if WAITMODE == 1
|
||||
awhang:
|
||||
lda $c000 ; keyboard; bit7 set = a key is down
|
||||
bpl awhang
|
||||
bit $c010 ; clear strobe
|
||||
lda $c051 ; switch to text so the BASIC prompt is visible
|
||||
lda $c054 ; page 1
|
||||
jmp $e000 ; Applesoft cold start
|
||||
#endif
|
||||
|
||||
#if WAITMODE == 2
|
||||
lda #WAITSECS
|
||||
sta $fd ; seconds remaining
|
||||
aw_o:
|
||||
lda #$03 ; ~1 second = 3 * (256*256 inner) at ~1 MHz
|
||||
sta $fc
|
||||
aw_m:
|
||||
ldx #$00
|
||||
aw_x:
|
||||
ldy #$00
|
||||
aw_y:
|
||||
dey
|
||||
bne aw_y
|
||||
dex
|
||||
bne aw_x
|
||||
dec $fc
|
||||
bne aw_m
|
||||
dec $fd
|
||||
bne aw_o
|
||||
lda $c051 ; switch to text so the BASIC prompt is visible
|
||||
lda $c054 ; page 1
|
||||
jmp $e000 ; Applesoft cold start
|
||||
#endif
|
||||
128
lenser/apple/viewer/dhgr.s
Normal file
128
lenser/apple/viewer/dhgr.s
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
; lenser -- Apple //e Double Hi-Res boot loader + viewer (self-contained)
|
||||
;
|
||||
; Loads 16K to main $2000-$5FFF with ordinary reads (main half at $2000, aux half
|
||||
; at $4000), then block-copies $4000-$5FFF into auxiliary $2000-$3FFF (RAMWRT on
|
||||
; only for that clean copy, never during the boot-ROM reads), then turns on DHGR.
|
||||
; ROM read at $C65C reads sector $3D into page $27 and re-enters $0801.
|
||||
;
|
||||
; assembled by apple/viewer/assemble.py via xa
|
||||
|
||||
* = $0800
|
||||
.byte $01
|
||||
entry: ; $0801, re-entered after each ROM read
|
||||
lda dpage
|
||||
cmp #$60 ; loaded $2000-$5FFF (64 pages)?
|
||||
bcs done
|
||||
lda psec
|
||||
cmp #$10
|
||||
bcc readit
|
||||
jsr seeknext
|
||||
lda #$00
|
||||
sta psec
|
||||
readit:
|
||||
lda psec
|
||||
sta $3d ; desired sector
|
||||
lda curtrk
|
||||
sta $41 ; desired track (the ROM read verifies BOTH)
|
||||
lda #$00
|
||||
sta $26
|
||||
lda dpage
|
||||
sta $27
|
||||
inc psec
|
||||
inc dpage
|
||||
ldx $2b
|
||||
jmp $c65c
|
||||
|
||||
done:
|
||||
; copy main $4000-$5FFF -> aux $2000-$3FFF
|
||||
lda #$00
|
||||
sta $06
|
||||
lda #$40
|
||||
sta $07 ; src = $4000
|
||||
lda #$00
|
||||
sta $08
|
||||
lda #$20
|
||||
sta $09 ; dst = $2000
|
||||
sta $c005 ; RAMWRT on (writes go to aux)
|
||||
ldx #$20 ; 32 pages
|
||||
cpl:
|
||||
ldy #$00
|
||||
cp1:
|
||||
lda ($06),y
|
||||
sta ($08),y
|
||||
iny
|
||||
bne cp1
|
||||
inc $07
|
||||
inc $09
|
||||
dex
|
||||
bne cpl
|
||||
sta $c004 ; RAMWRT off
|
||||
; turn on Double Hi-Res
|
||||
lda $c050 ; graphics
|
||||
lda $c052 ; full screen
|
||||
lda $c054 ; page 1
|
||||
lda $c057 ; hi-res
|
||||
sta $c00d ; SET80VID (write-triggered switch -- must STA)
|
||||
sta $c05e ; SETDHIRES (write-triggered)
|
||||
#include "awyt.i"
|
||||
|
||||
; advance the head one track (two half-steps) with the standard phase-overlap
|
||||
; seek. energize the NEXT phase while the current one is still on (this pulls
|
||||
; the head smoothly), then release the old phase, using on/off settle delays
|
||||
; from an acceleration table indexed by step number ($0a). the final phase is
|
||||
; released before reading.
|
||||
seeknext:
|
||||
inc curtrk ; now on the next track
|
||||
lda #$00
|
||||
sta $0a ; step index for the timing table
|
||||
jsr onestep
|
||||
jsr onestep
|
||||
lda halftrk ; release final phase before the read
|
||||
and #$03
|
||||
asl
|
||||
ora $2b
|
||||
tax
|
||||
lda $c080,x
|
||||
rts
|
||||
onestep:
|
||||
lda halftrk ; energize NEXT phase (halftrk+1), old still on
|
||||
clc
|
||||
adc #$01
|
||||
and #$03
|
||||
asl
|
||||
ora $2b
|
||||
tax
|
||||
lda $c081,x
|
||||
ldx $0a
|
||||
lda ontable,x
|
||||
jsr wait
|
||||
lda halftrk ; release OLD phase (halftrk)
|
||||
and #$03
|
||||
asl
|
||||
ora $2b
|
||||
tax
|
||||
lda $c080,x
|
||||
ldx $0a
|
||||
lda offtable,x
|
||||
jsr wait
|
||||
inc halftrk
|
||||
inc $0a
|
||||
rts
|
||||
; Apple seek timing tables (head accelerates over a move, slow first step).
|
||||
ontable: .byte $13,$0a,$08,$06,$05,$04,$04,$03
|
||||
offtable: .byte $46,$1a,$10,$0c,$0a,$09,$08,$08
|
||||
wait: ; delay loop ~ proportional to A
|
||||
tay
|
||||
w1:
|
||||
ldx #$00
|
||||
w2:
|
||||
dex
|
||||
bne w2
|
||||
dey
|
||||
bne w1
|
||||
rts
|
||||
|
||||
psec: .byte $01
|
||||
dpage: .byte $20
|
||||
halftrk: .byte $00
|
||||
curtrk: .byte $00
|
||||
102
lenser/apple/viewer/hgr.s
Normal file
102
lenser/apple/viewer/hgr.s
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
; lenser -- Apple II HGR boot loader + viewer (self-contained, no DOS)
|
||||
;
|
||||
; The Disk II boot ROM loads track 0 sector 0 to $0800 and JMPs $0801. This
|
||||
; re-entrant loader reads the 32 sectors of the 8K HGR bitmap into $2000-$3FFF
|
||||
; (track 0 sectors 1-15, then tracks 1 and 2), seeking the head between tracks
|
||||
; with the standard phase-overlap step, then switches on HGR and loops. The ROM
|
||||
; read at $Cn5C verifies the address field against BOTH the sector ($3D) and the
|
||||
; track ($41), then reads into page $27 and JMPs back to $0801.
|
||||
;
|
||||
; assembled by apple/viewer/assemble.py via xa
|
||||
|
||||
* = $0800
|
||||
.byte $01 ; ROM scratch (boot sector byte 0)
|
||||
entry: ; $0801, (re)entered after every ROM read
|
||||
lda dpage
|
||||
cmp #$40
|
||||
bcs done ; loaded $2000..$3FFF -> show it
|
||||
lda psec
|
||||
cmp #$10
|
||||
bcc readit ; still sectors left on this track
|
||||
jsr seeknext ; finished a track -> step to the next
|
||||
lda #$00
|
||||
sta psec
|
||||
readit:
|
||||
lda psec
|
||||
sta $3d ; desired sector
|
||||
lda curtrk
|
||||
sta $41 ; desired track (ROM read verifies both)
|
||||
lda #$00
|
||||
sta $26 ; buffer lo
|
||||
lda dpage
|
||||
sta $27 ; buffer hi
|
||||
inc psec
|
||||
inc dpage
|
||||
ldx $2b ; slot*16 (set by boot ROM)
|
||||
jmp $c65c ; slot 6 ROM read; reads sector then JMP $0801
|
||||
|
||||
done:
|
||||
lda $c050 ; graphics
|
||||
lda $c052 ; full screen (not mixed)
|
||||
lda $c054 ; page 1
|
||||
lda $c057 ; hi-res
|
||||
#include "awyt.i"
|
||||
|
||||
; advance the head one track (two half-steps) with the standard phase-overlap
|
||||
; step. energize the next phase while the current one is still on, then release
|
||||
; the old phase, using on/off settle delays from an acceleration table.
|
||||
seeknext:
|
||||
inc curtrk
|
||||
lda #$00
|
||||
sta $0a
|
||||
jsr onestep
|
||||
jsr onestep
|
||||
lda halftrk ; release final phase before reading
|
||||
and #$03
|
||||
asl
|
||||
ora $2b
|
||||
tax
|
||||
lda $c080,x
|
||||
rts
|
||||
onestep:
|
||||
lda halftrk ; energize NEXT phase (old still on -> overlap)
|
||||
clc
|
||||
adc #$01
|
||||
and #$03
|
||||
asl
|
||||
ora $2b
|
||||
tax
|
||||
lda $c081,x
|
||||
ldx $0a
|
||||
lda ontable,x
|
||||
jsr wait
|
||||
lda halftrk ; release OLD phase
|
||||
and #$03
|
||||
asl
|
||||
ora $2b
|
||||
tax
|
||||
lda $c080,x
|
||||
ldx $0a
|
||||
lda offtable,x
|
||||
jsr wait
|
||||
inc halftrk
|
||||
inc $0a
|
||||
rts
|
||||
ontable: .byte $13,$0a,$08,$06
|
||||
offtable: .byte $46,$1a,$10,$0c
|
||||
wait:
|
||||
tay
|
||||
w1:
|
||||
ldx #$00
|
||||
w2:
|
||||
dex
|
||||
bne w2
|
||||
dey
|
||||
bne w1
|
||||
rts
|
||||
|
||||
; ---- loader state (initial values; updated in place during the load) ----
|
||||
psec: .byte $01 ; next physical sector (track 0 starts at 1)
|
||||
dpage: .byte $20 ; next destination page ($2000)
|
||||
halftrk: .byte $00 ; current half-track
|
||||
curtrk: .byte $00 ; current track
|
||||
204
lenser/apple/viewer/slideshow.s
Normal file
204
lenser/apple/viewer/slideshow.s
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
; lenser -- Apple II HGR slideshow loader + viewer (self-contained, no DOS).
|
||||
;
|
||||
; The Disk II boot ROM loads track 0 sector 0 to $0800 and JMPs $0801. This
|
||||
; loader reads ALL the slideshow's HGR images (NIMAGES * 32 sectors) contiguously
|
||||
; into a RAM buffer from $4000 up (image i at $4000 + i*$2000), then cycles --
|
||||
; copying each image into HGR page 1 ($2000), switching on graphics, and waiting
|
||||
; (key / seconds / both) before the next. Looping is RAM-only (no re-seek).
|
||||
;
|
||||
; #defines from the wrapper --
|
||||
; WAITMODE 1 key / 2 seconds / 3 both WAITSECS seconds (~) NIMAGES count
|
||||
; LOOPFLAG 1 wrap / 0 stop ENDPAGE one past the last buffer page ($40+N*$20)
|
||||
;
|
||||
; assembled by apple/viewer/assemble.py via xa
|
||||
|
||||
src = $06 ; zero-page copy pointers
|
||||
dst = $08
|
||||
ssidx = $19
|
||||
|
||||
* = $0800
|
||||
.byte $01 ; ROM scratch (boot sector byte 0)
|
||||
entry: ; $0801, (re)entered after every ROM read
|
||||
lda dpage
|
||||
cmp #ENDPAGE
|
||||
bcs loaded ; whole buffer read -> start the show
|
||||
lda psec
|
||||
cmp #$10
|
||||
bcc readit
|
||||
jsr seeknext
|
||||
lda #$00
|
||||
sta psec
|
||||
readit:
|
||||
lda psec
|
||||
sta $3d
|
||||
lda curtrk
|
||||
sta $41
|
||||
lda #$00
|
||||
sta $26
|
||||
lda dpage
|
||||
sta $27
|
||||
inc psec
|
||||
inc dpage
|
||||
ldx $2b
|
||||
jmp $c65c ; slot-6 ROM read; reads a sector then JMP $0801
|
||||
|
||||
loaded:
|
||||
lda #$00
|
||||
sta ssidx
|
||||
cyc:
|
||||
; ---- copy image ssidx ($4000 + ssidx*$2000) -> HGR page 1 ($2000) ----
|
||||
lda ssidx
|
||||
asl
|
||||
asl
|
||||
asl
|
||||
asl
|
||||
asl ; ssidx * $20 pages (carry clear, <=4 images)
|
||||
adc #$40
|
||||
sta src+1 ; source hi = $40 + ssidx*$20
|
||||
lda #$00
|
||||
sta src
|
||||
sta dst
|
||||
lda #$20
|
||||
sta dst+1 ; dest = $2000
|
||||
ldx #$20 ; 32 pages
|
||||
ldy #$00
|
||||
cpyl:
|
||||
lda (src),y
|
||||
sta (dst),y
|
||||
iny
|
||||
bne cpyl
|
||||
inc src+1
|
||||
inc dst+1
|
||||
dex
|
||||
bne cpyl
|
||||
|
||||
lda $c050 ; graphics
|
||||
lda $c054 ; page 1
|
||||
lda $c057 ; hi-res
|
||||
|
||||
jsr sswait
|
||||
|
||||
inc ssidx
|
||||
lda ssidx
|
||||
cmp #NIMAGES
|
||||
bcc cyc
|
||||
#if LOOPFLAG == 1
|
||||
jmp loaded ; wrap (re-uses the ssidx=0 init at loaded)
|
||||
#else
|
||||
lda $c051 ; text
|
||||
lda $c054
|
||||
jmp $e000 ; Applesoft cold start
|
||||
#endif
|
||||
|
||||
; ---- wait (returns); clears the key strobe first ----
|
||||
sswait:
|
||||
bit $c010
|
||||
#if WAITMODE == 1
|
||||
swk:
|
||||
lda $c000
|
||||
bpl swk
|
||||
bit $c010
|
||||
rts
|
||||
#endif
|
||||
#if WAITMODE == 2
|
||||
lda #WAITSECS
|
||||
sta $fd
|
||||
so:
|
||||
lda #$03
|
||||
sta $fc
|
||||
sm:
|
||||
ldx #$00
|
||||
sx:
|
||||
ldy #$00
|
||||
sy:
|
||||
dey
|
||||
bne sy
|
||||
dex
|
||||
bne sx
|
||||
dec $fc
|
||||
bne sm
|
||||
dec $fd
|
||||
bne so
|
||||
rts
|
||||
#endif
|
||||
#if WAITMODE == 3
|
||||
lda #WAITSECS
|
||||
sta $fd
|
||||
bo:
|
||||
lda #$03
|
||||
sta $fc
|
||||
bm:
|
||||
ldx #$00
|
||||
bx:
|
||||
lda $c000
|
||||
bmi bdone ; a key ends the slide
|
||||
ldy #$00
|
||||
by:
|
||||
dey
|
||||
bne by
|
||||
dex
|
||||
bne bx
|
||||
dec $fc
|
||||
bne bm
|
||||
dec $fd
|
||||
bne bo
|
||||
bdone:
|
||||
bit $c010 ; (also harmless on timeout)
|
||||
rts
|
||||
#endif
|
||||
|
||||
; advance the head one track (two half-steps), phase-overlap step (from hgr.s)
|
||||
seeknext:
|
||||
inc curtrk
|
||||
lda #$00
|
||||
sta $0a
|
||||
jsr onestep
|
||||
jsr onestep
|
||||
lda halftrk
|
||||
and #$03
|
||||
asl
|
||||
ora $2b
|
||||
tax
|
||||
lda $c080,x
|
||||
rts
|
||||
onestep:
|
||||
lda halftrk
|
||||
clc
|
||||
adc #$01
|
||||
and #$03
|
||||
asl
|
||||
ora $2b
|
||||
tax
|
||||
lda $c081,x
|
||||
ldx $0a
|
||||
lda ontable,x
|
||||
jsr wait
|
||||
lda halftrk
|
||||
and #$03
|
||||
asl
|
||||
ora $2b
|
||||
tax
|
||||
lda $c080,x
|
||||
ldx $0a
|
||||
lda offtable,x
|
||||
jsr wait
|
||||
inc halftrk
|
||||
inc $0a
|
||||
rts
|
||||
ontable: .byte $13,$0a,$08,$06
|
||||
offtable: .byte $46,$1a,$10,$0c
|
||||
wait:
|
||||
tay
|
||||
w1:
|
||||
ldx #$00
|
||||
w2:
|
||||
dex
|
||||
bne w2
|
||||
dey
|
||||
bne w1
|
||||
rts
|
||||
|
||||
psec: .byte $01 ; next physical sector (track 0 starts at 1)
|
||||
dpage: .byte $40 ; next destination page ($4000)
|
||||
halftrk: .byte $00
|
||||
curtrk: .byte $00
|
||||
Loading…
Add table
Add a link
Reference in a new issue