orion128 0.1.0a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ivan Kosarev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: orion128
3
+ Version: 0.1.0a1
4
+ Summary: Orion-128 home computer emulator
5
+ Author-email: Ivan Kosarev <mail@ivankosarev.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/kosarev/orion128/
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: X11 Applications
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: End Users/Desktop
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: Implementation :: CPython
19
+ Classifier: Topic :: Games/Entertainment
20
+ Classifier: Topic :: System :: Emulators
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: z80>=1.2
26
+ Requires-Dist: pysdl2
27
+ Requires-Dist: numpy
28
+ Dynamic: license-file
29
+
30
+ # orion128
31
+
32
+ An emulator of the Orion-128, a home computer built around the KR580VM80A
33
+ processor (an i8080 clone).
34
+
35
+ [![Python package CI](https://github.com/kosarev/orion128/actions/workflows/python-package.yml/badge.svg)](https://github.com/kosarev/orion128/actions/workflows/python-package.yml)
36
+ [![PyPI](https://img.shields.io/pypi/v/orion128)](https://pypi.org/project/orion128/)
37
+ [![Python](https://img.shields.io/pypi/pyversions/orion128)](https://pypi.org/project/orion128/)
38
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/kosarev/orion128/blob/main/LICENSE)
39
+
40
+ orion128 emulates the hardware — memory-mapped video, the keyboard, ports and
41
+ the disk controller — and runs the real ROM and software on top of it. It is
42
+ built on the [z80](https://github.com/kosarev/z80) package for the i8080 core
43
+ and aims to stay in pure Python. The display is built on PySDL2 and numpy.
44
+
45
+ It ships with the MS7007 Monitor and an ORDOS 4.03 ROM-disk, so it boots a
46
+ working system out of the box:
47
+
48
+ ```shell
49
+ orion128
50
+ ```
51
+
52
+ `--monitor PATH` and `--romdisk PATH` replace the bundled ROMs. Any further
53
+ arguments are ORDOS files (`.ORD` or `.BRU`) preloaded onto the RAM-disk
54
+ (drive B:):
55
+
56
+ ```shell
57
+ orion128 PENX4$.ORD
58
+ ```
59
+
60
+ <kbd>F12</kbd> is the RESET button. <kbd>F10</kbd> closes the emulator.
61
+
62
+ The bundled ROMs are the copyright of their original authors and are not
63
+ covered by this package's MIT licence; see `orion128/ROMS.txt`.
64
+
65
+ This is early work in progress.
@@ -0,0 +1,36 @@
1
+ # orion128
2
+
3
+ An emulator of the Orion-128, a home computer built around the KR580VM80A
4
+ processor (an i8080 clone).
5
+
6
+ [![Python package CI](https://github.com/kosarev/orion128/actions/workflows/python-package.yml/badge.svg)](https://github.com/kosarev/orion128/actions/workflows/python-package.yml)
7
+ [![PyPI](https://img.shields.io/pypi/v/orion128)](https://pypi.org/project/orion128/)
8
+ [![Python](https://img.shields.io/pypi/pyversions/orion128)](https://pypi.org/project/orion128/)
9
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/kosarev/orion128/blob/main/LICENSE)
10
+
11
+ orion128 emulates the hardware — memory-mapped video, the keyboard, ports and
12
+ the disk controller — and runs the real ROM and software on top of it. It is
13
+ built on the [z80](https://github.com/kosarev/z80) package for the i8080 core
14
+ and aims to stay in pure Python. The display is built on PySDL2 and numpy.
15
+
16
+ It ships with the MS7007 Monitor and an ORDOS 4.03 ROM-disk, so it boots a
17
+ working system out of the box:
18
+
19
+ ```shell
20
+ orion128
21
+ ```
22
+
23
+ `--monitor PATH` and `--romdisk PATH` replace the bundled ROMs. Any further
24
+ arguments are ORDOS files (`.ORD` or `.BRU`) preloaded onto the RAM-disk
25
+ (drive B:):
26
+
27
+ ```shell
28
+ orion128 PENX4$.ORD
29
+ ```
30
+
31
+ <kbd>F12</kbd> is the RESET button. <kbd>F10</kbd> closes the emulator.
32
+
33
+ The bundled ROMs are the copyright of their original authors and are not
34
+ covered by this package's MIT licence; see `orion128/ROMS.txt`.
35
+
36
+ This is early work in progress.
@@ -0,0 +1,7 @@
1
+ The bundled ROM images, ms7007.rom and disk.rom, are NOT covered by the
2
+ MIT licence that applies to the rest of orion128.
3
+
4
+ They contain the Orion-128 Monitor, the ORDOS operating system, the VC
5
+ commander and a character generator. These remain the copyright of their
6
+ original authors and are included only so that the emulator boots a
7
+ working system out of the box.
@@ -0,0 +1,41 @@
1
+ # Orion-128 home computer emulator.
2
+ # https://github.com/kosarev/orion128
3
+ #
4
+ # Copyright (C) 2026 Ivan Kosarev.
5
+ # mail@ivankosarev.com
6
+ #
7
+ # Published under the MIT license.
8
+
9
+ from ._machine import (
10
+ CPU_FREQUENCY,
11
+ IO_BASE,
12
+ IO_SIZE,
13
+ KEYBOARD_SCAN,
14
+ KEYBOARD_SENSE,
15
+ MONITOR_BASE,
16
+ MONITOR_SIZE,
17
+ ROMDISK_PORT,
18
+ SCREEN_HEIGHT,
19
+ SCREEN_WIDTH,
20
+ VIDEO_BASE,
21
+ VIDEO_SIZE,
22
+ Orion128Machine,
23
+ )
24
+ from ._main import main
25
+
26
+ __all__ = [
27
+ 'CPU_FREQUENCY',
28
+ 'IO_BASE',
29
+ 'IO_SIZE',
30
+ 'KEYBOARD_SCAN',
31
+ 'KEYBOARD_SENSE',
32
+ 'MONITOR_BASE',
33
+ 'MONITOR_SIZE',
34
+ 'ROMDISK_PORT',
35
+ 'SCREEN_HEIGHT',
36
+ 'SCREEN_WIDTH',
37
+ 'VIDEO_BASE',
38
+ 'VIDEO_SIZE',
39
+ 'Orion128Machine',
40
+ 'main',
41
+ ]
@@ -0,0 +1,137 @@
1
+ # Orion-128 home computer emulator.
2
+ # https://github.com/kosarev/orion128
3
+ #
4
+ # Copyright (C) 2026 Ivan Kosarev.
5
+ # mail@ivankosarev.com
6
+ #
7
+ # Published under the MIT license.
8
+
9
+ import ctypes
10
+ import os
11
+
12
+ import numpy as np
13
+ import numpy.typing as npt
14
+ import sdl2
15
+
16
+ from ._machine import SCREEN_HEIGHT, SCREEN_WIDTH
17
+
18
+ # The MS7007 keyboard: host key to (column, row) matrix crossing. The
19
+ # columns are the port A scan lines, the rows the port B sense lines (0-7)
20
+ # and the top of port C (8-11). The crossings were read off the Monitor
21
+ # itself, by pressing each one and seeing which character it decodes, so
22
+ # the host letter and digit keys reach the Orion keys of the same name.
23
+ _KEY_MATRIX = {
24
+ sdl2.SDLK_a: (4, 4), sdl2.SDLK_b: (4, 0), sdl2.SDLK_c: (3, 2),
25
+ sdl2.SDLK_d: (3, 1), sdl2.SDLK_e: (2, 7), sdl2.SDLK_f: (3, 11),
26
+ sdl2.SDLK_g: (3, 6), sdl2.SDLK_h: (7, 0), sdl2.SDLK_i: (4, 7),
27
+ sdl2.SDLK_j: (2, 11), sdl2.SDLK_k: (3, 4), sdl2.SDLK_l: (3, 0),
28
+ sdl2.SDLK_m: (5, 4), sdl2.SDLK_n: (3, 5), sdl2.SDLK_o: (4, 6),
29
+ sdl2.SDLK_p: (3, 7), sdl2.SDLK_q: (4, 11), sdl2.SDLK_r: (4, 5),
30
+ sdl2.SDLK_s: (5, 3), sdl2.SDLK_t: (5, 5), sdl2.SDLK_u: (3, 3),
31
+ sdl2.SDLK_v: (6, 1), sdl2.SDLK_w: (4, 3), sdl2.SDLK_x: (5, 6),
32
+ sdl2.SDLK_y: (4, 2), sdl2.SDLK_z: (7, 1),
33
+ sdl2.SDLK_0: (6, 10), sdl2.SDLK_1: (6, 11), sdl2.SDLK_2: (7, 11),
34
+ sdl2.SDLK_3: (0, 11), sdl2.SDLK_4: (6, 2), sdl2.SDLK_5: (7, 2),
35
+ sdl2.SDLK_6: (0, 2), sdl2.SDLK_7: (6, 9), sdl2.SDLK_8: (7, 9),
36
+ sdl2.SDLK_9: (0, 9),
37
+ sdl2.SDLK_PERIOD: (7, 10), sdl2.SDLK_COMMA: (1, 9),
38
+ sdl2.SDLK_SEMICOLON: (1, 11), sdl2.SDLK_SLASH: (0, 5),
39
+ sdl2.SDLK_MINUS: (2, 9), sdl2.SDLK_EQUALS: (0, 6),
40
+ sdl2.SDLK_QUOTE: (1, 0), sdl2.SDLK_LEFTBRACKET: (2, 0),
41
+ sdl2.SDLK_RIGHTBRACKET: (2, 1), sdl2.SDLK_BACKSLASH: (6, 0),
42
+ sdl2.SDLK_SPACE: (0, 0), sdl2.SDLK_RETURN: (0, 10),
43
+ sdl2.SDLK_LEFT: (5, 0), sdl2.SDLK_RIGHT: (6, 7),
44
+ sdl2.SDLK_UP: (7, 5), sdl2.SDLK_DOWN: (6, 5),
45
+ sdl2.SDLK_BACKSPACE: (6, 4), sdl2.SDLK_ESCAPE: (1, 10),
46
+ # Shift gives the Orion's shifted characters (and lowercase letters).
47
+ sdl2.SDLK_LSHIFT: (5, 9), sdl2.SDLK_RSHIFT: (5, 9),
48
+ }
49
+
50
+ # The RESET key is a hardware button, not a matrix key, so it has its own
51
+ # host key.
52
+ _RESET_KEY = sdl2.SDLK_F12
53
+
54
+ # F10 closes the emulator, as an alternative to closing the window.
55
+ _QUIT_KEY = sdl2.SDLK_F10
56
+
57
+
58
+ class Display:
59
+ '''A window showing the Orion screen, built on SDL.
60
+
61
+ It takes the pixel bitmap from the machine and shows it in black and
62
+ white, scaled up so the small screen is comfortable to look at.
63
+ '''
64
+
65
+ def __init__(self, scale: int = 2, title: str = 'Orion-128') -> None:
66
+ # On Wayland, use the Wayland driver so the window follows the
67
+ # system display scaling instead of being upscaled by XWayland.
68
+ on_wayland = 'WAYLAND_DISPLAY' in os.environ
69
+ if on_wayland and 'SDL_VIDEODRIVER' not in os.environ:
70
+ os.environ['SDL_VIDEODRIVER'] = 'wayland'
71
+
72
+ sdl2.SDL_Init(sdl2.SDL_INIT_VIDEO)
73
+
74
+ # SDL_WINDOW_ALLOW_HIGHDPI makes the drawable the full device-pixel
75
+ # size on a scaled display, so the window is the intended physical
76
+ # size and the screen is drawn crisply rather than being upscaled.
77
+ self.__window = sdl2.SDL_CreateWindow(
78
+ title.encode(),
79
+ sdl2.SDL_WINDOWPOS_CENTERED, sdl2.SDL_WINDOWPOS_CENTERED,
80
+ SCREEN_WIDTH * scale, SCREEN_HEIGHT * scale,
81
+ sdl2.SDL_WINDOW_SHOWN | sdl2.SDL_WINDOW_ALLOW_HIGHDPI)
82
+
83
+ self.__renderer = sdl2.SDL_CreateRenderer(self.__window, -1, 0)
84
+
85
+ # A streaming texture the size of the screen; SDL scales it up to
86
+ # fill the window when it is copied.
87
+ self.__texture = sdl2.SDL_CreateTexture(
88
+ self.__renderer, sdl2.SDL_PIXELFORMAT_RGB24,
89
+ sdl2.SDL_TEXTUREACCESS_STREAMING, SCREEN_WIDTH, SCREEN_HEIGHT)
90
+
91
+ self.__quit = False
92
+ self.__reset_pressed = False
93
+ self.__keys_down: set[int] = set()
94
+
95
+ def update(self, frame: npt.NDArray[np.uint8]) -> None:
96
+ '''Show the given SCREEN_HEIGHT by SCREEN_WIDTH RGB frame.'''
97
+ frame = np.ascontiguousarray(frame, dtype=np.uint8)
98
+ sdl2.SDL_UpdateTexture(
99
+ self.__texture, None, frame.ctypes.data, SCREEN_WIDTH * 3)
100
+ sdl2.SDL_RenderClear(self.__renderer)
101
+ sdl2.SDL_RenderCopy(self.__renderer, self.__texture, None, None)
102
+ sdl2.SDL_RenderPresent(self.__renderer)
103
+
104
+ def closed(self) -> bool:
105
+ '''Process window events, then report whether close was requested.'''
106
+ event = sdl2.SDL_Event()
107
+ while sdl2.SDL_PollEvent(ctypes.byref(event)) != 0:
108
+ if event.type == sdl2.SDL_QUIT:
109
+ self.__quit = True
110
+ elif event.type == sdl2.SDL_KEYDOWN:
111
+ key = int(event.key.keysym.sym)
112
+ if key == _QUIT_KEY:
113
+ self.__quit = True
114
+ elif key == _RESET_KEY:
115
+ self.__reset_pressed = True
116
+ else:
117
+ self.__keys_down.add(key)
118
+ elif event.type == sdl2.SDL_KEYUP:
119
+ self.__keys_down.discard(int(event.key.keysym.sym))
120
+ return self.__quit
121
+
122
+ def pressed_keys(self) -> set[tuple[int, int]]:
123
+ '''The pressed keys as Orion (column, row) matrix crossings.'''
124
+ return {_KEY_MATRIX[key]
125
+ for key in self.__keys_down if key in _KEY_MATRIX}
126
+
127
+ def take_reset(self) -> bool:
128
+ '''Report whether the RESET key was pressed, and clear it.'''
129
+ pressed = self.__reset_pressed
130
+ self.__reset_pressed = False
131
+ return pressed
132
+
133
+ def close(self) -> None:
134
+ sdl2.SDL_DestroyTexture(self.__texture)
135
+ sdl2.SDL_DestroyRenderer(self.__renderer)
136
+ sdl2.SDL_DestroyWindow(self.__window)
137
+ sdl2.SDL_Quit()
@@ -0,0 +1,307 @@
1
+ # Orion-128 home computer emulator.
2
+ # https://github.com/kosarev/orion128
3
+ #
4
+ # Copyright (C) 2026 Ivan Kosarev.
5
+ # mail@ivankosarev.com
6
+ #
7
+ # Published under the MIT license.
8
+
9
+ import numpy as np
10
+ import numpy.typing as npt
11
+ import z80
12
+
13
+ # The Orion-128 screen is a bitmap of this size.
14
+ SCREEN_WIDTH = 384
15
+ SCREEN_HEIGHT = 256
16
+
17
+ # The screen is a bitmap in the processor's address space. Each byte holds
18
+ # eight horizontal pixels, the most significant bit leftmost. The bytes run
19
+ # down a column of the screen before stepping to the next column to the
20
+ # right, so the byte for pixel (x, y) is at
21
+ # VIDEO_BASE + (x // 8) * SCREEN_HEIGHT + y.
22
+ # The base address and this layout are provisional until confirmed against
23
+ # the ROM and the disk images.
24
+ VIDEO_BASE = 0xc000
25
+ VIDEO_SIZE = (SCREEN_WIDTH // 8) * SCREEN_HEIGHT
26
+
27
+ # The 2K Monitor ROM sits at the top of memory. The i8080 resets to
28
+ # address 0, but on the Orion the reset makes the Monitor run first, and
29
+ # its first byte is a jump into itself. So starting the processor at
30
+ # MONITOR_BASE reproduces the boot without emulating the reset ROM overlay.
31
+ MONITOR_BASE = 0xf800
32
+ MONITOR_SIZE = 0x800
33
+
34
+ # The processor runs at about 2.5 MHz.
35
+ CPU_FREQUENCY = 2_500_000
36
+
37
+ # The Orion reaches its hardware through memory, not through i8080 ports.
38
+ # The 8255 chips (keyboard, ROM-disk, user ports) occupy F400-F7FF, and the
39
+ # system latches (colour, RAM page, screen page) share F800-FFFF with the
40
+ # Monitor ROM: reads there return the ROM, writes set the latches.
41
+ IO_BASE = 0xf400
42
+ IO_SIZE = 0x400
43
+
44
+ # The ROM-disk 8255 lives at F500: F500 reads data, F501 sets the address
45
+ # low byte, F502 the high byte. Writing the address makes the addressed
46
+ # byte appear at the data port for the Monitor to read.
47
+ ROMDISK_PORT = 0xf500
48
+
49
+ # The keyboard 8255 at F400: port A (F400) drives the scan columns active
50
+ # low, and the sense rows are read active low from port B (F401) for rows
51
+ # 0-7 and the top half of port C (F402) for rows 8-11. A pressed key is a
52
+ # (column, row) crossing that pulls its row low when its column is scanned.
53
+ KEYBOARD_SCAN = 0xf400
54
+ KEYBOARD_SENSE = 0xf401
55
+ KEYBOARD_SENSE_C = 0xf402
56
+
57
+ # Memory paging. The processor's page for the 0x0000-0xEFFF region is
58
+ # selected through port F9 (or the F900 latch). The selected page is
59
+ # exposed in the core's memory, so reads and writes run at full speed; the
60
+ # other pages are held aside as images. Switching pages saves the exposed
61
+ # page back to its image and copies the newly selected image into the
62
+ # address space. F000-FFFF is common to all pages and never swapped.
63
+ PAGE_SELECT_PORT = 0xf9
64
+ PAGE_SELECT = 0xf900
65
+ PAGED_SIZE = 0xf000
66
+
67
+ # Pages 1, 2 and 3 are ORDOS's read/write RAM-disks, drives B:, C: and D:.
68
+ # A preloaded RAM-disk goes to page 1, drive B:.
69
+ RAM_DISK_PAGE = 1
70
+
71
+ # Colour control at F800 (or port F8). Bit D2 turns colour on; without it
72
+ # the screen is monochrome. In 16-colour mode a colour byte at the same
73
+ # address in page 1 holds the background colour in its top nibble and the
74
+ # foreground in its bottom nibble, and the pixel bit chooses between them.
75
+ COLOUR_CONTROL = 0xf800
76
+ COLOUR_CONTROL_PORT = 0xf8
77
+ COLOUR_ON = 0x04
78
+
79
+ # Monochrome is the Orion's green on black.
80
+ _GREEN = (0x30, 0xff, 0x30)
81
+
82
+
83
+ # The 16 colours, one bit each for intensity, red, green and blue.
84
+ def _build_palette() -> npt.NDArray[np.uint8]:
85
+ palette = np.zeros((16, 3), dtype=np.uint8)
86
+ for code in range(16):
87
+ level = 0xff if code & 0x08 else 0xaa
88
+ palette[code] = (level if code & 0x04 else 0,
89
+ level if code & 0x02 else 0,
90
+ level if code & 0x01 else 0)
91
+ return palette
92
+
93
+
94
+ _PALETTE = _build_palette()
95
+
96
+
97
+ def _to_record(data: bytes) -> bytes:
98
+ '''Normalise a stored ORDOS file to an on-disk record.
99
+
100
+ An on-disk record holds the data length in its size field and no
101
+ padding. A .ORD file puts the whole file length there (16 more than the
102
+ data); a .BRU file puts the data length but pads the data to a block
103
+ boundary. Tell them apart by whether the size field plus the 16-byte
104
+ header still fits inside the file.
105
+ '''
106
+ size = data[10] | (data[11] << 8)
107
+ data_size = size if 16 + size <= len(data) else len(data) - 16
108
+ record = bytearray(data[:16 + data_size])
109
+ record[10:12] = data_size.to_bytes(2, 'little')
110
+ return bytes(record)
111
+
112
+
113
+ class Orion128Machine(z80.I8080Machine):
114
+ '''The Orion-128 machine: the i8080 core with the Orion's memory.
115
+
116
+ The processor reads memory straight from the core, which is fast. Only
117
+ writes need watching, so a single write handler forms the whole bus: it
118
+ protects the Monitor ROM, captures the system latches, and leaves the
119
+ 8255 region idle. Banking, the keyboard and the disk controller are
120
+ still to come.
121
+ '''
122
+
123
+ def __init__(self, monitor: bytes | None = None) -> None:
124
+ super().__init__()
125
+ self.__system_ports: dict[int, int] = {}
126
+ self.__romdisk = b''
127
+ self.__romdisk_addr = 0
128
+ self.__scan = 0xff
129
+ self.__keys: set[tuple[int, int]] = set()
130
+ self.__page_images = {page: bytearray(PAGED_SIZE) for page in range(4)}
131
+ self.__current_page = 0
132
+ self.__colour_control = 0
133
+ if monitor is not None:
134
+ self.load_monitor(monitor)
135
+
136
+ def load_monitor(self, monitor: bytes) -> None:
137
+ '''Load the 2K Monitor ROM, set up the bus and start at the entry.'''
138
+ assert len(monitor) == MONITOR_SIZE
139
+ self.set_memory_block(MONITOR_BASE, monitor)
140
+
141
+ # Idle I/O reads as no key pressed and no ROM-disk present.
142
+ self.set_memory_block(IO_BASE, b'\xff' * IO_SIZE)
143
+
144
+ self.pc = MONITOR_BASE
145
+
146
+ # Watch only the I/O and ROM region for writes. Every other write
147
+ # goes straight to memory in the core, which keeps the processor
148
+ # fast even while a program repaints the screen.
149
+ self.set_write_callback(self.__on_write)
150
+ self.mark_addrs(IO_BASE, 0x10000 - IO_BASE, self.WRITE_MARK)
151
+
152
+ # Port F9 selects the memory page.
153
+ self.set_output_callback(self.__on_output)
154
+
155
+ def reset(self) -> None:
156
+ '''Reset the processor to the Monitor, as the RESET key does.
157
+
158
+ RAM, including the RAM-disks, is kept, so the Monitor re-boots ORDOS
159
+ with everything still in place. The Monitor expects page 0 and a
160
+ cleared colour mode.
161
+ '''
162
+ self.__select_page(0)
163
+ self.__colour_control = 0
164
+ self.pc = MONITOR_BASE
165
+
166
+ def load_romdisk(self, romdisk: bytes) -> None:
167
+ '''Attach a ROM-disk image, served through the F500 8255.'''
168
+ self.__romdisk = romdisk
169
+ self.__romdisk_addr = 0
170
+ self.set_memory_block(ROMDISK_PORT, bytes([romdisk[0]]))
171
+
172
+ def load_ram_disk(self, files: list[bytes]) -> None:
173
+ '''Fill the RAM-disk (drive B:, page 1) with ORDOS file records.
174
+
175
+ Each ORDOS file is a record: an 8-byte name, a 2-byte load address,
176
+ a 2-byte size, four more header bytes, then the data. The records
177
+ lie end to end and an FF byte closes the directory. ORDOS keeps such
178
+ a preloaded disk instead of clearing it at start-up.
179
+
180
+ The files may be in either stored form: a .ORD holds the whole file
181
+ length in the size field, a .BRU holds the data length and pads the
182
+ data out to a block boundary. _to_record normalises both.
183
+ '''
184
+ image = bytearray(PAGED_SIZE)
185
+ offset = 0
186
+ for data in files:
187
+ record = _to_record(data)
188
+ # Leave room for the record and the FF terminator after it.
189
+ if offset + len(record) + 1 > PAGED_SIZE:
190
+ raise ValueError(
191
+ f'the RAM-disk (drive B:) holds {PAGED_SIZE} bytes; '
192
+ f'these files do not fit')
193
+ image[offset:offset + len(record)] = record
194
+ offset += len(record)
195
+ image[offset] = 0xff
196
+ self.__page_images[RAM_DISK_PAGE][:] = image
197
+
198
+ def set_keys(self, keys: set[tuple[int, int]]) -> None:
199
+ '''Set the pressed keys as (column, row) matrix crossings.'''
200
+ self.__keys = set(keys)
201
+ self.__update_keyboard()
202
+
203
+ def __update_keyboard(self) -> None:
204
+ # A pressed key pulls its sense row low while its scan column is
205
+ # driven low. Rows 0-7 report on port B, rows 8-11 on the top of
206
+ # port C.
207
+ sense_b = 0xff
208
+ sense_c = 0xff
209
+ for column, row in self.__keys:
210
+ if not (self.__scan >> column) & 1:
211
+ if row < 8:
212
+ sense_b &= ~(1 << row) & 0xff
213
+ else:
214
+ sense_c &= ~(1 << (row - 8 + 4)) & 0xff
215
+ self.set_memory_block(KEYBOARD_SENSE, bytes([sense_b]))
216
+ self.set_memory_block(KEYBOARD_SENSE_C, bytes([sense_c]))
217
+
218
+ def __on_write(self, addr: int, value: int) -> None:
219
+ # Only F400-FFFF is marked for this handler; the paged region below
220
+ # writes straight to the core's memory.
221
+ # The Monitor ROM and the system latches sit at MONITOR_BASE and
222
+ # above. Capture the latch writes and never touch the ROM. The F900
223
+ # latch also selects the memory page.
224
+ if addr >= MONITOR_BASE:
225
+ self.__system_ports[addr] = value
226
+ if addr == PAGE_SELECT:
227
+ self.__select_page(value)
228
+ elif addr == COLOUR_CONTROL:
229
+ self.__colour_control = value
230
+ return
231
+
232
+ # The 8255 region. Writing the ROM-disk address ports makes the
233
+ # addressed byte appear at its data port. Writing the keyboard scan
234
+ # column updates the sense rows for the pressed keys.
235
+ if self.__romdisk and ROMDISK_PORT < addr <= ROMDISK_PORT + 2:
236
+ self.__set_romdisk_address(addr, value)
237
+ elif addr == KEYBOARD_SCAN:
238
+ self.__scan = value
239
+ self.__update_keyboard()
240
+
241
+ def __on_output(self, port: int, value: int) -> None:
242
+ if port & 0xff == PAGE_SELECT_PORT:
243
+ self.__select_page(value)
244
+ elif port & 0xff == COLOUR_CONTROL_PORT:
245
+ self.__colour_control = value
246
+
247
+ def __select_page(self, page: int) -> None:
248
+ page &= 0x03
249
+ if page == self.__current_page:
250
+ return
251
+
252
+ # Save the exposed page back to its image, then expose the newly
253
+ # selected page. The core's memory always holds the current page,
254
+ # so ordinary reads and writes stay at full speed.
255
+ self.__page_images[self.__current_page][:] = self.memory[:PAGED_SIZE]
256
+ self.set_memory_block(0, self.__page_images[page])
257
+ self.__current_page = page
258
+
259
+ def __set_romdisk_address(self, addr: int, value: int) -> None:
260
+ if addr == ROMDISK_PORT + 1:
261
+ self.__romdisk_addr = (self.__romdisk_addr & 0xff00) | value
262
+ else:
263
+ self.__romdisk_addr = (self.__romdisk_addr & 0x00ff) | (value << 8)
264
+ byte = self.__romdisk[self.__romdisk_addr % len(self.__romdisk)]
265
+ self.set_memory_block(ROMDISK_PORT, bytes([byte]))
266
+
267
+ def __page_memory(self, page: int) -> bytes | bytearray | memoryview:
268
+ if page == self.__current_page:
269
+ return self.memory
270
+ return self.__page_images[page]
271
+
272
+ def read_screen(self) -> npt.NDArray[np.uint8]:
273
+ '''Return the screen as a SCREEN_HEIGHT by SCREEN_WIDTH array of
274
+ pixels, each 0 or 1.'''
275
+ video = np.frombuffer(
276
+ self.__page_memory(0), dtype=np.uint8, count=VIDEO_SIZE,
277
+ offset=VIDEO_BASE)
278
+
279
+ # Bytes go down a column, so the buffer is columns of pixels; put
280
+ # the rows first, then expand each byte into its eight pixels.
281
+ columns = video.reshape(SCREEN_WIDTH // 8, SCREEN_HEIGHT)
282
+ return np.unpackbits(columns.T, axis=1)
283
+
284
+ def render(self) -> npt.NDArray[np.uint8]:
285
+ '''Return the screen as a SCREEN_HEIGHT by SCREEN_WIDTH RGB frame,
286
+ in the current colour mode.'''
287
+ pixels = self.read_screen()
288
+
289
+ # Monochrome until a program turns colour on.
290
+ if not self.__colour_control & COLOUR_ON:
291
+ frame = np.zeros((SCREEN_HEIGHT, SCREEN_WIDTH, 3), dtype=np.uint8)
292
+ frame[pixels != 0] = _GREEN
293
+ return frame
294
+
295
+ # 16-colour: the colour byte for each pixel byte is at the same
296
+ # address in page 1. Its top nibble is the background colour and its
297
+ # bottom nibble the foreground; the pixel bit chooses between them.
298
+ colour = np.frombuffer(
299
+ self.__page_memory(1), dtype=np.uint8, count=VIDEO_SIZE,
300
+ offset=VIDEO_BASE)
301
+ per_byte = colour.reshape(SCREEN_WIDTH // 8, SCREEN_HEIGHT).T
302
+ per_pixel = np.repeat(per_byte, 8, axis=1)
303
+ foreground = per_pixel & 0x0f
304
+ background = per_pixel >> 4
305
+ index = np.where(pixels != 0, foreground, background)
306
+ coloured: npt.NDArray[np.uint8] = _PALETTE[index]
307
+ return coloured
@@ -0,0 +1,76 @@
1
+ # Orion-128 home computer emulator.
2
+ # https://github.com/kosarev/orion128
3
+ #
4
+ # Copyright (C) 2026 Ivan Kosarev.
5
+ # mail@ivankosarev.com
6
+ #
7
+ # Published under the MIT license.
8
+
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+
13
+ from ._display import Display
14
+ from ._machine import CPU_FREQUENCY, Orion128Machine
15
+
16
+ # The window is redrawn this many times a second, and the processor runs a
17
+ # matching slice of ticks between redraws, so it runs at about real speed.
18
+ FRAMES_PER_SECOND = 50
19
+
20
+ # The bundled default ROMs live next to this module.
21
+ _PACKAGE = Path(__file__).parent
22
+
23
+
24
+ def _take_path(args: list[str], option: str) -> str:
25
+ if not args:
26
+ sys.exit(f'orion128: {option} needs a path')
27
+ return args.pop(0)
28
+
29
+
30
+ def _parse_args(args: list[str]) -> tuple[Path, Path, list[Path]]:
31
+ '''Read the leading options -- '--monitor PATH' and '--romdisk PATH',
32
+ each defaulting to the bundled ROM -- followed by any number of ORDOS
33
+ files (.ORD or .BRU) to preload onto the RAM-disk (drive B:).'''
34
+ monitor = _PACKAGE / 'ms7007.rom'
35
+ romdisk = _PACKAGE / 'disk.rom'
36
+ while args and args[0].startswith('--'):
37
+ option = args.pop(0)
38
+ if option == '--monitor':
39
+ monitor = Path(_take_path(args, option))
40
+ elif option == '--romdisk':
41
+ romdisk = Path(_take_path(args, option))
42
+ else:
43
+ sys.exit(f'orion128: unknown option: {option}')
44
+ files = [Path(arg) for arg in args]
45
+ return monitor, romdisk, files
46
+
47
+
48
+ def main() -> None:
49
+ '''The orion128 command-line entry point.
50
+
51
+ It runs the machine and shows its screen until the window is closed. By
52
+ default it boots the bundled Monitor and ORDOS ROM-disk; '--monitor
53
+ PATH' and '--romdisk PATH' replace them. Any further arguments are ORDOS
54
+ files (.ORD or .BRU) preloaded onto the RAM-disk (drive B:).
55
+ '''
56
+ monitor, romdisk, files = _parse_args(sys.argv[1:])
57
+
58
+ machine = Orion128Machine()
59
+ machine.load_monitor(monitor.read_bytes())
60
+ machine.load_romdisk(romdisk.read_bytes())
61
+ if files:
62
+ machine.load_ram_disk([path.read_bytes() for path in files])
63
+
64
+ ticks_per_frame = CPU_FREQUENCY // FRAMES_PER_SECOND
65
+ display = Display()
66
+ try:
67
+ while not display.closed():
68
+ if display.take_reset():
69
+ machine.reset()
70
+ machine.set_keys(display.pressed_keys())
71
+ machine.ticks_to_stop = ticks_per_frame
72
+ machine.run()
73
+ display.update(machine.render())
74
+ time.sleep(1 / FRAMES_PER_SECOND)
75
+ finally:
76
+ display.close()
Binary file
Binary file
File without changes
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: orion128
3
+ Version: 0.1.0a1
4
+ Summary: Orion-128 home computer emulator
5
+ Author-email: Ivan Kosarev <mail@ivankosarev.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/kosarev/orion128/
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: X11 Applications
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: End Users/Desktop
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: Implementation :: CPython
19
+ Classifier: Topic :: Games/Entertainment
20
+ Classifier: Topic :: System :: Emulators
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: z80>=1.2
26
+ Requires-Dist: pysdl2
27
+ Requires-Dist: numpy
28
+ Dynamic: license-file
29
+
30
+ # orion128
31
+
32
+ An emulator of the Orion-128, a home computer built around the KR580VM80A
33
+ processor (an i8080 clone).
34
+
35
+ [![Python package CI](https://github.com/kosarev/orion128/actions/workflows/python-package.yml/badge.svg)](https://github.com/kosarev/orion128/actions/workflows/python-package.yml)
36
+ [![PyPI](https://img.shields.io/pypi/v/orion128)](https://pypi.org/project/orion128/)
37
+ [![Python](https://img.shields.io/pypi/pyversions/orion128)](https://pypi.org/project/orion128/)
38
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/kosarev/orion128/blob/main/LICENSE)
39
+
40
+ orion128 emulates the hardware — memory-mapped video, the keyboard, ports and
41
+ the disk controller — and runs the real ROM and software on top of it. It is
42
+ built on the [z80](https://github.com/kosarev/z80) package for the i8080 core
43
+ and aims to stay in pure Python. The display is built on PySDL2 and numpy.
44
+
45
+ It ships with the MS7007 Monitor and an ORDOS 4.03 ROM-disk, so it boots a
46
+ working system out of the box:
47
+
48
+ ```shell
49
+ orion128
50
+ ```
51
+
52
+ `--monitor PATH` and `--romdisk PATH` replace the bundled ROMs. Any further
53
+ arguments are ORDOS files (`.ORD` or `.BRU`) preloaded onto the RAM-disk
54
+ (drive B:):
55
+
56
+ ```shell
57
+ orion128 PENX4$.ORD
58
+ ```
59
+
60
+ <kbd>F12</kbd> is the RESET button. <kbd>F10</kbd> closes the emulator.
61
+
62
+ The bundled ROMs are the copyright of their original authors and are not
63
+ covered by this package's MIT licence; see `orion128/ROMS.txt`.
64
+
65
+ This is early work in progress.
@@ -0,0 +1,19 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ orion128/ROMS.txt
5
+ orion128/__init__.py
6
+ orion128/_display.py
7
+ orion128/_machine.py
8
+ orion128/_main.py
9
+ orion128/disk.rom
10
+ orion128/ms7007.rom
11
+ orion128/py.typed
12
+ orion128.egg-info/PKG-INFO
13
+ orion128.egg-info/SOURCES.txt
14
+ orion128.egg-info/dependency_links.txt
15
+ orion128.egg-info/entry_points.txt
16
+ orion128.egg-info/requires.txt
17
+ orion128.egg-info/top_level.txt
18
+ tests/test_import.py
19
+ tests/test_machine.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ orion128 = orion128:main
@@ -0,0 +1,3 @@
1
+ z80>=1.2
2
+ pysdl2
3
+ numpy
@@ -0,0 +1 @@
1
+ orion128
@@ -0,0 +1,61 @@
1
+ [build-system]
2
+ requires = ['setuptools>=61.0', 'wheel']
3
+ build-backend = 'setuptools.build_meta'
4
+
5
+ [project]
6
+ name = 'orion128'
7
+ version = '0.1.0a1'
8
+ description = 'Orion-128 home computer emulator'
9
+ readme = 'README.md'
10
+ license = {text = 'MIT'}
11
+ authors = [
12
+ {name = 'Ivan Kosarev', email = 'mail@ivankosarev.com'}
13
+ ]
14
+ requires-python = '>=3.10'
15
+ dependencies = [
16
+ 'z80>=1.2',
17
+ 'pysdl2',
18
+ 'numpy',
19
+ ]
20
+ classifiers = [
21
+ 'Development Status :: 3 - Alpha',
22
+ 'Environment :: X11 Applications',
23
+ 'Intended Audience :: Developers',
24
+ 'Intended Audience :: End Users/Desktop',
25
+ 'Operating System :: OS Independent',
26
+ 'Programming Language :: Python :: 3',
27
+ 'Programming Language :: Python :: 3.10',
28
+ 'Programming Language :: Python :: 3.11',
29
+ 'Programming Language :: Python :: 3.12',
30
+ 'Programming Language :: Python :: 3.13',
31
+ 'Programming Language :: Python :: Implementation :: CPython',
32
+ 'Topic :: Games/Entertainment',
33
+ 'Topic :: System :: Emulators',
34
+ 'Typing :: Typed',
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = 'https://github.com/kosarev/orion128/'
39
+
40
+ [project.scripts]
41
+ orion128 = 'orion128:main'
42
+
43
+ [tool.setuptools]
44
+ packages = ['orion128']
45
+
46
+ [tool.setuptools.package-data]
47
+ # py.typed is the PEP 561 marker telling type checkers the package
48
+ # carries type information. The .rom files are the bundled default Monitor
49
+ # and ORDOS ROM-disk; ROMS.txt is their copyright notice.
50
+ orion128 = ['py.typed', '*.rom', 'ROMS.txt']
51
+
52
+ [tool.ruff]
53
+ # Anchors the ruff project root, so the orion128 package is recognised as
54
+ # first-party regardless of the directory ruff is invoked from.
55
+
56
+ # Match pycodestyle, so ruff does not keep imports on lines it rejects.
57
+ line-length = 79
58
+
59
+ [tool.ruff.lint]
60
+ # Reject needlessly quoted type annotations.
61
+ extend-select = ['UP037']
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,12 @@
1
+ # Orion-128 home computer emulator.
2
+ # https://github.com/kosarev/orion128
3
+ #
4
+ # Copyright (C) 2026 Ivan Kosarev.
5
+ # mail@ivankosarev.com
6
+ #
7
+ # Published under the MIT license.
8
+
9
+
10
+ def test_import() -> None:
11
+ import orion128
12
+ assert orion128 is not None
@@ -0,0 +1,202 @@
1
+ # Orion-128 home computer emulator.
2
+ # https://github.com/kosarev/orion128
3
+ #
4
+ # Copyright (C) 2026 Ivan Kosarev.
5
+ # mail@ivankosarev.com
6
+ #
7
+ # Published under the MIT license.
8
+
9
+ import orion128
10
+
11
+
12
+ def test_load_monitor() -> None:
13
+ # A synthetic ROM whose first byte is the reset jump, as the real
14
+ # Monitor's is.
15
+ monitor = bytes([0xc3, 0x42, 0xf8]) + bytes(orion128.MONITOR_SIZE - 3)
16
+ machine = orion128.Orion128Machine(monitor)
17
+ assert machine.pc == orion128.MONITOR_BASE
18
+ start = orion128.MONITOR_BASE
19
+ assert bytes(machine.memory[start:start + 3]) == bytes([0xc3, 0x42, 0xf8])
20
+
21
+
22
+ def test_io_bus() -> None:
23
+ # A monitor that stores to a system latch (F800) and to RAM (0x1234),
24
+ # then halts.
25
+ code = bytes([
26
+ 0x3e, 0x99, # MVI A, 0x99
27
+ 0x32, 0x00, 0xf8, # STA 0xF800 -- a latch; the ROM must survive
28
+ 0x32, 0x34, 0x12, # STA 0x1234 -- ordinary RAM
29
+ 0x76, # HLT
30
+ ])
31
+ monitor = code + bytes(orion128.MONITOR_SIZE - len(code))
32
+ machine = orion128.Orion128Machine(monitor)
33
+ machine.ticks_to_stop = 1000
34
+ machine.run()
35
+
36
+ # The write to F800 was captured as a latch, not stored, so the ROM
37
+ # byte there is unchanged.
38
+ assert machine.memory[0xf800] == monitor[0]
39
+ # The write to RAM went through.
40
+ assert machine.memory[0x1234] == 0x99
41
+
42
+
43
+ def test_paging() -> None:
44
+ # Write a byte to page 1, read it back to common memory, return to
45
+ # page 0, and halt.
46
+ code = bytes([
47
+ 0x3e, 0x01, 0xd3, 0xf9, # MVI A,1; OUT F9 -- select page 1
48
+ 0x3e, 0x5a, # MVI A, 0x5A
49
+ 0x32, 0x00, 0x20, # STA 0x2000 -- into page 1
50
+ 0x3a, 0x00, 0x20, # LDA 0x2000 -- back from page 1
51
+ 0x32, 0x00, 0xf0, # STA 0xF000 -- common memory
52
+ 0xaf, 0xd3, 0xf9, # XRA A; OUT F9 -- select page 0
53
+ 0x76, # HLT
54
+ ])
55
+ monitor = code + bytes(orion128.MONITOR_SIZE - len(code))
56
+ machine = orion128.Orion128Machine(monitor)
57
+ machine.ticks_to_stop = 1000
58
+ machine.run()
59
+
60
+ # Page 0 at 0x2000 is untouched; the byte went to page 1 and read back.
61
+ assert machine.memory[0x2000] == 0x00
62
+ assert machine.memory[0xf000] == 0x5a
63
+
64
+
65
+ def test_romdisk() -> None:
66
+ # A monitor that sets the ROM-disk address to 0x1234, reads the data
67
+ # port, stores the byte to RAM, then halts.
68
+ code = bytes([
69
+ 0x3e, 0x34, # MVI A, 0x34
70
+ 0x32, 0x01, 0xf5, # STA 0xF501 -- address low
71
+ 0x3e, 0x12, # MVI A, 0x12
72
+ 0x32, 0x02, 0xf5, # STA 0xF502 -- address high
73
+ 0x3a, 0x00, 0xf5, # LDA 0xF500 -- the data port
74
+ 0x32, 0x00, 0x10, # STA 0x1000
75
+ 0x76, # HLT
76
+ ])
77
+ monitor = code + bytes(orion128.MONITOR_SIZE - len(code))
78
+ machine = orion128.Orion128Machine(monitor)
79
+
80
+ romdisk = bytearray(0x10000)
81
+ romdisk[0x1234] = 0x5a
82
+ machine.load_romdisk(bytes(romdisk))
83
+
84
+ machine.ticks_to_stop = 1000
85
+ machine.run()
86
+ assert machine.memory[0x1000] == 0x5a
87
+
88
+
89
+ def test_keyboard() -> None:
90
+ # A monitor that drives scan column 2 low, reads the sense rows, stores
91
+ # them to RAM, then halts.
92
+ code = bytes([
93
+ 0x3e, 0xfb, # MVI A, 0xFB -- column 2 low
94
+ 0x32, 0x00, 0xf4, # STA 0xF400 -- scan
95
+ 0x3a, 0x01, 0xf4, # LDA 0xF401 -- sense
96
+ 0x32, 0x00, 0x10, # STA 0x1000
97
+ 0x76, # HLT
98
+ ])
99
+ monitor = code + bytes(orion128.MONITOR_SIZE - len(code))
100
+ machine = orion128.Orion128Machine(monitor)
101
+
102
+ # A key at column 2, row 3, plus one on another column that must stay
103
+ # invisible while column 2 is scanned.
104
+ machine.set_keys({(2, 3), (5, 6)})
105
+ machine.ticks_to_stop = 1000
106
+ machine.run()
107
+
108
+ # Row 3 pulled low, the rest high: 0xFF & ~(1 << 3) == 0xF7.
109
+ assert machine.memory[0x1000] == 0xf7
110
+
111
+
112
+ def test_ordos_record_formats() -> None:
113
+ from orion128._machine import _to_record
114
+ header = b'FILE ' + (0x100).to_bytes(2, 'little')
115
+
116
+ # A .ORD file: the size field is the whole file length.
117
+ ord_file = header + (24).to_bytes(2, 'little') + bytes(4) + b'12345678'
118
+ record = _to_record(ord_file)
119
+ assert len(record) == 24
120
+ assert (record[10] | (record[11] << 8)) == 8
121
+ assert record[16:] == b'12345678'
122
+
123
+ # A .BRU file: the size field is the data length, with block padding.
124
+ bru_file = (header + (8).to_bytes(2, 'little') + bytes(4)
125
+ + b'12345678' + bytes(40))
126
+ record = _to_record(bru_file)
127
+ assert len(record) == 24
128
+ assert (record[10] | (record[11] << 8)) == 8
129
+ assert record[16:] == b'12345678'
130
+
131
+
132
+ def test_reset() -> None:
133
+ machine = orion128.Orion128Machine()
134
+ machine.set_memory_block(0x1000, bytes([0x5a]))
135
+ machine.reset()
136
+ # The processor is back at the Monitor, and RAM is kept.
137
+ assert machine.pc == orion128.MONITOR_BASE
138
+ assert machine.memory[0x1000] == 0x5a
139
+
140
+
141
+ def test_ram_disk() -> None:
142
+ # Preload a record, then read the RAM-disk page's first byte back to
143
+ # common memory.
144
+ code = bytes([
145
+ 0x3e, 0x01, 0xd3, 0xf9, # select page 1 (the RAM-disk)
146
+ 0x3a, 0x00, 0x00, # LDA 0x0000
147
+ 0x32, 0x00, 0xf0, # STA 0xF000 (common memory)
148
+ 0xaf, 0xd3, 0xf9, # select page 0
149
+ 0x76, # HLT
150
+ ])
151
+ monitor = code + bytes(orion128.MONITOR_SIZE - len(code))
152
+ machine = orion128.Orion128Machine(monitor)
153
+
154
+ record = b'HELLO ' + bytes([0x00, 0x10, 0x08, 0x00, 0, 0, 0, 0])
155
+ machine.load_ram_disk([record])
156
+ machine.ticks_to_stop = 1000
157
+ machine.run()
158
+ assert machine.memory[0xf000] == ord('H')
159
+
160
+
161
+ def test_render_monochrome() -> None:
162
+ machine = orion128.Orion128Machine()
163
+ machine.set_memory_block(orion128.VIDEO_BASE, bytes([0x80]))
164
+ frame = machine.render()
165
+ assert frame.shape == (orion128.SCREEN_HEIGHT, orion128.SCREEN_WIDTH, 3)
166
+ # The lit top-left pixel is drawn, the next one is dark.
167
+ assert frame[0, 0].any()
168
+ assert not frame[0, 1].any()
169
+
170
+
171
+ def test_screen_pixel_layout() -> None:
172
+ machine = orion128.Orion128Machine()
173
+ screen = machine.read_screen()
174
+ assert screen.shape == (orion128.SCREEN_HEIGHT, orion128.SCREEN_WIDTH)
175
+ assert not screen.any()
176
+
177
+ # The first video byte is the leftmost eight pixels of the top row,
178
+ # the most significant bit first.
179
+ machine.set_memory_block(orion128.VIDEO_BASE, bytes([0x80]))
180
+ screen = machine.read_screen()
181
+ assert screen[0, 0] == 1
182
+ assert screen[0, 1] == 0
183
+
184
+ # The least significant bit of that byte is its rightmost pixel.
185
+ machine.set_memory_block(orion128.VIDEO_BASE, bytes([0x01]))
186
+ screen = machine.read_screen()
187
+ assert screen[0, 7] == 1
188
+ assert screen[0, 6] == 0
189
+
190
+ # Stepping the address by the screen height moves right by one byte.
191
+ machine.set_memory_block(orion128.VIDEO_BASE, bytes([0x00]))
192
+ machine.set_memory_block(
193
+ orion128.VIDEO_BASE + orion128.SCREEN_HEIGHT, bytes([0x80]))
194
+ screen = machine.read_screen()
195
+ assert screen[0, 8] == 1
196
+
197
+ # Stepping the address by one moves down by one row.
198
+ machine.set_memory_block(
199
+ orion128.VIDEO_BASE + orion128.SCREEN_HEIGHT, bytes([0x00]))
200
+ machine.set_memory_block(orion128.VIDEO_BASE + 1, bytes([0x80]))
201
+ screen = machine.read_screen()
202
+ assert screen[1, 0] == 1