Skip to main content
GameLord uses libretro cores for emulation. Libretro provides a unified API that allows a single frontend to support dozens of emulation cores without system-specific integration.

What is libretro?

Libretro is a simple API that lets emulation cores (“cores”) communicate with frontends (“GameLord”):
  • Cores provide emulation logic: load ROM, run frame, serialize state
  • Frontends provide I/O: video rendering, audio playback, input handling, save management
GameLord loads cores as dynamic libraries (.dylib/.so/.dll) and calls their libretro API functions directly via a native Node addon.
Unlike RetroArch (which runs as a separate process), GameLord loads cores in-process for lower latency and tighter integration.

How cores are loaded

The native addon (gamelord_libretro.node) uses dlopen to load cores at runtime:
1

Core path validation

The main process validates the core path is within the allowed directory:
const corePath = path.join(coresDir, `${coreName}.dylib`);
if (!corePath.startsWith(coresDir)) {
  throw new Error('Invalid core path');
}
2

Worker initialization

The worker process receives the validated core path:
worker.postMessage({
  action: 'init',
  corePath: '/path/to/cores/fceumm_libretro.dylib',
  romPath: '/path/to/rom.nes',
  // ...
});
3

Dynamic loading

The native addon opens the library:
dl_handle_ = dlopen(corePath, RTLD_LAZY);
if (!dl_handle_) {
  // Handle error
}
4

Function resolution

All libretro API functions are resolved via dlsym:
fn_init_ = (retro_init_t)dlsym(dl_handle_, "retro_init");
fn_load_game_ = (retro_load_game_t)dlsym(dl_handle_, "retro_load_game");
// ... 20+ function pointers
5

Core initialization

The core is initialized and callbacks are registered:
fn_set_environment_(EnvironmentCallback);
fn_set_video_refresh_(VideoRefreshCallback);
fn_set_audio_sample_batch_(AudioSampleBatchCallback);
fn_set_input_poll_(InputPollCallback);
fn_set_input_state_(InputStateCallback);
fn_init_();
6

Game loading

The ROM is loaded:
retro_game_info game_info = {
  .path = romPath,
  .data = romData,
  .size = romSize,
  // ...
};
fn_load_game_(&game_info);

Libretro API implementation

Environment callback

The core queries the frontend for configuration via RETRO_ENVIRONMENT_* commands:
bool LibretroCore::EnvironmentCallback(unsigned cmd, void *data) {
  switch (cmd) {
    case RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY:
      *(const char **)data = s_instance->system_dir_.c_str();
      return true;
    
    case RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY:
      *(const char **)data = s_instance->save_dir_.c_str();
      return true;
    
    case RETRO_ENVIRONMENT_SET_PIXEL_FORMAT:
      retro_pixel_format fmt = *(retro_pixel_format *)data;
      // Validate and store pixel format
      return true;
    
    case RETRO_ENVIRONMENT_GET_VARIABLE:
      // Core options (planned feature)
      return false;
    
    // ... 40+ environment commands
  }
}
  • GET_SYSTEM_DIRECTORY - BIOS files location
  • GET_SAVE_DIRECTORY - SRAM save location
  • SET_PIXEL_FORMAT - XRGB8888 or RGB565
  • SET_INPUT_DESCRIPTORS - Controller button labels
  • GET_LOG_INTERFACE - Logging callbacks
  • GET_PERF_INTERFACE - Performance counters
  • SET_MEMORY_MAPS - Memory regions for achievements
  • SET_GEOMETRY - Video resolution changes
  • SET_SYSTEM_AV_INFO - Timing/aspect ratio changes

Video callback

The core pushes frames via video_refresh callback:
void LibretroCore::VideoRefreshCallback(const void *data, unsigned width,
                                         unsigned height, size_t pitch) {
  if (!data) return; // Dupe frame
  
  auto *instance = s_instance;
  
  // Convert XRGB8888 → RGBA
  const size_t frameSize = width * height * 4;
  instance->video_frame_.resize(frameSize);
  
  const uint8_t *src = (const uint8_t *)data;
  uint8_t *dst = instance->video_frame_.data();
  
  for (unsigned y = 0; y < height; ++y) {
    const uint8_t *row = src + y * pitch;
    for (unsigned x = 0; x < width; ++x) {
      dst[0] = row[2]; // R
      dst[1] = row[1]; // G
      dst[2] = row[0]; // B
      dst[3] = 255;    // A
      row += 4;
      dst += 4;
    }
  }
  
  instance->frame_ready_ = true;
}
Format conversion happens in C++ for performance. JavaScript receives ready-to-render RGBA data.

Audio callback

The core pushes audio samples via batch callback:
size_t LibretroCore::AudioSampleBatchCallback(const int16_t *data, size_t frames) {
  auto *instance = s_instance;
  const size_t samples = frames * 2; // Stereo
  
  // Write to lock-free circular buffer
  size_t writePos = instance->audio_write_pos_;
  const size_t available = AUDIO_BUFFER_SIZE - 
    (writePos - instance->audio_read_pos_);
  
  if (samples > available) {
    // Buffer full, drop samples
    return 0;
  }
  
  // Wraparound-aware memcpy
  const size_t mask = AUDIO_BUFFER_SIZE - 1;
  const size_t idx1 = writePos & mask;
  const size_t count1 = std::min(samples, AUDIO_BUFFER_SIZE - idx1);
  
  memcpy(&instance->audio_buffer_[idx1], data, count1 * sizeof(int16_t));
  
  if (count1 < samples) {
    // Wrapped around
    memcpy(&instance->audio_buffer_[0], data + count1, 
           (samples - count1) * sizeof(int16_t));
  }
  
  instance->audio_write_pos_ = writePos + samples;
  return frames;
}

Input callbacks

The frontend provides input state to the core:
void LibretroCore::InputPollCallback() {
  // No-op: input is polled by the frontend and cached
}

int16_t LibretroCore::InputStateCallback(unsigned port, unsigned device,
                                          unsigned index, unsigned id) {
  auto *instance = s_instance;
  
  if (device == RETRO_DEVICE_JOYPAD) {
    return instance->input_state_[port] & (1 << id) ? 1 : 0;
  }
  
  return 0;
}
Input flows: Renderer → Main → Worker → Native addon → Core

Supported systems

GameLord currently supports 12 systems with auto-download:
SystemPreferred CoreAlt CoresBIOS Required
NESFCEUmmNestopia, MesenNo
SNESSnes9xbsnesNo
GenesisGenesis Plus GXPicoDriveNo
Game BoyGambattemGBANo
Game Boy ColorGambattemGBANo
Game Boy AdvancemGBAVBA-MNo
Nintendo 64Mupen64PlusParaLLEl N64No
Nintendo DSDeSmuME-No
PlayStationPCSX ReARMedBeetle PSXYes
PSPPPSSPP-No
Sega SaturnBeetle SaturnYabauseYes
Arcade (MAME)MAME-Varies
More systems are planned. See #116 for the expansion roadmap (PS2, GameCube, Dreamcast, etc.).

Core selection

Cores are mapped in CoreDownloader.ts:
const PREFERRED_CORES: Record<string, string> = {
  'nes': 'fceumm_libretro',
  'snes': 'snes9x_libretro',
  'genesis': 'genesis_plus_gx_libretro',
  'gba': 'mgba_libretro',
  'saturn': 'mednafen_saturn_libretro',
  // ...
};

const SYSTEM_CORES: Record<string, string[]> = {
  'nes': ['fceumm_libretro', 'nestopia_libretro', 'mesen_libretro'],
  'snes': ['snes9x_libretro', 'bsnes_libretro'],
  // ...
};
Users can switch cores via the Settings UI (planned feature).

Adding a new system

1

Add system definition

Edit apps/desktop/src/types/library.ts:
export const DEFAULT_SYSTEMS: GameSystem[] = [
  // ... existing systems
  {
    id: 'dreamcast',
    name: 'Sega Dreamcast',
    shortName: 'Dreamcast',
    manufacturer: 'Sega',
    extensions: ['.cdi', '.chd', '.gdi', '.zip'],
    requiresBios: true,
    biosFiles: ['dc_boot.bin', 'dc_flash.bin'],
  },
];
2

Add core mapping

Edit CoreDownloader.ts:
const PREFERRED_CORES: Record<string, string> = {
  // ...
  'dreamcast': 'flycast_libretro',
};

const SYSTEM_CORES: Record<string, string[]> = {
  // ...
  'dreamcast': ['flycast_libretro', 'redream_libretro'],
};

const CORE_DISPLAY_NAMES: Record<string, string> = {
  // ...
  'flycast_libretro': 'Flycast',
  'redream_libretro': 'Redream',
};
3

Add ScreenScraper system ID

Edit ArtworkService.ts:
const SYSTEM_ID_MAP: Record<string, number> = {
  // ...
  'dreamcast': 23, // ScreenScraper system ID
};
4

Test core loading

# Download core manually
curl -o ~/Library/Application\ Support/GameLord/cores/flycast_libretro.dylib \
  https://buildbot.libretro.com/nightly/apple/osx/arm64/latest/flycast_libretro.dylib.zip

# Extract and test
unzip flycast_libretro.dylib.zip
# Launch GameLord and try loading a .cdi ROM
5

Add BIOS documentation

If the system requires BIOS files, document them in this guide and in the app’s UI.
Always test with real games before committing. Some cores have quirks (e.g., Mesen doesn’t load via the addon but works standalone - see Known Issues).

Adding a new core for existing system

To add an alternative core (e.g., bsnes for SNES):
1

Update core lists

// Add to SYSTEM_CORES
'snes': ['snes9x_libretro', 'bsnes_libretro'],

// Add display name
const CORE_DISPLAY_NAMES: Record<string, string> = {
  'bsnes_libretro': 'bsnes',
};

// Add description
const CORE_DESCRIPTIONS: Record<string, string> = {
  'bsnes_libretro': 'Cycle-accurate SNES emulation. Perfect accuracy, higher CPU usage.',
};
2

Download and test

cd ~/Library/Application\ Support/GameLord/cores/
curl -O https://buildbot.libretro.com/nightly/apple/osx/arm64/latest/bsnes_libretro.dylib.zip
unzip bsnes_libretro.dylib.zip
3

Add UI for core selection

The core switcher UI is planned but not yet implemented. See #91.

BIOS requirements

Sega Saturn

Required files in ~/Library/Application Support/GameLord/BIOS/:
  • sega_101.bin (Japan BIOS, 512 KB)
  • mpr-17933.bin (US/Europe BIOS, 512 KB)
At least one BIOS is required. The core auto-selects based on game region.
BIOS files are copyrighted and cannot be legally distributed. You must dump them from your own hardware.

PlayStation

Required files in ~/Library/Application Support/GameLord/BIOS/:
  • scph5500.bin (Japan, 512 KB)
  • scph5501.bin (US, 512 KB)
  • scph5502.bin (Europe, 512 KB)
At least one region BIOS is required.

Verifying BIOS files

Use checksums to verify BIOS integrity:
md5sum ~/Library/Application\ Support/GameLord/BIOS/sega_101.bin
# Expected: 85ec9ca47d8f6807718151cbcca8b964

md5sum ~/Library/Application\ Support/GameLord/BIOS/mpr-17933.bin
# Expected: 3240872c70984b6cbfda1586cab68dbe

Core buildbot URLs

Libretro maintains nightly builds at buildbot.libretro.com:
PlatformURL
macOS ARM64https://buildbot.libretro.com/nightly/apple/osx/arm64/latest/
macOS x86_64https://buildbot.libretro.com/nightly/apple/osx/x86_64/latest/
Linux x86_64https://buildbot.libretro.com/nightly/linux/x86_64/latest/
Windows x86_64https://buildbot.libretro.com/nightly/windows/x86_64/latest/
GameLord currently only supports macOS ARM64. Cross-platform support is tracked in #118.

Troubleshooting

Check the core file is present and not corrupted:
ls -lh ~/Library/Application\ Support/GameLord/cores/
file ~/Library/Application\ Support/GameLord/cores/fceumm_libretro.dylib
On macOS, cores may be quarantined by Gatekeeper:
xattr -d com.apple.quarantine ~/Library/Application\ Support/GameLord/cores/*.dylib
Check logs for environment callback errors:
tail -f ~/Library/Logs/GameLord/main.log | grep -i error
Common issues:
  • Missing BIOS files
  • Unsupported ROM format (e.g., .cue without matching .bin)
  • Corrupted ROM file
The audio buffer may be too small. Increase AUDIO_BUFFER_SIZE in libretro_core.cc and rebuild:
static const size_t AUDIO_BUFFER_SIZE = 32768; // Default: 16384
Check system load:
# macOS
sudo powermetrics --samplers cpu_power -i 1000 -n 1

# Linux
htop
High-accuracy cores (bsnes, Beetle PSX) require more CPU. Switch to performance cores (Snes9x, PCSX ReARMed).

Known issues

  • Mesen core fails to load games via the native addon (works in standalone C test). Use fceumm instead.
  • Singleton constraint: Only one core instance can be active at a time. See #68.

Architecture

Deep dive into native addon implementation and callbacks

Building

Build the native addon and test cores locally

Build docs developers (and LLMs) love