ssrecomp

ssrecomp is a native port of Pokémon SoulSilver for single-screen devices, recompiled from the pokeheartgold decompilation using the melonDS core.

README

ssrecomp

A native build of Pokémon SoulSilver for single-screen devices. The game's ARM9 code is statically recompiled to C++ from the pokeheartgold decompilation's build output; the melonDS core (GPLv3, vendored in melonDS/ at tag 1.1 with a few patches) provides the DS hardware: graphics, sound, cartridge, save memory and the ARM7.

Made with heavy use of AI. Nearly all of the code, documentation and testing in this project was done by an AI coding assistant (Anthropic's Claude, through Claude Code), directed and play-tested by the maintainer. Expect bugs a person would not have made, and review the code with that in mind before reusing it.

This repository contains no game code or data. It holds the recompiler, the runtime and the frontend. The game-specific part (gen/) is generated on your own machine from your own build of pret/pokeheartgold, which in turn needs your own copy of the game; see "Building". Do not commit gen/, ROMs, saves or save states to this repository.

Not affiliated with or endorsed by Nintendo, Game Freak, The Pokémon Company, pret or the melonDS project. Pokémon is a trademark of its owners. Because it is AI-written, do not submit any of it to pret, whose contribution rules do not accept such work.

Screenshots

The overworld and battles drawn across a 16:9 screen, with the touch-screen buttons laid out along the bottom in battle (one screen, played with a controller):

Route 29 in widescreenViolet City in widescreen
Battle: choosing a commandBattle: choosing a move

Quick start

You need your own copy of Pokémon SoulSilver (US) as a .nds file, dumped from your cartridge. Nothing of the game is in this repository or its releases.

  1. Download the setup program for your system from the latest release:

    • Linux PC: ssrecomp-setup-linux-x86_64
    • Linux on ARM (Raspberry Pi 4/5 with a 64-bit OS, other arm64 boards and laptops): ssrecomp-setup-linux-aarch64.AppImage
    • Windows: ssrecomp-setup-windows-x86_64.exe

    On Linux, make it executable (chmod +x <file>, or the file's properties).

  2. Start it.

  3. Choose your ROM, press Build, wait (the first build takes 20–40 minutes), then press Play.

macOS (experimental)

Apple Silicon and Intel builds target macOS 11 (Big Sur) or later. Install Xcode Command Line Tools and Python 3 with Tk, then run from this repository:

xcode-select --install
python3 -m pip install "cmake>=3.21,<4" ninja
python3 ssrecomp_setup.py

Choose your ROM, press Build, then Play. Without the setup window:

python3 ssrecomp_setup.py --rom "/path/to/your SoulSilver.nds"
./run.sh --fullscreen

Setup downloads and builds SDL2 and libpng automatically; Homebrew libraries are not needed. The first build needs an internet connection. Builds resume when setup is run again; add --jobs N to choose the number of compile jobs. Use native arm64 Python on Apple Silicon or x86_64 Python on Intel.

The Mac setup download, when available, is a .zip containing ssrecomp-setup.app; unpack it and open the app. Python/Tk is included, but Command Line Tools, CMake and Ninja are still required. Development builds are ad-hoc signed and may need approval in System Settings → Privacy & Security.

The frontend uses Metal and supports Retina displays. Control-Command-F or F11 toggles fullscreen. Full builds and boot/intro instruction comparisons have passed on Apple Silicon; older macOS releases and Intel gameplay still need hardware testing. Android builds and setup.sh's decompilation route are currently unavailable on macOS.

Android (experimental)

The Android version is built on your PC by the same setup program, from your ROM, and installed on the phone or handheld (64-bit ARM, Android 7.0 or later):

  1. In the setup program, choose your ROM and press Build for Android. It needs only CMake and Ninja on the PC (see below); the first time it downloads Google's Android NDK (about 650 MB on Linux, 750 MB on Windows) and SDL2. The build takes about as long as the PC one.
  2. Copy SoulSilver-ssrecomp.apk (in the setup program's folder) to the device, open it there and allow installing it when asked.

The APK contains your copy of the game: never share it. It is signed with a key kept next to it (android/signing-key.json); builds made later with the same key install over it and keep the save. Uninstalling the app deletes its save.

On the device: the touch screen works on the touch-screen parts of the picture, and on-screen buttons are shown over it. Using a controller (Bluetooth, USB, or a handheld's built-in controls) hides them; touching the screen brings them back. The MENU button or Back opens the pause menu. Messages and frame timings go to log.txt in Android/data/io.github.clickingmaster.ssrecomp/files on the phone's storage (open it with a file manager, or from a PC over USB), which helps with bug reports.

When a newer version is released, the app says so at start (it asks GitHub, its only use of the internet): update the setup program, press Build for Android again and install the new APK over the old one.

The setup program carries this project's source and unpacks it into a folder of its own (~/.local/share/ssrecomp on Linux, %LOCALAPPDATA%\ssrecomp on Windows, ~/Library/Application Support/ssrecomp on macOS), where the game is built and its save is kept. It contains nothing of the game. Instead of the download you can also run python3 ssrecomp_setup.py from a copy of this repository (then the game is built in that folder).

Updating: the setup window shows its version and whether a newer release is out; Update downloads it, checks its checksum, replaces the setup program and restarts it. Press Build once afterwards to rebuild the game with the new version (your save and settings are kept). Without a display: ssrecomp-setup-linux-x86_64 --update, then --rom again.

Portable mode: put an empty file named portable.txt next to the setup program (the .exe, or the AppImage on Linux). Everything — the unpacked source, the build, the save and the settings — then goes into a folder ssrecomp-data beside it instead, so the whole thing can live on a USB stick or another drive and move between PCs. Keep the ROM in that folder too (or on the same drive), so it is still found when the drive letter changes. After moving to another PC, Play works straight away; Build (only needed for updates) compiles everything again, since the build remembers where it was made.

The window checks that the ROM is the right game and tells you if something needed for compiling is missing, with the command to install it (with python3 ssrecomp_setup.py instead of the download, add Python's Tk support: python3-tk, python3-tkinter or tk):

  • Linux (Ubuntu, Debian, Mint): sudo apt install cmake ninja-build g++ pkg-config libsdl2-dev libpng-dev zlib1g-dev

  • Fedora: sudo dnf install cmake ninja-build gcc-c++ pkgconf-pkg-config SDL2-devel libpng-devel zlib-devel

  • Arch: sudo pacman -S --needed cmake ninja gcc pkgconf sdl2 libpng zlib

  • Immutable Linux (Bazzite, Silverblue, SteamOS): run it in a container:

    distrobox create -n ssrecomp -i ubuntu:24.04
    distrobox enter ssrecomp -- sudo apt install -y cmake ninja-build g++ pkg-config libsdl2-dev libpng-dev zlib1g-dev
    distrobox enter ssrecomp -- ./ssrecomp-setup-linux-x86_64
    

    The finished game also runs outside the container (./run.sh).

  • Windows (untested so far): install MSYS2 in its default place (C:\msys64), open "MSYS2 UCRT64" and install the compiler and libraries with

    pacman -S --needed mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-cmake mingw-w64-ucrt-x86_64-ninja mingw-w64-ucrt-x86_64-SDL2 mingw-w64-ucrt-x86_64-libpng mingw-w64-ucrt-x86_64-zlib
    

    The setup program finds them there by itself. (To run ssrecomp_setup.py instead of the download, also install mingw-w64-ucrt-x86_64-python and mingw-w64-ucrt-x86_64-tk and start it from that window.)

Without a display: ssrecomp-setup-linux-x86_64 --rom your-rom.nds (or python3 ssrecomp_setup.py --rom ...) does the same in the terminal; add --android for the Android build on Linux or Windows.

Making the setup programs: pip install "pyinstaller>=6" certifi pillow && python tools/package_setup.py on the system they are for; tools/make_appimage.sh wraps the Linux one into an AppImage. On macOS it creates dist/ssrecomp-setup.app with Python/Tk included. .github/workflows/release.yml packages Linux, Windows and separate Mac architectures when started by hand from the Actions tab. Mac bundles are checked for dependency paths and minimum OS versions before archiving. Set SSRECOMP_CODESIGN_IDENTITY when packaging with a Developer ID; public distribution also needs notarization.

Mac builds compile pinned SDL2/libpng sources for the selected deployment target, using the SDK's zlib. Advanced CMake builds can use installed libraries with -DSSR_MACOS_SOURCE_DEPS=OFF; those libraries must support the chosen architecture and OS version. MACOSX_DEPLOYMENT_TARGET (default 11.0) selects the minimum OS for direct CMake builds. The setup workflow uses 11.0.

How it works without the decompilation: the code to recompile is read straight from your ROM (tools/ndsrom.py). What the decompilation adds, which parts of it are ARM or Thumb code and where the functions the frontend watches are, comes from layouts/soulsilver-us.json, made with tools/make_layout.py; that file holds addresses and checksums only. The generated code is identical to what the decompilation's build gives.

If you want to change the game itself, build from the decompilation instead with ./setup.sh (see "Building").

Running

./run.sh                 # windowed
./run.sh --fullscreen

First build it: see "Building" below. run.sh expects the ROM at ../pokeheartgold/build/soulsilver.us/pokesoulsilver.us.nds, i.e. the decompilation checked out next to this repository and built. The save file is soulsilver.sav next to the script; settings are remembered in ssrecomp.cfg.

Everything can be done from a controller:

ActionControllerKeyboard
D-padD-pad or left stickarrow keys
A / B / X / Ythe buttons with those labelsX / Z / S / A
L / RshouldersQ / W
Start / SelectStart / BackEnter / Right Shift
Pause menuGuide, or Start + Select togetherEsc
Touch screenoff by default; with "STICK TOUCH CURSOR" on in the pause menu: right stick moves a cursor, right trigger pressesmouse, or I J K L and Space
Fast forward (4x)hold left triggerhold Left Shift
Swap the large screenclick right stick (only with "STICK TOUCH CURSOR" on)F1
Next layoutclick left stick (only with "STICK TOUCH CURSOR" on)Tab
Quick save / load statepause menuF5 / F8
Fullscreenpause menuF11

The pause menu also has the view, 60 FPS, smooth scaling, button mapping by label or by position, reset and quit.

run.sh also writes what the game prints to game.log next to it; a bug report about a controller should include it (it lists every controller SDL sees).

Steam Deck / SteamOS: add run.sh to Steam as a non-Steam game. If a controller does nothing that way, setting the shortcut's compatibility tool to Proton Experimental (Properties → Compatibility) has been reported to make it work.

Single-screen view

The default view (merged) shows one picture, with no second screen beside it and no switching by hand. What it is made of depends on what the game is doing:

SituationWhat is shown
Overworld, dialoguethe top screen; on a wide display the world fills the whole view (see "Widescreen world")
A question in the overworld (Yes/No, PC and shop choices)the answer buttons drawn at the right above the message box
X menu openthe menu panel at the right edge, mostly beside the overworld; the game's "look at the other screen" strip is not drawn. Coming back from the Bag, Pokegear and so on, that strip is also what tells the frontend the menu is up before the menu's own code runs again, so the overworld fades in with the panel already in place
Battlethe scene across the whole view: each row's outermost pixels continue into the sides, darkening outwards, so the sky, ground and HP boxes run to the edges
Battle, choosing a command or movethe buttons taken off the touch screen and laid out as a strip along the bottom in place of the message box, under a label with the message's first line ("What will X do?"): FIGHT, BAG, POKeMON, RUN, or the four moves (B cancels). Left and right walk along the row (empty move slots are skipped) and A presses the outlined button; the frontend does that by touching it, since the game's own cursor moves in the touch screen's two-row arrangement; nothing of the scene is covered. Other touch-screen questions in battle, and narrow views, get the whole touch panel as a card at the lower left
In-battle bag and partythat screen alone
Learning a fifth move"Forget a move / Keep old moves" at the lower left, then the move picker and its confirm page alone
Bagthe item list with the description box from the other screen underneath
Poke Martthe item description above the list of goods; quantity and confirmation in the same page
PC boxthe selected Pokemon's info card beside the box
Pokedex listone page: title bar across the top, name, picture and type/size card on the left, description above the number grid and its buttons on the right
Pokedex area, size, formsone page: the title bar and the tab bar span the view, the two bodies are its columns
Pokedex cover, searchthe closed cover as one tall picture; search as a two-column page
Party, options, trainer card, naming, save, Voltorb Flip, ...the bottom screen alone
Summaryboth halves joined into one page
Choosing a starterthe preview at the left edge, the tray of Poke Balls as a card on the right
Pokegear phoneclock above the contact list; during a call, the call screen
Pokegear map, town mapthe location's description beside the map
New-game introthe professor's screen in the middle with its backdrop continued across the view; for a choice it slides left and the choices stand beside him
Top screen blank (e.g. Continue menu)the bottom screen
Any other screen that waits for a touch (e.g. Apricorn Box)both screens joined as one spread, so nothing is hidden

The right-stick touch cursor works through these overlays; if nothing touchable is on screen, moving it brings up the bottom screen. Clicking the right stick (F1) shows the other screen as an escape hatch for situations not covered yet.

The pieces are put together by the graphics card (frontend/gpucompose.h): each is uploaded at DS resolution and scaled by the SDL renderer, so the cost does not grow with the size of the window. MergedCompose in frontend/merged.h draws the same picture on the CPU for the headless tests; the two are compared with SSR_CPU_COMPOSE=1 and window screenshots (see "Performance").

How it works: a patch to the 2D renderer records which graphics layer each pixel came from, so a screen's background can be dropped and only its buttons drawn (frontend/merged.h). The situation is recognised by which game functions ran in the last few frames: tools/apps.py reads every application's per-frame function, and the names in tools/focus_symbols.txt, out of the decompilation's linker map. The rules are in pickScene in frontend/main.cpp. A new layout must be asked for several frames in a row before it replaces the one on screen, so transitions do not flash a wrong one.

The older layouts are still in the pause menu: hybrid, side, vertical, top, bottom.

Widescreen world

In the merged view on a wide display the overworld fills the whole picture instead of a 4:3 box, and is drawn at high resolution ("WIDESCREEN WORLD" and "WORLD DETAIL" in the pause menu; wide and wide_lines in ssrecomp.cfg). Because it is no longer limited to the DS's 256x192 pixels, the world also moves in steps smaller than a DS pixel: walking up and down, where the camera's tilt makes the DS scroll 2, 1, 2, 1 pixels, is even.

How it works (melonDS/src/GPU3D_Wide.cpp): every polygon the game submits is recorded before clipping. When the frame is flushed, the list is submitted a second time with the horizontal clip coordinate squeezed by 256 / (view width), so that what lies left and right of the DS's view survives clipping, into separate polygon memory. A separate rasteriser draws that list at the size of the output (perspective-correct textures, depth, translucency, fog; no edge marking or shadow volumes), on worker threads while emulation continues. The frontend puts the picture behind the top screen and draws only the top screen's 2D pixels (text boxes, signs) over it; fades are applied to it from the brightness registers, and 2D effects that touch the screen's edge (wipes) are continued into the margins. The game sees the same polygon counts as without it, with one exception: the box test, which the game uses to decide whether a tree or a building is in view, is made against the wide view while widescreen is on, so that those are also drawn beside the DS's own picture instead of popping in at its edge (GPU3D::BoxTest). --lockstep therefore runs with widescreen off. Battles, menus and everything that is not the overworld stay 4:3.

Known limits: anything the game culls by other means than the box test could still pop in at the far left and right (none found so far); there are no edge-marking outlines; 2D weather or overlays do not extend beyond the 4:3 area; "WORLD DETAIL" above 720 costs frame time (about 5 ms at 720 lines, 12 at 1080, 18 at 1440 on a 6-core desktop CPU). On slow devices, 384, 288 or 192 (the DS's own sharpness) cost the least; "FPS COUNTER" in the same menu shows whether the game keeps up.

60 fps

The game updates its 3D scene 30 times a second. With "60 FPS" on (the default, in the pause menu), the 3D engine records the polygons of each frame and, on the display frame in between, draws them halfway between their old and new positions. Walking scrolls 1 pixel per frame instead of 2 every other frame. Game speed and game state are unchanged, the 3D picture is one display frame (1/60 s) later than without it, and 2D elements such as text boxes still update at the game's own rate. A polygon is paired with its counterpart in the previous frame by how the scene as a whole moved (found by letting every possible pairing vote), and only if it moved roughly like that; anything else is drawn where it now is. The polygon after the last partner is tried first (objects keep their order in the list), the scene's movement is voted on from both ends of the list so that a map block entering it does not upset the vote, and a strip polygon whose neighbour was treated differently is built on its own. SSR_PAIR_DEBUG=1 logs where the pairing changes step. Pairing with the nearest look-alike instead drew models halfway towards a neighbour that had just left the picture (SSR_PAIR_LOOSE=1 brings that back for comparison). It switches itself off when the scene does not update on a steady 30 fps cadence, when polygons cannot be matched between frames, and while the game captures the 3D image.

Performance

SSR_PERF=1 prints, every 300 fr