Repository: RetroAchievements/RAIntegration
Pull request #1041 (2023-12-02 20:39:24)

move non-hardcore warning to account for async load


Patch:
diff --git a/rcheevos b/rcheevos
index c6887b29..64fcb9ea 160000
--- a/rcheevos
+++ b/rcheevos
@@ -1,1 +1,1 @@
-Subproject commit c6887b295657a742b686dfdc2f14023a4cd07773
+Subproject commit 64fcb9ea3cf990e65343057ace9271ff3b77428e
diff --git a/src/api/impl/ConnectedServer.cpp b/src/api/impl/ConnectedServer.cpp
index ab8eccba..3e56133b 100644
--- a/src/api/impl/ConnectedServer.cpp
+++ b/src/api/impl/ConnectedServer.cpp
@@ -346,7 +346,7 @@ static bool DoRequest(const rc_api_request_t& api_request, const char* sApiName,
                 if (!sParams.empty())
                     sParams.push_back('&');
 
-                sParams.append(api_request.post_data, ptr - api_request.post_data - 1);
+                sParams.append(api_request.post_data, ptr - api_request.post_data);
             }
 
             while (*ptr && ptr[0] != '&')
diff --git a/src/data/context/GameContext.cpp b/src/data/context/GameContext.cpp
index a89b6aa0..68f422bf 100644
--- a/src/data/context/GameContext.cpp
+++ b/src/data/context/GameContext.cpp
@@ -18,6 +18,7 @@
 #include "data\models\RichPresenceModel.hh"
 
 #include "services\AchievementRuntime.hh"
+#include "services\FrameEventQueue.hh"
 #include "services\IAudioSystem.hh"
 #include "services\IConfiguration.hh"
 #include "services\ILocalStorage.hh"
@@ -145,9 +146,6 @@ void GameContext::LoadGame(unsigned int nGameId, const std::string& sGameHash, M
         }
     }
 
-    // enable spectator mode to prevent unlocks when testing compatibility
-    rc_client_set_spectator_mode_enabled(pRuntime.GetClient(), nMode == Mode::CompatibilityTest);
-
     const bool bWasPaused = pRuntime.IsPaused();
     pRuntime.SetPaused(true);
 
@@ -293,6 +291,39 @@ void GameContext::FinishLoadGame(int nResult, const char* sErrorMessage, bool bW
     pRuntime.SyncAssets();
 
     EndLoad();
+
+    // non-hardcore warning
+    auto& pConfiguration = ra::services::ServiceLocator::Get<ra::services::IConfiguration>();
+    if (!pConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore))
+    {
+        bool bShowHardcorePrompt = false;
+        if (pConfiguration.IsFeatureEnabled(ra::services::Feature::NonHardcoreWarning))
+            bShowHardcorePrompt = Assets().HasCoreAssets();
+
+        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);
+            });
+        }
+        else
+        {
+            const bool bLeaderboardsEnabled = pConfiguration.IsFeatureEnabled(ra::services::Feature::Leaderboards);
+
+            ra::services::ServiceLocator::Get<ra::services::IAudioSystem>().PlayAudioFile(L"Overlay\\info.wav");
+            ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>().QueueMessage(
+                L"Playing in Softcore Mode",
+                bLeaderboardsEnabled ? L"Leaderboard entries will not be submitted." : L"");
+        }
+    }
+
     OnActiveGameChanged();
 }
 
@@ -322,6 +353,15 @@ void GameContext::InitializeFromAchievementRuntime(const std::map<uint32_t, std:
                 {
                     case RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE:
                         nCategory = ra::data::models::AssetCategory::Core;
+
+                        // automatically activate all core achievements in compatibility mode
+                        if (GetMode() == Mode::CompatibilityTest)
+                        {
+                            pAchievementData->public_.state = RC_CLIENT_ACHIEVEMENT_STATE_ACTIVE;
+
+                            if (pAchievementData->trigger)
+                                pAchievementData->trigger->state = RC_TRIGGER_STATE_WAITING;
+                        }
                         break;
 
                     case RC_CLIENT_ACHIEVEMENT_CATEGORY_UNOFFICIAL:
diff --git a/src/data/models/CodeNotesModel.cpp b/src/data/models/CodeNotesModel.cpp
index 905e7c5c..22a8f4c1 100644
--- a/src/data/models/CodeNotesModel.cpp
+++ b/src/data/models/CodeNotesModel.cpp
@@ -110,7 +110,12 @@ void CodeNotesModel::ExtractSize(CodeNote& pNote)
         const wchar_t c = (nIndex == nLength) ? 0 : pNote.Note.at(nIndex);
 
         // find the next word
-        if (isalpha(c))
+        if (c > 255)
+        {
+            // ignore unicode characters - isalpha with the default locale would return false,
+            // but also likes to pop up asserts when in a debug build.
+        }
+        else if (isalpha(c))
         {
             if (sWord.empty())
             {
@@ -252,7 +257,7 @@ void CodeNotesModel::ExtractSize(CodeNote& pNote)
         bLastWordIsSize = bWordIsSize;
         bLastWordIsNumber = bWordIsNumber;
 
-        if (isalnum(c))
+        if (c < 256 && isalnum(c))
         {
             std::swap(sPreviousWord, sWord);
             sWord.clear();
diff --git a/src/services/AchievementRuntime.cpp b/src/services/AchievementRuntime.cpp
index 38ad3ec5..11c1ae62 100644
--- a/src/services/AchievementRuntime.cpp
+++ b/src/services/AchievementRuntime.cpp
@@ -20,6 +20,7 @@
 #include "services\IHttpRequester.hh"
 #include "services\ILocalStorage.hh"
 #include "services\ServiceLocator.hh"
+#include "services\impl\JsonFileConfiguration.hh"
 
 #include "ui\viewmodels\MessageBoxViewModel.hh"
 #include "ui\viewmodels\OverlayManager.hh"
@@ -987,6 +988,24 @@ rc_client_async_handle_t* AchievementRuntime::BeginLoadGame(const char* sHash, u
     return rc_client_begin_load_game(client, sHash, AchievementRuntime::LoadGameCallback, pCallbackWrapper);
 }
 
+rc_client_async_handle_t* AchievementRuntime::BeginIdentifyAndLoadGame(uint32_t console_id, const char* file_path,
+                                                                       const uint8_t* data, size_t data_size,
+                                                                       CallbackWrapper* pCallbackWrapper) noexcept
+{
+    auto* client = GetClient();
+
+    // unload the game and free any additional memory we allocated for local changes
+    rc_client_unload_game(client);
+    m_pClientSynchronizer.reset();
+    s_mAchievementPopups.clear();
+
+    // start the load process
+    client->callbacks.post_process_game_data_response = PostProcessGameDataResponse;
+
+    return rc_client_begin_identify_and_load_game(client, console_id, file_path, data, data_size,
+                                                  AchievementRuntime::LoadGameCallback, pCallbackWrapper);
+}
+
 GSL_SUPPRESS_CON3
 void AchievementRuntime::LoadGameCallback(int nResult, const char* sErrorMessage, rc_client_t*, void* pUserdata)
 {
@@ -1009,6 +1028,28 @@ void AchievementRuntime::LoadGameCallback(int nResult, const char* sErrorMessage
     delete wrapper;
 }
 
+rc_client_async_handle_t* AchievementRuntime::BeginChangeMedia(const char* file_path,
+    const uint8_t* data, size_t data_size, CallbackWrapper* pCallbackWrapper) noexcept
+{
+    auto* client = GetClient();
+    return rc_client_begin_change_media(client, file_path, data, data_size, AchievementRuntime::ChangeMediaCallback, pCallbackWrapper);
+}
+
+GSL_SUPPRESS_CON3
+void AchievementRuntime::ChangeMediaCallback(int nResult, const char* sErrorMessage, rc_client_t*, void* pUserdata)
+{
+    auto* wrapper = static_cast<CallbackWrapper*>(pUserdata);
+    Expects(wrapper != nullptr);
+
+    if (nResult == RC_HARDCORE_DISABLED)
+        ra::services::ServiceLocator::GetMutable<ra::data::context::EmulatorContext>().DisableHardcoreMode();
+
+    wrapper->DoCallback(nResult, sErrorMessage);
+
+    delete wrapper;
+}
+
+
 /* ---- DoFrame ----- */
 
 static void PrepareForPauseOnChangeEvents(rc_client_t* pClient,
@@ -2284,15 +2325,127 @@ int AchievementRuntime::SaveProgressToBuffer(uint8_t* pBuffer, int nBufferSize)
 
 /* ---- Exports ----- */
 
-#ifdef RC_CLIENT_SUPPORTS_EXTERNAL
+#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>();
@@ -2303,10 +2456,11 @@ public:
                                                             const char* token, rc_client_callback_t callback,
                                                             void* callback_userdata)
     {
-        auto* pCallbackData = new CallbackWrapper(client, callback, 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, pCallbackData);
+        return pClient.BeginLoginWithToken(username, token, pCallbackWrapper);
     }
 
     static const rc_client_user_t* get_user_info()
@@ -2320,9 +2474,312 @@ public:
         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);
+    }
 };
 
-#endif RC_CLIENT_SUPPORTS_EXTERNAL
+AchievementRuntimeExports::ExternalClientCallbacks AchievementRuntimeExports::s_callbacks{};
+
+#endif RC_CLIENT_EXPORTS_EXTERNAL
 
 } // namespace services
 } // namespace ra
@@ -2343,16 +2800,75 @@ extern "C" unsigned int rc_peek_callback(unsigned int nAddress, unsigned int nBy
     }
 }
 
-#ifdef RC_CLIENT_SUPPORTS_EXTERNAL
+#ifdef RC_CLIENT_EXPORTS_EXTERNAL
 
 #include "Exports.hh"
-#include "rcheevos/src/rcheevos/rc_client_external.h"
+#include "rcheevos/src/rc_client_external.h"
 
 #ifdef __cplusplus
 extern "C" {
 #endif
 
-API int CCONV _Rcheevos_GetExternalClient(rc_client_external_t* pClient, int nVersion)
+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)
     {
@@ -2361,10 +2877,7 @@ API int CCONV _Rcheevos_GetExternalClient(rc_client_external_t* pClient, int nVe
             __fallthrough;
 
         case 1:
-            pClient->begin_login_with_password = ra::services::AchievementRuntimeExports::begin_login_with_password;
-            pClient->begin_login_with_token = ra::services::AchievementRuntimeExports::begin_login_with_token;
-            pClient->logout = ra::services::AchievementRuntimeExports::logout;
-            pClient->get_user_info = ra::services::AchievementRuntimeExports::get_user_info;
+            GetExternalClientV1(pClientExternal);
             break;
     }
 
@@ -2375,4 +2888,4 @@ API int CCONV _Rcheevos_GetExternalClient(rc_client_external_t* pClient, int nVe
 } // extern "C"
 #endif
 
-#endif /* RC_CLIENT_SUPPORTS_EXTERNAL */
+#endif /* RC_CLIENT_EXPORTS_EXTERNAL */
diff --git a/src/services/AchievementRuntime.hh b/src/services/AchievementRuntime.hh
index 392912f6..5c801c85 100644
--- a/src/services/AchievementRuntime.hh
+++ b/src/services/AchievementRuntime.hh
@@ -13,6 +13,8 @@
 
 #include <string>
 
+#define RC_CLIENT_EXPORTS_EXTERNAL
+
 #include <rcheevos\include\rc_client.h>
 
 struct rc_api_fetch_game_data_response_t;
@@ -260,6 +262,14 @@ private:
     rc_client_async_handle_t* BeginLoadGame(const char* sHash, unsigned id, CallbackWrapper* pCallbackWrapper) noexcept;
     static void LoadGameCallback(int nResult, const char* sErrorMessage, rc_client_t* pClient, void* pUserdata);
 
+    rc_client_async_handle_t* BeginIdentifyAndLoadGame(uint32_t console_id, const char* file_path,
+                                                       const uint8_t* data, size_t data_size,
+                                                       CallbackWrapper* pCallbackWrapper) noexcept;
+
+    rc_client_async_handle_t* BeginChangeMedia(const char* file_path, const uint8_t* data, size_t data_size,
+                                               CallbackWrapper* pCallbackWrapper) noexcept;
+    static void ChangeMediaCallback(int nResult, const char* sErrorMessage, rc_client_t*, void* pUserdata);
+
     static void 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);
diff --git a/src/services/FrameEventQueue.cpp b/src/services/FrameEventQueue.cpp
index e3f391e2..d7e9ee4e 100644
--- a/src/services/FrameEventQueue.cpp
+++ b/src/services/FrameEventQueue.cpp
@@ -13,6 +13,14 @@ void FrameEventQueue::DoFrame()
 {
     std::wstring sPauseMessage;
 
+    if (!m_vFunctions.empty())
+    {
+        for (auto& fFunction : m_vFunctions)
+            fFunction();
+
+        m_vFunctions.clear();
+    }
+
     if (!m_vTriggeredTriggers.empty())
     {
         sPauseMessage.append(L"The following triggers have triggered:");
diff --git a/src/services/FrameEventQueue.hh b/src/services/FrameEventQueue.hh
index 3aefb09f..5539bb2f 100644
--- a/src/services/FrameEventQueue.hh
+++ b/src/services/FrameEventQueue.hh
@@ -35,12 +35,18 @@ public:
         m_vTriggeredTriggers.push_back(sTriggerName);
     }
 
+    void QueueFunction(std::function<void(void)> fAction)
+    {
+        m_vFunctions.push_back(fAction);
+    }
+
     void DoFrame();
 
 protected:
     std::set<std::wstring> m_vMemChanges;
     std::vector<std::wstring> m_vResetTriggers;
     std::vector<std::wstring> m_vTriggeredTriggers;
+    std::vector<std::function<void(void)>> m_vFunctions;
 };
 
 } // namespace services
diff --git a/src/services/GameIdentifier.cpp b/src/services/GameIdentifier.cpp
index 3f70b48b..f9935ba9 100644
--- a/src/services/GameIdentifier.cpp
+++ b/src/services/GameIdentifier.cpp
@@ -184,35 +184,6 @@ void GameIdentifier::ActivateGame(unsigned int nGameId)
         pGameContext.SetGameHash((nGameId == m_nPendingGameId) ? m_sPendingHash : "");
 
         ra::services::ServiceLocator::GetMutable<ra::data::context::SessionTracker>().BeginSession(nGameId);
-
-        auto& pConfiguration = ra::services::ServiceLocator::Get<ra::services::IConfiguration>();
-        if (!pConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore))
-        {
-            bool bShowHardcorePrompt = false;
-            if (pConfiguration.IsFeatureEnabled(ra::services::Feature::NonHardcoreWarning))
-                bShowHardcorePrompt = pGameContext.Assets().HasCoreAssets();
-
-            if (bShowHardcorePrompt)
-            {
-                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
-            {
-                const bool bLeaderboardsEnabled = pConfiguration.IsFeatureEnabled(ra::services::Feature::Leaderboards);
-
-                ra::services::ServiceLocator::Get<ra::services::IAudioSystem>().PlayAudioFile(L"Overlay\\info.wav");
-                ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>().QueueMessage(
-                    L"Playing in Softcore Mode",
-                    bLeaderboardsEnabled ? L"Leaderboard entries will not be submitted." : L"");
-            }
-        }
     }
     else
     {
diff --git a/src/services/impl/JsonFileConfiguration.cpp b/src/services/impl/JsonFileConfiguration.cpp
index 797eb869..7575dc00 100644
--- a/src/services/impl/JsonFileConfiguration.cpp
+++ b/src/services/impl/JsonFileConfiguration.cpp
@@ -301,15 +301,21 @@ ra::ui::Size JsonFileConfiguration::GetWindowSize(const std::string & sPositionK
     return ra::ui::Size{ INT32_MIN, INT32_MIN };
 }
 
-void JsonFileConfiguration::SetWindowSize(const std::string & sPositionKey, const ra::ui::Size & oSize)
+void JsonFileConfiguration::SetWindowSize(const std::string& sPositionKey, const ra::ui::Size& oSize)
 {
     m_mWindowPositions[sPositionKey].oSize = oSize;
 }
 
+void JsonFileConfiguration::SetHost(const std::string& sHost)
+{
+    m_sHostName = sHost;
+    UpdateHost();
+}
+
 const std::string& JsonFileConfiguration::GetHostName() const
 {
     if (m_sHostName.empty())
-        GSL_SUPPRESS_TYPE3 const_cast<JsonFileConfiguration*>(this)->UpdateHost();
+        GSL_SUPPRESS_TYPE3 const_cast<JsonFileConfiguration*>(this)->ReadHostFile();
 
     return m_sHostName;
 }
@@ -317,7 +323,7 @@ const std::string& JsonFileConfiguration::GetHostName() const
 const std::string& JsonFileConfiguration::GetHostUrl() const
 {
     if (m_sHostUrl.empty())
-        GSL_SUPPRESS_TYPE3 const_cast<JsonFileConfiguration*>(this)->UpdateHost();
+        GSL_SUPPRESS_TYPE3 const_cast<JsonFileConfiguration*>(this)->ReadHostFile();
 
     return m_sHostUrl;
 }
@@ -325,12 +331,12 @@ const std::string& JsonFileConfiguration::GetHostUrl() const
 const std::string& JsonFileConfiguration::GetImageHostUrl() const
 {
     if (m_sImageHostUrl.empty())
-        GSL_SUPPRESS_TYPE3 const_cast<JsonFileConfiguration*>(this)->UpdateHost();
+        GSL_SUPPRESS_TYPE3 const_cast<JsonFileConfiguration*>(this)->ReadHostFile();
 
     return m_sImageHostUrl;
 }
 
-void JsonFileConfiguration::UpdateHost()
+void JsonFileConfiguration::ReadHostFile()
 {
     const auto& pFileSystem = ra::services::ServiceLocator::Get<ra::services::IFileSystem>();
     if (pFileSystem.GetFileSize(L"host.txt") > 0)
@@ -340,8 +346,14 @@ void JsonFileConfiguration::UpdateHost()
             pFile->GetLine(m_sHostName);
     }
 
+    UpdateHost();
+}
+
+void JsonFileConfiguration::UpdateHost()
+{
     if (m_sHostName.empty())
     {
+        m_bCustomHost = false;
         m_sHostName = "retroachievements.org";
         m_sHostUrl = "https://retroachievements.org";
         m_sImageHostUrl = "http://i.retroachievements.org";
diff --git a/src/services/impl/JsonFileConfiguration.hh b/src/services/impl/JsonFileConfiguration.hh
index ff375972..271cb1ed 100644
--- a/src/services/impl/JsonFileConfiguration.hh
+++ b/src/services/impl/JsonFileConfiguration.hh
@@ -38,6 +38,7 @@ public:
     ra::ui::Size GetWindowSize(const std::string& sPositionKey) const override;
     void SetWindowSize(const std::string& sPositionKey, const ra::ui::Size& oSize) override;
 
+    void SetHost(const std::string& sHost);
     bool IsCustomHost() const noexcept override { return m_bCustomHost; }
     const std::string& GetHostName() const override;
     const std::string& GetHostUrl() const override;
@@ -46,6 +47,7 @@ public:
     void Save() const override;
 
 private:
+    void ReadHostFile();
     void UpdateHost();
 
     std::string m_sUsername;
diff --git a/src/services/impl/WindowsHttpRequester.cpp b/src/services/impl/WindowsHttpRequester.cpp
index ba2544ed..25950d71 100644
--- a/src/services/impl/WindowsHttpRequester.cpp
+++ b/src/services/impl/WindowsHttpRequester.cpp
@@ -157,6 +157,7 @@ unsigned int WindowsHttpRequester::Request(const Http::Request& pRequest, TextWr
 
             // open the connection
             auto sPathWide = ra::Widen(sPath);
+            GSL_SUPPRESS_ES47
             HINTERNET hRequest = WinHttpOpenRequest(hConnect,
                 sPostData.empty() ? L"GET" : L"POST",
                 sPathWide.c_str(),
@@ -185,6 +186,7 @@ unsigned int WindowsHttpRequester::Request(const Http::Request& pRequest, TextWr
                     // send the request
                     if (sPostData.empty())
                     {
+                        GSL_SUPPRESS_ES47
                         bResults = WinHttpSendRequest(hRequest,
                             sHeaders.c_str(), gsl::narrow_cast<int>(sHeaders.length()),
                             WINHTTP_NO_REQUEST_DATA,
@@ -195,7 +197,7 @@ unsigned int WindowsHttpRequester::Request(const Http::Request& pRequest, TextWr
                     {
                         bResults = WinHttpSendRequest(hRequest,
                             sHeaders.c_str(), gsl::narrow_cast<int>(sHeaders.length()),
-                            static_cast<LPVOID>(sPostData.data()),
+                            sPostData.data(),
                             gsl::narrow_cast<int>(sPostData.length()), gsl::narrow_cast<int>(sPostData.length()),
                             0);
                     }
@@ -319,7 +321,7 @@ std::string WindowsHttpRequester::GetStatusCodeText(unsigned int nStatusCode) co
         const DWORD nResult = ::FormatMessage(FORMAT_MESSAGE_FROM_HMODULE | FORMAT_MESSAGE_IGNORE_INSERTS,
             ::GetModuleHandle(TEXT("winhttp.dll")),
             nStatusCode, 0, (LPTSTR)szMessageBuffer,
-            sizeof(szMessageBuffer)/sizeof(szMessageBuffer[0]), NULL);
+            sizeof(szMessageBuffer)/sizeof(szMessageBuffer[0]), nullptr);
 
         if (nResult > 0)
         {
diff --git a/src/ui/drawing/gdi/GDISurface.cpp b/src/ui/drawing/gdi/GDISurface.cpp
index 3c45cab1..f0285bb6 100644
--- a/src/ui/drawing/gdi/GDISurface.cpp
+++ b/src/ui/drawing/gdi/GDISurface.cpp
@@ -125,7 +125,7 @@ void GDISurface::DrawSurface(int nX, int nY, const ISurface& pSurface)
     if (pGDISurface != nullptr)
     {
         ::BitBlt(m_hDC, nX, nY,
-            static_cast<int>(pSurface.GetWidth()), static_cast<int>(pSurface.GetHeight()),
+            gsl::narrow_cast<int>(pSurface.GetWidth()), gsl::narrow_cast<int>(pSurface.GetHeight()),
             pGDISurface->m_hDC, 0, 0, SRCCOPY);
     }
 }
diff --git a/src/ui/drawing/gdi/ImageRepository.cpp b/src/ui/drawing/gdi/ImageRepository.cpp
index 7b4f7f10..5051e414 100644
--- a/src/ui/drawing/gdi/ImageRepository.cpp
+++ b/src/ui/drawing/gdi/ImageRepository.cpp
@@ -39,6 +39,7 @@ bool ImageRepository::Initialize()
 
     if (SUCCEEDED(hr) || hr == RPC_E_CHANGED_MODE)
     {
+        GSL_SUPPRESS_TYPE1
         hr = CoCreateInstance(
 
 #if defined (__cplusplus)


Test patch:
diff --git a/tests/data/context/GameContext_Tests.cpp b/tests/data/context/GameContext_Tests.cpp
index 6aab2225..c5230976 100644
--- a/tests/data/context/GameContext_Tests.cpp
+++ b/tests/data/context/GameContext_Tests.cpp
@@ -12,6 +12,7 @@
 
 #include "tests\RA_UnitTestHelpers.h"
 #include "tests\data\DataAsserts.hh"
+#include "tests\ui\UIAsserts.hh"
 
 #include "tests\mocks\MockAchievementRuntime.hh"
 #include "tests\mocks\MockAudioSystem.hh"
@@ -20,6 +21,7 @@
 #include "tests\mocks\MockClock.hh"
 #include "tests\mocks\MockConfiguration.hh"
 #include "tests\mocks\MockDesktop.hh"
+#include "tests\mocks\MockFrameEventQueue.hh"
 #include "tests\mocks\MockLocalStorage.hh"
 #include "tests\mocks\MockOverlayManager.hh"
 #include "tests\mocks\MockServer.hh"
@@ -65,6 +67,7 @@ public:
         ra::services::mocks::MockThreadPool mockThreadPool;
         ra::services::mocks::MockAudioSystem mockAudioSystem;
         ra::services::mocks::MockAchievementRuntime mockAchievementRuntime;
+        ra::services::mocks::MockFrameEventQueue mockFrameEventQueue;
         ra::ui::viewmodels::mocks::MockOverlayManager mockOverlayManager;
         ra::data::context::mocks::MockConsoleContext mockConsoleContext;
         ra::data::context::mocks::MockEmulatorContext mockEmulator;
@@ -1235,6 +1238,180 @@ public:
         Assert::AreEqual(GameContext::Mode::Normal, game.GetMode());
         Assert::IsFalse(notifyHarness.m_bNotified);
     }
+
+
+    TEST_METHOD(TestLoadGameNonHardcoreWarningHardcore)
+    {
+        GameContextHarness game;
+        game.MockLoadGameAPIs(1U, "0123456789abcdeffedcba987654321", "",
+            "{\"ID\":5,\"Title\":\"Ach1\",\"Description\":\"Desc1\",\"Flags\":3,"
+              "\"Points\":5,\"MemAddr\":\"1=1\",\"Author\":\"Auth1\",\"BadgeName\":\"12345\","
+              "\"Created\":1234567890,\"Modified\":123459999},"
+            "{\"ID\":7,\"Title\":\"Ach2\",\"Description\":\"Desc2\",\"Flags\":3,"
+              "\"Points\":10,\"MemAddr\":\"1=1\",\"Author\":\"Auth2\",\"BadgeName\":\"12345\","
+              "\"Created\":1234567890,\"Modified\":123459999}"
+        );
+
+        game.mockConfiguration.SetFeatureEnabled(ra::services::Feature::NonHardcoreWarning, true);
+        game.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
+        game.LoadGame(1U, "0123456789abcdeffedcba987654321");
+
+        // no dialogs when game with achievements loaded in hardcore mode
+        game.mockFrameEventQueue.DoFrame(); // hardcore warning gets queued for the UI thread
+        Assert::IsFalse(game.mockDesktop.WasDialogShown());
+        Assert::IsNull(game.mockOverlayManager.GetMessage(2U)); // message 1 will be game placard
+    }
+
+    TEST_METHOD(TestLoadGameNonHardcoreWarningLeaderboards)
+    {
+        GameContextHarness game;
+        game.MockLoadGameAPIs(1U, "0123456789abcdeffedcba987654321", "",
+            "{\"ID\":5,\"Title\":\"Ach1\",\"Description\":\"Desc1\",\"Flags\":3,"
+              "\"Points\":5,\"MemAddr\":\"1=1\",\"Author\":\"Auth1\",\"BadgeName\":\"12345\","
+              "\"Created\":1234567890,\"Modified\":123459999},"
+            "{\"ID\":7,\"Title\":\"Ach2\",\"Description\":\"Desc2\",\"Flags\":3,"
+              "\"Points\":10,\"MemAddr\":\"1=1\",\"Author\":\"Auth2\",\"BadgeName\":\"12345\","
+              "\"Created\":1234567890,\"Modified\":123459999}"
+        );
+
+        game.mockConfiguration.SetFeatureEnabled(ra::services::Feature::NonHardcoreWarning, false);
+        game.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, false);
+        game.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Leaderboards, true);
+        game.LoadGame(1U, "0123456789abcdeffedcba987654321");
+
+        // should get popup message indicating leaderboards not supported in softcore
+        game.mockFrameEventQueue.DoFrame(); // hardcore warning gets queued for the UI thread
+        Assert::IsFalse(game.mockDesktop.WasDialogShown());
+
+        const auto* pPopup = game.mockOverlayManager.GetMessage(2U);
+        Expects(pPopup != nullptr);
+        Assert::AreEqual(std::wstring(L"Playing in Softcore Mode"), pPopup->GetTitle());
+        Assert::AreEqual(std::wstring(L"Leaderboard entries will not be submitted."), pPopup->GetDescription());
+    }
+
+    TEST_METHOD(TestLoadGameNonHardcoreWarningLeaderboardsOff)
+    {
+        GameContextHarness game;
+        game.MockLoadGameAPIs(1U, "0123456789abcdeffedcba987654321", "",
+            "{\"ID\":5,\"Title\":\"Ach1\",\"Description\":\"Desc1\",\"Flags\":3,"
+              "\"Points\":5,\"MemAddr\":\"1=1\",\"Author\":\"Auth1\",\"BadgeName\":\"12345\","
+              "\"Created\":1234567890,\"Modified\":123459999},"
+            "{\"ID\":7,\"Title\":\"Ach2\",\"Description\":\"Desc2\",\"Flags\":3,"
+              "\"Points\":10,\"MemAddr\":\"1=1\",\"Author\":\"Auth2\",\"BadgeName\":\"12345\","
+              "\"Created\":1234567890,\"Modified\":123459999}"
+        );
+
+        game.mockConfiguration.SetFeatureEnabled(ra::services::Feature::NonHardcoreWarning, false);
+        game.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, false);
+        game.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Leaderboards, false);
+        game.LoadGame(1U, "0123456789abcdeffedcba987654321");
+
+        // should get popup message indicating playing in softcore
+        game.mockFrameEventQueue.DoFrame(); // hardcore warning gets queued for the UI thread
+        Assert::IsFalse(game.mockDesktop.WasDialogShown());
+
+        const auto* pPopup = game.mockOverlayManager.GetMessage(2U);
+        Expects(pPopup != nullptr);
+        Assert::AreEqual(std::wstring(L"Playing in Softcore Mode"), pPopup->GetTitle());
+        Assert::AreEqual(std::wstring(L""), pPopup->GetDescription());
+    }
+
+    TEST_METHOD(TestLoadGameNonHardcoreWarningCancel)
+    {
+        GameContextHarness game;
+        game.MockLoadGameAPIs(1U, "0123456789abcdeffedcba987654321", "",
+            "{\"ID\":5,\"Title\":\"Ach1\",\"Description\":\"Desc1\",\"Flags\":3,"
+              "\"Points\":5,\"MemAddr\":\"1=1\",\"Author\":\"Auth1\",\"BadgeName\":\"12345\","
+              "\"Created\":1234567890,\"Modified\":123459999},"
+            "{\"ID\":7,\"Title\":\"Ach2\",\"Description\":\"Desc2\",\"Flags\":3,"
+              "\"Points\":10,\"MemAddr\":\"1=1\",\"Author\":\"Auth2\",\"BadgeName\":\"12345\","
+              "\"Created\":1234567890,\"Modified\":123459999}"
+        );
+
+        game.mockConfiguration.SetFeatureEnabled(ra::services::Feature::NonHardcoreWarning, true);
+        game.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, false);
+        game.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>(
+            [](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox) {
+                Assert::AreEqual(std::wstring(L"Enable Hardcore mode?"), vmMessageBox.GetHeader());
+                Assert::AreEqual(std::wstring(L"You are loading a game with achievements and do not currently have hardcore mode enabled."), vmMessageBox.GetMessage());
+                Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Icon::Warning, vmMessageBox.GetIcon());
+                Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo, vmMessageBox.GetButtons());
+                return ra::ui::DialogResult::No;
+            });
+
+        game.LoadGame(1U, "0123456789abcdeffedcba987654321");
+
+        // should get confirmation dialog about hardcore and no popups
+        game.mockFrameEventQueue.DoFrame(); // hardcore warning gets queued for the UI thread
+        Assert::IsTrue(game.mockDesktop.WasDialogShown());
+        Assert::IsNull(game.mockOverlayManager.GetMessage(2U));
+
+        // hardcore should remain disabled
+        Assert::IsFalse(game.mockConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore));
+    }
+
+    TEST_METHOD(TestLoadGameNonHardcoreWarningAccept)
+    {
+        GameContextHarness game;
+        game.MockLoadGameAPIs(1U, "0123456789abcdeffedcba987654321", "",
+            "{\"ID\":5,\"Title\":\"Ach1\",\"Description\":\"Desc1\",\"Flags\":3,"
+              "\"Points\":5,\"MemAddr\":\"1=1\",\"Author\":\"Auth1\",\"BadgeName\":\"12345\","
+              "\"Created\":1234567890,\"Modified\":123459999},"
+            "{\"ID\":7,\"Title\":\"Ach2\",\"Description\":\"Desc2\",\"Flags\":3,"
+              "\"Points\":10,\"MemAddr\":\"1=1\",\"Author\":\"Auth2\",\"BadgeName\":\"12345\","
+              "\"Created\":1234567890,\"Modified\":123459999}"
+        );
+
+        game.mockConfiguration.SetFeatureEnabled(ra::services::Feature::NonHardcoreWarning, true);
+        game.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, false);
+        game.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>(
+            [](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox) {
+                Assert::AreEqual(std::wstring(L"Enable Hardcore mode?"), vmMessageBox.GetHeader());
+                Assert::AreEqual(std::wstring(L"You are loading a game with achievements and do not currently have hardcore mode enabled."), vmMessageBox.GetMessage());
+                Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Icon::Warning, vmMessageBox.GetIcon());
+                Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo, vmMessageBox.GetButtons());
+                return ra::ui::DialogResult::Yes;
+            });
+
+        game.LoadGame(1U, "0123456789abcdeffedcba987654321");
+
+        // should get confirmation dialog about hardcore and no popups
+        game.mockFrameEventQueue.DoFrame(); // hardcore warning gets queued for the UI thread
+        Assert::IsTrue(game.mockDesktop.WasDialogShown());
+        Assert::IsNull(game.mockOverlayManager.GetMessage(2U));
+
+        // hardcore should remain disabled
+        Assert::IsTrue(game.mockConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore));
+    }
+
+    TEST_METHOD(TestLoadGameNonHardcoreWarningNoCoreAchievements)
+    {
+        GameContextHarness game;
+        game.MockLoadGameAPIs(1U, "0123456789abcdeffedcba987654321", "",
+                              "{\"ID\":5,\"Title\":\"Ach1\",\"Description\":\"Desc1\",\"Flags\":5,"
+                              "\"Points\":5,\"MemAddr\":\"1=1\",\"Author\":\"Auth1\",\"BadgeName\":\"12345\","
+                              "\"Created\":1234567890,\"Modified\":123459999},"
+                              "{\"ID\":7,\"Title\":\"Ach2\",\"Description\":\"Desc2\",\"Flags\":5,"
+                              "\"Points\":10,\"MemAddr\":\"1=1\",\"Author\":\"Auth2\",\"BadgeName\":\"12345\","
+                              "\"Created\":1234567890,\"Modified\":123459999}");
+
+        game.mockConfiguration.SetFeatureEnabled(ra::services::Feature::NonHardcoreWarning, true);
+        game.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, false);
+
+        game.LoadGame(1U, "0123456789abcdeffedcba987654321");
+
+        // should only get softcore popup
+        game.mockFrameEventQueue.DoFrame(); // hardcore warning gets queued for the UI thread
+        Assert::IsFalse(game.mockDesktop.WasDialogShown());
+
+        const auto* pPopup = game.mockOverlayManager.GetMessage(2U);
+        Expects(pPopup != nullptr);
+        Assert::AreEqual(std::wstring(L"Playing in Softcore Mode"), pPopup->GetTitle());
+        Assert::AreEqual(std::wstring(L""), pPopup->GetDescription());
+
+        // hardcore should remain disabled
+        Assert::IsFalse(game.mockConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore));
+    }
 };
 
 } // namespace tests
diff --git a/tests/mocks/MockAchievementRuntime.cpp b/tests/mocks/MockAchievementRuntime.cpp
index dd5a6a07..fcab935e 100644
--- a/tests/mocks/MockAchievementRuntime.cpp
+++ b/tests/mocks/MockAchievementRuntime.cpp
@@ -35,10 +35,11 @@ void MockAchievementRuntime::MockUser(const std::string& sUsername, const std::s
 {
     m_sUsername = sUsername;
     m_sApiToken = sApiToken;
-    GetClient()->user.username = m_sUsername.c_str();
-    GetClient()->user.display_name = m_sUsername.c_str();
-    GetClient()->user.token = m_sApiToken.c_str();
-    GetClient()->state.user = RC_CLIENT_USER_STATE_LOGGED_IN;
+    rc_client_t* pClient = GetClient();
+    pClient->user.username = m_sUsername.c_str();
+    pClient->user.display_name = m_sUsername.c_str();
+    pClient->user.token = m_sApiToken.c_str();
+    pClient->state.user = RC_CLIENT_USER_STATE_LOGGED_IN;
 }
 
 void MockAchievementRuntime::MockGame()
diff --git a/tests/services/AchievementRuntime_Tests.cpp b/tests/services/AchievementRuntime_Tests.cpp
index f72a1bd8..1767a6c9 100644
--- a/tests/services/AchievementRuntime_Tests.cpp
+++ b/tests/services/AchievementRuntime_Tests.cpp
@@ -24,6 +24,12 @@
 #include <rcheevos\src\rapi\rc_api_common.h>
 #include <rcheevos\src\rc_client_internal.h>
 
+#ifdef RC_CLIENT_EXPORTS_EXTERNAL
+#include <rcheevos\src\rc_client_external.h>
+#include "Exports.hh"
+extern "C" API int CCONV _Rcheevos_GetExternalClient(rc_client_external_t* pClientExternal, int nVersion);
+#endif
+
 using namespace Microsoft::VisualStudio::CppUnitTestFramework;
 
 namespace ra {
@@ -3105,6 +3111,77 @@ public:
         runtime.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, false);
         Assert::AreEqual(std::string("Developing Achievements"), runtime.GetRichPresenceOverride());
     }
+
+#ifdef RC_CLIENT_EXPORTS_EXTERNAL
+    static void AssertV1Exports(rc_client_external_t& pClient)
+    {
+        Assert::IsNotNull((void*)pClient.destroy, L"destory not set");
+
+        Assert::IsNotNull((void*)pClient.enable_logging, L"enable_logging not set");
+        Assert::IsNotNull((void*)pClient.set_event_handler, L"set_event_handler not set");
+        Assert::IsNotNull((void*)pClient.set_read_memory, L"set_read_memory not set");
+        Assert::IsNotNull((void*)pClient.set_get_time_millisecs, L"set_get_time_millisecs not set");
+        Assert::IsNotNull((void*)pClient.set_host, L"set_host not set");
+
+        Assert::IsNotNull((void*)pClient.set_hardcore_enabled, L"set_hardcore_enabled not set");
+        Assert::IsNotNull((void*)pClient.get_hardcore_enabled, L"get_hardcore_enabled not set");
+        Assert::IsNotNull((void*)pClient.set_unofficial_enabled, L"set_unofficial_enabled not set");
+        Assert::IsNotNull((void*)pClient.get_unofficial_enabled, L"get_unofficial_enabled not set");
+        Assert::IsNotNull((void*)pClient.set_encore_mode_enabled, L"set_encore_mode_enabled not set");
+        Assert::IsNotNull((void*)pClient.get_encore_mode_enabled, L"get_encore_mode_enabled not set");
+        Assert::IsNotNull((void*)pClient.set_spectator_mode_enabled, L"set_spectator_mode_enabled not set");
+        Assert::IsNotNull((void*)pClient.get_spectator_mode_enabled, L"get_spectator_mode_enabled not set");
+
+        Assert::IsNotNull((void*)pClient.abort_async, L"abort_async not set");
+
+        Assert::IsNotNull((void*)pClient.begin_login_with_password, L"begin_login_with_password not set");
+        Assert::IsNotNull((void*)pClient.begin_login_with_token, L"begin_login_with_token not set");
+        Assert::IsNotNull((void*)pClient.logout, L"logout not set");
+        Assert::IsNotNull((void*)pClient.get_user_info, L"get_user_info not set");
+
+        Assert::IsNotNull((void*)pClient.begin_identify_and_load_game, L"begin_identify_and_load_game not set");
+        Assert::IsNotNull((void*)pClient.begin_load_game, L"begin_load_game not set");
+        Assert::IsNotNull((void*)pClient.get_game_info, L"get_game_info not set");
+        Assert::IsNull((void*)pClient.begin_load_subset, L"begin_load_subset set");
+        Assert::IsNotNull((void*)pClient.get_subset_info, L"get_subset_info not set");
+        Assert::IsNotNull((void*)pClient.unload_game, L"unload_game not set");
+        Assert::IsNotNull((void*)pClient.get_user_game_summary, L"get_user_game_summary not set");
+        Assert::IsNotNull((void*)pClient.begin_change_media, L"begin_change_media not set");
+
+        Assert::IsNotNull((void*)pClient.create_achievement_list, L"create_achievement_list not set");
+        Assert::IsNotNull((void*)pClient.has_achievements, L"has_achievements not set");
+        Assert::IsNotNull((void*)pClient.get_achievement_info, L"get_achievement_info not set");
+
+        Assert::IsNotNull((void*)pClient.create_leaderboard_list, L"create_leaderboard_list not set");
+        Assert::IsNotNull((void*)pClient.has_leaderboards, L"has_leaderboards not set");
+        Assert::IsNotNull((void*)pClient.get_leaderboard_info, L"get_leaderboard_info not set");
+        Assert::IsNotNull((void*)pClient.begin_fetch_leaderboard_entries, L"begin_fetch_leaderboard_entries not set");
+        Assert::IsNotNull((void*)pClient.begin_fetch_leaderboard_entries_around_user, L"begin_fetch_leaderboard_entries_around_user not set");
+
+        Assert::IsNotNull((void*)pClient.get_rich_presence_message, L"get_rich_presence_message not set");
+        Assert::IsNotNull((void*)pClient.has_rich_presence, L"has_rich_presence not set");
+
+        Assert::IsNotNull((void*)pClient.do_frame, L"do_frame not set");
+        Assert::IsNotNull((void*)pClient.idle, L"idle not set");
+        Assert::IsNotNull((void*)pClient.is_processing_required, L"is_processing_required not set");
+        Assert::IsNotNull((void*)pClient.can_pause, L"can_pause not set");
+        Assert::IsNotNull((void*)pClient.reset, L"reset not set");
+
+        Assert::IsNotNull((void*)pClient.progress_size, L"progress_size not set");
+        Assert::IsNotNull((void*)pClient.serialize_progress, L"serialize_progress not set");
+        Assert::IsNotNull((void*)pClient.deserialize_progress, L"deserialize_progress not set");
+    }
+
+    TEST_METHOD(TestGetExternalClientV1)
+    {
+        rc_client_external_t pClient;
+        memset(&pClient, 0, sizeof(pClient));
+
+        _Rcheevos_GetExternalClient(&pClient, 1);
+
+        AssertV1Exports(pClient);
+    }
+#endif
 };
 
 } // namespace tests
diff --git a/tests/services/GameIdentifier_Tests.cpp b/tests/services/GameIdentifier_Tests.cpp
index cf2d4b85..7641c262 100644
--- a/tests/services/GameIdentifier_Tests.cpp
+++ b/tests/services/GameIdentifier_Tests.cpp
@@ -369,165 +369,6 @@ public:
         Assert::IsFalse(identifier.mockGameContext.WasLoaded());
     }
 
-    TEST_METHOD(TestActivateGamePendingHardcore)
-    {
-        GameIdentifierHarness identifier;
-        identifier.MockResolveHashResponse(23U);
-        identifier.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
-        identifier.mockUserContext.Initialize("User", "ApiToken");
-
-        Assert::AreEqual(23U, identifier.IdentifyGame(&ROM.at(0), ROM.size()));
-        identifier.ActivateGame(23U);
-
-        Assert::AreEqual(23U, identifier.mockGameContext.GameId());
-        Assert::AreEqual(ROM_HASH, identifier.mockGameContext.GameHash());
-        Assert::AreEqual(ra::data::context::GameContext::Mode::Normal, identifier.mockGameContext.GetMode());
-        Assert::AreEqual(23U, identifier.mockSessionTracker.CurrentSessionGameId());
-        Assert::IsNull(identifier.mockOverlayManager.GetMessage(1U));
-        Assert::IsTrue(identifier.mockGameContext.WasLoaded());
-    }
-
-    TEST_METHOD(TestActivateGamePendingCompatibilityTest)
-    {
-        GameIdentifierHarness identifier;
-        identifier.MockCompatibilityTest(23U);
-        identifier.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
-        identifier.mockUserContext.Initialize("User", "ApiToken");
-
-        Assert::AreEqual(23U, identifier.IdentifyGame(&ROM.at(0), ROM.size()));
-        identifier.ActivateGame(23U);
-
-        Assert::AreEqual(23U, identifier.mockGameContext.GameId());
-        Assert::AreEqual(ROM_HASH, identifier.mockGameContext.GameHash());
-        Assert::AreEqual(ra::data::context::GameContext::Mode::CompatibilityTest, identifier.mockGameContext.GetMode());
-        Assert::AreEqual(23U, identifier.mockSessionTracker.CurrentSessionGameId());
-        Assert::IsNull(identifier.mockOverlayManager.GetMessage(1U));
-        Assert::IsTrue(identifier.mockGameContext.WasLoaded());
-    }
-
-    TEST_METHOD(TestActivateGamePendingNonHardcoreLeaderboards)
-    {
-        GameIdentifierHarness identifier;
-        identifier.MockResolveHashResponse(23U);
-        identifier.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, false);
-        identifier.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Leaderboards, true);
-        identifier.mockUserContext.Initialize("User", "ApiToken");
-
-        Assert::AreEqual(23U, identifier.IdentifyGame(&ROM.at(0), ROM.size()));
-        identifier.ActivateGame(23U);
-
-        Assert::AreEqual(23U, identifier.mockGameContext.GameId());
-        Assert::AreEqual(ROM_HASH, identifier.mockGameContext.GameHash());
-        Assert::AreEqual(23U, identifier.mockSessionTracker.CurrentSessionGameId());
-        const auto* pPopup = identifier.mockOverlayManager.GetMessage(1U);
-        Expects(pPopup != nullptr);
-        Assert::AreEqual(std::wstring(L"Playing in Softcore Mode"), pPopup->GetTitle());
-        Assert::AreEqual(std::wstring(L"Leaderboard entries will not be submitted."), pPopup->GetDescription());
-    }
-
-    TEST_METHOD(TestActivateGamePendingNonHardcoreNoLeaderboards)
-    {
-        GameIdentifierHarness identifier;
-        identifier.MockResolveHashResponse(23U);
-        identifier.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, false);
-        identifier.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Leaderboards, false);
-        identifier.mockUserContext.Initialize("User", "ApiToken");
-
-        Assert::AreEqual(23U, identifier.IdentifyGame(&ROM.at(0), ROM.size()));
-        identifier.ActivateGame(23U);
-
-        Assert::AreEqual(23U, identifier.mockGameContext.GameId());
-        Assert::AreEqual(ROM_HASH, identifier.mockGameContext.GameHash());
-        Assert::AreEqual(23U, identifier.mockSessionTracker.CurrentSessionGameId());
-        const auto* pPopup = identifier.mockOverlayManager.GetMessage(1U);
-        Expects(pPopup != nullptr);
-        Assert::AreEqual(std::wstring(L"Playing in Softcore Mode"), pPopup->GetTitle());
-        Assert::AreEqual(std::wstring(L""), pPopup->GetDescription());
-    }
-
-    TEST_METHOD(TestActivateGamePendingNonHardcoreWarningNoCoreAchievements)
-    {
-        GameIdentifierHarness identifier;
-        identifier.MockResolveHashResponse(23U);
-        identifier.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, false);
-        identifier.mockConfiguration.SetFeatureEnabled(ra::services::Feature::NonHardcoreWarning, true);
-        identifier.mockUserContext.Initialize("User", "ApiToken");
-
-        Assert::AreEqual(23U, identifier.IdentifyGame(&ROM.at(0), ROM.size()));
-        identifier.ActivateGame(23U);
-
-        Assert::AreEqual(23U, identifier.mockGameContext.GameId());
-        Assert::AreEqual(ROM_HASH, identifier.mockGameContext.GameHash());
-        Assert::AreEqual(23U, identifier.mockSessionTracker.CurrentSessionGameId());
-        const auto* pPopup = identifier.mockOverlayManager.GetMessage(1U);
-        Expects(pPopup != nullptr);
-        Assert::AreEqual(std::wstring(L"Playing in Softcore Mode"), pPopup->GetTitle());
-        Assert::AreEqual(std::wstring(L""), pPopup->GetDescription());
-
-        Assert::IsFalse(identifier.mockDesktop.WasDialogShown());
-        Assert::IsFalse(identifier.mockConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore));
-    }
-
-    TEST_METHOD(TestActivateGamePendingNonHardcoreWarningDecline)
-    {
-        GameIdentifierHarness identifier;
-        identifier.MockResolveHashResponse(23U);
-        identifier.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, false);
-        identifier.mockConfiguration.SetFeatureEnabled(ra::services::Feature::NonHardcoreWarning, true);
-        identifier.mockUserContext.Initialize("User", "ApiToken");
-        identifier.mockGameContext.Assets().NewAchievement().SetCategory(ra::data::models::AssetCategory::Core);
-        identifier.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([](ra::ui::viewmodels::MessageBoxViewModel & vmMessageBox)
-        {
-            Assert::AreEqual(std::wstring(L"Enable Hardcore mode?"), vmMessageBox.GetHeader());
-            Assert::AreEqual(std::wstring(L"You are loading a game with achievements and do not currently have hardcore mode enabled."), vmMessageBox.GetMessage());
-            Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Icon::Warning, vmMessageBox.GetIcon());
-            Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo, vmMessageBox.GetButtons());
-            return ra::ui::DialogResult::No;
-        });
-
-        Assert::AreEqual(23U, identifier.IdentifyGame(&ROM.at(0), ROM.size()));
-        identifier.ActivateGame(23U);
-
-        Assert::AreEqual(23U, identifier.mockGameContext.GameId());
-        Assert::AreEqual(ROM_HASH, identifier.mockGameContext.GameHash());
-        Assert::AreEqual(23U, identifier.mockSessionTracker.CurrentSessionGameId());
-        const auto* pPopup = identifier.mockOverlayManager.GetMessage(1U);
-        Assert::IsNull(pPopup);
-
-        Assert::IsTrue(identifier.mockDesktop.WasDialogShown());
-        Assert::IsFalse(identifier.mockConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore));
-    }
-
-    TEST_METHOD(TestActivateGamePendingNonHardcoreWarningAccept)
-    {
-        GameIdentifierHarness identifier;
-        identifier.MockResolveHashResponse(23U);
-        identifier.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, false);
-        identifier.mockConfiguration.SetFeatureEnabled(ra::services::Feature::NonHardcoreWarning, true);
-        identifier.mockUserContext.Initialize("User", "ApiToken");
-        identifier.mockGameContext.Assets().NewAchievement().SetCategory(ra::data::models::AssetCategory::Core);
-        identifier.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([](ra::ui::viewmodels::MessageBoxViewModel & vmMessageBox)
-        {
-            Assert::AreEqual(std::wstring(L"Enable Hardcore mode?"), vmMessageBox.GetHeader());
-            Assert::AreEqual(std::wstring(L"You are loading a game with achievements and do not currently have hardcore mode enabled."), vmMessageBox.GetMessage());
-            Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Icon::Warning, vmMessageBox.GetIcon());
-            Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo, vmMessageBox.GetButtons());
-            return ra::ui::DialogResult::Yes;
-        });
-
-        Assert::AreEqual(23U, identifier.IdentifyGame(&ROM.at(0), ROM.size()));
-        identifier.ActivateGame(23U);
-
-        Assert::AreEqual(23U, identifier.mockGameContext.GameId());
-        Assert::AreEqual(ROM_HASH, identifier.mockGameContext.GameHash());
-        Assert::AreEqual(23U, identifier.mockSessionTracker.CurrentSessionGameId());
-        const auto* pPopup = identifier.mockOverlayManager.GetMessage(1U);
-        Assert::IsNull(pPopup);
-
-        Assert::IsTrue(identifier.mockDesktop.WasDialogShown());
-        Assert::IsTrue(identifier.mockConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore));
-    }
-
     TEST_METHOD(TestActivateGameResetCompatibility)
     {
         GameIdentifierHarness identifier;
@@ -800,28 +641,6 @@ public:
         Assert::AreEqual(32U, identifier.IdentifyGame(&ROM.at(0), ROM.size()));
         Assert::IsFalse(identifier.mockDesktop.WasDialogShown());
     }
-
-    TEST_METHOD(TestActivateGamePendingOffline)
-    {
-        GameIdentifierHarness identifier;
-        identifier.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
-        identifier.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Offline, true);
-
-        const auto sFileContents = ra::StringPrintf("%s=%u\n", ROM_HASH, 32U);
-        identifier.mockLocalStorage.MockStoredData(
-            ra::services::StorageItemType::HashMapping, KNOWN_HASHES_KEY, sFileContents);
-
-        Assert::AreEqual(32U, identifier.IdentifyGame(&ROM.at(0), ROM.size()));
-        identifier.ActivateGame(32U);
-
-        Assert::AreEqual(32U, identifier.mockGameContext.GameId());
-        Assert::AreEqual(ROM_HASH, identifier.mockGameContext.GameHash());
-        Assert::AreEqual(ra::data::context::GameContext::Mode::Normal, identifier.mockGameContext.GetMode());
-        Assert::AreEqual(32U, identifier.mockSessionTracker.CurrentSessionGameId());
-        Assert::IsNull(identifier.mockOverlayManager.GetMessage(1U));
-        Assert::IsTrue(identifier.mockGameContext.WasLoaded());
-    }
-
 };
 
 } // namespace tests
diff --git a/tests/services/JsonFileConfiguration_Tests.cpp b/tests/services/JsonFileConfiguration_Tests.cpp
index 3e0440b6..9c09cb3e 100644
--- a/tests/services/JsonFileConfiguration_Tests.cpp
+++ b/tests/services/JsonFileConfiguration_Tests.cpp
@@ -279,6 +279,17 @@ public:
         Assert::AreEqual(std::string("https://stage.retroachievements.org"), config.GetHostUrl());
         Assert::AreEqual(std::string("https://stage.retroachievements.org"), config.GetImageHostUrl());
     }
+
+    TEST_METHOD(TestHostNameFromRcClient)
+    {
+        MockFileSystem mockFileSystem;
+        mockFileSystem.MockFile(L"host.txt", "stage.retroachievements.org");
+        JsonFileConfiguration config;
+        config.SetHost("localhost");
+        Assert::AreEqual(std::string("localhost"), config.GetHostName());
+        Assert::AreEqual(std::string("http://localhost"), config.GetHostUrl());
+        Assert::AreEqual(std::string("http://localhost"), config.GetImageHostUrl());
+    }
 };
 
 } // namespace tests