Repository: RetroAchievements/RAIntegration
Pull request #707 (2021-04-25 18:39:49)

open editor when enter is pressed within the achievement list


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..bc740401 100644
--- a/src/Exports.cpp
+++ b/src/Exports.cpp
@@ -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)
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/GameContext.cpp b/src/data/context/GameContext.cpp
index e7bb25d7..abac918a 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();
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/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/AssetListViewModel.cpp b/src/ui/viewmodels/AssetListViewModel.cpp
index dd05e5f9..8b43e517 100644
--- a/src/ui/viewmodels/AssetListViewModel.cpp
+++ b/src/ui/viewmodels/AssetListViewModel.cpp
@@ -94,11 +94,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 +117,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,6 +126,9 @@ 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 (nNewState == ra::data::models::AssetState::Triggered && KeepActive())
         {
@@ -150,23 +148,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 +206,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 +214,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 +230,8 @@ void AssetListViewModel::OnDataModelRemoved(_UNUSED gsl::index nIndex)
                 }
             }
         }
+
+        UpdateTotals();
     }
 }
 
@@ -258,17 +240,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 +258,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 +322,7 @@ void AssetListViewModel::ApplyFilter()
 
     m_vFilteredAssets.EndUpdate();
 
+    UpdateTotals();
     UpdateButtons();
 }
 
@@ -392,7 +373,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 +408,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 +418,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
@@ -1252,7 +1249,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));
 
@@ -1324,7 +1321,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/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/win32/AssetEditorDialog.cpp b/src/ui/win32/AssetEditorDialog.cpp
index a86baf47..1175137b 100644
--- a/src/ui/win32/AssetEditorDialog.cpp
+++ b/src/ui/win32/AssetEditorDialog.cpp
@@ -141,7 +141,7 @@ public:
 
     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/GameContext_Tests.cpp b/tests/data/context/GameContext_Tests.cpp
index ac154c17..9a8ffb48 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;
         });
diff --git a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
index a6f4a83d..6b296f0b 100644
--- a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
@@ -792,6 +792,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 +950,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 +958,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());
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)