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.
- orion128-0.1.0a1/LICENSE +21 -0
- orion128-0.1.0a1/PKG-INFO +65 -0
- orion128-0.1.0a1/README.md +36 -0
- orion128-0.1.0a1/orion128/ROMS.txt +7 -0
- orion128-0.1.0a1/orion128/__init__.py +41 -0
- orion128-0.1.0a1/orion128/_display.py +137 -0
- orion128-0.1.0a1/orion128/_machine.py +307 -0
- orion128-0.1.0a1/orion128/_main.py +76 -0
- orion128-0.1.0a1/orion128/disk.rom +0 -0
- orion128-0.1.0a1/orion128/ms7007.rom +0 -0
- orion128-0.1.0a1/orion128/py.typed +0 -0
- orion128-0.1.0a1/orion128.egg-info/PKG-INFO +65 -0
- orion128-0.1.0a1/orion128.egg-info/SOURCES.txt +19 -0
- orion128-0.1.0a1/orion128.egg-info/dependency_links.txt +1 -0
- orion128-0.1.0a1/orion128.egg-info/entry_points.txt +2 -0
- orion128-0.1.0a1/orion128.egg-info/requires.txt +3 -0
- orion128-0.1.0a1/orion128.egg-info/top_level.txt +1 -0
- orion128-0.1.0a1/pyproject.toml +61 -0
- orion128-0.1.0a1/setup.cfg +4 -0
- orion128-0.1.0a1/tests/test_import.py +12 -0
- orion128-0.1.0a1/tests/test_machine.py +202 -0
orion128-0.1.0a1/LICENSE
ADDED
|
@@ -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
|
+
[](https://github.com/kosarev/orion128/actions/workflows/python-package.yml)
|
|
36
|
+
[](https://pypi.org/project/orion128/)
|
|
37
|
+
[](https://pypi.org/project/orion128/)
|
|
38
|
+
[](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
|
+
[](https://github.com/kosarev/orion128/actions/workflows/python-package.yml)
|
|
7
|
+
[](https://pypi.org/project/orion128/)
|
|
8
|
+
[](https://pypi.org/project/orion128/)
|
|
9
|
+
[](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
|
+
[](https://github.com/kosarev/orion128/actions/workflows/python-package.yml)
|
|
36
|
+
[](https://pypi.org/project/orion128/)
|
|
37
|
+
[](https://pypi.org/project/orion128/)
|
|
38
|
+
[](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 @@
|
|
|
1
|
+
|
|
@@ -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,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
|