pdretro 0.1.2__cp313-cp313-win_amd64.whl
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.
- _ra_wrapper.cp313-win_amd64.pyd +0 -0
- pdretro/__init__.py +2 -0
- pdretro/emulator.py +258 -0
- pdretro-0.1.2.dist-info/METADATA +651 -0
- pdretro-0.1.2.dist-info/RECORD +8 -0
- pdretro-0.1.2.dist-info/WHEEL +5 -0
- pdretro-0.1.2.dist-info/licenses/LICENSE +21 -0
- pdretro-0.1.2.dist-info/top_level.txt +2 -0
|
Binary file
|
pdretro/__init__.py
ADDED
pdretro/emulator.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import _ra_wrapper
|
|
2
|
+
import numpy as np
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import IntEnum
|
|
6
|
+
|
|
7
|
+
class RetroButton(IntEnum):
|
|
8
|
+
"""RetroArch button IDs matching libretro RETRO_DEVICE_ID_JOYPAD_*"""
|
|
9
|
+
B = 0
|
|
10
|
+
Y = 1
|
|
11
|
+
SELECT = 2
|
|
12
|
+
START = 3
|
|
13
|
+
UP = 4
|
|
14
|
+
DOWN = 5
|
|
15
|
+
LEFT = 6
|
|
16
|
+
RIGHT = 7
|
|
17
|
+
A = 8
|
|
18
|
+
X = 9
|
|
19
|
+
L = 10
|
|
20
|
+
R = 11
|
|
21
|
+
L2 = 12
|
|
22
|
+
R2 = 13
|
|
23
|
+
L3 = 14
|
|
24
|
+
R3 = 15
|
|
25
|
+
|
|
26
|
+
class RetroAnalogStick(IntEnum):
|
|
27
|
+
"""Analog stick selection"""
|
|
28
|
+
LEFT = 0
|
|
29
|
+
RIGHT = 1
|
|
30
|
+
|
|
31
|
+
class RetroAnalogAxis(IntEnum):
|
|
32
|
+
"""Analog axis selection"""
|
|
33
|
+
X = 0
|
|
34
|
+
Y = 1
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class SystemInfo:
|
|
38
|
+
library_name: str
|
|
39
|
+
library_version: str
|
|
40
|
+
valid_extensions: list[str]
|
|
41
|
+
need_fullpath: bool
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_dict(cls, info: dict) -> 'SystemInfo':
|
|
45
|
+
return cls(
|
|
46
|
+
library_name=info['library_name'],
|
|
47
|
+
library_version=info['library_version'],
|
|
48
|
+
valid_extensions=info['valid_extensions'].split('|'),
|
|
49
|
+
need_fullpath=info['need_fullpath']
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class AVInfo:
|
|
54
|
+
fps: float
|
|
55
|
+
sample_rate: float
|
|
56
|
+
base_width: int
|
|
57
|
+
base_height: int
|
|
58
|
+
max_width: int
|
|
59
|
+
max_height: int
|
|
60
|
+
aspect_ratio: float
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_dict(cls, info: dict) -> 'AVInfo':
|
|
64
|
+
return cls(**info)
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class VideoFrame:
|
|
68
|
+
data: np.ndarray # Raw pixel data (pointer / bytes)
|
|
69
|
+
width: int
|
|
70
|
+
height: int
|
|
71
|
+
pitch: int
|
|
72
|
+
format: int
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_dict(cls, frame: dict) -> 'VideoFrame':
|
|
76
|
+
return cls(**frame)
|
|
77
|
+
|
|
78
|
+
def to_rgb(self) -> np.ndarray:
|
|
79
|
+
# Allocate output array once
|
|
80
|
+
out = np.empty((self.height, self.width, 3), dtype=np.uint8)
|
|
81
|
+
|
|
82
|
+
if self.format == 1: # XRGB8888
|
|
83
|
+
raw = np.frombuffer(self.data, dtype=np.uint32).reshape(self.height, self.pitch // 4)
|
|
84
|
+
raw = raw[:, :self.width] # just view, no copy
|
|
85
|
+
out[..., 0] = (raw >> 16) & 0xFF # R
|
|
86
|
+
out[..., 1] = (raw >> 8) & 0xFF # G
|
|
87
|
+
out[..., 2] = raw & 0xFF # B
|
|
88
|
+
|
|
89
|
+
elif self.format == 0: # 0RGB1555
|
|
90
|
+
raw = np.frombuffer(self.data, dtype=np.uint16).reshape(self.height, self.pitch // 2)
|
|
91
|
+
raw = raw[:, :self.width]
|
|
92
|
+
out[..., 0] = ((raw >> 10) & 0x1F) << 3
|
|
93
|
+
out[..., 1] = ((raw >> 5) & 0x1F) << 3
|
|
94
|
+
out[..., 2] = (raw & 0x1F) << 3
|
|
95
|
+
|
|
96
|
+
elif self.format == 2: # RGB565
|
|
97
|
+
raw = np.frombuffer(self.data, dtype=np.uint16).reshape(self.height, self.pitch // 2)
|
|
98
|
+
raw = raw[:, :self.width]
|
|
99
|
+
out[..., 0] = ((raw >> 11) & 0x1F) << 3
|
|
100
|
+
out[..., 1] = ((raw >> 5) & 0x3F) << 2
|
|
101
|
+
out[..., 2] = (raw & 0x1F) << 3
|
|
102
|
+
|
|
103
|
+
else:
|
|
104
|
+
raise ValueError(f"Unsupported video format: {self.format}")
|
|
105
|
+
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class AudioFrame:
|
|
112
|
+
data: np.ndarray # Stereo int16 samples
|
|
113
|
+
frames: int
|
|
114
|
+
sample_rate: float
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def from_dict(cls, audio: dict) -> 'AudioFrame':
|
|
118
|
+
return cls(**audio)
|
|
119
|
+
|
|
120
|
+
class Emulator:
|
|
121
|
+
def __init__(self, core_path: str):
|
|
122
|
+
self.core_path = os.path.abspath(core_path)
|
|
123
|
+
|
|
124
|
+
if not os.path.isfile(core_path):
|
|
125
|
+
raise FileNotFoundError(f"Core not found: {core_path}")
|
|
126
|
+
|
|
127
|
+
_ra_wrapper.init_core(self.core_path)
|
|
128
|
+
self._system_info = SystemInfo.from_dict(_ra_wrapper.get_system_info())
|
|
129
|
+
self._av_info = None
|
|
130
|
+
self._game_loaded = False
|
|
131
|
+
|
|
132
|
+
def __enter__(self):
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
136
|
+
try:
|
|
137
|
+
_ra_wrapper.shutdown()
|
|
138
|
+
except:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def system_info(self) -> SystemInfo:
|
|
143
|
+
return self._system_info
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def av_info(self) -> AVInfo:
|
|
147
|
+
if not self._game_loaded or self._av_info is None:
|
|
148
|
+
raise RuntimeError("No game loaded")
|
|
149
|
+
return self._av_info
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def is_game_loaded(self) -> bool:
|
|
154
|
+
return self._game_loaded
|
|
155
|
+
|
|
156
|
+
def load_game(self, rom_path: str):
|
|
157
|
+
rom_path = os.path.abspath(rom_path)
|
|
158
|
+
|
|
159
|
+
if not os.path.isfile(rom_path):
|
|
160
|
+
raise FileNotFoundError(f"ROM not found: {rom_path}")
|
|
161
|
+
|
|
162
|
+
_ra_wrapper.load_game(rom_path)
|
|
163
|
+
self._av_info = AVInfo.from_dict(_ra_wrapper.get_av_info())
|
|
164
|
+
self._game_loaded = True
|
|
165
|
+
|
|
166
|
+
def unload_game(self):
|
|
167
|
+
if self._game_loaded:
|
|
168
|
+
_ra_wrapper.unload_game()
|
|
169
|
+
self._game_loaded = False
|
|
170
|
+
self._av_info = None
|
|
171
|
+
|
|
172
|
+
def reset(self):
|
|
173
|
+
if not self._game_loaded:
|
|
174
|
+
raise RuntimeError("No game loaded")
|
|
175
|
+
_ra_wrapper.reset()
|
|
176
|
+
|
|
177
|
+
def step(self):
|
|
178
|
+
if not self._game_loaded:
|
|
179
|
+
raise RuntimeError("No game loaded")
|
|
180
|
+
_ra_wrapper.step()
|
|
181
|
+
|
|
182
|
+
def get_video_frame(self) -> VideoFrame:
|
|
183
|
+
return VideoFrame.from_dict(_ra_wrapper.get_video_frame())
|
|
184
|
+
|
|
185
|
+
def get_audio_frame(self) -> AudioFrame:
|
|
186
|
+
return AudioFrame.from_dict(_ra_wrapper.get_audio_frame())
|
|
187
|
+
|
|
188
|
+
def set_button(self, port: int, button: int | RetroButton, pressed: bool):
|
|
189
|
+
"""Set button state
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
port: Controller port (0-3)
|
|
193
|
+
button: Button ID (0-15) or RetroButton enum
|
|
194
|
+
pressed: True if pressed, False if released
|
|
195
|
+
|
|
196
|
+
Example:
|
|
197
|
+
emu.set_button(0, RetroButton.A, True)
|
|
198
|
+
emu.set_button(0, 8, True) # Also valid
|
|
199
|
+
"""
|
|
200
|
+
_ra_wrapper.set_button(port, int(button), pressed)
|
|
201
|
+
|
|
202
|
+
def set_analog(self, port: int, stick: int | RetroAnalogStick, axis: int | RetroAnalogAxis, value: int):
|
|
203
|
+
"""Set analog stick axis value
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
port: Controller port (0-3)
|
|
207
|
+
stick: Which stick (0=left, 1=right) or RetroAnalogStick enum
|
|
208
|
+
axis: Which axis (0=X, 1=Y) or RetroAnalogAxis enum
|
|
209
|
+
value: Analog value (-32768 to 32767, 0 is center)
|
|
210
|
+
|
|
211
|
+
Example:
|
|
212
|
+
emu.set_analog(0, RetroAnalogStick.LEFT, RetroAnalogAxis.X, 32767)
|
|
213
|
+
emu.set_analog(0, 0, 0, 32767) # Also valid
|
|
214
|
+
"""
|
|
215
|
+
_ra_wrapper.set_analog(port, int(stick), int(axis), value)
|
|
216
|
+
|
|
217
|
+
def clear_input(self):
|
|
218
|
+
"""Clear all input state (release all buttons, center all sticks)"""
|
|
219
|
+
_ra_wrapper.clear_input()
|
|
220
|
+
|
|
221
|
+
def _frame_generator(self):
|
|
222
|
+
while True:
|
|
223
|
+
self.step()
|
|
224
|
+
yield self.get_video_frame(), self.get_audio_frame()
|
|
225
|
+
|
|
226
|
+
def _video_frame_generator(self):
|
|
227
|
+
while True:
|
|
228
|
+
self.step()
|
|
229
|
+
yield self.get_video_frame()
|
|
230
|
+
|
|
231
|
+
def _audio_frame_generator(self):
|
|
232
|
+
while True:
|
|
233
|
+
self.step()
|
|
234
|
+
yield self.get_audio_frame()
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def frames(self):
|
|
238
|
+
if not self._game_loaded:
|
|
239
|
+
raise RuntimeError("No game loaded")
|
|
240
|
+
return self._frame_generator()
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def video_frames(self):
|
|
244
|
+
if not self._game_loaded:
|
|
245
|
+
raise RuntimeError("No game loaded")
|
|
246
|
+
return self._video_frame_generator()
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def audio_frames(self):
|
|
250
|
+
if not self._game_loaded:
|
|
251
|
+
raise RuntimeError("No game loaded")
|
|
252
|
+
return self._audio_frame_generator()
|
|
253
|
+
|
|
254
|
+
def __del__(self):
|
|
255
|
+
try:
|
|
256
|
+
_ra_wrapper.shutdown()
|
|
257
|
+
except:
|
|
258
|
+
pass
|
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pdretro
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Python wrapper for libretro cores
|
|
5
|
+
Author-email: Colin Politi <urboycolinthepanda@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: homepage, https://github.com/ColinThePanda/pdretro
|
|
8
|
+
Project-URL: Bug_Tracker, https://github.com/ColinThePanda/pdretro/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.8
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: numpy>=1.20.0
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest>=7.0.0; extra == "test"
|
|
17
|
+
Requires-Dist: Pillow>=9.0.0; extra == "test"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# pdretro
|
|
21
|
+
|
|
22
|
+
## Python Libretro Wrapper
|
|
23
|
+
|
|
24
|
+
**Headless RetroArch emulation with a clean Python API for frame-by-frame control, audio capture, and seamless game swapping**
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/pdretro/)
|
|
27
|
+
[](LICENSE)
|
|
28
|
+
|
|
29
|
+
[Installation](#installation) • [Quick Start](#quick-start) • [Usage](#usage) • [Documentation](#documentation)
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Overview
|
|
34
|
+
|
|
35
|
+
pdretro provides a lightweight Python interface to libretro cores, enabling programmatic control of retro game emulation. Built with a pull-based architecture, it allows frame-by-frame stepping, real-time audio/video capture, and dynamic ROM swapping—all without GUI dependencies.
|
|
36
|
+
|
|
37
|
+
### Key Features
|
|
38
|
+
|
|
39
|
+
- **Pure Headless Operation**: No SDL, OpenGL, or GUI dependencies
|
|
40
|
+
- **Frame-Perfect Control**: Step through emulation frame-by-frame with Python generators
|
|
41
|
+
- **Zero-Copy Video Access**: Direct memory access to frame buffers via NumPy arrays
|
|
42
|
+
- **Audio Streaming**: Capture PCM16 stereo audio synchronized with video frames
|
|
43
|
+
- **Hot-Swappable ROMs**: Load and unload games without restarting the core
|
|
44
|
+
- **Save State Support**: Serialize and restore emulation state at any time
|
|
45
|
+
- **Multiple Input Types**: Full controller and analog stick support (4 ports)
|
|
46
|
+
- **Fast & Lightweight**: Minimal C wrapper around libretro with Numba-optimized pixel conversion
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
### Install with pip
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install pdretro
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Prerequisites
|
|
57
|
+
|
|
58
|
+
**Required:**
|
|
59
|
+
|
|
60
|
+
- Python 3.8 or higher
|
|
61
|
+
- NumPy 1.20.0+
|
|
62
|
+
- Libretro cores (`.so`/`.dll`/`.dylib` files)
|
|
63
|
+
|
|
64
|
+
**Optional:**
|
|
65
|
+
|
|
66
|
+
- Pillow (for image output in examples)
|
|
67
|
+
- pytest (for running tests)
|
|
68
|
+
|
|
69
|
+
### Obtaining Libretro Cores
|
|
70
|
+
|
|
71
|
+
Download precompiled cores from:
|
|
72
|
+
|
|
73
|
+
- **RetroArch Buildbot**: https://buildbot.libretro.com/nightly/
|
|
74
|
+
- **Platform-specific packages**: Most Linux distributions include `libretro-*` packages
|
|
75
|
+
|
|
76
|
+
**Common cores:**
|
|
77
|
+
|
|
78
|
+
- `snes9x_libretro` - SNES emulation
|
|
79
|
+
- `genesis_plus_gx_libretro` - Genesis/Mega Drive
|
|
80
|
+
- `gambatte_libretro` - Game Boy / Game Boy Color
|
|
81
|
+
- `mgba_libretro` - Game Boy Advance
|
|
82
|
+
- `nestopia_libretro` - NES
|
|
83
|
+
|
|
84
|
+
## Quick Start
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from pdretro import Emulator
|
|
88
|
+
|
|
89
|
+
# Initialize emulator with a core
|
|
90
|
+
with Emulator("cores/snes9x_libretro.so") as emu:
|
|
91
|
+
# Load a ROM
|
|
92
|
+
emu.load_game("roms/super_mario_world.sfc")
|
|
93
|
+
|
|
94
|
+
# Get system information
|
|
95
|
+
print(f"Core: {emu.system_info.library_name} v{emu.system_info.library_version}")
|
|
96
|
+
print(f"Running at {emu.av_info.fps} FPS")
|
|
97
|
+
|
|
98
|
+
# Run 60 frames
|
|
99
|
+
for i, (video, audio) in enumerate(emu.frames):
|
|
100
|
+
if i >= 60:
|
|
101
|
+
break
|
|
102
|
+
|
|
103
|
+
# Convert to RGB and process
|
|
104
|
+
rgb_frame = video.to_rgb()
|
|
105
|
+
print(f"Frame {i}: {rgb_frame.shape}, Audio: {audio.frames} samples")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Generator-Based Workflow
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
# Video-only generator
|
|
112
|
+
for video in emu.video_frames:
|
|
113
|
+
frame = video.to_rgb() # Shape: (height, width, 3)
|
|
114
|
+
# Process frame...
|
|
115
|
+
|
|
116
|
+
# Audio-only generator
|
|
117
|
+
for audio in emu.audio_frames:
|
|
118
|
+
samples = audio.data # NumPy array (frames, 2)
|
|
119
|
+
# Process audio...
|
|
120
|
+
|
|
121
|
+
# Combined generator
|
|
122
|
+
for video, audio in emu.frames:
|
|
123
|
+
# Process both simultaneously
|
|
124
|
+
pass
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Usage
|
|
128
|
+
|
|
129
|
+
### Basic Emulation
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from pdretro import Emulator
|
|
133
|
+
|
|
134
|
+
# Initialize and load
|
|
135
|
+
emu = Emulator("cores/snes9x_libretro.so")
|
|
136
|
+
emu.load_game("roms/game.sfc")
|
|
137
|
+
|
|
138
|
+
# Step through frames manually
|
|
139
|
+
emu.step()
|
|
140
|
+
video = emu.get_video_frame()
|
|
141
|
+
audio = emu.get_audio_frame()
|
|
142
|
+
|
|
143
|
+
# Or use generators
|
|
144
|
+
for video, audio in emu.frames:
|
|
145
|
+
# Your processing logic
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
# Cleanup
|
|
149
|
+
emu.unload_game()
|
|
150
|
+
emu.shutdown() # Or use context manager
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Context Manager Pattern
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
with Emulator("cores/mgba_libretro.so") as emu:
|
|
157
|
+
emu.load_game("roms/pokemon.gba")
|
|
158
|
+
|
|
159
|
+
# Generator automatically handles cleanup
|
|
160
|
+
for i, video in enumerate(emu.video_frames):
|
|
161
|
+
if i >= 100:
|
|
162
|
+
break
|
|
163
|
+
# Process frames...
|
|
164
|
+
# Automatic cleanup on exit
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Input Control
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from pdretro import Emulator
|
|
171
|
+
|
|
172
|
+
with Emulator("cores/snes9x_libretro.so") as emu:
|
|
173
|
+
emu.load_game("roms/game.sfc")
|
|
174
|
+
|
|
175
|
+
# Button press (port 0, button A)
|
|
176
|
+
import _ra_wrapper
|
|
177
|
+
_ra_wrapper.set_button(0, 8, True) # RA_BUTTON_A = 8
|
|
178
|
+
|
|
179
|
+
# Step with input applied
|
|
180
|
+
emu.step()
|
|
181
|
+
|
|
182
|
+
# Release button
|
|
183
|
+
_ra_wrapper.set_button(0, 8, False)
|
|
184
|
+
|
|
185
|
+
# Clear all inputs
|
|
186
|
+
_ra_wrapper.clear_input()
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**Available buttons:**
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
RA_BUTTON_B = 0
|
|
193
|
+
RA_BUTTON_Y = 1
|
|
194
|
+
RA_BUTTON_SELECT = 2
|
|
195
|
+
RA_BUTTON_START = 3
|
|
196
|
+
RA_BUTTON_UP = 4
|
|
197
|
+
RA_BUTTON_DOWN = 5
|
|
198
|
+
RA_BUTTON_LEFT = 6
|
|
199
|
+
RA_BUTTON_RIGHT = 7
|
|
200
|
+
RA_BUTTON_A = 8
|
|
201
|
+
RA_BUTTON_X = 9
|
|
202
|
+
RA_BUTTON_L = 10
|
|
203
|
+
RA_BUTTON_R = 11
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Analog Input
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
# Set left analog stick on port 0
|
|
210
|
+
_ra_wrapper.set_analog(
|
|
211
|
+
port=0,
|
|
212
|
+
stick=0, # 0=left, 1=right
|
|
213
|
+
axis=0, # 0=X, 1=Y
|
|
214
|
+
value=32767 # Range: -32768 to 32767
|
|
215
|
+
)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Save States
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
# Get required state size
|
|
222
|
+
state_size = _ra_wrapper.get_state_size()
|
|
223
|
+
|
|
224
|
+
if state_size > 0:
|
|
225
|
+
# Save state
|
|
226
|
+
state_data = _ra_wrapper.serialize_state()
|
|
227
|
+
|
|
228
|
+
# Run some frames...
|
|
229
|
+
for _ in range(100):
|
|
230
|
+
emu.step()
|
|
231
|
+
|
|
232
|
+
# Restore state
|
|
233
|
+
_ra_wrapper.unserialize_state(state_data)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Multiple ROMs
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
with Emulator("cores/snes9x_libretro.so") as emu:
|
|
240
|
+
# Load first game
|
|
241
|
+
emu.load_game("roms/game1.sfc")
|
|
242
|
+
for i, video in enumerate(emu.video_frames):
|
|
243
|
+
if i >= 60:
|
|
244
|
+
break
|
|
245
|
+
|
|
246
|
+
# Switch to second game
|
|
247
|
+
emu.unload_game()
|
|
248
|
+
emu.load_game("roms/game2.sfc")
|
|
249
|
+
for i, video in enumerate(emu.video_frames):
|
|
250
|
+
if i >= 60:
|
|
251
|
+
break
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Documentation
|
|
255
|
+
|
|
256
|
+
### Architecture
|
|
257
|
+
|
|
258
|
+
pdretro uses a two-layer architecture:
|
|
259
|
+
|
|
260
|
+
```
|
|
261
|
+
┌─────────────────────────────────┐
|
|
262
|
+
│ Python API (emulator.py) │ ← High-level interface
|
|
263
|
+
│ - Emulator class │
|
|
264
|
+
│ - Generator methods │
|
|
265
|
+
│ - NumPy integration │
|
|
266
|
+
└─────────────────────────────────┘
|
|
267
|
+
↕
|
|
268
|
+
┌─────────────────────────────────┐
|
|
269
|
+
│ C Extension (_ra_wrapper) │ ← Low-level wrapper
|
|
270
|
+
│ - Core loading/management │
|
|
271
|
+
│ - Frame stepping │
|
|
272
|
+
│ - Input handling │
|
|
273
|
+
└─────────────────────────────────┘
|
|
274
|
+
↕
|
|
275
|
+
┌─────────────────────────────────┐
|
|
276
|
+
│ Libretro Core (.so) │ ← Emulation engine
|
|
277
|
+
│ - Game logic │
|
|
278
|
+
│ - Video/audio generation │
|
|
279
|
+
└─────────────────────────────────┘
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### Data Classes
|
|
283
|
+
|
|
284
|
+
#### `SystemInfo`
|
|
285
|
+
|
|
286
|
+
Contains core metadata:
|
|
287
|
+
|
|
288
|
+
```python
|
|
289
|
+
@dataclass
|
|
290
|
+
class SystemInfo:
|
|
291
|
+
library_name: str # e.g., "Snes9x"
|
|
292
|
+
library_version: str # e.g., "1.62.3"
|
|
293
|
+
valid_extensions: list[str] # e.g., ["sfc", "smc"]
|
|
294
|
+
need_fullpath: bool # Whether core needs full ROM path
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
#### `AVInfo`
|
|
298
|
+
|
|
299
|
+
Audio/video specifications:
|
|
300
|
+
|
|
301
|
+
```python
|
|
302
|
+
@dataclass
|
|
303
|
+
class AVInfo:
|
|
304
|
+
fps: float # Target frames per second
|
|
305
|
+
sample_rate: float # Audio sample rate (Hz)
|
|
306
|
+
base_width: int # Native video width
|
|
307
|
+
base_height: int # Native video height
|
|
308
|
+
max_width: int # Maximum width
|
|
309
|
+
max_height: int # Maximum height
|
|
310
|
+
aspect_ratio: float # Pixel aspect ratio
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
#### `VideoFrame`
|
|
314
|
+
|
|
315
|
+
Video frame data:
|
|
316
|
+
|
|
317
|
+
```python
|
|
318
|
+
@dataclass
|
|
319
|
+
class VideoFrame:
|
|
320
|
+
data: np.ndarray # Raw pixel buffer
|
|
321
|
+
width: int # Frame width
|
|
322
|
+
height: int # Frame height
|
|
323
|
+
pitch: int # Bytes per row
|
|
324
|
+
format: int # Pixel format (0/1/2)
|
|
325
|
+
|
|
326
|
+
def to_rgb(self) -> np.ndarray:
|
|
327
|
+
# Returns: (height, width, 3) uint8 array
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
**Supported pixel formats:**
|
|
331
|
+
|
|
332
|
+
- `0`: 0RGB1555 (16-bit)
|
|
333
|
+
- `1`: XRGB8888 (32-bit)
|
|
334
|
+
- `2`: RGB565 (16-bit)
|
|
335
|
+
|
|
336
|
+
#### `AudioFrame`
|
|
337
|
+
|
|
338
|
+
Audio sample data:
|
|
339
|
+
|
|
340
|
+
```python
|
|
341
|
+
@dataclass
|
|
342
|
+
class AudioFrame:
|
|
343
|
+
data: np.ndarray # Shape: (frames, 2), dtype: int16
|
|
344
|
+
frames: int # Number of stereo frames
|
|
345
|
+
sample_rate: float # Sample rate in Hz
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### Video Frame Conversion
|
|
349
|
+
|
|
350
|
+
The `VideoFrame.to_rgb()` method efficiently converts raw pixel data to RGB:
|
|
351
|
+
|
|
352
|
+
```python
|
|
353
|
+
video = emu.get_video_frame()
|
|
354
|
+
rgb = video.to_rgb() # NumPy array (height, width, 3)
|
|
355
|
+
|
|
356
|
+
# Use with PIL
|
|
357
|
+
from PIL import Image
|
|
358
|
+
img = Image.fromarray(rgb, 'RGB')
|
|
359
|
+
img.save('frame.png')
|
|
360
|
+
|
|
361
|
+
# Use with OpenCV
|
|
362
|
+
import cv2
|
|
363
|
+
bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
|
|
364
|
+
cv2.imwrite('frame.png', bgr)
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
### Performance Considerations
|
|
368
|
+
|
|
369
|
+
#### Frame Rate Control
|
|
370
|
+
|
|
371
|
+
pdretro operates in a pull-based model—Python controls the frame rate:
|
|
372
|
+
|
|
373
|
+
```python
|
|
374
|
+
import time
|
|
375
|
+
|
|
376
|
+
target_fps = emu.av_info.fps
|
|
377
|
+
frame_time = 1.0 / target_fps
|
|
378
|
+
|
|
379
|
+
for video, audio in emu.frames:
|
|
380
|
+
start = time.time()
|
|
381
|
+
|
|
382
|
+
# Process frame...
|
|
383
|
+
|
|
384
|
+
# Maintain target FPS
|
|
385
|
+
elapsed = time.time() - start
|
|
386
|
+
if elapsed < frame_time:
|
|
387
|
+
time.sleep(frame_time - elapsed)
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
#### Zero-Copy Access
|
|
391
|
+
|
|
392
|
+
Video and audio data use NumPy array views with no copying:
|
|
393
|
+
|
|
394
|
+
```python
|
|
395
|
+
# Efficient: view into existing buffer
|
|
396
|
+
video = emu.get_video_frame()
|
|
397
|
+
print(video.data.flags.owndata) # False
|
|
398
|
+
|
|
399
|
+
# Efficient: direct RGB conversion
|
|
400
|
+
rgb = video.to_rgb() # Uses NumPy vectorized operations
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
#### Generator Efficiency
|
|
404
|
+
|
|
405
|
+
Generators maintain minimal memory footprint:
|
|
406
|
+
|
|
407
|
+
```python
|
|
408
|
+
# Memory-efficient: only one frame in memory
|
|
409
|
+
for video in emu.video_frames:
|
|
410
|
+
process(video)
|
|
411
|
+
|
|
412
|
+
# Memory-inefficient: loads all frames
|
|
413
|
+
frames = list(emu.video_frames) # Don't do this!
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
### Testing
|
|
417
|
+
|
|
418
|
+
Run the test suite:
|
|
419
|
+
|
|
420
|
+
```bash
|
|
421
|
+
# Install test dependencies
|
|
422
|
+
pip install pytest pillow
|
|
423
|
+
|
|
424
|
+
# Run all tests
|
|
425
|
+
pytest tests/
|
|
426
|
+
|
|
427
|
+
# Run with coverage
|
|
428
|
+
pytest tests/ --cov=pdretro
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
**Test requirements:**
|
|
432
|
+
|
|
433
|
+
- SNES9x core: `snes9x_libretro.dll` in `cores/`
|
|
434
|
+
- F-Zero ROM: `f-zero.sfc` in `roms/`
|
|
435
|
+
|
|
436
|
+
## Examples
|
|
437
|
+
|
|
438
|
+
### Save Screenshots
|
|
439
|
+
|
|
440
|
+
```python
|
|
441
|
+
from pdretro import Emulator
|
|
442
|
+
from PIL import Image
|
|
443
|
+
import time
|
|
444
|
+
|
|
445
|
+
with Emulator("cores/snes9x_libretro.so") as emu:
|
|
446
|
+
emu.load_game("roms/game.sfc")
|
|
447
|
+
|
|
448
|
+
time.sleep(5) # Skip black start screen
|
|
449
|
+
|
|
450
|
+
# Save screenshot
|
|
451
|
+
video = emu.get_video_frame()
|
|
452
|
+
rgb = video.to_rgb()
|
|
453
|
+
Image.fromarray(rgb, 'RGB').save('screenshot.png')
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
### Record Video
|
|
457
|
+
|
|
458
|
+
```python
|
|
459
|
+
from pdretro import Emulator
|
|
460
|
+
import cv2
|
|
461
|
+
import numpy as np
|
|
462
|
+
import time
|
|
463
|
+
|
|
464
|
+
with Emulator("cores/snes9x_libretro.so") as emu:
|
|
465
|
+
emu.load_game("roms/game.sfc")
|
|
466
|
+
|
|
467
|
+
# Setup video writer
|
|
468
|
+
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
|
469
|
+
out = cv2.VideoWriter(
|
|
470
|
+
'output.mp4',
|
|
471
|
+
fourcc,
|
|
472
|
+
emu.av_info.fps,
|
|
473
|
+
(emu.av_info.base_width, emu.av_info.base_height)
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# Record 300 frames (10 seconds at 30fps)
|
|
477
|
+
for i, video in enumerate(emu.video_frames):
|
|
478
|
+
if i >= 300:
|
|
479
|
+
break
|
|
480
|
+
|
|
481
|
+
rgb = video.to_rgb()
|
|
482
|
+
bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
|
|
483
|
+
out.write(bgr)
|
|
484
|
+
time.sleep(1/30)
|
|
485
|
+
|
|
486
|
+
out.release()
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
### AI Training Loop
|
|
490
|
+
|
|
491
|
+
```python
|
|
492
|
+
from pdretro import Emulator
|
|
493
|
+
import numpy as np
|
|
494
|
+
|
|
495
|
+
def train_agent():
|
|
496
|
+
with Emulator("cores/snes9x_libretro.so") as emu:
|
|
497
|
+
emu.load_game("roms/game.sfc")
|
|
498
|
+
|
|
499
|
+
for episode in range(1000):
|
|
500
|
+
# Reset game
|
|
501
|
+
emu.reset()
|
|
502
|
+
|
|
503
|
+
for frame_num in range(1000):
|
|
504
|
+
# Get current state
|
|
505
|
+
video = emu.get_video_frame()
|
|
506
|
+
state = video.to_rgb()
|
|
507
|
+
|
|
508
|
+
# Agent decides action
|
|
509
|
+
action = agent.get_action(state)
|
|
510
|
+
|
|
511
|
+
# Apply action
|
|
512
|
+
_ra_wrapper.clear_input()
|
|
513
|
+
if action == 0: # Jump
|
|
514
|
+
_ra_wrapper.set_button(0, 8, True) # A button
|
|
515
|
+
|
|
516
|
+
# Step emulation
|
|
517
|
+
emu.step()
|
|
518
|
+
|
|
519
|
+
# Get reward and train
|
|
520
|
+
reward = compute_reward(state)
|
|
521
|
+
agent.train(state, action, reward)
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
### Audio Analysis
|
|
525
|
+
|
|
526
|
+
```python
|
|
527
|
+
from pdretro import Emulator
|
|
528
|
+
import numpy as np
|
|
529
|
+
import matplotlib.pyplot as plt
|
|
530
|
+
|
|
531
|
+
with Emulator("cores/snes9x_libretro.so") as emu:
|
|
532
|
+
emu.load_game("roms/game.sfc")
|
|
533
|
+
|
|
534
|
+
# Collect audio samples
|
|
535
|
+
audio_buffer = []
|
|
536
|
+
for i, audio in enumerate(emu.audio_frames):
|
|
537
|
+
if i >= 60: # 2 seconds at 30fps
|
|
538
|
+
break
|
|
539
|
+
audio_buffer.append(audio.data)
|
|
540
|
+
|
|
541
|
+
# Concatenate all audio
|
|
542
|
+
full_audio = np.concatenate(audio_buffer, axis=0)
|
|
543
|
+
|
|
544
|
+
# Analyze
|
|
545
|
+
left_channel = full_audio[:, 0]
|
|
546
|
+
right_channel = full_audio[:, 1]
|
|
547
|
+
|
|
548
|
+
# Plot waveform
|
|
549
|
+
plt.plot(left_channel[:1000])
|
|
550
|
+
plt.title('Audio Waveform')
|
|
551
|
+
plt.show()
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
## Troubleshooting
|
|
555
|
+
|
|
556
|
+
### Core Loading Issues
|
|
557
|
+
|
|
558
|
+
```bash
|
|
559
|
+
# Error: "Failed to load core"
|
|
560
|
+
# Check core path and architecture (32/64-bit)
|
|
561
|
+
|
|
562
|
+
# Linux: verify with
|
|
563
|
+
file cores/snes9x_libretro.so
|
|
564
|
+
ldd cores/snes9x_libretro.so # Check dependencies
|
|
565
|
+
|
|
566
|
+
# Windows: use Dependency Walker
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
### ROM Loading Failures
|
|
570
|
+
|
|
571
|
+
```bash
|
|
572
|
+
# Error: "Failed to load game"
|
|
573
|
+
# Verify ROM format matches core's valid_extensions
|
|
574
|
+
|
|
575
|
+
with Emulator("cores/snes9x_libretro.so") as emu:
|
|
576
|
+
print(emu.system_info.valid_extensions)
|
|
577
|
+
# ['sfc', 'smc', 'swc', 'fig', 'bs', 'st']
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
### Performance Issues
|
|
581
|
+
|
|
582
|
+
**Slow frame processing:**
|
|
583
|
+
|
|
584
|
+
- Use NumPy vectorized operations
|
|
585
|
+
- Avoid list comprehensions on large arrays
|
|
586
|
+
- Profile with `cProfile` to find bottlenecks
|
|
587
|
+
|
|
588
|
+
**Memory leaks:**
|
|
589
|
+
|
|
590
|
+
- Always use context managers or call `shutdown()`
|
|
591
|
+
- Don't hold references to `VideoFrame` objects
|
|
592
|
+
- Clear input state after use
|
|
593
|
+
|
|
594
|
+
### Platform-Specific Notes
|
|
595
|
+
|
|
596
|
+
**Windows:**
|
|
597
|
+
|
|
598
|
+
- Use `.dll` cores from RetroArch buildbot
|
|
599
|
+
- Ensure Visual C++ Redistributable is installed
|
|
600
|
+
|
|
601
|
+
**Linux:**
|
|
602
|
+
|
|
603
|
+
- Use `.so` cores
|
|
604
|
+
- May need to install `libgomp1` for some cores
|
|
605
|
+
|
|
606
|
+
**macOS:**
|
|
607
|
+
|
|
608
|
+
- Use `.dylib` cores
|
|
609
|
+
- May need to allow core in Security & Privacy settings
|
|
610
|
+
|
|
611
|
+
## Project Structure
|
|
612
|
+
|
|
613
|
+
```
|
|
614
|
+
pdretro/
|
|
615
|
+
├── src/
|
|
616
|
+
│ ├── pdretro/
|
|
617
|
+
│ │ ├── __init__.py
|
|
618
|
+
│ │ └── emulator.py # High-level Python API
|
|
619
|
+
│ └── wrapper/
|
|
620
|
+
│ ├── ra_wrapper.c # Core wrapper implementation
|
|
621
|
+
│ ├── ra_wrapper.h # C API header
|
|
622
|
+
│ └── ra_wrapper_python.c # CPython extension
|
|
623
|
+
├── tests/
|
|
624
|
+
│ └── test.py # Test suite
|
|
625
|
+
├── setup.py # Build configuration
|
|
626
|
+
├── pyproject.toml
|
|
627
|
+
└── README.md
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
## Roadmap
|
|
631
|
+
|
|
632
|
+
- [ ] Implement memory search/manipulation
|
|
633
|
+
- [ ] Cheat code support
|
|
634
|
+
- [ ] Network play hooks
|
|
635
|
+
|
|
636
|
+
## License
|
|
637
|
+
|
|
638
|
+
MIT License - Copyright (c) 2026
|
|
639
|
+
|
|
640
|
+
See [LICENSE](LICENSE) for full text.
|
|
641
|
+
|
|
642
|
+
## Links
|
|
643
|
+
|
|
644
|
+
- **PyPI**: https://pypi.org/project/pdretro/
|
|
645
|
+
- **GitHub**: https://github.com/ColinThePanda/pdretro
|
|
646
|
+
- **Issues**: https://github.com/ColinThePanda/pdretro/issues
|
|
647
|
+
- **Libretro**: https://www.libretro.com/
|
|
648
|
+
|
|
649
|
+
## Acknowledgments
|
|
650
|
+
|
|
651
|
+
Built on the [libretro API](https://www.libretro.com/) - a simple but powerful emulation framework enabling cross-platform emulator development.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
_ra_wrapper.cp313-win_amd64.pyd,sha256=9xGqmTeBDC0U0irGaWNW-BTOZ0gR9WJpaohsWa0ZEgI,19968
|
|
2
|
+
pdretro/__init__.py,sha256=4yjXtUCAL-rnGbox-xqEpYik_gXt6SqltXBz8q6jAIs,252
|
|
3
|
+
pdretro/emulator.py,sha256=Q6cC3OQoaMpy1AKIskpd-90VuVHjT-oqr1MZBbrnOjw,7558
|
|
4
|
+
pdretro-0.1.2.dist-info/licenses/LICENSE,sha256=qOvs5ApAj5oPv8OieRSqbR30jUWKhp9nStv62fxcmfU,1090
|
|
5
|
+
pdretro-0.1.2.dist-info/METADATA,sha256=hN8TrqWSn24aQLduaO-U4fqpL4RJ5xyGsFo4U4rkvc0,16422
|
|
6
|
+
pdretro-0.1.2.dist-info/WHEEL,sha256=T5i2ODeLs0s2las6bzoWK6w-WYjxkhtXh7SRJsBsjo4,102
|
|
7
|
+
pdretro-0.1.2.dist-info/top_level.txt,sha256=CbQDJyuLDonumwYRM0WRyzxsVApOVWv_CNqbSeLuxV8,20
|
|
8
|
+
pdretro-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Colin Politi
|
|
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.
|