paul@1 | 1 | #!/usr/bin/env python |
paul@1 | 2 | |
paul@1 | 3 | """ |
paul@1 | 4 | Acorn Electron ULA simulation. |
paul@1 | 5 | """ |
paul@1 | 6 | |
paul@29 | 7 | from array import array |
paul@29 | 8 | from itertools import repeat |
paul@29 | 9 | |
paul@22 | 10 | LINES_PER_ROW = 8 # the number of pixel lines per character row |
paul@22 | 11 | MAX_HEIGHT = 256 # the height of the screen in pixels |
paul@22 | 12 | MAX_SCANLINE = 312 # the number of scanlines in each frame |
paul@22 | 13 | MAX_WIDTH = 640 # the width of the screen in pixels |
paul@22 | 14 | MAX_SCANPOS = 1024 # the number of positions in each scanline |
paul@22 | 15 | SCREEN_LIMIT = 0x8000 # the first address after the screen memory |
paul@22 | 16 | MAX_MEMORY = 0x10000 # the number of addressable memory locations |
paul@3 | 17 | BLANK = (0, 0, 0) |
paul@1 | 18 | |
paul@29 | 19 | def update(ula): |
paul@1 | 20 | |
paul@1 | 21 | """ |
paul@31 | 22 | Update the 'ula' for one frame. Return the resulting screen. |
paul@31 | 23 | """ |
paul@31 | 24 | |
paul@31 | 25 | video = ula.video |
paul@31 | 26 | |
paul@31 | 27 | i = 0 |
paul@31 | 28 | limit = MAX_SCANLINE * MAX_SCANPOS |
paul@31 | 29 | while i < limit: |
paul@31 | 30 | ula.update() |
paul@31 | 31 | video.update() |
paul@31 | 32 | i += 1 |
paul@31 | 33 | return video.screen |
paul@31 | 34 | |
paul@31 | 35 | class Video: |
paul@31 | 36 | |
paul@31 | 37 | """ |
paul@31 | 38 | A class representing the video circuitry. |
paul@1 | 39 | """ |
paul@1 | 40 | |
paul@31 | 41 | def __init__(self): |
paul@31 | 42 | self.screen = array("B", repeat(0, MAX_WIDTH * 3 * MAX_HEIGHT)) |
paul@31 | 43 | self.colour = BLANK |
paul@31 | 44 | self.csync = 1 |
paul@31 | 45 | self.hs = 1 |
paul@31 | 46 | self.reset() |
paul@1 | 47 | |
paul@31 | 48 | def reset(self): |
paul@31 | 49 | self.pos = 0 |
paul@31 | 50 | |
paul@31 | 51 | def update(self): |
paul@31 | 52 | if self.csync: |
paul@31 | 53 | if self.hs: |
paul@31 | 54 | self.screen[self.pos] = self.colour[0]; self.pos += 1 |
paul@31 | 55 | self.screen[self.pos] = self.colour[1]; self.pos += 1 |
paul@31 | 56 | self.screen[self.pos] = self.colour[2]; self.pos += 1 |
paul@31 | 57 | else: |
paul@31 | 58 | self.pos = 0 |
paul@29 | 59 | |
paul@2 | 60 | class ULA: |
paul@2 | 61 | |
paul@31 | 62 | """ |
paul@31 | 63 | A class providing the ULA functionality. Instances of this class refer to |
paul@31 | 64 | the system memory, maintain internal state (such as information about the |
paul@31 | 65 | current screen mode), and provide outputs (such as the current pixel |
paul@31 | 66 | colour). |
paul@31 | 67 | """ |
paul@1 | 68 | |
paul@2 | 69 | modes = [ |
paul@2 | 70 | (640, 1, 32), (320, 2, 32), (160, 4, 32), # (width, depth, rows) |
paul@3 | 71 | (640, 1, 25), (320, 1, 32), (160, 2, 32), |
paul@3 | 72 | (320, 1, 25) |
paul@2 | 73 | ] |
paul@2 | 74 | |
paul@2 | 75 | palette = range(0, 8) * 2 |
paul@2 | 76 | |
paul@31 | 77 | def __init__(self, memory, video): |
paul@1 | 78 | |
paul@31 | 79 | "Initialise the ULA with the given 'memory' and 'video'." |
paul@2 | 80 | |
paul@2 | 81 | self.memory = memory |
paul@31 | 82 | self.video = video |
paul@2 | 83 | self.set_mode(6) |
paul@1 | 84 | |
paul@2 | 85 | # Internal state. |
paul@2 | 86 | |
paul@2 | 87 | self.buffer = [0] * 8 |
paul@2 | 88 | |
paul@31 | 89 | self.reset() |
paul@31 | 90 | |
paul@31 | 91 | def reset(self): |
paul@31 | 92 | |
paul@31 | 93 | "Reset the ULA." |
paul@31 | 94 | |
paul@31 | 95 | self.vsync() |
paul@31 | 96 | |
paul@2 | 97 | def set_mode(self, mode): |
paul@1 | 98 | |
paul@2 | 99 | """ |
paul@2 | 100 | For the given 'mode', initialise the... |
paul@1 | 101 | |
paul@2 | 102 | * width in pixels |
paul@2 | 103 | * colour depth in bits per pixel |
paul@2 | 104 | * number of character rows |
paul@2 | 105 | * character row size in bytes |
paul@2 | 106 | * screen size in bytes |
paul@2 | 107 | * default screen start address |
paul@2 | 108 | * horizontal pixel scaling factor |
paul@2 | 109 | * line spacing in pixels |
paul@2 | 110 | * number of entries in the pixel buffer |
paul@31 | 111 | |
paul@31 | 112 | The ULA should be reset after a mode switch in order to cleanly display |
paul@31 | 113 | a full screen. |
paul@2 | 114 | """ |
paul@1 | 115 | |
paul@3 | 116 | self.width, self.depth, rows = self.modes[mode] |
paul@3 | 117 | |
paul@31 | 118 | columns = (self.width * self.depth) / 8 # bits read -> bytes read |
paul@31 | 119 | row_size = columns * LINES_PER_ROW |
paul@2 | 120 | |
paul@3 | 121 | # Memory access configuration. |
paul@4 | 122 | # Note the limitation on positioning the screen start. |
paul@3 | 123 | |
paul@4 | 124 | screen_size = row_size * rows |
paul@4 | 125 | self.screen_start = (SCREEN_LIMIT - screen_size) & 0xff00 |
paul@4 | 126 | self.screen_size = SCREEN_LIMIT - self.screen_start |
paul@3 | 127 | |
paul@3 | 128 | # Scanline configuration. |
paul@1 | 129 | |
paul@22 | 130 | self.xscale = MAX_WIDTH / self.width # pixel width in display pixels |
paul@3 | 131 | self.spacing = MAX_HEIGHT / rows - LINES_PER_ROW # pixels between rows |
paul@3 | 132 | |
paul@3 | 133 | # Start of unused region. |
paul@3 | 134 | |
paul@3 | 135 | self.footer = rows * LINES_PER_ROW |
paul@22 | 136 | self.margin = MAX_SCANLINE - rows * (LINES_PER_ROW + self.spacing) + self.spacing |
paul@3 | 137 | |
paul@3 | 138 | # Internal pixel buffer size. |
paul@3 | 139 | |
paul@2 | 140 | self.buffer_limit = 8 / self.depth |
paul@1 | 141 | |
paul@2 | 142 | def vsync(self): |
paul@2 | 143 | |
paul@2 | 144 | "Signal the start of a frame." |
paul@1 | 145 | |
paul@2 | 146 | self.line_start = self.address = self.screen_start |
paul@5 | 147 | self.line = self.line_start % LINES_PER_ROW |
paul@3 | 148 | self.ssub = 0 |
paul@31 | 149 | self.y = 0 |
paul@2 | 150 | self.reset_horizontal() |
paul@1 | 151 | |
paul@31 | 152 | # Signal the video circuit. |
paul@2 | 153 | |
paul@31 | 154 | self.csync = self.video.csync = 1 |
paul@1 | 155 | |
paul@2 | 156 | def hsync(self): |
paul@2 | 157 | |
paul@31 | 158 | "Signal the end of a scanline." |
paul@31 | 159 | |
paul@31 | 160 | self.y += 1 |
paul@31 | 161 | self.reset_horizontal() |
paul@2 | 162 | |
paul@3 | 163 | # Support spacing between character rows. |
paul@3 | 164 | |
paul@3 | 165 | if self.ssub: |
paul@3 | 166 | self.ssub -= 1 |
paul@3 | 167 | return |
paul@3 | 168 | |
paul@2 | 169 | self.line += 1 |
paul@2 | 170 | |
paul@3 | 171 | # If not on a row boundary, move to the next line. |
paul@3 | 172 | |
paul@3 | 173 | if self.line % LINES_PER_ROW: |
paul@2 | 174 | self.address = self.line_start + 1 |
paul@2 | 175 | self.wrap_address() |
paul@2 | 176 | |
paul@2 | 177 | # After the end of the last line in a row, the address should already |
paul@2 | 178 | # have been positioned on the last line of the next column. |
paul@1 | 179 | |
paul@2 | 180 | else: |
paul@2 | 181 | self.address -= LINES_PER_ROW - 1 |
paul@2 | 182 | self.wrap_address() |
paul@1 | 183 | |
paul@3 | 184 | # Test for the footer region. |
paul@3 | 185 | |
paul@3 | 186 | if self.spacing and self.line == self.footer: |
paul@22 | 187 | self.ssub = self.margin |
paul@3 | 188 | return |
paul@1 | 189 | |
paul@3 | 190 | # Support spacing between character rows. |
paul@2 | 191 | |
paul@22 | 192 | self.ssub = self.spacing |
paul@3 | 193 | |
paul@3 | 194 | self.line_start = self.address |
paul@1 | 195 | |
paul@31 | 196 | def reset_horizontal(self): |
paul@31 | 197 | |
paul@31 | 198 | "Reset horizontal state." |
paul@31 | 199 | |
paul@31 | 200 | self.x = 0 |
paul@31 | 201 | self.buffer_index = self.buffer_limit # need refill |
paul@31 | 202 | |
paul@31 | 203 | # Signal the video circuit. |
paul@31 | 204 | |
paul@31 | 205 | self.hs = self.video.hs = 1 |
paul@31 | 206 | |
paul@31 | 207 | def update(self): |
paul@1 | 208 | |
paul@2 | 209 | """ |
paul@31 | 210 | Update the pixel colour by reading from the pixel buffer. |
paul@2 | 211 | """ |
paul@2 | 212 | |
paul@31 | 213 | # Detect the end of the line. |
paul@31 | 214 | |
paul@31 | 215 | if self.x >= MAX_WIDTH: |
paul@31 | 216 | if self.x == MAX_WIDTH: |
paul@31 | 217 | self.hs = self.video.hs = 0 |
paul@31 | 218 | |
paul@31 | 219 | # Detect the end of the scanline. |
paul@31 | 220 | |
paul@31 | 221 | elif self.x == MAX_SCANPOS: |
paul@31 | 222 | self.hsync() |
paul@31 | 223 | |
paul@31 | 224 | # Detect the end of the frame. |
paul@31 | 225 | |
paul@31 | 226 | if self.y == MAX_SCANLINE: |
paul@31 | 227 | self.vsync() |
paul@31 | 228 | |
paul@31 | 229 | # Detect the end of the screen. |
paul@31 | 230 | |
paul@31 | 231 | elif self.y == MAX_HEIGHT: |
paul@31 | 232 | self.csync = self.video.csync = 0 |
paul@31 | 233 | |
paul@3 | 234 | # Detect spacing between character rows. |
paul@3 | 235 | |
paul@3 | 236 | if self.ssub: |
paul@31 | 237 | self.video.colour = BLANK |
paul@3 | 238 | |
paul@31 | 239 | # Detect horizontal and vertical sync conditions. |
paul@1 | 240 | |
paul@31 | 241 | elif not self.hs or not self.csync: |
paul@31 | 242 | pass |
paul@31 | 243 | |
paul@31 | 244 | # For pixels within the frame, obtain and output the value. |
paul@31 | 245 | |
paul@31 | 246 | else: |
paul@1 | 247 | |
paul@31 | 248 | # Scale pixels horizontally, only accessing the next pixel value |
paul@31 | 249 | # after the required number of scan positions. |
paul@22 | 250 | |
paul@31 | 251 | if self.x % self.xscale == 0: |
paul@31 | 252 | self.buffer_index += 1 |
paul@31 | 253 | |
paul@31 | 254 | # Fill the buffer once all values have been read. |
paul@22 | 255 | |
paul@31 | 256 | if self.buffer_index >= self.buffer_limit: |
paul@31 | 257 | self.buffer_index = 0 |
paul@31 | 258 | self.fill_pixel_buffer() |
paul@22 | 259 | |
paul@31 | 260 | self.video.colour = self.buffer[self.buffer_index] |
paul@2 | 261 | |
paul@31 | 262 | self.x += 1 |
paul@2 | 263 | |
paul@2 | 264 | def fill_pixel_buffer(self): |
paul@1 | 265 | |
paul@2 | 266 | """ |
paul@2 | 267 | Fill the pixel buffer by translating memory content for the current |
paul@2 | 268 | mode. |
paul@2 | 269 | """ |
paul@1 | 270 | |
paul@2 | 271 | byte_value = self.memory[self.address] |
paul@1 | 272 | |
paul@2 | 273 | i = 0 |
paul@2 | 274 | for colour in decode(byte_value, self.depth): |
paul@2 | 275 | self.buffer[i] = get_physical_colour(self.palette[colour]) |
paul@2 | 276 | i += 1 |
paul@2 | 277 | |
paul@2 | 278 | # Advance to the next column. |
paul@1 | 279 | |
paul@2 | 280 | self.address += LINES_PER_ROW |
paul@2 | 281 | self.wrap_address() |
paul@2 | 282 | |
paul@2 | 283 | def wrap_address(self): |
paul@2 | 284 | if self.address >= SCREEN_LIMIT: |
paul@2 | 285 | self.address -= self.screen_size |
paul@1 | 286 | |
paul@11 | 287 | # Convenience methods. |
paul@11 | 288 | |
paul@11 | 289 | def fill(self, start, end, value): |
paul@11 | 290 | fill(self.memory, start, end, value) |
paul@11 | 291 | |
paul@1 | 292 | def get_physical_colour(value): |
paul@1 | 293 | |
paul@1 | 294 | """ |
paul@1 | 295 | Return the physical colour as an RGB triple for the given 'value'. |
paul@1 | 296 | """ |
paul@1 | 297 | |
paul@1 | 298 | return value & 1, value >> 1 & 1, value >> 2 & 1 |
paul@1 | 299 | |
paul@1 | 300 | def decode(value, depth): |
paul@1 | 301 | |
paul@1 | 302 | """ |
paul@1 | 303 | Decode the given byte 'value' according to the 'depth' in bits per pixel, |
paul@1 | 304 | returning a sequence of pixel values. |
paul@1 | 305 | """ |
paul@1 | 306 | |
paul@1 | 307 | if depth == 1: |
paul@1 | 308 | return (value >> 7, value >> 6 & 1, value >> 5 & 1, value >> 4 & 1, |
paul@1 | 309 | value >> 3 & 1, value >> 2 & 1, value >> 1 & 1, value & 1) |
paul@1 | 310 | elif depth == 2: |
paul@1 | 311 | return (value >> 6 & 2 | value >> 3 & 1, value >> 5 & 2 | value >> 2 & 1, |
paul@1 | 312 | value >> 4 & 2 | value >> 1 & 1, value >> 3 & 2 | value & 1) |
paul@1 | 313 | elif depth == 4: |
paul@1 | 314 | return (value >> 4 & 8 | value >> 3 & 4 | value >> 2 & 2 | value >> 1 & 1, |
paul@1 | 315 | value >> 3 & 8 | value >> 2 & 4 | value >> 1 & 2 | value & 1) |
paul@1 | 316 | else: |
paul@1 | 317 | raise ValueError, "Only depths of 1, 2 and 4 are supported, not %d." % depth |
paul@1 | 318 | |
paul@1 | 319 | # Convenience functions. |
paul@1 | 320 | |
paul@1 | 321 | def encode(values, depth): |
paul@1 | 322 | |
paul@1 | 323 | """ |
paul@1 | 324 | Encode the given 'values' according to the 'depth' in bits per pixel, |
paul@1 | 325 | returning a byte value for the pixels. |
paul@1 | 326 | """ |
paul@1 | 327 | |
paul@1 | 328 | result = 0 |
paul@1 | 329 | |
paul@1 | 330 | if depth == 1: |
paul@1 | 331 | for value in values: |
paul@1 | 332 | result = result << 1 | (value & 1) |
paul@1 | 333 | elif depth == 2: |
paul@1 | 334 | for value in values: |
paul@1 | 335 | result = result << 1 | (value & 2) << 3 | (value & 1) |
paul@1 | 336 | elif depth == 4: |
paul@1 | 337 | for value in values: |
paul@1 | 338 | result = result << 1 | (value & 8) << 3 | (value & 4) << 2 | (value & 2) << 1 | (value & 1) |
paul@1 | 339 | else: |
paul@1 | 340 | raise ValueError, "Only depths of 1, 2 and 4 are supported, not %d." % depth |
paul@1 | 341 | |
paul@1 | 342 | return result |
paul@1 | 343 | |
paul@11 | 344 | def get_ula(): |
paul@11 | 345 | |
paul@31 | 346 | "Return a ULA initialised with a memory array and video." |
paul@31 | 347 | |
paul@31 | 348 | return ULA(get_memory(), get_video()) |
paul@11 | 349 | |
paul@31 | 350 | def get_video(): |
paul@31 | 351 | |
paul@31 | 352 | "Return a video circuit." |
paul@31 | 353 | |
paul@31 | 354 | return Video() |
paul@11 | 355 | |
paul@7 | 356 | def get_memory(): |
paul@10 | 357 | |
paul@10 | 358 | "Return an array representing the computer's memory." |
paul@10 | 359 | |
paul@20 | 360 | return [0] * MAX_MEMORY |
paul@7 | 361 | |
paul@1 | 362 | def fill(memory, start, end, value): |
paul@1 | 363 | for i in xrange(start, end): |
paul@1 | 364 | memory[i] = value |
paul@1 | 365 | |
paul@7 | 366 | # Test program providing coverage (necessary for compilers like Shedskin). |
paul@7 | 367 | |
paul@7 | 368 | if __name__ == "__main__": |
paul@11 | 369 | ula = get_ula() |
paul@7 | 370 | ula.set_mode(2) |
paul@11 | 371 | ula.fill(0x5800 - 320, 0x8000, encode((2, 7), 4)) |
paul@7 | 372 | |
paul@7 | 373 | # Make a simple two-dimensional array of tuples (three-dimensional in pygame |
paul@7 | 374 | # terminology). |
paul@7 | 375 | |
paul@29 | 376 | a = update(ula) |
paul@7 | 377 | |
paul@1 | 378 | # vim: tabstop=4 expandtab shiftwidth=4 |