Repository: RetroAchievements/RAIntegration
Pull request #716 (2021-04-30 17:15:30)

hide challenge indicator when achievement is manually deactivated


Patch:
diff --git a/rcheevos b/rcheevos
index 74631ea6..d26ccb97 160000
--- a/rcheevos
+++ b/rcheevos
@@ -1,1 +1,1 @@
-Subproject commit 74631ea6779cec15c0371ef4ff17331c025d1a0f
+Subproject commit d26ccb977ba1e3e8ebd91f7ad3cb19e37e489c42
diff --git a/src/Exports.cpp b/src/Exports.cpp
index 1b5f6758..38086531 100644
--- a/src/Exports.cpp
+++ b/src/Exports.cpp
@@ -55,14 +55,14 @@ API int CCONV _RA_HardcoreModeIsActive()
     return pConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore);
 }
 
-API bool CCONV _RA_WarnDisableHardcore(const char* sActivity)
+API int CCONV _RA_WarnDisableHardcore(const char* sActivity)
 {
     std::string sActivityString;
     if (sActivity)
         sActivityString = sActivity;
 
     auto& pEmulatorContext = ra::services::ServiceLocator::GetMutable<ra::data::context::EmulatorContext>();
-    return pEmulatorContext.WarnDisableHardcoreMode(sActivityString);
+    return pEmulatorContext.WarnDisableHardcoreMode(sActivityString) ? 1 : 0;
 }
 
 #ifndef RA_UTEST
@@ -222,7 +222,7 @@ static void HandleLoginResponse(const ra::api::Login::Response& response)
     }
 }
 
-API void CCONV _RA_AttemptLogin(bool bBlocking)
+API void CCONV _RA_AttemptLogin(int bBlocking)
 {
     auto& pUserContext = ra::services::ServiceLocator::GetMutable<ra::data::context::UserContext>();
     if (pUserContext.IsLoginDisabled())
@@ -301,7 +301,9 @@ API unsigned int CCONV _RA_IdentifyHash(const char* sHash)
 
 API void CCONV _RA_ActivateGame(unsigned int nGameId)
 {
+    _RA_SuspendRepaint();
     ra::services::ServiceLocator::GetMutable<ra::services::GameIdentifier>().ActivateGame(nGameId);
+    _RA_ResumeRepaint();
 }
 
 API int CCONV _RA_OnLoadNewRom(const BYTE* pROM, unsigned int nROMSize)
@@ -317,9 +319,9 @@ API void CCONV _RA_UpdateAppTitle(const char* sMessage)
     vmEmulator.SetWindowTitle(sTitle);
 }
 
-API bool _RA_IsOverlayFullyVisible()
+API int _RA_IsOverlayFullyVisible()
 {
-    return ra::services::ServiceLocator::Get<ra::ui::viewmodels::OverlayManager>().IsOverlayFullyVisible();
+    return ra::services::ServiceLocator::Get<ra::ui::viewmodels::OverlayManager>().IsOverlayFullyVisible() ? 1 : 0;
 }
 
 _Use_decl_annotations_
@@ -492,7 +494,7 @@ static void ProcessAchievements()
     }
 }
 
-API void CCONV _RA_SetPaused(bool bIsPaused)
+API void CCONV _RA_SetPaused(int bIsPaused)
 {
     auto& pOverlayManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>();
     if (bIsPaused)
@@ -545,10 +547,10 @@ API void CCONV _RA_DoAchievementsFrame()
     CHECK_PERFORMANCE();
 }
 
-API void CCONV _RA_SetForceRepaint([[maybe_unused]] bool bEnable)
+API void CCONV _RA_SetForceRepaint([[maybe_unused]] int bEnable)
 {
 #ifndef RA_UTEST
-    ra::ui::win32::bindings::ControlBinding::SetNeedsUpdateWindow(bEnable);
+    ra::ui::win32::bindings::ControlBinding::SetNeedsUpdateWindow(bEnable != 0);
 #endif
 }
 
diff --git a/src/Exports.hh b/src/Exports.hh
index 39d02936..1c03caad 100644
--- a/src/Exports.hh
+++ b/src/Exports.hh
@@ -46,7 +46,7 @@ extern "C" {
     API HMENU CCONV _RA_CreatePopupMenu();
 
     // Check all achievement sets for changes, and displays a dlg box to warn lost changes.
-    API bool CCONV _RA_ConfirmLoadNewRom(bool bQuittingApp);
+    API int CCONV _RA_ConfirmLoadNewRom(int bQuittingApp);
 
     //  Gets the unique identifier of the game associated to the provided ROM data
     API unsigned int CCONV _RA_IdentifyRom(const BYTE* pROMData, unsigned int nROMSize);
@@ -90,7 +90,7 @@ extern "C" {
 
     // Enables forced repainting for controls when InvalidateWindow doesn't cause them to update
     // properly. Seems to primarily be an issue when integrating with an application using SDL.
-    API void CCONV _RA_SetForceRepaint(bool bEnable);
+    API void CCONV _RA_SetForceRepaint(int bEnable);
 
     // Temporarily disable forced redraws
     API void CCONV _RA_SuspendRepaint();
@@ -108,10 +108,10 @@ extern "C" {
     API void CCONV _RA_InvokeDialog(LPARAM nID);
 
     // Call this when the pause state changes, to update RA with the new state.
-    API void CCONV _RA_SetPaused(bool bIsPaused);
+    API void CCONV _RA_SetPaused(int bIsPaused);
 
     // Attempt to login, or present login dialog.
-    API void CCONV _RA_AttemptLogin(bool bBlocking);
+    API void CCONV _RA_AttemptLogin(int bBlocking);
 
     // Gets the user name of the currently logged in user - empty string is no user is currently logged in.
     API const char* CCONV _RA_UserName();
@@ -122,7 +122,7 @@ extern "C" {
     // Should be called before performing an activity that is not allowed in hardcore mode to
     // give the user a chance to disable hardcore mode and continue with the activity.
     // Returns TRUE if hardcore was disabled, or FALSE to cancel the activity.
-    API bool CCONV _RA_WarnDisableHardcore(const char* sActivity);
+    API int CCONV _RA_WarnDisableHardcore(const char* sActivity);
 
     // Install user-side functions that can be called from the DLL
     API void CCONV _RA_InstallSharedFunctions(bool(*)(void), void(*fpCauseUnpause)(void), void(*fpRebuildMenu)(void), void(*fpEstimateTitle)(char*), void(*fpResetEmulation)(void), void(*fpLoadROM)(const char*));
@@ -130,10 +130,7 @@ extern "C" {
 
     struct ControllerInput;
     API void CCONV _RA_NavigateOverlay(_In_ const ControllerInput* pInput);
-    API int CCONV _RA_UpdateOverlay(_In_ const ControllerInput* pInput, _In_ float /*fDTime*/,
-        _In_ bool /*Full_Screen*/, _In_ bool /*Paused*/);
-    API void CCONV _RA_RenderOverlay(_In_ HDC hDC, _In_ const RECT* rcSize);
-    API bool CCONV _RA_IsOverlayFullyVisible();
+    API int CCONV _RA_IsOverlayFullyVisible();
 
 #ifdef __cplusplus
 }
diff --git a/src/RA_Core.cpp b/src/RA_Core.cpp
index 56d607cb..38bb2abe 100644
--- a/src/RA_Core.cpp
+++ b/src/RA_Core.cpp
@@ -170,7 +170,7 @@ API int CCONV _RA_Shutdown()
     return 0;
 }
 
-API bool CCONV _RA_ConfirmLoadNewRom(bool bQuittingApp)
+API int CCONV _RA_ConfirmLoadNewRom(int bQuittingApp)
 {
     //	Returns true if we can go ahead and load the new rom.
     std::wstring sModifiedSet;
diff --git a/src/RA_Leaderboard.cpp b/src/RA_Leaderboard.cpp
index 278cb445..cafd399a 100644
--- a/src/RA_Leaderboard.cpp
+++ b/src/RA_Leaderboard.cpp
@@ -22,10 +22,10 @@ RA_Leaderboard::~RA_Leaderboard() noexcept
     }
 }
 
-void RA_Leaderboard::ParseFromString(const char* sBuffer, const char* sFormat)
+void RA_Leaderboard::ParseFromString(const char* sBuffer, int nFormat)
 {
     m_sDefinition = sBuffer;
-    m_nFormat = rc_parse_format(sFormat);
+    m_nFormat = nFormat;
 }
 
 void RA_Leaderboard::SetActive(bool bActive) noexcept
diff --git a/src/RA_Leaderboard.h b/src/RA_Leaderboard.h
index f2916c0a..5fd25984 100644
--- a/src/RA_Leaderboard.h
+++ b/src/RA_Leaderboard.h
@@ -15,7 +15,7 @@ public:
     RA_Leaderboard(RA_Leaderboard&&) noexcept = default;
     RA_Leaderboard& operator=(RA_Leaderboard&&) noexcept = default;
 
-    void ParseFromString(const char* sBuffer, const char* sFormat);
+    void ParseFromString(const char* sBuffer, int nFormat);
 
     ra::LeaderboardID ID() const noexcept { return m_nID; }
 
diff --git a/src/api/FetchGameData.hh b/src/api/FetchGameData.hh
index 65b71fad..2417aa24 100644
--- a/src/api/FetchGameData.hh
+++ b/src/api/FetchGameData.hh
@@ -16,8 +16,6 @@ public:
     {
         std::wstring Title;
         unsigned int ConsoleId{};
-        unsigned int ForumTopicId{};
-        unsigned int Flags{};
         std::string ImageIcon;
         std::string RichPresence;
 
@@ -42,7 +40,7 @@ public:
             std::string Title;
             std::string Description;
             std::string Definition;
-            std::string Format;
+            int Format{ 0 };
         };
         std::vector<Leaderboard> Leaderboards;
     };
diff --git a/src/api/impl/ConnectedServer.cpp b/src/api/impl/ConnectedServer.cpp
index 307c8ac7..4d5de45a 100644
--- a/src/api/impl/ConnectedServer.cpp
+++ b/src/api/impl/ConnectedServer.cpp
@@ -17,21 +17,25 @@
 
 #include <rapidjson\document.h>
 
+#include <rcheevos\src\rapi\rc_api_common.h> // for parsing cached patchdata response
+#include <rc_api_runtime.h>
+#include <rc_api_user.h>
+
 namespace ra {
 namespace api {
 namespace impl {
 
-_NODISCARD static bool HandleHttpError(_In_ const ra::services::Http::Response& httpResponse,
+_NODISCARD static bool HandleHttpError(_In_ const ra::services::Http::StatusCode nStatusCode,
                                        _Inout_ ApiResponseBase& pResponse)
 {
-    if (httpResponse.StatusCode() != ra::services::Http::StatusCode::OK)
+    if (nStatusCode != ra::services::Http::StatusCode::OK)
     {
         const auto& pHttpRequester = ra::services::ServiceLocator::Get<ra::services::IHttpRequester>();
-        const bool bRetry = pHttpRequester.IsRetryable(ra::etoi(httpResponse.StatusCode()));
+        const bool bRetry = pHttpRequester.IsRetryable(ra::etoi(nStatusCode));
         pResponse.Result = bRetry ? ApiResult::Incomplete : ApiResult::Error;
-        pResponse.ErrorMessage = ra::BuildString("HTTP error code: ", ra::etoi(httpResponse.StatusCode()));
+        pResponse.ErrorMessage = ra::BuildString("HTTP error code: ", ra::etoi(nStatusCode));
 
-        const auto& pStatusCodeText = pHttpRequester.GetStatusCodeText(ra::etoi(httpResponse.StatusCode()));
+        const auto& pStatusCodeText = pHttpRequester.GetStatusCodeText(ra::etoi(nStatusCode));
         if (!pStatusCodeText.empty())
             pResponse.ErrorMessage = ra::StringPrintf("%s (%s)", pResponse.ErrorMessage, pStatusCodeText);
 
@@ -49,7 +53,7 @@ _NODISCARD static bool GetJson([[maybe_unused]] _In_ const char* sApiName,
     {
         pDocument.SetArray();
 
-        if (!HandleHttpError(httpResponse, pResponse))
+        if (!HandleHttpError(httpResponse.StatusCode(), pResponse))
         {
             pResponse.ErrorMessage = "Empty JSON response";
             pResponse.Result = ApiResult::Failed;
@@ -64,7 +68,7 @@ _NODISCARD static bool GetJson([[maybe_unused]] _In_ const char* sApiName,
     pDocument.Parse(httpResponse.Content());
     if (pDocument.HasParseError())
     {
-        if (HandleHttpError(httpResponse, pResponse))
+        if (HandleHttpError(httpResponse.StatusCode(), pResponse))
         {
             RA_LOG_ERR("-- %s: %s", sApiName, pResponse.ErrorMessage.c_str());
             return false;
@@ -120,7 +124,7 @@ _NODISCARD static bool GetJson([[maybe_unused]] _In_ const char* sApiName,
         return false;
     }
 
-    if (HandleHttpError(httpResponse, pResponse))
+    if (HandleHttpError(httpResponse.StatusCode(), pResponse))
     {
         RA_LOG_ERR("-- %s: %s", sApiName, pResponse.ErrorMessage.c_str());
         return false;
@@ -209,44 +213,6 @@ static void GetRequiredJsonField(_Out_ unsigned int& nValue, _In_ const rapidjso
     }
 }
 
-static void GetRequiredJsonField(_Out_ int& nValue, _In_ const rapidjson::Value& pDocument,
-    _In_ const char* const sField, _Inout_ ApiResponseBase& response)
-{
-    if (!pDocument.HasMember(sField))
-    {
-        nValue = 0;
-
-        response.Result = ApiResult::Error;
-        if (response.ErrorMessage.empty())
-            response.ErrorMessage = ra::StringPrintf("%s not found in response", sField);
-    }
-    else
-    {
-        auto& pField = pDocument[sField];
-        if (pField.IsInt())
-            nValue = pField.GetInt();
-        else
-            nValue = 0;
-    }
-}
-
-static void GetOptionalJsonField(_Out_ unsigned int& nValue, _In_ const rapidjson::Value& pDocument,
-                                 _In_ const char* sField, _In_ unsigned int nDefaultValue = 0)
-{
-    if (pDocument.HasMember(sField))
-    {
-        auto& pField = pDocument[sField];
-        if (pField.IsNumber())
-            nValue = pDocument[sField].GetUint();
-        else
-            nValue = nDefaultValue;
-    }
-    else
-    {
-        nValue = nDefaultValue;
-    }
-}
-
 static void AppendUrlParam(_Inout_ std::string& sParams, _In_ const char* const sParam, _In_ const std::string& sValue)
 {
     if (!sParams.empty() && sParams.back() != '?')
@@ -282,6 +248,131 @@ static bool DoRequest(const std::string& sHost, const char* restrict sApiName, c
     return GetJson(sApiName, httpResponse, pResponse, document);
 }
 
+static bool DoRequestWithoutLog(const rc_api_request_t& api_request, _UNUSED const char* sApiName, ra::services::Http::Response& pHttpResponse, ApiResponseBase& pResponse)
+{
+    ra::services::Http::Request httpRequest(api_request.url);
+    httpRequest.SetPostData(api_request.post_data);
+    pHttpResponse = httpRequest.Call();
+
+    if (pHttpResponse.Content().empty())
+    {
+        if (!HandleHttpError(pHttpResponse.StatusCode(), pResponse))
+        {
+            pResponse.ErrorMessage = "Empty JSON response";
+            pResponse.Result = ApiResult::Failed;
+        }
+
+        RA_LOG_ERR("-- %s: %s", sApiName, pResponse.ErrorMessage);
+        return false;
+    }
+
+    RA_LOG_INFO("-- %s Response: %s", sApiName, pHttpResponse.Content());
+
+    switch (pHttpResponse.Content().at(0))
+    {
+        case '{': // JSON, expected
+        case '<': // HTML, not expected
+            break;
+
+        default:
+            // not HTML or JSON, return the first line of the response as the error message
+            const auto nIndex = pHttpResponse.Content().find('\n');
+            std::string sContent = (nIndex != std::string::npos) ? pHttpResponse.Content().substr(0, nIndex) : pHttpResponse.Content();
+            ra::TrimLineEnding(sContent);
+
+            if (!sContent.empty())
+            {
+                pResponse.ErrorMessage = sContent;
+                pResponse.Result = ApiResult::Error;
+            }
+            return false;
+    }
+
+    return true;
+}
+
+static bool DoRequest(const rc_api_request_t& api_request, const char* sApiName, ra::services::Http::Response& pHttpResponse, ApiResponseBase& pResponse)
+{
+#ifndef RA_UTEST
+    const auto& pLogger = ra::services::ServiceLocator::Get<ra::services::ILogger>();
+    if (pLogger.IsEnabled(ra::services::LogLevel::Info))
+    {
+        // capture the GET parameters
+        const char* ptr = api_request.url;
+        Expects(ptr != nullptr);
+        while (*ptr && *ptr != '?')
+            ++ptr;
+
+        std::string sParams;
+        if (*ptr == '?')
+            sParams = ++ptr;
+
+        // capture the POST parameters
+        ptr = api_request.post_data;
+        if (ptr)
+        {
+            // don't log the api token
+            while (*ptr && (ptr[0] != 't' || ptr[1] != '='))
+                ++ptr;
+
+            if (ptr > api_request.post_data)
+            {
+                if (!sParams.empty())
+                    sParams.push_back('&');
+
+                sParams.append(api_request.post_data, ptr - api_request.post_data - 1);
+            }
+
+            while (*ptr && ptr[0] != '&')
+                ++ptr;
+
+            if (*ptr)
+                sParams.append(ptr);
+        }
+
+        pLogger.LogMessage(ra::services::LogLevel::Info, ra::StringPrintf("%s Request: %s", sApiName, sParams));
+    }
+#endif
+    return DoRequestWithoutLog(api_request, sApiName, pHttpResponse, pResponse);
+}
+
+static bool ValidateResponse(int nResult, const rc_api_response_t& api_response, _UNUSED const char* sApiName, ra::services::Http::StatusCode nStatusCode, ApiResponseBase& pResponse)
+{
+    if (nResult != RC_OK)
+    {
+        /* parse error */
+        pResponse.ErrorMessage = ra::StringPrintf("JSON Parse Error: %s", rc_error_str(nResult));
+        pResponse.Result = ApiResult::Error;
+        RA_LOG_ERR("-- %s %s", sApiName, pResponse.ErrorMessage);
+        return false;
+    }
+
+    if (!api_response.succeeded)
+    {
+        if (api_response.error_message && *api_response.error_message)
+        {
+            pResponse.ErrorMessage = api_response.error_message;
+            pResponse.Result = ApiResult::Error;
+            RA_LOG_ERR("-- %s Error: %s", sApiName, pResponse.ErrorMessage);
+        }
+        else
+        {
+            pResponse.Result = ApiResult::Failed;
+            RA_LOG_ERR("-- %s Error: Success=false", sApiName, pResponse.ErrorMessage);
+        }
+
+        return false;
+    }
+
+    if (HandleHttpError(nStatusCode, pResponse))
+    {
+        RA_LOG_ERR("-- %s: %s", sApiName, pResponse.ErrorMessage.c_str());
+        return false;
+    }
+
+    return true;
+}
+
 static bool DoUpload(const std::string& sHost, const char* restrict sApiName, const char* restrict sRequestName,
     const std::wstring& sFilePath, ApiResponseBase& pResponse, rapidjson::Document& document)
 {
@@ -362,36 +453,44 @@ static bool DoUpload(const std::string& sHost, const char* restrict sApiName, co
 
 Login::Response ConnectedServer::Login(const Login::Request& request)
 {
-    ra::services::Http::Request httpRequest(ra::StringPrintf("%s/login_app.php", m_sHost));
+    Login::Response response;
 
     std::string sPostData;
-    AppendUrlParam(sPostData, "u", request.Username);
+    rc_api_login_request_t api_params;
+    memset(&api_params, 0, sizeof(api_params));
+
+    api_params.username = request.Username.c_str();
     if (!request.ApiToken.empty())
-    {
-        AppendUrlParam(sPostData, "t", request.ApiToken);
-        RA_LOG_INFO("%s Request: %s", Login::Name(), sPostData.c_str());
-    }
+        api_params.api_token = request.ApiToken.c_str();
     else
+        api_params.password = request.Password.c_str();
+
+    rc_api_request_t api_request;
+    if (rc_api_init_login_request(&api_request, &api_params) == RC_OK)
     {
-        // intentionally do not log password
-        RA_LOG_INFO("%s Request: %s", Login::Name(), sPostData.c_str());
-        AppendUrlParam(sPostData, "p", request.Password);
-    }
-    httpRequest.SetPostData(sPostData);
+        RA_LOG_INFO("Login Request: (using %s)", !request.ApiToken.empty() ? "token" : "password");
 
-    ra::services::Http::Response httpResponse = httpRequest.Call();
+        ra::services::Http::Response httpResponse;
+        if (DoRequestWithoutLog(api_request, Login::Name(), httpResponse, response))
+        {
+            rc_api_login_response_t api_response;
+            const auto nResult = rc_api_process_login_response(&api_response, httpResponse.Content().c_str());
 
-    Login::Response response;
-    rapidjson::Document document;
-    if (GetJson(Login::Name(), httpResponse, response, document))
-    {
-        response.Result = ApiResult::Success;
-        GetRequiredJsonField(response.Username, document, "User", response);
-        GetRequiredJsonField(response.ApiToken, document, "Token", response);
-        GetOptionalJsonField(response.Score, document, "Score");
-        GetOptionalJsonField(response.NumUnreadMessages, document, "Messages");
+            if (ValidateResponse(nResult, api_response.response, Login::Name(), httpResponse.StatusCode(), response))
+            {
+                response.Result = ApiResult::Success;
+                response.Username = api_response.username;
+                response.ApiToken = api_response.api_token;
+                response.Score = api_response.score;
+                response.NumUnreadMessages = api_response.num_unread_messages;
+            }
+
+            rc_api_destroy_login_response(&api_response);
+        }
     }
 
+    rc_api_destroy_request(&api_request);
+
     return std::move(response);
 }
 
@@ -408,21 +507,33 @@ Logout::Response ConnectedServer::Logout(const Logout::Request&)
 StartSession::Response ConnectedServer::StartSession(const StartSession::Request& request)
 {
     StartSession::Response response;
-    rapidjson::Document document;
+
     std::string sPostData;
+    rc_api_start_session_request_t api_params;
+    memset(&api_params, 0, sizeof(api_params));
 
-    // activity type enum (only 3 is used )
-    // 1 = earned achievement - handled by awardachievement
-    // 2 = logged in - handled by login
-    // 3 = started playing
-    // 4 = uploaded achievement - handled by uploadachievement
-    // 5 = modified achievmeent - handled by uploadachievement
-    AppendUrlParam(sPostData, "a", "3");
+    const auto& pUserContext = ra::services::ServiceLocator::Get<ra::data::context::UserContext>();
+    api_params.username = pUserContext.GetUsername().c_str();
+    api_params.api_token = pUserContext.GetApiToken().c_str();
+    api_params.game_id = request.GameId;
 
-    AppendUrlParam(sPostData, "m", std::to_string(request.GameId));
+    rc_api_request_t api_request;
+    if (rc_api_init_start_session_request(&api_request, &api_params) == RC_OK)
+    {
+        ra::services::Http::Response httpResponse;
+        if (DoRequest(api_request, StartSession::Name(), httpResponse, response))
+        {
+            rc_api_start_session_response_t api_response;
+            const auto nResult = rc_api_process_start_session_response(&api_response, httpResponse.Content().c_str());
 
-    if (DoRequest(m_sHost, StartSession::Name(), "postactivity", sPostData, response, document))
-        response.Result = ApiResult::Success;
+            if (ValidateResponse(nResult, api_response.response, StartSession::Name(), httpResponse.StatusCode(), response))
+                response.Result = ApiResult::Success;
+
+            rc_api_destroy_start_session_response(&api_response);
+        }
+    }
+
+    rc_api_destroy_request(&api_request);
 
     return std::move(response);
 }
@@ -430,15 +541,40 @@ StartSession::Response ConnectedServer::StartSession(const StartSession::Request
 Ping::Response ConnectedServer::Ping(const Ping::Request& request)
 {
     Ping::Response response;
-    rapidjson::Document document;
+
     std::string sPostData;
+    rc_api_ping_request_t api_params;
+    memset(&api_params, 0, sizeof(api_params));
 
-    AppendUrlParam(sPostData, "g", std::to_string(request.GameId));
+    const auto& pUserContext = ra::services::ServiceLocator::Get<ra::data::context::UserContext>();
+    api_params.username = pUserContext.GetUsername().c_str();
+    api_params.api_token = pUserContext.GetApiToken().c_str();
+    api_params.game_id = request.GameId;
+
+    std::string sRichPresence;
     if (!request.CurrentActivity.empty())
-        AppendUrlParam(sPostData, "m", ra::Narrow(request.CurrentActivity));
+    {
+        sRichPresence = ra::Narrow(request.CurrentActivity);
+        api_params.rich_presence = sRichPresence.c_str();
+    }
 
-    if (DoRequest(m_sHost, Ping::Name(), "ping", sPostData, response, document))
-        response.Result = ApiResult::Success;
+    rc_api_request_t api_request;
+    if (rc_api_init_ping_request(&api_request, &api_params) == RC_OK)
+    {
+        ra::services::Http::Response httpResponse;
+        if (DoRequest(api_request, Ping::Name(), httpResponse, response))
+        {
+            rc_api_ping_response_t api_response;
+            const auto nResult = rc_api_process_ping_response(&api_response, httpResponse.Content().c_str());
+
+            if (ValidateResponse(nResult, api_response.response, Ping::Name(), httpResponse.StatusCode(), response))
+                response.Result = ApiResult::Success;
+
+            rc_api_destroy_ping_response(&api_response);
+        }
+    }
+
+    rc_api_destroy_request(&api_request);
 
     return std::move(response);
 }
@@ -446,113 +582,136 @@ Ping::Response ConnectedServer::Ping(const Ping::Request& request)
 FetchUserUnlocks::Response ConnectedServer::FetchUserUnlocks(const FetchUserUnlocks::Request& request)
 {
     FetchUserUnlocks::Response response;
-    rapidjson::Document document;
+
     std::string sPostData;
+    rc_api_fetch_user_unlocks_request_t api_params;
+    memset(&api_params, 0, sizeof(api_params));
 
-    AppendUrlParam(sPostData, "g", std::to_string(request.GameId));
-    AppendUrlParam(sPostData, "h", request.Hardcore ? "1" : "0");
+    const auto& pUserContext = ra::services::ServiceLocator::Get<ra::data::context::UserContext>();
+    api_params.username = pUserContext.GetUsername().c_str();
+    api_params.api_token = pUserContext.GetApiToken().c_str();
+    api_params.game_id = request.GameId;
+    api_params.hardcore = request.Hardcore ? 1 : 0;
 
-    if (DoRequest(m_sHost, FetchUserUnlocks::Name(), "unlocks", sPostData, response, document))
+    rc_api_request_t api_request;
+    if (rc_api_init_fetch_user_unlocks_request(&api_request, &api_params) == RC_OK)
     {
-        if (!document.HasMember("UserUnlocks"))
-        {
-            response.Result = ApiResult::Error;
-            response.ErrorMessage = ra::StringPrintf("%s not found in response", "UserUnlocks");
-        }
-        else
+        ra::services::Http::Response httpResponse;
+        if (DoRequest(api_request, FetchUserUnlocks::Name(), httpResponse, response))
         {
-            response.Result = ApiResult::Success;
+            rc_api_fetch_user_unlocks_response_t api_response;
+            const auto nResult = rc_api_process_fetch_user_unlocks_response(&api_response, httpResponse.Content().c_str());
+
+            if (ValidateResponse(nResult, api_response.response, FetchUserUnlocks::Name(), httpResponse.StatusCode(), response))
+            {
+                response.Result = ApiResult::Success;
 
-            const auto& UserUnlocks{ document["UserUnlocks"] };
-            for (const auto& unlocked : UserUnlocks.GetArray())
-                response.UnlockedAchievements.insert(unlocked.GetUint());
+                for (unsigned i = 0; i < api_response.num_achievement_ids; ++i)
+                    response.UnlockedAchievements.insert(api_response.achievement_ids[i]);
+            }
+
+            rc_api_destroy_fetch_user_unlocks_response(&api_response);
         }
     }
 
+    rc_api_destroy_request(&api_request);
+
     return std::move(response);
 }
 
 AwardAchievement::Response ConnectedServer::AwardAchievement(const AwardAchievement::Request& request)
 {
     AwardAchievement::Response response;
-    rapidjson::Document document;
+
     std::string sPostData;
+    rc_api_award_achievement_request_t api_params;
+    memset(&api_params, 0, sizeof(api_params));
+
+    const auto& pUserContext = ra::services::ServiceLocator::Get<ra::data::context::UserContext>();
+    api_params.username = pUserContext.GetUsername().c_str();
+    api_params.api_token = pUserContext.GetApiToken().c_str();
 
-    AppendUrlParam(sPostData, "a", std::to_string(request.AchievementId));
-    AppendUrlParam(sPostData, "h", request.Hardcore ? "1" : "0");
     if (!request.GameHash.empty())
-        AppendUrlParam(sPostData, "m", request.GameHash);
+        api_params.game_hash = request.GameHash.c_str();
 
-    if (DoRequest(m_sHost, AwardAchievement::Name(), "awardachievement", sPostData, response, document))
+    api_params.achievement_id = request.AchievementId;
+    api_params.hardcore = request.Hardcore ? 1 : 0;
+
+    rc_api_request_t api_request;
+    if (rc_api_init_award_achievement_request(&api_request, &api_params) == RC_OK)
     {
-        response.Result = ApiResult::Success;
-        GetRequiredJsonField(response.NewPlayerScore, document, "Score", response);
+        ra::services::Http::Response httpResponse;
+        if (DoRequest(api_request, AwardAchievement::Name(), httpResponse, response))
+        {
+            rc_api_award_achievement_response_t api_response;
+            const auto nResult = rc_api_process_award_achievement_response(&api_response, httpResponse.Content().c_str());
+
+            if (ValidateResponse(nResult, api_response.response, AwardAchievement::Name(), httpResponse.StatusCode(), response))
+            {
+                response.Result = ApiResult::Success;
+                response.NewPlayerScore = api_response.new_player_score;
+            }
+
+            rc_api_destroy_award_achievement_response(&api_response);
+        }
     }
 
+    rc_api_destroy_request(&api_request);
+
     return std::move(response);
 }
 
 SubmitLeaderboardEntry::Response ConnectedServer::SubmitLeaderboardEntry(const SubmitLeaderboardEntry::Request& request)
 {
     SubmitLeaderboardEntry::Response response;
-    rapidjson::Document document;
-    std::string sPostData;
 
-    AppendUrlParam(sPostData, "i", std::to_string(request.LeaderboardId));
-    AppendUrlParam(sPostData, "s", std::to_string(request.Score));
+    std::string sPostData;
+    rc_api_submit_lboard_entry_request_t api_params;
+    memset(&api_params, 0, sizeof(api_params));
 
     const auto& pUserContext = ra::services::ServiceLocator::Get<ra::data::context::UserContext>();
-    std::string sValidationSignature = ra::StringPrintf("%u%s%u", request.LeaderboardId, pUserContext.GetUsername(), request.LeaderboardId);
-    AppendUrlParam(sPostData, "v", RAGenerateMD5(sValidationSignature));
+    api_params.username = pUserContext.GetUsername().c_str();
+    api_params.api_token = pUserContext.GetApiToken().c_str();
+
+    api_params.leaderboard_id = request.LeaderboardId;
+    api_params.score = request.Score;
 
     if (!request.GameHash.empty())
-        AppendUrlParam(sPostData, "m", request.GameHash);
+        api_params.game_hash = request.GameHash.c_str();
 
-    if (DoRequest(m_sHost, SubmitLeaderboardEntry::Name(), "submitlbentry", sPostData, response, document))
+    rc_api_request_t api_request;
+    if (rc_api_init_submit_lboard_entry_request(&api_request, &api_params) == RC_OK)
     {
-        response.Result = ApiResult::Success;
-
-        if (!document.HasMember("Response"))
+        ra::services::Http::Response httpResponse;
+        if (DoRequest(api_request, SubmitLeaderboardEntry::Name(), httpResponse, response))
         {
-            response.Result = ApiResult::Error;
-            response.ErrorMessage = ra::StringPrintf("%s not found in response", "Response");
-        }
-        else
-        {
-            const auto& submitResponse = document["Response"];
-            GetRequiredJsonField(response.Score, submitResponse, "Score", response);
-            GetRequiredJsonField(response.BestScore, submitResponse, "BestScore", response);
+            rc_api_submit_lboard_entry_response_t api_response;
+            const auto nResult = rc_api_process_submit_lboard_entry_response(&api_response, httpResponse.Content().c_str());
 
-            if (!submitResponse.HasMember("RankInfo"))
+            if (ValidateResponse(nResult, api_response.response, SubmitLeaderboardEntry::Name(), httpResponse.StatusCode(), response))
             {
-                response.Result = ApiResult::Error;
-                response.ErrorMessage = ra::StringPrintf("%s not found in response", "RankInfo");
-            }
-            else
-            {
-                const auto& pRankInfo = submitResponse["RankInfo"];
-                GetRequiredJsonField(response.NewRank, pRankInfo, "Rank", response);
-                std::string sNumEntries;
-                GetRequiredJsonField(sNumEntries, pRankInfo, "NumEntries", response);
-                response.NumEntries = std::stoi(sNumEntries);
-            }
+                response.Result = ApiResult::Success;
+                response.Score = api_response.submitted_score;
+                response.BestScore = api_response.best_score;
+                response.NewRank = api_response.new_rank;
+                response.NumEntries = api_response.num_entries;
 
-            if (submitResponse.HasMember("TopEntries"))
-            {
-                const auto& pTopEntries = submitResponse["TopEntries"].GetArray();
-                response.TopEntries.reserve(pTopEntries.Size());
-                for (const auto& pEntry : pTopEntries)
+                for (unsigned i = 0; i < api_response.num_top_entries; ++i)
                 {
-                    SubmitLeaderboardEntry::Response::Entry entry;
-                    GetRequiredJsonField(entry.Rank, pEntry, "Rank", response);
-                    GetRequiredJsonField(entry.User, pEntry, "User", response);
-                    GetRequiredJsonField(entry.Score, pEntry, "Score", response);
-                    response.TopEntries.emplace_back(entry);
+                    const auto* pSrcEntry = &api_response.top_entries[i];
+                    auto& entry = response.TopEntries.emplace_back();
+                    entry.Rank = pSrcEntry->rank;
+                    entry.Score = pSrcEntry->score;
+                    entry.User = pSrcEntry->username;
                 }
             }
+
+            rc_api_destroy_submit_lboard_entry_response(&api_response);
         }
     }
 
+    rc_api_destroy_request(&api_request);
+
     return std::move(response);
 }
 
@@ -599,109 +758,135 @@ FetchUserFriends::Response ConnectedServer::FetchUserFriends(const FetchUserFrie
 ResolveHash::Response ConnectedServer::ResolveHash(const ResolveHash::Request& request)
 {
     ResolveHash::Response response;
-    rapidjson::Document document;
+
     std::string sPostData;
+    rc_api_resolve_hash_request_t api_params;
+    memset(&api_params, 0, sizeof(api_params));
 
-    AppendUrlParam(sPostData, "m", request.Hash);
+    const auto& pUserContext = ra::services::ServiceLocator::Get<ra::data::context::UserContext>();
+    api_params.username = pUserContext.GetUsername().c_str();
+    api_params.api_token = pUserContext.GetApiToken().c_str();
+    api_params.game_hash = request.Hash.c_str();
 
-    if (DoRequest(m_sHost, ResolveHash::Name(), "gameid", sPostData, response, document))
+    rc_api_request_t api_request;
+    if (rc_api_init_resolve_hash_request(&api_request, &api_params) == RC_OK)
     {
-        response.Result = ApiResult::Success;
-        GetRequiredJsonField(response.GameId, document, "GameID", response);
+        ra::services::Http::Response httpResponse;
+        if (DoRequest(api_request, ResolveHash::Name(), httpResponse, response))
+        {
+            rc_api_resolve_hash_response_t api_response;
+            const auto nResult = rc_api_process_resolve_hash_response(&api_response, httpResponse.Content().c_str());
+
+            if (ValidateResponse(nResult, api_response.response, ResolveHash::Name(), httpResponse.StatusCode(), response))
+            {
+                response.Result = ApiResult::Success;
+                response.GameId = api_response.game_id;
+            }
+
+            rc_api_destroy_resolve_hash_response(&api_response);
+        }
     }
 
+    rc_api_destroy_request(&api_request);
+
     return std::move(response);
 }
 
 FetchGameData::Response ConnectedServer::FetchGameData(const FetchGameData::Request& request)
 {
     FetchGameData::Response response;
-    rapidjson::Document document;
+
     std::string sPostData;
+    rc_api_fetch_game_data_request_t api_params;
+    memset(&api_params, 0, sizeof(api_params));
 
-    AppendUrlParam(sPostData, "g", std::to_string(request.GameId));
+    const auto& pUserContext = ra::services::ServiceLocator::Get<ra::data::context::UserContext>();
+    api_params.username = pUserContext.GetUsername().c_str();
+    api_params.api_token = pUserContext.GetApiToken().c_str();
+    api_params.game_id = request.GameId;
 
-    if (DoRequest(m_sHost, FetchGameData::Name(), "patch", sPostData, response, document))
+    rc_api_request_t api_request;
+    if (rc_api_init_fetch_game_data_request(&api_request, &api_params) == RC_OK)
     {
-        if (!document.HasMember("PatchData"))
+        ra::services::Http::Response httpResponse;
+        if (DoRequest(api_request, FetchGameData::Name(), httpResponse, response))
         {
-            response.Result = ApiResult::Error;
-            response.ErrorMessage = ra::StringPrintf("%s not found in response", "PatchData");
-        }
-        else
-        {
-            response.Result = ApiResult::Success;
-
-            const auto& PatchData = document["PatchData"];
+            // process it
+            ProcessGamePatchData(response, httpResponse);
 
-            // store a copy in the cache for offline mode
-            auto& pLocalStorage = ra::services::ServiceLocator::GetMutable<ra::services::ILocalStorage>();
-            auto pData = pLocalStorage.WriteText(ra::services::StorageItemType::GameData, std::to_wstring(request.GameId));
-            if (pData != nullptr)
+            if (response.Result == ApiResult::Success)
             {
-                rapidjson::Document patchData;
-                patchData.CopyFrom(PatchData, document.GetAllocator());
-                SaveDocument(patchData, *pData.get());
+                // extract the PatchData and store a copy in the cache for offline mode
+                rc_api_response_t api_response{};
+                rc_json_field_t fields[] = {
+                    {"Success"},
+                    {"Error"},
+                    {"PatchData"}
+                };
+
+                if (rc_json_parse_response(&api_response, httpResponse.Content().c_str(), fields, sizeof(fields) / sizeof(fields[0])) == RC_OK &&
+                    fields[2].value_start && fields[2].value_end)
+                {
+                    auto& pLocalStorage = ra::services::ServiceLocator::GetMutable<ra::services::ILocalStorage>();
+                    auto pData = pLocalStorage.WriteText(ra::services::StorageItemType::GameData, std::to_wstring(request.GameId));
+                    if (pData != nullptr)
+                    {
+                        std::string sPatchData;
+                        sPatchData.append(fields[2].value_start, fields[2].value_end - fields[2].value_start);
+                        pData->Write(sPatchData);
+                    }
+                }
             }
-
-            // process it
-            ProcessGamePatchData(response, PatchData);
         }
     }
 
+    rc_api_destroy_request(&api_request);
+
     return std::move(response);
 }
 
-void ConnectedServer::ProcessGamePatchData(ra::api::FetchGameData::Response &response, const rapidjson::Value& PatchData)
+void ConnectedServer::ProcessGamePatchData(ra::api::FetchGameData::Response &response, const ra::services::Http::Response& httpResponse)
 {
-    GetRequiredJsonField(response.Title, PatchData, "Title", response);
-    GetRequiredJsonField(response.ConsoleId, PatchData, "ConsoleID", response);
-    GetOptionalJsonField(response.ForumTopicId, PatchData, "ForumTopicID");
-    GetOptionalJsonField(response.Flags, PatchData, "Flags");
-    GetOptionalJsonField(response.ImageIcon, PatchData, "ImageIcon");
-    GetOptionalJsonField(response.RichPresence, PatchData, "RichPresencePatch");
-
-    if (!response.ImageIcon.empty())
-    {
-        // ImageIcon value will be "/Images/001234.png" - extract the "001234"
-        auto nIndex = response.ImageIcon.find_last_of('/');
-        if (nIndex != std::string::npos)
-            response.ImageIcon.erase(0, nIndex + 1);
-        nIndex = response.ImageIcon.find_last_of('.');
-        if (nIndex != std::string::npos)
-            response.ImageIcon.erase(nIndex);
-    }
+    rc_api_fetch_game_data_response_t api_response;
+    const auto nResult = rc_api_process_fetch_game_data_response(&api_response, httpResponse.Content().c_str());
 
-    const auto& AchievementsData{ PatchData["Achievements"] };
-    for (const auto& achData : AchievementsData.GetArray())
+    if (ValidateResponse(nResult, api_response.response, FetchGameData::Name(), httpResponse.StatusCode(), response))
     {
-        auto& pAchievement = response.Achievements.emplace_back();
-        GetRequiredJsonField(pAchievement.Id, achData, "ID", response);
-        GetRequiredJsonField(pAchievement.Title, achData, "Title", response);
-        GetRequiredJsonField(pAchievement.Description, achData, "Description", response);
-        GetRequiredJsonField(pAchievement.CategoryId, achData, "Flags", response);
-        GetOptionalJsonField(pAchievement.Points, achData, "Points");
-        GetRequiredJsonField(pAchievement.Definition, achData, "MemAddr", response);
-        GetOptionalJsonField(pAchievement.Author, achData, "Author");
-        GetRequiredJsonField(pAchievement.BadgeName, achData, "BadgeName", response);
-
-        unsigned int time;
-        GetRequiredJsonField(time, achData, "Created", response);
-        pAchievement.Created = static_cast<time_t>(time);
-        GetRequiredJsonField(time, achData, "Modified", response);
-        pAchievement.Updated = static_cast<time_t>(time);
-    }
+        response.Result = ApiResult::Success;
+        response.Title = ra::Widen(api_response.title);
+        response.ConsoleId = api_response.console_id;
+        response.ImageIcon = api_response.image_name;
+        response.RichPresence = api_response.rich_presence_script;
 
-    const auto& LeaderboardsData{ PatchData["Leaderboards"] };
-    for (const auto& lbData : LeaderboardsData.GetArray())
-    {
-        auto& pLeaderboard = response.Leaderboards.emplace_back();
-        GetRequiredJsonField(pLeaderboard.Id, lbData, "ID", response);
-        GetRequiredJsonField(pLeaderboard.Title, lbData, "Title", response);
-        GetRequiredJsonField(pLeaderboard.Description, lbData, "Description", response);
-        GetRequiredJsonField(pLeaderboard.Definition, lbData, "Mem", response);
-        GetOptionalJsonField(pLeaderboard.Format, lbData, "Format", "VALUE");
+        for (unsigned i = 0; i < api_response.num_achievements; ++i)
+        {
+            const auto* pSrcAchievement = &api_response.achievements[i];
+            auto& pAchievement = response.Achievements.emplace_back();
+            pAchievement.Id = pSrcAchievement->id;
+            pAchievement.Title = pSrcAchievement->title;
+            pAchievement.Description = pSrcAchievement->description;
+            pAchievement.CategoryId = pSrcAchievement->category;
+            pAchievement.Points = pSrcAchievement->points;
+            pAchievement.Definition = pSrcAchievement->definition;
+            pAchievement.Author = pSrcAchievement->author;
+            pAchievement.BadgeName = pSrcAchievement->badge_name;
+            pAchievement.Created = pSrcAchievement->created;
+            pAchievement.Updated = pSrcAchievement->updated;
+        }
+
+        for (unsigned i = 0; i < api_response.num_leaderboards; ++i)
+        {
+            const auto* pSrcLeaderboard = &api_response.leaderboards[i];
+            auto& pLeaderboard = response.Leaderboards.emplace_back();
+            pLeaderboard.Id = pSrcLeaderboard->id;
+            pLeaderboard.Title = pSrcLeaderboard->title;
+            pLeaderboard.Description = pSrcLeaderboard->description;
+            pLeaderboard.Definition = pSrcLeaderboard->definition;
+            pLeaderboard.Format = pSrcLeaderboard->format;
+        }
     }
+
+    rc_api_destroy_fetch_game_data_response(&api_response);
 }
 
 FetchCodeNotes::Response ConnectedServer::FetchCodeNotes(const FetchCodeNotes::Request& request)
diff --git a/src/api/impl/ConnectedServer.hh b/src/api/impl/ConnectedServer.hh
index 27548c31..fb7c5e04 100644
--- a/src/api/impl/ConnectedServer.hh
+++ b/src/api/impl/ConnectedServer.hh
@@ -2,6 +2,8 @@
 
 #include "ServerBase.hh"
 
+#include "services\Http.hh"
+
 namespace ra {
 namespace api {
 namespace impl {
@@ -36,7 +38,7 @@ public:
     FetchBadgeIds::Response FetchBadgeIds(const FetchBadgeIds::Request& request) override;
     UploadBadge::Response UploadBadge(const UploadBadge::Request& request) override;
 
-    static void ProcessGamePatchData(FetchGameData::Response &response, const rapidjson::Value& PatchData);
+    static void ProcessGamePatchData(FetchGameData::Response &response, const ra::services::Http::Response& httpResponse);
     static void ProcessCodeNotes(FetchCodeNotes::Response &response, const rapidjson::Value& CodeNotes);
 
 private:
diff --git a/src/api/impl/OfflineServer.cpp b/src/api/impl/OfflineServer.cpp
index 67823707..d28d3f4b 100644
--- a/src/api/impl/OfflineServer.cpp
+++ b/src/api/impl/OfflineServer.cpp
@@ -40,16 +40,21 @@ FetchGameData::Response OfflineServer::FetchGameData(const FetchGameData::Reques
         response.Result = ApiResult::Failed;
         response.ErrorMessage = ra::StringPrintf("Achievement data for game %u not found in cache", request.GameId);
     }
-    else if (!LoadDocument(document, *pData.get()))
-    {
-        response.Result = ApiResult::Error;
-        response.ErrorMessage =
-            ra::StringPrintf("%s (%zu)", GetParseError_En(document.GetParseError()), document.GetErrorOffset());
-    }
-    else
+    else 
     {
-        response.Result = ApiResult::Success;
-        ConnectedServer::ProcessGamePatchData(response, document);
+        std::string sContents = "{\"Success\":true,\"PatchData\":";
+        std::string sLine;
+        while (pData->GetLine(sLine))
+        {
+            if (!sContents.empty())
+                sContents.push_back('\n');
+
+            sContents.append(sLine);
+        }
+        sContents.push_back('}');
+
+        ra::services::Http::Response httpResponse(ra::services::Http::StatusCode::OK, sContents);
+        ConnectedServer::ProcessGamePatchData(response, httpResponse);
     }
 
     return std::move(response);
diff --git a/src/data/context/EmulatorContext.cpp b/src/data/context/EmulatorContext.cpp
index b42e0d7d..ed4fb110 100644
--- a/src/data/context/EmulatorContext.cpp
+++ b/src/data/context/EmulatorContext.cpp
@@ -629,7 +629,9 @@ uint8_t EmulatorContext::ReadMemoryByte(ra::ByteAddress nAddress) const
         nAddress -= gsl::narrow_cast<ra::ByteAddress>(pBlock.size);
     }
 
-    ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>().InvalidateAddress(nOriginalAddress);
+    if (nOriginalAddress < m_nTotalMemorySize)
+        ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>().InvalidateAddress(nOriginalAddress);
+
     return 0;
 }
 
diff --git a/src/data/context/GameContext.cpp b/src/data/context/GameContext.cpp
index e7bb25d7..6a64eed1 100644
--- a/src/data/context/GameContext.cpp
+++ b/src/data/context/GameContext.cpp
@@ -182,7 +182,7 @@ void GameContext::LoadGame(unsigned int nGameId, Mode nMode)
         RA_Leaderboard& pLeaderboard = *m_vLeaderboards.emplace_back(std::make_unique<RA_Leaderboard>(pLeaderboardData.Id));
         pLeaderboard.SetTitle(pLeaderboardData.Title);
         pLeaderboard.SetDescription(pLeaderboardData.Description);
-        pLeaderboard.ParseFromString(pLeaderboardData.Definition.c_str(), pLeaderboardData.Format.c_str());
+        pLeaderboard.ParseFromString(pLeaderboardData.Definition.c_str(), pLeaderboardData.Format);
     }
 
     ActivateLeaderboards();
@@ -797,6 +797,10 @@ void GameContext::ReloadRichPresenceScript()
             sRichPresence.append(sLine);
             sRichPresence.append("\n");
         }
+
+        // remove UTF-8 BOM if present
+        if (ra::StringStartsWith(sRichPresence, "\xef\xbb\xbf"))
+            sRichPresence.erase(0, 3);
     }
 
     const auto sFileRichPresenceMD5 = RAGenerateMD5(sRichPresence);
diff --git a/src/rcheevos.vcxproj b/src/rcheevos.vcxproj
index 48057577..b97f336e 100644
--- a/src/rcheevos.vcxproj
+++ b/src/rcheevos.vcxproj
@@ -178,6 +178,9 @@
     <ClInclude Include="..\rcheevos\include\rcheevos.h" />
     <ClInclude Include="..\rcheevos\src\rcheevos\compat.h" />
     <ClInclude Include="..\rcheevos\src\rcheevos\internal.h" />
+    <ClCompile Include="..\rcheevos\src\rapi\rc_api_common.c" />
+    <ClCompile Include="..\rcheevos\src\rapi\rc_api_runtime.c" />
+    <ClCompile Include="..\rcheevos\src\rapi\rc_api_user.c" />
     <ClCompile Include="..\rcheevos\src\rcheevos\consoleinfo.c" />
     <ClCompile Include="..\rcheevos\src\rcheevos\compat.c">
       <PreprocessorDefinitions>_CRT_SECURE_NO_WARNINGS;%(PreprocessorDefinitions)</PreprocessorDefinitions>
diff --git a/src/rcheevos.vcxproj.filters b/src/rcheevos.vcxproj.filters
index d2a24437..a6830a88 100644
--- a/src/rcheevos.vcxproj.filters
+++ b/src/rcheevos.vcxproj.filters
@@ -121,6 +121,15 @@
     <ClInclude Include="..\rcheevos\src\rcheevos\compat.h">
       <Filter>rcheevos</Filter>
     </ClInclude>
+    <ClCompile Include="..\rcheevos\src\rapi\rc_api_user.c">
+      <Filter>rapi</Filter>
+    </ClCompile>
+    <ClCompile Include="..\rcheevos\src\rapi\rc_api_common.c">
+      <Filter>rapi</Filter>
+    </ClCompile>
+    <ClCompile Include="..\rcheevos\src\rapi\rc_api_runtime.c">
+      <Filter>rapi</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <Filter Include="lua">
@@ -132,5 +141,8 @@
     <Filter Include="md5">
       <UniqueIdentifier>{0d89b82c-19cc-4151-b2f6-c54895534c95}</UniqueIdentifier>
     </Filter>
+    <Filter Include="rapi">
+      <UniqueIdentifier>{d363eb07-b5ba-4ce7-99a4-de23227fd446}</UniqueIdentifier>
+    </Filter>
   </ItemGroup>
 </Project>
\ No newline at end of file
diff --git a/src/services/AchievementRuntime.cpp b/src/services/AchievementRuntime.cpp
index b0d38764..d6281894 100644
--- a/src/services/AchievementRuntime.cpp
+++ b/src/services/AchievementRuntime.cpp
@@ -178,6 +178,8 @@ void AchievementRuntime::ActivateRichPresence(const std::string& sScript)
     else
     {
         m_nRichPresenceParseResult = rc_runtime_activate_richpresence(&m_pRuntime, sScript.c_str(), nullptr, 0);
+        if (m_nRichPresenceParseResult != RC_OK)
+            m_nRichPresenceParseResult = rc_richpresence_size_lines(sScript.c_str(), &m_nRichPresenceErrorLine);
     }
 }
 
@@ -188,7 +190,10 @@ std::wstring AchievementRuntime::GetRichPresenceDisplayString() const
         std::lock_guard<std::mutex> pLock(m_pMutex);
 
         if (m_nRichPresenceParseResult != RC_OK)
-            return ra::StringPrintf(L"Parse error %d: %s\n", m_nRichPresenceParseResult, rc_error_str(m_nRichPresenceParseResult));
+        {
+            return ra::StringPrintf(L"Parse error %d (line %d): %s", m_nRichPresenceParseResult,
+                m_nRichPresenceErrorLine, rc_error_str(m_nRichPresenceParseResult));
+        }
 
         char sRichPresence[256];
         if (rc_runtime_get_richpresence(&m_pRuntime, sRichPresence, sizeof(sRichPresence), rc_peek_callback, nullptr, nullptr) > 0)
diff --git a/src/services/AchievementRuntime.hh b/src/services/AchievementRuntime.hh
index dc8244b9..a01f4228 100644
--- a/src/services/AchievementRuntime.hh
+++ b/src/services/AchievementRuntime.hh
@@ -222,6 +222,7 @@ private:
 
     std::map<unsigned int, std::string> m_vQueuedAchievements;
     int m_nRichPresenceParseResult = RC_OK;
+    int m_nRichPresenceErrorLine = 0;
     bool m_bInitialized = false;
 };
 
diff --git a/src/services/Http.hh b/src/services/Http.hh
index 976dcc3d..2c30de74 100644
--- a/src/services/Http.hh
+++ b/src/services/Http.hh
@@ -26,11 +26,12 @@ public:
         {
         }
 
+        Response() noexcept = default;
         ~Response() noexcept = default;
         Response(const Response&) noexcept = default;
-        Response& operator=(const Response&) noexcept = delete;
+        Response& operator=(const Response&) noexcept = default;
         Response(Response&&) noexcept = default;
-        Response& operator=(Response&&) noexcept = delete;
+        Response& operator=(Response&&) noexcept = default;
 
         /// <summary>
         /// Gets the HTTP status code returned from the server.
diff --git a/src/ui/viewmodels/AssetEditorViewModel.cpp b/src/ui/viewmodels/AssetEditorViewModel.cpp
index 31ebe3ca..fc72d9c5 100644
--- a/src/ui/viewmodels/AssetEditorViewModel.cpp
+++ b/src/ui/viewmodels/AssetEditorViewModel.cpp
@@ -268,13 +268,13 @@ void AssetEditorViewModel::OnValueChanged(const StringModelProperty::ChangeArgs&
 
 void AssetEditorViewModel::OnValueChanged(const IntModelProperty::ChangeArgs& args)
 {
-    if (args.Property == StateProperty)
+    if (m_pAsset != nullptr)
     {
-        const auto nOldState = ra::itoe<ra::data::models::AssetState>(args.tOldValue);
-        const auto nNewState = ra::itoe<ra::data::models::AssetState>(args.tNewValue);
-
-        if (m_pAsset != nullptr)
+        if (args.Property == StateProperty)
         {
+            const auto nOldState = ra::itoe<ra::data::models::AssetState>(args.tOldValue);
+            const auto nNewState = ra::itoe<ra::data::models::AssetState>(args.tNewValue);
+
             // make sure to update the hit counts in the frame where the achievement triggers
             // before it's deactivated, which might remove it from the runtime
             if (nNewState == ra::data::models::AssetState::Triggered)
@@ -289,19 +289,15 @@ void AssetEditorViewModel::OnValueChanged(const IntModelProperty::ChangeArgs& ar
                 if (!ra::data::models::AssetModelBase::IsActive(nOldState))
                     UpdateTriggerBinding();
             }
-        }
-
-        if (nNewState == ra::data::models::AssetState::Waiting)
-            SetValue(WaitingLabelProperty, L"Waiting");
-        else if (nOldState == ra::data::models::AssetState::Waiting)
-            SetValue(WaitingLabelProperty, WaitingLabelProperty.GetDefaultValue());
 
-        UpdateAssetFrameValues();
-    }
+            if (nNewState == ra::data::models::AssetState::Waiting)
+                SetValue(WaitingLabelProperty, L"Waiting");
+            else if (nOldState == ra::data::models::AssetState::Waiting)
+                SetValue(WaitingLabelProperty, WaitingLabelProperty.GetDefaultValue());
 
-    if (m_pAsset != nullptr)
-    {
-        if (args.Property == CategoryProperty)
+            UpdateAssetFrameValues();
+        }
+        else if (args.Property == CategoryProperty)
         {
             m_pAsset->SetCategory(ra::itoe<ra::data::models::AssetCategory>(args.tNewValue));
         }
diff --git a/src/ui/viewmodels/AssetListViewModel.cpp b/src/ui/viewmodels/AssetListViewModel.cpp
index dd05e5f9..0d49e203 100644
--- a/src/ui/viewmodels/AssetListViewModel.cpp
+++ b/src/ui/viewmodels/AssetListViewModel.cpp
@@ -11,6 +11,7 @@
 
 #include "ui\viewmodels\AssetUploadViewModel.hh"
 #include "ui\viewmodels\MessageBoxViewModel.hh"
+#include "ui\viewmodels\OverlayManager.hh"
 #include "ui\viewmodels\WindowManager.hh"
 
 #include "Exports.hh"
@@ -94,11 +95,6 @@ void AssetListViewModel::OnActiveGameChanged()
     SetGameId(pGameContext.GameId());
 }
 
-static bool IsTalliedAchievement(const ra::data::models::AchievementModel& pAchievement)
-{
-    return (pAchievement.GetCategory() == ra::data::models::AssetCategory::Core);
-}
-
 void AssetListViewModel::OnDataModelStringValueChanged(gsl::index nIndex, const StringModelProperty::ChangeArgs& args)
 {
     // sync this property through to the summary view model
@@ -122,7 +118,7 @@ void AssetListViewModel::OnDataModelIntValueChanged(gsl::index nIndex, const Int
         const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
         const auto* pAsset = pGameContext.Assets().GetItemAt(nIndex);
         const auto* pAchievement = dynamic_cast<const ra::data::models::AchievementModel*>(pAsset);
-        if (pAchievement != nullptr && IsTalliedAchievement(*pAchievement))
+        if (pAchievement != nullptr && GetFilteredAssetIndex(*pAsset) >= 0)
         {
             auto nPoints = GetTotalPoints();
             nPoints += args.tNewValue - args.tOldValue;
@@ -131,7 +127,27 @@ void AssetListViewModel::OnDataModelIntValueChanged(gsl::index nIndex, const Int
     }
     else if (args.Property == ra::data::models::AssetModelBase::StateProperty)
     {
+        if (GetSpecialFilter() != SpecialFilter::All)
+            AddOrRemoveFilteredItem(nIndex);
+
         const auto nNewState = ra::itoe<ra::data::models::AssetState>(args.tNewValue);
+        if (!ra::data::models::AssetModelBase::IsActive(nNewState))
+        {
+            // when an active achievement is deactivated, the challenge indicator needs
+            // to be hidden as no event will be raised
+            const auto nOldState = ra::itoe<ra::data::models::AssetState>(args.tOldValue);
+            if (ra::data::models::AssetModelBase::IsActive(nOldState))
+            {
+                const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
+                const auto* pAsset = pGameContext.Assets().GetItemAt(nIndex);
+                if (pAsset->GetType() == ra::data::models::AssetType::Achievement)
+                {
+                    auto& pOverlayManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>();
+                    pOverlayManager.RemoveChallengeIndicator(pAsset->GetID());
+                }
+            }
+        }
+
         if (nNewState == ra::data::models::AssetState::Triggered && KeepActive())
         {
             // if KeepActive is selected, set a Triggered achievement back to Waiting
@@ -150,23 +166,13 @@ void AssetListViewModel::OnDataModelIntValueChanged(gsl::index nIndex, const Int
         UpdateTotals();
 
         if (GetFilterCategory() != FilterCategory::All)
-        {
-            const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
-            const auto* pAsset = pGameContext.Assets().GetItemAt(nIndex);
-            if (pAsset != nullptr)
-                AddOrRemoveFilteredItem(*pAsset);
-        }
+            AddOrRemoveFilteredItem(nIndex);
     }
     else if (args.Property == ra::data::models::AssetModelBase::StateProperty ||
              args.Property == ra::data::models::AssetModelBase::ChangesProperty)
     {
         if (GetSpecialFilter() != SpecialFilter::All)
-        {
-            const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
-            const auto* pAsset = pGameContext.Assets().GetItemAt(nIndex);
-            if (pAsset != nullptr)
-                AddOrRemoveFilteredItem(*pAsset);
-        }
+            AddOrRemoveFilteredItem(nIndex);
     }
 
     // sync these properties through to the summary view model
@@ -218,13 +224,7 @@ void AssetListViewModel::OnDataModelAdded(_UNUSED gsl::index nIndex)
 {
     const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
     if (!pGameContext.Assets().IsUpdating())
-    {
-        UpdateTotals();
-
-        const auto* pAsset = pGameContext.Assets().GetItemAt(nIndex);
-        if (pAsset != nullptr)
-            AddOrRemoveFilteredItem(*pAsset);
-    }
+        AddOrRemoveFilteredItem(nIndex);
 }
 
 void AssetListViewModel::OnDataModelRemoved(_UNUSED gsl::index nIndex)
@@ -232,8 +232,6 @@ void AssetListViewModel::OnDataModelRemoved(_UNUSED gsl::index nIndex)
     const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
     if (!pGameContext.Assets().IsUpdating())
     {
-        UpdateTotals();
-
         // the item has already been removed from the vAssets collection, and all we know about it
         // is the index where it was located. scan through the vFilteredAssets collection and remove 
         // any that no longer exist in the vAssets collection.
@@ -250,6 +248,8 @@ void AssetListViewModel::OnDataModelRemoved(_UNUSED gsl::index nIndex)
                 }
             }
         }
+
+        UpdateTotals();
     }
 }
 
@@ -258,17 +258,16 @@ void AssetListViewModel::OnDataModelChanged(_UNUSED gsl::index nIndex)
     const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
     if (!pGameContext.Assets().IsUpdating())
     {
-        UpdateTotals();
-
         const auto* pAsset = pGameContext.Assets().GetItemAt(nIndex);
         if (pAsset != nullptr)
             AddOrRemoveFilteredItem(*pAsset);
+
+        UpdateTotals();
     }
 }
 
 void AssetListViewModel::OnEndDataModelCollectionUpdate()
 {
-    UpdateTotals();
     ApplyFilter();
 }
 
@@ -277,14 +276,13 @@ void AssetListViewModel::UpdateTotals()
     int nAchievementCount = 0;
     int nTotalPoints = 0;
 
-    const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
-    for (gsl::index nIndex = 0; nIndex < gsl::narrow_cast<gsl::index>(pGameContext.Assets().Count()); ++nIndex)
+    for (gsl::index nIndex = 0; nIndex < gsl::narrow_cast<gsl::index>(m_vFilteredAssets.Count()); ++nIndex)
     {
-        auto* pAchievement = dynamic_cast<const ra::data::models::AchievementModel*>(pGameContext.Assets().GetItemAt(nIndex));
-        if (pAchievement != nullptr && IsTalliedAchievement(*pAchievement))
+        auto* pAsset = m_vFilteredAssets.GetItemAt(nIndex);
+        if (pAsset && pAsset->GetType() == ra::data::models::AssetType::Achievement)
         {
             ++nAchievementCount;
-            nTotalPoints += pAchievement->GetPoints();
+            nTotalPoints += pAsset->GetPoints();
         }
     }
 
@@ -342,6 +340,7 @@ void AssetListViewModel::ApplyFilter()
 
     m_vFilteredAssets.EndUpdate();
 
+    UpdateTotals();
     UpdateButtons();
 }
 
@@ -392,7 +391,21 @@ bool AssetListViewModel::MatchesFilter(const ra::data::models::AssetModelBase& p
     return true;
 }
 
-void AssetListViewModel::AddOrRemoveFilteredItem(const ra::data::models::AssetModelBase& pAsset)
+void AssetListViewModel::AddOrRemoveFilteredItem(gsl::index nAssetIndex)
+{
+    const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
+    const auto* pAsset = pGameContext.Assets().GetItemAt(nAssetIndex);
+    if (pAsset != nullptr)
+    {
+        if (AddOrRemoveFilteredItem(*pAsset))
+        {
+            UpdateTotals();
+            UpdateButtons();
+        }
+    }
+}
+
+bool AssetListViewModel::AddOrRemoveFilteredItem(const ra::data::models::AssetModelBase& pAsset)
 {
     const auto nIndex = GetFilteredAssetIndex(pAsset);
 
@@ -413,7 +426,7 @@ void AssetListViewModel::AddOrRemoveFilteredItem(const ra::data::models::AssetMo
                 pSummary->SetPoints(pAchievement->GetPoints());
 
             m_vFilteredAssets.Append(std::move(pSummary));
-            UpdateButtons();
+            return true;
         }
     }
     else
@@ -423,9 +436,11 @@ void AssetListViewModel::AddOrRemoveFilteredItem(const ra::data::models::AssetMo
             const bool bIsSelected = m_vFilteredAssets.GetItemValue(nIndex, AssetSummaryViewModel::IsSelectedProperty);
 
             m_vFilteredAssets.RemoveAt(nIndex);
-            UpdateButtons();
+            return true;
         }
     }
+
+    return false;
 }
 
 gsl::index AssetListViewModel::GetFilteredAssetIndex(const ra::data::models::AssetModelBase& pAsset) const
@@ -583,16 +598,10 @@ void AssetListViewModel::DoUpdateButtons()
                             break;
                     }
 
-                    switch (pItem->GetState())
-                    {
-                        case ra::data::models::AssetState::Inactive:
-                        case ra::data::models::AssetState::Triggered:
-                            bHasInactiveSelection = true;
-                            break;
-                        default:
-                            bHasActiveSelection = true;
-                            break;
-                    }
+                    if (ra::data::models::AssetModelBase::IsActive(pItem->GetState()))
+                        bHasActiveSelection = true;
+                    else
+                        bHasInactiveSelection = true;
 
                     switch (pItem->GetChanges())
                     {
@@ -1252,7 +1261,7 @@ void AssetListViewModel::CreateNew()
 
     FilteredAssets().EndUpdate();
 
-    // assert: only Core assets are tallied, so we shouldn't need to call UpdateTotals
+    UpdateTotals();
 
     SetValue(EnsureVisibleAssetIndexProperty, gsl::narrow_cast<int>(nIndex));
 
@@ -1296,7 +1305,6 @@ void AssetListViewModel::CloneSelected()
         vmAchievement.SetBadge(pSourceAchievement->GetBadge());
         vmAchievement.SetPoints(pSourceAchievement->GetPoints());
         vmAchievement.SetTrigger(pSourceAchievement->GetTrigger());
-        vmAchievement.UpdateServerCheckpoint();
         vmAchievement.SetNew();
 
         vNewIDs.push_back(vmAchievement.GetID());
@@ -1324,7 +1332,7 @@ void AssetListViewModel::CloneSelected()
 
     FilteredAssets().EndUpdate();
 
-    // assert: only Core assets are tallied, so we shouldn't need to call UpdateTotals
+    UpdateTotals();
 
     SetValue(EnsureVisibleAssetIndexProperty, gsl::narrow_cast<int>(m_vFilteredAssets.Count()) - 1);
 
diff --git a/src/ui/viewmodels/AssetListViewModel.hh b/src/ui/viewmodels/AssetListViewModel.hh
index 11411bb7..a87a7990 100644
--- a/src/ui/viewmodels/AssetListViewModel.hh
+++ b/src/ui/viewmodels/AssetListViewModel.hh
@@ -282,7 +282,8 @@ private:
     std::atomic_bool m_bNeedToUpdateButtons = false;
 
     bool MatchesFilter(const ra::data::models::AssetModelBase& pAsset);
-    void AddOrRemoveFilteredItem(const ra::data::models::AssetModelBase& pAsset);
+    void AddOrRemoveFilteredItem(gsl::index nAssetIndex);
+    bool AddOrRemoveFilteredItem(const ra::data::models::AssetModelBase& pAsset);
     gsl::index GetFilteredAssetIndex(const ra::data::models::AssetModelBase& pAsset) const;
     void ApplyFilter();
 
diff --git a/src/ui/viewmodels/MemorySearchViewModel.cpp b/src/ui/viewmodels/MemorySearchViewModel.cpp
index f9609355..287e5a17 100644
--- a/src/ui/viewmodels/MemorySearchViewModel.cpp
+++ b/src/ui/viewmodels/MemorySearchViewModel.cpp
@@ -348,7 +348,6 @@ void MemorySearchViewModel::DoFrame()
         return;
     }
 
-    m_bNeedsRedraw = false;
     m_vResults.BeginUpdate();
 
     const auto& pEmulatorContext = ra::services::ServiceLocator::Get<ra::data::context::EmulatorContext>();
@@ -372,13 +371,6 @@ void MemorySearchViewModel::DoFrame()
     m_vResults.EndUpdate();
 }
 
-bool MemorySearchViewModel::NeedsRedraw() noexcept
-{
-    const bool bNeedsRedraw = m_bNeedsRedraw;
-    m_bNeedsRedraw = false;
-    return bNeedsRedraw;
-}
-
 inline static constexpr auto ParseAddress(const wchar_t* ptr, ra::ByteAddress& address) noexcept
 {
     if (ptr == nullptr)
@@ -1026,18 +1018,6 @@ void MemorySearchViewModel::OnViewModelBoolValueChanged(gsl::index nIndex, const
     }
 }
 
-void MemorySearchViewModel::OnViewModelIntValueChanged(gsl::index, const IntModelProperty::ChangeArgs&) noexcept
-{
-    // assume color
-    m_bNeedsRedraw = true;
-}
-
-void MemorySearchViewModel::OnViewModelStringValueChanged(gsl::index, const StringModelProperty::ChangeArgs&) noexcept
-{
-    // assume text
-    m_bNeedsRedraw = true;
-}
-
 void MemorySearchViewModel::NextPage()
 {
     if (m_nSelectedSearchResult < m_vSearchResults.size() - 1)
diff --git a/src/ui/viewmodels/MemorySearchViewModel.hh b/src/ui/viewmodels/MemorySearchViewModel.hh
index 6dbba1fe..6fd90436 100644
--- a/src/ui/viewmodels/MemorySearchViewModel.hh
+++ b/src/ui/viewmodels/MemorySearchViewModel.hh
@@ -35,8 +35,6 @@ public:
 
     void DoFrame();
 
-    bool NeedsRedraw() noexcept;
-
     class PredefinedFilterRangeViewModel : public LookupItemViewModel
     {
     public:
@@ -452,8 +450,6 @@ protected:
 
     // ViewModelCollectionBase::NotifyTarget
     void OnViewModelBoolValueChanged(gsl::index nIndex, const BoolModelProperty::ChangeArgs& args) override;
-    void OnViewModelIntValueChanged(gsl::index nIndex, const IntModelProperty::ChangeArgs& args) noexcept override;
-    void OnViewModelStringValueChanged(gsl::index nIndex, const StringModelProperty::ChangeArgs& args) noexcept override;
 
     // EmulatorContext::NotifyTarget
     void OnTotalMemorySizeChanged() override;
@@ -482,7 +478,6 @@ private:
     ViewModelCollection<SearchResultViewModel> m_vResults;
     bool m_bIsContinuousFiltering = false;
     std::chrono::steady_clock::time_point m_tLastContinuousFilter;
-    bool m_bNeedsRedraw = false;
     bool m_bScrolling = false;
     bool m_bSelectingFilter = false;
 
diff --git a/src/ui/viewmodels/MemoryViewerViewModel.cpp b/src/ui/viewmodels/MemoryViewerViewModel.cpp
index a15dfd0b..ee5d7e14 100644
--- a/src/ui/viewmodels/MemoryViewerViewModel.cpp
+++ b/src/ui/viewmodels/MemoryViewerViewModel.cpp
@@ -16,6 +16,7 @@ const IntModelProperty MemoryViewerViewModel::AddressProperty("MemoryViewerViewM
 const IntModelProperty MemoryViewerViewModel::FirstAddressProperty("MemoryViewerViewModel", "FirstAddress", 0);
 const IntModelProperty MemoryViewerViewModel::NumVisibleLinesProperty("MemoryViewerViewModel", "NumVisibleLines", 8);
 const IntModelProperty MemoryViewerViewModel::SizeProperty("MemoryViewerViewModel", "Size", ra::etoi(MemSize::EightBit));
+const IntModelProperty MemoryViewerViewModel::PendingAddressProperty("MemoryViewerViewModel", "PendingAddress", 0);
 
 constexpr uint8_t STALE_COLOR = 0x80;
 constexpr uint8_t HIGHLIGHTED_COLOR = STALE_COLOR | gsl::narrow_cast<uint8_t>(ra::etoi(MemoryViewerViewModel::TextColor::Selected));
@@ -543,10 +544,32 @@ void MemoryViewerViewModel::OnCodeNoteChanged(ra::ByteAddress nAddress, const st
 void MemoryViewerViewModel::OnTotalMemorySizeChanged()
 {
     const auto& pEmulatorContext = ra::services::ServiceLocator::Get<ra::data::context::EmulatorContext>();
-    m_nTotalMemorySize = gsl::narrow<ra::ByteAddress>(pEmulatorContext.TotalMemorySize());
+    const bool bTotalMemorySizeWasZero = (m_nTotalMemorySize == 0);
+    m_nTotalMemorySize = gsl::narrow_cast<ra::ByteAddress>(pEmulatorContext.TotalMemorySize());
 
-    if (GetAddress() >= m_nTotalMemorySize)
-        SetAddress(m_nTotalMemorySize - 1);
+    if (m_nTotalMemorySize == 0)
+    {
+        // size changing to 0, clear out addresses
+        SetValue(AddressProperty, AddressProperty.GetDefaultValue());
+        SetValue(PendingAddressProperty, PendingAddressProperty.GetDefaultValue());
+    }
+    else
+    {
+        if (bTotalMemorySizeWasZero)
+        {
+            // size changing from 0, see if a memory address was requested
+            const auto nPendingAddress = gsl::narrow_cast<ra::ByteAddress>(GetValue(PendingAddressProperty));
+            if (nPendingAddress > 0)
+            {
+                SetValue(PendingAddressProperty, PendingAddressProperty.GetDefaultValue());
+                SetAddress(nPendingAddress);
+            }
+        }
+
+        // make sure the current memory address is valid
+        if (GetAddress() >= m_nTotalMemorySize)
+            SetValue(AddressProperty, gsl::narrow_cast<int>(m_nTotalMemorySize - 1));
+    }
 
     const auto nVisibleLines = GetNumVisibleLines();
     if (m_nTotalMemorySize < ra::to_unsigned(nVisibleLines * 16))
diff --git a/src/ui/viewmodels/MemoryViewerViewModel.hh b/src/ui/viewmodels/MemoryViewerViewModel.hh
index f8ad1f48..05ee01eb 100644
--- a/src/ui/viewmodels/MemoryViewerViewModel.hh
+++ b/src/ui/viewmodels/MemoryViewerViewModel.hh
@@ -95,12 +95,19 @@ public:
     void SetAddress(ByteAddress value)
     {
         int nSignedValue = ra::to_signed(value);
-        if (nSignedValue < 0 || m_nTotalMemorySize == 0)
-            nSignedValue = 0;
-        else if (nSignedValue >= gsl::narrow_cast<int>(m_nTotalMemorySize))
-            nSignedValue = gsl::narrow_cast<int>(m_nTotalMemorySize) - 1;
-
-        SetValue(AddressProperty, nSignedValue);
+        if (m_nTotalMemorySize > 0)
+        {
+            if (nSignedValue < 0)
+                nSignedValue = 0;
+            else if (nSignedValue >= gsl::narrow_cast<int>(m_nTotalMemorySize))
+                nSignedValue = gsl::narrow_cast<int>(m_nTotalMemorySize) - 1;
+
+            SetValue(AddressProperty, nSignedValue);
+        }
+        else
+        {
+            SetValue(PendingAddressProperty, gsl::narrow_cast<int>(value));
+        }
     }
 
     /// <summary>
@@ -201,6 +208,8 @@ protected:
     static constexpr int MaxLines = 128;
 
 private:
+    static const IntModelProperty PendingAddressProperty;
+
     void BuildFontSurface();
     void RenderAddresses();
     void RenderHeader();
diff --git a/src/ui/viewmodels/OverlayManager.cpp b/src/ui/viewmodels/OverlayManager.cpp
index 998814a2..be3612ed 100644
--- a/src/ui/viewmodels/OverlayManager.cpp
+++ b/src/ui/viewmodels/OverlayManager.cpp
@@ -251,6 +251,15 @@ void OverlayManager::QueueScoreboard(ra::LeaderboardID nLeaderboardId, Scoreboar
 ChallengeIndicatorViewModel& OverlayManager::AddChallengeIndicator(ra::AchievementID nAchievementId,
     ra::ui::ImageType imageType, const std::string& sImageName)
 {
+    for (auto pIter = m_vChallengeIndicators.begin(); pIter != m_vChallengeIndicators.end(); ++pIter)
+    {
+        if (ra::to_unsigned((*pIter)->GetPopupId()) == nAchievementId)
+        {
+            // already showing indicator for achievement, just return it
+            return **pIter;
+        }
+    }
+
     auto pChallengeIndicator = std::make_unique<ChallengeIndicatorViewModel>();
     pChallengeIndicator->SetPopupId(nAchievementId);
     pChallengeIndicator->SetImage(imageType, sImageName);
diff --git a/src/ui/viewmodels/TriggerConditionViewModel.cpp b/src/ui/viewmodels/TriggerConditionViewModel.cpp
index 264e4fca..32e64492 100644
--- a/src/ui/viewmodels/TriggerConditionViewModel.cpp
+++ b/src/ui/viewmodels/TriggerConditionViewModel.cpp
@@ -260,11 +260,29 @@ void TriggerConditionViewModel::OnValueChanged(const IntModelProperty::ChangeArg
 {
     if (args.Property == SourceTypeProperty)
     {
-        SetValue(HasSourceSizeProperty, ra::itoe<TriggerOperandType>(args.tNewValue) != TriggerOperandType::Value);
+        if (ra::itoe<TriggerOperandType>(args.tNewValue) == TriggerOperandType::Value)
+        {
+            SetValue(HasSourceSizeProperty, false);
+            SetSourceSize(MemSize::ThirtyTwoBit);
+        }
+        else if (ra::itoe<TriggerOperandType>(args.tOldValue) == TriggerOperandType::Value)
+        {
+            SetSourceSize(GetTargetSize());
+            SetValue(HasSourceSizeProperty, true);
+        }
     }
     else if (args.Property == TargetTypeProperty)
     {
-        SetValue(HasTargetSizeProperty, ra::itoe<TriggerOperandType>(args.tNewValue) != TriggerOperandType::Value && GetValue(HasTargetProperty));
+        if (ra::itoe<TriggerOperandType>(args.tNewValue) == TriggerOperandType::Value)
+        {
+            SetValue(HasTargetSizeProperty, false);
+            SetTargetSize(MemSize::ThirtyTwoBit);
+        }
+        else if (ra::itoe<TriggerOperandType>(args.tOldValue) == TriggerOperandType::Value)
+        {
+            SetTargetSize(GetSourceSize());
+            SetValue(HasTargetSizeProperty, GetValue(HasTargetProperty));
+        }
     }
     else if (args.Property == OperatorProperty)
     {
diff --git a/src/ui/viewmodels/TriggerViewModel.cpp b/src/ui/viewmodels/TriggerViewModel.cpp
index 121194a5..2d90049c 100644
--- a/src/ui/viewmodels/TriggerViewModel.cpp
+++ b/src/ui/viewmodels/TriggerViewModel.cpp
@@ -113,17 +113,22 @@ void TriggerViewModel::GroupViewModel::UpdateSerialized(std::string& sSerialized
 
 const std::string& TriggerViewModel::GroupViewModel::GetSerialized() const
 {
-    if (m_sSerialized.empty() && m_pConditionSet)
+    if (m_sSerialized == TriggerViewModel::GroupViewModel::NOT_SERIALIZED)
     {
-        ViewModelCollection<TriggerConditionViewModel> vConditions;
-        rc_condition_t* pCondition = m_pConditionSet->conditions;
-        for (; pCondition != nullptr; pCondition = pCondition->next)
+        m_sSerialized.clear();
+
+        if (m_pConditionSet)
         {
-            auto& vmCondition = vConditions.Add();
-            vmCondition.InitializeFrom(*pCondition);
-        }
+            ViewModelCollection<TriggerConditionViewModel> vConditions;
+            rc_condition_t* pCondition = m_pConditionSet->conditions;
+            for (; pCondition != nullptr; pCondition = pCondition->next)
+            {
+                auto& vmCondition = vConditions.Add();
+                vmCondition.InitializeFrom(*pCondition);
+            }
 
-        UpdateSerialized(m_sSerialized, vConditions);
+            UpdateSerialized(m_sSerialized, vConditions);
+        }
     }
 
     return m_sSerialized;
diff --git a/src/ui/viewmodels/TriggerViewModel.hh b/src/ui/viewmodels/TriggerViewModel.hh
index 5d530eaa..9c7c35a2 100644
--- a/src/ui/viewmodels/TriggerViewModel.hh
+++ b/src/ui/viewmodels/TriggerViewModel.hh
@@ -42,7 +42,7 @@ public:
 
         bool UpdateSerialized(const ViewModelCollection<TriggerConditionViewModel>& vConditions);
         const std::string& GetSerialized() const;
-        void ResetSerialized() noexcept { m_sSerialized.clear(); }
+        void ResetSerialized() { m_sSerialized = NOT_SERIALIZED; }
 
         static const IntModelProperty ColorProperty;
         Color GetColor() const { return Color(ra::to_unsigned(GetValue(ColorProperty))); }
@@ -50,7 +50,8 @@ public:
 
     private:
         static void UpdateSerialized(std::string& sSerialized, const ViewModelCollection<TriggerConditionViewModel>& vConditions);
-        mutable std::string m_sSerialized;
+        mutable std::string m_sSerialized = NOT_SERIALIZED;
+        static constexpr const char* NOT_SERIALIZED = "?";
     };
 
     ViewModelCollection<GroupViewModel>& Groups() noexcept { return m_vGroups; }
diff --git a/src/ui/win32/AssetEditorDialog.cpp b/src/ui/win32/AssetEditorDialog.cpp
index a86baf47..b7db339b 100644
--- a/src/ui/win32/AssetEditorDialog.cpp
+++ b/src/ui/win32/AssetEditorDialog.cpp
@@ -134,14 +134,14 @@ public:
 
         const auto& pConfiguration = ra::services::ServiceLocator::Get<ra::services::IConfiguration>();
         if (pConfiguration.IsFeatureEnabled(ra::services::Feature::PreferDecimal))
-            return std::to_wstring(nValue);
+            return std::to_wstring(ra::to_unsigned(nValue));
 
         return ra::StringPrintf(L"0x%02x", nValue);
     }
 
     bool SetText(ra::ui::ViewModelCollectionBase& vmItems, gsl::index nIndex, const std::wstring& sValue) override
     {
-        if (ra::StringStartsWith(sValue, L"0x"))
+        if (ra::StringStartsWith(sValue, L"0x") || IsAddressType(vmItems, nIndex))
         {
             std::wstring sError;
             unsigned int nValue = 0U;
diff --git a/src/ui/win32/MemoryInspectorDialog.cpp b/src/ui/win32/MemoryInspectorDialog.cpp
index ea52813b..061086da 100644
--- a/src/ui/win32/MemoryInspectorDialog.cpp
+++ b/src/ui/win32/MemoryInspectorDialog.cpp
@@ -48,16 +48,6 @@ void MemoryInspectorDialog::Presenter::OnClosed() noexcept { m_pDialog.reset();
 
 // ------------------------------------
 
-void MemoryInspectorDialog::SearchResultsGridBinding::Invalidate()
-{
-    if (GetViewModel<MemorySearchViewModel>().NeedsRedraw())
-    {
-        GridBinding::Invalidate();
-
-        ControlBinding::ForceRepaint(m_hWnd);
-    }
-}
-
 void MemoryInspectorDialog::SearchResultsGridBinding::OnLvnItemChanged(const LPNMLISTVIEW pnmListView)
 {
     GridBinding::OnLvnItemChanged(pnmListView);
diff --git a/src/ui/win32/MemoryInspectorDialog.hh b/src/ui/win32/MemoryInspectorDialog.hh
index 4ee4bd63..4ed772c6 100644
--- a/src/ui/win32/MemoryInspectorDialog.hh
+++ b/src/ui/win32/MemoryInspectorDialog.hh
@@ -66,9 +66,6 @@ private:
         void OnLvnItemChanged(const LPNMLISTVIEW pnmListView) override;
 
         void OnViewModelIntValueChanged(const IntModelProperty::ChangeArgs& args) override;
-
-    protected:
-        void Invalidate() override;
     };
 
     SearchResultsGridBinding m_bindSearchResults;
diff --git a/src/ui/win32/bindings/ControlBinding.cpp b/src/ui/win32/bindings/ControlBinding.cpp
index 9b4669aa..a064aa48 100644
--- a/src/ui/win32/bindings/ControlBinding.cpp
+++ b/src/ui/win32/bindings/ControlBinding.cpp
@@ -31,13 +31,18 @@ ControlBinding::~ControlBinding() noexcept
 }
 
 void ControlBinding::ForceRepaint(HWND hWnd)
+{
+    ::InvalidateRect(hWnd, nullptr, FALSE);
+
+    if (NeedsUpdateWindow())
+        RedrawWindow(hWnd);
+}
+
+void ControlBinding::RedrawWindow(HWND hWnd)
 {
     if (g_nSuspendRepaintCount == 0)
     {
-        ::InvalidateRect(hWnd, nullptr, FALSE);
-
-        if (NeedsUpdateWindow())
-            ::UpdateWindow(hWnd);
+        ::UpdateWindow(hWnd);
     }
     else
     {
@@ -65,13 +70,7 @@ void ControlBinding::ResumeRepaint()
         if (!vSuspendedRepaintWnds.empty())
         {
             for (HWND hWnd : vSuspendedRepaintWnds)
-                ::InvalidateRect(hWnd, nullptr, FALSE);
-
-            if (NeedsUpdateWindow())
-            {
-                for (HWND hWnd : vSuspendedRepaintWnds)
-                    ::UpdateWindow(hWnd);
-            }
+                ::UpdateWindow(hWnd);
         }
     }
     else
diff --git a/src/ui/win32/bindings/ControlBinding.hh b/src/ui/win32/bindings/ControlBinding.hh
index 50e9d320..d8cfb793 100644
--- a/src/ui/win32/bindings/ControlBinding.hh
+++ b/src/ui/win32/bindings/ControlBinding.hh
@@ -97,6 +97,11 @@ public:
     /// </summary>
     static void ForceRepaint(HWND hWnd);
 
+    /// <summary>
+    /// Calls UpdateWindow() to cause the control to repaint
+    /// </summary>
+    static void RedrawWindow(HWND hWnd);
+
     /// <summary>
     /// Delays ForceRepaint calls until resumed.
     /// </summary>
diff --git a/src/ui/win32/bindings/GridBinding.cpp b/src/ui/win32/bindings/GridBinding.cpp
index da1f3744..bbe243be 100644
--- a/src/ui/win32/bindings/GridBinding.cpp
+++ b/src/ui/win32/bindings/GridBinding.cpp
@@ -298,7 +298,10 @@ void GridBinding::OnViewModelIntValueChanged(gsl::index nIndex, const IntModelPr
     if (m_pRowColorProperty && *m_pRowColorProperty == args.Property)
     {
         if (m_nAdjustingScrollOffset == 0)
+        {
             ListView_RedrawItems(m_hWnd, nIndex, nIndex);
+            m_bForceRepaintItems = true;
+        }
         return;
     }
 
@@ -323,7 +326,8 @@ void GridBinding::OnViewModelIntValueChanged(gsl::index nIndex, const IntModelPr
             GSL_SUPPRESS_TYPE1
             SNDMSG(m_hWnd, LVM_SETITEMW, 0, reinterpret_cast<LPARAM>(&item));
 
-            m_bForceRepaint = true;
+            ListView_RedrawItems(m_hWnd, nIndex, nIndex);
+            m_bForceRepaintItems = true;
         }
     }
 }
@@ -357,7 +361,8 @@ void GridBinding::OnViewModelBoolValueChanged(gsl::index nIndex, const BoolModel
             GSL_SUPPRESS_TYPE1
             SNDMSG(m_hWnd, LVM_SETITEMW, 0, reinterpret_cast<LPARAM>(&item));
 
-            m_bForceRepaint = true;
+            ListView_RedrawItems(m_hWnd, nIndex, nIndex);
+            m_bForceRepaintItems = true;
         }
     }
 }
@@ -388,7 +393,8 @@ void GridBinding::OnViewModelStringValueChanged(gsl::index nIndex, const StringM
             GSL_SUPPRESS_TYPE1
             SNDMSG(m_hWnd, LVM_SETITEMW, 0, reinterpret_cast<LPARAM>(&item));
 
-            m_bForceRepaint = true;
+            ListView_RedrawItems(m_hWnd, nIndex, nIndex);
+            m_bForceRepaintItems = true;
         }
     }
 }
@@ -536,31 +542,35 @@ void GridBinding::CheckForScrollBar()
 void GridBinding::OnBeginViewModelCollectionUpdate() noexcept
 {
     m_bForceRepaint = false;
-
-    if (m_hWnd)
-        SendMessage(m_hWnd, WM_SETREDRAW, FALSE, 0);
+    m_bForceRepaintItems = false;
 }
 
 void GridBinding::OnEndViewModelCollectionUpdate()
 {
     if (m_hWnd)
     {
-        // enable redraw before calling CheckForScrollBar to ensure metrics are updated
-        SendMessage(m_hWnd, WM_SETREDRAW, TRUE, 0);
-
         CheckForScrollBar();
 
-        UpdateSelectedItemStates();
-
-        if (m_bForceRepaint && m_nAdjustingScrollOffset == 0)
+        if (m_bUpdateSelectedItemStates)
         {
-            m_bForceRepaint = false;
-
-            ControlBinding::ForceRepaint(m_hWnd);
+            m_bUpdateSelectedItemStates = false;
+            UpdateSelectedItemStates();
         }
-        else
+
+        if (m_nAdjustingScrollOffset == 0)
         {
-            Invalidate();
+            if (m_bForceRepaint)
+            {
+                m_bForceRepaint = false;
+                m_bForceRepaintItems = false;
+
+                ControlBinding::ForceRepaint(m_hWnd);
+            }
+            else if (m_bForceRepaintItems)
+            {
+                m_bForceRepaintItems = false;
+                ControlBinding::RedrawWindow(m_hWnd);
+            }
         }
     }
 }
@@ -577,11 +587,6 @@ void GridBinding::UpdateSelectedItemStates()
     }
 }
 
-void GridBinding::Invalidate()
-{
-    InvalidateRect(m_hWnd, nullptr, FALSE);
-}
-
 void GridBinding::BindIsSelected(const BoolModelProperty& pIsSelectedProperty)
 {
     m_pIsSelectedProperty = &pIsSelectedProperty;
@@ -659,34 +664,37 @@ INT_PTR CALLBACK GridBinding::WndProc(HWND hControl, UINT uMsg, WPARAM wParam, L
     switch (uMsg)
     {
         case WM_MOUSEMOVE:
-            LVHITTESTINFO lvHitTestInfo;
-            GetCursorPos(&lvHitTestInfo.pt);
-            ScreenToClient(m_hWnd, &lvHitTestInfo.pt);
-
-            int nTooltipLocation = -1;
-            if (ListView_SubItemHitTest(m_hWnd, &lvHitTestInfo) != -1)
-                nTooltipLocation = lvHitTestInfo.iItem * 256 + lvHitTestInfo.iSubItem;
-
-            // if the mouse has moved to a new grid cell, hide the toolip
-            if (nTooltipLocation != m_nTooltipLocation)
+            if (m_hTooltip)
             {
-                m_nTooltipLocation = nTooltipLocation;
-                m_sTooltip.clear();
+                LVHITTESTINFO lvHitTestInfo;
+                GetCursorPos(&lvHitTestInfo.pt);
+                ScreenToClient(m_hWnd, &lvHitTestInfo.pt);
 
-                if (IsWindowVisible(m_hTooltip))
-                {
-                    // tooltip is visible, just hide it
-                    SendMessage(m_hTooltip, TTM_POP, 0, 0);
-                }
-                else
+                int nTooltipLocation = -1;
+                if (ListView_SubItemHitTest(m_hWnd, &lvHitTestInfo) != -1)
+                    nTooltipLocation = lvHitTestInfo.iItem * 256 + lvHitTestInfo.iSubItem;
+
+                // if the mouse has moved to a new grid cell, hide the toolip
+                if (nTooltipLocation != m_nTooltipLocation)
                 {
-                    // tooltip is not visible. if we told the tooltip to not display in the TTM_GETDISPINFO handler by
-                    // setting lpszText to empty string, it won't try to display again unless we tell it to, so do so now
-                    SendMessage(m_hTooltip, TTM_POPUP, 0, 0);
+                    m_nTooltipLocation = nTooltipLocation;
+                    m_sTooltip.clear();
+
+                    if (IsWindowVisible(m_hTooltip))
+                    {
+                        // tooltip is visible, just hide it
+                        SendMessage(m_hTooltip, TTM_POP, 0, 0);
+                    }
+                    else
+                    {
+                        // tooltip is not visible. if we told the tooltip to not display in the TTM_GETDISPINFO handler by
+                        // setting lpszText to empty string, it won't try to display again unless we tell it to, so do so now
+                        SendMessage(m_hTooltip, TTM_POPUP, 0, 0);
 
-                    // but we don't want the tooltip to immediately display when entering a new cell, so immediately
-                    // hide it. the normal hover behavior will make it display as expected after a normal hover wait time.
-                    SendMessage(m_hTooltip, TTM_POP, 0, 0);
+                        // but we don't want the tooltip to immediately display when entering a new cell, so immediately
+                        // hide it. the normal hover behavior will make it display as expected after a normal hover wait time.
+                        SendMessage(m_hTooltip, TTM_POP, 0, 0);
+                    }
                 }
             }
             break;
@@ -791,6 +799,10 @@ void GridBinding::OnLvnItemChanged(const LPNMLISTVIEW pnmListView)
             else if (pnmListView->uOldState & LVIS_SELECTED)
                 m_vmItems->SetItemValue(nIndex, *m_pIsSelectedProperty, false);
         }
+        else
+        {
+            m_bUpdateSelectedItemStates = true;
+        }
     }
 
     switch (pnmListView->uNewState & 0x3000)
@@ -1259,7 +1271,23 @@ void GridBinding::OnLvnKeyDown(const LPNMLVKEYDOWN pnmKeyDown)
     // only interested in Ctrl key events
     const bool bControlHeld = (GetKeyState(VK_CONTROL) < 0);
     if (!bControlHeld)
+    {
+        // if Enter is pushed, treat it as a double-click on the first selected item
+        if (pnmKeyDown->wVKey == VK_RETURN && m_pDoubleClickHandler)
+        {
+            // ignore Alt && Shift
+            const bool bAltHeld = (GetKeyState(VK_MENU) < 0);
+            const bool bShiftHeld = (GetKeyState(VK_SHIFT) < 0);
+            if (bAltHeld || bShiftHeld)
+                return;
+
+            const int nFirstSelectedItem = ListView_GetNextItem(m_hWnd, 0, LVNI_SELECTED);
+            if (nFirstSelectedItem != -1)
+                m_pDoubleClickHandler(gsl::narrow_cast<gsl::index>(nFirstSelectedItem));
+        }
+
         return;
+    }
 
     // ignore Ctrl+Alt
     const bool bAltHeld = (GetKeyState(VK_MENU) < 0);
diff --git a/src/ui/win32/bindings/GridBinding.hh b/src/ui/win32/bindings/GridBinding.hh
index 8df66a6d..789741ac 100644
--- a/src/ui/win32/bindings/GridBinding.hh
+++ b/src/ui/win32/bindings/GridBinding.hh
@@ -80,7 +80,6 @@ protected:
     void CheckForScrollBar();
     int GetVisibleItemIndex(int iItem);
     gsl::index GetRealItemIndex(gsl::index iItem) const;
-    virtual void Invalidate() noexcept(false);
 
     INT_PTR CALLBACK WndProc(HWND hControl, UINT uMsg, WPARAM wParam, LPARAM lParam) override;
 
@@ -112,6 +111,8 @@ private:
     bool m_bShowGridLines = false;
     bool m_bHasScrollbar = false;
     bool m_bForceRepaint = false;
+    bool m_bForceRepaintItems = false;
+    bool m_bUpdateSelectedItemStates = false;
     int m_nAdjustingScrollOffset = 0;
 
     size_t m_nColumnsCreated = 0;


Test patch:
diff --git a/tests/api/ConnectedServer_Tests.cpp b/tests/api/ConnectedServer_Tests.cpp
index e828b696..c934dc6f 100644
--- a/tests/api/ConnectedServer_Tests.cpp
+++ b/tests/api/ConnectedServer_Tests.cpp
@@ -7,6 +7,7 @@
 #include "tests\RA_UnitTestHelpers.h"
 #include "tests\api\ApiAsserts.hh"
 #include "tests\mocks\MockHttpRequester.hh"
+#include "tests\mocks\MockLocalStorage.hh"
 #include "tests\mocks\MockServer.hh"
 #include "tests\mocks\MockThreadPool.hh"
 #include "tests\mocks\MockUserContext.hh"
@@ -17,6 +18,7 @@ using ra::api::impl::ConnectedServer;
 using ra::api::mocks::MockServer;
 using ra::data::context::mocks::MockUserContext;
 using ra::services::mocks::MockHttpRequester;
+using ra::services::mocks::MockLocalStorage;
 using ra::services::mocks::MockThreadPool;
 using ra::services::Http;
 
@@ -351,7 +353,7 @@ public:
         auto response = server.Login(request);
 
         Assert::AreEqual(ApiResult::Error, response.Result);
-        Assert::AreEqual(std::string("JSON Parse Error: Invalid value. (at 0)"), response.ErrorMessage);
+        Assert::AreEqual(std::string("JSON Parse Error: Invalid JSON"), response.ErrorMessage);
         Assert::AreEqual(std::string(""), response.Username);
         Assert::AreEqual(std::string(""), response.ApiToken);
         Assert::AreEqual(0U, response.Score);
@@ -375,7 +377,7 @@ public:
 
         // only the first missing required field is reported
         Assert::AreEqual(ApiResult::Error, response.Result);
-        Assert::AreEqual(std::string("User not found in response"), response.ErrorMessage);
+        Assert::AreEqual(std::string("JSON Parse Error: Missing value expression"), response.ErrorMessage);
         Assert::AreEqual(std::string(""), response.Username);
         Assert::AreEqual(std::string(""), response.ApiToken);
         Assert::AreEqual(0U, response.Score);
@@ -390,10 +392,8 @@ public:
 
     TEST_METHOD(TestLoginPasswordSuccess)
     {
-        MockHttpRequester mockHttp([](const Http::Request& request)
+        MockHttpRequester mockHttp([](const Http::Request&)
         {
-            Assert::AreEqual(std::string("host.com/login_app.php"), request.GetUrl());
-            Assert::AreEqual(std::string("u=User&p=pa%24%24w0rd"), request.GetPostData());
             return Http::Response(Http::StatusCode::OK, "{\"Success\":true,\"User\":\"User\",\"Token\":\"ApiTOKEN\",\"Score\":1234,\"Messages\":2}");
         });
 
@@ -415,10 +415,8 @@ public:
 
     TEST_METHOD(TestLoginTokenSuccess)
     {
-        MockHttpRequester mockHttp([](const Http::Request& request)
+        MockHttpRequester mockHttp([](const Http::Request&)
         {
-            Assert::AreEqual(std::string("host.com/login_app.php"), request.GetUrl());
-            Assert::AreEqual(std::string("u=User&t=ApiTOKEN"), request.GetPostData());
             return Http::Response(Http::StatusCode::OK, "{\"Success\":true,\"User\":\"User\",\"Token\":\"ApiTOKEN\",\"Score\":1234,\"Messages\":2}");
         });
 
@@ -515,10 +513,8 @@ public:
         MockUserContext mockUserContext;
         mockUserContext.Initialize("Username", "ApiToken");
 
-        MockHttpRequester mockHttp([](const Http::Request& request)
+        MockHttpRequester mockHttp([](const Http::Request&)
         {
-            Assert::AreEqual(std::string("host.com/dorequest.php"), request.GetUrl());
-            Assert::AreEqual(std::string("u=Username&r=submitlbentry&i=234&s=-55667788&v=82e403f7ff0395469330977dbe46f2a3&t=ApiToken"), request.GetPostData());
             return Http::Response(Http::StatusCode::OK,
                 "{\"Success\":true,\"Response\":{\"Score\":1234,\"BestScore\":2345,"
                 "\"TopEntries\":[{\"User\":\"Player1\",\"Score\":8765,\"Rank\":1},{\"User\":\"Player2\",\"Score\":7654,\"Rank\":2}],"
@@ -536,6 +532,38 @@ public:
         Assert::AreEqual(ApiResult::Success, response.Result);
         Assert::AreEqual(std::string(), response.ErrorMessage);
     }
+
+    // ====================================================
+    // FetchGameData
+
+    TEST_METHOD(TestFetchGameDataCachesPatchData)
+    {
+        MockUserContext mockUserContext;
+        mockUserContext.Initialize("Username", "ApiToken");
+
+        MockHttpRequester mockHttp([](const Http::Request&)
+        {
+            return Http::Response(Http::StatusCode::OK,
+                "{\"Success\":true,\"PatchData\":"
+                    "{\"ID\":99, \"Title\":\"Game Name\", \"ConsoleID\":5, \"ImageIcon\":\"/Images/BADGE.png\", \"Achievements\":[], \"Leaderboards\":[]}"
+                "}");
+        });
+
+        MockLocalStorage mockLocalStorage;
+
+        ra::services::ServiceLocator::ServiceOverride<ra::api::IServer> serviceOverride(new ConnectedServer("host.com"), true);
+        auto& server = ra::services::ServiceLocator::GetMutable<ra::api::IServer>();
+
+        FetchGameData::Request request;
+        request.GameId = 99;
+        auto response = server.FetchGameData(request);
+
+        Assert::AreEqual(ApiResult::Success, response.Result);
+        Assert::AreEqual(std::string(), response.ErrorMessage);
+
+        std::string sPatchData = "{\"ID\":99, \"Title\":\"Game Name\", \"ConsoleID\":5, \"ImageIcon\":\"/Images/BADGE.png\", \"Achievements\":[], \"Leaderboards\":[]}";
+        Assert::AreEqual(sPatchData, mockLocalStorage.GetStoredData(ra::services::StorageItemType::GameData, L"99"));
+    }
 };
 
 } // namespace tests
diff --git a/tests/api/DisconnectedServer_Tests.cpp b/tests/api/DisconnectedServer_Tests.cpp
index 0829bd03..b4430254 100644
--- a/tests/api/DisconnectedServer_Tests.cpp
+++ b/tests/api/DisconnectedServer_Tests.cpp
@@ -27,10 +27,8 @@ public:
         ra::services::ServiceLocator::ServiceOverride<ra::api::IServer> serviceOverride(new DisconnectedServer("host.com"), true);
         auto& server = ra::services::ServiceLocator::GetMutable<ra::api::IServer>();
 
-        MockHttpRequester mockHttp([](const Http::Request& request)
+        MockHttpRequester mockHttp([](const Http::Request&)
         {
-            Assert::AreEqual(std::string("host.com/login_app.php"), request.GetUrl());
-            Assert::AreEqual(std::string("u=User&p=pa%24%24w0rd"), request.GetPostData());
             return Http::Response(Http::StatusCode::OK, "{\"Success\":true,\"User\":\"User\",\"Token\":\"ApiTOKEN\",\"Score\":1234,\"Messages\":2}");
         });
 
diff --git a/tests/data/context/EmulatorContext_Tests.cpp b/tests/data/context/EmulatorContext_Tests.cpp
index 2472274a..9d21af52 100644
--- a/tests/data/context/EmulatorContext_Tests.cpp
+++ b/tests/data/context/EmulatorContext_Tests.cpp
@@ -1146,6 +1146,7 @@ public:
         EmulatorContextHarness emulator;
         emulator.AddMemoryBlock(0, 20, &ReadMemory0, &WriteMemory0);
 
+        // ReadMemory calls ReadMemoryByte for small sizes
         Assert::AreEqual(0, static_cast<int>(emulator.ReadMemory(4U, MemSize::Bit_0)));
         Assert::AreEqual(0, static_cast<int>(emulator.ReadMemory(4U, MemSize::Bit_1)));
         Assert::AreEqual(0, static_cast<int>(emulator.ReadMemory(4U, MemSize::Bit_2)));
@@ -1178,6 +1179,36 @@ public:
         Assert::AreEqual(0x57, static_cast<int>(emulator.ReadMemory(4U, MemSize::EightBit)));
     }
 
+    TEST_METHOD(TestReadMemoryByteInvalidAddressDisablesAchievement)
+    {
+        memory.at(4) = 0xA8;
+        memory.at(5) = 0x00;
+        memory.at(6) = 0x37;
+        memory.at(7) = 0x2E;
+
+        EmulatorContextHarness emulator;
+        emulator.mockAchievementRuntime.ActivateAchievement(1, "0xH0004=1_0xH0044=1");
+        Assert::IsTrue(emulator.mockAchievementRuntime.IsAchievementSupported(1));
+
+        // memory has not yet been registered. achievement should not be disabled [client may register memory later]
+        Assert::AreEqual(0x00, static_cast<int>(emulator.ReadMemory(4U, MemSize::EightBit)));
+        Assert::IsTrue(emulator.mockAchievementRuntime.IsAchievementSupported(1));
+
+        // valid memory registered. achievement should not be disabled
+        emulator.AddMemoryBlock(0, 20, &ReadMemory0, &WriteMemory0);
+        Assert::AreEqual(0xA8, static_cast<int>(emulator.ReadMemory(4U, MemSize::EightBit)));
+        Assert::IsTrue(emulator.mockAchievementRuntime.IsAchievementSupported(1));
+
+        // address not in supported memory range, achievement should not be disabled [indirect memory address]
+        Assert::AreEqual(0x00, static_cast<int>(emulator.ReadMemory(68U, MemSize::EightBit)));
+        Assert::IsTrue(emulator.mockAchievementRuntime.IsAchievementSupported(1));
+
+        // invalid memory region registered, address not supported. achievement should be disabled
+        emulator.AddMemoryBlock(1, 80, nullptr, nullptr);
+        Assert::AreEqual(0x00, static_cast<int>(emulator.ReadMemory(68U, MemSize::EightBit)));
+        Assert::IsFalse(emulator.mockAchievementRuntime.IsAchievementSupported(1));
+    }
+
     TEST_METHOD(TestReadMemoryBuffer)
     {
         InitializeMemory();
diff --git a/tests/data/context/GameContext_Tests.cpp b/tests/data/context/GameContext_Tests.cpp
index ac154c17..a9fa35f6 100644
--- a/tests/data/context/GameContext_Tests.cpp
+++ b/tests/data/context/GameContext_Tests.cpp
@@ -561,14 +561,14 @@ public:
             lb1.Title = "LB1";
             lb1.Description = "Desc1";
             lb1.Definition = "STA:1=1::CAN:1=1::SUB:1=1::VAL:1";
-            lb1.Format = "SECS";
+            lb1.Format = RC_FORMAT_SECONDS;
 
             auto& lb2 = response.Leaderboards.emplace_back();
             lb2.Id = 8U;
             lb2.Title = "LB2";
             lb2.Description = "Desc2";
             lb2.Definition = "STA:1=1::CAN:1=1::SUB:1=1::VAL:1";
-            lb2.Format = "FRAMES";
+            lb2.Format = RC_FORMAT_FRAMES;
 
             return true;
         });
@@ -937,6 +937,43 @@ public:
         Assert::IsTrue(game.IsRichPresenceFromFile());
     }
 
+    TEST_METHOD(TestReloadRichPresenceScriptBOM)
+    {
+        GameContextHarness game;
+        game.mockServer.HandleRequest<ra::api::FetchGameData>([](const ra::api::FetchGameData::Request&, ra::api::FetchGameData::Response& response)
+        {
+            response.RichPresence = "Display:\nHello, World";
+            return true;
+        });
+
+        game.LoadGame(1U);
+
+        /* load game will write the server RP to storage, so overwrite it now */
+        std::string sFileContents = "...Display:\nFrom File";
+        sFileContents.at(0) = gsl::narrow_cast<char>(0xef);
+        sFileContents.at(1) = gsl::narrow_cast<char>(0xbb);
+        sFileContents.at(2) = gsl::narrow_cast<char>(0xbf);
+
+        game.mockStorage.MockStoredData(ra::services::StorageItemType::RichPresence, L"1", sFileContents);
+        game.ReloadRichPresenceScript();
+
+        Assert::IsTrue(game.HasRichPresence());
+        Assert::AreEqual(std::wstring(L"From File"), game.GetRichPresenceDisplayString());
+        Assert::IsTrue(game.IsRichPresenceFromFile());
+
+        /* hash should ignore BOM and convert windows newlines to unix newlines */
+        sFileContents = "...Display:\r\nHello, World";
+        sFileContents.at(0) = gsl::narrow_cast<char>(0xef);
+        sFileContents.at(1) = gsl::narrow_cast<char>(0xbb);
+        sFileContents.at(2) = gsl::narrow_cast<char>(0xbf);
+        game.mockStorage.MockStoredData(ra::services::StorageItemType::RichPresence, L"1", sFileContents);
+        game.ReloadRichPresenceScript();
+
+        Assert::IsTrue(game.HasRichPresence());
+        Assert::AreEqual(std::wstring(L"Hello, World"), game.GetRichPresenceDisplayString());
+        Assert::IsFalse(game.IsRichPresenceFromFile());
+    }
+
     TEST_METHOD(TestAwardAchievementNonExistant)
     {
         GameContextHarness game;
diff --git a/tests/mocks/MockAchievementRuntime.hh b/tests/mocks/MockAchievementRuntime.hh
index f8e98501..36dec0ff 100644
--- a/tests/mocks/MockAchievementRuntime.hh
+++ b/tests/mocks/MockAchievementRuntime.hh
@@ -16,6 +16,21 @@ public:
     {
     }
 
+    bool IsAchievementSupported(ra::AchievementID nAchievement) noexcept
+    {
+        for (unsigned i = 0; i < m_pRuntime.trigger_count; ++i)
+        {
+            if (m_pRuntime.triggers[i].id == nAchievement)
+            {
+                const auto* pTrigger = m_pRuntime.triggers[i].trigger;
+                if (pTrigger)
+                    return pTrigger->state != RC_TRIGGER_STATE_DISABLED && !m_pRuntime.triggers[i].invalid_memref;
+            }
+        }
+
+        return false;
+    }
+
 private:
     ra::services::ServiceLocator::ServiceOverride<ra::services::AchievementRuntime> m_Override;
 };
diff --git a/tests/services/AchievementRuntime_Tests.cpp b/tests/services/AchievementRuntime_Tests.cpp
index ef9e921a..3d24ebd4 100644
--- a/tests/services/AchievementRuntime_Tests.cpp
+++ b/tests/services/AchievementRuntime_Tests.cpp
@@ -882,7 +882,7 @@ public:
         AchievementRuntimeHarness runtime;
         runtime.mockEmulatorContext.MockMemory(memory);
 
-        auto* pRichPresence = "Format:Num\nFormatType:Value\n\nDisplay:\n@Num(0xH01) @Num(d0xH01)\n";
+        const char* pRichPresence = "Format:Num\nFormatType:Value\n\nDisplay:\n@Num(0xH01) @Num(d0xH01)\n";
         runtime.ActivateRichPresence(pRichPresence);
 
         // string is evaluated with current memrefs (which will be 0)
@@ -928,6 +928,20 @@ public:
         runtime.ActivateRichPresence("");
         Assert::AreEqual(std::wstring(L"No Rich Presence defined."), runtime.GetRichPresenceDisplayString());
     }
+
+    TEST_METHOD(TestActivateRichPresenceWithError)
+    {
+        std::array<unsigned char, 5> memory{ 0x00, 0x12, 0x34, 0xAB, 0x56 };
+        std::vector<ra::services::AchievementRuntime::Change> changes;
+
+        AchievementRuntimeHarness runtime;
+        runtime.mockEmulatorContext.MockMemory(memory);
+
+        const char* pRichPresence = "Format:Num\nFormatType:Value\n\nDisplay:\n@Num(0H01) @Num(d0xH01)\n";
+        runtime.ActivateRichPresence(pRichPresence);
+
+        Assert::AreEqual(std::wstring(L"Parse error -6 (line 5): Invalid operator"), runtime.GetRichPresenceDisplayString());
+    }
 };
 
 } // namespace tests
diff --git a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
index a6f4a83d..64eaae81 100644
--- a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
@@ -1,5 +1,7 @@
 #include "CppUnitTest.h"
 
+#include "data\models\LocalBadgesModel.hh"
+
 #include "ui\viewmodels\AssetListViewModel.hh"
 
 #include "services\impl\StringTextWriter.hh"
@@ -15,6 +17,9 @@
 #include "tests\mocks\MockEmulatorContext.hh"
 #include "tests\mocks\MockGameContext.hh"
 #include "tests\mocks\MockLocalStorage.hh"
+#include "tests\mocks\MockOverlayManager.hh"
+#include "tests\mocks\MockOverlayTheme.hh"
+#include "tests\mocks\MockSurface.hh"
 #include "tests\mocks\MockThreadPool.hh"
 #include "tests\mocks\MockUserContext.hh"
 #include "tests\mocks\MockWindowManager.hh"
@@ -123,6 +128,9 @@ private:
         ra::data::context::mocks::MockGameContext mockGameContext;
         ra::data::context::mocks::MockUserContext mockUserContext;
         ra::ui::mocks::MockDesktop mockDesktop;
+        ra::ui::mocks::MockOverlayTheme mockTheme;
+        ra::ui::drawing::mocks::MockSurfaceFactory mockSurfaceFactory;
+        ra::ui::viewmodels::mocks::MockOverlayManager mockOverlayManager;
         mocks::MockWindowManager mockWindowManager;
 
         AssetListViewModelHarness() noexcept
@@ -415,6 +423,17 @@ private:
             return false;
         }
 
+        ra::data::models::LocalBadgesModel* AddLocalBadgesModel()
+        {
+            auto pLocalBadges = std::make_unique<ra::data::models::LocalBadgesModel>();
+            pLocalBadges->CreateServerCheckpoint();
+            pLocalBadges->CreateLocalCheckpoint();
+            mockGameContext.Assets().Append(std::move(pLocalBadges));
+
+            return dynamic_cast<ra::data::models::LocalBadgesModel*>(mockGameContext.Assets().FindAsset(ra::data::models::AssetType::LocalBadges, 0));
+        }
+
+
     private:
         std::map<ra::AchievementID, std::wstring> m_mPublishErrors;
     };
@@ -792,6 +811,29 @@ public:
         Assert::AreEqual(1, vmAssetList.FilteredAssets().GetItemAt(2)->GetId()); // item changed to match filter appears at end of list
     }
 
+    TEST_METHOD(TestChangeFilter)
+    {
+        AssetListViewModelHarness vmAssetList;
+        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+
+        vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Ach1");
+        vmAssetList.AddAchievement(AssetCategory::Unofficial, 10, L"Ach2");
+        vmAssetList.AddAchievement(AssetCategory::Core, 15, L"Ach3");
+
+        Assert::AreEqual({ 3U }, vmAssetList.mockGameContext.Assets().Count());
+        Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
+        Assert::AreEqual(20, vmAssetList.GetTotalPoints());
+        Assert::AreEqual(1, vmAssetList.FilteredAssets().GetItemAt(0)->GetId());
+        Assert::AreEqual(3, vmAssetList.FilteredAssets().GetItemAt(1)->GetId());
+
+        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Unofficial);
+
+        Assert::AreEqual({ 3U }, vmAssetList.mockGameContext.Assets().Count());
+        Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
+        Assert::AreEqual(10, vmAssetList.GetTotalPoints());
+        Assert::AreEqual(2, vmAssetList.FilteredAssets().GetItemAt(0)->GetId());
+    }
+
     TEST_METHOD(TestSpecialFilterActive)
     {
         AssetListViewModelHarness vmAssetList;
@@ -927,6 +969,7 @@ public:
         Assert::AreEqual(std::wstring(L"Title"), pItem->GetLabel());
         Assert::AreEqual(5, pItem->GetPoints());
         Assert::AreEqual(AssetState::Inactive, pItem->GetState());
+        Assert::AreEqual(5, vmAssetList.GetTotalPoints());
 
         vmAchievement.SetName(L"New Title");
         Assert::AreEqual(std::wstring(L"New Title"), pItem->GetLabel());
@@ -934,6 +977,7 @@ public:
 
         vmAchievement.SetPoints(10);
         Assert::AreEqual(10, pItem->GetPoints());
+        Assert::AreEqual(10, vmAssetList.GetTotalPoints());
 
         vmAchievement.SetState(AssetState::Active);
         Assert::AreEqual(AssetState::Active, pItem->GetState());
@@ -1611,6 +1655,42 @@ public:
         Assert::IsTrue(vmAssetList.mockConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore));
     }
 
+    TEST_METHOD(TestDeactivatePrimed)
+    {
+        AssetListViewModelHarness vmAssetList;
+        vmAssetList.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, false);
+        vmAssetList.SetGameId(1U);
+        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.AddThreeAchievements();
+        vmAssetList.mockGameContext.Assets().GetItemAt(2)->SetState(AssetState::Primed);
+        const auto nAchievementId = vmAssetList.mockGameContext.Assets().GetItemAt(2)->GetID();
+
+        vmAssetList.mockOverlayManager.AddChallengeIndicator(nAchievementId, ra::ui::ImageType::Badge, "12345");
+        const auto* pIndicator = vmAssetList.mockOverlayManager.GetChallengeIndicator(nAchievementId);
+        Assert::IsNotNull(pIndicator);
+        Ensures(pIndicator != nullptr);
+        Assert::IsFalse(pIndicator->IsDestroyPending());
+
+        Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
+        vmAssetList.FilteredAssets().GetItemAt(1)->SetSelected(true);
+        Assert::IsFalse(vmAssetList.FilteredAssets().GetItemAt(0)->IsSelected());
+        Assert::IsTrue(vmAssetList.FilteredAssets().GetItemAt(1)->IsSelected());
+        Assert::AreEqual(AssetState::Primed, vmAssetList.FilteredAssets().GetItemAt(1)->GetState());
+        vmAssetList.ForceUpdateButtons();
+        Assert::AreEqual(std::wstring(L"De&activate"), vmAssetList.GetActivateButtonText());
+        Assert::IsTrue(vmAssetList.CanActivate());
+
+        vmAssetList.ActivateSelected();
+        vmAssetList.ForceUpdateButtons();
+        Assert::AreEqual(AssetState::Inactive, vmAssetList.FilteredAssets().GetItemAt(1)->GetState());
+        Assert::AreEqual(std::wstring(L"&Activate"), vmAssetList.GetActivateButtonText());
+        Assert::IsTrue(vmAssetList.CanActivate());
+
+        // RemoveChallengeIndicator only marks the item as to be destroyed.
+        // it still exists until the next Render()
+        Assert::IsTrue(pIndicator->IsDestroyPending());
+    }
+
     TEST_METHOD(TestSaveSelectedLocalUnmodified)
     {
         AssetListViewModelHarness vmAssetList;
@@ -2412,6 +2492,64 @@ public:
         Assert::IsTrue(pAsset->IsSelected());
     }
 
+    TEST_METHOD(TestCloneLocal)
+    {
+        AssetListViewModelHarness vmAssetList;
+        const auto* pLocalBadges = vmAssetList.AddLocalBadgesModel();
+        const std::wstring sLocalBadgeName = L"local\\1234.png";
+
+        vmAssetList.SetGameId(22U);
+        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmAssetList.AddAchievement(AssetCategory::Local, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
+        auto* pSourceAchievement = vmAssetList.mockGameContext.Assets().FindAchievement(2);
+        Expects(pSourceAchievement != nullptr);
+        pSourceAchievement->SetBadge(sLocalBadgeName);
+
+        Assert::AreEqual({ 2U }, vmAssetList.mockGameContext.Assets().Count());
+        Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
+
+        vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
+        vmAssetList.ForceUpdateButtons();
+
+        bool bEditorShown = false;
+        vmAssetList.mockDesktop.ExpectWindow<AssetEditorViewModel>([&bEditorShown](AssetEditorViewModel&)
+        {
+            bEditorShown = true;
+            return DialogResult::None;
+        });
+
+        vmAssetList.CloneSelected();
+
+        // new Local achievement should be created and focused
+        Assert::AreEqual({ 3U }, vmAssetList.mockGameContext.Assets().Count());
+        Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
+        Assert::AreEqual(AssetListViewModel::FilterCategory::Local, vmAssetList.GetFilterCategory());
+
+        const auto* pAsset = vmAssetList.FilteredAssets().GetItemAt(1);
+        Expects(pAsset != nullptr);
+        Assert::IsTrue(pAsset->IsSelected());
+        Assert::AreEqual(std::wstring(L"Test1 (copy)"), pAsset->GetLabel());
+        Assert::AreEqual(AssetCategory::Local, pAsset->GetCategory());
+        Assert::AreEqual(AssetState::Inactive, pAsset->GetState());
+        Assert::AreEqual(AssetChanges::New, pAsset->GetChanges());
+        Assert::AreEqual({ 111000001U }, pAsset->GetId());
+        Assert::AreEqual(5, pAsset->GetPoints());
+
+        const auto* pAchievement = vmAssetList.mockGameContext.Assets().FindAchievement(pAsset->GetId());
+        Expects(pAchievement != nullptr);
+        Assert::AreEqual(std::wstring(L"Desc1"), pAchievement->GetDescription());
+        Assert::AreEqual(sLocalBadgeName, pAchievement->GetBadge());
+        Assert::AreEqual(std::string("0xH1234=1"), pAchievement->GetTrigger());
+
+        // and loaded in the editor, which should be shown (local achievement will always have ID 0)
+        Assert::AreEqual({ 0U }, vmAssetList.mockWindowManager.AssetEditor.GetID());
+        Assert::IsTrue(vmAssetList.mockWindowManager.AssetEditor.IsAssetLoaded());
+        Assert::IsTrue(bEditorShown);
+
+        // image should have two references - one for source achievement, and one for clone
+        Assert::AreEqual(2, pLocalBadges->GetReferenceCount(sLocalBadgeName, false));
+    }
+
     TEST_METHOD(TestCloneMultiple)
     {
         AssetListViewModelHarness vmAssetList;
diff --git a/tests/ui/viewmodels/MemoryViewerViewModel_Tests.cpp b/tests/ui/viewmodels/MemoryViewerViewModel_Tests.cpp
index 15aa1576..a1e779a0 100644
--- a/tests/ui/viewmodels/MemoryViewerViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/MemoryViewerViewModel_Tests.cpp
@@ -180,7 +180,7 @@ public:
 
         // address after viewing window, first address should change to center new address in viewing window
         viewer.SetAddress(444U);
-        Assert::AreEqual({ 368U }, viewer.GetFirstAddress()); // (444 & 0x0F) - 4 * 16
+        Assert::AreEqual({ 368U }, viewer.GetFirstAddress()); // (444 & ~0x0F) - 4 * 16
         Assert::AreEqual({ 444U }, viewer.GetAddress());
         Assert::AreEqual({ COLOR_RED | COLOR_REDRAW }, viewer.GetColor(444U));
         for (ra::ByteAddress i = 1; i < 128; ++i)
@@ -192,7 +192,7 @@ public:
 
         // address before viewing window, first address should change to center new address in viewing window
         viewer.SetAddress(360U);
-        Assert::AreEqual({ 288U }, viewer.GetFirstAddress()); // (360U & 0x0F) - 4 * 16
+        Assert::AreEqual({ 288U }, viewer.GetFirstAddress()); // (360U & ~0x0F) - 4 * 16
         Assert::AreEqual({ 360U }, viewer.GetAddress());
         Assert::AreEqual({ COLOR_RED | COLOR_REDRAW }, viewer.GetColor(360U));
         for (ra::ByteAddress i = 1; i < 128; ++i)
@@ -203,6 +203,62 @@ public:
         Assert::IsTrue(viewer.NeedsRedraw());
     }
 
+    TEST_METHOD(TestSetAddressBeforeMemoryAvailable)
+    {
+        MemoryViewerViewModelHarness viewer;
+        viewer.MockRender();
+
+        Assert::AreEqual({ 0U }, viewer.GetFirstAddress());
+        Assert::AreEqual({ 0U }, viewer.GetAddress());
+
+        // address not available, no memory registered
+        viewer.SetAddress(444U);
+        Assert::AreEqual({ 0U }, viewer.GetFirstAddress());
+        Assert::AreEqual({ 0U }, viewer.GetAddress());
+        Assert::IsFalse(viewer.NeedsRedraw());
+
+        // memory becomes available, address should immediately update
+        viewer.InitializeMemory(1024);
+        Assert::AreEqual({ 444U }, viewer.GetAddress());
+        Assert::AreEqual({ 368U }, viewer.GetFirstAddress()); // (444 & ~0x0F) - 4 * 16
+        Assert::AreEqual({ COLOR_RED | COLOR_REDRAW }, viewer.GetColor(444U));
+        for (ra::ByteAddress i = 1; i < 128; ++i)
+        {
+            if (i != 444 - 368)
+                Assert::AreEqual({ COLOR_BLACK | COLOR_REDRAW }, viewer.GetColor(i + 368));
+        }
+        Assert::IsTrue(viewer.NeedsRedraw());
+        viewer.MockRender();
+    }
+
+    TEST_METHOD(TestSetAddressBeforeMemoryAvailableOutOfRange)
+    {
+        MemoryViewerViewModelHarness viewer;
+        viewer.MockRender();
+
+        Assert::AreEqual({ 0U }, viewer.GetFirstAddress());
+        Assert::AreEqual({ 0U }, viewer.GetAddress());
+
+        // address not available, no memory registered
+        viewer.SetAddress(4444U);
+        Assert::AreEqual({ 0U }, viewer.GetFirstAddress());
+        Assert::AreEqual({ 0U }, viewer.GetAddress());
+        Assert::IsFalse(viewer.NeedsRedraw());
+
+        // memory becomes available, address should immediately update (clamped at max address)
+        viewer.InitializeMemory(1024);
+        Assert::AreEqual({ 1023U }, viewer.GetAddress());
+        Assert::AreEqual({ 896U }, viewer.GetFirstAddress()); // (1023 & ~0x0F) - 4 * 16
+        Assert::AreEqual({ COLOR_RED | COLOR_REDRAW }, viewer.GetColor(1023U));
+        for (ra::ByteAddress i = 1; i < 128; ++i)
+        {
+            if (i != 1023 - 896)
+                Assert::AreEqual({ COLOR_BLACK | COLOR_REDRAW }, viewer.GetColor(i + 896));
+        }
+        Assert::IsTrue(viewer.NeedsRedraw());
+        viewer.MockRender();
+    }
+
     TEST_METHOD(TestAdvanceCursorEightBit)
     {
         MemoryViewerViewModelHarness viewer;
diff --git a/tests/ui/viewmodels/OverlayManager_Tests.cpp b/tests/ui/viewmodels/OverlayManager_Tests.cpp
index 6d9159f1..544dc32c 100644
--- a/tests/ui/viewmodels/OverlayManager_Tests.cpp
+++ b/tests/ui/viewmodels/OverlayManager_Tests.cpp
@@ -470,6 +470,25 @@ public:
         Assert::AreEqual(10, vmIndicator3.GetVerticalOffset());
     }
 
+    TEST_METHOD(TestAddExistingChallengeIndicator)
+    {
+        OverlayManagerHarness overlay;
+        overlay.mockConfiguration.SetPopupLocation(ra::ui::viewmodels::Popup::Challenge, ra::ui::viewmodels::PopupLocation::BottomRight);
+
+        const auto& vmIndicator = overlay.AddChallengeIndicator(6, ra::ui::ImageType::Badge, "12345");
+        Assert::AreEqual(6, vmIndicator.GetPopupId());
+        Assert::AreEqual(ra::ui::ImageType::Badge, vmIndicator.GetImage().Type());
+        Assert::AreEqual(std::string("12345"), vmIndicator.GetImage().Name());
+        Assert::IsTrue(overlay.WasRenderRequested());
+        Assert::IsFalse(vmIndicator.IsDestroyPending());
+        Assert::AreEqual(10, vmIndicator.GetVerticalOffset());
+
+        Assert::IsTrue(&vmIndicator == overlay.GetChallengeIndicator(6));
+
+        const auto& vmIndicator2 = overlay.AddChallengeIndicator(6, ra::ui::ImageType::Badge, "12345");
+        Assert::IsTrue(&vmIndicator == &vmIndicator2);
+    }
+
     TEST_METHOD(TestShowHideOverlay)
     {
         OverlayManagerHarness overlay;
diff --git a/tests/ui/viewmodels/OverlayRecentGamesPageViewModel_Tests.cpp b/tests/ui/viewmodels/OverlayRecentGamesPageViewModel_Tests.cpp
index 208a8d4b..3a94e798 100644
--- a/tests/ui/viewmodels/OverlayRecentGamesPageViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/OverlayRecentGamesPageViewModel_Tests.cpp
@@ -138,7 +138,7 @@ public:
         gamesPage.mockSessions.MockSession(3U, 1234567890U, std::chrono::seconds(5000));
         gamesPage.mockServer.ExpectUncalled<ra::api::FetchGameData>();
         gamesPage.mockLocalStorage.MockStoredData(ra::services::StorageItemType::GameData, L"3",
-            "{\"Title\":\"Game Name\", \"ConsoleID\":5, \"ImageIcon\":\"/Images/BADGE.png\", \"Achievements\":[], \"Leaderboards\":[]}");
+            "{\"ID\":99, \"Title\":\"Game Name\", \"ConsoleID\":5, \"ImageIcon\":\"/Images/BADGE.png\", \"Achievements\":[], \"Leaderboards\":[]}");
 
         gamesPage.Refresh();
 
diff --git a/tests/ui/viewmodels/TriggerConditionViewModel_Tests.cpp b/tests/ui/viewmodels/TriggerConditionViewModel_Tests.cpp
index da5e1afa..b846d206 100644
--- a/tests/ui/viewmodels/TriggerConditionViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/TriggerConditionViewModel_Tests.cpp
@@ -88,28 +88,36 @@ public:
     TEST_METHOD(TestHasSourceSize)
     {
         TriggerConditionViewModelHarness vmCondition;
+        vmCondition.SetTargetSize(MemSize::SixteenBit);
+
         Assert::AreEqual(TriggerOperandType::Address, vmCondition.GetSourceType());
         Assert::IsTrue(vmCondition.HasSourceSize());
+        Assert::AreEqual(MemSize::EightBit, vmCondition.GetSourceSize()); // initial size is 8-bit
 
         vmCondition.SetSourceType(TriggerOperandType::BCD);
         Assert::AreEqual(TriggerOperandType::BCD, vmCondition.GetSourceType());
         Assert::IsTrue(vmCondition.HasSourceSize());
+        Assert::AreEqual(MemSize::EightBit, vmCondition.GetSourceSize());
 
         vmCondition.SetSourceType(TriggerOperandType::Delta);
         Assert::AreEqual(TriggerOperandType::Delta, vmCondition.GetSourceType());
         Assert::IsTrue(vmCondition.HasSourceSize());
+        Assert::AreEqual(MemSize::EightBit, vmCondition.GetSourceSize());
 
         vmCondition.SetSourceType(TriggerOperandType::Prior);
         Assert::AreEqual(TriggerOperandType::Prior, vmCondition.GetSourceType());
         Assert::IsTrue(vmCondition.HasSourceSize());
+        Assert::AreEqual(MemSize::EightBit, vmCondition.GetSourceSize());
 
         vmCondition.SetSourceType(TriggerOperandType::Value);
         Assert::AreEqual(TriggerOperandType::Value, vmCondition.GetSourceType());
         Assert::IsFalse(vmCondition.HasSourceSize());
+        Assert::AreEqual(MemSize::ThirtyTwoBit, vmCondition.GetSourceSize()); // value always 32-bit
 
         vmCondition.SetSourceType(TriggerOperandType::Address);
         Assert::AreEqual(TriggerOperandType::Address, vmCondition.GetSourceType());
         Assert::IsTrue(vmCondition.HasSourceSize());
+        Assert::AreEqual(MemSize::SixteenBit, vmCondition.GetSourceSize()); // copied from target size
     }
 
     TEST_METHOD(TestHasTarget)
@@ -162,10 +170,12 @@ public:
         Assert::AreEqual(TriggerOperatorType::Equals, vmCondition.GetOperator());
         Assert::IsFalse(vmCondition.HasTargetSize());
         Assert::IsTrue(vmCondition.HasTarget());
+        Assert::AreEqual(MemSize::ThirtyTwoBit, vmCondition.GetTargetSize()); // value always 32-bit
 
         vmCondition.SetTargetType(TriggerOperandType::Address);
         Assert::IsTrue(vmCondition.HasTargetSize());
         Assert::IsTrue(vmCondition.HasTarget());
+        Assert::AreEqual(MemSize::EightBit, vmCondition.GetTargetSize()); // size copied from source
 
         vmCondition.SetOperator(TriggerOperatorType::None);
         Assert::IsFalse(vmCondition.HasTargetSize());
@@ -174,30 +184,37 @@ public:
         vmCondition.SetTargetType(TriggerOperandType::Value);
         Assert::IsFalse(vmCondition.HasTargetSize());
         Assert::IsFalse(vmCondition.HasTarget());
+        Assert::AreEqual(MemSize::ThirtyTwoBit, vmCondition.GetTargetSize()); // value always 32-bit
 
         vmCondition.SetTargetType(TriggerOperandType::Delta);
         Assert::IsFalse(vmCondition.HasTargetSize());
         Assert::IsFalse(vmCondition.HasTarget());
+        Assert::AreEqual(MemSize::EightBit, vmCondition.GetTargetSize()); // size copied from source
 
         vmCondition.SetOperator(TriggerOperatorType::Equals);
         Assert::IsTrue(vmCondition.HasTargetSize());
         Assert::IsTrue(vmCondition.HasTarget());
+        Assert::AreEqual(MemSize::EightBit, vmCondition.GetTargetSize()); // size copied from source
 
         vmCondition.SetTargetType(TriggerOperandType::Value);
         Assert::IsFalse(vmCondition.HasTargetSize());
         Assert::IsTrue(vmCondition.HasTarget());
+        Assert::AreEqual(MemSize::ThirtyTwoBit, vmCondition.GetTargetSize()); // value always 32-bit
 
         vmCondition.SetOperator(TriggerOperatorType::None);
         Assert::IsFalse(vmCondition.HasTargetSize());
         Assert::IsFalse(vmCondition.HasTarget());
+        Assert::AreEqual(MemSize::ThirtyTwoBit, vmCondition.GetTargetSize()); // value still 32-bit
 
         vmCondition.SetTargetType(TriggerOperandType::Prior);
         Assert::IsFalse(vmCondition.HasTargetSize());
         Assert::IsFalse(vmCondition.HasTarget());
+        Assert::AreEqual(MemSize::EightBit, vmCondition.GetTargetSize()); // size copied from source
 
         vmCondition.SetOperator(TriggerOperatorType::NotEquals);
         Assert::IsTrue(vmCondition.HasTargetSize());
         Assert::IsTrue(vmCondition.HasTarget());
+        Assert::AreEqual(MemSize::EightBit, vmCondition.GetTargetSize());
     }
 
     TEST_METHOD(TestHasTargetSizeHasTarget)
diff --git a/tests/ui/viewmodels/TriggerViewModel_Tests.cpp b/tests/ui/viewmodels/TriggerViewModel_Tests.cpp
index 3182e633..b7620bc8 100644
--- a/tests/ui/viewmodels/TriggerViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/TriggerViewModel_Tests.cpp
@@ -528,6 +528,36 @@ public:
         Assert::AreEqual(std::string("0xH1234=16_0xL65ff=11.1._R:0xT3333=1_0xW5555=16"), vmTrigger.Serialize());
     }
 
+    TEST_METHOD(TestRemoveSelectedConditionsAll)
+    {
+        TriggerViewModelHarness vmTrigger;
+        Parse(vmTrigger, "0xH1234=16_0xL65FF=11");
+
+        Assert::AreEqual({ 2U }, vmTrigger.Conditions().Count());
+        vmTrigger.Conditions().GetItemAt(0)->SetSelected(true);
+        vmTrigger.Conditions().GetItemAt(1)->SetSelected(true);
+        // DO NOT test Serialize() response here. We need to validate that it can go TO empty from uninitialized
+        Assert::AreEqual(0, vmTrigger.GetVersion());
+
+        // two items selected
+        bool bDialogShown = false;
+        vmTrigger.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>(
+            [&bDialogShown](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bDialogShown = true;
+
+            Assert::AreEqual(std::wstring(L"Are you sure that you want to delete 2 conditions?"), vmMessageBox.GetMessage());
+
+            return DialogResult::Yes;
+        });
+
+        vmTrigger.RemoveSelectedConditions();
+        Assert::IsTrue(bDialogShown);
+        Assert::AreEqual({ 0U }, vmTrigger.Conditions().Count());
+        Assert::AreEqual(std::string(""), vmTrigger.Serialize());
+        Assert::AreEqual(1, vmTrigger.GetVersion());
+    }
+
     TEST_METHOD(TestMoveSelectedConditionsUp)
     {
         TriggerViewModelHarness vmTrigger;