Update comments and fix some bugs

make_edit_distance
- use MASKED_DOTS since it does not have a simple relationship to the
  HEADER_BITS/BODY_BITS for HGR
- try disabling transposition distances for Damerau-Levenshtein, this
  may give better quality

screen
- introduce separate notion of MASKED_DOTS which is the number of
  (coloured) pixels we can extract from MASKED_BITS.  For HGR this is
  not the same.
- fix bug in _fix_array_neighbours that was not fixing headers for HGR
- don't cache everything in byte_pair_differences, it's effectively
  unbounded.  Using 1M for LRU size seems to work just as well in
  practise, without leaking memory.
- fix bug in _diff_weights when comparing content, we want to evaluate
  the effect of storing content byte in each offset separately, not
  cumulatively.
- add a consistency check function (not currently wired up) to assert
  that headers/footers are in sync across columns
- HGR should have 16 body bits, this was causing headers not to
  propagate correctly to/from neighbouring column
- add test case for this bug

video
- Use 8 random bits consistently, using 16 in some places may have
  introduced bias
- ignore palette bit when comparing 0x00 and 0x7f in sanity check
This commit is contained in:
kris 2019-07-11 23:40:00 +01:00
parent 722e9c5d70
commit f3d03a1b87
13 changed files with 623 additions and 403 deletions

View File

@ -1,7 +1,8 @@
"""Apple II nominal display colours, represented by 4-bit dot sequences. """Apple II nominal display colours, represented by 4-bit dot sequences.
These are distinct from the effective colours that are actually displayed, These are the "asymptotic" colours as displayed in e.g. continuous runs of
e.g. due to white/black coalescing and NTSC artifacting. pixels. The effective colours that are actually displayed are not discrete,
due to NTSC artifacting being a continuous process.
""" """
from typing import Tuple, Type from typing import Tuple, Type
@ -10,6 +11,66 @@ import enum
import functools import functools
class NominalColours(enum.Enum):
pass
class HGRColours(NominalColours):
"""Map from 4-bit dot representation to DHGR pixel colours.
Dots are in memory bit order (MSB -> LSB), which is opposite to screen
order (LSB -> MSB is ordered left-to-right on the screen)
Note that these are right-rotated from the HGR mapping, because of a
1-tick phase difference in the colour reference signal for DHGR vs HGR
"""
BLACK = 0b0000
MAGENTA = 0b0001
BROWN = 0b1000
ORANGE = 0b1001 # HGR colour
DARK_GREEN = 0b0100
GREY1 = 0b0101
GREEN = 0b1100 # HGR colour
YELLOW = 0b1101
DARK_BLUE = 0b0010
VIOLET = 0b0011 # HGR colour
GREY2 = 0b1010
PINK = 0b1011
MED_BLUE = 0b0110 # HGR colour
LIGHT_BLUE = 0b0111
AQUA = 0b1110
WHITE = 0b1111
class DHGRColours(NominalColours):
"""Map from 4-bit dot representation to DHGR pixel colours.
Dots are in memory bit order (MSB -> LSB), which is opposite to screen
order (LSB -> MSB is ordered left-to-right on the screen)
Note that these are right-rotated from the HGR mapping, because of a
1-tick phase difference in the colour reference signal for DHGR vs HGR
"""
# representation.
BLACK = 0b0000
MAGENTA = 0b1000
BROWN = 0b0100
ORANGE = 0b1100 # HGR colour
DARK_GREEN = 0b0010
GREY1 = 0b1010
GREEN = 0b0110 # HGR colour
YELLOW = 0b1110
DARK_BLUE = 0b0001
VIOLET = 0b1001 # HGR colour
GREY2 = 0b0101
PINK = 0b1101
MED_BLUE = 0b0011 # HGR colour
LIGHT_BLUE = 0b1011
AQUA = 0b0111
WHITE = 0b1111
def ror(int4: int, howmany: int) -> int: def ror(int4: int, howmany: int) -> int:
"""Rotate-right an int4 some number of times.""" """Rotate-right an int4 some number of times."""
res = int4 res = int4
@ -36,52 +97,6 @@ def _rol(int4: int) -> int:
return ((int4 & 0b0111) << 1) ^ ((int4 & 0b1000) >> 3) return ((int4 & 0b0111) << 1) ^ ((int4 & 0b1000) >> 3)
class NominalColours(enum.Enum):
pass
class HGRColours(NominalColours):
# Value is memory bit order, which is opposite to screen order (bits
# ordered Left to Right on screen)
BLACK = 0b0000
MAGENTA = 0b0001
BROWN = 0b1000
ORANGE = 0b1001 # HGR colour
DARK_GREEN = 0b0100
GREY1 = 0b0101
GREEN = 0b1100 # HGR colour
YELLOW = 0b1101
DARK_BLUE = 0b0010
VIOLET = 0b0011 # HGR colour
GREY2 = 0b1010
PINK = 0b1011
MED_BLUE = 0b0110 # HGR colour
LIGHT_BLUE = 0b0111
AQUA = 0b1110
WHITE = 0b1111
class DHGRColours(NominalColours):
# DHGR 4-bit memory representation is right-rotated from the HGR video
# representation.
BLACK = 0b0000
MAGENTA = 0b1000
BROWN = 0b0100
ORANGE = 0b1100 # HGR colour
DARK_GREEN = 0b0010
GREY1 = 0b1010
GREEN = 0b0110 # HGR colour
YELLOW = 0b1110
DARK_BLUE = 0b0001
VIOLET = 0b1001 # HGR colour
GREY2 = 0b0101
PINK = 0b1101
MED_BLUE = 0b0011 # HGR colour
LIGHT_BLUE = 0b1011
AQUA = 0b0111
WHITE = 0b1111
@functools.lru_cache(None) @functools.lru_cache(None)
def dots_to_nominal_colour_pixels( def dots_to_nominal_colour_pixels(
num_bits: int, num_bits: int,
@ -92,10 +107,10 @@ def dots_to_nominal_colour_pixels(
"""Sequence of num_bits nominal colour pixels via sliding 4-bit window. """Sequence of num_bits nominal colour pixels via sliding 4-bit window.
Includes the 3-bit header that represents the trailing 3 bits of the Includes the 3-bit header that represents the trailing 3 bits of the
previous tuple body. i.e. storing a byte in aux even columns will also previous tuple body. e.g. for DHGR, storing a byte in aux even columns
influence the colours of the previous main odd column. will also influence the colours of the previous main odd column.
This naively models the NTSC colour artifacting. This naively models (approximates) the NTSC colour artifacting.
TODO: Use a more careful analogue colour composition model to produce TODO: Use a more careful analogue colour composition model to produce
effective pixel colours. effective pixel colours.
@ -126,6 +141,8 @@ def dots_to_nominal_colour_pixel_values(
colours: Type[NominalColours], colours: Type[NominalColours],
init_phase: int = 1 # Such that phase = 0 at start of body init_phase: int = 1 # Such that phase = 0 at start of body
) -> Tuple[int]: ) -> Tuple[int]:
""""Sequence of num_bits nominal colour values via sliding 4-bit window."""
return tuple(p.value for p in dots_to_nominal_colour_pixels( return tuple(p.value for p in dots_to_nominal_colour_pixels(
num_bits, dots, colours, init_phase num_bits, dots, colours, init_phase
)) ))

View File

@ -54,7 +54,7 @@ class FileFrameGrabber(FrameGrabber):
return "P%d" % self.palette.value return "P%d" % self.palette.value
def frames(self) -> Iterator[screen.MemoryMap]: def frames(self) -> Iterator[screen.MemoryMap]:
"""Encode frame to HGR using bmp2dhr. """Encode frame to (D)HGR using bmp2dhr.
We do the encoding in a background thread to parallelize. We do the encoding in a background thread to parallelize.
""" """

View File

@ -3,12 +3,11 @@
from typing import Iterator from typing import Iterator
# TODO: screen memory changes should happen via Machine while emitting opcodes?
class Machine: class Machine:
"""Represents Apple II and player virtual machine state.""" """Represents Apple II and player virtual machine state."""
def __init__(self):
self.page = 0x20 # type: int
def emit(self, opcode: "Opcode") -> Iterator[int]: def emit(self, opcode: "Opcode") -> Iterator[int]:
""" """

View File

@ -1,4 +1,4 @@
"""Transcodes an input video file to ][Vision format.""" """Transcodes an input video file to ][-Vision format."""
import argparse import argparse
@ -7,7 +7,7 @@ import palette
import video_mode import video_mode
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description='Transcode videos to ][Vision format.') description='Transcode videos to ][-Vision format.')
parser.add_argument( parser.add_argument(
'input', help='Path to input video file.') 'input', help='Path to input video file.')
parser.add_argument( parser.add_argument(

View File

@ -15,62 +15,6 @@ import colours
import palette import palette
import screen import screen
# 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)
#
# Where A..G are the pixels, and P represents the (unused) palette bit.
#
# This layout makes more sense when written as a (little-endian) 32-bit integer:
#
# 33222222222211111111110000000000 <- bit pos in uint32
# 10987654321098765432109876543210
# PGGGGFFFPFEEEEDDPDDCCCCBPBBBAAAA
#
# i.e. apart from the palette bits this is a linear ordering of pixels,
# when read from LSB to MSB (i.e. right-to-left). i.e. the screen layout order
# of bits is opposite to the usual binary representation ordering.
#
# If we now look at the effect of storing a byte in each of the 4
# byte-offset positions within this uint32,
#
# PGGGGFFFPFEEEEDDPDDCCCCBPBBBAAAA
# 33333333222222221111111100000000
#
# We see that these byte offsets cause changes to the following pixels:
#
# 0: A B
# 1: B C D
# 2: D E F
# 3: F G
#
# i.e. DHGR byte stores to offsets 0 and 3 result in changing one 8-bit value
# (2 DHGR pixels) into another; offsets 1 and 3 result in changing one 12-bit
# value (3 DHGR pixels).
#
# We can simplify things by stripping out the palette bit and packing
# down to a 28-bit integer representation:
#
# 33222222222211111111110000000000 <- bit pos in uint32
# 10987654321098765432109876543210
#
# 0000GGGGFFFFEEEEDDDDCCCCBBBBAAAA <- pixel A..G
# 3210321032103210321032103210 <- bit pos in A..G pixel
#
# 3333333222222211111110000000 <- byte offset 0.3
#
# With this representation, we can precompute an edit distance for the
# pixel changes resulting from all possible DHGR byte stores.
#
# We further encode these (source, target) -> distance mappings by
# concatenating source and target into 16- or 24-bit values. This is
# efficient to work with in the video transcoder.
#
# Since we are enumerating all such 16- or 24-bit values, these can be packed
# contiguously into an array whose index is the (source, target) pair and
# the value is the edit distance.
PIXEL_CHARS = "0123456789ABCDEF" PIXEL_CHARS = "0123456789ABCDEF"
@ -85,6 +29,8 @@ def pixel_string(pixels: Iterable[int]) -> str:
class EditDistanceParams: class EditDistanceParams:
"""Data class for parameters to Damerau-Levenshtein edit distance."""
# Don't even consider insertions and deletions into the string, they don't # Don't even consider insertions and deletions into the string, they don't
# make sense for comparing pixel strings # make sense for comparing pixel strings
insert_costs = np.ones(128, dtype=np.float64) * 100000 insert_costs = np.ones(128, dtype=np.float64) * 100000
@ -92,20 +38,26 @@ class EditDistanceParams:
# Smallest substitution value is ~20 from palette.diff_matrices, i.e. # Smallest substitution value is ~20 from palette.diff_matrices, i.e.
# we always prefer to transpose 2 pixels rather than substituting colours. # we always prefer to transpose 2 pixels rather than substituting colours.
transpose_costs = np.ones((128, 128), dtype=np.float64) * 10 # TODO: is quality really better allowing transposes?
transpose_costs = np.ones((128, 128), dtype=np.float64) * 100000 # 10
# These will be filled in later
substitute_costs = np.zeros((128, 128), dtype=np.float64) substitute_costs = np.zeros((128, 128), dtype=np.float64)
# Substitution costs to use when evaluating other potential offsets at which # Substitution costs to use when evaluating other potential offsets at which
# to store a content byte. We penalize more harshly for introducing # to store a content byte. We penalize more harshly for introducing
# errors that alter pixel colours, since these tend to be very # errors that alter pixel colours, since these tend to be very
# noticeable as visual noise. # noticeable as visual noise.
#
# TODO: currently unused
error_substitute_costs = np.zeros((128, 128), dtype=np.float64) error_substitute_costs = np.zeros((128, 128), dtype=np.float64)
def compute_diff_matrix(pal: Type[palette.BasePalette]): def compute_diff_matrix(pal: Type[palette.BasePalette]):
# Compute matrix of CIE2000 delta values for this pal, representing """Compute matrix of perceptual distance between colour pairs.
# perceptual distance between colours.
Specifically CIE2000 delta values for this palette.
"""
dm = np.ndarray(shape=(16, 16), dtype=np.int) dm = np.ndarray(shape=(16, 16), dtype=np.int)
for colour1, a in pal.RGB.items(): for colour1, a in pal.RGB.items():
@ -120,6 +72,8 @@ def compute_diff_matrix(pal: Type[palette.BasePalette]):
def compute_substitute_costs(pal: Type[palette.BasePalette]): def compute_substitute_costs(pal: Type[palette.BasePalette]):
"""Compute costs for substituting one colour pixel for another."""
edp = EditDistanceParams() edp = EditDistanceParams()
diff_matrix = compute_diff_matrix(pal) diff_matrix = compute_diff_matrix(pal)
@ -128,10 +82,10 @@ def compute_substitute_costs(pal: Type[palette.BasePalette]):
for i, c in enumerate(PIXEL_CHARS): for i, c in enumerate(PIXEL_CHARS):
for j, d in enumerate(PIXEL_CHARS): for j, d in enumerate(PIXEL_CHARS):
cost = diff_matrix[i, j] cost = diff_matrix[i, j]
edp.substitute_costs[(ord(c), ord(d))] = cost # / 20 edp.substitute_costs[(ord(c), ord(d))] = cost
edp.substitute_costs[(ord(d), ord(c))] = cost # / 20 edp.substitute_costs[(ord(d), ord(c))] = cost
edp.error_substitute_costs[(ord(c), ord(d))] = 5 * cost # / 4 edp.error_substitute_costs[(ord(c), ord(d))] = 5 * cost
edp.error_substitute_costs[(ord(d), ord(c))] = 5 * cost # / 4 edp.error_substitute_costs[(ord(d), ord(c))] = 5 * cost
return edp return edp
@ -141,6 +95,7 @@ def edit_distance(
a: str, a: str,
b: str, b: str,
error: bool) -> np.float64: error: bool) -> np.float64:
"""Damerau-Levenshtein edit distance between two pixel strings."""
res = weighted_levenshtein.dam_lev( res = weighted_levenshtein.dam_lev(
a, b, a, b,
insert_costs=edp.insert_costs, insert_costs=edp.insert_costs,
@ -149,7 +104,8 @@ def edit_distance(
edp.error_substitute_costs if error else edp.substitute_costs), edp.error_substitute_costs if error else edp.substitute_costs),
) )
assert res == 0 or (1 <= res < 2 ** 16), res # Make sure result can fit in a uint16
assert (0 <= res < 2 ** 16), res
return res return res
@ -158,6 +114,19 @@ def compute_edit_distance(
bitmap_cls: Type[screen.Bitmap], bitmap_cls: Type[screen.Bitmap],
nominal_colours: Type[colours.NominalColours] nominal_colours: Type[colours.NominalColours]
): ):
"""Computes edit distance matrix between all pairs of pixel strings.
Enumerates all possible values of the masked bit representation from
bitmap_cls (assuming it is contiguous, i.e. we enumerate all
2**bitmap_cls.MASKED_BITS values). These are mapped to the dot
representation, turned into coloured pixel strings, and we compute the
edit distance.
The effect of this is that we precompute the effect of storing all possible
byte values against all possible screen backgrounds (e.g. as
influencing/influenced by neighbouring bytes).
"""
bits = bitmap_cls.MASKED_BITS bits = bitmap_cls.MASKED_BITS
bitrange = np.uint64(2 ** bits) bitrange = np.uint64(2 ** bits)
@ -171,7 +140,7 @@ def compute_edit_distance(
# triangle # triangle
bar = ProgressBar((bitrange * (bitrange - 1)) / 2, max_width=80) bar = ProgressBar((bitrange * (bitrange - 1)) / 2, max_width=80)
num_dots = bitmap_cls.HEADER_BITS + bitmap_cls.BODY_BITS num_dots = bitmap_cls.MASKED_DOTS
cnt = 0 cnt = 0
for i in range(np.uint64(bitrange)): for i in range(np.uint64(bitrange)):
@ -211,6 +180,8 @@ def make_edit_distance(
bitmap_cls: Type[screen.Bitmap], bitmap_cls: Type[screen.Bitmap],
nominal_colours: Type[colours.NominalColours] nominal_colours: Type[colours.NominalColours]
): ):
"""Write file containing (D)HGR edit distance matrix for a palette."""
dist = compute_edit_distance(edp, bitmap_cls, nominal_colours) dist = compute_edit_distance(edp, bitmap_cls, nominal_colours)
data = "transcoder/data/%s_palette_%d_edit_distance.pickle.bz2" % ( data = "transcoder/data/%s_palette_%d_edit_distance.pickle.bz2" % (
bitmap_cls.NAME, pal.ID.value) bitmap_cls.NAME, pal.ID.value)
@ -223,7 +194,7 @@ def main():
print("Processing palette %s" % p) print("Processing palette %s" % p)
edp = compute_substitute_costs(p) edp = compute_substitute_costs(p)
# TODO: error distance matrices # TODO: still worth using error distance matrices?
make_edit_distance(p, edp, screen.HGRBitmap, colours.HGRColours) make_edit_distance(p, edp, screen.HGRBitmap, colours.HGRColours)
make_edit_distance(p, edp, screen.DHGRBitmap, colours.DHGRColours) make_edit_distance(p, edp, screen.DHGRBitmap, colours.DHGRColours)

View File

@ -15,7 +15,8 @@ class TestMakeDataTables(unittest.TestCase):
pixels = (HGRColours.BLACK, HGRColours.WHITE, HGRColours.ORANGE) pixels = (HGRColours.BLACK, HGRColours.WHITE, HGRColours.ORANGE)
self.assertEqual("0FC", make_data_tables.pixel_string(pixels)) self.assertEqual("0FC", make_data_tables.pixel_string(pixels))
def test_edit_distances(self): def test_edit_distances_dhgr(self):
"""Assert invariants and symmetries of the edit distance matrices."""
for p in PALETTES: for p in PALETTES:
ed = screen.DHGRBitmap.edit_distances(p) ed = screen.DHGRBitmap.edit_distances(p)
print(p) print(p)
@ -52,6 +53,8 @@ class TestMakeDataTables(unittest.TestCase):
self.assertGreaterEqual(ed[ph][(i << 13) + j], 0) self.assertGreaterEqual(ed[ph][(i << 13) + j], 0)
def test_edit_distances_hgr(self): def test_edit_distances_hgr(self):
"""Assert invariants and symmetries of the edit distance matrices."""
for p in PALETTES: for p in PALETTES:
ed = screen.HGRBitmap.edit_distances(p) ed = screen.HGRBitmap.edit_distances(p)
print(p) print(p)
@ -61,13 +64,17 @@ class TestMakeDataTables(unittest.TestCase):
cnt = 0 cnt = 0
for ph in range(2): for ph in range(2):
# Only zero entries should be on diagonal, i.e. of form # TODO: for HGR this invariant isn't true, all-0 and all-1
# values for header/footer/body with/without palette bit can
# also have zero difference
# # Only zero entries should be on diagonal, i.e. of form
# # i << 14 + i # # i << 14 + i
# zeros = np.arange(len(ed[ph]))[ed[ph] == 0] # zeros = np.arange(len(ed[ph]))[ed[ph] == 0]
# for z in zeros: # for z in zeros:
# z1 = z & (2**14-1) # z1 = z & (2**14-1)
# z2 = (z >> 14) & (2**14-1) # z2 = (z >> 14) & (2**14-1)
# self.assertEqual(z1, z2) # if z1 != z2:
# self.assertEqual(z1, z2)
# Assert that matrix is symmetrical # Assert that matrix is symmetrical
for i in range(2 ** 14): for i in range(2 ** 14):

View File

@ -76,6 +76,7 @@ class Movie:
if aux: if aux:
aux_seq = self.video.encode_frame(aux, is_aux=True) aux_seq = self.video.encode_frame(aux, is_aux=True)
# au has range -15 .. 16 (step=1) # au has range -15 .. 16 (step=1)
# Tick cycles are units of 2 # Tick cycles are units of 2
tick = au * 2 # -30 .. 32 (step=2) tick = au * 2 # -30 .. 32 (step=2)
@ -86,18 +87,20 @@ class Movie:
yield opcodes.TICK_OPCODES[(tick, page)](content, offsets) yield opcodes.TICK_OPCODES[(tick, page)](content, offsets)
def _emit_bytes(self, _op): def _emit_bytes(self, _op: opcodes.Opcode) -> Iterable[int]:
""" """Emit compiled bytes corresponding to a player opcode.
:param _op: Also tracks byte stream position.
:return:
""" """
for b in self.state.emit(_op): for b in self.state.emit(_op):
yield b yield b
self.stream_pos += 1 self.stream_pos += 1
def emit_stream(self, ops: Iterable[opcodes.Opcode]) -> Iterator[int]: def emit_stream(self, ops: Iterable[opcodes.Opcode]) -> Iterator[int]:
""" """Emit compiled byte stream corresponding to opcode stream.
Inserts padding opcodes at 2KB stream boundaries, to instruct player
to manage the TCP socket buffer.
:param ops: :param ops:
:return: :return:
@ -124,7 +127,7 @@ class Movie:
yield from self.done() yield from self.done()
def done(self) -> Iterator[int]: def done(self) -> Iterator[int]:
"""Terminate opcode stream. """Terminate byte stream by emitting terminal opcode and padding to 2KB.
:return: :return:
""" """

View File

@ -1,3 +1,5 @@
"""RGB palette values for rendering NominalColour pixels."""
import enum import enum
from typing import Dict, Type from typing import Dict, Type
@ -14,7 +16,8 @@ def rgb(r, g, b):
class Palette(enum.Enum): class Palette(enum.Enum):
"""BMP2DHR palette numbers""" """BMP2DHR palette numbers."""
UNKNOWN = -1 UNKNOWN = -1
IIGS = 0 IIGS = 0
NTSC = 5 NTSC = 5

View File

@ -14,7 +14,7 @@ IntOrArray = Union[np.uint64, np.ndarray]
def y_to_base_addr(y: int, page: int = 0) -> int: def y_to_base_addr(y: int, page: int = 0) -> int:
"""Maps y coordinate to base address on given screen page""" """Maps y coordinate to base address on given screen page."""
a = y // 64 a = y // 64
d = y - 64 * a d = y - 64 * a
b = d // 8 b = d // 8
@ -126,33 +126,44 @@ class MemoryMap:
class Bitmap: class Bitmap:
"""Packed 28-bit bitmap representation of (D)HGR screen memory. """Packed bitmap representation of (D)HGR screen memory.
XXX comments Maintains a page-based array whose entries contain a packed representation
of multiple screen bytes, in a representation that supports efficiently
The memory layout is still page-oriented, not linear y-x buffer but the determining the visual effect of storing bytes at arbitrary screen offsets.
bit map is such that 20 consecutive entries linearly encode the 28*20 =
560-bit monochrome dot positions that underlie both Mono and Colour (
D)HGR screens.
For Colour display the (nominal) colours are encoded as 4-bit pixels.
""" """
# NOTE: See https://github.com/numpy/numpy/issues/2524 and related issues
# for why we have to cast things explicitly to np.uint64 - type promotion
# to uint64 is broken in numpy :(
# Name of bitmap type
NAME = None # type: str NAME = None # type: str
# Size of packed representation # Size of packed representation, consisting of header + body + footer
HEADER_BITS = None # type: np.uint64 HEADER_BITS = None # type: np.uint64
BODY_BITS = None # type: np.uint64 BODY_BITS = None # type: np.uint64
FOOTER_BITS = None # type: np.uint64 FOOTER_BITS = None # type: np.uint64
# How many bits of packed representation are necessary to determine the
# effect of storing a memory byte, e.g. because they influence pixel
# colour or are influenced by other bits.
MASKED_BITS = None # type: np.uint64
# How many coloured screen pixels we can extract from MASKED_BITS. Note
# that this does not include the last 3 dots represented by the footer,
# since we don't have enough information to determine their colour (we
# would fall off the end of the 4-bit sliding window)
MASKED_DOTS = None # type: np.uint64
# List of bitmasks for extracting the subset of packed data corresponding
# to bits influencing/influenced by a given byte offset. These must be
# a contiguous bit mask, i.e. so that after shifting they are enumerated
# by 0..2**MASKED_BITS-1
BYTE_MASKS = None # type: List[np.uint64] BYTE_MASKS = None # type: List[np.uint64]
BYTE_SHIFTS = None # type: List[np.uint64] BYTE_SHIFTS = None # type: List[np.uint64]
# How many bits of packed representation are influenced when storing a # NTSC clock phase at first masked bit
# memory byte
MASKED_BITS = None # type: np.uint64
# XXX
PHASES = None # type: List[int] PHASES = None # type: List[int]
def __init__( def __init__(
@ -176,18 +187,21 @@ class Bitmap:
shape=(32, 128), dtype=np.uint64) # type: np.ndarray shape=(32, 128), dtype=np.uint64) # type: np.ndarray
self._pack() self._pack()
def _body(self) -> np.ndarray:
raise NotImplementedError
# TODO: don't leak headers/footers across screen rows. We should be using # TODO: don't leak headers/footers across screen rows. We should be using
# x-y representation rather than page-offset # x-y representation rather than page-offset
@staticmethod @staticmethod
def _make_header(prev_col: IntOrArray) -> IntOrArray: def _make_header(col: IntOrArray) -> IntOrArray:
"""Extract values to use as header of next column."""
raise NotImplementedError
def _body(self) -> np.ndarray:
"""Pack related screen bytes into an efficient representation."""
raise NotImplementedError raise NotImplementedError
@staticmethod @staticmethod
def _make_footer(next_col: IntOrArray) -> IntOrArray: def _make_footer(col: IntOrArray) -> IntOrArray:
"""Extract values to use as footer of previous column."""
raise NotImplementedError raise NotImplementedError
def _pack(self) -> None: def _pack(self) -> None:
@ -195,17 +209,15 @@ class Bitmap:
body = self._body() body = self._body()
# XXX comments # Prepend last 3 bits of previous odd byte so we can correctly
# Prepend last 3 bits of previous main odd byte so we can correctly # decode the effective colours at the beginning of the 22-bit tuple
# decode the effective colours at the beginning of the 28-bit
# tuple
prev_col = np.roll(body, 1, axis=1).astype(np.uint64) prev_col = np.roll(body, 1, axis=1).astype(np.uint64)
header = self._make_header(prev_col) header = self._make_header(prev_col)
# Don't leak header across page boundaries # Don't leak header across page boundaries
header[:, 0] = 0 header[:, 0] = 0
# Append first 3 bits of next aux even byte so we can correctly # Append first 3 bits of next even byte so we can correctly
# decode the effective colours at the end of the 28-bit tuple # decode the effective colours at the end of the 22-bit tuple
next_col = np.roll(body, -1, axis=1).astype(np.uint64) next_col = np.roll(body, -1, axis=1).astype(np.uint64)
footer = self._make_footer(next_col) footer = self._make_footer(next_col)
# Don't leak footer across page boundaries # Don't leak footer across page boundaries
@ -218,20 +230,27 @@ class Bitmap:
byte_offset: int, byte_offset: int,
old_value: IntOrArray, old_value: IntOrArray,
new_value: np.uint8) -> IntOrArray: new_value: np.uint8) -> IntOrArray:
"""Update int/array to store new value at byte_offset in every entry.
Does not patch up headers/footers of neighbouring columns.
"""
raise NotImplementedError raise NotImplementedError
@staticmethod @staticmethod
@functools.lru_cache(None) @functools.lru_cache(None)
def byte_offset(x_byte: int, is_aux: bool) -> int: def byte_offset(page_offset: int, is_aux: bool) -> int:
"""Map screen offset for aux/main into offset within packed data."""
raise NotImplementedError raise NotImplementedError
@staticmethod @staticmethod
@functools.lru_cache(None) @functools.lru_cache(None)
def _byte_offsets(is_aux: bool) -> Tuple[int, int]: def _byte_offsets(is_aux: bool) -> Tuple[int, int]:
"""Return byte offsets within packed data for AUX/MAIN memory."""
raise NotImplementedError raise NotImplementedError
@classmethod @classmethod
def to_dots(cls, masked_val: int, byte_offset: int) -> int: def to_dots(cls, masked_val: int, byte_offset: int) -> int:
"""Convert masked representation to bit sequence of display dots."""
raise NotImplementedError raise NotImplementedError
def apply( def apply(
@ -254,6 +273,7 @@ class Bitmap:
page: int, page: int,
offset: int, offset: int,
byte_offset: int) -> None: byte_offset: int) -> None:
"""Fix up column headers/footers when updating a (page, offset)."""
if byte_offset == 0 and offset > 0: if byte_offset == 0 and offset > 0:
self.packed[page, offset - 1] = self._fix_column_left( self.packed[page, offset - 1] = self._fix_column_left(
@ -272,6 +292,8 @@ class Bitmap:
column_left: IntOrArray, column_left: IntOrArray,
column: IntOrArray column: IntOrArray
) -> IntOrArray: ) -> IntOrArray:
"""Patch up the footer of the column to the left."""
# Mask out footer(s) # Mask out footer(s)
column_left &= np.uint64(2 ** (self.HEADER_BITS + self.BODY_BITS) - 1) column_left &= np.uint64(2 ** (self.HEADER_BITS + self.BODY_BITS) - 1)
column_left ^= self._make_footer(column) column_left ^= self._make_footer(column)
@ -283,6 +305,8 @@ class Bitmap:
column_right: IntOrArray, column_right: IntOrArray,
column: IntOrArray column: IntOrArray
) -> IntOrArray: ) -> IntOrArray:
"""Patch up the header of the column to the right."""
# Mask out header(s) # Mask out header(s)
column_right &= np.uint64( column_right &= np.uint64(
(2 ** (self.BODY_BITS + self.FOOTER_BITS) - 1)) << self.HEADER_BITS (2 ** (self.BODY_BITS + self.FOOTER_BITS) - 1)) << self.HEADER_BITS
@ -295,15 +319,19 @@ class Bitmap:
ary: np.ndarray, ary: np.ndarray,
byte_offset: int byte_offset: int
) -> None: ) -> None:
"""Fix up column headers/footers for all array entries."""
# TODO: don't leak header/footer across page boundaries
# Propagate new value into neighbouring byte headers/footers if # Propagate new value into neighbouring byte headers/footers if
# necessary # necessary
if byte_offset == 0: if byte_offset == 0:
# Need to also update the 3-bit footer of the preceding column # Need to also update the footer of the preceding column
shifted_left = np.roll(ary, -1, axis=1) shifted_left = np.roll(ary, -1, axis=1)
self._fix_column_left(ary, shifted_left) self._fix_column_left(ary, shifted_left)
elif byte_offset == 3: elif byte_offset == (self.SCREEN_BYTES - 1):
# Need to also update the 3-bit header of the next column # Need to also update the header of the next column
shifted_right = np.roll(ary, 1, axis=1) shifted_right = np.roll(ary, 1, axis=1)
self._fix_column_right(ary, shifted_right) self._fix_column_right(ary, shifted_right)
@ -340,22 +368,25 @@ class Bitmap:
cls, cls,
data: IntOrArray, data: IntOrArray,
byte_offset: int) -> IntOrArray: byte_offset: int) -> IntOrArray:
"""Masks and shifts data into the MASKED_BITS range.""" """Masks and shifts packed data into the MASKED_BITS range."""
res = (data & cls.BYTE_MASKS[byte_offset]) >> ( res = (data & cls.BYTE_MASKS[byte_offset]) >> (
cls.BYTE_SHIFTS[byte_offset]) cls.BYTE_SHIFTS[byte_offset])
assert np.all(res <= 2 ** cls.MASKED_BITS) assert np.all(res <= 2 ** cls.MASKED_BITS)
return res return res
# Can't cache all possible values but this seems to give a good enough hit
# rate without costing too much memory
# TODO: unit tests # TODO: unit tests
@functools.lru_cache(None) @functools.lru_cache(10 ** 6)
def byte_pair_difference( def byte_pair_difference(
self, self,
byte_offset: int, byte_offset: int,
old_packed: np.uint64, old_packed: np.uint64,
content: np.uint8 content: np.uint8
) -> np.uint16: ) -> np.uint16:
old_pixels = self.mask_and_shift_data( """Compute effect of storing a new content byte within packed data."""
old_packed, byte_offset)
old_pixels = self.mask_and_shift_data(old_packed, byte_offset)
new_pixels = self.mask_and_shift_data( new_pixels = self.mask_and_shift_data(
self.masked_update(byte_offset, old_packed, content), byte_offset) self.masked_update(byte_offset, old_packed, content), byte_offset)
@ -368,15 +399,24 @@ class Bitmap:
source: "Bitmap", source: "Bitmap",
is_aux: bool is_aux: bool
) -> np.ndarray: ) -> np.ndarray:
"""Compute edit distance matrix from source bitmap."""
return self._diff_weights(source.packed, is_aux) return self._diff_weights(source.packed, is_aux)
# TODO: unit test
def _diff_weights( def _diff_weights(
self, self,
source_packed: np.ndarray, source_packed: np.ndarray,
is_aux: bool, is_aux: bool,
content: np.uint8 = None content: np.uint8 = None
) -> np.ndarray: ) -> np.ndarray:
"""Computes diff from source_packed to self.packed""" """Computes edit distance matrix from source_packed to self.packed
If content is set, the distance will be computed as if this value
was stored into each offset position of source_packed, i.e. to
allow evaluating which offsets (if any) should be chosen for storing
this content byte.
"""
diff = np.ndarray((32, 256), dtype=np.int) diff = np.ndarray((32, 256), dtype=np.int)
offsets = self._byte_offsets(is_aux) offsets = self._byte_offsets(is_aux)
@ -384,63 +424,173 @@ class Bitmap:
dists = [] dists = []
for o in offsets: for o in offsets:
if content is not None: if content is not None:
source_packed = self.masked_update(o, source_packed, content) compare_packed = self.masked_update(o, source_packed, content)
self._fix_array_neighbours(source_packed, o) self._fix_array_neighbours(compare_packed, o)
else:
compare_packed = source_packed
# Pixels influenced by byte offset o # Pixels influenced by byte offset o
source_pixels = self.mask_and_shift_data(source_packed, o) source_pixels = self.mask_and_shift_data(compare_packed, o)
target_pixels = self.mask_and_shift_data(self.packed, o) target_pixels = self.mask_and_shift_data(self.packed, o)
# Concatenate 13-bit source and target into 26-bit values # Concatenate N-bit source and target into 2N-bit values
pair = (source_pixels << self.MASKED_BITS) + target_pixels pair = (source_pixels << self.MASKED_BITS) + target_pixels
dist = self.edit_distances(self.palette)[o][pair].reshape( dist = self.edit_distances(self.palette)[o][pair].reshape(
pair.shape) pair.shape)
dists.append(dist) dists.append(dist)
# Interleave even/odd columns
diff[:, 0::2] = dists[0] diff[:, 0::2] = dists[0]
diff[:, 1::2] = dists[1] diff[:, 1::2] = dists[1]
return diff return diff
def _check_consistency(self):
"""Sanity check that headers and footers are consistent."""
headers = np.roll(self._make_header(self.packed), 1, axis=1).astype(
np.uint64)
footers = np.roll(self._make_footer(self.packed), -1, axis=1).astype(
np.uint64)
mask_hf = np.uint64(0b1110000000000000000000000000000111)
res = (self.packed ^ headers ^ footers) & mask_hf
nz = np.transpose(np.nonzero(res))
ok = True
if nz.size != 0:
for p, o in nz.tolist():
if o == 0 or o == 127:
continue
ok = False
print(p, o, bin(self.packed[p, o - 1]),
bin(headers[p, o]),
bin(self.packed[p, o]),
bin(self.packed[p, o + 1]), bin(footers[p, o]),
bin(res[p, o])
)
assert ok
# TODO: unit tests # TODO: unit tests
def compute_delta( def compute_delta(
self, self,
content: int, content: int,
old: np.ndarray, diff_weights: np.ndarray,
is_aux: bool is_aux: bool
) -> np.ndarray: ) -> np.ndarray:
# TODO: use error edit distance """Compute which content stores introduce the least additional error.
diff = self._diff_weights(self.packed, is_aux, content) We compute the effect of storing content at all possible offsets
within self.packed, and then subtract the previous diff weights.
Negative values indicate that the new content value is closer to the
target than the current content.
"""
# TODO: use error edit distance?
new_diff = self._diff_weights(self.packed, is_aux, content)
# TODO: try different weightings # TODO: try different weightings
return (diff * 5) - old return (new_diff * 5) - diff_weights
class HGRBitmap(Bitmap): class HGRBitmap(Bitmap):
"""Packed bitmap representation of HGR screen memory.
The HGR display is encoded in a somewhat complicated way, so we have to
do a bit of work to turn it into a useful format.
Each screen byte consists of a palette bit (7) and 6 data bits (0..6)
Each non-palette bit turns on two consecutive display dots, with bit 6
repeated a third time. This third dot may or may not be overwritten by the
effect of the next byte.
Turning on the palette bit shifts that byte's dots right by one
position.
Given two neighbouring screen bytes Aaaaaaaa, Bbbbbbbb (at even and odd
offsets), where capital letter indicates the position of the palette bit,
we use the following 22-bit packed representation:
2211111111110000000000 <-- bit position in uint22
1098765432109876543210
ffFbbbbbbbBAaaaaaaaHhh
h and f are headers/footers derived from the neighbouring screen bytes.
Since our colour artifact model (see colours.py) uses a sliding 4-bit window
onto the dot string, we need to also include a 3-bit header and footer
to account for the influence from/on neighbouring bytes, i.e. adjacent
packed values. These are just the low/high 2 data bits of the 16-bit
body of those neighbouring columns, plus the corresponding palette bit.
This 22-bit packed representation is sufficient to compute the effects
(on pixel colours) of storing a byte at even or odd offsets. From it we
can extract the bit stream of displayed HGR dots, and the mapping to pixel
colours follows the HGRColours bitmap, see colours.py.
We put the two A/B palette bits next to each other so that we can
mask a contiguous range of bits whose colours influence/are influenced by
storing a byte at a given offset.
We need to mask out bit subsequences of size 3+8+3=14, i.e. the 8-bits
corresponding to the byte being stored, plus the neighbouring 3 bits that
influence it/are influenced by it.
Note that the masked representation has the same size for both offsets (
14 bits), but different meaning, since the palette bit is in a different
position.
With this masked representation, we can precompute an edit distance for the
pixel changes resulting from all possible HGR byte stores, see
make_edit_distance.py.
The edit distance matrix is encoded by concatenating the 14-bit source
and target masked values into a 28-bit pair, which indexes into the
edit_distance array to give the corresponding edit distance.
"""
NAME = 'HGR' NAME = 'HGR'
# hhhbbbbbbbpPBBBBBBBfff # Size of packed representation, consisting of header + body + footer
# 0000000011111111111111 HEADER_BITS = np.uint64(3)
# 1111111111111100000000 # 2x 8-bit screen bytes
BODY_BITS = np.uint64(16)
FOOTER_BITS = np.uint64(3)
# Header: # How many bits of packed representation are necessary to determine the
# 0000000010000011 # effect of storing a memory byte, e.g. because they influence pixel
# Footer: # colour or are influenced by other bits.
# 1100000100000000 MASKED_BITS = np.uint64(14) # 3 + 8 + 3
# How many coloured screen pixels we can extract from MASKED_BITS. Note
# that this does not include the last 3 dots represented by the footer,
# since we don't have enough information to determine their colour (we
# would fall off the end of the 4-bit sliding window)
#
# From header: 3 bits (2 HGR pixels but might be shifted right by palette)
# From body: 7 bits doubled, plus possible shift from palette bit
MASKED_DOTS = np.uint64(18) # 3 + 7 + 7
# List of bitmasks for extracting the subset of packed data corresponding
# to bits influencing/influenced by a given byte offset. These must be
# a contiguous bit mask, i.e. so that after shifting they are enumerated
# by 0..2**MASKED_BITS-1
BYTE_MASKS = [ BYTE_MASKS = [
np.uint64(0b0000000011111111111111), np.uint64(0b0000000011111111111111),
np.uint64(0b1111111111111100000000) np.uint64(0b1111111111111100000000)
] ]
BYTE_SHIFTS = [np.uint64(0), np.uint64(8)] BYTE_SHIFTS = [np.uint64(0), np.uint64(8)]
MASKED_BITS = np.uint64(14) # 3 + 8 + 3
HEADER_BITS = np.uint64(3)
# 7-bits doubled, plus possible shift from palette bit
BODY_BITS = np.uint64(15)
FOOTER_BITS = np.uint64(3)
# NTSC clock phase at first masked bit
#
# Each HGR byte offset has the same range of uint14 possible
# values and nominal colour pixels, but with different initial
# phases:
# even: 0 (1 at start of 3-bit header)
# odd: 2 (3)
PHASES = [1, 3] PHASES = [1, 3]
def __init__(self, palette: pal.Palette, main_memory: MemoryMap): def __init__(self, palette: pal.Palette, main_memory: MemoryMap):
@ -448,10 +598,11 @@ class HGRBitmap(Bitmap):
@staticmethod @staticmethod
def _make_header(col: IntOrArray) -> IntOrArray: def _make_header(col: IntOrArray) -> IntOrArray:
# Header format is bits 5,6,0 of previous byte """Extract values to use as header of next column.
# i.e. offsets 16, 17, 11
# return (col & np.uint64(0b111 << 16)) >> np.uint64(16) Header format is bits 5,6,0 of previous screen byte
i.e. offsets 17, 18, 11 in packed representation
"""
return ( return (
(col & np.uint64(0b1 << 11)) >> np.uint64(9) ^ ( (col & np.uint64(0b1 << 11)) >> np.uint64(9) ^ (
@ -459,11 +610,13 @@ class HGRBitmap(Bitmap):
) )
def _body(self) -> np.ndarray: def _body(self) -> np.ndarray:
# Body is in order """Pack related screen bytes into an efficient representation.
# a0 a1 a2 a3 a4 a5 a6 a7 b7 b0 b1 b2 b3 b4 b5 b6
# so that a) the header and footer have the same order Body is of the form:
# across the two byte offsets, and b) so that they bbbbbbbBAaaaaaaa
# can be extracted as contiguous bit ranges
where capital indicates the palette bit.
"""
even = self.main_memory.page_offset[:, 0::2].astype(np.uint64) even = self.main_memory.page_offset[:, 0::2].astype(np.uint64)
odd = self.main_memory.page_offset[:, 1::2].astype(np.uint64) odd = self.main_memory.page_offset[:, 1::2].astype(np.uint64)
@ -474,133 +627,46 @@ class HGRBitmap(Bitmap):
@staticmethod @staticmethod
def _make_footer(col: IntOrArray) -> IntOrArray: def _make_footer(col: IntOrArray) -> IntOrArray:
# Footer format is bits 7,0,1 of next byte """Extract values to use as footer of previous column.
# i.e. offsets 10,3,4
Footer format is bits 7,0,1 of next screen byte
i.e. offsets 10,3,4 in packed representation
"""
return ( return (
(col & np.uint64(0b1 << 10)) >> np.uint64(10) ^ ( (col & np.uint64(0b1 << 10)) >> np.uint64(10) ^ (
(col & np.uint64(0b11 << 3)) >> np.uint64(2)) (col & np.uint64(0b11 << 3)) >> np.uint64(2))
) << np.uint64(19) ) << np.uint64(19)
# # XXX move to make_data_tables
# def _pack(self) -> None:
# """Pack main memory into (28+3)-bit uint64 array"""
#
# # 00000000001111111111222222222233
# # 01234567890123456789012345678901
# # AAAABBBBCCCCDDd
# # AAAABBBBCCCCDd
# # DDEEEEFFFFGGGGg
# # dDDEEEEFFFFGGGg
#
# # Even, P0: store unshifted (0..14)
# # Even, P1: store shifted << 1 (1..15) (only need 1..14)
#
# # Odd, P0: store shifted << 14 (14 .. 28) - set bit 14 as bit 0 of next
# # byte
# # Odd, p1: store shifted << 15 (15 .. 29) (only need 15 .. 28) - set
# # bit 13 as bit 0 of next byte
#
# # Odd overflow only matters for even, P1
# # - bit 0 is either bit 14 if odd, P0 or bit 13 if odd, P1
# # - but these both come from the undoubled bit 6.
#
# main = self.main_memory.page_offset.astype(np.uint64)
#
# # Double 7-bit pixel data from a into 14-bit fat pixels, and extend MSB
# # into 15-bits tohandle case when subsequent byte has palette bit set,
# # i.e. is right-shifted by 1 dot. This only matters for even bytes
# # with P=0 that are followed by odd bytes with P=1; in other cases
# # this extra bit will be overwritten.
# double = (
# # Bit pos 6
# ((main & 0x40) << 8) + ((main & 0x40) << 7) + (
# (main & 0x40) << 6)) + (
# # Bit pos 5
# ((main & 0x20) << 6) + ((main & 0x20) << 5)) + (
# # Bit pos 4
# ((main & 0x10) << 5) + ((main & 0x10) << 4)) + (
# # Bit pos 3
# ((main & 0x08) << 4) + ((main & 0x08) << 3)) + (
# # Bit pos 2
# ((main & 0x04) << 3) + ((main & 0x04) << 2)) + (
# # Bit pos 1
# ((main & 0x02) << 2) + ((main & 0x02) << 1)) + (
# # Bit pos 0
# ((main & 0x01) << 1) + (main & 0x01))
#
# a_even = main[:, ::2]
# a_odd = main[:, 1::2]
#
# double_even = double[:, ::2]
# double_odd = double[:, 1::2]
#
# # Place even offsets at bits 1..15 (P=1) or 0..14 (P=0)
# packed = np.where(a_even & 0x80, double_even << 1, double_even)
#
# # Place off offsets at bits 15..27 (P=1) or 14..27 (P=0)
# packed = np.where(
# a_odd & 0x80,
# np.bitwise_xor(
# np.bitwise_and(packed, (2 ** 15 - 1)),
# double_odd << 15
# ),
# np.bitwise_xor(
# np.bitwise_and(packed, (2 ** 14 - 1)),
# double_odd << 14
# )
# )
#
# # Patch up even offsets with P=1 with extended bit from previous odd
# # column
#
# previous_odd = np.roll(a_odd, 1, axis=1).astype(np.uint64)
#
# packed = np.where(
# a_even & 0x80,
# # Truncate to 28-bits and set bit 0 from bit 6 of previous byte
# np.bitwise_xor(
# np.bitwise_and(packed, (2 ** 28 - 2)),
# (previous_odd & (1 << 6)) >> 6
# ),
# # Truncate to 28-bits
# np.bitwise_and(packed, (2 ** 28 - 1))
# )
#
# # Append first 3 bits of next even byte so we can correctly
# # decode the effective colours at the end of the 28-bit tuple
# trailing = np.roll(packed, -1, axis=1).astype(np.uint64)
#
# packed = np.bitwise_xor(
# packed,
# (trailing & 0b111) << 28
# )
#
# self.packed = packed
@staticmethod @staticmethod
@functools.lru_cache(None) @functools.lru_cache(None)
def byte_offset(x_byte: int, is_aux: bool) -> int: def byte_offset(page_offset: int, is_aux: bool) -> int:
"""Returns 0..1 offset in packed representation for a given x_byte.""" """Returns 0..1 offset in packed representation for page_offset."""
assert not is_aux
is_odd = x_byte % 2 == 1 assert not is_aux
is_odd = page_offset % 2 == 1
return 1 if is_odd else 0 return 1 if is_odd else 0
@staticmethod @staticmethod
@functools.lru_cache(None) @functools.lru_cache(None)
def _byte_offsets(is_aux: bool) -> Tuple[int, int]: def _byte_offsets(is_aux: bool) -> Tuple[int, int]:
"""Return byte offsets within packed data for AUX/MAIN memory."""
assert not is_aux assert not is_aux
return 0, 1 return 0, 1
@staticmethod @staticmethod
@functools.lru_cache(None) @functools.lru_cache(None)
def _double_pixels(int7: int) -> int: def _double_pixels(int7: int) -> int:
"""Each bit 0..6 controls two hires dots.
# Input bit 6 is repeated 3 times in case the neighbouring byte is Input bit 6 is repeated 3 times in case the neighbouring byte is
# delayed (right-shifted by one dot) due to the palette bit being set. delayed (right-shifted by one dot) due to the palette bit being set,
# Care needs to be taken to mask this out when overwriting. which means the effect of this byte is "extended" by an extra dot.
Care needs to be taken to mask this out when overwriting.
"""
double = ( double = (
# Bit pos 6 # Bit pos 6
((int7 & 0x40) << 8) + ((int7 & 0x40) << 7) + ( ((int7 & 0x40) << 8) + ((int7 & 0x40) << 7) + (
@ -608,32 +674,38 @@ class HGRBitmap(Bitmap):
# Bit pos 5 # Bit pos 5
((int7 & 0x20) << 6) + ((int7 & 0x20) << 5) + ((int7 & 0x20) << 6) + ((int7 & 0x20) << 5) +
# Bit pos 4 # Bit pos 4
((int7 & 0x10) << 5) + ((int7 & 0x10) << 4) + ( ((int7 & 0x10) << 5) + ((int7 & 0x10) << 4) +
# Bit pos 3 # Bit pos 3
((int7 & 0x08) << 4) + ((int7 & 0x08) << 3) + ((int7 & 0x08) << 4) + ((int7 & 0x08) << 3) +
# Bit pos 2 # Bit pos 2
((int7 & 0x04) << 3) + ((int7 & 0x04) << 2) + ((int7 & 0x04) << 3) + ((int7 & 0x04) << 2) +
# Bit pos 1 # Bit pos 1
((int7 & 0x02) << 2) + ((int7 & 0x02) << 1) + ((int7 & 0x02) << 2) + ((int7 & 0x02) << 1) +
# Bit pos 0 # Bit pos 0
((int7 & 0x01) << 1) + (int7 & 0x01)) ((int7 & 0x01) << 1) + (int7 & 0x01)
) )
return double return double
@classmethod @classmethod
def to_dots(cls, masked_val: int, byte_offset: int) -> int: def to_dots(cls, masked_val: int, byte_offset: int) -> int:
"""Convert masked representation to bit sequence of display dots.
Packed representation is of the form:
ffFbbbbbbbBAaaaaaaaHhh
where capital indicates the palette bit.
Each non-palette bit turns on two display dots, with bit 6 repeated
a third time. This may or may not be overwritten by the next byte.
Turning on the palette bit shifts that byte's dots right by one
position.
"""
# Assert 14-bit representation # Assert 14-bit representation
assert (masked_val & (2 ** 14 - 1)) == masked_val assert (masked_val & (2 ** 14 - 1)) == masked_val
# Unpack hhHaaaaaaaABbbbbbbbFff
# --> hhhaaaaaaaaaaaaaabbbb (P=0, P=0, P=0)
# hhhaaaaaaaaaaaaaabbbb (P=1, P=0, P=0)
# hhhhaaaaaaaaaaaaabbbb (P=1, P=1, P=0)
# hhhhaaaaaaaaaaaaaabbb (P=1, P=1, P=1)
# Take top 3 bits from header (plus duplicated MSB) not 4, because if it # Take top 3 bits from header (plus duplicated MSB) not 4, because if it
# is palette-shifted then we don't know what is in bit 0 # is palette-shifted then we don't know what is in bit 0
h = (masked_val & 0b111) << 5 h = (masked_val & 0b111) << 5
@ -641,11 +713,11 @@ class HGRBitmap(Bitmap):
res = cls._double_pixels(h & 0x7f) >> (11 - hp) res = cls._double_pixels(h & 0x7f) >> (11 - hp)
if byte_offset == 0: if byte_offset == 0:
# Offset 0: hhHaaaaaaaABbb # Offset 0: bbBAaaaaaaaHhh
b = (masked_val >> 3) & 0xff b = (masked_val >> 3) & 0xff
bp = (b & 0x80) >> 7 bp = (b & 0x80) >> 7
else: else:
# Offset 1: aaABbbbbbbbFff # Offset 1: ffFbbbbbbbBAaa
bp = (masked_val >> 3) & 0x01 bp = (masked_val >> 3) & 0x01
b = ((masked_val >> 4) & 0x7f) ^ (bp << 7) b = ((masked_val >> 4) & 0x7f) ^ (bp << 7)
@ -664,7 +736,6 @@ class HGRBitmap(Bitmap):
res ^= cls._double_pixels(f & 0x7f) << (17 + fp) res ^= cls._double_pixels(f & 0x7f) << (17 + fp)
return res & (2 ** 21 - 1) return res & (2 ** 21 - 1)
# XXX test
@staticmethod @staticmethod
def masked_update( def masked_update(
byte_offset: int, byte_offset: int,
@ -694,37 +765,99 @@ class HGRBitmap(Bitmap):
class DHGRBitmap(Bitmap): class DHGRBitmap(Bitmap):
# NOTE: See https://github.com/numpy/numpy/issues/2524 and related issues """Packed bitmap representation of DHGR screen memory.
# for why we have to cast things explicitly to np.uint64 - type promotion
# to uint64 is broken in numpy :( 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)
Where A..G are the pixels, and P represents the (unused) palette bit.
This layout makes more sense when written as a (little-endian) 32-bit
integer:
33222222222211111111110000000000 <- bit pos in uint32
10987654321098765432109876543210
PGGGGFFFPFEEEEDDPDDCCCCBPBBBAAAA
i.e. apart from the palette bits this is a linear ordering of pixels,
when read from LSB to MSB (i.e. right-to-left). i.e. the screen layout
order of bits is opposite to the usual binary representation ordering.
We can simplify things by stripping out the palette bit and packing
down to a 28-bit integer representation:
33222222222211111111110000000000 <- bit pos in uint32
10987654321098765432109876543210
GGGGFFFFEEEEDDDDCCCCBBBBAAAA <- pixel A..G
3210321032103210321032103210 <- bit pos in A..G pixel
3333333222222211111110000000 <- byte offset 0.3
Since our colour artifact model (see colours.py) uses a sliding 4-bit window
onto the dot string, we need to also include a 3-bit header and footer
to account for the influence from/on neighbouring bytes, i.e. adjacent
packed values. These are just the low/high 3 bits of the 28-bit body of
those neighbouring columns.
This gives a 34-bit packed representation that is sufficient to compute
the effects (on pixel colours) of storing a byte at one of the 0..3 offsets.
Note that this representation is also 1:1 with the actual displayed
DHGR dots. The mapping to pixel colours follows the DHGRColours
bitmap, see colours.py.
Because the packed representation is contiguous, we need to mask out bit
subsequences of size 3+7+3=13, i.e. the 7-bits corresponding to the
byte being stored, plus the neighbouring 3 bits that influence it/are
influenced by it.
With this masked representation, we can precompute an edit distance for the
pixel changes resulting from all possible DHGR byte stores, see
make_edit_distance.py.
The edit distance matrix is encoded by concatenating the 13-bit source
and target masked values into a 26-bit pair, which indexes into the
edit_distance array to give the corresponding edit distance.
"""
NAME = 'DHGR' NAME = 'DHGR'
# 3-bit header + 28-bit body + 3-bit footer # Packed representation is 3 + 28 + 3 = 34 bits
BYTE_MASKS = [
# 3333333222222211111110000000 <- byte 0.3
#
# 3333222222222211111111110000000000 <- bit pos in uint64
# 3210987654321098765432109876543210
# tttGGGGFFFFEEEEDDDDCCCCBBBBAAAAhhh <- pixel A..G
# 3210321032103210321032103210 <- bit pos in A..G pixel
np.uint64(0b0000000000000000000001111111111111), # byte 0 int13 mask
np.uint64(0b0000000000000011111111111110000000), # byte 1 int13 mask
np.uint64(0b0000000111111111111100000000000000), # byte 2 int13 mask
np.uint64(0b1111111111111000000000000000000000), # byte 3 int13 mask
]
# How much to right-shift bits after masking to bring into int13 range
BYTE_SHIFTS = [np.uint64(0), np.uint64(7), np.uint64(14), np.uint64(21)]
HEADER_BITS = np.uint64(3) HEADER_BITS = np.uint64(3)
BODY_BITS = np.uint64(28) BODY_BITS = np.uint64(28)
FOOTER_BITS = np.uint64(3) FOOTER_BITS = np.uint64(3)
MASKED_BITS = np.uint64(13) # Masked representation selecting the influence of each byte offset
MASKED_BITS = np.uint64(13) # 7-bit body + 3-bit header + 3-bit footer
# Masking is 1:1 with screen dots, but we can't compute the colour of the
# last 3 dots because we fall off the end of the 4-bit sliding window
MASKED_DOTS = np.uint64(10)
# 3-bit header + 28-bit body + 3-bit footer
BYTE_MASKS = [
# 3333222222222211111111110000000000 <- bit pos in uint64
# 3210987654321098765432109876543210
# tttGGGGFFFFEEEEDDDDCCCCBBBBAAAAhhh <- pixel A..G
# 3210321032103210321032103210 <- bit pos in A..G pixel
#
# 3333333222222211111110000000 <- byte offset 0.3
np.uint64(0b0000000000000000000001111111111111), # byte 0 uint13 mask
np.uint64(0b0000000000000011111111111110000000), # byte 1 uint13 mask
np.uint64(0b0000000111111111111100000000000000), # byte 2 uint13 mask
np.uint64(0b1111111111111000000000000000000000), # byte 3 uint13 mask
]
# How much to right-shift bits after masking, to bring into uint13 range
BYTE_SHIFTS = [np.uint64(0), np.uint64(7), np.uint64(14), np.uint64(21)]
# NTSC clock phase at first masked bit # NTSC clock phase at first masked bit
# Each DHGR byte offset has the same range of int13 possible #
# Each DHGR byte offset has the same range of uint13 possible
# values and nominal colour pixels, but with different initial # values and nominal colour pixels, but with different initial
# phases: # phases:
# AUX 0: 0 (1 at start of 3-bit header) # AUX 0: 0 (1 at start of 3-bit header)
@ -733,19 +866,27 @@ class DHGRBitmap(Bitmap):
# MAIN 1: 1 (2) # MAIN 1: 1 (2)
PHASES = [1, 0, 3, 2] PHASES = [1, 0, 3, 2]
@staticmethod
def _make_header(col: IntOrArray) -> IntOrArray:
"""Extract upper 3 bits of body for header of next column."""
return (col & np.uint64(0b111 << 28)) >> np.uint64(28)
def _body(self) -> np.ndarray: def _body(self) -> np.ndarray:
"""Pack related screen bytes into an efficient representation.
For DHGR we first strip off the (unused) palette bit to produce
7-bit values, then interleave aux and main memory columns and pack
these 7-bit values into 28-bits. This sequentially encodes 7 4-bit
DHGR pixels, which is the "repeating unit" of the DHGR screen, and
in a form that is convenient to operate on.
We also shift to make room for the 3-bit header.
"""
# Palette bit is unused for DHGR so mask it out # Palette bit is unused for DHGR so mask it out
aux = (self.aux_memory.page_offset & 0x7f).astype(np.uint64) aux = (self.aux_memory.page_offset & 0x7f).astype(np.uint64)
main = (self.main_memory.page_offset & 0x7f).astype(np.uint64) main = (self.main_memory.page_offset & 0x7f).astype(np.uint64)
# XXX update
# Interleave aux and main memory columns and pack 7-bit masked values
# into a 28-bit value, with 3-bit header and footer. This
# sequentially encodes 7 4-bit DHGR pixels, together with the
# neighbouring 3 bits that are necessary to decode artifact colours.
#
# See make_data_tables.py for more discussion about this representation.
return ( return (
(aux[:, 0::2] << 3) + (aux[:, 0::2] << 3) +
(main[:, 0::2] << 10) + (main[:, 0::2] << 10) +
@ -753,11 +894,6 @@ class DHGRBitmap(Bitmap):
(main[:, 1::2] << 24) (main[:, 1::2] << 24)
) )
@staticmethod
def _make_header(col: IntOrArray) -> IntOrArray:
"""Extract upper 3 bits of body for header of next column."""
return (col & np.uint64(0b111 << 28)) >> np.uint64(28)
@staticmethod @staticmethod
def _make_footer(col: IntOrArray) -> IntOrArray: def _make_footer(col: IntOrArray) -> IntOrArray:
"""Extract lower 3 bits of body for footer of previous column.""" """Extract lower 3 bits of body for footer of previous column."""
@ -765,9 +901,10 @@ class DHGRBitmap(Bitmap):
@staticmethod @staticmethod
@functools.lru_cache(None) @functools.lru_cache(None)
def byte_offset(x_byte: int, is_aux: bool) -> int: def byte_offset(page_offset: int, is_aux: bool) -> int:
"""Returns 0..3 packed byte offset for a given x_byte and is_aux""" """Returns 0..3 packed byte offset for a given page_offset and is_aux"""
is_odd = x_byte % 2 == 1
is_odd = page_offset % 2 == 1
if is_aux: if is_aux:
if is_odd: if is_odd:
return 2 return 2
@ -781,6 +918,8 @@ class DHGRBitmap(Bitmap):
@staticmethod @staticmethod
@functools.lru_cache(None) @functools.lru_cache(None)
def _byte_offsets(is_aux: bool) -> Tuple[int, int]: def _byte_offsets(is_aux: bool) -> Tuple[int, int]:
"""Return byte offsets within packed data for AUX/MAIN memory."""
if is_aux: if is_aux:
offsets = (0, 2) offsets = (0, 2)
else: else:
@ -790,8 +929,11 @@ class DHGRBitmap(Bitmap):
@classmethod @classmethod
def to_dots(cls, masked_val: int, byte_offset: int) -> int: def to_dots(cls, masked_val: int, byte_offset: int) -> int:
# For DHGR the 13-bit masked value is already a 13-bit dot sequence """Convert masked representation to bit sequence of display dots.
# so no need to transform it.
For DHGR the 13-bit masked value is already a 13-bit dot sequence
so no need to transform it.
"""
return masked_val return masked_val
@ -804,7 +946,6 @@ class DHGRBitmap(Bitmap):
Does not patch up headers/footers of neighbouring columns. Does not patch up headers/footers of neighbouring columns.
""" """
# Mask out 7-bit value where update will go # Mask out 7-bit value where update will go
masked_value = old_value & ( masked_value = old_value & (
~np.uint64(0x7f << (7 * byte_offset + 3))) ~np.uint64(0x7f << (7 * byte_offset + 3)))

View File

@ -9,12 +9,18 @@ import colours
from palette import Palette from palette import Palette
def binary(a):
return np.vectorize("{:032b}".format)(a)
class TestDHGRBitmap(unittest.TestCase): class TestDHGRBitmap(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.aux = screen.MemoryMap(screen_page=1) self.aux = screen.MemoryMap(screen_page=1)
self.main = screen.MemoryMap(screen_page=1) self.main = screen.MemoryMap(screen_page=1)
def test_make_header(self): def test_make_header(self):
"""Header extracted correctly from packed representation."""
self.assertEqual( self.assertEqual(
0b100, 0b100,
screen.DHGRBitmap._make_header( screen.DHGRBitmap._make_header(
@ -22,6 +28,8 @@ class TestDHGRBitmap(unittest.TestCase):
) )
def test_make_footer(self): def test_make_footer(self):
"""Footer extracted correctly from packed representation."""
self.assertEqual( self.assertEqual(
0b1010000000000000000000000000000000, 0b1010000000000000000000000000000000,
screen.DHGRBitmap._make_footer( screen.DHGRBitmap._make_footer(
@ -29,6 +37,8 @@ class TestDHGRBitmap(unittest.TestCase):
) )
def test_pixel_packing_offset_0(self): def test_pixel_packing_offset_0(self):
"""Screen byte packing happens correctly at offset 0."""
# PBBBAAAA # PBBBAAAA
self.aux.page_offset[0, 0] = 0b11110101 self.aux.page_offset[0, 0] = 0b11110101
# PDDCCCCB # PDDCCCCB
@ -57,6 +67,8 @@ class TestDHGRBitmap(unittest.TestCase):
self.assertEqual(2, np.count_nonzero(dhgr.packed)) self.assertEqual(2, np.count_nonzero(dhgr.packed))
def test_pixel_packing_offset_1(self): def test_pixel_packing_offset_1(self):
"""Screen byte packing happens correctly at offset 1."""
# PBBBAAAA # PBBBAAAA
self.aux.page_offset[0, 2] = 0b11110101 self.aux.page_offset[0, 2] = 0b11110101
# PDDCCCCB # PDDCCCCB
@ -90,6 +102,8 @@ class TestDHGRBitmap(unittest.TestCase):
self.assertEqual(3, np.count_nonzero(dhgr.packed)) self.assertEqual(3, np.count_nonzero(dhgr.packed))
def test_pixel_packing_offset_127(self): def test_pixel_packing_offset_127(self):
"""Screen byte packing happens correctly at offset 127."""
# PBBBAAAA # PBBBAAAA
self.aux.page_offset[0, 254] = 0b11110101 self.aux.page_offset[0, 254] = 0b11110101
# PDDCCCCB # PDDCCCCB
@ -118,16 +132,22 @@ class TestDHGRBitmap(unittest.TestCase):
self.assertEqual(2, np.count_nonzero(dhgr.packed)) self.assertEqual(2, np.count_nonzero(dhgr.packed))
def test_byte_offset(self): def test_byte_offset(self):
"""Test the byte_offset behaviour."""
self.assertEqual(0, screen.DHGRBitmap.byte_offset(0, is_aux=True)) self.assertEqual(0, screen.DHGRBitmap.byte_offset(0, is_aux=True))
self.assertEqual(1, screen.DHGRBitmap.byte_offset(0, is_aux=False)) self.assertEqual(1, screen.DHGRBitmap.byte_offset(0, is_aux=False))
self.assertEqual(2, screen.DHGRBitmap.byte_offset(1, is_aux=True)) self.assertEqual(2, screen.DHGRBitmap.byte_offset(1, is_aux=True))
self.assertEqual(3, screen.DHGRBitmap.byte_offset(1, is_aux=False)) self.assertEqual(3, screen.DHGRBitmap.byte_offset(1, is_aux=False))
def test_byte_offsets(self): def test_byte_offsets(self):
"""Test the _byte_offsets behaviour."""
self.assertEqual((0, 2), screen.DHGRBitmap._byte_offsets(is_aux=True)) self.assertEqual((0, 2), screen.DHGRBitmap._byte_offsets(is_aux=True))
self.assertEqual((1, 3), screen.DHGRBitmap._byte_offsets(is_aux=False)) self.assertEqual((1, 3), screen.DHGRBitmap._byte_offsets(is_aux=False))
def test_mask_and_shift_data(self): def test_mask_and_shift_data(self):
"""Verify that mask_and_shift_data extracts the right bit positions."""
int13_max = np.uint64(2 ** 13 - 1) int13_max = np.uint64(2 ** 13 - 1)
int34_max = np.uint64(2 ** 34 - 1) int34_max = np.uint64(2 ** 34 - 1)
@ -152,6 +172,8 @@ class TestDHGRBitmap(unittest.TestCase):
) )
def test_masked_update(self): def test_masked_update(self):
"""Verify that masked_update updates the expected bit positions."""
self.assertEqual( self.assertEqual(
0b0000000000000000000000001111111000, 0b0000000000000000000000001111111000,
screen.DHGRBitmap.masked_update( screen.DHGRBitmap.masked_update(
@ -204,6 +226,8 @@ class TestDHGRBitmap(unittest.TestCase):
)) ))
def test_apply(self): def test_apply(self):
"""Test that apply() correctly updates neighbours."""
dhgr = screen.DHGRBitmap( dhgr = screen.DHGRBitmap(
main_memory=self.main, aux_memory=self.aux, palette=Palette.NTSC) main_memory=self.main, aux_memory=self.aux, palette=Palette.NTSC)
@ -292,6 +316,8 @@ class TestDHGRBitmap(unittest.TestCase):
dhgr.packed[12, 17]) dhgr.packed[12, 17])
def test_fix_array_neighbours(self): def test_fix_array_neighbours(self):
"""Test that _fix_array_neighbours DTRT after masked_update."""
dhgr = screen.DHGRBitmap( dhgr = screen.DHGRBitmap(
main_memory=self.main, aux_memory=self.aux, palette=Palette.NTSC) main_memory=self.main, aux_memory=self.aux, palette=Palette.NTSC)
@ -326,15 +352,13 @@ class TestDHGRBitmap(unittest.TestCase):
) )
def binary(a):
return np.vectorize("{:032b}".format)(a)
class TestHGRBitmap(unittest.TestCase): class TestHGRBitmap(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.main = screen.MemoryMap(screen_page=1) self.main = screen.MemoryMap(screen_page=1)
def test_make_header(self): def test_make_header(self):
"""Header extracted correctly from packed representation."""
self.assertEqual( self.assertEqual(
0b111, 0b111,
screen.HGRBitmap._make_header( screen.HGRBitmap._make_header(
@ -349,6 +373,8 @@ class TestHGRBitmap(unittest.TestCase):
) )
def test_make_footer(self): def test_make_footer(self):
"""Footer extracted correctly from packed representation."""
self.assertEqual( self.assertEqual(
0b1110000000000000000000, 0b1110000000000000000000,
screen.HGRBitmap._make_footer( screen.HGRBitmap._make_footer(
@ -363,6 +389,8 @@ class TestHGRBitmap(unittest.TestCase):
) )
def test_pixel_packing_p0_p0(self): def test_pixel_packing_p0_p0(self):
"""Screen byte packing happens correctly with P=0, P=0 palette bits."""
# PDCCBBAA # PDCCBBAA
self.main.page_offset[0, 0] = 0b01000011 self.main.page_offset[0, 0] = 0b01000011
# PGGFFEED # PGGFFEED
@ -378,6 +406,8 @@ class TestHGRBitmap(unittest.TestCase):
) )
def test_pixel_packing_p0_p1(self): def test_pixel_packing_p0_p1(self):
"""Screen byte packing happens correctly with P=0, P=1 palette bits."""
# PDCCBBAA # PDCCBBAA
self.main.page_offset[0, 0] = 0b01000011 self.main.page_offset[0, 0] = 0b01000011
# PGGFFEED # PGGFFEED
@ -393,6 +423,8 @@ class TestHGRBitmap(unittest.TestCase):
) )
def test_pixel_packing_p1_p0(self): def test_pixel_packing_p1_p0(self):
"""Screen byte packing happens correctly with P=1, P=0 palette bits."""
# PDCCBBAA # PDCCBBAA
self.main.page_offset[0, 0] = 0b11000011 self.main.page_offset[0, 0] = 0b11000011
# PGGFFEED # PGGFFEED
@ -408,6 +440,8 @@ class TestHGRBitmap(unittest.TestCase):
) )
def test_pixel_packing_p1_p1(self): def test_pixel_packing_p1_p1(self):
"""Screen byte packing happens correctly with P=1, P=1 palette bits."""
# PDCCBBAA # PDCCBBAA
self.main.page_offset[0, 0] = 0b11000011 self.main.page_offset[0, 0] = 0b11000011
# PGGFFEED # PGGFFEED
@ -422,8 +456,8 @@ class TestHGRBitmap(unittest.TestCase):
want, got, "\n%s\n%s" % (binary(want), binary(got)) want, got, "\n%s\n%s" % (binary(want), binary(got))
) )
def test_masked_update(self): def test_apply(self):
"""Test that header, body and footer are placed correctly."""
hgr = screen.HGRBitmap(main_memory=self.main, palette=Palette.NTSC) hgr = screen.HGRBitmap(main_memory=self.main, palette=Palette.NTSC)
hgr.apply(0, 0, False, 0b11000011) hgr.apply(0, 0, False, 0b11000011)
@ -436,7 +470,25 @@ class TestHGRBitmap(unittest.TestCase):
want, got, "\n%s\n%s" % (binary(want), binary(got)) want, got, "\n%s\n%s" % (binary(want), binary(got))
) )
# Now check with 4 consecutive bytes, i.e. even/odd pair plus the
# neighbouring header/footer.
hgr = screen.HGRBitmap(main_memory=self.main, palette=Palette.NTSC)
hgr.apply(1, 197, False, 128)
hgr.apply(1, 198, False, 143)
hgr.apply(1, 199, False, 192)
hgr.apply(1, 200, False, 128)
want = 0b0011000000110001111100
got = hgr.packed[1, 199 // 2]
self.assertEqual(
want, got, "\n%s\n%s" % (binary(want), binary(got))
)
def test_double_pixels(self): def test_double_pixels(self):
"""Verify behaviour of _double_pixels."""
want = 0b111001100110011 want = 0b111001100110011
got = screen.HGRBitmap._double_pixels(0b1010101) got = screen.HGRBitmap._double_pixels(0b1010101)
@ -445,6 +497,8 @@ class TestHGRBitmap(unittest.TestCase):
) )
def test_to_dots_offset_0(self): def test_to_dots_offset_0(self):
"""Verify to_dots behaviour with byte_offset=0"""
# Header has P=0, Body has P=0 # Header has P=0, Body has P=0
want = 0b00000000000000000111 want = 0b00000000000000000111
got = screen.HGRBitmap.to_dots(0b00000000000011, 0) got = screen.HGRBitmap.to_dots(0b00000000000011, 0)
@ -510,6 +564,8 @@ class TestHGRBitmap(unittest.TestCase):
) )
def test_to_dots_offset_1(self): def test_to_dots_offset_1(self):
"""Verify to_dots behaviour with byte_offset=1"""
# Header has P=0, Body has P=0 # Header has P=0, Body has P=0
want = 0b000000000000000000111 want = 0b000000000000000000111
got = screen.HGRBitmap.to_dots(0b00000000000011, 1) got = screen.HGRBitmap.to_dots(0b00000000000011, 1)
@ -576,6 +632,8 @@ class TestHGRBitmap(unittest.TestCase):
class TestNominalColours(unittest.TestCase): class TestNominalColours(unittest.TestCase):
"""Tests that screen pixel values produce expected colour sequences."""
def setUp(self) -> None: def setUp(self) -> None:
self.main = screen.MemoryMap(screen_page=1) self.main = screen.MemoryMap(screen_page=1)
@ -658,10 +716,12 @@ class TestNominalColours(unittest.TestCase):
init_phase=screen.HGRBitmap.PHASES[1]) init_phase=screen.HGRBitmap.PHASES[1])
) )
# See Figure 8.15 from Sather, "Understanding the Apple IIe" # The following tests check for the extended/truncated behaviour across
# byte boundaries when mismatching palette bits. See Figure 8.15 from
# Sather, "Understanding the Apple IIe"
def test_nominal_colours_sather_even_1(self): def test_nominal_colours_sather_even_1(self):
# Extend violet into light blue """Extend violet into light blue."""
# PDCCBBAA # PDCCBBAA
self.main.page_offset[0, 0] = 0b01000000 self.main.page_offset[0, 0] = 0b01000000
@ -702,7 +762,7 @@ class TestNominalColours(unittest.TestCase):
) )
def test_nominal_colours_sather_even_2(self): def test_nominal_colours_sather_even_2(self):
# Cut off blue with black to produce dark blue """Cut off blue with black to produce dark blue."""
# PDCCBBAA # PDCCBBAA
self.main.page_offset[0, 0] = 0b11000000 self.main.page_offset[0, 0] = 0b11000000
@ -742,7 +802,7 @@ class TestNominalColours(unittest.TestCase):
) )
def test_nominal_colours_sather_even_3(self): def test_nominal_colours_sather_even_3(self):
# Cut off blue with green to produce aqua """Cut off blue with green to produce aqua."""
# PDCCBBAA # PDCCBBAA
self.main.page_offset[0, 0] = 0b11000000 self.main.page_offset[0, 0] = 0b11000000
@ -782,7 +842,7 @@ class TestNominalColours(unittest.TestCase):
) )
def test_nominal_colours_sather_even_4(self): def test_nominal_colours_sather_even_4(self):
# Cut off white with black to produce pink """Cut off white with black to produce pink."""
# PDCCBBAA # PDCCBBAA
self.main.page_offset[0, 0] = 0b11100000 self.main.page_offset[0, 0] = 0b11100000
@ -822,10 +882,10 @@ class TestNominalColours(unittest.TestCase):
) )
def test_nominal_colours_sather_even_5(self): def test_nominal_colours_sather_even_5(self):
# Cut off orange-black with green to produce bright green """Cut off orange-black with green to produce bright green.
# "Bright" here is because the sequence of pixels has high intensity "Bright" here is because the sequence of pixels has high intensity
# Orange-Orange-Yellow-Yellow-Green-Green Orange-Orange-Yellow-Yellow-Green-Green."""
# PDCCBBAA # PDCCBBAA
self.main.page_offset[0, 0] = 0b10100000 self.main.page_offset[0, 0] = 0b10100000
@ -865,7 +925,7 @@ class TestNominalColours(unittest.TestCase):
) )
def test_nominal_colours_sather_odd_1(self): def test_nominal_colours_sather_odd_1(self):
# Extend green into light brown """Extend green into light brown."""
# PDCCBBAA # PDCCBBAA
self.main.page_offset[0, 1] = 0b01000000 self.main.page_offset[0, 1] = 0b01000000
@ -905,7 +965,7 @@ class TestNominalColours(unittest.TestCase):
) )
def test_nominal_colours_sather_odd_2(self): def test_nominal_colours_sather_odd_2(self):
# Cut off orange with black to produce dark brown """Cut off orange with black to produce dark brown."""
# PDCCBBAA # PDCCBBAA
self.main.page_offset[0, 1] = 0b11000000 self.main.page_offset[0, 1] = 0b11000000
@ -945,7 +1005,7 @@ class TestNominalColours(unittest.TestCase):
) )
def test_nominal_colours_sather_odd_3(self): def test_nominal_colours_sather_odd_3(self):
# Cut off orange with violet to produce pink """Cut off orange with violet to produce pink."""
# PDCCBBAA # PDCCBBAA
self.main.page_offset[0, 1] = 0b11000000 self.main.page_offset[0, 1] = 0b11000000
@ -985,7 +1045,7 @@ class TestNominalColours(unittest.TestCase):
) )
def test_nominal_colours_sather_odd_4(self): def test_nominal_colours_sather_odd_4(self):
# Cut off white with black to produce aqua """Cut off white with black to produce aqua."""
# PDCCBBAA # PDCCBBAA
self.main.page_offset[0, 1] = 0b11100000 self.main.page_offset[0, 1] = 0b11100000
@ -1025,10 +1085,11 @@ class TestNominalColours(unittest.TestCase):
) )
def test_nominal_colours_sather_odd_5(self): def test_nominal_colours_sather_odd_5(self):
# Cut off blue-black with violet to produce bright violet """Cut off blue-black with violet to produce bright violet.
# "Bright" here is because the sequence of pixels has high intensity "Bright" here is because the sequence of pixels has high intensity
# Blue-Blue-Light Blue-Light Blue-Violet-Violet Blue-Blue-Light Blue-Light Blue-Violet-Violet.
"""
# PDCCBBAA # PDCCBBAA
self.main.page_offset[0, 1] = 0b10100000 self.main.page_offset[0, 1] = 0b10100000

View File

@ -14,7 +14,7 @@ from video_mode import VideoMode
class Video: class Video:
"""Apple II screen memory map encoding a bitmapped frame.""" """Encodes sequence of images into prioritized screen byte changes."""
CLOCK_SPEED = 1024 * 1024 # type: int CLOCK_SPEED = 1024 * 1024 # type: int
@ -58,6 +58,8 @@ class Video:
self.aux_update_priority = np.zeros((32, 256), dtype=np.int) self.aux_update_priority = np.zeros((32, 256), dtype=np.int)
def tick(self, ticks: int) -> bool: def tick(self, ticks: int) -> bool:
"""Keep track of when it is time for a new image frame."""
if ticks >= (self.ticks_per_frame * self.frame_number): if ticks >= (self.ticks_per_frame * self.frame_number):
self.frame_number += 1 self.frame_number += 1
return True return True
@ -68,7 +70,8 @@ class Video:
target: screen.MemoryMap, target: screen.MemoryMap,
is_aux: bool, is_aux: bool,
) -> Iterator[opcodes.Opcode]: ) -> Iterator[opcodes.Opcode]:
"""Update to match content of frame within provided budget.""" """Converge towards target frame in priority order of edit distance."""
if is_aux: if is_aux:
memory_map = self.aux_memory_map memory_map = self.aux_memory_map
update_priority = self.aux_update_priority update_priority = self.aux_update_priority
@ -114,7 +117,6 @@ class Video:
) )
diff_weights = target_pixelmap.diff_weights(self.pixelmap, is_aux) diff_weights = target_pixelmap.diff_weights(self.pixelmap, is_aux)
# Don't bother storing into screen holes # Don't bother storing into screen holes
diff_weights[screen.SCREEN_HOLES] = 0 diff_weights[screen.SCREEN_HOLES] = 0
@ -123,8 +125,6 @@ class Video:
update_priority[diff_weights == 0] = 0 update_priority[diff_weights == 0] = 0
update_priority += diff_weights update_priority += diff_weights
assert np.count_nonzero(update_priority[screen.SCREEN_HOLES]) == 0
priorities = self._heapify_priorities(update_priority) priorities = self._heapify_priorities(update_priority)
content_deltas = {} content_deltas = {}
@ -172,13 +172,12 @@ class Video:
is_aux is_aux
): ):
assert o != offset assert o != offset
assert not screen.SCREEN_HOLES[page, o], ( assert not screen.SCREEN_HOLES[page, o], (
"Attempted to store into screen hole at (%d, %d)" % ( "Attempted to store into screen hole at (%d, %d)" % (
page, o)) page, o))
if update_priority[page, o] == 0: if update_priority[page, o] == 0:
# print("Skipping page=%d, offset=%d" % (page, o)) # Someone already resolved this diff.
continue continue
# Make sure we don't end up considering this (page, offset) # Make sure we don't end up considering this (page, offset)
@ -195,7 +194,7 @@ class Video:
byte_offset, old_packed, content) byte_offset, old_packed, content)
# Update priority for the offset we're emitting # Update priority for the offset we're emitting
update_priority[page, o] = p # 0 update_priority[page, o] = p
source.page_offset[page, o] = content source.page_offset[page, o] = content
self.pixelmap.apply(page, o, is_aux, content) self.pixelmap.apply(page, o, is_aux, content)
@ -205,7 +204,7 @@ class Video:
# heap in case we can get back to fixing it exactly # heap in case we can get back to fixing it exactly
# during this frame. Otherwise we'll get to it later. # during this frame. Otherwise we'll get to it later.
heapq.heappush( heapq.heappush(
priorities, (-p, random.getrandbits(16), page, o)) priorities, (-p, random.getrandbits(8), page, o))
offsets.append(o) offsets.append(o)
if len(offsets) == 3: if len(offsets) == 3:
@ -216,19 +215,30 @@ class Video:
offsets.append(offsets[0]) offsets.append(offsets[0])
yield (page + 32, content, offsets) yield (page + 32, content, offsets)
# TODO: there is still a bug causing residual diffs when we have # # TODO: there is still a bug causing residual diffs when we have
# apparently run out of work to do # # apparently run out of work to do
if not np.array_equal(source.page_offset, target.page_offset): if not np.array_equal(source.page_offset, target.page_offset):
diffs = np.nonzero(source.page_offset != target.page_offset) diffs = np.nonzero(source.page_offset != target.page_offset)
for i in range(len(diffs[0])): for i in range(len(diffs[0])):
diff_p = diffs[0][i] diff_p = diffs[0][i]
diff_o = diffs[1][i] diff_o = diffs[1][i]
# For HGR, 0x00 or 0x7f may be visually equivalent to the same
# bytes with high bit set (depending on neighbours), so skip
# them
if (source.page_offset[diff_p, diff_o] & 0x7f) == 0 and \
(target.page_offset[diff_p, diff_o] & 0x7f) == 0:
continue
if (source.page_offset[diff_p, diff_o] & 0x7f) == 0x7f and \
(target.page_offset[diff_p, diff_o] & 0x7f) == 0x7f:
continue
print("Diff at (%d, %d): %d != %d" % ( print("Diff at (%d, %d): %d != %d" % (
diff_p, diff_o, source.page_offset[diff_p, diff_o], diff_p, diff_o, source.page_offset[diff_p, diff_o],
target.page_offset[diff_p, diff_o] target.page_offset[diff_p, diff_o]
)) ))
# assert False # assert False
# If we run out of things to do, pad forever # If we run out of things to do, pad forever
content = target.page_offset[0, 0] content = target.page_offset[0, 0]
@ -237,6 +247,10 @@ class Video:
@staticmethod @staticmethod
def _heapify_priorities(update_priority: np.array) -> List: def _heapify_priorities(update_priority: np.array) -> List:
"""Build priority queue of (page, offset) ordered by update priority."""
# Use numpy vectorization to efficiently compute the list of
# (priority, random nonce, page, offset) tuples to be heapified.
pages, offsets = update_priority.nonzero() pages, offsets = update_priority.nonzero()
priorities = [tuple(data) for data in np.stack(( priorities = [tuple(data) for data in np.stack((
-update_priority[pages, offsets], -update_priority[pages, offsets],
@ -251,13 +265,17 @@ class Video:
_OFFSETS = np.arange(256) _OFFSETS = np.arange(256)
def _compute_error(self, page, content, target_pixelmap, old_error, def _compute_error(self, page, content, target_pixelmap, diff_weights,
content_deltas, is_aux): content_deltas, is_aux):
"""Build priority queue of other offsets at which to store content.
Ordered by offsets which are closest to the target content value.
"""
# TODO: move this up into parent # TODO: move this up into parent
delta_screen = content_deltas.get(content) delta_screen = content_deltas.get(content)
if delta_screen is None: if delta_screen is None:
delta_screen = target_pixelmap.compute_delta( delta_screen = target_pixelmap.compute_delta(
content, old_error, is_aux) content, diff_weights, is_aux)
content_deltas[content] = delta_screen content_deltas[content] = delta_screen
delta_page = delta_screen[page] delta_page = delta_screen[page]
@ -266,7 +284,7 @@ class Video:
priorities = delta_page[cond] priorities = delta_page[cond]
deltas = [ deltas = [
(priorities[i], random.getrandbits(16), candidate_offsets[i]) (priorities[i], random.getrandbits(8), candidate_offsets[i])
for i in range(len(candidate_offsets)) for i in range(len(candidate_offsets))
] ]
heapq.heapify(deltas) heapq.heapify(deltas)

View File

@ -4,5 +4,5 @@ import enum
class VideoMode(enum.Enum): class VideoMode(enum.Enum):
HGR = 0 HGR = 0 # Hi-Res
DHGR = 1 DHGR = 1 # Double Hi-Res

View File

@ -67,11 +67,11 @@ class TestVideo(unittest.TestCase):
diff = target_pixelmap.diff_weights(v.pixelmap, is_aux=True) diff = target_pixelmap.diff_weights(v.pixelmap, is_aux=True)
# Expect byte 0 to map to 0b01111111 01101101 XXX # Masked offset 0 changes from 0001111111000 to 0001101101000
expect0 = target_pixelmap.edit_distances(pal.ID)[0][ expect0 = target_pixelmap.edit_distances(pal.ID)[0][
0b00011111110000001101101000] 0b00011111110000001101101000]
# Expect byte 2 to map to 0b000101010100 000011011000 # Masked offset 2 changes from 0001010101000 to 0000110110000
expect2 = target_pixelmap.edit_distances(pal.ID)[2][ expect2 = target_pixelmap.edit_distances(pal.ID)[2][
0b00010101010000000110110000] 0b00010101010000000110110000]