1 #!/usr/bin/env python 2 3 """ 4 Acorn Electron ULA simulation. 5 """ 6 7 import pygame 8 import array 9 import itertools 10 11 WIDTH = 640 12 HEIGHT = 512 13 INTENSITY = 255 14 15 LINES_PER_ROW = 8 16 MAX_HEIGHT = 256 17 SCREEN_LIMIT = 0x8000 18 MAX_MEMORY = 0x10000 19 BLANK = (0, 0, 0) 20 21 def update(screen, ula): 22 23 """ 24 Update the 'screen' array by reading from the 'ula'. 25 """ 26 27 ula.vsync() 28 y = 0 29 while y < HEIGHT: 30 x = 0 31 while x < WIDTH: 32 colour = ula.get_pixel_colour() 33 pixel = tuple(map(lambda x: x * INTENSITY, colour)) 34 screen[x][y] = pixel 35 x += 1 36 ula.hsync() 37 y += 1 38 39 class ULA: 40 41 "The ULA functionality." 42 43 modes = [ 44 (640, 1, 32), (320, 2, 32), (160, 4, 32), # (width, depth, rows) 45 (640, 1, 25), (320, 1, 32), (160, 2, 32), 46 (320, 1, 25) 47 ] 48 49 palette = range(0, 8) * 2 50 51 def __init__(self, memory): 52 53 "Initialise the ULA with the given 'memory'." 54 55 self.memory = memory 56 self.set_mode(6) 57 58 # Internal state. 59 60 self.buffer = [0] * 8 61 62 def set_mode(self, mode): 63 64 """ 65 For the given 'mode', initialise the... 66 67 * width in pixels 68 * colour depth in bits per pixel 69 * number of character rows 70 * character row size in bytes 71 * screen size in bytes 72 * default screen start address 73 * horizontal pixel scaling factor 74 * vertical pixel scaling factor 75 * line spacing in pixels 76 * number of entries in the pixel buffer 77 """ 78 79 self.width, self.depth, rows = self.modes[mode] 80 81 row_size = (self.width * self.depth * LINES_PER_ROW) / 8 # bits per row -> bytes per row 82 83 # Memory access configuration. 84 # Note the limitation on positioning the screen start. 85 86 screen_size = row_size * rows 87 self.screen_start = (SCREEN_LIMIT - screen_size) & 0xff00 88 self.screen_size = SCREEN_LIMIT - self.screen_start 89 90 # Scanline configuration. 91 92 self.xscale = WIDTH / self.width # pixel width in display pixels 93 self.yscale = HEIGHT / (rows * LINES_PER_ROW) # pixel height in display pixels 94 95 self.spacing = MAX_HEIGHT / rows - LINES_PER_ROW # pixels between rows 96 97 # Start of unused region. 98 99 self.footer = rows * LINES_PER_ROW 100 self.margin = MAX_HEIGHT - rows * (LINES_PER_ROW + self.spacing) + self.spacing 101 102 # Internal pixel buffer size. 103 104 self.buffer_limit = 8 / self.depth 105 106 def vsync(self): 107 108 "Signal the start of a frame." 109 110 self.line_start = self.address = self.screen_start 111 self.line = self.line_start % LINES_PER_ROW 112 self.ysub = 0 113 self.ssub = 0 114 self.reset_horizontal() 115 116 def reset_horizontal(self): 117 118 "Reset horizontal state." 119 120 self.xsub = 0 121 self.buffer_index = self.buffer_limit # need refill 122 123 def hsync(self): 124 125 "Signal the end of a line." 126 127 # Support spacing between character rows. 128 129 if self.ssub: 130 self.ssub -= 1 131 return 132 133 self.reset_horizontal() 134 135 # Scale pixels vertically. 136 137 self.ysub += 1 138 139 # Re-read the current line if appropriate. 140 141 if self.ysub < self.yscale: 142 self.address = self.line_start 143 return 144 145 # Otherwise, move on to the next line. 146 147 self.ysub = 0 148 self.line += 1 149 150 # If not on a row boundary, move to the next line. 151 152 if self.line % LINES_PER_ROW: 153 self.address = self.line_start + 1 154 self.wrap_address() 155 156 # After the end of the last line in a row, the address should already 157 # have been positioned on the last line of the next column. 158 159 else: 160 self.address -= LINES_PER_ROW - 1 161 self.wrap_address() 162 163 # Test for the footer region. 164 165 if self.spacing and self.line == self.footer: 166 self.ssub = self.margin * self.yscale 167 return 168 169 # Support spacing between character rows. 170 171 self.ssub = self.spacing * self.yscale 172 173 self.line_start = self.address 174 175 def get_pixel_colour(self): 176 177 """ 178 Return a pixel colour by reading from the pixel buffer. 179 """ 180 181 # Detect spacing between character rows. 182 183 if self.ssub: 184 return BLANK 185 186 # Scale pixels horizontally. 187 188 if self.xsub == self.xscale: 189 self.xsub = 0 190 self.buffer_index += 1 191 192 if self.buffer_index == self.buffer_limit: 193 self.buffer_index = 0 194 self.fill_pixel_buffer() 195 196 self.xsub += 1 197 return self.buffer[self.buffer_index] 198 199 def fill_pixel_buffer(self): 200 201 """ 202 Fill the pixel buffer by translating memory content for the current 203 mode. 204 """ 205 206 byte_value = self.memory[self.address] 207 208 i = 0 209 for colour in decode(byte_value, self.depth): 210 self.buffer[i] = get_physical_colour(self.palette[colour]) 211 i += 1 212 213 # Advance to the next column. 214 215 self.address += LINES_PER_ROW 216 self.wrap_address() 217 218 def wrap_address(self): 219 if self.address >= SCREEN_LIMIT: 220 self.address -= self.screen_size 221 222 def get_physical_colour(value): 223 224 """ 225 Return the physical colour as an RGB triple for the given 'value'. 226 """ 227 228 return value & 1, value >> 1 & 1, value >> 2 & 1 229 230 def decode(value, depth): 231 232 """ 233 Decode the given byte 'value' according to the 'depth' in bits per pixel, 234 returning a sequence of pixel values. 235 """ 236 237 if depth == 1: 238 return (value >> 7, value >> 6 & 1, value >> 5 & 1, value >> 4 & 1, 239 value >> 3 & 1, value >> 2 & 1, value >> 1 & 1, value & 1) 240 elif depth == 2: 241 return (value >> 6 & 2 | value >> 3 & 1, value >> 5 & 2 | value >> 2 & 1, 242 value >> 4 & 2 | value >> 1 & 1, value >> 3 & 2 | value & 1) 243 elif depth == 4: 244 return (value >> 4 & 8 | value >> 3 & 4 | value >> 2 & 2 | value >> 1 & 1, 245 value >> 3 & 8 | value >> 2 & 4 | value >> 1 & 2 | value & 1) 246 else: 247 raise ValueError, "Only depths of 1, 2 and 4 are supported, not %d." % depth 248 249 # Convenience functions. 250 251 def encode(values, depth): 252 253 """ 254 Encode the given 'values' according to the 'depth' in bits per pixel, 255 returning a byte value for the pixels. 256 """ 257 258 result = 0 259 260 if depth == 1: 261 for value in values: 262 result = result << 1 | (value & 1) 263 elif depth == 2: 264 for value in values: 265 result = result << 1 | (value & 2) << 3 | (value & 1) 266 elif depth == 4: 267 for value in values: 268 result = result << 1 | (value & 8) << 3 | (value & 4) << 2 | (value & 2) << 1 | (value & 1) 269 else: 270 raise ValueError, "Only depths of 1, 2 and 4 are supported, not %d." % depth 271 272 return result 273 274 def fill(memory, start, end, value): 275 for i in xrange(start, end): 276 memory[i] = value 277 278 def mainloop(): 279 while 1: 280 pygame.display.flip() 281 event = pygame.event.wait() 282 if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: 283 break 284 285 if __name__ == "__main__": 286 pygame.init() 287 screen = pygame.display.set_mode((WIDTH, HEIGHT), 0) 288 289 memory = array.array("B", itertools.repeat(0, MAX_MEMORY)) 290 a = pygame.surfarray.pixels3d(screen) 291 292 ula = ULA(memory) 293 294 # Test MODE 2. 295 296 ula.set_mode(2) 297 298 fill(memory, 0x3000, 0x5800 - 320, encode((1, 6), 4)) 299 fill(memory, 0x5800 - 320, 0x8000, encode((2, 7), 4)) 300 update(a, ula) 301 mainloop() 302 303 ula.screen_start = 0x3000 + 2 304 update(a, ula) 305 mainloop() 306 307 # Test MODE 6. 308 309 ula.set_mode(6) 310 311 fill(memory, 0x6000, 0x6f00 + 160, encode((1, 0, 1, 1, 0, 0, 1, 1), 1)) 312 fill(memory, 0x6f00 + 160, 0x7f40, encode((1, 0, 1, 0, 1, 0, 1, 0), 1)) 313 update(a, ula) 314 mainloop() 315 316 ula.screen_start = 0x6f00 + 160 317 update(a, ula) 318 mainloop() 319 320 # vim: tabstop=4 expandtab shiftwidth=4