"""Representation of Apple II screen memory.""" from enum import Enum import numpy as np import palette as palette_py class Mode(Enum): LO_RES = 1 DOUBLE_LO_RES = 2 HI_RES = 3 DOUBLE_HI_RES = 4 SUPER_HI_RES_320 = 5 SUPER_HI_RES_640 = 6 SUPER_HI_RES_3200 = 7 class SHR320Screen: X_RES = 320 Y_RES = 200 MODE = Mode.SUPER_HI_RES_320 def __init__(self): self.palettes = {k: np.zeros((16, 3), dtype=np.uint8) for k in range(16)} # Really 4-bit values, indexing into palette self.pixels = np.array((self.Y_RES, self.X_RES), dtype=np.uint8) # Choice of palette per scan-line self.line_palette = np.zeros(self.Y_RES, dtype=np.uint8) self.memory = None def set_palette(self, idx: int, palette: np.array): if idx < 0 or idx > 15: raise ValueError("Palette index %s must be in range 0 .. 15" % idx) if palette.shape != (16, 3): raise ValueError("Palette size %s != (16, 3)" % palette.shape) # XXX check element range if palette.dtype != np.uint8: raise ValueError("Palette must be of type np.uint8") # print(palette) self.palettes[idx] = np.array(palette) def set_pixels(self, pixels): self.pixels = np.array(pixels) def pack(self): dump = np.zeros(32768, dtype=np.uint8) for y in range(self.Y_RES): pixel_pair = 0 for x in range(self.X_RES): if x % 2 == 0: pixel_pair |= (self.pixels[y, x] << 4) else: pixel_pair |= self.pixels[y, x] # print(pixel_pair) dump[y * 160 + (x - 1) // 2] = pixel_pair pixel_pair = 0 scan_control_offset = 320 * 200 // 2 for y in range(self.Y_RES): dump[scan_control_offset + y] = self.line_palette[y] palette_offset = scan_control_offset + 256 for palette_idx, palette in self.palettes.items(): for rgb_idx, rgb in enumerate(palette): r, g, b = rgb assert r <= 15 and g <= 15 and b <= 15 # print(r, g, b) rgb_low = (g << 4) | b rgb_hi = r # print(hex(rgb_hi), hex(rgb_low)) palette_idx_offset = palette_offset + (32 * palette_idx) dump[palette_idx_offset + (2 * rgb_idx)] = rgb_low dump[palette_idx_offset + (2 * rgb_idx + 1)] = rgb_hi self.memory = dump class BaseDHGRScreen: @staticmethod def y_to_base_addr(y: int) -> int: """Maps y coordinate to screen memory base address.""" a = y // 64 d = y - 64 * a b = d // 8 c = d - 8 * b return 1024 * c + 128 * b + 40 * a class DHGRScreen(BaseDHGRScreen): X_RES = 560 Y_RES = 192 MODE = Mode.DOUBLE_HI_RES def __init__(self): self.main = np.zeros(8192, dtype=np.uint8) self.aux = np.zeros(8192, dtype=np.uint8) def pack(self, bitmap: np.ndarray): """Packs an image into memory format (8k AUX + 8K MAIN).""" # The DHGR display encodes 7 pixels across interleaved 4-byte sequences # of AUX and MAIN memory, as follows: # PBBBAAAA PDDCCCCB PFEEEEDD PGGGGFFF # Aux N Main N Aux N+1 Main N+1 (N even) main_col = np.zeros( (self.Y_RES, self.X_RES // 14), dtype=np.uint8) aux_col = np.zeros( (self.Y_RES, self.X_RES // 14), dtype=np.uint8) for byte_offset in range(80): column = np.zeros(self.Y_RES, dtype=np.uint8) for bit in range(7): column |= (bitmap[:, 7 * byte_offset + bit].astype( np.uint8) << bit) if byte_offset % 2 == 0: aux_col[:, byte_offset // 2] = column else: main_col[:, (byte_offset - 1) // 2] = column for y in range(self.Y_RES): addr = self.y_to_base_addr(y) self.aux[addr:addr + 40] = aux_col[y, :] self.main[addr:addr + 40] = main_col[y, :] return class NTSCScreen: NTSC_PHASE_SHIFT = None def _sin(self, pos): x = pos % 12 + self.NTSC_PHASE_SHIFT * 3 return np.sin(x * 2 * np.pi / 12) def _cos(self, pos): x = pos % 12 + self.NTSC_PHASE_SHIFT * 3 return np.cos(x * 2 * np.pi / 12) def _read(self, lines, pos): if pos < 0: return np.zeros(lines.shape[0], dtype=np.float32) return lines[:, pos].astype(np.float32) def bitmap_to_image_ntsc(self, bitmap: np.ndarray) -> np.ndarray: y_width = 12 u_width = 24 v_width = 24 contrast = 1 # TODO: This is necessary to match OpenEmulator. I think it is because # they introduce an extra (unexplained) factor of 2 when applying the # Chebyshev/Lanczos filtering to the u and v components. saturation = 2 # TODO: this phase shift is necessary to match OpenEmulator. I'm not # sure where it comes from - e.g. it doesn't match the phaseInfo # calculation for the signal phase at the start of the visible region. hue = 0.2 * (2 * np.pi) # Apply effect of saturation yuv_to_rgb = np.array( ((1, 0, 0), (0, saturation, 0), (0, 0, saturation)), dtype=np.float32) # Apply hue phase rotation yuv_to_rgb = np.matmul(np.array( ((1, 0, 0), (0, np.cos(hue), np.sin(hue)), (0, -np.sin(hue), np.cos(hue)))), yuv_to_rgb) # Y'UV to R'G'B' conversion yuv_to_rgb = np.matmul(np.array( ((1, 0, 1.139883), (1, -0.394642, -.5806227), (1, 2.032062, 0))), yuv_to_rgb) # Apply effect of contrast yuv_to_rgb *= contrast out_rgb = np.empty((bitmap.shape[0], bitmap.shape[1] * 3, 3), dtype=np.uint8) ysum = np.zeros(bitmap.shape[0], dtype=np.float32) usum = np.zeros(bitmap.shape[0], dtype=np.float32) vsum = np.zeros(bitmap.shape[0], dtype=np.float32) # Repeat each pixel 3 times so we can do sub-pixel colour sampling lines = np.repeat(bitmap, 3, axis=1) for x in range(bitmap.shape[1] * 3): ysum += self._read(lines, x) - self._read(lines, x - y_width) usum += self._read(lines, x) * self._sin(x) - self._read( lines, x - u_width) * self._sin((x - u_width)) vsum += self._read(lines, x) * self._cos(x) - self._read( lines, x - v_width) * self._cos((x - v_width)) rgb = np.matmul( yuv_to_rgb, np.stack( (ysum / y_width, usum / u_width, vsum / v_width), axis=1).reshape( (bitmap.shape[0], 3, 1))).reshape( bitmap.shape[0], 3) out_rgb[:, x, 0] = np.minimum(255, np.maximum(0, rgb[:, 0] * 255)) out_rgb[:, x, 1] = np.minimum(255, np.maximum(0, rgb[:, 1] * 255)) out_rgb[:, x, 2] = np.minimum(255, np.maximum(0, rgb[:, 2] * 255)) return out_rgb def bitmap_to_image_rgb(self, bitmap: np.ndarray) -> np.ndarray: """Convert our 2-bit bitmap image into a RGB image. Colour at every pixel is determined by the value of an n-bit sliding window and x % 4, which give the index into our RGB palette. """ image_rgb = np.empty((self.Y_RES, self.X_RES, 3), dtype=np.uint8) for y in range(self.Y_RES): bitmap_window = [False] * self.palette.PALETTE_DEPTH for x in range(self.X_RES): # Maintain a sliding window of pixels of width PALETTE_DEPTH bitmap_window = bitmap_window[1:] + [bitmap[y, x]] image_rgb[y, x, :] = self.palette.RGB[ self.palette.bitmap_to_idx( # Mapping from bit pattern to colour is rotated by # NTSC phase shift np.roll( np.array(bitmap_window, dtype=bool), self.NTSC_PHASE_SHIFT ) ), x % 4] return image_rgb class DHGRNTSCScreen(DHGRScreen, NTSCScreen): def __init__(self, palette: palette_py.Palette): self.palette = palette super(DHGRNTSCScreen, self).__init__() NTSC_PHASE_SHIFT = 0 class HGRNTSCScreen(BaseDHGRScreen, NTSCScreen): # Hi-Res really is 560 pixels horizontally, not 280 - but unlike DHGR # you can only independently control about half of the pixels. # # In more detail, hi-res graphics works like this: # - Each of the low 7 bits in a byte of screen memory results in # enabling or disabling two sequential 560-resolution pixels. # - pixel screen order is from LSB to MSB # - if bit 8 (the "palette bit") is set then the 14-pixel sequence is # shifted one position to the right, and the left-most pixel is filled # in by duplicating the right-most pixel produced by the previous # screen byte (i.e. bit 7) # - thus each byte produces a 15 or 14 pixel sequence depending on # whether or not the palette bit is set. X_RES = 560 Y_RES = 192 MODE = Mode.HI_RES NTSC_PHASE_SHIFT = 3 def __init__(self, palette: palette_py.Palette): self.main = np.zeros(8192, dtype=np.uint8) self.palette = palette super(HGRNTSCScreen, self).__init__() def pack_bytes(self, linear_bytemap: np.ndarray): """Packs an image into memory format (8K main).""" for y in range(self.Y_RES): addr = self.y_to_base_addr(y) self.main[addr:addr + 40] = linear_bytemap[y, :] return