pdretro
Python Libretro Wrapper
Headless RetroArch emulation with a clean Python API for frame-by-frame control, audio capture, memory access, and seamless game swapping
Installation • Quick Start • Usage • Documentation
Overview
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, direct memory access, and dynamic ROM swapping—all without GUI dependencies.
Key Features
- Pure Headless Operation: No SDL, OpenGL, or GUI dependencies
- Frame-Perfect Control: Step through emulation frame-by-frame with Python generators
- Zero-Copy Video Access: Direct memory access to frame buffers via NumPy arrays
- Audio Streaming: Capture PCM16 stereo audio synchronized with video frames
- Direct Memory Access: Read/write game memory in real-time for AI training, cheats, and analysis
- Hot-Swappable ROMs: Load and unload games without restarting the core
- Save State Support: Serialize and restore emulation state at any time
- Multiple Input Types: Full controller and analog stick support (4 ports)
- Fast & Lightweight: Minimal C wrapper around libretro with NumPy-optimized operations
Installation
Install with pip
pip install pdretro
Prerequisites
Required:
- Python 3.8 or higher
- NumPy 1.20.0+
- Libretro cores (
.so/.dll/.dylibfiles)
Optional:
- Pillow (for image output in examples)
- pytest (for running tests)
Obtaining Libretro Cores
Download precompiled cores from:
- RetroArch Buildbot: https://buildbot.libretro.com/nightly/
- Platform-specific packages: Most Linux distributions include
libretro-*packages
Common cores:
snes9x_libretro- SNES emulationgenesis_plus_gx_libretro- Genesis/Mega Drivegambatte_libretro- Game Boy / Game Boy Colormgba_libretro- Game Boy Advancenestopia_libretro- NES
Quick Start
from pdretro import Emulator
# Initialize emulator with a core
with Emulator("cores/snes9x_libretro.so") as emu:
# Load a ROM
emu.load_game("roms/super_mario_world.sfc")
# Get system information
print(f"Core: {emu.system_info.library_name} v{emu.system_info.library_version}")
print(f"Running at {emu.av_info.fps} FPS")
# Run 60 frames
for i, (video, audio) in enumerate(emu.frames):
if i >= 60:
break
# Convert to RGB and process
rgb_frame = video.to_rgb()
print(f"Frame {i}: {rgb_frame.shape}, Audio: {audio.frames} samples")
Generator-Based Workflow
# Video-only generator
for video in emu.video_frames:
frame = video.to_rgb() # Shape: (height, width, 3)
# Process frame...
# Audio-only generator
for audio in emu.audio_frames:
samples = audio.data # NumPy array (frames, 2)
# Process audio...
# Combined generator
for video, audio in emu.frames:
# Process both simultaneously
pass
Usage
Basic Emulation
from pdretro import Emulator
# Initialize and load
emu = Emulator("cores/snes9x_libretro.so")
emu.load_game("roms/game.sfc")
# Step through frames manually
emu.step()
video = emu.get_video_frame()
audio = emu.get_audio_frame()
# Or use generators
for video, audio in emu.frames:
# Your processing logic
pass
# Cleanup
emu.unload_game()
emu.shutdown() # Or use context manager
Context Manager Pattern
with Emulator("cores/mgba_libretro.so") as emu:
emu.load_game("roms/pokemon.gba")
# Generator automatically handles cleanup
for i, video in enumerate(emu.video_frames):
if i >= 100:
break
# Process frames...
# Automatic cleanup on exit
Input Control
from pdretro import Emulator, RetroButton
with Emulator("cores/snes9x_libretro.so") as emu:
emu.load_game("roms/game.sfc")
# Button press using enum
emu.set_button(0, RetroButton.A, True)
# Step with input applied
emu.step()
# Release button
emu.set_button(0, RetroButton.A, False)
# Clear all inputs
emu.clear_input()
Available buttons:
RetroButton.B, RetroButton.Y, RetroButton.SELECT, RetroButton.START
RetroButton.UP, RetroButton.DOWN, RetroButton.LEFT, RetroButton.RIGHT
RetroButton.A, RetroButton.X, RetroButton.L, RetroButton.R
RetroButton.L2, RetroButton.R2, RetroButton.L3, RetroButton.R3
Analog Input
from pdretro import RetroAnalogStick, RetroAnalogAxis
# Set left analog stick on port 0
emu.set_analog(
port=0,
stick=RetroAnalogStick.LEFT,
axis=RetroAnalogAxis.X,
value=32767 # Range: -32768 to 32767
)
Memory Access
from pdretro import Emulator, MemoryRegion
with Emulator("cores/snes9x_libretro.so") as emu:
emu.load_game("roms/super_mario_world.sfc")
# Get system RAM
wram = emu.get_memory(MemoryRegion.SYSTEM_RAM)
print(f"Memory: {wram.name}, Size: {wram.size} bytes")
# Read single byte
coins = wram.read_byte(0x0DBF)
# Write single byte
wram.write_byte(0x0DBE, 99) # Set lives to 99
# Read multi-byte values
x_position = wram.read_uint16(0x94)
score = wram.read_uint32(0x0F34)
# Direct NumPy array access (fastest)
wram.data[0x100] = 0xFF
values = wram.data[0x100:0x200]
# Search for patterns
matches = wram.search(b'\x01\x02\x03')
print(f"Found pattern at: {[hex(addr) for addr in matches]}")
Available memory regions:
MemoryRegion.SAVE_RAM # Battery-backed save RAM (SRAM)
MemoryRegion.SYSTEM_RAM # Main system RAM (WRAM on SNES)
MemoryRegion.VIDEO_RAM # Video RAM (VRAM)
MemoryRegion.RTC # Real-time clock data
Save States
import _ra_wrapper
# Get required state size
state_size = _ra_wrapper.get_state_size()
if state_size > 0:
# Save state
state_data = _ra_wrapper.serialize_state()
# Run some frames...
for _ in range(100):
emu.step()
# Restore state
_ra_wrapper.unserialize_state(state_data)
Multiple ROMs
with Emulator("cores/snes9x_libretro.so") as emu:
# Load first game
emu.load_game("roms/game1.sfc")
for i, video in enumerate(emu.video_frames):
if i >= 60:
break
# Switch to second game
emu.unload_game()
emu.load_game("roms/game2.sfc")
for i, video in enumerate(emu.video_frames):
if i >= 60:
break
Documentation
Architecture
pdretro uses a two-layer architecture:
┌─────────────────────────────────┐
│ Python API (emulator.py) │ ← High-level interface
│ - Emulator class │
│ - Generator methods │
│ - NumPy integration │
│ - Memory access wrapper │
└─────────────────────────────────┘
↕
┌─────────────────────────────────┐
│ C Extension (_ra_wrapper) │ ← Low-level wrapper
│ - Core loading/management │
│ - Frame stepping │
│ - Input handling │
│ - Memory operations │
└─────────────────────────────────┘
↕
┌─────────────────────────────────┐
│ Libretro Core (.so) │ ← Emulation engine
│ - Game logic │
│ - Video/audio generation │
│ - Memory management │
└─────────────────────────────────┘
Data Classes
SystemInfo
Contains core metadata:
@dataclass
class SystemInfo:
library_name: str # e.g., "Snes9x"
library_version: str # e.g., "1.62.3"
valid_extensions: list[str] # e.g., ["sfc", "smc"]
need_fullpath: bool # Whether core needs full ROM path
AVInfo
Audio/video specifications:
@dataclass
class AVInfo:
fps: float # Target frames per second
sample_rate: float # Audio sample rate (Hz)
base_width: int # Native video width
base_height: int # Native video height
max_width: int # Maximum width
max_height: int # Maximum height
aspect_ratio: float # Pixel aspect ratio
VideoFrame
Video frame data:
@dataclass
class VideoFrame:
data: np.ndarray # Raw pixel buffer
width: int # Frame width
height: int # Frame height
pitch: int # Bytes per row
format: int # Pixel format (0/1/2)
def to_rgb(self) -> np.ndarray:
# Returns: (height, width, 3) uint8 array
Supported pixel formats:
0: 0RGB1555 (16-bit)1: XRGB8888 (32-bit)2: RGB565 (16-bit)
AudioFrame
Audio sample data:
@dataclass
class AudioFrame:
data: np.ndarray # Shape: (frames, 2), dtype: int16
frames: int # Number of stereo frames
sample_rate: float # Sample rate in Hz
Memory
Memory region wrapper:
@dataclass
class Memory:
data: np.ndarray # Direct NumPy array access
size: int # Size in bytes
name: str # Region name (e.g., "WRAM")
region_type: MemoryRegion # Region type enum
# Methods
read_byte(address: int) -> int
write_byte(address: int, value: int)
read_bytes(address: int, length: int) -> bytes
write_bytes(address: int, data: bytes)
read_uint16(address: int, little_endian: bool = True) -> int
write_uint16(address: int, value: int, little_endian: bool = True)
read_uint32(address: int, little_endian: bool = True) -> int
write_uint32(address: int, value: int, little_endian: bool = True)
search(pattern: bytes, start: int = 0) -> list[int]
Video Frame Conversion
The VideoFrame.to_rgb() method efficiently converts raw pixel data to RGB:
video = emu.get_video_frame()
rgb = video.to_rgb() # NumPy array (height, width, 3)
# Use with PIL
from PIL import Image
img = Image.fromarray(rgb, 'RGB')
img.save('frame.png')
# Use with OpenCV
import cv2
bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
cv2.imwrite('frame.png', bgr)
Performance Considerations
Frame Rate Control
pdretro operates in a pull-based model—Python controls the frame rate:
import time
target_fps = emu.av_info.fps
frame_time = 1.0 / target_fps
for video, audio in emu.frames:
start = time.time()
# Process frame...
# Maintain target FPS
elapsed = time.time() - start
if elapsed < frame_time:
time.sleep(frame_time - elapsed)
Zero-Copy Access
Video, audio, and memory data use NumPy array views with no copying:
# Efficient: view into existing buffer
video = emu.get_video_frame()
print(video.data.flags.owndata) # False
# Efficient: direct RGB conversion
rgb = video.to_rgb() # Uses NumPy vectorized operations
# Efficient: direct memory access
wram = emu.get_memory(MemoryRegion.SYSTEM_RAM)
value = wram.data[0x100] # Direct array indexing
Generator Efficiency
Generators maintain minimal memory footprint:
# Memory-efficient: only one frame in memory
for video in emu.video_frames:
process(video)
# Memory-inefficient: loads all frames
frames = list(emu.video_frames) # Don't do this!
Memory Access Performance
wram = emu.get_memory(MemoryRegion.SYSTEM_RAM)
# FASTEST: Direct NumPy array access
value = wram.data[0x100]
# FAST: Batch operations
values = wram.data[0x100:0x200]
# SLOWER: Helper methods (bounds checking overhead)
value = wram.read_byte(0x100)
Testing
Run the test suite:
# Install test dependencies
pip install pytest pillow
# Run all tests
pytest tests/
# Run with coverage
pytest tests/ --cov=pdretro
Test requirements:
- SNES9x core:
snes9x_libretro.dllincores/ - F-Zero ROM:
f-zero.sfcinroms/
Examples
Save Screenshots
from pdretro import Emulator
from PIL import Image
import time
with Emulator("cores/snes9x_libretro.so") as emu:
emu.load_game("roms/game.sfc")
time.sleep(5) # Skip black start screen
# Save screenshot
video = emu.get_video_frame()
rgb = video.to_rgb()
Image.fromarray(rgb, 'RGB').save('screenshot.png')
Record Video
from pdretro import Emulator
import cv2
import numpy as np
import time
with Emulator("cores/snes9x_libretro.so") as emu:
emu.load_game("roms/game.sfc")
# Setup video writer
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
out = cv2.VideoWriter(
'output.mp4',
fourcc,
emu.av_info.fps,
(emu.av_info.base_width, emu.av_info.base_height)
)
# Record 300 frames (10 seconds at 30fps)
for i, video in enumerate(emu.video_frames):
if i >= 300:
break
rgb = video.to_rgb()
bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
out.write(bgr)
time.sleep(1/30)
out.release()
AI Training Loop
from pdretro import Emulator, MemoryRegion, RetroButton
import numpy as np
def train_agent():
with Emulator("cores/snes9x_libretro.so") as emu:
emu.load_game("roms/game.sfc")
# Get memory access
wram = emu.get_memory(MemoryRegion.SYSTEM_RAM)
for episode in range(1000):
# Reset game
emu.reset()
for frame_num in range(1000):
# Get current state from memory
state = {
'x_pos': wram.read_uint16(0x94),
'y_pos': wram.read_uint16(0x96),
'lives': wram.read_byte(0x0DBE),
'coins': wram.read_byte(0x0DBF)
}
# Agent decides action
action = agent.get_action(state)
# Apply action
emu.clear_input()
if action == 0: # Jump
emu.set_button(0, RetroButton.A, True)
elif action == 1: # Right
emu.set_button(0, RetroButton.RIGHT, True)
# Step emulation
emu.step()
# Get reward and train
reward = compute_reward(state)
agent.train(state, action, reward)
Game State Extraction
from pdretro import Emulator, MemoryRegion
def get_mario_state(emu):
"""Extract Mario's complete game state"""
wram = emu.get_memory(MemoryRegion.SYSTEM_RAM)
return {
'x_position': wram.read_uint16(0x94),
'y_position': wram.read_uint16(0x96),
'x_speed': wram.read_uint16(0x7B),
'y_speed': wram.read_uint16(0x7D),
'power_up': wram.read_byte(0x19),
'coins': wram.read_byte(0x0DBF),
'lives': wram.read_byte(0x0DBE),
'score': wram.read_uint32(0x0F34) & 0xFFFFFF,
'level': wram.read_byte(0x13BF),
}
with Emulator("cores/snes9x_libretro.so") as emu:
emu.load_game("roms/super_mario_world.sfc")
for i, video in enumerate(emu.video_frames):
if i >= 300:
break
state = get_mario_state(emu)
print(f"Frame {i}: Mario at ({state['x_position']}, {state['y_position']})")
Memory Cheats
from pdretro import Emulator, MemoryRegion
with Emulator("cores/snes9x_libretro.so") as emu:
emu.load_game("roms/super_mario_world.sfc")
# Get WRAM access
wram = emu.get_memory(MemoryRegion.SYSTEM_RAM)
# Wait for game to start
for _ in range(100):
emu.step()
# Apply cheats
wram.write_byte(0x0DBE, 99) # Infinite lives
wram.write_byte(0x0DBF, 99) # Max coins
wram.write_byte(0x19, 3) # Fire flower power-up
# Continue playing with cheats
for video in emu.video_frames:
pass
Audio Analysis
from pdretro import Emulator
import numpy as np
import matplotlib.pyplot as plt
with Emulator("cores/snes9x_libretro.so") as emu:
emu.load_game("roms/game.sfc")
# Collect audio samples
audio_buffer = []
for i, audio in enumerate(emu.audio_frames):
if i >= 60: # 2 seconds at 30fps
break
audio_buffer.append(audio.data)
# Concatenate all audio
full_audio = np.concatenate(audio_buffer, axis=0)
# Analyze
left_channel = full_audio[:, 0]
right_channel = full_audio[:, 1]
# Plot waveform
plt.plot(left_channel[:1000])
plt.title('Audio Waveform')
plt.show()
Memory Search and Reverse Engineering
from pdretro import Emulator, MemoryRegion
with Emulator("cores/snes9x_libretro.so") as emu:
emu.load_game("roms/game.sfc")
wram = emu.get_memory(MemoryRegion.SYSTEM_RAM)
# Take snapshot of initial state
snapshot = wram.data.copy()
# Play for a bit (collect coins, etc)
for _ in range(300):
emu.step()
# Find what changed
changes = np.where(snapshot != wram.data)[0]
print(f"Found {len(changes)} changed bytes:")
for addr in changes[:10]: # Show first 10
print(f" 0x{addr:04X}: {snapshot[addr]} -> {wram.data[addr]}")
# Search for specific patterns
matches = wram.search(b'\xFF\xFF')
print(f"Found 0xFFFF at addresses: {[hex(a) for a in matches[:5]]}")
Troubleshooting
Core Loading Issues
# Error: "Failed to load core"
# Check core path and architecture (32/64-bit)
# Linux: verify with
file cores/snes9x_libretro.so
ldd cores/snes9x_libretro.so # Check dependencies
# Windows: use Dependency Walker
ROM Loading Failures
# Error: "Failed to load game"
# Verify ROM format matches core's valid_extensions
with Emulator("cores/snes9x_libretro.so") as emu:
print(emu.system_info.valid_extensions)
# ['sfc', 'smc', 'swc', 'fig', 'bs', 'st']
Memory Access Issues
# Error: "Memory region not available"
# Not all cores support all memory regions
from pdretro import MemoryRegion
try:
vram = emu.get_memory(MemoryRegion.VIDEO_RAM)
except RuntimeError:
print("VRAM not available for this core")
# Check which regions are available
for region in MemoryRegion:
try:
mem = emu.get_memory(region)
print(f"✓ {mem.name}: {mem.size} bytes")
except RuntimeError:
print(f"✗ {region.name}: Not available")
Performance Issues
Slow frame processing:
- Use NumPy vectorized operations
- For memory access, use direct array indexing:
wram.data[addr] - Avoid list comprehensions on large arrays
- Profile with
cProfileto find bottlenecks
Memory leaks:
- Always use context managers or call
shutdown() - Don't hold references to
VideoFrameorAudioFrameobjects - Clear input state after use
Platform-Specific Notes
Windows:
- Use
.dllcores from RetroArch buildbot - Ensure Visual C++ Redistributable is installed
Linux:
- Use
.socores - May need to install
libgomp1for some cores
macOS:
- Use
.dylibcores - May need to allow core in Security & Privacy settings
Project Structure
pdretro/
├── src/
│ ├── pdretro/
│ │ ├── __init__.py
│ │ └── emulator.py # High-level Python API
│ └── wrapper/
│ ├── ra_wrapper.c # Core wrapper implementation
│ ├── ra_wrapper.h # C API header
│ └── ra_wrapper_python.c # CPython extension
├── tests/
│ └── test.py # Test suite
├── setup.py # Build configuration
├── pyproject.toml
└── README.md
License
MIT License - Copyright (c) 2026
See LICENSE for full text.
Links
- PyPI: https://pypi.org/project/pdretro/
- GitHub: https://github.com/ColinThePanda/pdretro
- Issues: https://github.com/ColinThePanda/pdretro/issues
- Libretro: https://www.libretro.com/
Acknowledgments
Built on the libretro API - a simple but powerful emulation framework enabling cross-platform emulator development
Release files for pdretro 0.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pdretro-0.2.1.tar.gz | 22.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pdretro-0.2.1-cp313-cp313-win_amd64.whl | CPython 3.13 | CPython 3.13 | Windows x86-64 | Details |
Total release size: 46.5 kB
Release files / pdretro-0.2.1.tar.gz
| Download URL | pdretro-0.2.1.tar.gz |
|---|---|
| Size | 22.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b54136a325199a23ea0817d8d175e56f62201f8ba357a771a18034568c30c3cc
|
|
BLAKE2b-256 checksum How to use checksums |
e448cf963dc26bb22ea6b9cf9d0dd38ad16901e1b3f0719f6ee7b3523045ad1d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.13.3
|
Release files / pdretro-0.2.1-cp313-cp313-win_amd64.whl
| Download URL | pdretro-0.2.1-cp313-cp313-win_amd64.whl |
|---|---|
| Size | 23.8 kB |
| Tags | CPython 3.13 Windows x86-64 |
|
SHA-256 checksum How to use checksums |
91f241c6d0a8869ad718e837fc86796ab3faca942feb843fd65460aee30a3671
|
|
BLAKE2b-256 checksum How to use checksums |
0d282e4d37c7e78cb66539762e30a02a8bed0684171ac481d8f54f6f23e02ea0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.13.3
|