Skip to main content

Python wrapper for libretro cores

Project description

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

Python License

InstallationQuick StartUsageDocumentation


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/.dylib files)

Optional:

  • Pillow (for image output in examples)
  • pytest (for running tests)

Obtaining Libretro Cores

Download precompiled cores from:

Common cores:

  • snes9x_libretro - SNES emulation
  • genesis_plus_gx_libretro - Genesis/Mega Drive
  • gambatte_libretro - Game Boy / Game Boy Color
  • mgba_libretro - Game Boy Advance
  • nestopia_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.dll in cores/
  • F-Zero ROM: f-zero.sfc in roms/

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 cProfile to find bottlenecks

Memory leaks:

  • Always use context managers or call shutdown()
  • Don't hold references to VideoFrame or AudioFrame objects
  • Clear input state after use

Platform-Specific Notes

Windows:

  • Use .dll cores from RetroArch buildbot
  • Ensure Visual C++ Redistributable is installed

Linux:

  • Use .so cores
  • May need to install libgomp1 for some cores

macOS:

  • Use .dylib cores
  • 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

Acknowledgments

Built on the libretro API - a simple but powerful emulation framework enabling cross-platform emulator development

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pdretro-0.2.1.tar.gz (22.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pdretro-0.2.1-cp313-cp313-win_amd64.whl (23.8 kB view details)

Uploaded CPython 3.13Windows x86-64

File details

Details for the file pdretro-0.2.1.tar.gz.

File metadata

  • Download URL: pdretro-0.2.1.tar.gz
  • Upload date:
  • Size: 22.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.3

File hashes

Hashes for pdretro-0.2.1.tar.gz
Algorithm Hash digest
SHA256 b54136a325199a23ea0817d8d175e56f62201f8ba357a771a18034568c30c3cc
MD5 e241b87dc5a56dd31eed345e48c3a898
BLAKE2b-256 e448cf963dc26bb22ea6b9cf9d0dd38ad16901e1b3f0719f6ee7b3523045ad1d

See more details on using hashes here.

File details

Details for the file pdretro-0.2.1-cp313-cp313-win_amd64.whl.

File metadata

  • Download URL: pdretro-0.2.1-cp313-cp313-win_amd64.whl
  • Upload date:
  • Size: 23.8 kB
  • Tags: CPython 3.13, Windows x86-64
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.3

File hashes

Hashes for pdretro-0.2.1-cp313-cp313-win_amd64.whl
Algorithm Hash digest
SHA256 91f241c6d0a8869ad718e837fc86796ab3faca942feb843fd65460aee30a3671
MD5 728d2229826a3e8efb74203598a9f591
BLAKE2b-256 0d282e4d37c7e78cb66539762e30a02a8bed0684171ac481d8f54f6f23e02ea0

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page