Repository: RetroAchievements/RAIntegration
Pull request #1047 (2024-01-10 03:38:29)

export menu for rc_client_raintegration
also pushes a lot of UI updates onto the UI thread when models are updated from not-the-UI-thread.


Patch:
diff --git a/rcheevos b/rcheevos
index 64fcb9ea..48942653 160000
--- a/rcheevos
+++ b/rcheevos
@@ -1,1 +1,1 @@
-Subproject commit 64fcb9ea3cf990e65343057ace9271ff3b77428e
+Subproject commit 489426539757de6106a4d0d34d3495f5922f62df
diff --git a/src/Exports.cpp b/src/Exports.cpp
index 636788b0..e600b511 100644
--- a/src/Exports.cpp
+++ b/src/Exports.cpp
@@ -15,6 +15,7 @@
 #include "data\context\UserContext.hh"
 
 #include "services\AchievementRuntime.hh"
+#include "services\AchievementRuntimeExports.hh"
 #include "services\FrameEventQueue.hh"
 #include "services\GameIdentifier.hh"
 #include "services\Http.hh"
@@ -79,8 +80,11 @@ API void CCONV _RA_UpdateHWnd(HWND hMainHWND)
     {
         pDesktop.SetMainHWnd(hMainHWND);
 
-        auto& pOverlayWindow = ra::services::ServiceLocator::GetMutable<ra::ui::win32::OverlayWindow>();
-        pOverlayWindow.CreateOverlayWindow(hMainHWND);
+        if (!IsExternalRcheevosClient())
+        {
+            auto& pOverlayWindow = ra::services::ServiceLocator::GetMutable<ra::ui::win32::OverlayWindow>();
+            pOverlayWindow.CreateOverlayWindow(hMainHWND);
+        }
     }
 }
 #endif
diff --git a/src/RA_Core.cpp b/src/RA_Core.cpp
index 55ca0722..c07304a2 100644
--- a/src/RA_Core.cpp
+++ b/src/RA_Core.cpp
@@ -95,7 +95,7 @@ void __gsl_contract_handler(const char* const file, unsigned int line, const cha
 }
 #endif
 
-API int CCONV _RA_Shutdown()
+static int DoShutdown()
 {
     // if the services haven't been registered, there's nothing to shut down
     if (!ra::services::Initialization::IsInitialized())
@@ -155,6 +155,20 @@ API int CCONV _RA_Shutdown()
     return 0;
 }
 
+API int CCONV _RA_Shutdown()
+{
+    try
+    {
+        return DoShutdown();
+    }
+    catch (std::runtime_error&)
+    {
+
+    }
+
+    return 0;
+}
+
 API int CCONV _RA_ConfirmLoadNewRom(int bQuittingApp)
 {
     //	Returns true if we can go ahead and load the new rom.
diff --git a/src/RA_Integration.vcxproj b/src/RA_Integration.vcxproj
index 88dc51fa..0a466692 100644
--- a/src/RA_Integration.vcxproj
+++ b/src/RA_Integration.vcxproj
@@ -86,6 +86,7 @@
     <ClCompile Include="RA_md5factory.cpp" />
     <ClCompile Include="RA_StringUtils.cpp" />
     <ClCompile Include="services\AchievementRuntime.cpp" />
+    <ClCompile Include="services\AchievementRuntimeExports.cpp" />
     <ClCompile Include="services\FrameEventQueue.cpp" />
     <ClCompile Include="services\GameIdentifier.cpp" />
     <ClCompile Include="services\Http.cpp" />
@@ -231,6 +232,7 @@
     <ClInclude Include="RA_Resource.h" />
     <ClInclude Include="RA_StringUtils.h" />
     <ClInclude Include="services\AchievementRuntime.hh" />
+    <ClInclude Include="services\AchievementRuntimeExports.hh" />
     <ClInclude Include="services\FrameEventQueue.hh" />
     <ClInclude Include="services\GameIdentifier.hh" />
     <ClInclude Include="services\Http.hh" />
diff --git a/src/RA_Integration.vcxproj.filters b/src/RA_Integration.vcxproj.filters
index da4ec9ba..a9d0b890 100644
--- a/src/RA_Integration.vcxproj.filters
+++ b/src/RA_Integration.vcxproj.filters
@@ -405,6 +405,9 @@
     <ClCompile Include="ui\viewmodels\ProgressTrackerViewModel.cpp">
       <Filter>UI\ViewModels</Filter>
     </ClCompile>
+    <ClCompile Include="services\AchievementRuntimeExports.cpp">
+      <Filter>Services</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <ClInclude Include="RA_Resource.h">
@@ -968,6 +971,9 @@
     <ClInclude Include="ui\viewmodels\ProgressTrackerViewModel.hh">
       <Filter>UI\ViewModels</Filter>
     </ClInclude>
+    <ClInclude Include="services\AchievementRuntimeExports.hh">
+      <Filter>Services</Filter>
+    </ClInclude>
   </ItemGroup>
   <ItemGroup>
     <ResourceCompile Include="RA_Shared.rc">
diff --git a/src/data/context/EmulatorContext.cpp b/src/data/context/EmulatorContext.cpp
index 8cff7dc0..5f0f62c5 100644
--- a/src/data/context/EmulatorContext.cpp
+++ b/src/data/context/EmulatorContext.cpp
@@ -3,6 +3,7 @@
 #include "Exports.hh"
 #include "RA_BuildVer.h"
 #include "RA_Log.h"
+#include "RA_Resource.h"
 #include "RA_StringUtils.h"
 
 #include "api\LatestClient.hh"
@@ -11,6 +12,7 @@
 #include "data\context\UserContext.hh"
 
 #include "services\AchievementRuntime.hh"
+#include "services\AchievementRuntimeExports.hh"
 #include "services\IClock.hh"
 #include "services\IConfiguration.hh"
 #include "services\IFileSystem.hh"
@@ -456,10 +458,9 @@ void EmulatorContext::DisableHardcoreMode()
         auto& pWindowManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::WindowManager>();
         pWindowManager.AssetList.UpdateButtons();
 
-        RebuildMenu();
+        SyncClientExternalHardcoreState();
 
-        auto& pRuntime = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-        rc_client_set_hardcore_enabled(pRuntime.GetClient(), false);
+        UpdateMenuState(IDM_RA_HARDCORE_MODE);
 
         // GameContext::DoFrame synchronizes the models to the runtime
         auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
@@ -532,12 +533,11 @@ bool EmulatorContext::EnableHardcoreMode(bool bShowWarning)
     // updating the enabled-ness of the buttons of the asset list
     pWindowManager.AssetList.UpdateButtons();
 
-    // update the integration menu
-    RebuildMenu();
-
     // update the runtime
-    auto* pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>().GetClient();
-    rc_client_set_hardcore_enabled(pClient, true);
+    SyncClientExternalHardcoreState();
+
+    // update the integration menu
+    UpdateMenuState(IDM_RA_HARDCORE_MODE);
 
     // GameContext::DoFrame synchronizes the models to the runtime
     pGameContext.DoFrame();
@@ -1027,6 +1027,13 @@ bool EmulatorContext::IsMemoryInsecure() const
     return m_bMemoryInsecure;
 }
 
+void EmulatorContext::UpdateMenuState(int nMenuItemId) const
+{
+    SyncClientExternalRAIntegrationMenuItem(nMenuItemId);
+
+    RebuildMenu();
+}
+
 } // namespace context
 } // namespace data
 } // namespace ra
diff --git a/src/data/context/EmulatorContext.hh b/src/data/context/EmulatorContext.hh
index 4f5dd5d2..d5ddef52 100644
--- a/src/data/context/EmulatorContext.hh
+++ b/src/data/context/EmulatorContext.hh
@@ -115,6 +115,12 @@ public:
             m_fRebuildMenu();
     }
 
+    /// <summary>
+    /// Updates the state of a single menu item.
+    /// </summary>
+    /// <remarks>Indirectly calls RebuildMenu after raising an event for the singular menu item</remarks>
+    void UpdateMenuState(int nMenuItemId) const;
+
     /// <summary>
     /// Gets whether or not the emulator can be paused.
     /// </summary>
diff --git a/src/data/context/GameContext.cpp b/src/data/context/GameContext.cpp
index 68f422bf..eda226c2 100644
--- a/src/data/context/GameContext.cpp
+++ b/src/data/context/GameContext.cpp
@@ -86,7 +86,7 @@ static bool ValidateConsole(int nServerConsoleId)
     return true;
 }
 
-void GameContext::LoadGame(unsigned int nGameId, const std::string& sGameHash, Mode nMode)
+bool GameContext::BeginLoadGame(unsigned int nGameId, Mode nMode, bool& bWasPaused)
 {
     OnBeforeActiveGameChanged();
 
@@ -119,7 +119,7 @@ void GameContext::LoadGame(unsigned int nGameId, const std::string& sGameHash, M
             m_nGameId = 0;
             OnActiveGameChanged();
         }
-        return;
+        return false;
     }
 
     // start the load process
@@ -132,45 +132,39 @@ void GameContext::LoadGame(unsigned int nGameId, const std::string& sGameHash, M
     pLocalBadges->CreateLocalCheckpoint();
     m_vAssets.Append(std::move(pLocalBadges));
 
-    // capture the old rich presence data so we can tell if it changed on the server
-    std::string sOldRichPresence = "[NONE]";
-    {
-        auto& pLocalStorage = ra::services::ServiceLocator::GetMutable<ra::services::ILocalStorage>();
-        auto pData = pLocalStorage.ReadText(ra::services::StorageItemType::GameData, std::to_wstring(nGameId));
-        if (pData != nullptr)
-        {
-            rapidjson::Document pDocument;
-            if (LoadDocument(pDocument, *pData) && pDocument.HasMember("RichPresencePatch") &&
-                pDocument["RichPresencePatch"].IsString())
-                sOldRichPresence = pDocument["RichPresencePatch"].GetString();
-        }
-    }
-
-    const bool bWasPaused = pRuntime.IsPaused();
+    bWasPaused = pRuntime.IsPaused();
     pRuntime.SetPaused(true);
 
+    return true;
+}
+
+void GameContext::LoadGame(unsigned int nGameId, const std::string& sGameHash, Mode nMode)
+{
+    bool bWasPaused;
+
+    if (!BeginLoadGame(nGameId, nMode, bWasPaused))
+        return;
+
     // download the game data
     struct LoadGameUserData
     {
         bool bWasPaused = false;
-        std::string sOldRichPresence;
     }* pLoadGameUserData;
     pLoadGameUserData = new LoadGameUserData;
     pLoadGameUserData->bWasPaused = bWasPaused;
-    pLoadGameUserData->sOldRichPresence = std::move(sOldRichPresence);
 
+    auto& pRuntime = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
     pRuntime.BeginLoadGame(sGameHash, nGameId,
         [](int nResult, const char* sErrorMessage, rc_client_t*, void* pUserdata) {
             auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
             auto* pLoadGameUserData = static_cast<struct LoadGameUserData*>(pUserdata);
-            pGameContext.FinishLoadGame(nResult, sErrorMessage, pLoadGameUserData->bWasPaused,
-                                        pLoadGameUserData->sOldRichPresence);
+            pGameContext.FinishLoadGame(nResult, sErrorMessage, pLoadGameUserData->bWasPaused);
             delete pLoadGameUserData;
         },
         pLoadGameUserData);
 }
 
-void GameContext::FinishLoadGame(int nResult, const char* sErrorMessage, bool bWasPaused, const std::string& sOldRichPresence)
+void GameContext::FinishLoadGame(int nResult, const char* sErrorMessage, bool bWasPaused)
 {
     if (nResult != RC_OK)
     {
@@ -181,26 +175,22 @@ void GameContext::FinishLoadGame(int nResult, const char* sErrorMessage, bool bW
     }
     else
     {
-        BeginLoad();
-        auto pCodeNotes = std::make_unique<ra::data::models::CodeNotesModel>();
-        pCodeNotes->Refresh(
-            m_nGameId,
-            [this](ra::ByteAddress nAddress, const std::wstring& sNewNote) {
-                OnCodeNoteChanged(nAddress, sNewNote);
-            },
-            [this]() { EndLoad(); });
-        m_vAssets.Append(std::move(pCodeNotes));
-
         auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
         auto* pGame = rc_client_get_game_info(pClient.GetClient());
         if (pGame == nullptr || pGame->id == 0)
         {
             // invalid hash
+            m_nGameId = 0;
+            m_sGameTitle.clear();
+            m_sGameHash.clear();
+            nResult = RC_NO_GAME_LOADED;
         }
         else if (!ValidateConsole(pGame->console_id))
         {
             m_nGameId = 0;
             m_sGameTitle.clear();
+            m_sGameHash.clear();
+            nResult = RC_INVALID_STATE;
         }
         else
         {
@@ -218,13 +208,42 @@ void GameContext::FinishLoadGame(int nResult, const char* sErrorMessage, bool bW
                 ra::StringPrintf(L"Loaded %s", pGame->title), sDescription,
                 ra::StringPrintf(L"You have earned %u achievements", pSummary.num_unlocked_achievements),
                 ra::ui::ImageType::Icon, pGame->badge_name);
-
-            // merge local assets
-            std::vector<ra::data::models::AssetModelBase*> vEmptyAssetsList;
-            m_vAssets.ReloadAssets(vEmptyAssetsList);
         }
     }
 
+    EndLoadGame(nResult, bWasPaused, true);
+}
+
+void GameContext::EndLoadGame(int nResult, bool bWasPaused, bool bShowSoftcoreWarning)
+{
+    ra::data::models::RichPresenceModel* pRichPresence = nullptr;
+    std::string sOldRichPresence;
+
+    if (nResult == RC_OK && m_nGameId > 0) {
+        BeginLoad();
+
+        auto pCodeNotes = std::make_unique<ra::data::models::CodeNotesModel>();
+        pCodeNotes->Refresh(m_nGameId,
+            [this](ra::ByteAddress nAddress, const std::wstring& sNewNote) {
+                OnCodeNoteChanged(nAddress, sNewNote);
+            },
+            [this]() {
+                EndLoad();
+            });
+
+        m_vAssets.Append(std::move(pCodeNotes));
+
+        // the old server value (if different from current server value) will be stored as Local modification.
+        // capture it now. ReloadAssets will load the XXX-Rich.txt file and replace it
+        pRichPresence = m_vAssets.FindRichPresence();
+        if (pRichPresence)
+            sOldRichPresence = pRichPresence->GetScript();
+
+        // merge local assets
+        std::vector<ra::data::models::AssetModelBase*> vEmptyAssetsList;
+        m_vAssets.ReloadAssets(vEmptyAssetsList);
+    }
+
     if (!bWasPaused)
     {
         ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>().SetPaused(false);
@@ -235,7 +254,6 @@ void GameContext::FinishLoadGame(int nResult, const char* sErrorMessage, bool bW
     }
 
     // activate rich presence (or remove if not defined)
-    auto* pRichPresence = m_vAssets.FindRichPresence();
     if (pRichPresence && nResult == RC_OK)
     {
         // if the server value differs from the local value, the model will appear as Unpublished
@@ -264,15 +282,20 @@ void GameContext::FinishLoadGame(int nResult, const char* sErrorMessage, bool bW
     }
 
     // modified assets should start in the inactive state
+    size_t nLocalAssets = 0;
     for (gsl::index nIndex = 0; nIndex < gsl::narrow_cast<gsl::index>(m_vAssets.Count()); ++nIndex)
     {
         auto* pAsset = m_vAssets.GetItemAt(nIndex);
         if (pAsset != nullptr && pAsset->GetChanges() != ra::data::models::AssetChanges::None)
         {
+            if (pAsset->HasUnpublishedChanges())
+                ++nLocalAssets;
+
             if (pAsset->IsActive())
             {
                 if (pAsset->GetType() == ra::data::models::AssetType::RichPresence &&
-                    ra::services::ServiceLocator::Get<ra::ui::viewmodels::WindowManager>().RichPresenceMonitor.IsVisible())
+                    ra::services::ServiceLocator::Get<ra::ui::viewmodels::WindowManager>()
+                        .RichPresenceMonitor.IsVisible())
                 {
                     // if rich presence monitor is open, allow modified rich presence to remain active. otherwise,
                     // it will be activated when the monitor is opened. it cannot be activated from the list.
@@ -283,6 +306,10 @@ void GameContext::FinishLoadGame(int nResult, const char* sErrorMessage, bool bW
             }
         }
     }
+    if (nLocalAssets > 0)
+    {
+        RA_LOG_INFO("%d unpublished assets loaded", nLocalAssets);
+    }
 
     // finish up
     m_vAssets.EndUpdate();
@@ -302,18 +329,17 @@ void GameContext::FinishLoadGame(int nResult, const char* sErrorMessage, bool bW
 
         if (bShowHardcorePrompt)
         {
-            ra::services::ServiceLocator::GetMutable<ra::services::FrameEventQueue>().QueueFunction([]() {
-                ra::ui::viewmodels::MessageBoxViewModel vmWarning;
-                vmWarning.SetHeader(L"Enable Hardcore mode?");
-                vmWarning.SetMessage(L"You are loading a game with achievements and do not currently have hardcore mode enabled.");
-                vmWarning.SetIcon(ra::ui::viewmodels::MessageBoxViewModel::Icon::Warning);
-                vmWarning.SetButtons(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo);
-
-                if (vmWarning.ShowModal() == ra::ui::DialogResult::Yes)
-                    ra::services::ServiceLocator::GetMutable<ra::data::context::EmulatorContext>().EnableHardcoreMode(false);
-            });
+            ra::ui::viewmodels::MessageBoxViewModel vmWarning;
+            vmWarning.SetHeader(L"Enable Hardcore mode?");
+            vmWarning.SetMessage(
+                L"You are loading a game with achievements and do not currently have hardcore mode enabled.");
+            vmWarning.SetIcon(ra::ui::viewmodels::MessageBoxViewModel::Icon::Warning);
+            vmWarning.SetButtons(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo);
+
+            if (vmWarning.ShowModal() == ra::ui::DialogResult::Yes)
+                ra::services::ServiceLocator::GetMutable<ra::data::context::EmulatorContext>().EnableHardcoreMode(false);
         }
-        else
+        else if (bShowSoftcoreWarning)
         {
             const bool bLeaderboardsEnabled = pConfiguration.IsFeatureEnabled(ra::services::Feature::Leaderboards);
 
@@ -332,7 +358,9 @@ void GameContext::InitializeFromAchievementRuntime(const std::map<uint32_t, std:
 {
     const auto* pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>().GetClient();
     const auto* pGame = rc_client_get_game_info(pClient);
+    m_nGameId = pGame->id;
     m_sGameTitle = ra::Widen(pGame->title);
+    m_sGameHash = pGame->hash ? pGame->hash : "";
 
 #ifndef RA_UTEST
     auto& pImageRepository = ra::services::ServiceLocator::GetMutable<ra::ui::IImageRepository>();
@@ -392,6 +420,9 @@ void GameContext::InitializeFromAchievementRuntime(const std::map<uint32_t, std:
 #ifndef RA_UTEST
                 // prefetch the achievement image
                 pImageRepository.FetchImage(ra::ui::ImageType::Badge, pAchievementData->public_.badge_name);
+
+                if (!pAchievementData->public_.unlocked)
+                    pImageRepository.FetchImage(ra::ui::ImageType::Badge, std::string(pAchievementData->public_.badge_name) + "_lock");
 #endif
             }
         }
diff --git a/src/data/context/GameContext.hh b/src/data/context/GameContext.hh
index ccc1cc17..464ddc41 100644
--- a/src/data/context/GameContext.hh
+++ b/src/data/context/GameContext.hh
@@ -7,6 +7,8 @@
 #include <string>
 #include <atomic>
 
+namespace ra { namespace services { class AchievementRuntimeExports; } }
+
 namespace ra {
 namespace data {
 namespace context {
@@ -117,7 +119,11 @@ public:
 
 private:
     using NotifyTargetSet = std::set<NotifyTarget*>;
-    void FinishLoadGame(int nResult, const char* sErrorMessage, bool bWasPaused, const std::string& sOldRichPresence);
+    void FinishLoadGame(int nResult, const char* sErrorMessage, bool bWasPaused);
+
+    friend class ra::services::AchievementRuntimeExports;
+    bool BeginLoadGame(unsigned int nGameId, Mode nMode, bool& bWasPaused);
+    void EndLoadGame(int nResult, bool bWasPaused, bool bShowSoftcoreWarning);
 
 protected:
     void OnBeforeActiveGameChanged();
@@ -129,7 +135,6 @@ protected:
     unsigned int m_nGameId = 0;
     std::wstring m_sGameTitle;
     std::string m_sGameHash;
-    std::string m_sGameImage;
     Mode m_nMode{};
 
 private:
diff --git a/src/rcheevos.vcxproj b/src/rcheevos.vcxproj
index 36ee58dc..11e76cfa 100644
--- a/src/rcheevos.vcxproj
+++ b/src/rcheevos.vcxproj
@@ -147,6 +147,8 @@
     <Lib />
   </ItemDefinitionGroup>
   <ItemGroup>
+    <ClInclude Include="..\rcheevos\include\rc_client.h" />
+    <ClInclude Include="..\rcheevos\include\rc_client_raintegration.h" />
     <ClInclude Include="..\rcheevos\include\rc_runtime.h" />
     <ClInclude Include="..\rcheevos\include\rc_runtime_types.h" />
     <ClInclude Include="..\rcheevos\src\rcheevos\internal.h" />
@@ -162,6 +164,7 @@
     <ClCompile Include="..\rcheevos\src\rcheevos\rc_validate.c" />
     <ClCompile Include="..\rcheevos\src\rcheevos\richpresence.c" />
     <ClInclude Include="..\rcheevos\src\rcheevos\rc_internal.h" />
+    <ClInclude Include="..\rcheevos\src\rc_client_external.h" />
     <ClInclude Include="..\rcheevos\src\rhash\md5.h" />
     <ClCompile Include="..\lua\lapi.c" />
     <ClCompile Include="..\lua\lauxlib.c" />
diff --git a/src/rcheevos.vcxproj.filters b/src/rcheevos.vcxproj.filters
index c312b230..b94b7f00 100644
--- a/src/rcheevos.vcxproj.filters
+++ b/src/rcheevos.vcxproj.filters
@@ -161,5 +161,8 @@
     <ClInclude Include="..\rcheevos\include\rc_runtime_types.h">
       <Filter>rcheevos</Filter>
     </ClInclude>
+    <ClInclude Include="..\rcheevos\include\rc_client.h" />
+    <ClInclude Include="..\rcheevos\include\rc_client_raintegration.h" />
+    <ClInclude Include="..\rcheevos\src\rc_client_external.h" />
   </ItemGroup>
 </Project>
\ No newline at end of file
diff --git a/src/services/AchievementRuntime.cpp b/src/services/AchievementRuntime.cpp
index 11c1ae62..3002ce8d 100644
--- a/src/services/AchievementRuntime.cpp
+++ b/src/services/AchievementRuntime.cpp
@@ -3,6 +3,7 @@
 #include "Exports.hh"
 #include "RA_Defs.h"
 #include "RA_Log.h"
+#include "RA_Resource.h"
 #include "RA_StringUtils.h"
 
 #include "RA_md5factory.h"
@@ -22,12 +23,15 @@
 #include "services\ServiceLocator.hh"
 #include "services\impl\JsonFileConfiguration.hh"
 
+#include "ui\viewmodels\IntegrationMenuViewModel.hh"
+#include "ui\viewmodels\LoginViewModel.hh"
 #include "ui\viewmodels\MessageBoxViewModel.hh"
 #include "ui\viewmodels\OverlayManager.hh"
 #include "ui\viewmodels\PopupMessageViewModel.hh"
 #include "ui\viewmodels\WindowManager.hh"
 
 #include <rcheevos\include\rc_api_runtime.h>
+#include <rcheevos\include\rc_client_raintegration.h>
 #include <rcheevos\src\rapi\rc_api_common.h> // for parsing cached patchdata response
 #include <rcheevos\src\rcheevos\rc_internal.h>
 #include <rcheevos\src\rc_client_internal.h>
@@ -880,6 +884,8 @@ void AchievementRuntime::LoginCallback(int nResult, const char* sErrorMessage, r
             {
                 pUserContext.Initialize(user->username, user->display_name, user->token);
                 pUserContext.SetScore(user->score);
+
+                ra::ui::viewmodels::LoginViewModel::PostLoginInitialization();
             }
         }
     }
@@ -927,7 +933,7 @@ static void ExtractPatchData(const rc_api_server_response_t* server_response, ui
 GSL_SUPPRESS_CON3
 void AchievementRuntime::PostProcessGameDataResponse(const rc_api_server_response_t* server_response,
                                                  struct rc_api_fetch_game_data_response_t* game_data_response,
-                                                 rc_client_t* client, void* pUserdata)
+                                                 rc_client_t*, void* pUserdata)
 {
     auto* wrapper = static_cast<LoadGameCallbackWrapper*>(pUserdata);
     Expects(wrapper != nullptr);
@@ -937,18 +943,29 @@ void AchievementRuntime::PostProcessGameDataResponse(const rc_api_server_respons
     auto pRichPresence = std::make_unique<ra::data::models::RichPresenceModel>();
     pRichPresence->SetScript(game_data_response->rich_presence_script);
     pRichPresence->CreateServerCheckpoint();
+
+    // extract the old rich presence script from the last cached server response so we can tell if the
+    // local value has changed. store it as the local value, and we'll merge in the real local value later.
+    auto& pLocalStorage = ra::services::ServiceLocator::GetMutable<ra::services::ILocalStorage>();
+    auto pData = pLocalStorage.ReadText(ra::services::StorageItemType::GameData, std::to_wstring(game_data_response->id));
+    if (pData != nullptr)
+    {
+        rapidjson::Document pDocument;
+        if (LoadDocument(pDocument, *pData) &&
+            pDocument.HasMember("RichPresencePatch") &&
+            pDocument["RichPresencePatch"].IsString())
+        {
+            pRichPresence->SetScript(pDocument["RichPresencePatch"].GetString());
+        }
+    }
+
     pRichPresence->CreateLocalCheckpoint();
     pGameContext.Assets().Append(std::move(pRichPresence));
 
 #ifndef RA_UTEST
     // prefetch the game icon
-    if (client->game)
-    {
-        auto& pImageRepository = ra::services::ServiceLocator::GetMutable<ra::ui::IImageRepository>();
-        pImageRepository.FetchImage(ra::ui::ImageType::Icon, client->game->public_.badge_name);
-    }
-#else
-    (void*)client;
+    auto& pImageRepository = ra::services::ServiceLocator::GetMutable<ra::ui::IImageRepository>();
+    pImageRepository.FetchImage(ra::ui::ImageType::Icon, game_data_response->image_name);
 #endif
 
     const rc_api_achievement_definition_t* pAchievement = game_data_response->achievements;
@@ -2323,464 +2340,6 @@ int AchievementRuntime::SaveProgressToBuffer(uint8_t* pBuffer, int nBufferSize)
     return nSize;
 }
 
-/* ---- Exports ----- */
-
-#ifdef RC_CLIENT_EXPORTS_EXTERNAL
-
-class AchievementRuntimeExports : private AchievementRuntime
-{
-public:
-    static void destroy() noexcept
-    {
-        memset(&s_callbacks, 0, sizeof(s_callbacks));
-    }
-
-    static void enable_logging(rc_client_t* client, int level, rc_client_message_callback_t callback)
-    {
-        s_callbacks.log_callback = callback;
-        s_callbacks.log_client = client;
-
-        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-        rc_client_enable_logging(pClient.GetClient(), level, AchievementRuntimeExports::LogMessageExternal);
-    }
-
-    static void set_event_handler(rc_client_t* client, rc_client_event_handler_t handler)
-    {
-        s_callbacks.event_handler = handler;
-        s_callbacks.event_client = client;
-
-        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-        rc_client_set_event_handler(pClient.GetClient(), AchievementRuntimeExports::EventHandlerExternal);
-    }
-
-    static void set_read_memory(rc_client_t* client, rc_client_read_memory_func_t handler)
-    {
-        s_callbacks.read_memory_handler = handler;
-        s_callbacks.read_memory_client = client;
-
-        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-        rc_client_set_read_memory_function(pClient.GetClient(), AchievementRuntimeExports::ReadMemoryExternal);
-
-        auto& pEmulatorContext = ra::services::ServiceLocator::GetMutable<ra::data::context::EmulatorContext>();
-        pEmulatorContext.ClearMemoryBlocks();
-        pEmulatorContext.AddMemoryBlock(0, 0xFFFFFFFF, nullptr, nullptr);
-        pEmulatorContext.AddMemoryBlockReader(0, AchievementRuntimeExports::ReadMemoryBlock);
-    }
-
-    static void set_get_time_millisecs(rc_client_t* client, rc_get_time_millisecs_func_t handler)
-    {
-        s_callbacks.get_time_millisecs_handler = handler;
-        s_callbacks.get_time_millisecs_client = client;
-
-        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-        rc_client_set_get_time_millisecs_function(pClient.GetClient(), AchievementRuntimeExports::GetTimeMillisecsExternal);
-    }
-
-    static void set_host(const char* value)
-    {
-        auto* pConfiguration = dynamic_cast<ra::services::impl::JsonFileConfiguration*>(
-            &ra::services::ServiceLocator::GetMutable<ra::services::IConfiguration>());
-        if (pConfiguration != nullptr)
-            pConfiguration->SetHost(value);
-    }
-
-    static void set_hardcore_enabled(int value)
-    {
-        auto& pEmulatorContext = ra::services::ServiceLocator::GetMutable<ra::data::context::EmulatorContext>();
-        if (value)
-            pEmulatorContext.EnableHardcoreMode(false);
-        else
-            pEmulatorContext.DisableHardcoreMode();
-    }
-
-    static int get_hardcore_enabled()
-    {
-        const auto& pConfiguration = ra::services::ServiceLocator::Get<ra::services::IConfiguration>();
-        return pConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore);
-    }
-
-    static void set_unofficial_enabled(int value)
-    {
-        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-        rc_client_set_unofficial_enabled(pClient.GetClient(), value);
-    }
-
-    static int get_unofficial_enabled()
-    {
-        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
-        return rc_client_get_unofficial_enabled(pClient.GetClient());
-    }
-
-    static void set_encore_mode_enabled(int value)
-    {
-        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-        rc_client_set_encore_mode_enabled(pClient.GetClient(), value);
-    }
-
-    static int get_encore_mode_enabled()
-    {
-        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
-        return rc_client_get_encore_mode_enabled(pClient.GetClient());
-    }
-
-    static void set_spectator_mode_enabled(int value)
-    {
-        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-        rc_client_set_spectator_mode_enabled(pClient.GetClient(), value);
-    }
-
-    static int get_spectator_mode_enabled()
-    {
-        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
-        return rc_client_get_spectator_mode_enabled(pClient.GetClient());
-    }
-
-    static void abort_async(rc_client_async_handle_t* handle)
-    {
-        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
-        rc_client_abort_async(pClient.GetClient(), handle);
-    }
-
-    static rc_client_async_handle_t* begin_login_with_password(rc_client_t* client, const char* username,
-                                                               const char* password, rc_client_callback_t callback,
-                                                               void* callback_userdata)
-    {
-        GSL_SUPPRESS_R3
-        auto* pCallbackData = new CallbackWrapper(client, callback, callback_userdata);
-
-        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-        return pClient.BeginLoginWithPassword(username, password, pCallbackData);
-    }
-
-    static rc_client_async_handle_t* begin_login_with_token(rc_client_t* client, const char* username,
-                                                            const char* token, rc_client_callback_t callback,
-                                                            void* callback_userdata)
-    {
-        GSL_SUPPRESS_R3
-        auto* pCallbackWrapper = new LoadGameCallbackWrapper(client, callback, callback_userdata);
-
-        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-        return pClient.BeginLoginWithToken(username, token, pCallbackWrapper);
-    }
-
-    static const rc_client_user_t* get_user_info()
-    {
-        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-        return rc_client_get_user_info(pClient.GetClient());
-    }
-
-    static void logout()
-    {
-        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-        return rc_client_logout(pClient.GetClient());
-    }
-
-    static rc_client_async_handle_t* begin_identify_and_load_game(rc_client_t* client, uint32_t console_id,
-                                                                  const char* file_path, const uint8_t* data,
-                                                                  size_t data_size, rc_client_callback_t callback,
-                                                                  void* callback_userdata)
-    {
-        GSL_SUPPRESS_R3
-        auto* pCallbackWrapper = new LoadGameCallbackWrapper(client, callback, callback_userdata);
-
-        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-        return pClient.BeginIdentifyAndLoadGame(console_id, file_path, data, data_size, pCallbackWrapper);
-    }
-
-    static rc_client_async_handle_t* begin_load_game(rc_client_t* client, const char* hash,
-                                                     rc_client_callback_t callback, void* callback_userdata)
-    {
-        GSL_SUPPRESS_R3
-        auto* pCallbackData = new CallbackWrapper(client, callback, callback_userdata);
-
-        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-        return pClient.BeginLoadGame(hash, 0, pCallbackData);
-    }
-
-    static void unload_game()
-    {
-        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-        return rc_client_unload_game(pClient.GetClient());
-    }
-
-    static const rc_client_game_t* get_game_info()
-    {
-        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
-        return rc_client_get_game_info(pClient.GetClient());
-    }
-
-    static const rc_client_subset_t* get_subset_info(uint32_t subset_id)
-    {
-        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
-        return rc_client_get_subset_info(pClient.GetClient(), subset_id);
-    }
-
-    static void get_user_game_summary(rc_client_user_game_summary_t* summary)
-    {
-        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
-        return rc_client_get_user_game_summary(pClient.GetClient(), summary);
-    }
-
-    static rc_client_async_handle_t* begin_change_media(rc_client_t* client, const char* file_path,
-                                                        const uint8_t* data, size_t data_size,
-                                                        rc_client_callback_t callback, void* callback_userdata)
-    {
-        GSL_SUPPRESS_R3
-        auto* pCallbackData = new CallbackWrapper(client, callback, callback_userdata);
-
-        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-        return pClient.BeginChangeMedia(file_path, data, data_size, pCallbackData);
-    }
-
-    static rc_client_achievement_list_info_t* create_achievement_list(int category, int grouping)
-    {
-        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-        GSL_SUPPRESS_TYPE1
-        auto* list = reinterpret_cast<rc_client_achievement_list_info_t*>(
-            rc_client_create_achievement_list(pClient.GetClient(), category, grouping));
-        list->destroy_func = destroy_achievement_list;
-        return list;
-    }
-
-    static int has_achievements()
-    {
-        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
-        return rc_client_has_achievements(pClient.GetClient());
-    }
-
-    static const rc_client_achievement_t* get_achievement_info(uint32_t id)
-    {
-        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
-        return rc_client_get_achievement_info(pClient.GetClient(), id);
-    }
-
-    static rc_client_leaderboard_list_info_t* create_leaderboard_list(int grouping)
-    {
-        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-        GSL_SUPPRESS_TYPE1
-        auto* list = reinterpret_cast<rc_client_leaderboard_list_info_t*>(
-            rc_client_create_leaderboard_list(pClient.GetClient(), grouping));
-        list->destroy_func = destroy_leaderboard_list;
-        return list;
-    }
-
-    static int has_leaderboards()
-    {
-        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
-        return rc_client_has_leaderboards(pClient.GetClient());
-    }
-
-    static const rc_client_leaderboard_t* get_leaderboard_info(uint32_t id)
-    {
-        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
-        return rc_client_get_leaderboard_info(pClient.GetClient(), id);
-    }
-
-private:
-    class LeaderboardEntriesListCallbackWrapper
-    {
-    public:
-        LeaderboardEntriesListCallbackWrapper(rc_client_t* client,
-                                              rc_client_fetch_leaderboard_entries_callback_t callback,
-                                              void* callback_userdata) noexcept :
-            m_pClient(client), m_fCallback(callback), m_pCallbackUserdata(callback_userdata)
-        {}
-
-        void DoCallback(int nResult, rc_client_leaderboard_entry_list_t* pList, const char* sErrorMessage) noexcept
-        {
-            m_fCallback(nResult, sErrorMessage, pList, m_pClient, m_pCallbackUserdata);
-        }
-
-        static void Dispatch(int nResult, const char* sErrorMessage, rc_client_leaderboard_entry_list_t* pList,
-                             rc_client_t*, void* pUserdata)
-        {
-            auto* wrapper = static_cast<LeaderboardEntriesListCallbackWrapper*>(pUserdata);
-            Expects(wrapper != nullptr);
-
-            if (pList)
-            {
-                GSL_SUPPRESS_TYPE1
-                reinterpret_cast<rc_client_leaderboard_entry_list_info_t*>(pList)->destroy_func = destroy_leaderboard_entry_list;
-            }
-
-            wrapper->DoCallback(nResult, pList, sErrorMessage);
-
-            delete wrapper;
-        }
-
-    private:
-        rc_client_t* m_pClient;
-        rc_client_fetch_leaderboard_entries_callback_t m_fCallback;
-        void* m_pCallbackUserdata;
-    };
-
-public:
-    static rc_client_async_handle_t* begin_fetch_leaderboard_entries(rc_client_t* client,
-        uint32_t leaderboard_id, uint32_t first_entry, uint32_t count,
-        rc_client_fetch_leaderboard_entries_callback_t callback, void* callback_userdata)
-    {
-        GSL_SUPPRESS_R3
-        auto* pCallbackData = new LeaderboardEntriesListCallbackWrapper(client, callback, callback_userdata);
-
-        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-        return rc_client_begin_fetch_leaderboard_entries(pClient.GetClient(), leaderboard_id, first_entry, count,
-                                                         LeaderboardEntriesListCallbackWrapper::Dispatch, pCallbackData);
-    }
-
-    static rc_client_async_handle_t* begin_fetch_leaderboard_entries_around_user(rc_client_t* client,
-        uint32_t leaderboard_id, uint32_t count,
-        rc_client_fetch_leaderboard_entries_callback_t callback, void* callback_userdata)
-    {
-        GSL_SUPPRESS_R3
-        auto* pCallbackData = new LeaderboardEntriesListCallbackWrapper(client, callback, callback_userdata);
-
-        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-        return rc_client_begin_fetch_leaderboard_entries_around_user(pClient.GetClient(), leaderboard_id, count,
-                                                                     LeaderboardEntriesListCallbackWrapper::Dispatch, pCallbackData);
-    }
-
-    static size_t get_rich_presence_message(char buffer[], size_t buffer_size)
-    {
-        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
-        return rc_client_get_rich_presence_message(pClient.GetClient(), buffer, buffer_size);
-    }
-
-    static int has_rich_presence()
-    {
-        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
-        return rc_client_has_rich_presence(pClient.GetClient());
-    }
-
-    static void do_frame() noexcept
-    {
-        _RA_DoAchievementsFrame();
-    }
-
-    static void idle()
-    {
-        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-        return rc_client_idle(pClient.GetClient());
-    }
-
-    static int is_processing_required()
-    {
-        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
-        return rc_client_is_processing_required(pClient.GetClient());
-    }
-
-    static int can_pause(uint32_t* frames_remaining)
-    {
-        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
-        return rc_client_can_pause(pClient.GetClient(), frames_remaining);
-    }
-
-    static void reset() noexcept
-    {
-#ifndef RA_UTEST
-        _RA_OnReset();
-#endif
-    }
-
-    static size_t progress_size()
-    {
-        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
-        return rc_client_progress_size(pClient.GetClient());
-    }
-
-    static int serialize_progress(uint8_t* buffer)
-    {
-        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
-        return rc_client_serialize_progress(pClient.GetClient(), buffer);
-    }
-
-    static int deserialize_progress(const uint8_t* buffer)
-    {
-        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-        return rc_client_deserialize_progress(pClient.GetClient(), buffer);
-    }
-
-private:
-    typedef struct ExternalClientCallbacks
-    {
-        rc_client_message_callback_t log_callback;
-        rc_client_t* log_client;
-
-        rc_client_event_handler_t event_handler;
-        rc_client_t* event_client;
-
-        rc_client_read_memory_func_t read_memory_handler;
-        rc_client_t* read_memory_client;
-
-        rc_get_time_millisecs_func_t get_time_millisecs_handler;
-        rc_client_t* get_time_millisecs_client;
-
-    } ExternalClientCallbacks;
-
-    static ExternalClientCallbacks s_callbacks;
-
-    static void LogMessageExternal(const char* sMessage, const rc_client_t*)
-    {
-        const auto& pLogger = ra::services::ServiceLocator::Get<ra::services::ILogger>();
-        if (pLogger.IsEnabled(ra::services::LogLevel::Info))
-            pLogger.LogMessage(ra::services::LogLevel::Info, sMessage);
-
-        if (s_callbacks.log_callback)
-            s_callbacks.log_callback(sMessage, s_callbacks.log_client);
-    }
-
-    static void EventHandlerExternal(const rc_client_event_t* event, rc_client_t*) noexcept(false)
-    {
-        if (s_callbacks.event_handler)
-            s_callbacks.event_handler(event, s_callbacks.event_client);
-    }
-
-    static uint32_t ReadMemoryBlock(uint32_t address, uint8_t* buffer, uint32_t num_bytes) noexcept(false)
-    {
-        if (s_callbacks.read_memory_handler)
-            return s_callbacks.read_memory_handler(address, buffer, num_bytes, s_callbacks.read_memory_client);
-
-        return 0;
-    }
-
-    static uint32_t ReadMemoryExternal(uint32_t address, uint8_t* buffer, uint32_t num_bytes, rc_client_t*)  noexcept(false)
-    {
-        if (s_callbacks.read_memory_handler)
-            return s_callbacks.read_memory_handler(address, buffer, num_bytes, s_callbacks.read_memory_client);
-
-        return 0;
-    }
-
-    static rc_clock_t GetTimeMillisecsExternal(const rc_client_t*) noexcept(false)
-    {
-        if (s_callbacks.get_time_millisecs_handler)
-            return s_callbacks.get_time_millisecs_handler(s_callbacks.get_time_millisecs_client);
-
-        return 0;
-    }
-
-    static void destroy_achievement_list(rc_client_achievement_list_info_t* list) noexcept
-    {
-        if (list)
-            free(list);
-    }
-
-    static void destroy_leaderboard_list(rc_client_leaderboard_list_info_t* list) noexcept
-    {
-        if (list)
-            free(list);
-    }
-
-    static void destroy_leaderboard_entry_list(rc_client_leaderboard_entry_list_info_t* list) noexcept
-    {
-        if (list)
-            free(list);
-    }
-};
-
-AchievementRuntimeExports::ExternalClientCallbacks AchievementRuntimeExports::s_callbacks{};
-
-#endif RC_CLIENT_EXPORTS_EXTERNAL
-
 } // namespace services
 } // namespace ra
 
@@ -2799,93 +2358,3 @@ extern "C" unsigned int rc_peek_callback(unsigned int nAddress, unsigned int nBy
             return 0U;
     }
 }
-
-#ifdef RC_CLIENT_EXPORTS_EXTERNAL
-
-#include "Exports.hh"
-#include "rcheevos/src/rc_client_external.h"
-
-#ifdef __cplusplus
-extern "C" {
-#endif
-
-static void GetExternalClientV1(rc_client_external_t* pClientExternal)
-{
-    pClientExternal->destroy = ra::services::AchievementRuntimeExports::destroy;
-
-    pClientExternal->enable_logging = ra::services::AchievementRuntimeExports::enable_logging;
-    pClientExternal->set_event_handler = ra::services::AchievementRuntimeExports::set_event_handler;
-    pClientExternal->set_read_memory = ra::services::AchievementRuntimeExports::set_read_memory;
-    pClientExternal->set_get_time_millisecs = ra::services::AchievementRuntimeExports::set_get_time_millisecs;
-    pClientExternal->set_host = ra::services::AchievementRuntimeExports::set_host;
-
-    pClientExternal->set_hardcore_enabled = ra::services::AchievementRuntimeExports::set_hardcore_enabled;
-    pClientExternal->get_hardcore_enabled = ra::services::AchievementRuntimeExports::get_hardcore_enabled;
-    pClientExternal->set_unofficial_enabled = ra::services::AchievementRuntimeExports::set_unofficial_enabled;
-    pClientExternal->get_unofficial_enabled = ra::services::AchievementRuntimeExports::get_unofficial_enabled;
-    pClientExternal->set_encore_mode_enabled = ra::services::AchievementRuntimeExports::set_encore_mode_enabled;
-    pClientExternal->get_encore_mode_enabled = ra::services::AchievementRuntimeExports::get_encore_mode_enabled;
-    pClientExternal->set_spectator_mode_enabled = ra::services::AchievementRuntimeExports::set_spectator_mode_enabled;
-    pClientExternal->get_spectator_mode_enabled = ra::services::AchievementRuntimeExports::get_spectator_mode_enabled;
-
-    pClientExternal->abort_async = ra::services::AchievementRuntimeExports::abort_async;
-
-    pClientExternal->begin_login_with_password = ra::services::AchievementRuntimeExports::begin_login_with_password;
-    pClientExternal->begin_login_with_token = ra::services::AchievementRuntimeExports::begin_login_with_token;
-    pClientExternal->logout = ra::services::AchievementRuntimeExports::logout;
-    pClientExternal->get_user_info = ra::services::AchievementRuntimeExports::get_user_info;
-
-    pClientExternal->begin_identify_and_load_game = ra::services::AchievementRuntimeExports::begin_identify_and_load_game;
-    pClientExternal->begin_load_game = ra::services::AchievementRuntimeExports::begin_load_game;
-    pClientExternal->get_game_info = ra::services::AchievementRuntimeExports::get_game_info;
-    pClientExternal->get_subset_info = ra::services::AchievementRuntimeExports::get_subset_info;
-    pClientExternal->unload_game = ra::services::AchievementRuntimeExports::unload_game;
-    pClientExternal->get_user_game_summary = ra::services::AchievementRuntimeExports::get_user_game_summary;
-    pClientExternal->begin_change_media = ra::services::AchievementRuntimeExports::begin_change_media;
-
-    pClientExternal->create_achievement_list = ra::services::AchievementRuntimeExports::create_achievement_list;
-    pClientExternal->has_achievements = ra::services::AchievementRuntimeExports::has_achievements;
-    pClientExternal->get_achievement_info = ra::services::AchievementRuntimeExports::get_achievement_info;
-
-    pClientExternal->create_leaderboard_list = ra::services::AchievementRuntimeExports::create_leaderboard_list;
-    pClientExternal->has_leaderboards = ra::services::AchievementRuntimeExports::has_leaderboards;
-    pClientExternal->get_leaderboard_info = ra::services::AchievementRuntimeExports::get_leaderboard_info;
-    pClientExternal->begin_fetch_leaderboard_entries = ra::services::AchievementRuntimeExports::begin_fetch_leaderboard_entries;
-    pClientExternal->begin_fetch_leaderboard_entries_around_user =
-        ra::services::AchievementRuntimeExports::begin_fetch_leaderboard_entries_around_user;
-
-    pClientExternal->get_rich_presence_message = ra::services::AchievementRuntimeExports::get_rich_presence_message;
-    pClientExternal->has_rich_presence = ra::services::AchievementRuntimeExports::has_rich_presence;
-
-    pClientExternal->do_frame = ra::services::AchievementRuntimeExports::do_frame;
-    pClientExternal->idle = ra::services::AchievementRuntimeExports::idle;
-    pClientExternal->is_processing_required = ra::services::AchievementRuntimeExports::is_processing_required;
-    pClientExternal->can_pause = ra::services::AchievementRuntimeExports::can_pause;
-    pClientExternal->reset = ra::services::AchievementRuntimeExports::reset;
-
-    pClientExternal->progress_size = ra::services::AchievementRuntimeExports::progress_size;
-    pClientExternal->serialize_progress = ra::services::AchievementRuntimeExports::serialize_progress;
-    pClientExternal->deserialize_progress = ra::services::AchievementRuntimeExports::deserialize_progress;
-}
-
-API int CCONV _Rcheevos_GetExternalClient(rc_client_external_t* pClientExternal, int nVersion)
-{
-    switch (nVersion)
-    {
-        default:
-            RA_LOG_WARN("Unknown rc_client_external interface version: %s", nVersion);
-            __fallthrough;
-
-        case 1:
-            GetExternalClientV1(pClientExternal);
-            break;
-    }
-
-    return 1;
-}
-
-#ifdef __cplusplus
-} // extern "C"
-#endif
-
-#endif /* RC_CLIENT_EXPORTS_EXTERNAL */
diff --git a/src/services/AchievementRuntime.hh b/src/services/AchievementRuntime.hh
index 5c801c85..fbdcb6bb 100644
--- a/src/services/AchievementRuntime.hh
+++ b/src/services/AchievementRuntime.hh
@@ -13,8 +13,6 @@
 
 #include <string>
 
-#define RC_CLIENT_EXPORTS_EXTERNAL
-
 #include <rcheevos\include\rc_client.h>
 
 struct rc_api_fetch_game_data_response_t;
diff --git a/src/services/AchievementRuntimeExports.cpp b/src/services/AchievementRuntimeExports.cpp
new file mode 100644
index 00000000..25ede0a8
--- /dev/null
+++ b/src/services/AchievementRuntimeExports.cpp
@@ -0,0 +1,950 @@
+#include "AchievementRuntime.hh"
+
+#include "Exports.hh"
+#include "RA_Log.h"
+#include "RA_Resource.h"
+
+#include "data\context\ConsoleContext.hh"
+#include "data\context\GameContext.hh"
+
+#include "services\AchievementRuntime.hh"
+#include "services\IConfiguration.hh"
+#include "services\ServiceLocator.hh"
+#include "services\impl\JsonFileConfiguration.hh"
+
+#include "ui\viewmodels\IntegrationMenuViewModel.hh"
+
+#include <rcheevos\src\rc_client_internal.h>
+#include <rcheevos\src\rc_client_external.h>
+#include <rcheevos\include\rc_client_raintegration.h>
+
+namespace ra {
+namespace services {
+
+class AchievementRuntimeExports : private AchievementRuntime
+{
+public:
+    static void destroy() noexcept
+    {
+        memset(&s_callbacks, 0, sizeof(s_callbacks));
+
+        if (s_pIntegrationMenu)
+        {
+            s_pIntegrationMenu = nullptr;
+            rc_buffer_destroy(&s_pIntegrationMenuBuffer);
+        }
+    }
+
+    static void enable_logging(rc_client_t* client, int level, rc_client_message_callback_t callback)
+    {
+        s_callbacks.log_callback = callback;
+        s_callbacks.log_client = client;
+
+        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        rc_client_enable_logging(pClient.GetClient(), level, AchievementRuntimeExports::LogMessageExternal);
+    }
+
+    static void set_event_handler(rc_client_t* client, rc_client_event_handler_t handler)
+    {
+        s_callbacks.event_handler = handler;
+        s_callbacks.event_client = client;
+
+        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        rc_client_set_event_handler(pClient.GetClient(), AchievementRuntimeExports::EventHandlerExternal);
+    }
+
+    static void set_read_memory(rc_client_t* client, rc_client_read_memory_func_t handler)
+    {
+        s_callbacks.read_memory_handler = handler;
+        s_callbacks.read_memory_client = client;
+
+        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        rc_client_set_read_memory_function(pClient.GetClient(), AchievementRuntimeExports::ReadMemoryExternal);
+
+        ResetMemory();
+    }
+
+    static void set_get_time_millisecs(rc_client_t* client, rc_get_time_millisecs_func_t handler)
+    {
+        s_callbacks.get_time_millisecs_handler = handler;
+        s_callbacks.get_time_millisecs_client = client;
+
+        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        rc_client_set_get_time_millisecs_function(pClient.GetClient(), AchievementRuntimeExports::GetTimeMillisecsExternal);
+    }
+
+    static void set_host(const char* value)
+    {
+        auto* pConfiguration = dynamic_cast<ra::services::impl::JsonFileConfiguration*>(
+            &ra::services::ServiceLocator::GetMutable<ra::services::IConfiguration>());
+        if (pConfiguration != nullptr)
+            pConfiguration->SetHost(value);
+    }
+
+    static bool IsUpdatingHardcore() noexcept { return s_bUpdatingHardcore; }
+
+    static void set_hardcore_enabled(int value)
+    {
+        auto& pEmulatorContext = ra::services::ServiceLocator::GetMutable<ra::data::context::EmulatorContext>();
+
+        s_bUpdatingHardcore = true; // prevent raising RC_CLIENT_RAINTEGRATION_EVENT_HARDCORE_CHANGED event
+
+        if (value)
+            pEmulatorContext.EnableHardcoreMode(false);
+        else
+            pEmulatorContext.DisableHardcoreMode();
+
+        s_bUpdatingHardcore = false;
+    }
+
+    static int get_hardcore_enabled()
+    {
+        const auto& pConfiguration = ra::services::ServiceLocator::Get<ra::services::IConfiguration>();
+        return pConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore);
+    }
+
+    static void set_unofficial_enabled(int value)
+    {
+        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        rc_client_set_unofficial_enabled(pClient.GetClient(), value);
+    }
+
+    static int get_unofficial_enabled()
+    {
+        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+        return rc_client_get_unofficial_enabled(pClient.GetClient());
+    }
+
+    static void set_encore_mode_enabled(int value)
+    {
+        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        rc_client_set_encore_mode_enabled(pClient.GetClient(), value);
+    }
+
+    static int get_encore_mode_enabled()
+    {
+        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+        return rc_client_get_encore_mode_enabled(pClient.GetClient());
+    }
+
+    static void set_spectator_mode_enabled(int value)
+    {
+        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        rc_client_set_spectator_mode_enabled(pClient.GetClient(), value);
+    }
+
+    static int get_spectator_mode_enabled()
+    {
+        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+        return rc_client_get_spectator_mode_enabled(pClient.GetClient());
+    }
+
+    static void abort_async(rc_client_async_handle_t* handle)
+    {
+        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+        rc_client_abort_async(pClient.GetClient(), handle);
+    }
+
+    static rc_client_async_handle_t* begin_login_with_password(rc_client_t* client, const char* username,
+                                                               const char* password, rc_client_callback_t callback,
+                                                               void* callback_userdata)
+    {
+        GSL_SUPPRESS_R3
+        auto* pCallbackData = new CallbackWrapper(client, callback, callback_userdata);
+
+        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        return pClient.BeginLoginWithPassword(username, password, pCallbackData);
+    }
+
+    static rc_client_async_handle_t* begin_login_with_token(rc_client_t* client, const char* username,
+                                                            const char* token, rc_client_callback_t callback,
+                                                            void* callback_userdata)
+    {
+        GSL_SUPPRESS_R3
+        auto* pCallbackWrapper = new LoadGameCallbackWrapper(client, callback, callback_userdata);
+
+        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        return pClient.BeginLoginWithToken(username, token, pCallbackWrapper);
+    }
+
+    static const rc_client_user_t* get_user_info()
+    {
+        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        return rc_client_get_user_info(pClient.GetClient());
+    }
+
+    static void logout()
+    {
+        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        return rc_client_logout(pClient.GetClient());
+    }
+
+    class LoadExternalGameCallbackWrapper : public CallbackWrapper
+    {
+    public:
+        LoadExternalGameCallbackWrapper(rc_client_t* client, rc_client_callback_t callback,
+                                        void* callback_userdata) noexcept :
+            CallbackWrapper(client, callback, callback_userdata)
+        {}
+
+        bool bWasPaused = false;
+    };
+
+    static void load_game_callback(int nResult, const char* sErrorMessage, rc_client_t*, void* pUserdata)
+    {
+        auto* wrapper = static_cast<LoadExternalGameCallbackWrapper*>(pUserdata);
+        Expects(wrapper != nullptr);
+
+        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        const auto* pGame = pClient.GetClient()->game;
+        if (pGame)
+        {
+            _RA_SetConsoleID(pGame->public_.console_id);
+            ResetMemory();
+        }
+
+        auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
+        pGameContext.EndLoadGame(nResult, wrapper->bWasPaused, false);
+
+        wrapper->DoCallback(nResult, sErrorMessage);
+
+        delete wrapper;
+    }
+
+    static rc_client_async_handle_t* begin_identify_and_load_game(rc_client_t* client, uint32_t console_id,
+                                                                  const char* file_path, const uint8_t* data,
+                                                                  size_t data_size, rc_client_callback_t callback,
+                                                                  void* callback_userdata)
+    {
+        GSL_SUPPRESS_R3
+        auto* pNestedCallbackData = new LoadExternalGameCallbackWrapper(client, callback, callback_userdata);
+        GSL_SUPPRESS_R3
+        auto* pCallbackData = new LoadGameCallbackWrapper(client, load_game_callback, pNestedCallbackData);
+
+        auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
+        pGameContext.BeginLoadGame(0xFFFFFFFF, ra::data::context::GameContext::Mode::Normal,
+                                   pNestedCallbackData->bWasPaused);
+
+        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        return pClient.BeginIdentifyAndLoadGame(console_id, file_path, data, data_size, pCallbackData);
+    }
+
+    static rc_client_async_handle_t* begin_load_game(rc_client_t* client, const char* hash,
+                                                     rc_client_callback_t callback, void* callback_userdata)
+    {
+        GSL_SUPPRESS_R3
+        auto* pNestedCallbackData = new LoadExternalGameCallbackWrapper(client, callback, callback_userdata);
+        GSL_SUPPRESS_R3
+        auto* pCallbackData = new LoadGameCallbackWrapper(client, load_game_callback, pNestedCallbackData);
+
+        auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
+        pGameContext.BeginLoadGame(0xFFFFFFFF, ra::data::context::GameContext::Mode::Normal,
+                                   pNestedCallbackData->bWasPaused);
+
+        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        return pClient.BeginLoadGame(hash, 0, pCallbackData);
+    }
+
+    static void unload_game()
+    {
+        _RA_ActivateGame(0);
+
+        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        return rc_client_unload_game(pClient.GetClient());
+    }
+
+    static const rc_client_game_t* get_game_info()
+    {
+        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+        return rc_client_get_game_info(pClient.GetClient());
+    }
+
+    static const rc_client_subset_t* get_subset_info(uint32_t subset_id)
+    {
+        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+        return rc_client_get_subset_info(pClient.GetClient(), subset_id);
+    }
+
+    static void get_user_game_summary(rc_client_user_game_summary_t* summary)
+    {
+        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+        return rc_client_get_user_game_summary(pClient.GetClient(), summary);
+    }
+
+    static rc_client_async_handle_t* begin_change_media(rc_client_t* client, const char* file_path,
+                                                        const uint8_t* data, size_t data_size,
+                                                        rc_client_callback_t callback, void* callback_userdata)
+    {
+        GSL_SUPPRESS_R3
+        auto* pCallbackData = new CallbackWrapper(client, callback, callback_userdata);
+
+        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        return pClient.BeginChangeMedia(file_path, data, data_size, pCallbackData);
+    }
+
+    static rc_client_achievement_list_info_t* create_achievement_list(int category, int grouping)
+    {
+        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        GSL_SUPPRESS_TYPE1
+        auto* list = reinterpret_cast<rc_client_achievement_list_info_t*>(
+            rc_client_create_achievement_list(pClient.GetClient(), category, grouping));
+        list->destroy_func = destroy_achievement_list;
+        return list;
+    }
+
+    static int has_achievements()
+    {
+        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+        return rc_client_has_achievements(pClient.GetClient());
+    }
+
+    static const rc_client_achievement_t* get_achievement_info(uint32_t id)
+    {
+        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+        return rc_client_get_achievement_info(pClient.GetClient(), id);
+    }
+
+    static rc_client_leaderboard_list_info_t* create_leaderboard_list(int grouping)
+    {
+        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        GSL_SUPPRESS_TYPE1
+        auto* list = reinterpret_cast<rc_client_leaderboard_list_info_t*>(
+            rc_client_create_leaderboard_list(pClient.GetClient(), grouping));
+        list->destroy_func = destroy_leaderboard_list;
+        return list;
+    }
+
+    static int has_leaderboards()
+    {
+        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+        return rc_client_has_leaderboards(pClient.GetClient());
+    }
+
+    static const rc_client_leaderboard_t* get_leaderboard_info(uint32_t id)
+    {
+        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+        return rc_client_get_leaderboard_info(pClient.GetClient(), id);
+    }
+
+private:
+    class LeaderboardEntriesListCallbackWrapper
+    {
+    public:
+        LeaderboardEntriesListCallbackWrapper(rc_client_t* client,
+                                              rc_client_fetch_leaderboard_entries_callback_t callback,
+                                              void* callback_userdata) noexcept :
+            m_pClient(client), m_fCallback(callback), m_pCallbackUserdata(callback_userdata)
+        {}
+
+        void DoCallback(int nResult, rc_client_leaderboard_entry_list_t* pList, const char* sErrorMessage) noexcept
+        {
+            m_fCallback(nResult, sErrorMessage, pList, m_pClient, m_pCallbackUserdata);
+        }
+
+        static void Dispatch(int nResult, const char* sErrorMessage, rc_client_leaderboard_entry_list_t* pList,
+                             rc_client_t*, void* pUserdata)
+        {
+            auto* wrapper = static_cast<LeaderboardEntriesListCallbackWrapper*>(pUserdata);
+            Expects(wrapper != nullptr);
+
+            if (pList)
+            {
+                GSL_SUPPRESS_TYPE1
+                reinterpret_cast<rc_client_leaderboard_entry_list_info_t*>(pList)->destroy_func = destroy_leaderboard_entry_list;
+            }
+
+            wrapper->DoCallback(nResult, pList, sErrorMessage);
+
+            delete wrapper;
+        }
+
+    private:
+        rc_client_t* m_pClient;
+        rc_client_fetch_leaderboard_entries_callback_t m_fCallback;
+        void* m_pCallbackUserdata;
+    };
+
+public:
+    static rc_client_async_handle_t* begin_fetch_leaderboard_entries(rc_client_t* client,
+        uint32_t leaderboard_id, uint32_t first_entry, uint32_t count,
+        rc_client_fetch_leaderboard_entries_callback_t callback, void* callback_userdata)
+    {
+        GSL_SUPPRESS_R3
+        auto* pCallbackData = new LeaderboardEntriesListCallbackWrapper(client, callback, callback_userdata);
+
+        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        return rc_client_begin_fetch_leaderboard_entries(pClient.GetClient(), leaderboard_id, first_entry, count,
+                                                         LeaderboardEntriesListCallbackWrapper::Dispatch, pCallbackData);
+    }
+
+    static rc_client_async_handle_t* begin_fetch_leaderboard_entries_around_user(rc_client_t* client,
+        uint32_t leaderboard_id, uint32_t count,
+        rc_client_fetch_leaderboard_entries_callback_t callback, void* callback_userdata)
+    {
+        GSL_SUPPRESS_R3
+        auto* pCallbackData = new LeaderboardEntriesListCallbackWrapper(client, callback, callback_userdata);
+
+        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        return rc_client_begin_fetch_leaderboard_entries_around_user(pClient.GetClient(), leaderboard_id, count,
+                                                                     LeaderboardEntriesListCallbackWrapper::Dispatch, pCallbackData);
+    }
+
+    static size_t get_rich_presence_message(char buffer[], size_t buffer_size)
+    {
+        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+        return rc_client_get_rich_presence_message(pClient.GetClient(), buffer, buffer_size);
+    }
+
+    static int has_rich_presence()
+    {
+        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+        return rc_client_has_rich_presence(pClient.GetClient());
+    }
+
+    static void do_frame() noexcept
+    {
+        _RA_DoAchievementsFrame();
+    }
+
+    static void idle()
+    {
+        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        return rc_client_idle(pClient.GetClient());
+    }
+
+    static int is_processing_required()
+    {
+        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+        return rc_client_is_processing_required(pClient.GetClient());
+    }
+
+    static int can_pause(uint32_t* frames_remaining)
+    {
+        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+        return rc_client_can_pause(pClient.GetClient(), frames_remaining);
+    }
+
+    static void reset() noexcept
+    {
+#ifndef RA_UTEST
+        _RA_OnReset();
+#endif
+    }
+
+    static size_t progress_size()
+    {
+        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+        return rc_client_progress_size(pClient.GetClient());
+    }
+
+    static int serialize_progress(uint8_t* buffer)
+    {
+        const auto& pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+        return rc_client_serialize_progress(pClient.GetClient(), buffer);
+    }
+
+    static int deserialize_progress(const uint8_t* buffer)
+    {
+        auto& pClient = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        return rc_client_deserialize_progress(pClient.GetClient(), buffer);
+    }
+
+    static void set_raintegration_write_memory_function(rc_client_t* client, rc_client_raintegration_write_memory_func_t handler) noexcept
+    {
+        s_callbacks.write_memory_client = client;
+        s_callbacks.write_memory_handler = handler;
+    }
+
+    static void set_raintegration_event_handler(rc_client_t* client, rc_client_raintegration_event_handler_t handler) noexcept
+    {
+        s_callbacks.raintegration_event_client = client;
+        s_callbacks.raintegration_event_handler = handler;
+    }
+
+    static const rc_client_raintegration_menu_t* get_raintegration_menu()
+    {
+        ra::ui::viewmodels::LookupItemViewModelCollection vmMenuItems;
+        ra::ui::viewmodels::IntegrationMenuViewModel::BuildMenu(vmMenuItems);
+
+        for (gsl::index nIndex = gsl::narrow_cast<gsl::index>(vmMenuItems.Count()) - 1; nIndex >= 0; --nIndex)
+        {
+            const auto* pItem = vmMenuItems.GetItemAt(nIndex);
+            if (!pItem)
+                continue;
+
+            switch (pItem->GetId())
+            {
+                case IDM_RA_FILES_LOGIN:
+                case IDM_RA_FILES_LOGOUT:
+                case IDM_RA_TOGGLELEADERBOARDS:
+                case IDM_RA_OVERLAYSETTINGS:
+                    vmMenuItems.RemoveAt(nIndex);
+                    break;
+
+                case 0: // separator - prevent two adjacent separators
+                    pItem = vmMenuItems.GetItemAt(nIndex + 1);
+                    if (pItem && pItem->GetId() == 0)
+                        vmMenuItems.RemoveAt(nIndex + 1);
+                    break;
+            }
+        }
+
+        {
+            const auto* pItem = vmMenuItems.GetItemAt(0);
+            if (pItem && pItem->GetId() == 0)
+                vmMenuItems.RemoveAt(0);
+        }
+
+        bool bChanged = true;
+        rc_client_raintegration_menu_item_t* pMenuItem = nullptr;
+
+        if (s_pIntegrationMenu && s_pIntegrationMenu->num_items == vmMenuItems.Count())
+        {
+            bChanged = false;
+
+            pMenuItem = s_pIntegrationMenu->items;
+            for (gsl::index nIndex = 0; nIndex < gsl::narrow_cast<gsl::index>(vmMenuItems.Count()); ++nIndex, ++pMenuItem)
+            {
+                const auto* pItem = vmMenuItems.GetItemAt(nIndex);
+                if (!pItem)
+                    continue;
+
+                const auto nId = ra::to_unsigned(pItem->GetId());
+                if (pMenuItem->id != nId)
+                {
+                    if (pMenuItem->id == 0 || nId == 0)
+                    {
+                        bChanged = true;
+                        break;
+                    }
+
+                    pMenuItem->id = nId;
+                }
+
+                pMenuItem->checked = pItem->IsSelected() ? 1 : 0;
+            }
+
+            if (!bChanged)
+            {
+                pMenuItem = s_pIntegrationMenu->items;
+                for (gsl::index nIndex = 0; nIndex < gsl::narrow_cast<gsl::index>(vmMenuItems.Count());
+                     ++nIndex, ++pMenuItem)
+                {
+                    const auto* pItem = vmMenuItems.GetItemAt(nIndex);
+                    if (!pItem)
+                        continue;
+
+                    if (pMenuItem->label)
+                    {
+                        if (ra::Narrow(pItem->GetLabel()) != pMenuItem->label)
+                        {
+                            bChanged = true;
+                            break;
+                        }
+                    }
+                    else if (!pItem->GetLabel().empty())
+                    {
+                        bChanged = true;
+                        break;
+                    }
+                }
+            }
+        }
+
+        if (!bChanged)
+            return s_pIntegrationMenu;
+
+        if (s_pIntegrationMenu)
+            rc_buffer_destroy(&s_pIntegrationMenuBuffer);
+
+        rc_buffer_init(&s_pIntegrationMenuBuffer);
+        s_pIntegrationMenu = static_cast<rc_client_raintegration_menu_t*>(
+            rc_buffer_alloc(&s_pIntegrationMenuBuffer, sizeof(*s_pIntegrationMenu)));
+        s_pIntegrationMenu->num_items = 0;
+        s_pIntegrationMenu->items = static_cast<rc_client_raintegration_menu_item_t*>(
+            rc_buffer_alloc(&s_pIntegrationMenuBuffer, sizeof(rc_client_raintegration_menu_item_t) * vmMenuItems.Count()));
+
+        pMenuItem = s_pIntegrationMenu->items;
+        for (gsl::index nIndex = 0; nIndex < gsl::narrow_cast<gsl::index>(vmMenuItems.Count()); ++nIndex, ++pMenuItem)
+        {
+            const auto* pItem = vmMenuItems.GetItemAt(nIndex);
+            const auto nId = pItem ? pItem->GetId() : 0;
+            if (nId == 0)
+            {
+                memset(pMenuItem, 0, sizeof(*pMenuItem));
+            }
+            else
+            {
+                pMenuItem->label = rc_buffer_strcpy(&s_pIntegrationMenuBuffer, ra::Narrow(pItem->GetLabel()).c_str());
+                pMenuItem->id = nId;
+                pMenuItem->checked = pItem->IsSelected();
+            }
+
+            pMenuItem->enabled = (nId != IDM_RA_FILES_LOGIN);
+        }
+
+        s_pIntegrationMenu->num_items = gsl::narrow_cast<uint32_t>(pMenuItem - s_pIntegrationMenu->items);
+        return s_pIntegrationMenu;
+    }
+
+    static void SyncMenuItem(int nMenuItemId)
+    {
+        if (!s_pIntegrationMenu)
+            return;
+
+        auto* pMenuItem = s_pIntegrationMenu->items;
+        const auto* pStop = pMenuItem + s_pIntegrationMenu->num_items;
+        while (pMenuItem < pStop && ra::to_signed(pMenuItem->id) != nMenuItemId)
+            ++pMenuItem;
+        if (pMenuItem == pStop)
+            return;
+
+        rc_client_raintegration_event_t pEvent;
+        memset(&pEvent, 0, sizeof(pEvent));
+        pEvent.menu_item = pMenuItem;
+
+        ra::ui::viewmodels::LookupItemViewModelCollection vmMenuItems;
+        ra::ui::viewmodels::IntegrationMenuViewModel::BuildMenu(vmMenuItems);
+
+        for (gsl::index nIndex = gsl::narrow_cast<gsl::index>(vmMenuItems.Count()) - 1; nIndex >= 0; --nIndex)
+        {
+            const auto* pItem = vmMenuItems.GetItemAt(nIndex);
+            if (pItem && pItem->GetId() == nMenuItemId)
+            {
+                const uint8_t checked = pItem->IsSelected() ? 1 : 0;
+                if (pMenuItem->checked != checked)
+                {
+                    pMenuItem->checked = checked;
+
+                    if (s_callbacks.raintegration_event_handler)
+                    {
+                        pEvent.type = RC_CLIENT_RAINTEGRATION_EVENT_MENUITEM_CHECKED_CHANGED;
+                        s_callbacks.raintegration_event_handler(&pEvent, s_callbacks.raintegration_event_client);
+                    }
+                }
+
+                break;
+            }
+        }
+    }
+
+    static int activate_menu_item(uint32_t nMenuItemId)
+    {
+        if (nMenuItemId < IDM_RA_MENUSTART || nMenuItemId > IDM_RA_MENUEND)
+            return 0;
+
+        if (!s_pIntegrationMenu)
+            return 0;
+
+        auto* pMenuItem = s_pIntegrationMenu->items;
+        const auto* pStop = pMenuItem + s_pIntegrationMenu->num_items;
+        while (pMenuItem < pStop && pMenuItem->id != nMenuItemId)
+            ++pMenuItem;
+        if (pMenuItem == pStop || !pMenuItem->enabled)
+            return 0;
+
+        ra::ui::viewmodels::IntegrationMenuViewModel::ActivateMenuItem(ra::to_signed(nMenuItemId));
+        return 1;
+    }
+
+    static void RaiseIntegrationEvent(uint32_t nType) noexcept
+    {
+        if (s_callbacks.raintegration_event_handler)
+        {
+            rc_client_raintegration_event_t pEvent;
+            memset(&pEvent, 0, sizeof(pEvent));
+            pEvent.type = nType;
+
+            s_callbacks.raintegration_event_handler(&pEvent, s_callbacks.raintegration_event_client);
+        }
+    }
+
+    static bool IsExternalRcheevosClient() noexcept
+    {
+        return s_bIsExternalRcheevosClient;
+    }
+
+    static void HookupCallbackEvents()
+    {
+        auto& pEmulatorContext = ra::services::ServiceLocator::GetMutable<ra::data::context::EmulatorContext>();
+        pEmulatorContext.SetPauseFunction(RaisePauseEvent);
+        pEmulatorContext.SetResetFunction(RaiseResetEvent);
+
+        s_bIsExternalRcheevosClient = true;
+    }
+
+private:
+    typedef struct ExternalClientCallbacks
+    {
+        rc_client_message_callback_t log_callback;
+        rc_client_t* log_client;
+
+        rc_client_event_handler_t event_handler;
+        rc_client_t* event_client;
+
+        rc_client_read_memory_func_t read_memory_handler;
+        rc_client_t* read_memory_client;
+
+        rc_client_raintegration_write_memory_func_t write_memory_handler;
+        rc_client_t* write_memory_client;
+
+        rc_get_time_millisecs_func_t get_time_millisecs_handler;
+        rc_client_t* get_time_millisecs_client;
+
+        rc_client_raintegration_event_handler_t raintegration_event_handler;
+        rc_client_t* raintegration_event_client;
+
+    } ExternalClientCallbacks;
+
+    static ExternalClientCallbacks s_callbacks;
+
+    static void LogMessageExternal(const char* sMessage, const rc_client_t*)
+    {
+        const auto& pLogger = ra::services::ServiceLocator::Get<ra::services::ILogger>();
+        if (pLogger.IsEnabled(ra::services::LogLevel::Info))
+            pLogger.LogMessage(ra::services::LogLevel::Info, sMessage);
+
+        if (s_callbacks.log_callback)
+            s_callbacks.log_callback(sMessage, s_callbacks.log_client);
+    }
+
+    static void EventHandlerExternal(const rc_client_event_t* event, rc_client_t*) noexcept(false)
+    {
+        if (s_callbacks.event_handler)
+            s_callbacks.event_handler(event, s_callbacks.event_client);
+    }
+
+    static uint8_t ReadMemoryByte(uint32_t address) noexcept
+    {
+        if (s_callbacks.read_memory_handler)
+        {
+            uint8_t value = 0;
+
+            if (s_callbacks.read_memory_handler(address, &value, 1, s_callbacks.read_memory_client) == 1)
+                return value;
+        }
+
+        return 0;
+    }
+
+    static void WriteMemoryByte(uint32_t address, uint8_t value) noexcept
+    {
+        if (s_callbacks.write_memory_handler)
+            s_callbacks.write_memory_handler(address, &value, 1, s_callbacks.write_memory_client);
+    }
+
+    static uint32_t ReadMemoryBlock(uint32_t address, uint8_t* buffer, uint32_t num_bytes) noexcept(false)
+    {
+        if (s_callbacks.read_memory_handler)
+            return s_callbacks.read_memory_handler(address, buffer, num_bytes, s_callbacks.read_memory_client);
+
+        return 0;
+    }
+
+    static uint32_t ReadMemoryExternal(uint32_t address, uint8_t* buffer, uint32_t num_bytes, rc_client_t*)  noexcept(false)
+    {
+        if (s_callbacks.read_memory_handler)
+            return s_callbacks.read_memory_handler(address, buffer, num_bytes, s_callbacks.read_memory_client);
+
+        return 0;
+    }
+    
+    static void ResetMemory()
+    {
+        const auto& pConsoleContext = ra::services::ServiceLocator::Get<ra::data::context::ConsoleContext>();
+        const auto nNumBytes = pConsoleContext.MaxAddress() + 1;
+
+        auto& pEmulatorContext = ra::services::ServiceLocator::GetMutable<ra::data::context::EmulatorContext>();
+        pEmulatorContext.ClearMemoryBlocks();
+        pEmulatorContext.AddMemoryBlock(0, nNumBytes, AchievementRuntimeExports::ReadMemoryByte, AchievementRuntimeExports::WriteMemoryByte);
+        pEmulatorContext.AddMemoryBlockReader(0, AchievementRuntimeExports::ReadMemoryBlock);
+    }
+
+    static void RaisePauseEvent() noexcept
+    {
+        RaiseIntegrationEvent(RC_CLIENT_RAINTEGRATION_EVENT_PAUSE);
+    }
+
+    static void RaiseResetEvent() noexcept
+    {
+        if (s_callbacks.event_handler)
+        {
+            rc_client_event_t client_event;
+            memset(&client_event, 0, sizeof(client_event));
+            client_event.type = RC_CLIENT_EVENT_RESET;
+
+            s_callbacks.event_handler(&client_event, s_callbacks.event_client);
+        }
+    }
+
+    static rc_clock_t GetTimeMillisecsExternal(const rc_client_t*) noexcept(false)
+    {
+        if (s_callbacks.get_time_millisecs_handler)
+            return s_callbacks.get_time_millisecs_handler(s_callbacks.get_time_millisecs_client);
+
+        return 0;
+    }
+
+    static void destroy_achievement_list(rc_client_achievement_list_info_t* list) noexcept
+    {
+        if (list)
+            free(list);
+    }
+
+    static void destroy_leaderboard_list(rc_client_leaderboard_list_info_t* list) noexcept
+    {
+        if (list)
+            free(list);
+    }
+
+    static void destroy_leaderboard_entry_list(rc_client_leaderboard_entry_list_info_t* list) noexcept
+    {
+        if (list)
+            free(list);
+    }
+
+    static bool s_bIsExternalRcheevosClient;
+    static bool s_bUpdatingHardcore;
+
+    static rc_buffer_t s_pIntegrationMenuBuffer;
+    static rc_client_raintegration_menu_t* s_pIntegrationMenu;
+};
+
+AchievementRuntimeExports::ExternalClientCallbacks AchievementRuntimeExports::s_callbacks{};
+bool AchievementRuntimeExports::s_bIsExternalRcheevosClient = false;
+bool AchievementRuntimeExports::s_bUpdatingHardcore = false;
+rc_client_raintegration_menu_t* AchievementRuntimeExports::s_pIntegrationMenu = nullptr;
+rc_buffer_t AchievementRuntimeExports::s_pIntegrationMenuBuffer{};
+
+} // namespace services
+} // namespace ra
+
+bool IsExternalRcheevosClient() noexcept
+{
+    return ra::services::AchievementRuntimeExports::IsExternalRcheevosClient();
+}
+
+void SyncClientExternalRAIntegrationMenuItem(int nMenuItemId)
+{
+    ra::services::AchievementRuntimeExports::SyncMenuItem(nMenuItemId);
+}
+
+void SyncClientExternalHardcoreState()
+{
+    const auto& pConfiguration = ra::services::ServiceLocator::Get<ra::services::IConfiguration>();
+    const int bHardcore = pConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore) ? 1 : 0;
+
+    auto& pRuntime = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+    if (rc_client_get_hardcore_enabled(pRuntime.GetClient()) != bHardcore)
+    {
+        rc_client_set_hardcore_enabled(pRuntime.GetClient(), bHardcore);
+
+        if (!ra::services::AchievementRuntimeExports::IsUpdatingHardcore())
+            ra::services::AchievementRuntimeExports::RaiseIntegrationEvent(RC_CLIENT_RAINTEGRATION_EVENT_HARDCORE_CHANGED);
+    }
+}
+
+static void GetExternalClientV1(rc_client_external_t* pClientExternal) noexcept
+{
+    pClientExternal->destroy = ra::services::AchievementRuntimeExports::destroy;
+
+    pClientExternal->enable_logging = ra::services::AchievementRuntimeExports::enable_logging;
+    pClientExternal->set_event_handler = ra::services::AchievementRuntimeExports::set_event_handler;
+    pClientExternal->set_read_memory = ra::services::AchievementRuntimeExports::set_read_memory;
+    pClientExternal->set_get_time_millisecs = ra::services::AchievementRuntimeExports::set_get_time_millisecs;
+    pClientExternal->set_host = ra::services::AchievementRuntimeExports::set_host;
+
+    pClientExternal->set_hardcore_enabled = ra::services::AchievementRuntimeExports::set_hardcore_enabled;
+    pClientExternal->get_hardcore_enabled = ra::services::AchievementRuntimeExports::get_hardcore_enabled;
+    pClientExternal->set_unofficial_enabled = ra::services::AchievementRuntimeExports::set_unofficial_enabled;
+    pClientExternal->get_unofficial_enabled = ra::services::AchievementRuntimeExports::get_unofficial_enabled;
+    pClientExternal->set_encore_mode_enabled = ra::services::AchievementRuntimeExports::set_encore_mode_enabled;
+    pClientExternal->get_encore_mode_enabled = ra::services::AchievementRuntimeExports::get_encore_mode_enabled;
+    pClientExternal->set_spectator_mode_enabled = ra::services::AchievementRuntimeExports::set_spectator_mode_enabled;
+    pClientExternal->get_spectator_mode_enabled = ra::services::AchievementRuntimeExports::get_spectator_mode_enabled;
+
+    pClientExternal->abort_async = ra::services::AchievementRuntimeExports::abort_async;
+
+    pClientExternal->begin_login_with_password = ra::services::AchievementRuntimeExports::begin_login_with_password;
+    pClientExternal->begin_login_with_token = ra::services::AchievementRuntimeExports::begin_login_with_token;
+    pClientExternal->logout = ra::services::AchievementRuntimeExports::logout;
+    pClientExternal->get_user_info = ra::services::AchievementRuntimeExports::get_user_info;
+
+    pClientExternal->begin_identify_and_load_game = ra::services::AchievementRuntimeExports::begin_identify_and_load_game;
+    pClientExternal->begin_load_game = ra::services::AchievementRuntimeExports::begin_load_game;
+    pClientExternal->get_game_info = ra::services::AchievementRuntimeExports::get_game_info;
+    pClientExternal->get_subset_info = ra::services::AchievementRuntimeExports::get_subset_info;
+    pClientExternal->unload_game = ra::services::AchievementRuntimeExports::unload_game;
+    pClientExternal->get_user_game_summary = ra::services::AchievementRuntimeExports::get_user_game_summary;
+    pClientExternal->begin_change_media = ra::services::AchievementRuntimeExports::begin_change_media;
+
+    pClientExternal->create_achievement_list = ra::services::AchievementRuntimeExports::create_achievement_list;
+    pClientExternal->has_achievements = ra::services::AchievementRuntimeExports::has_achievements;
+    pClientExternal->get_achievement_info = ra::services::AchievementRuntimeExports::get_achievement_info;
+
+    pClientExternal->create_leaderboard_list = ra::services::AchievementRuntimeExports::create_leaderboard_list;
+    pClientExternal->has_leaderboards = ra::services::AchievementRuntimeExports::has_leaderboards;
+    pClientExternal->get_leaderboard_info = ra::services::AchievementRuntimeExports::get_leaderboard_info;
+    pClientExternal->begin_fetch_leaderboard_entries = ra::services::AchievementRuntimeExports::begin_fetch_leaderboard_entries;
+    pClientExternal->begin_fetch_leaderboard_entries_around_user =
+        ra::services::AchievementRuntimeExports::begin_fetch_leaderboard_entries_around_user;
+
+    pClientExternal->get_rich_presence_message = ra::services::AchievementRuntimeExports::get_rich_presence_message;
+    pClientExternal->has_rich_presence = ra::services::AchievementRuntimeExports::has_rich_presence;
+
+    pClientExternal->do_frame = ra::services::AchievementRuntimeExports::do_frame;
+    pClientExternal->idle = ra::services::AchievementRuntimeExports::idle;
+    pClientExternal->is_processing_required = ra::services::AchievementRuntimeExports::is_processing_required;
+    pClientExternal->can_pause = ra::services::AchievementRuntimeExports::can_pause;
+    pClientExternal->reset = ra::services::AchievementRuntimeExports::reset;
+
+    pClientExternal->progress_size = ra::services::AchievementRuntimeExports::progress_size;
+    pClientExternal->serialize_progress = ra::services::AchievementRuntimeExports::serialize_progress;
+    pClientExternal->deserialize_progress = ra::services::AchievementRuntimeExports::deserialize_progress;
+}
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+API int CCONV _Rcheevos_GetExternalClient(rc_client_external_t* pClientExternal, int nVersion)
+{
+    switch (nVersion)
+    {
+        default:
+            RA_LOG_WARN("Unknown rc_client_external interface version: %s", nVersion);
+            __fallthrough;
+
+        case 1:
+            GetExternalClientV1(pClientExternal);
+            break;
+    }
+
+    ra::services::AchievementRuntimeExports::HookupCallbackEvents();
+
+    return 1;
+}
+
+API const rc_client_raintegration_menu_t* CCONV _Rcheevos_RAIntegrationGetMenu()
+{
+    return ra::services::AchievementRuntimeExports::get_raintegration_menu();
+}
+
+API int CCONV _Rcheevos_ActivateRAIntegrationMenuItem(uint32_t nId)
+{
+    return ra::services::AchievementRuntimeExports::activate_menu_item(nId);
+}
+
+API void CCONV _Rcheevos_SetRAIntegrationWriteMemoryFunction(rc_client_t* client, rc_client_raintegration_write_memory_func_t handler)
+{
+    ra::services::AchievementRuntimeExports::set_raintegration_write_memory_function(client, handler);
+}
+
+API void CCONV _Rcheevos_SetRAIntegrationEventHandler(rc_client_t* client, rc_client_raintegration_event_handler_t handler)
+{
+    ra::services::AchievementRuntimeExports::set_raintegration_event_handler(client, handler);
+}
+
+#ifdef __cplusplus
+} // extern "C"
+#endif
diff --git a/src/services/AchievementRuntimeExports.hh b/src/services/AchievementRuntimeExports.hh
new file mode 100644
index 00000000..1177d459
--- /dev/null
+++ b/src/services/AchievementRuntimeExports.hh
@@ -0,0 +1,9 @@
+#ifndef RA_SERVICES_ACHIEVEMENT_RUNTIME_EXPORTS_HH
+#define RA_SERVICES_ACHIEVEMENT_RUNTIME_EXPORTS_HH
+#pragma once
+
+void SyncClientExternalRAIntegrationMenuItem(int nMenuItemId);
+void SyncClientExternalHardcoreState();
+bool IsExternalRcheevosClient();
+
+#endif // !RA_SERVICES_ACHIEVEMENT_RUNTIME_EXPORTS_HH
diff --git a/src/ui/IDesktop.hh b/src/ui/IDesktop.hh
index 512d6401..e4017960 100644
--- a/src/ui/IDesktop.hh
+++ b/src/ui/IDesktop.hh
@@ -77,12 +77,12 @@ public:
     /// <summary>
     /// Returns <c>true</c> if the current thread is the UI thread.
     /// </summary>
-    virtual bool IsOnUIThread(const WindowViewModelBase& vmViewModel) const = 0;
+    virtual bool IsOnUIThread() const = 0;
 
     /// <summary>
     /// Executes a function on the UI thread.
     /// </summary>
-    virtual void InvokeOnUIThread(const WindowViewModelBase& vmViewModel, std::function<void()> fAction) const = 0;
+    virtual void InvokeOnUIThread(std::function<void()> fAction) const = 0;
 
     virtual void Shutdown() = 0;
 
diff --git a/src/ui/viewmodels/IntegrationMenuViewModel.cpp b/src/ui/viewmodels/IntegrationMenuViewModel.cpp
index 5e215692..f3c463f2 100644
--- a/src/ui/viewmodels/IntegrationMenuViewModel.cpp
+++ b/src/ui/viewmodels/IntegrationMenuViewModel.cpp
@@ -237,7 +237,7 @@ void IntegrationMenuViewModel::ToggleNonHardcoreWarning()
         !pConfiguration.IsFeatureEnabled(ra::services::Feature::NonHardcoreWarning));
 
     const auto& pEmulatorContext = ra::services::ServiceLocator::Get<ra::data::context::EmulatorContext>();
-    pEmulatorContext.RebuildMenu();
+    pEmulatorContext.UpdateMenuState(IDM_RA_NON_HARDCORE_WARNING);
 }
 
 void IntegrationMenuViewModel::ToggleLeaderboards()
@@ -259,7 +259,7 @@ void IntegrationMenuViewModel::ToggleLeaderboards()
     }
 
     const auto& pEmulatorContext = ra::services::ServiceLocator::Get<ra::data::context::EmulatorContext>();
-    pEmulatorContext.RebuildMenu();
+    pEmulatorContext.UpdateMenuState(IDM_RA_TOGGLELEADERBOARDS);
 
 }
 
diff --git a/src/ui/viewmodels/MemorySearchViewModel.cpp b/src/ui/viewmodels/MemorySearchViewModel.cpp
index d4f20ec2..d9d23960 100644
--- a/src/ui/viewmodels/MemorySearchViewModel.cpp
+++ b/src/ui/viewmodels/MemorySearchViewModel.cpp
@@ -738,12 +738,11 @@ void MemorySearchViewModel::UpdateResults()
         return;
     }
 
-    const auto& vmMemoryInspector = ra::services::ServiceLocator::Get<ra::ui::viewmodels::WindowManager>().MemoryInspector;
     const auto& pDesktop = ra::services::ServiceLocator::Get<ra::ui::IDesktop>();
-    if (!pDesktop.IsOnUIThread(vmMemoryInspector))
+    if (!pDesktop.IsOnUIThread())
     {
         m_bUpdateResultsPending = true;
-        pDesktop.InvokeOnUIThread(vmMemoryInspector, [this]()
+        pDesktop.InvokeOnUIThread([this]()
         {
             if (m_bUpdateResultsPending)
             {
diff --git a/src/ui/viewmodels/OverlayAchievementsPageViewModel.cpp b/src/ui/viewmodels/OverlayAchievementsPageViewModel.cpp
index c5672077..c30b65ca 100644
--- a/src/ui/viewmodels/OverlayAchievementsPageViewModel.cpp
+++ b/src/ui/viewmodels/OverlayAchievementsPageViewModel.cpp
@@ -62,7 +62,7 @@ static void SetAchievement(OverlayListPageViewModel::ItemViewModel& vmItem,
             if (pAchievement.measured_progress[0])
             {
                 vmItem.SetProgressString(ra::Widen(pAchievement.measured_progress));
-                vmItem.SetProgressPercentage(pAchievement.measured_percent);
+                vmItem.SetProgressPercentage(pAchievement.measured_percent / 100.0f);
             }
             else
             {
diff --git a/src/ui/viewmodels/RichPresenceMonitorViewModel.cpp b/src/ui/viewmodels/RichPresenceMonitorViewModel.cpp
index 8d7ad50e..a0105142 100644
--- a/src/ui/viewmodels/RichPresenceMonitorViewModel.cpp
+++ b/src/ui/viewmodels/RichPresenceMonitorViewModel.cpp
@@ -66,18 +66,19 @@ void RichPresenceMonitorViewModel::UpdateDisplayString()
                 &pGameContext.Assets().Append(std::move(pNewRichPresence)));
         }
 
-        // modified rich presence cannot be active in hardcore
-        const bool bHardcore = ra::services::ServiceLocator::Get<ra::services::IConfiguration>().IsFeatureEnabled(
-            ra::services::Feature::Hardcore);
-        if (bHardcore)
-            pRichPresence->Deactivate();
-
         // load the updated file
         pRichPresence->ReloadRichPresenceScript();
 
         // automatically activate if not in hardcore
-        if (!bHardcore)
-            pRichPresence->Activate();
+        if (!pRichPresence->IsActive())
+        {
+            const bool bHardcore = ra::services::ServiceLocator::Get<ra::services::IConfiguration>().IsFeatureEnabled(
+                ra::services::Feature::Hardcore);
+            if (!bHardcore)
+            {
+                pRichPresence->Activate();
+            }
+        }
 
         UpdateWindowTitle();
     }
diff --git a/src/ui/win32/AssetEditorDialog.cpp b/src/ui/win32/AssetEditorDialog.cpp
index 63333323..7720df20 100644
--- a/src/ui/win32/AssetEditorDialog.cpp
+++ b/src/ui/win32/AssetEditorDialog.cpp
@@ -499,12 +499,15 @@ void AssetEditorDialog::ActiveCheckBoxBinding::OnViewModelIntValueChanged(const
     if (args.Property == ra::ui::viewmodels::AssetEditorViewModel::StateProperty)
     {
         const bool bIsActive = IsActive();
-        const bool bWasActive = Button_GetCheck(m_hWnd);
-        if (bIsActive != bWasActive)
-        {
-            Button_SetCheck(m_hWnd, bIsActive);
-            OnValueChanged();
-        }
+
+        InvokeOnUIThread([this, bIsActive]() {
+            const bool bWasActive = Button_GetCheck(m_hWnd);
+            if (bIsActive != bWasActive)
+            {
+                Button_SetCheck(m_hWnd, bIsActive);
+                OnValueChanged();
+            }
+        });
     }
 }
 
diff --git a/src/ui/win32/Desktop.cpp b/src/ui/win32/Desktop.cpp
index fb64fc77..217c403c 100644
--- a/src/ui/win32/Desktop.cpp
+++ b/src/ui/win32/Desktop.cpp
@@ -59,10 +59,16 @@ Desktop::Desktop() noexcept
 
 void Desktop::ShowWindow(WindowViewModelBase& vmViewModel) const
 {
+    if (m_pWindowBinding)
+        m_pWindowBinding->EnableInvokeOnUIThread();
+
     auto* pPresenter = GetDialogPresenter(vmViewModel);
     if (pPresenter != nullptr)
     {
-        pPresenter->ShowWindow(vmViewModel);
+        ra::ui::win32::bindings::WindowBinding::InvokeOnUIThread(
+            [this, &vmViewModel, pPresenter]() {
+                pPresenter->ShowWindow(vmViewModel);
+            });
     }
     else
     {
@@ -78,13 +84,19 @@ ra::ui::DialogResult Desktop::ShowModal(WindowViewModelBase& vmViewModel) const
 
 ra::ui::DialogResult Desktop::ShowModal(WindowViewModelBase& vmViewModel, const WindowViewModelBase& vmParentViewModel) const
 {
+    if (m_pWindowBinding)
+        m_pWindowBinding->EnableInvokeOnUIThread();
+
     auto* pPresenter = GetDialogPresenter(vmViewModel);
     if (pPresenter != nullptr)
     {
         const auto* pBinding = ui::win32::bindings::WindowBinding::GetBindingFor(vmParentViewModel);
         const HWND hParentWnd = pBinding ? pBinding->GetHWnd() : nullptr;
 
-        pPresenter->ShowModal(vmViewModel, hParentWnd);
+        ra::ui::win32::bindings::WindowBinding::InvokeOnUIThreadAndWait(
+            [this, &vmViewModel, pPresenter, hParentWnd]() {
+                pPresenter->ShowModal(vmViewModel, hParentWnd);
+            });
     }
     else
     {
@@ -166,6 +178,8 @@ void Desktop::SetMainHWnd(HWND hWnd)
     }
 
     m_pWindowBinding->SetHWND(nullptr, hWnd);
+    m_pWindowBinding->SetUIThread(::GetWindowThreadProcessId(hWnd, nullptr));
+    m_pWindowBinding->EnableInvokeOnUIThread();
 
     g_RAMainWnd = hWnd;
 }
@@ -299,18 +313,14 @@ bool Desktop::IsDebuggerPresent() const
     return IsSuspiciousProcessRunning();
 }
 
-bool Desktop::IsOnUIThread(const WindowViewModelBase& vmViewModel) const
+bool Desktop::IsOnUIThread() const noexcept
 {
-    const auto* pBinding = ra::ui::win32::bindings::WindowBinding::GetBindingFor(vmViewModel);
-    Expects(pBinding != nullptr);
-    return pBinding->IsOnUIThread();
+    return ra::ui::win32::bindings::WindowBinding::IsOnUIThread();
 }
 
-void Desktop::InvokeOnUIThread(const WindowViewModelBase& vmViewModel, std::function<void()> fAction) const
+void Desktop::InvokeOnUIThread(std::function<void()> fAction) const
 {
-    auto* pBinding = ra::ui::win32::bindings::WindowBinding::GetBindingFor(vmViewModel);
-    Expects(pBinding != nullptr);
-    pBinding->InvokeOnUIThread(fAction);
+    ra::ui::win32::bindings::WindowBinding::InvokeOnUIThread(fAction);
 }
 
 void Desktop::Shutdown() noexcept
diff --git a/src/ui/win32/Desktop.hh b/src/ui/win32/Desktop.hh
index 3d707b00..c75155b0 100644
--- a/src/ui/win32/Desktop.hh
+++ b/src/ui/win32/Desktop.hh
@@ -36,8 +36,8 @@ public:
 
     bool IsDebuggerPresent() const override;
 
-    bool IsOnUIThread(const WindowViewModelBase& vmViewModel) const override;
-    void InvokeOnUIThread(const WindowViewModelBase& vmViewModel, std::function<void()> fAction) const override;
+    bool IsOnUIThread() const noexcept override;
+    void InvokeOnUIThread(std::function<void()> fAction) const override;
 
 private:
     _Success_(return != nullptr)
diff --git a/src/ui/win32/DialogBase.cpp b/src/ui/win32/DialogBase.cpp
index adbe0857..1949bbf1 100644
--- a/src/ui/win32/DialogBase.cpp
+++ b/src/ui/win32/DialogBase.cpp
@@ -3,6 +3,7 @@
 #include "ui\win32\bindings\ControlBinding.hh"
 #include "ui\win32\bindings\GridBinding.hh"
 #include "ui\win32\bindings\NumericUpDownBinding.hh"
+#include "ui\win32\bindings\WindowBinding.hh"
 
 #include "RA_Core.h" // g_RAMainWnd, g_hThisDLLInst
 
diff --git a/src/ui/win32/DialogBase.hh b/src/ui/win32/DialogBase.hh
index d52b62f4..5b9e64eb 100644
--- a/src/ui/win32/DialogBase.hh
+++ b/src/ui/win32/DialogBase.hh
@@ -64,6 +64,11 @@ public:
     /// </summary>
     void SetDialogResult(DialogResult nResult);
 
+    /// <summary>
+    /// Returns <c>true</c> if the current thread is the UI thread.
+    /// </summary>
+    bool IsOnUIThread() const noexcept { return m_bindWindow.IsOnUIThread(); }
+
 protected:
     explicit DialogBase(_Inout_ ra::ui::WindowViewModelBase& vmWindow) noexcept;
     ~DialogBase() noexcept;
diff --git a/src/ui/win32/bindings/CheckBoxBinding.hh b/src/ui/win32/bindings/CheckBoxBinding.hh
index 78099a29..6ce14c90 100644
--- a/src/ui/win32/bindings/CheckBoxBinding.hh
+++ b/src/ui/win32/bindings/CheckBoxBinding.hh
@@ -45,7 +45,9 @@ protected:
     {
         if (m_pIsCheckedProperty && *m_pIsCheckedProperty == args.Property)
         {
-            Button_SetCheck(m_hWnd, args.tNewValue ? BST_CHECKED : BST_UNCHECKED);
+            InvokeOnUIThread([this, nValue = args.tNewValue ? BST_CHECKED : BST_UNCHECKED]() noexcept {
+                Button_SetCheck(m_hWnd, nValue);
+            });
             OnValueChanged();
         }
     }
diff --git a/src/ui/win32/bindings/ComboBoxBinding.hh b/src/ui/win32/bindings/ComboBoxBinding.hh
index 919568f9..602ba7b8 100644
--- a/src/ui/win32/bindings/ComboBoxBinding.hh
+++ b/src/ui/win32/bindings/ComboBoxBinding.hh
@@ -104,7 +104,7 @@ protected:
     void OnViewModelIntValueChanged(const IntModelProperty::ChangeArgs& args) override
     {
         if (m_pSelectedIdProperty && *m_pSelectedIdProperty == args.Property)
-            UpdateSelectedItem();
+            InvokeOnUIThread([this]() { UpdateSelectedItem(); });
     }
 
     void OnViewModelStringValueChanged(gsl::index nIndex, const StringModelProperty::ChangeArgs& args) override
@@ -112,26 +112,32 @@ protected:
         if (args.Property == *m_pItemTextProperty)
         {
             const auto& pLabel = m_pViewModelCollection->GetItemValue(nIndex, *m_pItemTextProperty);
-            ComboBox_DeleteString(m_hWnd, nIndex);
-            ComboBox_InsertString(m_hWnd, nIndex, NativeStr(pLabel).c_str());
-
-            if (m_nSelectedIndex == nIndex)
-            {
-                ComboBox_SetCurSel(m_hWnd, nIndex);
-                ComboBox_SetText(m_hWnd, NativeStr(pLabel).c_str());
-            }
+            InvokeOnUIThread([this, pLabel = NativeStr(pLabel), nIndex]() {
+                ComboBox_DeleteString(m_hWnd, nIndex);
+                ComboBox_InsertString(m_hWnd, nIndex, NativeStr(pLabel).c_str());
+
+                if (m_nSelectedIndex == nIndex)
+                {
+                    ComboBox_SetCurSel(m_hWnd, nIndex);
+                    ComboBox_SetText(m_hWnd, NativeStr(pLabel).c_str());
+                }
+            });
         }
     }
 
     void OnViewModelAdded(gsl::index nIndex) override
     {
         const auto& pLabel = m_pViewModelCollection->GetItemValue(nIndex, *m_pItemTextProperty);
-        ComboBox_InsertString(m_hWnd, nIndex, NativeStr(pLabel).c_str());
+        InvokeOnUIThread([this, nIndex, pLabel = NativeStr(pLabel)]() {
+            ComboBox_InsertString(m_hWnd, nIndex, NativeStr(pLabel).c_str());
+        });
     }
 
-    void OnViewModelRemoved(gsl::index nIndex) noexcept override
+    void OnViewModelRemoved(gsl::index nIndex) override
     {
-        ComboBox_DeleteString(m_hWnd, nIndex);
+        InvokeOnUIThread([this, nIndex]() noexcept {
+            ComboBox_DeleteString(m_hWnd, nIndex);
+        });
     }
 
     virtual void PopulateComboBox()
diff --git a/src/ui/win32/bindings/ControlBinding.hh b/src/ui/win32/bindings/ControlBinding.hh
index 6d5c10f1..f6887462 100644
--- a/src/ui/win32/bindings/ControlBinding.hh
+++ b/src/ui/win32/bindings/ControlBinding.hh
@@ -151,8 +151,12 @@ protected:
 
     void InvokeOnUIThread(std::function<void()> fAction)
     {
-        if (m_pDialog)
-            m_pDialog->QueueFunction(fAction);
+        WindowBinding::InvokeOnUIThread(fAction);
+    }
+
+    void InvokeOnUIThreadAndWait(std::function<void()> fAction)
+    {
+        WindowBinding::InvokeOnUIThreadAndWait(fAction);
     }
 
     HWND GetDialogHwnd() const
diff --git a/src/ui/win32/bindings/GridBinding.cpp b/src/ui/win32/bindings/GridBinding.cpp
index c41d6095..fbd6cca3 100644
--- a/src/ui/win32/bindings/GridBinding.cpp
+++ b/src/ui/win32/bindings/GridBinding.cpp
@@ -81,6 +81,12 @@ void GridBinding::UpdateLayout()
     if (m_vColumns.empty())
         return;
 
+    if (!WindowBinding::IsOnUIThread())
+    {
+        InvokeOnUIThread([this]() { UpdateLayout(); });
+        return;
+    }
+
     DWORD exStyle = LVS_EX_DOUBLEBUFFER | LVS_EX_FULLROWSELECT | LVS_EX_LABELTIP;
     if (m_bShowGridLines)
         exStyle |= LVS_EX_GRIDLINES;
@@ -275,10 +281,12 @@ void GridBinding::OnViewModelIntValueChanged(const IntModelProperty::ChangeArgs&
         {
             m_nScrollOffset = args.tNewValue;
 
-            const auto nTopIndex = ListView_GetTopIndex(m_hWnd);
-            const auto nSpacing = HIWORD(ListView_GetItemSpacing(m_hWnd, TRUE));
-            const int nDeltaY = (m_nScrollOffset - nTopIndex) * gsl::narrow_cast<int>(nSpacing);
-            ListView_Scroll(m_hWnd, 0, nDeltaY);
+            InvokeOnUIThread([this]() noexcept {
+                const auto nTopIndex = ListView_GetTopIndex(m_hWnd);
+                const auto nSpacing = HIWORD(ListView_GetItemSpacing(m_hWnd, TRUE));
+                const int nDeltaY = (m_nScrollOffset - nTopIndex) * gsl::narrow_cast<int>(nSpacing);
+                ListView_Scroll(m_hWnd, 0, nDeltaY);
+            });
             return;
         }
 
@@ -286,10 +294,12 @@ void GridBinding::OnViewModelIntValueChanged(const IntModelProperty::ChangeArgs&
         {
             if (m_hWnd)
             {
-                if (args.tNewValue < 1)
-                    ListView_SetItemCountEx(m_hWnd, 0, 0);
-                else
-                    ListView_SetItemCountEx(m_hWnd, gsl::narrow_cast<size_t>(args.tNewValue), LVSICF_NOSCROLL);
+                InvokeOnUIThread([this, nValue = args.tNewValue]() {
+                    if (nValue < 1)
+                        ListView_SetItemCountEx(m_hWnd, 0, 0);
+                    else
+                        ListView_SetItemCountEx(m_hWnd, gsl::narrow_cast<size_t>(nValue), LVSICF_NOSCROLL);
+                });
 
                 CheckForScrollBar();
             }
@@ -315,7 +325,10 @@ void GridBinding::OnViewModelIntValueChanged(gsl::index nIndex, const IntModelPr
     {
         if (m_nAdjustingScrollOffset == 0)
         {
-            ListView_RedrawItems(m_hWnd, nIndex, nIndex);
+            InvokeOnUIThread([this, nIndex]() noexcept {
+                ListView_RedrawItems(m_hWnd, nIndex, nIndex);
+            });
+
             m_bForceRepaintItems = true;
         }
         return;
@@ -325,18 +338,7 @@ void GridBinding::OnViewModelIntValueChanged(gsl::index nIndex, const IntModelPr
     {
         const auto& pColumn = *m_vColumns.at(nColumnIndex);
         if (pColumn.DependsOn(args.Property))
-        {
-            SuspendRedraw();
-
-            // if the affected data is in the sort column, it's no longer sorted
-            if (m_nSortIndex == ra::to_signed(nColumnIndex))
-                m_nSortIndex = -1;
-
             UpdateCell(nIndex, nColumnIndex);
-
-            ListView_RedrawItems(m_hWnd, nIndex, nIndex);
-            m_bForceRepaintItems = true;
-        }
     }
 
     if (!m_vmItems->IsUpdating())
@@ -349,24 +351,17 @@ void GridBinding::OnViewModelBoolValueChanged(gsl::index nIndex, const BoolModel
     nIndex = GetRealItemIndex(nIndex);
 
     if (m_pIsSelectedProperty && *m_pIsSelectedProperty == args.Property)
-        ListView_SetItemState(m_hWnd, nIndex, args.tNewValue ? LVIS_SELECTED : 0, LVIS_SELECTED);
+    {
+        InvokeOnUIThread([this, nIndex, nValue = args.tNewValue ? LVIS_SELECTED : 0]() noexcept {
+            ListView_SetItemState(m_hWnd, nIndex, nValue, LVIS_SELECTED);
+        });
+    }
 
     for (size_t nColumnIndex = 0; nColumnIndex < m_vColumns.size(); ++nColumnIndex)
     {
         const auto& pColumn = *m_vColumns.at(nColumnIndex);
         if (pColumn.DependsOn(args.Property))
-        {
-            SuspendRedraw();
-
-            // if the affected data is in the sort column, it's no longer sorted
-            if (m_nSortIndex == ra::to_signed(nColumnIndex))
-                m_nSortIndex = -1;
-
             UpdateCell(nIndex, nColumnIndex);
-
-            ListView_RedrawItems(m_hWnd, nIndex, nIndex);
-            m_bForceRepaintItems = true;
-        }
     }
 }
 
@@ -379,41 +374,46 @@ void GridBinding::OnViewModelStringValueChanged(gsl::index nIndex, const StringM
     {
         const auto& pColumn = *m_vColumns.at(nColumnIndex);
         if (pColumn.DependsOn(args.Property))
-        {
-            SuspendRedraw();
-
-            // if the affected data is in the sort column, it's no longer sorted
-            if (m_nSortIndex == ra::to_signed(nColumnIndex))
-                m_nSortIndex = -1;
-
             UpdateCell(nIndex, nColumnIndex);
-
-            ListView_RedrawItems(m_hWnd, nIndex, nIndex);
-            m_bForceRepaintItems = true;
-        }
     }
 }
 
 void GridBinding::UpdateCell(gsl::index nIndex, gsl::index nColumnIndex)
 {
-    std::wstring sText;
-    LV_ITEMW item{};
-    item.mask = LVIF_TEXT;
-    item.iItem = gsl::narrow_cast<int>(nIndex);
-    item.iSubItem = gsl::narrow_cast<int>(nColumnIndex);
+    SuspendRedraw();
+
+    // if the affected data is in the sort column, it's no longer sorted
+    if (m_nSortIndex == nColumnIndex)
+        m_nSortIndex = -1;
 
     const auto& pColumn = *m_vColumns.at(nColumnIndex);
-    sText = pColumn.GetText(*m_vmItems, nIndex);
-    item.pszText = sText.data();
+    std::wstring sText = pColumn.GetText(*m_vmItems, nIndex);
 
-    GSL_SUPPRESS_TYPE1
-    SNDMSG(m_hWnd, LVM_SETITEMW, 0, reinterpret_cast<LPARAM>(&item));
+    InvokeOnUIThread([this, sText, nIndex, nColumnIndex]() noexcept {
+        LV_ITEMW item{};
+        item.mask = LVIF_TEXT;
+        item.iItem = gsl::narrow_cast<int>(nIndex);
+        item.iSubItem = gsl::narrow_cast<int>(nColumnIndex);
+        GSL_SUPPRESS_TYPE3
+        item.pszText = const_cast<wchar_t*>(sText.data());
+
+        GSL_SUPPRESS_TYPE1
+        SNDMSG(m_hWnd, LVM_SETITEMW, 0, reinterpret_cast<LPARAM>(&item));
+
+        ListView_RedrawItems(m_hWnd, nIndex, nIndex);
+    });
+
+    m_bForceRepaintItems = true;
 }
 
 void GridBinding::EnsureVisible(gsl::index nIndex)
 {
     if (nIndex >= 0 && nIndex < gsl::narrow_cast<gsl::index>(m_vmItems->Count()))
-        ListView_EnsureVisible(m_hWnd, gsl::narrow_cast<int>(nIndex), FALSE);
+    {
+        InvokeOnUIThread([this, nIndex]() noexcept {
+            ListView_EnsureVisible(m_hWnd, gsl::narrow_cast<int>(nIndex), FALSE);
+        });
+    }
 }
 
 void GridBinding::DeselectAll() noexcept
@@ -449,6 +449,12 @@ void GridBinding::UpdateRow(gsl::index nIndex, bool bExisting)
     if (m_pUpdateSelectedItems)
         return;
 
+    if (!WindowBinding::IsOnUIThread())
+    {
+        InvokeOnUIThread([this, nIndex, bExisting]() { UpdateRow(nIndex, bExisting); });
+        return;
+    }
+
     std::wstring sText;
 
     LV_ITEMW item{};
@@ -526,7 +532,9 @@ void GridBinding::OnViewModelRemoved(gsl::index nIndex)
         else
         {
             SuspendRedraw();
-            ListView_DeleteItem(m_hWnd, nIndex);
+            InvokeOnUIThread([this, nIndex]() noexcept {
+                ListView_DeleteItem(m_hWnd, nIndex);
+            });
         }
 
         if (!m_vmItems->IsUpdating())
@@ -573,6 +581,12 @@ void GridBinding::OnEndViewModelCollectionUpdate()
 {
     if (m_hWnd)
     {
+        if (!WindowBinding::IsOnUIThread())
+        {
+            WindowBinding::InvokeOnUIThread([this]() { OnEndViewModelCollectionUpdate(); });
+            return;
+        }
+
         if (m_bRedrawSuspended)
         {
             // enable redraw before calling CheckForScrollBar to ensure metrics are updated
@@ -606,13 +620,15 @@ void GridBinding::OnEndViewModelCollectionUpdate()
     }
 }
 
-void GridBinding::SuspendRedraw() noexcept
+void GridBinding::SuspendRedraw()
 {
     if (!m_bRedrawSuspended && m_vmItems->IsUpdating())
     {
         // if we're in a BeginUpdate/EndUpdate block, stop redrawing until the EndUpdate
         m_bRedrawSuspended = true;
-        SendMessage(m_hWnd, WM_SETREDRAW, FALSE, 0);
+        InvokeOnUIThread([this]() noexcept {
+            SendMessage(m_hWnd, WM_SETREDRAW, FALSE, 0);
+        });
     }
 }
 
diff --git a/src/ui/win32/bindings/GridBinding.hh b/src/ui/win32/bindings/GridBinding.hh
index c0039266..3fdf7192 100644
--- a/src/ui/win32/bindings/GridBinding.hh
+++ b/src/ui/win32/bindings/GridBinding.hh
@@ -74,7 +74,7 @@ public:
     virtual void EnsureVisible(gsl::index nIndex) noexcept(false);
 
 protected:
-    void SuspendRedraw() noexcept;
+    void SuspendRedraw();
     void UpdateLayout();
     virtual void UpdateAllItems();
     virtual void UpdateItems(gsl::index nColumn);
diff --git a/src/ui/win32/bindings/TextBoxBinding.hh b/src/ui/win32/bindings/TextBoxBinding.hh
index 7f0e4f8d..aead9b61 100644
--- a/src/ui/win32/bindings/TextBoxBinding.hh
+++ b/src/ui/win32/bindings/TextBoxBinding.hh
@@ -98,7 +98,11 @@ protected:
     void OnViewModelBoolValueChanged(const BoolModelProperty::ChangeArgs& args) override
     {
         if (m_pReadOnlyProperty && *m_pReadOnlyProperty == args.Property)
-            UpdateReadOnly();
+        {
+            InvokeOnUIThread([this]() {
+                UpdateReadOnly();
+            });
+        }
     }
 
     INT_PTR CALLBACK WndProc(HWND hControl, UINT uMsg, WPARAM wParam, LPARAM lParam) override
@@ -135,7 +139,9 @@ protected:
 
     virtual void UpdateTextFromSource(const std::wstring& sText) noexcept(false)
     {
-        SetWindowTextW(m_hWnd, sText.c_str());
+        InvokeOnUIThread([this, sTextCopy = sText]() noexcept {
+            SetWindowTextW(m_hWnd, sTextCopy.c_str());
+        });
     }
 
     virtual void UpdateSourceFromText(const std::wstring& sText)
diff --git a/src/ui/win32/bindings/WindowBinding.cpp b/src/ui/win32/bindings/WindowBinding.cpp
index e53718a7..13f81c8b 100644
--- a/src/ui/win32/bindings/WindowBinding.cpp
+++ b/src/ui/win32/bindings/WindowBinding.cpp
@@ -1,6 +1,8 @@
 #include "WindowBinding.hh"
 
 #include "RA_Core.h"
+#include "RA_Log.h"
+#include "RA_Resource.h"
 
 #include "data\ModelProperty.hh"
 
@@ -16,6 +18,37 @@ namespace ui {
 namespace win32 {
 namespace bindings {
 
+class DispatchingWindow : public DialogBase
+{
+public:
+    explicit DispatchingWindow(WindowViewModelBase& vmWindow) : DialogBase(vmWindow) {}
+    virtual ~DispatchingWindow() noexcept = default;
+    DispatchingWindow(const DispatchingWindow&) noexcept = delete;
+    DispatchingWindow& operator=(const DispatchingWindow&) noexcept = delete;
+    DispatchingWindow(DispatchingWindow&&) noexcept = delete;
+    DispatchingWindow& operator=(DispatchingWindow&&) noexcept = delete;
+
+    class ViewModel : public WindowViewModelBase
+    {
+    };
+
+    class Presenter : public IDialogPresenter
+    {
+    public:
+        bool IsSupported(const ra::ui::WindowViewModelBase&) noexcept override { return true; }
+        void ShowWindow(ra::ui::WindowViewModelBase&) noexcept override {}
+        void ShowModal(ra::ui::WindowViewModelBase&, HWND) noexcept override {}
+    };
+
+    Presenter m_pPresenter;
+
+private:
+
+    ViewModel m_vmViewModel;
+};
+static std::unique_ptr<DispatchingWindow> s_pDispatchingWindow;
+static DispatchingWindow::ViewModel s_vmDispatchingWindow;
+
 std::vector<WindowBinding*> WindowBinding::s_vKnownBindings;
 DWORD WindowBinding::s_hUIThreadId;
 
@@ -94,14 +127,6 @@ void WindowBinding::SetHWND(DialogBase* pDialog, HWND hWnd)
 
         RestoreSizeAndPosition();
     }
-
-    if (m_pDialog)
-    {
-        InvokeOnUIThread([this]() noexcept
-        {
-            s_hUIThreadId = GetCurrentThreadId();
-        });
-    }
 }
 
 void WindowBinding::UpdateAppTitle()
@@ -300,7 +325,11 @@ void WindowBinding::OnViewModelStringValueChanged(const StringModelProperty::Cha
 
     const auto pIter = m_mLabelBindings.find(args.Property.GetKey());
     if (pIter != m_mLabelBindings.end())
-        SetDlgItemTextW(m_hWnd, pIter->second, args.tNewValue.c_str());
+    {
+        InvokeOnUIThread([this, nDlgItemId = pIter->second, sValue = args.tNewValue]() noexcept {
+            SetDlgItemTextW(m_hWnd, nDlgItemId, sValue.c_str());
+        });
+    }
 }
 
 void WindowBinding::BindLabel(int nDlgItemId, const StringModelProperty& pSourceProperty)
@@ -319,8 +348,9 @@ void WindowBinding::OnViewModelIntValueChanged(const IntModelProperty::ChangeArg
     const auto pIter = m_mLabelBindings.find(args.Property.GetKey());
     if (pIter != m_mLabelBindings.end())
     {
-        const auto sText = std::to_wstring(args.tNewValue);
-        SetDlgItemTextW(m_hWnd, pIter->second, sText.c_str());
+        InvokeOnUIThread([this, nDlgItemId = pIter->second, sText = std::to_wstring(args.tNewValue)]() noexcept {
+            SetDlgItemTextW(m_hWnd, nDlgItemId, sText.c_str());
+        });
     }
 
     if (m_pDialog && args.Property == WindowViewModelBase::DialogResultProperty)
@@ -351,7 +381,9 @@ void WindowBinding::OnViewModelBoolValueChanged(const BoolModelProperty::ChangeA
             auto hControl = GetDlgItem(m_hWnd, nDlgItemId);
             if (hControl)
             {
-                EnableWindow(hControl, args.tNewValue ? TRUE : FALSE);
+                InvokeOnUIThread([hControl, bValue = args.tNewValue ? TRUE : FALSE]() noexcept {
+                    EnableWindow(hControl, bValue);
+                });
                 bRepaint = true;
             }
         }
@@ -369,7 +401,9 @@ void WindowBinding::OnViewModelBoolValueChanged(const BoolModelProperty::ChangeA
                 if (bVisible && m_vMultipleVisibilityBoundControls.find(nDlgItemId) != m_vMultipleVisibilityBoundControls.end())
                     bVisible = CheckMultiBoundVisibility(nDlgItemId);
 
-                ShowWindow(hControl, bVisible ? SW_SHOW : SW_HIDE);
+                InvokeOnUIThread([hControl, bVisible]() noexcept {
+                    ShowWindow(hControl, bVisible ? SW_SHOW : SW_HIDE);
+                });
                 bRepaint = true;
             }
         }
@@ -489,10 +523,73 @@ bool WindowBinding::GetValueFromAny(const BoolModelProperty& pProperty) const
     return bValue;
 }
 
+void WindowBinding::SetUIThread(DWORD hThreadId) noexcept
+{
+    if (s_hUIThreadId == hThreadId)
+        return;
+
+    s_hUIThreadId = hThreadId;
+
+    s_pDispatchingWindow.reset();
+}
+
+void WindowBinding::EnableInvokeOnUIThread()
+{
+    if (s_pDispatchingWindow == nullptr && IsOnUIThread())
+    {
+        // create a hidden dummy window for dispatching UI messages
+
+        s_pDispatchingWindow.reset(new DispatchingWindow(s_vmDispatchingWindow));
+        if (!s_pDispatchingWindow->CreateDialogWindow(MAKEINTRESOURCE(IDD_RA_RICHPRESENCE),
+                                                      &s_pDispatchingWindow->m_pPresenter))
+        {
+            RA_LOG_ERR("Could not create Code Notes dialog!");
+            s_pDispatchingWindow.reset();
+        }
+    }
+}
+
 void WindowBinding::InvokeOnUIThread(std::function<void()> fAction)
 {
-    Expects(m_pDialog != nullptr);
-    m_pDialog->QueueFunction(fAction);
+    if (s_pDispatchingWindow == nullptr)
+    {
+        // no window to post the action to - just run it
+        fAction();
+    }
+    else if (IsOnUIThread())
+    {
+        // already on the UI thread
+        fAction();
+    }
+    else
+    {
+        // queue the function to be called from the UI thread
+        s_pDispatchingWindow->QueueFunction(fAction);
+    }
+}
+
+void WindowBinding::InvokeOnUIThreadAndWait(std::function<void()> fAction)
+{
+    if (s_pDispatchingWindow == nullptr || IsOnUIThread())
+    {
+        fAction();
+    }
+    else
+    {
+        std::mutex pMutex;
+        std::condition_variable pCondition;
+        bool bExecuted = false;
+
+        s_pDispatchingWindow->QueueFunction([fAction, &pCondition, &bExecuted]() {
+            fAction();
+
+            bExecuted = true;
+            pCondition.notify_all();
+        });
+
+        std::unique_lock<std::mutex> lock(pMutex);
+        pCondition.wait(lock, [&bExecuted]() { return bExecuted; });
+    }
 }
 
 } // namespace bindings
diff --git a/src/ui/win32/bindings/WindowBinding.hh b/src/ui/win32/bindings/WindowBinding.hh
index cc86ac95..4337c559 100644
--- a/src/ui/win32/bindings/WindowBinding.hh
+++ b/src/ui/win32/bindings/WindowBinding.hh
@@ -126,14 +126,29 @@ public:
     void OnPositionChanged(ra::ui::Position oPosition);
 
     /// <summary>
-    /// Executes a function on the UI thread.
+    /// Specifies the UI thread.
     /// </summary>
-    void InvokeOnUIThread(std::function<void()> fAction);
+    static void SetUIThread(DWORD hUIThreadId) noexcept;
+
+    /// <summary>
+    /// Ensures the UI thread dispatcher is enabled.
+    /// </summary>
+    static void EnableInvokeOnUIThread();
 
     /// <summary>
     /// Returns <c>true</c> if the current thread is the UI thread.
     /// </summary>
-    bool IsOnUIThread() const noexcept { return GetCurrentThreadId() == s_hUIThreadId; }
+    static bool IsOnUIThread() noexcept { return GetCurrentThreadId() == s_hUIThreadId; }
+
+    /// <summary>
+    /// Dispatches a function to the UI thread.
+    /// </summary>
+    static void InvokeOnUIThread(std::function<void()> fAction);
+
+    /// <summary>
+    /// Executes a function on the UI thread and waits for it to complete.
+    /// </summary>
+    static void InvokeOnUIThreadAndWait(std::function<void()> fAction);
 
 protected:
     // ViewModelBase::NotifyTarget


Test patch:
diff --git a/tests/RA_Integration.Tests.vcxproj b/tests/RA_Integration.Tests.vcxproj
index 09a7876c..3d788cc6 100644
--- a/tests/RA_Integration.Tests.vcxproj
+++ b/tests/RA_Integration.Tests.vcxproj
@@ -313,6 +313,7 @@
     <ClCompile Include="..\src\RA_md5factory.cpp" />
     <ClCompile Include="..\src\RA_StringUtils.cpp" />
     <ClCompile Include="..\src\services\AchievementRuntime.cpp" />
+    <ClCompile Include="..\src\services\AchievementRuntimeExports.cpp" />
     <ClCompile Include="..\src\services\FrameEventQueue.cpp" />
     <ClCompile Include="..\src\services\GameIdentifier.cpp" />
     <ClCompile Include="..\src\services\Http.cpp" />
diff --git a/tests/RA_Integration.Tests.vcxproj.filters b/tests/RA_Integration.Tests.vcxproj.filters
index 98a2c0ad..150cf5fa 100644
--- a/tests/RA_Integration.Tests.vcxproj.filters
+++ b/tests/RA_Integration.Tests.vcxproj.filters
@@ -462,6 +462,9 @@
     <ClCompile Include="mocks\MockAchievementRuntime.cpp">
       <Filter>Mocks</Filter>
     </ClCompile>
+    <ClCompile Include="..\src\services\AchievementRuntimeExports.cpp">
+      <Filter>Code</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <None Include="base.props" />
diff --git a/tests/data/context/GameContext_Tests.cpp b/tests/data/context/GameContext_Tests.cpp
index c5230976..140b5232 100644
--- a/tests/data/context/GameContext_Tests.cpp
+++ b/tests/data/context/GameContext_Tests.cpp
@@ -170,7 +170,7 @@ public:
                 "}}");
             mockAchievementRuntime.MockResponse(
                 "r=startsession&u=Username&t=ApiToken&g=" + std::to_string(nGameID) +
-                                                "&l=" RCHEEVOS_VERSION_STRING,
+                    "&h=1&m=" + sHash + "&l=" RCHEEVOS_VERSION_STRING,
                 sUnlocks.empty() ? "{\"Success\":true}" : "{\"Success\":true," + sUnlocks + "}");
         }
 
diff --git a/tests/mocks/MockDesktop.hh b/tests/mocks/MockDesktop.hh
index c363de97..60992158 100644
--- a/tests/mocks/MockDesktop.hh
+++ b/tests/mocks/MockDesktop.hh
@@ -94,8 +94,8 @@ public:
     bool IsDebuggerPresent() const noexcept override { return m_bDebuggerPresent; }
     void SetDebuggerPresent(bool bValue) noexcept { m_bDebuggerPresent = bValue; }
 
-    bool IsOnUIThread(const WindowViewModelBase&) const override { return true; }
-    void InvokeOnUIThread(const WindowViewModelBase&, std::function<void()> fAction) const override { fAction(); }
+    bool IsOnUIThread() const override { return true; }
+    void InvokeOnUIThread(std::function<void()> fAction) const override { fAction(); }
 
 private:
     ra::ui::DialogResult Handle(WindowViewModelBase& vmViewModel) const
diff --git a/tests/services/AchievementRuntime_Tests.cpp b/tests/services/AchievementRuntime_Tests.cpp
index 1767a6c9..c8fa2ff1 100644
--- a/tests/services/AchievementRuntime_Tests.cpp
+++ b/tests/services/AchievementRuntime_Tests.cpp
@@ -26,8 +26,11 @@
 
 #ifdef RC_CLIENT_EXPORTS_EXTERNAL
 #include <rcheevos\src\rc_client_external.h>
+#include <rcheevos\include\rc_client_raintegration.h>
 #include "Exports.hh"
+#include "RA_Resource.h"
 extern "C" API int CCONV _Rcheevos_GetExternalClient(rc_client_external_t* pClientExternal, int nVersion);
+extern "C" API const rc_client_raintegration_menu_t* CCONV _Rcheevos_RAIntegrationGetMenu();
 #endif
 
 using namespace Microsoft::VisualStudio::CppUnitTestFramework;
@@ -3181,6 +3184,97 @@ public:
 
         AssertV1Exports(pClient);
     }
+
+    TEST_METHOD(TestRAIntegrationGetMenu)
+    {
+        AchievementRuntimeHarness runtime;
+        runtime.mockUserContext.Logout();
+
+        const rc_client_raintegration_menu_t* pMenu;
+
+        auto AssertMenuItem = [&pMenu](int nIndex, uint32_t nId, const char* label) {
+            const auto* pItem = &pMenu->items[nIndex];
+            Assert::AreEqual(nId, pItem->id);
+            Assert::AreEqual(std::string(label), std::string(pItem->label));
+            Assert::AreEqual({1}, pItem->enabled);
+            Assert::AreEqual({0}, pItem->checked);
+        };
+
+        auto AssertMenuItemChecked = [&pMenu](int nIndex, uint32_t nId, const char* label) {
+            const auto* pItem = &pMenu->items[nIndex];
+            Assert::AreEqual(nId, pItem->id);
+            Assert::AreEqual(std::string(label), std::string(pItem->label));
+            Assert::AreEqual({1}, pItem->enabled);
+            Assert::AreEqual({1}, pItem->checked);
+        };
+
+        auto AssertMenuSeparator = [&pMenu](int nIndex) {
+            const auto* pItem = &pMenu->items[nIndex];
+            Assert::AreEqual(0U, pItem->id);
+            Assert::IsNull(pItem->label);
+            Assert::AreEqual({1}, pItem->enabled);
+            Assert::AreEqual({0}, pItem->checked);
+        };
+
+        pMenu = _Rcheevos_RAIntegrationGetMenu();
+        Assert::AreEqual(11U, pMenu->num_items);
+        AssertMenuItem(0, IDM_RA_HARDCORE_MODE, "&Hardcore Mode");
+        AssertMenuItem(1, IDM_RA_NON_HARDCORE_WARNING, "Non-Hardcore &Warning");
+        AssertMenuSeparator(2);
+        AssertMenuItem(3, IDM_RA_FILES_OPENALL, "&Open All");
+        AssertMenuItem(4, IDM_RA_FILES_ACHIEVEMENTS, "Assets Li&st");
+        AssertMenuItem(5, IDM_RA_FILES_ACHIEVEMENTEDITOR, "Assets &Editor");
+        AssertMenuItem(6, IDM_RA_FILES_MEMORYFINDER, "&Memory Inspector");
+        AssertMenuItem(7, IDM_RA_FILES_MEMORYBOOKMARKS, "Memory &Bookmarks");
+        AssertMenuItem(8, IDM_RA_FILES_POINTERFINDER, "Pointer &Finder");
+        AssertMenuItem(9, IDM_RA_FILES_CODENOTES, "Code &Notes");
+        AssertMenuItem(10, IDM_RA_PARSERICHPRESENCE, "Rich &Presence Monitor");
+
+        runtime.mockUserContext.Initialize("User", "ApiToken");
+
+        pMenu = _Rcheevos_RAIntegrationGetMenu();
+        Assert::AreEqual(17U, pMenu->num_items);
+        AssertMenuItem(0, IDM_RA_OPENUSERPAGE, "Open my &User Page");
+        AssertMenuItem(1, IDM_RA_OPENGAMEPAGE, "Open this &Game's Page");
+        AssertMenuSeparator(2);
+        AssertMenuItem(3, IDM_RA_HARDCORE_MODE, "&Hardcore Mode");
+        AssertMenuItem(4, IDM_RA_NON_HARDCORE_WARNING, "Non-Hardcore &Warning");
+        AssertMenuSeparator(5);
+        AssertMenuItem(6, IDM_RA_FILES_OPENALL, "&Open All");
+        AssertMenuItem(7, IDM_RA_FILES_ACHIEVEMENTS, "Assets Li&st");
+        AssertMenuItem(8, IDM_RA_FILES_ACHIEVEMENTEDITOR, "Assets &Editor");
+        AssertMenuItem(9, IDM_RA_FILES_MEMORYFINDER, "&Memory Inspector");
+        AssertMenuItem(10, IDM_RA_FILES_MEMORYBOOKMARKS, "Memory &Bookmarks");
+        AssertMenuItem(11, IDM_RA_FILES_POINTERFINDER, "Pointer &Finder");
+        AssertMenuItem(12, IDM_RA_FILES_CODENOTES, "Code &Notes");
+        AssertMenuItem(13, IDM_RA_PARSERICHPRESENCE, "Rich &Presence Monitor");
+        AssertMenuSeparator(14);
+        AssertMenuItem(15, IDM_RA_REPORTBROKENACHIEVEMENTS, "&Report Achievement Problem");
+        AssertMenuItem(16, IDM_RA_GETROMCHECKSUM, "View Game H&ash");
+
+        runtime.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
+        runtime.mockConfiguration.SetFeatureEnabled(ra::services::Feature::NonHardcoreWarning, true);
+
+        pMenu = _Rcheevos_RAIntegrationGetMenu();
+        Assert::AreEqual(17U, pMenu->num_items);
+        AssertMenuItem(0, IDM_RA_OPENUSERPAGE, "Open my &User Page");
+        AssertMenuItem(1, IDM_RA_OPENGAMEPAGE, "Open this &Game's Page");
+        AssertMenuSeparator(2);
+        AssertMenuItemChecked(3, IDM_RA_HARDCORE_MODE, "&Hardcore Mode");
+        AssertMenuItemChecked(4, IDM_RA_NON_HARDCORE_WARNING, "Non-Hardcore &Warning");
+        AssertMenuSeparator(5);
+        AssertMenuItem(6, IDM_RA_FILES_OPENALL, "&Open All");
+        AssertMenuItem(7, IDM_RA_FILES_ACHIEVEMENTS, "Assets Li&st");
+        AssertMenuItem(8, IDM_RA_FILES_ACHIEVEMENTEDITOR, "Assets &Editor");
+        AssertMenuItem(9, IDM_RA_FILES_MEMORYFINDER, "&Memory Inspector");
+        AssertMenuItem(10, IDM_RA_FILES_MEMORYBOOKMARKS, "Memory &Bookmarks");
+        AssertMenuItem(11, IDM_RA_FILES_POINTERFINDER, "Pointer &Finder");
+        AssertMenuItem(12, IDM_RA_FILES_CODENOTES, "Code &Notes");
+        AssertMenuItem(13, IDM_RA_PARSERICHPRESENCE, "Rich &Presence Monitor");
+        AssertMenuSeparator(14);
+        AssertMenuItem(15, IDM_RA_REPORTBROKENACHIEVEMENTS, "&Report Achievement Problem");
+        AssertMenuItem(16, IDM_RA_GETROMCHECKSUM, "View Game H&ash");
+    }
 #endif
 };
 
diff --git a/tests/ui/viewmodels/OverlayAchievementsPageViewModel_Tests.cpp b/tests/ui/viewmodels/OverlayAchievementsPageViewModel_Tests.cpp
index d7511368..8937c911 100644
--- a/tests/ui/viewmodels/OverlayAchievementsPageViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/OverlayAchievementsPageViewModel_Tests.cpp
@@ -603,7 +603,7 @@ public:
 
         achievementsPage.AssertHeader(0, L"Locked");
         achievementsPage.AssertProgressAchievement(1, pAch1, 0.0, L"");
-        achievementsPage.AssertProgressAchievement(2, pAch2, 50.0, L"5/10");
+        achievementsPage.AssertProgressAchievement(2, pAch2, 0.5, L"5/10");
         achievementsPage.AssertProgressAchievement(3, pAch3, 0.0, L"");
         achievementsPage.AssertHeader(4, L"Unlocked");
         achievementsPage.AssertUnlockedAchievement(5, pAch4);
@@ -652,10 +652,10 @@ public:
         Assert::AreEqual(std::wstring(L"2 of 10 points"), achievementsPage.GetTitleDetail());
 
         achievementsPage.AssertHeader(0, L"Almost There");
-        achievementsPage.AssertProgressAchievement(1, pAch3, 90.0, L"9/10");
+        achievementsPage.AssertProgressAchievement(1, pAch3, 0.9f, L"9/10");
         achievementsPage.AssertHeader(2, L"Locked");
-        achievementsPage.AssertProgressAchievement(3, pAch1, 10.0, L"1/10");
-        achievementsPage.AssertProgressAchievement(4, pAch4, 0.0, L"");
+        achievementsPage.AssertProgressAchievement(3, pAch1, 0.1f, L"1/10");
+        achievementsPage.AssertProgressAchievement(4, pAch4, 0.0f, L"");
         achievementsPage.AssertHeader(5, L"Unlocked");
         achievementsPage.AssertUnlockedAchievement(6, pAch2);
         Assert::IsNull(achievementsPage.GetItem(7));
@@ -704,10 +704,10 @@ public:
         Assert::AreEqual(std::wstring(L"0 of 10 points"), achievementsPage.GetTitleDetail());
 
         achievementsPage.AssertHeader(0, L"Almost There");
-        achievementsPage.AssertProgressAchievement(1, pAch3, 95.0, L"95/100");
-        achievementsPage.AssertProgressAchievement(2, pAch1, 90.0, L"90/100");
-        achievementsPage.AssertProgressAchievement(3, pAch2, 85.0, L"85/100");
-        achievementsPage.AssertProgressAchievement(4, pAch4, 85.0, L"85/100");
+        achievementsPage.AssertProgressAchievement(1, pAch3, 0.95f, L"95/100");
+        achievementsPage.AssertProgressAchievement(2, pAch1, 0.90f, L"90/100");
+        achievementsPage.AssertProgressAchievement(3, pAch2, 0.85f, L"85/100");
+        achievementsPage.AssertProgressAchievement(4, pAch4, 0.85f, L"85/100");
         Assert::IsNull(achievementsPage.GetItem(5));
     }