Most people who set up a Pokemon emulator PC rig end up with three emulators, three shortcuts and no memory of which one opens which game. The official mGBA site lists version 0.10.5 as its newest release, a bugfix build for Game Boy, Game Boy Color and Game Boy Advance titles. It knows nothing about the DS cartridge or the 3DS dump sitting in the next folder, and that gap is where the shortcut pile starts.
This tutorial fixes it with one small Python script. You type play emerald or play heartgold, and the script picks mGBA, melonDS or Azahar from the file extension, backs up your save, launches the game and logs how long you played. If you want a side-by-side look at the emulators themselves first, the mGBA, WatermelonDS and Azahar comparison covers the trade-offs. Here the goal is a working project you finish in about 50 minutes.
You will end up with a launcher of roughly 235 lines, a TOML config, a ROM library index, rolling save backups, a playtime report and desktop shortcuts for Windows and Linux. Every step shows the command, the expected output and the failure you are most likely to hit. One rule applies throughout: use ROMs you dumped yourself from cartridges you own. The script never downloads, patches or shares game files.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Why One Launcher Beats Three Separate Emulator Shortcuts
A Pokemon emulator PC setup spans at least three programs once you go past the Game Boy Advance era. Generations one through three run in mGBA. Generations four and five run in melonDS. Generations six and seven need a 3DS emulator, and Azahar fills that role as the continuation of the Citra line after Citra stopped. Each program has its own window, its own settings and its own idea of where saves live.
That spread causes real losses. Players overwrite a save while testing a new build, rename a ROM file and orphan the .sav beside it, or reinstall an emulator and lose a 3DS data folder. A launcher cannot stop every mistake, but it can make two habits automatic: snapshot the save before the game opens and snapshot it again after the game closes. Those two copies are the cheapest insurance in emulation.
The launcher also gives you one place to answer boring questions. Which emulator build do you actually run? Where are the ROMs? How many hours have you put into Platinum? If you already followed a single-system guide such as setting up melonDS for PokÃĐmon, the launcher sits on top of that work and changes nothing inside the emulator.
What the script does and does not do
- Does: index ROMs by reading their headers, route each file to the right emulator, snapshot saves with timestamped names, prune old snapshots, log session length and report totals.
- Does not: download games, supply BIOS or firmware files, patch ROM hacks, or configure graphics and controls. Those settings stay inside each emulator.
- Design limit: it launches one game at a time and waits for the emulator to close. That wait is what makes the after-backup and playtime log work.
Prerequisites and Versions for This Pokemon Emulator PC Setup
Pin the versions before you start, because emulator projects move quickly and a development build can change save handling. The table lists what this tutorial was written against on October 24, 2026. Where a project has both a stable line and a testing line, pick the stable one for a library you care about.
| Component | Version used | Why this one |
|---|---|---|
| Python | Not listed | The script reads TOML with the standard library tomllib module, which arrived in Python 3.11 |
| mGBA | 0.10.5 stable | Newest release on the official page. The 0.11 series is still development builds |
| melonDS | 1.1 | Reported as the newest standalone desktop release. Check the official melonDS site for anything newer |
| Azahar | 2126.2 stable | Current stable line. The 2127.0 builds are alphas for testing |
| Operating system | Windows 11 or a current Linux distribution | Paths and shortcuts for both appear in Step 13 |
| Editor | Any plain text editor | The config is a text file, so VS Code, Notepad++ or nano all work |
| Your ROMs | Dumps from cartridges you own | The script indexes files, it never fetches them |
A few notes on that table. The mGBA project says 0.10.5 is a bugfix release and may be the last release to support older Windows and macOS versions, so check the official downloads page if you are on an old machine. A maintainer on the mGBA issue tracker referred to a 0.11 development build in mid-October 2026, which tells you the next major version exists but has not shipped as stable. If you want the full walkthrough of the emulator itself, the mGBA setup guide for PokÃĐmon games goes deeper on video, audio and input.
On melonDS, treat version claims with care. The project name is shared by several things. There is the standalone desktop app, a separate RetroArch core named melonDS DS that was reported at version 1.4.0 in late September 2026, and community forks such as WatermelonDS and Project PM. They are not interchangeable. Download the desktop build from the official site, and do not assume a version number from a forum post matches the app you installed.
Azahar gets one extra warning. The project published a security advisory for a guest-to-host code execution flaw tied to installing files, and according to that advisory the affected versions run up to 2126.1.2, with 2126.2 listed as the patched release. If you installed Azahar months ago and never updated, fix that before you load a single dump.
Which Emulator Runs Which Pokemon Generation
The launcher routes by file extension, so it helps to see the full mapping before you write it. The table below is the routing logic in plain form. It also shows where each emulator usually keeps its save, which matters for the backup steps later.
| Generation and games | Console | Emulator | ROM extensions | Typical save location |
|---|---|---|---|---|
| Gen 1-2: Red, Blue, Yellow, Gold, Silver, Crystal | Game Boy / Game Boy Color | mGBA | .gb, .gbc | .sav beside the ROM by default |
| Gen 3: Ruby, Sapphire, Emerald, FireRed, LeafGreen | Game Boy Advance | mGBA | .gba | .sav beside the ROM by default |
| Gen 4-5: Diamond, Pearl, Platinum, HeartGold, SoulSilver, Black, White | Nintendo DS | melonDS | .nds, .dsi | .sav beside the ROM by default |
| Gen 6-7: X, Y, Omega Ruby, Alpha Sapphire, Sun, Moon | Nintendo 3DS | Azahar | .3ds, .cci, .cxi, .3dsx | Azahar user data folder (set save_dir) |
| Gen 8-9 on Switch | Nintendo Switch | Not covered here | Extend the config yourself | Depends on the Switch emulator |
Generation three is where ROM hacks live, and many players run those through mGBA. A hack such as PokÃĐmon Radical Red needs a patched ROM, and the launcher treats that patched file like any other .gba. Keep the hack in its own subfolder so the index shows both the base game and the hack. The header reader will label a patched FireRed as FireRed because the game code inside the file still says so, and the filename tells you which one it is.
Azahar only emulates Nintendo 3DS software. It does not run DS cartridges and it does not run Switch games, which is why the table keeps melonDS for generations four and five. For the 3DS side of this build, the Azahar setup guide for PokÃĐmon covers encryption keys, graphics options and cheat codes. The Azahar project site is the place to download builds, and the 2126.2 stable build is the one this tutorial assumes.
The Switch row stays empty on purpose. Switch emulation has a different legal and technical picture, and cramming it into this script would double the length. The config format leaves room for it, and the advanced tips section shows how to add one more system with five lines.
Steps 1-3: Set Up the Folders and Install the Three Emulators
The first three steps cost about 15 minutes and involve no code. You create a clean folder tree, install each emulator into its own directory and drop your ROMs into per-system folders. A tidy tree now saves you from path bugs in every later step.
Step 1: Create the project folder and check Python
Pick a location without spaces if you can. On Windows that means something like C:\Pokemon, and on Linux something like ~/pokemon. Open a terminal there and confirm your Python install is recent enough to include the tomllib module, so check first.
# Windows PowerShell
mkdir C:\Pokemon\launcher, C:\Pokemon\roms\gba, C:\Pokemon\roms\nds, C:\Pokemon\roms\3ds, C:\Pokemon\backups, C:\Pokemon\emulators
cd C:\Pokemon\launcher
python --version
# Linux / macOS shell
mkdir -p ~/pokemon/{launcher,backups,emulators,roms/{gba,nds,3ds}}
cd ~/pokemon/launcher
python3 --version
You should see Python 3.12 or higher. If the Windows command opens the Microsoft Store instead, install Python from the official installer and tick the option that adds it to PATH, then open a fresh terminal.
Step 2: Install mGBA, melonDS and Azahar in separate folders
Download each emulator from its own official site and extract it into a dedicated subfolder of emulators. Do not merge them into one directory. Keeping them apart means a failed update to one never touches the others, and it lets you keep a second copy of any emulator for testing.
- mGBA 0.10.5: extract to
emulators/mgba. On Windows the Qt executable ismGBA.exe, and Linux builds ship amgba-qtbinary or an AppImage. - melonDS 1.1: extract to
emulators/melonds. The executable ismelonDS.exeon Windows. - Azahar 2126.2: extract to
emulators/azahar. Check the extracted folder for the exact executable name, since release packaging has changed between versions. The tgames Azahar installation walkthrough shows the first-run dialogs if you want screenshots.
Launch each emulator once by hand and close it. That first run creates the settings files and user folders the later steps depend on. For melonDS, finish any firmware or BIOS prompts now, because the launcher assumes the emulator already starts a game cleanly on its own.
Step 3: Sort your dumped ROMs by system
Copy your own dumps into the matching folder: GBA files into roms/gba, DS files into roms/nds and 3DS files into roms/3ds. The folder names are only for your eyes, since the script routes by extension, but matching them avoids confusion. Subfolders are fine. The scanner walks the whole tree.
pokemon/
launcher/ <- the script and config live here
emulators/
mgba/ melonds/ azahar/
roms/
gba/ Emerald.gba FireRed.gba
nds/ HeartGold.nds Platinum.nds
3ds/ Moon.3ds
backups/ <- created and filled by the script
Use short, clean filenames, because you will type part of the name on the command line. Emerald.gba beats Pokemon - Emerald Version (USA, Europe).gba every time. One more habit worth forming here: do not rename a ROM after you have started a save. Both mGBA and melonDS pair the .sav file to the ROM by filename, so a rename silently starts a fresh save.
Steps 4-5: Write the Config That Maps File Types to Emulators
The config file is the only thing you edit when paths change. Keeping it separate from the code means you can update the script without touching your settings, and you can copy the folder to another PC and fix three paths instead of reading source.
Step 4: Create config.toml
Create config.toml inside the launcher folder. TOML treats backslashes in double-quoted strings as escape characters, so Windows paths need either forward slashes or single-quoted literal strings. The example uses single quotes for Windows paths, which is the safest habit.
[paths]
roms = 'C:\Pokemon\roms'
backups = 'C:\Pokemon\backups'
keep = 20 # snapshots kept per game
[emulators.gba]
command = ['C:\Pokemon\emulators\mgba\mGBA.exe']
extensions = ['.gba', '.gb', '.gbc']
[emulators.nds]
command = ['C:\Pokemon\emulators\melonds\melonDS.exe']
extensions = ['.nds', '.dsi']
[emulators.3ds]
command = ['C:\Pokemon\emulators\azahar\azahar.exe']
extensions = ['.3ds', '.cci', '.cxi', '.3dsx']
save_dir = 'C:\Users\YOU\AppData\Roaming\Azahar\sdmc'
# save_dir is the folder you want snapshotted for 3DS games.
# Open Azahar and use its File menu to find the real user folder.
Three details deserve attention. First, command is a list, not a string. That lets you point at an AppImage, a Flatpak wrapper or an executable with arguments, without any shell quoting. Second, keep = 20 caps snapshots per game so a long playthrough does not eat your disk. Third, save_dir exists only for 3DS because Azahar stores game data in a user folder rather than beside the ROM. The path above is an example. Your real location depends on your platform and on whether you run Azahar in portable mode, so confirm it from inside the emulator.
Step 5: Create the script skeleton and config loader
Create pokelauncher.py next to the config and start with the imports, the file locations and the loader. Everything here comes from the standard library, so there is nothing to install with pip.
#!/usr/bin/env python3
import argparse, hashlib, json, re, shutil, subprocess, sys, time, tomllib
from datetime import datetime
from pathlib import Path
HERE = Path(__file__).resolve().parent
CONFIG = HERE / "config.toml"
INDEX = HERE / "library.json"
SESSIONS = HERE / "sessions.jsonl"
def load_config():
if not CONFIG.exists():
sys.exit(f"Missing {CONFIG}. Create config.toml first.")
with CONFIG.open("rb") as fh:
return tomllib.load(fh)
The script resolves its own location with __file__, so the config, the index and the session log always sit beside it, whatever directory you launch from. That detail matters in Step 13, when shortcuts start the script from the desktop. Run python pokelauncher.py now. You should get an argparse usage error, which proves Python can read the file. The real commands arrive in the next steps.
Steps 6-7: Read ROM Headers and Build the Library Index
A launcher that only knows filenames is brittle. Game Boy Advance and Nintendo DS ROMs carry a title and a four-letter game code in fixed header positions, and reading those bytes lets the script label files correctly even when the filename says nothing useful. It also surfaces the region, which matters when you keep a USA and a European copy of the same game.
Step 6: Add the header reader
On a GBA ROM the 12-byte title starts at offset 0xA0 and the four-byte game code follows at 0xAC. On a DS ROM the title sits at the very start of the file and the game code at 0x0C. The first three characters of the code identify the game, and the fourth identifies the region. The script keeps a small lookup table of Pokemon games and falls back to the header title for anything else.
KNOWN = {
"AXV": "Pokemon Ruby", "AXP": "Pokemon Sapphire", "BPE": "Pokemon Emerald",
"BPR": "Pokemon FireRed", "BPG": "Pokemon LeafGreen",
"ADA": "Pokemon Diamond", "APA": "Pokemon Pearl", "CPU": "Pokemon Platinum",
"IPK": "Pokemon HeartGold", "IPG": "Pokemon SoulSilver",
"IRB": "Pokemon Black", "IRA": "Pokemon White",
}
def describe(path):
ext = path.suffix.lower()
with path.open("rb") as fh:
head = fh.read(0x200)
if ext == ".gba" and len(head) >= 0xB0:
title, code = clean(head[0xA0:0xAC]), clean(head[0xAC:0xB0])
elif ext in (".nds", ".dsi") and len(head) >= 0x10:
title, code = clean(head[0x00:0x0C]), clean(head[0x0C:0x10])
else:
return path.stem, ""
name = KNOWN.get(code[:3], title.title() or path.stem)
return name, REGIONS.get(code[3:4], code[3:4])
The 3DS formats use a different container, so the script does not parse them. A 3DS file keeps its filename as its title, which is another reason to name those files well. The lookup table is deliberately short. Add a line whenever you find a game that shows up under its raw header title and you would rather see a friendlier name.
Step 7: Scan the folders and write library.json
The scanner walks the ROM tree, asks the config which emulator owns each extension and stores one record per game. Writing the result to library.json means list and play start instantly instead of rescanning a big folder every time.
def scan(cfg):
roms = Path(cfg["paths"]["roms"]).expanduser()
entries = []
for p in sorted(roms.rglob("*")):
system = emulator_for(cfg, p) if p.is_file() else None
if not system:
continue
title, region = describe(p)
entries.append({"title": title, "region": region, "system": system,
"rom": str(p), "mb": round(p.stat().st_size / 1048576, 1)})
INDEX.write_text(json.dumps(entries, indent=2))
return entries
Rescan whenever you add a game. The command is cheap, and it prints how many ROMs it found, which is also your first sanity check that the extensions in the config match your files. A count of zero almost always means a wrong roms path or an extension typo.
Steps 8-10: Route Launches and Back Up Saves Around Every Session
This section holds the core of the project. The launcher resolves a name to one game, snapshots the save, starts the emulator, waits for it to exit and snapshots the save again. Snapshotting around every session sounds excessive until the day a core crash or a mistaken overwrite corrupts a save you care about.
Step 8: Match a name to exactly one game
The find function prefers an exact filename match, then falls back to a substring search across titles and filenames. If more than one game matches, it prints the candidates and stops. Guessing wrong and launching the wrong save is worse than asking you to type three more letters.
def find(entries, query):
q = query.lower()
exact = [e for e in entries if Path(e["rom"]).stem.lower() == q]
hits = exact or [e for e in entries
if q in e["title"].lower() or q in Path(e["rom"]).stem.lower()]
if len(hits) == 1:
return hits[0]
if not hits:
sys.exit(f"No match for '{query}'. Run 'list' to see the library.")
names = "\n ".join(f"{e['title']} ({e['region']}) -> {Path(e['rom']).name}" for e in hits)
sys.exit(f"'{query}' matches {len(hits)} games, be more specific:\n {names}")
Step 9: Snapshot saves with hashes and rotation
Each backup file is named with a timestamp, a tag (before, after or manual) and the first 12 characters of the save’s SHA-256 hash. The hash does two jobs. It lets the script skip a backup when the save has not changed since an earlier snapshot, and it lets you spot at a glance which snapshots hold identical data. Rotation then deletes the oldest files beyond the keep limit.
def fingerprint(path):
h = hashlib.sha256()
files = [path] if path.is_file() else sorted(f for f in path.rglob("*") if f.is_file())
for f in files:
h.update(f.name.encode())
with f.open("rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def backup(cfg, label, src, tag):
dest = Path(cfg["paths"]["backups"]).expanduser() / safe(label)
dest.mkdir(parents=True, exist_ok=True)
digest = fingerprint(src)[:12]
if any(digest in p.name for p in dest.iterdir()):
return None
stem = f"{datetime.now():%Y%m%d-%H%M%S}-{tag}-{digest}"
if src.is_file():
out = dest / f"{stem}{src.suffix}"
shutil.copy2(src, out)
else:
out = Path(shutil.make_archive(str(dest / stem), "zip", src))
keep = int(cfg["paths"].get("keep", 20))
for old in sorted(dest.iterdir())[:-keep]:
old.unlink()
return out
Because the timestamp leads the filename, a plain alphabetical sort is also a chronological sort, and that is how pruning finds the oldest snapshot. Players running a hard challenge will like this behavior. A PokÃĐmon Nuzlocke on an emulator depends on never losing the run state, and 20 rolling snapshots give you a rewind point after a misclick that save states alone might not.
Trading between two save profiles works the same way. If you follow the melonDS multiplayer trading setup, give each profile its own ROM copy with a distinct filename. The launcher labels backups by filename, so each profile gets its own snapshot folder and neither overwrites the other.
Step 10: Handle the 3DS save folder and launch the emulator
GBA and DS saves are single files, so the script looks for a .sav next to the ROM. 3DS data is a folder tree, so the script archives the whole save_dir as a zip. The helper below picks the right target, and the play function strings everything together.
def save_targets(emu, rom):
if emu.get("save_dir"):
folder = Path(emu["save_dir"]).expanduser()
return [folder] if folder.exists() else []
sav = rom.with_suffix(".sav")
return [sav] if sav.exists() else []
def play(cfg, entry):
emu = cfg["emulators"][entry["system"]]
rom = Path(entry["rom"])
for t in save_targets(emu, rom):
backup(cfg, rom.stem, t, "before")
cmd = [*emu["command"], *emu.get("args", []), str(rom)]
start = time.monotonic()
code = subprocess.run(cmd).returncode # blocks until the emulator closes
minutes = (time.monotonic() - start) / 60
for t in save_targets(emu, rom):
backup(cfg, rom.stem, t, "after")
The call to subprocess.run blocks until the emulator window closes, and that single behavior powers the after-backup and the timer. It also explains the most common surprise in this project: any wrapper that starts the emulator and exits immediately makes the script think the session lasted zero seconds. The full version below prints a warning when a session ends in under six seconds, so you notice instead of trusting a false playtime.
Archiving a whole 3DS folder can be heavy if your data directory also holds installed titles. Point save_dir at the narrowest folder that contains saves rather than the whole user directory. In the Citra lineage that Azahar continues, game saves live under the sdmc/Nintendo 3DS tree, but confirm the layout on your own install before you rely on it.
Steps 11-12: Log Playtime and Add a Doctor Command
Two small features finish the tool. The first records every session so you can see where your hours go. The second checks your configuration so path problems show up as one clear message instead of a stack trace halfway through launching a game.
Step 11: Append sessions to a log and print totals
Each session adds one JSON line to sessions.jsonl with the date, the title, the system, the minutes played and the emulator exit code. JSON Lines suits this job because appending never rewrites the file, so a crash cannot corrupt earlier entries. The stats command reads the log and prints hours per game, longest first.
def stats():
totals = {}
for line in SESSIONS.read_text().splitlines():
s = json.loads(line)
totals.setdefault(s["title"], [0, 0.0])
totals[s["title"]][0] += 1
totals[s["title"]][1] += s["minutes"]
for title, (n, mins) in sorted(totals.items(), key=lambda kv: -kv[1][1]):
print(f"{title:<28} {n:>3} sessions {mins / 60:>6.1f} h")
The exit code is worth keeping. A non-zero code on a session that otherwise looks normal is often the first sign of a crashing core or a bad build, and it costs nothing to record. If you test a development build of an emulator, the log lets you compare crash rates between versions over a few weeks.
Step 12: Add the doctor command
The doctor command walks the config and checks that every emulator path exists and that the ROM folder is a real directory. It accepts both absolute paths and bare command names, using shutil.which for the latter, so it works whether you point at an executable or rely on PATH.
def doctor(cfg):
problems = 0
for name, emu in cfg["emulators"].items():
exe = emu["command"][0]
found = Path(exe).expanduser().exists() or shutil.which(exe)
problems += not found
print(f"[{'ok' if found else 'MISSING'}] {name}: {exe}")
roms = Path(cfg["paths"]["roms"]).expanduser()
print(f"[{'ok' if roms.is_dir() else 'MISSING'}] roms: {roms}")
problems += not roms.is_dir()
print("Doctor found no problems." if not problems else f"Doctor found {problems} problem(s).")
Run doctor after every emulator update. Some projects rename their executable between releases, and a three-second check beats discovering it when you sit down to play. If you track emulator news, the weekly roundups from outlets such as Retro Replay’s Week in Emulation flag releases and renames worth knowing about before they break your paths.
Step 13: Add Shortcuts and Run the Full Test Loop
The last step turns the script into the front door of your Pokemon emulator PC library, something you launch with a double-click, and proves the whole chain works. You create one small shortcut file per game, run a full session and read the results.
Create per-game shortcuts
REM Windows: save as "Play Emerald.bat" on the desktop
@echo off
python C:\Pokemon\launcher\pokelauncher.py play emerald
pause
# Linux: save as ~/.local/share/applications/pokemon-emerald.desktop
[Desktop Entry]
Type=Application
Name=Pokemon Emerald
Exec=python3 /home/you/pokemon/launcher/pokelauncher.py play emerald
Terminal=true
The pause line keeps the Windows console open after the emulator closes, so you can read the backup and session lines. Terminal mode does the same on Linux. Once you trust the tool, switch the console off if you prefer a quiet launch.
Schedule a daily backup of everything
Launch-time backups only cover games you open. A scheduled backup run catches saves changed by other tools, such as a save editor or a sync client. The hash check means unchanged saves cost nothing.
REM Windows Task Scheduler, daily at 21:00
schtasks /Create /SC DAILY /TN "PokeBackup" /ST 21:00 /TR "python C:\Pokemon\launcher\pokelauncher.py backup"
# Linux crontab -e
0 21 * * * /usr/bin/python3 /home/you/pokemon/launcher/pokelauncher.py backup
Run the full test loop
Work through this sequence once, in order. Each command should print what the comment says. If one step fails, fix it before moving on, because later steps assume the earlier ones passed.
python pokelauncher.py doctor # all lines [ok]
python pokelauncher.py scan # Indexed N ROM(s)
python pokelauncher.py list # your games, one per line
python pokelauncher.py play emerald # emulator opens, play 2 minutes, save in-game, quit
python pokelauncher.py stats # one session logged
The Complete Working Project
Below is the finished pokelauncher.py. It combines every excerpt from the steps with the argument parser that ties the commands together. Save it beside your config.toml and the folder is a complete project. I ran this exact file against a test library of GBA, DS and 3DS entries using stand-in emulator programs, including the directory-archive path for 3DS data, and the scan, list, play, backup, stats and doctor commands all behaved as described.
#!/usr/bin/env python3
"""pokelauncher: route Pokemon ROMs to mGBA, melonDS or Azahar and protect the saves."""
import argparse
import hashlib
import json
import re
import shutil
import subprocess
import sys
import time
import tomllib
from datetime import datetime
from pathlib import Path
HERE = Path(__file__).resolve().parent
CONFIG = HERE / "config.toml"
INDEX = HERE / "library.json"
SESSIONS = HERE / "sessions.jsonl"
KNOWN = {
"AXV": "Pokemon Ruby", "AXP": "Pokemon Sapphire", "BPE": "Pokemon Emerald",
"BPR": "Pokemon FireRed", "BPG": "Pokemon LeafGreen",
"ADA": "Pokemon Diamond", "APA": "Pokemon Pearl", "CPU": "Pokemon Platinum",
"IPK": "Pokemon HeartGold", "IPG": "Pokemon SoulSilver",
"IRB": "Pokemon Black", "IRA": "Pokemon White",
}
REGIONS = {"E": "USA", "P": "Europe", "J": "Japan", "D": "Germany", "F": "France",
"I": "Italy", "S": "Spain", "K": "Korea", "O": "International"}
def load_config():
if not CONFIG.exists():
sys.exit(f"Missing {CONFIG}. Create config.toml first.")
with CONFIG.open("rb") as fh:
return tomllib.load(fh)
def emulator_for(cfg, path):
ext = path.suffix.lower()
for name, emu in cfg["emulators"].items():
if ext in emu["extensions"]:
return name
return None
def clean(raw):
return raw.split(b"\0")[0].decode("ascii", "replace").strip()
def describe(path):
ext = path.suffix.lower()
try:
with path.open("rb") as fh:
head = fh.read(0x200)
except OSError:
return path.stem, ""
if ext == ".gba" and len(head) >= 0xB0:
title, code = clean(head[0xA0:0xAC]), clean(head[0xAC:0xB0])
elif ext in (".nds", ".dsi") and len(head) >= 0x10:
title, code = clean(head[0x00:0x0C]), clean(head[0x0C:0x10])
else:
return path.stem, ""
name = KNOWN.get(code[:3], title.title() or path.stem)
return name, REGIONS.get(code[3:4], code[3:4])
def scan(cfg):
roms = Path(cfg["paths"]["roms"]).expanduser()
entries = []
for p in sorted(roms.rglob("*")):
system = emulator_for(cfg, p) if p.is_file() else None
if not system:
continue
title, region = describe(p)
entries.append({"title": title, "region": region, "system": system,
"rom": str(p), "mb": round(p.stat().st_size / 1048576, 1)})
INDEX.write_text(json.dumps(entries, indent=2))
return entries
def load_index(cfg):
return json.loads(INDEX.read_text()) if INDEX.exists() else scan(cfg)
def find(entries, query):
q = query.lower()
exact = [e for e in entries if Path(e["rom"]).stem.lower() == q]
hits = exact or [e for e in entries
if q in e["title"].lower() or q in Path(e["rom"]).stem.lower()]
if len(hits) == 1:
return hits[0]
if not hits:
sys.exit(f"No match for '{query}'. Run 'list' to see the library.")
names = "\n ".join(f"{e['title']} ({e['region']}) -> {Path(e['rom']).name}" for e in hits)
sys.exit(f"'{query}' matches {len(hits)} games, be more specific:\n {names}")
def fingerprint(path):
h = hashlib.sha256()
files = [path] if path.is_file() else sorted(f for f in path.rglob("*") if f.is_file())
for f in files:
h.update(f.name.encode())
with f.open("rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def safe(text):
return re.sub(r"[^A-Za-z0-9_.-]+", "_", text)
def backup(cfg, label, src, tag):
dest = Path(cfg["paths"]["backups"]).expanduser() / safe(label)
dest.mkdir(parents=True, exist_ok=True)
digest = fingerprint(src)[:12]
if any(digest in p.name for p in dest.iterdir()):
return None
stem = f"{datetime.now():%Y%m%d-%H%M%S}-{tag}-{digest}"
if src.is_file():
out = dest / f"{stem}{src.suffix}"
shutil.copy2(src, out)
else:
out = Path(shutil.make_archive(str(dest / stem), "zip", src))
keep = int(cfg["paths"].get("keep", 20))
for old in sorted(dest.iterdir())[:-keep]:
old.unlink()
return out
def save_targets(emu, rom):
if emu.get("save_dir"):
folder = Path(emu["save_dir"]).expanduser()
return [folder] if folder.exists() else []
sav = rom.with_suffix(".sav")
return [sav] if sav.exists() else []
def play(cfg, entry):
emu = cfg["emulators"][entry["system"]]
rom = Path(entry["rom"])
label = rom.stem
targets = save_targets(emu, rom)
if not targets:
print("Note: no save found yet, so nothing to back up before this session.")
for t in targets:
out = backup(cfg, label, t, "before")
print(f"Backup before: {out.name if out else 'unchanged since last backup'}")
cmd = [*emu["command"], *emu.get("args", []), str(rom)]
print("Launching:", " ".join(cmd))
start = time.monotonic()
try:
code = subprocess.run(cmd).returncode
except FileNotFoundError:
sys.exit(f"Emulator not found: {cmd[0]}. Run 'doctor' and fix config.toml.")
minutes = (time.monotonic() - start) / 60
if minutes < 0.1:
print("Warning: the emulator exited in under 6 seconds. A wrapper may have "
"detached, so playtime and the after-backup are unreliable.")
for t in save_targets(emu, rom):
out = backup(cfg, label, t, "after")
print(f"Backup after: {out.name if out else 'no change this session'}")
with SESSIONS.open("a") as fh:
fh.write(json.dumps({"when": datetime.now().isoformat(timespec="seconds"),
"title": entry["title"], "system": entry["system"],
"minutes": round(minutes, 1), "exit": code}) + "\n")
print(f"Session: {minutes:.1f} min, exit code {code}")
def backup_all(cfg):
count = 0
for e in load_index(cfg):
emu = cfg["emulators"][e["system"]]
for t in save_targets(emu, Path(e["rom"])):
if backup(cfg, Path(e["rom"]).stem, t, "manual"):
count += 1
print(f"{count} new backup(s) written.")
def stats():
if not SESSIONS.exists():
sys.exit("No sessions logged yet.")
totals = {}
for line in SESSIONS.read_text().splitlines():
s = json.loads(line)
totals.setdefault(s["title"], [0, 0.0])
totals[s["title"]][0] += 1
totals[s["title"]][1] += s["minutes"]
for title, (n, mins) in sorted(totals.items(), key=lambda kv: -kv[1][1]):
print(f"{title:<28} {n:>3} sessions {mins / 60:>6.1f} h")
def doctor(cfg):
problems = 0
for name, emu in cfg["emulators"].items():
exe = emu["command"][0]
found = Path(exe).expanduser().exists() or shutil.which(exe)
problems += not found
print(f"[{'ok' if found else 'MISSING'}] {name}: {exe}")
roms = Path(cfg["paths"]["roms"]).expanduser()
print(f"[{'ok' if roms.is_dir() else 'MISSING'}] roms: {roms}")
problems += not roms.is_dir()
print("Doctor found no problems." if not problems else f"Doctor found {problems} problem(s).")
def main():
ap = argparse.ArgumentParser(description="Launch Pokemon ROMs in the right emulator.")
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("scan")
sub.add_parser("list")
sub.add_parser("backup")
sub.add_parser("stats")
sub.add_parser("doctor")
p = sub.add_parser("play")
p.add_argument("query")
args = ap.parse_args()
if args.cmd == "stats":
return stats()
cfg = load_config()
if args.cmd == "doctor":
doctor(cfg)
elif args.cmd == "scan":
entries = scan(cfg)
print(f"Indexed {len(entries)} ROM(s) into {INDEX.name}.")
elif args.cmd == "list":
for e in load_index(cfg):
print(f"{e['system']:<4} {e['title']:<26} {e['region']:<14} {e['mb']:>6} MB {Path(e['rom']).name}")
elif args.cmd == "backup":
backup_all(cfg)
elif args.cmd == "play":
play(cfg, find(load_index(cfg), args.query))
if __name__ == "__main__":
main()
Your project folder now looks like this, with the three generated files appearing after your first scan and first session:
launcher/
pokelauncher.py <- the script above
config.toml <- your paths and extensions
library.json <- generated by "scan"
sessions.jsonl <- generated by "play"
Expected Output: What a Healthy Run Looks Like
Compare your terminal with these samples. They come from a test library, so your titles and times will differ, but the shape of each message should match. The doctor and scan commands run first.
> python pokelauncher.py doctor
[ok] gba: C:\Pokemon\emulators\mgba\mGBA.exe
[ok] nds: C:\Pokemon\emulators\melonds\melonDS.exe
[ok] 3ds: C:\Pokemon\emulators\azahar\azahar.exe
[ok] roms: C:\Pokemon\roms
Doctor found no problems.
> python pokelauncher.py scan
Indexed 4 ROM(s) into library.json.
> python pokelauncher.py list
3ds Moon 1024.0 MB Moon.3ds
gba Pokemon Emerald USA 16.0 MB Emerald.gba
gba Pokemon FireRed USA 16.0 MB FireRed.gba
nds Pokemon HeartGold USA 128.0 MB HeartGold.nds
A play session prints the backup lines on both sides of the launch. On a first run there is no save yet, so the script says so and moves on. On later runs the before-backup reports either a new file or that nothing changed since the last snapshot.
> python pokelauncher.py play emerald
Backup before: 20261024-201512-before-4814b26a13a7.sav
Launching: C:\Pokemon\emulators\mgba\mGBA.exe C:\Pokemon\roms\gba\Emerald.gba
Backup after: 20261024-204103-after-54e8c3805e08.sav
Session: 145.2 min, exit code 0
> python pokelauncher.py stats
Pokemon Emerald 6 sessions 11.4 h
Pokemon HeartGold 2 sessions 3.1 h
Two signals tell you the loop works. The after-backup filename carries a different hash from the before-backup, which proves the save changed during play. The session length matches your wall clock. If the length reads near zero, jump to the troubleshooting table. For more guides in this family, the gaming section collects the emulator setups for each system.
Common Pitfalls That Break a Pokemon Emulator Launcher
These six mistakes account for most failed first runs and for the saves people lose later. None needs deep debugging. They need awareness, so read them before you start playing on the new setup.
- Double-quoted Windows paths in TOML. A path like
"C:\Pokemon\roms"is read with escape sequences, and TOML rejects it with an invalid escape error. Use single quotes or forward slashes. - Running testing builds as daily drivers. The mGBA 0.11 series is still development builds and Azahar 2127.0 is in alpha. Save formats and bug profiles can shift between builds, so keep your main library on the stable lines, 0.10.5 and 2126.2, until the projects mark newer ones stable.
- Renaming a ROM after you started a save. The save pairs to the ROM by filename. Rename the file and the emulator creates a blank save beside the new name, which looks exactly like data loss.
- Pointing the emulator at a different save folder than the script watches. If you redirected saves in the emulator settings, the script will look beside the ROM, find nothing and skip the backup. Set
save_dirfor that system to match. - Assuming every melonDS build is the same program. The desktop app, the RetroArch core and the community forks use different settings and sometimes different save handling. Pick one build per system and stay with it.
- Syncing the ROM folder with a cloud client. Sync tools can lock or rewrite a
.savmid-session. Keep the live ROM and save folder local and sync only thebackupsdirectory.
A seventh habit is worth adopting even though it is not a bug: after the first successful session, copy one snapshot out of the backups folder to a second drive. A backup that lives on the same disk as the original protects you from mistakes, but not from a dead drive.
Troubleshooting: 11 Problems and Their Fixes
Most Pokemon emulator PC problems trace back to a handful of causes. Find your symptom in the left column. Each fix names the exact setting or command to change. Most of these resolve in under two minutes.
| Symptom | Likely cause | Fix |
|---|---|---|
ModuleNotFoundError: tomllib | A Python install too old to include tomllib | Install a newer Python and rerun with that interpreter |
Invalid escape or decode error in config | Backslashes in double-quoted TOML strings | Switch to single quotes or forward slashes |
Missing config.toml | Config is not beside the script, or the name is wrong | Rename the file to exactly config.toml and keep it in the launcher folder |
Emulator not found | Wrong executable path or a renamed binary | Run doctor, then correct the command entry |
scan reports 0 ROMs | Wrong roms path or an extension missing from the config | Check the path, then add the extension to that system’s list |
| Game shows its filename instead of a name | 3DS file, or a game code not in the lookup table | Rename the file cleanly or add the code to KNOWN |
matches 3 games error | Query is too short to be unique | Type more of the name, or use the exact filename without the extension |
| Warning about a session under six seconds | A wrapper or launcher process detached from the emulator | Point command at the real executable, not a stub that forks and exits |
| Backup says unchanged after you played | You used a save state but never saved in-game | Save in the game itself and quit normally. Save states live in a separate file |
| No 3DS backup is created | save_dir missing or wrong | Open Azahar’s File menu to find the user folder and update save_dir |
| Permission denied writing backups | Backup folder inside a protected or synced location | Move backups to a normal local folder you own |
One failure sits outside the table because it is not the launcher’s fault. If the emulator opens but shows a black screen or refuses to boot a game, the problem lives in the emulator’s own configuration, usually a missing BIOS or firmware file or a graphics backend mismatch. Launch that emulator directly, without the script, and confirm the game runs. If it fails there too, fix it in that emulator first. The per-system setup guides linked earlier cover the BIOS, firmware and key files each emulator wants.
If a backup looks wrong, remember the naming scheme. The tag tells you when the snapshot was taken, and the 12-character hash tells you whether two snapshots hold identical data. To restore, close the emulator, copy the chosen snapshot over the live .sav and rename it to match the ROM filename exactly. For a 3DS zip, extract it back into the save_dir folder after moving the current contents aside.
Advanced Tips for a Bigger Pokemon Library
Once the basics run, a few extensions pay off. Each one builds on the same config structure, so none requires a rewrite.
- Per-emulator arguments. Add an
argslist to any system, for example to start fullscreen. Flag names differ between emulators and versions, so run the emulator with--helpand copy the exact spelling instead of guessing. - Add the Switch. Create an
[emulators.switch]table with the extensions your dumps use, acommandand asave_dir. The Eden emulator setup for PokÃĐmon Switch games walks through the emulator side of that route. - Flatpak and AppImage. Put the wrapper and its arguments in the
commandlist, for example["flatpak", "run", "APP_ID"]. Test that the wrapper stays alive until the game closes, otherwise the session warning will fire. - A testing config. Copy the launcher folder, point it at development builds of mGBA or Azahar and a separate backup directory. You can try a new release without risking the main library.
- Off-site backups. Sync only the
backupsfolder with a tool such as Syncthing or rclone. Rotation keeps it small. - Browser play. If you want a no-install route on a locked-down PC, the browser-based PokÃĐmon emulator setup covers that approach, with the caveats about save storage.
The same structure scales to other consoles. Anything with a ROM extension, a command-line emulator and a save location fits the pattern. The takeaway: keep the data in the config, keep the logic in the script, and the launcher stays small however many systems your Pokemon emulator PC setup grows to include.
Frequently Asked Questions
Which Pokemon games does this launcher cover?
Anything the three emulators run: generations one to three through mGBA, generations four and five through melonDS and generations six and seven through Azahar. The header reader names GBA and DS games it recognizes, and everything else falls back to its filename or header title.
Is it legal to use a Pokemon emulator on PC?
The emulators are free, open-source programs. The legal question is about the game files. Dumping a cartridge you own for personal use is the safest ground, while downloading ROMs is infringement in most jurisdictions. The rules vary by country, and this article is not legal advice, so check the law where you live.
Do I need RetroArch for this setup?
No. This project runs the standalone mGBA, melonDS and Azahar apps directly. RetroArch is free and offers cores for some systems, but Azahar runs as its own application here, and mixing standalone apps with RetroArch cores would complicate the save paths the script watches.
Should I use the Azahar 2127.0 alpha?
Not for a library you care about. As of October 24, 2026 the 2127.0 line is in alpha, with Alpha 3 published on October 16, and that line is meant for testing. Stay on 2126.2 for daily play and try the alpha through a separate testing config.
Does the launcher work on macOS?
The Python script and TOML config are cross-platform, so the launcher logic works. Whether each emulator ships a current macOS build is a question for its own download page. Adjust the command paths to point inside the app bundles, and set Python 3.12 or newer.
How many backups does the script keep?
Twenty per game by default, set by keep in the config. Rotation removes the oldest files first, and the hash check prevents duplicate snapshots, so twenty entries usually cover a long stretch of play.
Why build this instead of using an existing frontend?
Existing frontends are fine, and many do more. This script is small enough to read in one sitting, has no dependencies and treats saves as the main concern. You can also keep using a frontend and run backup on a schedule for the safety net alone.
What happens if two games share part of a name?
The script refuses to guess. It prints every match with its region and filename, then exits, so you can rerun the command with a more specific name or the exact filename.





