Skip to content

Creating a new emulator

Jamiras edited this page Feb 11, 2023 · 11 revisions

The first step to enable an emulator for RetroAchievements is to locate an open source emulator to extend. This wiki shows how the fceux emulator was extended to support RetroAchievements. Similar steps can be applied to other emulators.

Creating a repository

Clone the existing source

First, clone the original emulator (fceux) into a directory called RANes. Then rename the origin remote git reference to vendor. We'll use origin for our local repository later.

$ git clone https://github.com/TASVideos/fceux/ RANes
$ cd RANes
$ git remote rename origin vendor
$ git submodule init
$ git submodule update --recursive

Next, create a new branch: source. This will contains only the source emulator code. RetroAchievements code should never be put into this branch. We aren't going to do anything with this now, but it's important to have for later.

$ git checkout -b source

Then, create another new branch: RANes. We'll use this branch to add the integration changes, then merge it to master. master will contain the RetroAchievements releases, and source will be used in the future to pick up changes made in the original emulator repository.

$ git checkout master
$ git checkout -b RANes

Adding the Integration

Finally, add the RAInterface submodule so the build can access the code and tools.

$ git submodule add https://github.com/RetroAchievements/RAInterface.git
$ cd RAInterface
$ git checkout RAIntegration.0.79

The very last line ensures the emulator version is tied to a specific integration version. You should always use the tag associated to the most recent release.

Update the project build files

To make updating from the original emulator easier in the future, we're going to make all RetroAchievements specific changes isolated. The first step of this is to create separate build configurations where we can define our compilation constant and pre/post build commands.

Open the solution for the emulator in Visual Studio. Do a build just to make sure you have all the dependencies identified.

Open the Configuration Manager.

  • Select <New...> from the Active Solution dropdown
  • Name it "RA-Debug" and copy the settings from "Debug".
  • Repeat by copying "RA-Release" from "Release".

Open the Project Settings dialog

  • For RA-Debug and RA-Release:
    • Change the Target Name to the desired RetroAchievements emulator name (i.e. RANes) in General
    • Add RETROACHIEVEMENTS to the Preprocessor Definitions in C/C++ > Preprocessor
    • Add winhttp.lib to the Additional Dependencies in Linker > Input
    • Add the following to the Pre-Build event (relative paths may need to be updated)
      $(ProjectDir)..\RAInterface\MakeBuildVer.bat $(ProjectDir)..\src\RA_BuildVer.h RANes RANES
      
      The second parameter is the git tag prefix, and should match the emulator name. The third parameter is an all-caps prefix used for generating the #defines.
    • Add the following to the Post-Build event (relative paths may need to be updated):
      $(ProjectDir)..\RAInterface\CopyOverlay.bat $(TargetDir)
      
  • Add "RA_BuildVer.h" to the .gitignore file, as well as any new output files created as a result of changing the Target Name.

Create an editor config

It's important to keep whitespace consistent with the source, so if an .editorconfig doesn't exist in the root source directory (or possibly the repository root directory), create one and set the values appropriately to match the source.

# More info: http://EditorConfig.org
root = true

# * here means any file type
[*]
end_of_line          = crlf
insert_final_newline = true

# latin1 is a type of ASCII, should work with mbcs
[*.{h,cpp}]
charset                  = latin1
indent_style             = tab
indent_size              = 4
trim_trailing_whitespace = true

Then use "Add Existing Item" to associate the .editorconfig to the Solution.

Add integration stubs

We want the RetroAchievements changes to the main source files to be as minimal as possible. In that regards, we're going to create a single .cpp file to contain anything that calls into RAInterface using more than one or two lines of code.

Create two files in the source directory (usually in the root source folder, but find somewhere appropriate). Try to match the naming convention of other files in that directory. For RANes, we've chosen src/retroachievements.cpp and src/retroachievements.h.

Initially, retroachievements.h should only contain an #include for the RA_Interface.h file:

#ifndef __RETROACHIEVEMENTS_H_
#define __RETROACHIEVEMENTS_H_

#include "../RAInterface/RA_Interface.h"

#endif __RETROACHIEVEMENTS_H_

And retroachievements.cpp only has an #include for retroachievements.h:

#include "retroachievements.h"

Add retroachievements.cpp and RAInterface\RA_Interface.cpp to the project.

Using the file properties dialog, exclude both from all configurations except the new RA-Debug and RA-Release configurations added above.

Open retroachievements.cpp and press Ctrl+E, Ctrl+D to reformat the file to match the .editorconfig settings

Open retroachievements.h and press Ctrl+E, Ctrl+D to reformat the file to match the .editorconfig settings

Implementing the Integration

Downloading the DLL and logging in

The first thing to do when integrating the DLL is to download it. Find the code that is creating the main window for the emulator. Shortly after the window has been created, but before the message loop starts, add the following code:

#ifdef RETROACHIEVEMENTS
    RA_Init(hWnd);
#endif

You'll need to add a #include at the top of the file too:

#ifdef RETROACHIEVEMENTS
#include "retroachievements.h"
#endif

RA_Init is going to be one of those multi-line helper functions mentioned earlier, so add a prototype for it in the retroachievements.h file:

void RA_Init(HWND hWnd);

And a whole bunch of stubs in the retroachievements.cpp file:

#include "RA_BuildVer.h"

static HWND g_hWnd;

static void CauseUnpause() {}
static void CausePause() {}

static int GetMenuItemIndex(HMENU hMenu, const char* pItemName)
{
    int nIndex = 0;
    char pBuffer[256];

    while (nIndex < GetMenuItemCount(hMenu))
    {
        if (GetMenuStringA(hMenu, nIndex, pBuffer, sizeof(pBuffer)-1, MF_BYPOSITION))
        {
            if (!strcmp(pItemName, pBuffer))
                return nIndex;
        }
        nIndex++;
    }

    return -1;
}

static void RebuildMenu() 
{
    HMENU hMainMenu = GetMenu(hAppWnd);
    if (!hMainMenu) 
        return;

    // if RetroAchievements submenu exists, destroy it
    int index = GetMenuItemIndex(hMainMenu, "&RetroAchievements");
    if (index >= 0)
        DeleteMenu(hMainMenu, index, MF_BYPOSITION);

    // append RetroAchievements menu
    AppendMenu(hMainMenu, MF_POPUP|MF_STRING, (UINT_PTR)RA_CreatePopupMenu(), TEXT("&RetroAchievements"));

    // repaint
    DrawMenuBar(hAppWnd);
}

static void GetEstimatedGameTitle(char* sNameOut) {}
static void ResetEmulator() {}
static void LoadROM(const char* sFullPath) {}

void RA_Init(HWND hWnd)
{
    // initialize the DLL
    RA_Init(hWnd, UnknownEmulator, RANES_VERSION);
    RA_SetConsoleID(NES);
    g_hWnd = hWnd;

    // provide callbacks to the DLL
    RA_InstallSharedFunctions(NULL, CauseUnpause, CausePause, RebuildMenu, GetEstimatedGameTitle, ResetEmulator, LoadROM);

    // add a placeholder menu item and start the login process - menu will be updated when login completes
    RebuildMenu();
    RA_AttemptLogin(false);

    // ensure titlebar text matches expected format
    RA_UpdateAppTitle("");
}

And after the message loop completes, add the following:

#ifdef RETROACHIEVEMENTS
    RA_Shutdown();
#endif

RA_Shutdown() is a function provided by RAInterface, so we don't need to make any further changes to retroachievements.cpp.

At this point, the emulator should download the DLL and have the user log in. The menu should be updated to reflect that the user has been logged in.

Hook up the menu

Locate the code where the emulator is handling existing menu items and add this block of code:

#if RETROACHIEVEMENTS
    if (LOWORD(wParam) >= IDM_RA_MENUSTART &&
        LOWORD(wParam) < IDM_RA_MENUEND)
    {
        RA_InvokeDialog(LOWORD(wParam));
        return 0;
    }
#endif

This should allow you to logout/login using the menu, as well as open any of the toolkit windows.

Things to look for:

  • If you're unable to type in the text fields, then the application's message loop isn't calling TranslateMessage (Allegro is known to have this issue). To address that, you may need a separate message loop that does call TranslateMessage, and you should call RA_InvokeDialog from within that loop.
  • If the tool windows don't update unless the mouse cursor is over the main window, call RA_ForceRepaint(true) after RA_Init to inform the DLL to manually repaint things when they change. This is primarily an issue with SDL where the message loop is constantly doing stuff, so there's never time for Windows to promote the pending InvalidateRect messages to WM_PAINT messages.

Load a game

Find the location in the code where the emulator is loading the game data into memory. Determine an appropriate portion of the data to hash (preferably the entire file [excluding any header information], but at a minimum, the executable area of the code). If you're uncertain what data to hash, please consult the RetroAchievements development team.

Add the following code to let the DLL calculate the hash and load the game:

#ifdef RETROACHIEVEMENTS
    RA_OnLoadNewRom(pMemory, nMemorySize);
#endif

If the hash isn't known to the server, the Unknown Game dialog will be shown. Implement the GetEstimatedGameTitle callback in retroachievements.cpp to provide a description of the game being loaded - typically this is just the filename without path or extension. Note that sNameOut is a pointer to a 256-byte buffer, so use snprintf or something similar to prevent overflowing the buffer.

static void GetEstimatedGameTitle(char* sNameOut)
{
    const char* ptr = GameInfo->filename;
    if (ptr)
        strncpy(sNameOut, ptr, 256);
}

When you load a game, you should now be presented with the Unknown Game dialog showing the filename as the description for the game. If the hash is resolved, you won't see anything happen, but you should be able to open the game's page using the menu item.

If the emulator supports dynamic patching (applying an ips after loading the game), make sure the hash calculation occurs after the ips is applied. Also make sure that the hash algorithm ignores any saved data. The game should resolve correctly whether it's the first time the user plays or the 1000th.

Ensuring the window title is correct

Find everywhere the emulator calls SetWindowText for the main window and modify it to call RA_UpdateAppTitle. RA_UpdateAppTitle will automatically include the emulator name, version, and player name. You only need to provide the additional detail - like currently loaded game name.

#ifdef RETROACHIEVEMENTS
    if (Memory.ROMFilename[0])
    {
        char def[_MAX_FNAME];
        _splitpath(Memory.ROMFilename, NULL, NULL, def, NULL);
        RA_UpdateAppTitle(def);
    }
    else
    {
        RA_UpdateAppTitle("");
    }
#else
    char buf [1024];
    if (Memory.ROMFilename[0])
    {
        char def[_MAX_FNAME];
        _splitpath(Memory.ROMFilename, NULL, NULL, def, NULL);
        _sprintf(buf, "%s - %s %s", def, WINDOW_TITLE, TEXT(VERSION));
    }
    else
        _sprintf(buf, "%s %s", WINDOW_TITLE, TEXT(VERSION));

    SetWindowText (hWnd, buf);
#endif

Getting the overlay working

To make the fullscreen overlay appear, you have to call RA_SetPaused(true), and you can call RA_SetPaused(false) to make it disappear. Typically, the emulator will have some form of Pause functionality, just find it and add the call:

#ifdef RETROACHIEVEMENTS
    RA_SetPaused(state == Paused);
#endif

To enable navigation of the overlay, find where the input is being processed and add the following code

#ifdef RETROACHIEVEMENTS
    RA_ProcessInputs();
#endif

Then add the prototype to the header file and the implementation in the cpp file:

void RA_ProcessInputs()
{
    if (RA_IsOverlayFullyVisible())
    {
        ControllerInput input;
        input.m_bUpPressed      = (CHECK_KEY(0, Up));
        input.m_bDownPressed    = (CHECK_KEY(0, Down));
        input.m_bLeftPressed    = (CHECK_KEY(0, Left));
        input.m_bRightPressed   = (CHECK_KEY(0, Right));
        input.m_bConfirmPressed = (CHECK_KEY(0, A));
        input.m_bCancelPressed  = (CHECK_KEY(0, B));
        input.m_bQuitPressed    = (CHECK_KEY(0, Start));

        RA_NavigateOverlay(&input);
    }
}

Also make sure to implement CauseUnpause to allow the overlay to unpause the emulator when it closes itself.

static void CauseUnpause()
{
    Settings.Paused = false;
}

Hooking up the memory inspector

Add the following lines to RA_Init() in retroachievements.cpp (after RA_InstallSharedFunctions):

    // register the system memory
    RA_ClearMemoryBanks();
    RA_InstallMemoryBank(0, ByteReader, ByteWriter, 0x10000);

And provide implementations for ByteReader and ByteWriter:

unsigned char ByteReader(unsigned int nOffs)
{
    if (GameInfo)
        return static_cast<unsigned char>(ARead[nOffs](nOffs));

    return 0;
}

void ByteWriter(unsigned int nOffs, unsigned char nVal)
{
    if (GameInfo)
        BWrite[nOffs](nOffs, nVal);
}

Then find the code associated with processing a frame. Note that not all frames may be rendered - particularly if the emulator has a speed-up feature. Even if the frame isn't rendered, we still want the DLL to process it. This should not be called if the emulator is paused.

#ifdef RETROACHIEVEMENTS
    RA_DoAchievementsFrame();
#endif

With these changes, you should be able to open the memory inspector and watch memory change while the game is running. Additionally, you should be able to modify memory and affect the game.

Save states

Find the code that saves a state and add the following (after the save occurs):

#ifdef RETROACHIEVEMENTS
    RA_OnSaveState(filename);
#endif

Find the code that loads a state and add the following (before the load occurs):

#ifdef RETROACHIEVEMENTS
    if (!RA_WarnDisableHardcore("load a state"))
        return;
#endif

And the following (after the load occurs):

#ifdef RETROACHIEVEMENTS
    RA_OnLoadState(fn);
#endif

You should now be able to persist hitcounts along with the save state, and be warned that save states cannot be loaded when playing in hardcore mode. You should still be allowed to create save states in hardcore mode. Make sure to put this code where it would be called if save states are saved/loaded via menu or shortcuts.

Handling reset

If your emulator supports restarting content, find the related code and add the following (after the reset has completed). This tells the toolkit that the memory is in an invalid state and disables any active leaderboards.

#ifdef RETROACHIEVEMENTS
    RA_OnReset();
#endif

You should also implement the ResetEmulator callback in retroachievements.cpp at this time. This is called when switching to hardcore mode.

static void ResetEmulator()
{
    FCEUI_ResetNES();
}

Handling modified achievement sets

Find the code related to closing the emulator and add the following code (make sure to account for closing via the menu, title bar X, and any shortcuts):

#ifdef RETROACHIEVEMENTS
    if (!RA_ConfirmLoadNewRom(true))
        return;
#endif

Find the code related to loading the game (you probably did this earlier) and add the following code before the load starts (make sure to test loading a new game and loading from the recent games list):

#if RETROACHIEVEMENTS
    if (!RA_ConfirmLoadNewRom(false))
        return;
#endif

If the emulator supports closing a game (system shutdown without closing the emulator), add this code before starting to close the game:

#if RETROACHIEVEMENTS
    if (!RA_ConfirmLoadNewRom(false))
        return;
#endif

And this code after the game has been closed:

#if RETROACHIEVEMENTS
    RA_ActivateGame(0);
#endif

To test this, open a game, add a dummy achievement and try closing the emulator, closing the game, and loading a different game. In all cases, the user should be warned that they have modifications, and selecting "no" should prevent the action, leaving the game and emulator running.

Hardcore restrictions

NOTE: Any restrictions must be enforced from all possible inputs - make sure to check menu items, shortcuts, and customizable hotkeys.

Slowdown and frame advance

If the emulator supports slowdown or frame advance, those features should be disabled in hardcore mode.

#ifdef RETROACHIEVEMENTS
    if (!RA_HardcoreModeIsActive())
        return;
#endif

These should not popup warnings, instead they should just not function in non-hardcore mode.

NOTE: Speed up and "turbo" should be allowed in hardcore. Speed down should only prevent going below normal speed.

NOTE: When using frame advance, RA_DoAchievementsFrame should only be called when the frame actually advances.

Make sure to update ResetEmulator to ensure the speed is not below normal and frame advance is disabled when switching to hardcore mode.

Debugger windows

If the emulator provides any sort of debugger windows (i.e. memory watch) they should not be allowed in hardcore.

Any tools that play the game for you (TAS / recording playback) should be disabled in hardcore.

Other debugging tools (like disabling render layers) should be disabled in hardcore.

Update the about page

Find the about dialog code and insert RetroAchievements emulator version information at the top of it.

"RANes " RANES_VERSION_SHORT "\n"

Leave all of the original about information, especially original emulator version information.

Share your changes

  • Create an empty repository on github.
  • Set it as the origin for your local directory.
  $ git remote add origin https://github.com/Jamiras/RANes.git
  $ git checkout master
  $ git push origin master
  $ git branch --set-upstream-to origin/master
  $ git checkout source
  $ git push -u origin source
  $ git checkout RANes
  $ git push -u origin RANes
  • Create a PR from RANes to master in your repository.
  • Share this PR with the RetroAchievements team.

If the emulator is accepted, we will clone your repository to our github. After that, you should delete your repository and fork from our repository so you can create PR's directly into our repository.