Repository: RetroAchievements/RAIntegration
Pull request #1182 (2025-06-04 02:27:15)

write multiset achievements to XXX-User.txt
Appends the subset id to the local achievement identifier to indicate which subset it's associated to.
```
111000003|1236:\"0x1234=8...
```
If an XXX-User.txt already exists for the subset game it will be merged into the XXX-User.txt for the core game.


Patch:
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 00000000..33d3307e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,32 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Emulator (please complete the following information):**
+ - Emulator [e.g. RAlibretro, RANes]
+ - Version [e.g. 1.8.1]
+ - Integration version [e.g. 1.3]
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index fdb50902..f6ce0479 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -3,6 +3,3 @@ contact_links:
   - name: Suggest a new feature
     url: https://github.com/RetroAchievements/RAIntegration/discussions/new?category=ideas
     about: Share ideas for new features or enhancements to the toolkit
-  - name: Report a bug
-    url: https://github.com/RetroAchievements/RAIntegration/issues/new
-    about: Report an issue with one of the toolkit features
diff --git a/rcheevos b/rcheevos
index e34aff3f..f58ed103 160000
--- a/rcheevos
+++ b/rcheevos
@@ -1,1 +1,1 @@
-Subproject commit e34aff3f3cc382190b274b7a1af4ec3f395bb9b2
+Subproject commit f58ed10348a3708cdfdcd26825eb9684c2ce5882
diff --git a/src/data/context/GameAssets.cpp b/src/data/context/GameAssets.cpp
index 9f2ac2c4..7933cbe1 100644
--- a/src/data/context/GameAssets.cpp
+++ b/src/data/context/GameAssets.cpp
@@ -211,6 +211,7 @@ void GameAssets::ReloadAssets(const std::vector<ra::data::models::AssetModelBase
     {
         ra::data::models::AssetType nType = ra::data::models::AssetType::None;
         unsigned nId = 0;
+        unsigned nSubsetId = 0;
 
         ra::Tokenizer pTokenizer(sLine);
         switch (pTokenizer.PeekChar())
@@ -230,12 +231,14 @@ void GameAssets::ReloadAssets(const std::vector<ra::data::models::AssetModelBase
                 nType = ra::data::models::AssetType::CodeNotes;
                 pTokenizer.Consume('N');
                 break;
+
+            default:
+                continue;
         }
 
         nId = pTokenizer.ReadNumber();
+        nSubsetId = pTokenizer.Consume('|') ? pTokenizer.ReadNumber() : 0;
 
-        if (nType == ra::data::models::AssetType::None)
-            continue;
         if (!pTokenizer.Consume(':'))
             continue;
 
@@ -312,6 +315,9 @@ void GameAssets::ReloadAssets(const std::vector<ra::data::models::AssetModelBase
                     vUnnumberedAssets.push_back(pAsset);
             }
         }
+
+        if (pAsset)
+            pAsset->SetSubsetID(nSubsetId);
     }
 
     // assign IDs for any assets where one was not available
@@ -449,6 +455,14 @@ void GameAssets::SaveAssets(const std::vector<ra::data::models::AssetModelBase*>
         }
 
         pData->Write(std::to_string(pItem->GetID()));
+
+        const auto nSubsetId = pItem->GetSubsetID();
+        if (nSubsetId > 0)
+        {
+            pData->Write("|");
+            pData->Write(std::to_string(nSubsetId));
+        }
+
         pItem->Serialize(*pData);
         pData->WriteLine();
     }
diff --git a/src/data/context/GameContext.cpp b/src/data/context/GameContext.cpp
index e49d5a41..24708a22 100644
--- a/src/data/context/GameContext.cpp
+++ b/src/data/context/GameContext.cpp
@@ -34,6 +34,7 @@
 #include "ui\viewmodels\WindowManager.hh"
 
 #include <rcheevos\src\rc_client_internal.h>
+#include <rcheevos\include\rc_api_runtime.h>
 
 namespace ra {
 namespace data {
@@ -113,6 +114,7 @@ bool GameContext::BeginLoadGame(unsigned int nGameId, Mode nMode, bool& bWasPaus
     // reset the GameContext
     m_nMode = nMode;
     m_sGameTitle.clear();
+    m_vSubsets.clear();
     m_vAssets.ResetLocalId();
 
     m_vAssets.BeginUpdate();
@@ -125,6 +127,7 @@ bool GameContext::BeginLoadGame(unsigned int nGameId, Mode nMode, bool& bWasPaus
         m_vAssets.EndUpdate();
 
         m_sGameHash.clear();
+        m_nActiveGameId = 0;
 
         if (m_nGameId != 0)
         {
@@ -139,7 +142,7 @@ bool GameContext::BeginLoadGame(unsigned int nGameId, Mode nMode, bool& bWasPaus
 
     // start the load process
     BeginLoad();
-    m_nGameId = GetRealGameId(nGameId);
+    m_nGameId = m_nActiveGameId = GetRealGameId(nGameId);
 
     // create a model for managing badges
     auto pLocalBadges = std::make_unique<ra::data::models::LocalBadgesModel>();
@@ -195,14 +198,14 @@ void GameContext::FinishLoadGame(int nResult, const char* sErrorMessage, bool bW
         if (pGame == nullptr || pGame->id == 0)
         {
             // invalid hash
-            m_nGameId = 0;
+            m_nGameId = m_nActiveGameId = 0;
             m_sGameTitle.clear();
             m_sGameHash.clear();
             nResult = RC_NO_GAME_LOADED;
         }
         else if (!ValidateConsole(pGame->console_id))
         {
-            m_nGameId = 0;
+            m_nGameId = m_nActiveGameId = 0;
             m_sGameTitle.clear();
             m_sGameHash.clear();
             nResult = RC_INVALID_STATE;
@@ -474,6 +477,129 @@ void GameContext::InitializeFromAchievementRuntime(const std::map<uint32_t, std:
     }
 }
 
+void GameContext::InitializeSubsets(const rc_api_fetch_game_sets_response_t* game_data_response)
+{
+    Expects(game_data_response != nullptr);
+    m_vSubsets.clear();
+
+    // GameID dictates which game is loaded for purposes of local achievement storage and code notes
+    m_nGameId = game_data_response->id;
+    // ActiveGameID dictates which game is running for purposes of rich presence and pings
+    m_nActiveGameId = game_data_response->session_game_id;
+
+    for (uint32_t i = 0; i < game_data_response->num_sets; ++i)
+    {
+        const auto* pSet = &game_data_response->sets[i];
+        if (pSet->type == RC_ACHIEVEMENT_SET_TYPE_CORE)
+        {
+            // core subset should always be first
+            m_vSubsets.insert(m_vSubsets.begin(),
+                              Subset(pSet->id, pSet->game_id, ra::Widen(pSet->title), SubsetType::Core));
+        }
+        else
+        {
+            SubsetType nType = SubsetType::Bonus;
+            switch (pSet->type)
+            {
+                case RC_ACHIEVEMENT_SET_TYPE_EXCLUSIVE:
+                    nType = SubsetType::Exclusive;
+                    break;
+                case RC_ACHIEVEMENT_SET_TYPE_SPECIALTY:
+                    nType = SubsetType::Specialty;
+                    break;
+                default:
+                    nType = SubsetType::Bonus;
+                    break;
+            }
+            m_vSubsets.emplace_back(pSet->id, pSet->game_id, ra::Widen(pSet->title), nType);
+        }
+    }
+
+    // if subsets were found, migrate any SUBSET-User.txt files into the the GAME-User.txt file
+    if (m_vSubsets.size() > 1)
+        MigrateSubsetUserFiles();
+}
+
+void GameContext::MigrateSubsetUserFiles()
+{
+    auto& pLocalStorage = ra::services::ServiceLocator::GetMutable<ra::services::ILocalStorage>();
+    std::unique_ptr<ra::services::TextWriter> pGameData;
+
+    for (size_t i = 1; i < m_vSubsets.size(); ++i)
+    {
+        const auto& pSubset = m_vSubsets.at(i);
+        Expects(pSubset.GameID() != m_nGameId);
+
+        auto pSubsetData = pLocalStorage.ReadText(ra::services::StorageItemType::UserAchievements,
+                                                  std::to_wstring(pSubset.GameID()));
+        if (pSubsetData)
+        {
+            // replace ID with "0|SUBSETID" so new IDs will be generated
+            std::string sSubsetReplace = "0|" + std::to_string(pSubset.AchievementSetID());
+
+            if (!pGameData)
+            {
+                pGameData = pLocalStorage.AppendText(ra::services::StorageItemType::UserAchievements,
+                                                     std::to_wstring(m_nGameId));
+                if (!pGameData)
+                {
+                    RA_LOG_ERR("Could not append to %u-User.txt", m_nGameId);
+                    break;
+                }
+            }
+
+            std::string sLine;
+            pSubsetData->GetLine(sLine); // version
+            pSubsetData->GetLine(sLine); // game name
+            while (pSubsetData->GetLine(sLine))
+            {
+                if (!sLine.empty())
+                {
+                    if (isdigit(sLine.at(0)) || sLine.at(0) == 'L')
+                    {
+                        // found a local achievement or leaderboard. inject subset id
+                        const auto nIndex = sLine.find(':');
+                        if (nIndex != std::string::npos && nIndex > 8)
+                        {
+                            if (sLine.at(0) == 'L')
+                                sLine.replace(1, nIndex - 1, sSubsetReplace);
+                            else
+                                sLine.replace(0, nIndex, sSubsetReplace);
+                        }
+                    }
+                    else if (sLine.at(0) == 'N')
+                    {
+                        // found a note, just copy it over
+                    }
+                    else
+                    {
+                        // unexpected. ignore
+                        continue;
+                    }
+                }
+
+                pGameData->WriteLine(sLine);
+            }
+
+            // release the file so it can be deleted, then delete it.
+            pSubsetData.reset();
+            pLocalStorage.Delete(ra::services::StorageItemType::UserAchievements,
+                                 std::to_wstring(pSubset.GameID()));
+        }
+    }
+}
+
+uint32_t GameContext::GetGameId(uint32_t nSubsetId) const noexcept
+{
+    for (const auto& pSubset : m_vSubsets)
+    {
+        if (pSubset.ID() == nSubsetId)
+            return pSubset.GameID();
+    }
+
+    return GameId();
+}
+
 void GameContext::OnBeforeActiveGameChanged()
 {
     // create a copy of the list of pointers in case it's modified by one of the callbacks
diff --git a/src/data/context/GameContext.hh b/src/data/context/GameContext.hh
index 431f91f7..3bb40494 100644
--- a/src/data/context/GameContext.hh
+++ b/src/data/context/GameContext.hh
@@ -7,6 +7,8 @@
 #include <string>
 #include <atomic>
 
+struct rc_api_fetch_game_sets_response_t;
+
 namespace ra { namespace services { class AchievementRuntimeExports; } }
 
 namespace ra {
@@ -50,6 +52,14 @@ public:
     /// </summary>
     unsigned int GameId() const noexcept { return m_nGameId; }
 
+    /// <summary>
+    /// Gets the unique identifier of the currently loaded subset game.
+    /// </summary>
+    /// <remarks>
+    /// Will match <see cref="GameId"/> unless an exclusive or specialty subset is loaded.
+    /// </remarks>
+    unsigned int ActiveGameId() const noexcept { return m_nActiveGameId; }
+
     /// <summary>
     /// Gets the title of the currently loaded game.
     /// </summary>
@@ -147,9 +157,44 @@ public:
 
     void DoFrame();
 
+    enum SubsetType
+    {
+        Core,
+        Bonus,
+        Specialty,
+        Exclusive,
+    };
+
+    class Subset
+    {
+    public:
+        Subset(uint32_t nAchievementSetId, uint32_t nGameId, const std::wstring& sTitle, SubsetType nType) :
+            m_sTitle(sTitle), m_nType(nType), m_nAchievementSetId(nAchievementSetId), m_nGameId(nGameId)
+        {
+        }
+
+        const std::wstring& Title() const noexcept { return m_sTitle; }
+        SubsetType Type() const noexcept { return m_nType; }
+        uint32_t AchievementSetID() const noexcept { return m_nAchievementSetId; }
+        uint32_t GameID() const noexcept { return m_nGameId; }
+
+        // Core assets have SubsetId of 0
+        uint32_t ID() const noexcept { return (m_nType == SubsetType::Core) ? 0 : m_nAchievementSetId; }
+
+    private:
+        std::wstring m_sTitle;
+        SubsetType m_nType;
+        uint32_t m_nAchievementSetId;
+        uint32_t m_nGameId;
+    };
+    const std::vector<Subset>& Subsets() const noexcept { return m_vSubsets; }
+    void InitializeSubsets(const rc_api_fetch_game_sets_response_t* game_data_response);
+    uint32_t GetGameId(uint32_t nSubsetId) const noexcept;
+
 private:
     using NotifyTargetSet = std::set<NotifyTarget*>;
     void FinishLoadGame(int nResult, const char* sErrorMessage, bool bWasPaused);
+    void MigrateSubsetUserFiles();
 
     friend class ra::services::AchievementRuntimeExports;
     bool BeginLoadGame(unsigned int nGameId, Mode nMode, bool& bWasPaused);
@@ -163,10 +208,13 @@ protected:
     void EndLoad();
 
     unsigned int m_nGameId = 0;
+    unsigned int m_nActiveGameId = 0;
     std::wstring m_sGameTitle;
     std::string m_sGameHash;
     Mode m_nMode{};
 
+    std::vector<Subset> m_vSubsets;
+
 private:
     /// <summary>
     /// A collection of pointers to other objects. These are not allocated object and do not need to be free'd. It's
diff --git a/src/services/AchievementRuntime.cpp b/src/services/AchievementRuntime.cpp
index 7e02e7d4..eb0acf36 100644
--- a/src/services/AchievementRuntime.cpp
+++ b/src/services/AchievementRuntime.cpp
@@ -837,13 +837,6 @@ public:
         rc_mutex_unlock(&pClient->state.mutex);
     }
 
-    void GetSubsets(std::vector<std::pair<uint32_t, std::wstring>>& vSubsets) const
-    {
-        const auto* pSubset = m_pPublishedSubset;
-        for (; pSubset; pSubset = pSubset->next)
-            vSubsets.emplace_back(pSubset->public_.id, ra::Widen(pSubset->public_.title));
-    }
-
     void AttachMemory(void* pMemory)
     {
         if (m_pSubsetWrapper)
@@ -892,12 +885,6 @@ void AchievementRuntime::SyncAssets()
     m_pClientSynchronizer->SyncAssets(GetClient());
 }
 
-void AchievementRuntime::GetSubsets(std::vector<std::pair<uint32_t, std::wstring>>& vSubsets) const
-{
-    if (m_pClientSynchronizer != nullptr)
-        m_pClientSynchronizer->GetSubsets(vSubsets);
-}
-
 void AchievementRuntime::AttachMemory(void* pMemory)
 {
     if (m_pClientSynchronizer != nullptr)
@@ -1190,58 +1177,54 @@ void AchievementRuntime::BeginLoadGame(const std::string& sHash, unsigned id, rc
     BeginLoadGame(sHash.c_str(), id, pCallbackWrapper);
 }
 
-static void ExtractPatchData(const rc_api_server_response_t* server_response, uint32_t nGameId)
+static void ProcessPatchData(const rc_api_server_response_t* server_response,
+                             const rc_api_fetch_game_sets_response_t* game_data_response)
 {
-    // extract the PatchData and store a copy in the cache for offline mode
-    rc_api_response_t api_response{};
-    GSL_SUPPRESS_ES47
-    rc_json_field_t fields[] = {RC_JSON_NEW_FIELD("Success"), RC_JSON_NEW_FIELD("Error"),
-                                RC_JSON_NEW_FIELD("PatchData")};
+    Expects(game_data_response != nullptr);
+    // determine the active game ID and any identify any provided subsets
+    auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
+    pGameContext.InitializeSubsets(game_data_response);
 
-    if (rc_json_parse_server_response(&api_response, server_response, fields, sizeof(fields) / sizeof(fields[0])) ==
-            RC_OK &&
-        fields[2].value_start && fields[2].value_end)
+    // extract the old rich presence script from the last cached server response so we can tell if the
+    // local value has changed. store it as the local value, and we'll merge in the real local value later.
+    auto& pLocalStorage = ra::services::ServiceLocator::GetMutable<ra::services::ILocalStorage>();
+    auto pOldData = pLocalStorage.ReadText(StorageItemType::GameData, std::to_wstring(pGameContext.ActiveGameId()));
+    if (pOldData != nullptr)
     {
-        auto& pLocalStorage = ra::services::ServiceLocator::GetMutable<ra::services::ILocalStorage>();
-        auto pData = pLocalStorage.WriteText(ra::services::StorageItemType::GameData, std::to_wstring(nGameId));
-        if (pData != nullptr)
+        rapidjson::Document pDocument;
+        if (LoadDocument(pDocument, *pOldData) && pDocument.HasMember("RichPresencePatch") &&
+            pDocument["RichPresencePatch"].IsString())
         {
-            std::string sPatchData;
-            sPatchData.append(fields[2].value_start, fields[2].value_end - fields[2].value_start);
-            pData->Write(sPatchData);
+            auto* pRichPresence = pGameContext.Assets().FindRichPresence();
+            Expects(pRichPresence != nullptr);
+            pRichPresence->SetScript(pDocument["RichPresencePatch"].GetString());
+            pRichPresence->UpdateLocalCheckpoint();
         }
     }
+
+    // cache the patchdata so it can be used in offline mode or by external tools
+    auto pData = pLocalStorage.WriteText(StorageItemType::GameData, std::to_wstring(pGameContext.ActiveGameId()));
+    if (pData != nullptr)
+    {
+        std::string sPatchData(server_response->body, server_response->body_length);
+        pData->Write(sPatchData);
+    }
 }
 
 GSL_SUPPRESS_CON3
 void AchievementRuntime::PostProcessGameDataResponse(const rc_api_server_response_t* server_response,
-                                                 struct rc_api_fetch_game_data_response_t* game_data_response,
+                                                 rc_api_fetch_game_sets_response_t* game_data_response,
                                                  rc_client_t*, void* pUserdata)
 {
     auto* wrapper = static_cast<LoadGameCallbackWrapper*>(pUserdata);
     Expects(wrapper != nullptr);
+    Expects(game_data_response != nullptr);
 
     auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
 
     auto pRichPresence = std::make_unique<ra::data::models::RichPresenceModel>();
     pRichPresence->SetScript(game_data_response->rich_presence_script);
     pRichPresence->CreateServerCheckpoint();
-
-    // extract the old rich presence script from the last cached server response so we can tell if the
-    // local value has changed. store it as the local value, and we'll merge in the real local value later.
-    auto& pLocalStorage = ra::services::ServiceLocator::GetMutable<ra::services::ILocalStorage>();
-    auto pData = pLocalStorage.ReadText(ra::services::StorageItemType::GameData, std::to_wstring(game_data_response->id));
-    if (pData != nullptr)
-    {
-        rapidjson::Document pDocument;
-        if (LoadDocument(pDocument, *pData) &&
-            pDocument.HasMember("RichPresencePatch") &&
-            pDocument["RichPresencePatch"].IsString())
-        {
-            pRichPresence->SetScript(pDocument["RichPresencePatch"].GetString());
-        }
-    }
-
     pRichPresence->CreateLocalCheckpoint();
     pGameContext.Assets().Append(std::move(pRichPresence));
 
@@ -1251,18 +1234,23 @@ void AchievementRuntime::PostProcessGameDataResponse(const rc_api_server_respons
     pImageRepository.FetchImage(ra::ui::ImageType::Icon, game_data_response->image_name, game_data_response->image_url);
 #endif
 
-    const rc_api_achievement_definition_t* pAchievement = game_data_response->achievements;
-    const rc_api_achievement_definition_t* pAchievementStop = pAchievement + game_data_response->num_achievements;
-    for (; pAchievement < pAchievementStop; ++pAchievement)
-        wrapper->m_mAchievementDefinitions[pAchievement->id] = pAchievement->definition;
+    for (uint32_t i = 0; i < game_data_response->num_sets; ++i)
+    {
+        const auto* pSet = &game_data_response->sets[i];
 
-    const rc_api_leaderboard_definition_t* pLeaderboard = game_data_response->leaderboards;
-    const rc_api_leaderboard_definition_t* pLeaderboardStop = pLeaderboard + game_data_response->num_leaderboards;
-    for (; pLeaderboard < pLeaderboardStop; ++pLeaderboard)
-        wrapper->m_mLeaderboardDefinitions[pLeaderboard->id] = pLeaderboard->definition;
+        const auto* pAchievement = pSet->achievements;
+        const auto* pAchievementStop = pAchievement + pSet->num_achievements;
+        for (; pAchievement < pAchievementStop; ++pAchievement)
+            wrapper->m_mAchievementDefinitions[pAchievement->id] = pAchievement->definition;
+
+        const auto* pLeaderboard = pSet->leaderboards;
+        const auto* pLeaderboardStop = pLeaderboard + pSet->num_leaderboards;
+        for (; pLeaderboard < pLeaderboardStop; ++pLeaderboard)
+            wrapper->m_mLeaderboardDefinitions[pLeaderboard->id] = pLeaderboard->definition;
+    }
 
     if (!ra::data::context::GameContext::IsVirtualGameId(game_data_response->id))
-        ExtractPatchData(server_response, game_data_response->id);
+        ProcessPatchData(server_response, game_data_response);
 }
 
 rc_client_async_handle_t* AchievementRuntime::BeginLoadGame(const char* sHash, unsigned id,
@@ -1283,7 +1271,7 @@ rc_client_async_handle_t* AchievementRuntime::BeginLoadGame(const char* sHash, u
     }
 
     // start the load process
-    client->callbacks.post_process_game_data_response = PostProcessGameDataResponse;
+    client->callbacks.post_process_game_sets_response = PostProcessGameDataResponse;
 
     return rc_client_begin_load_game(client, sHash, AchievementRuntime::LoadGameCallback, pCallbackWrapper);
 }
@@ -1300,7 +1288,7 @@ rc_client_async_handle_t* AchievementRuntime::BeginIdentifyAndLoadGame(uint32_t
     s_mAchievementPopups.clear();
 
     // start the load process
-    client->callbacks.post_process_game_data_response = PostProcessGameDataResponse;
+    client->callbacks.post_process_game_sets_response = PostProcessGameDataResponse;
 
     return rc_client_begin_identify_and_load_game(client, console_id, file_path, data, data_size,
                                                   AchievementRuntime::LoadGameCallback, pCallbackWrapper);
diff --git a/src/services/AchievementRuntime.hh b/src/services/AchievementRuntime.hh
index f08d0584..716ba312 100644
--- a/src/services/AchievementRuntime.hh
+++ b/src/services/AchievementRuntime.hh
@@ -15,7 +15,7 @@
 
 #include <rcheevos\include\rc_client.h>
 
-struct rc_api_fetch_game_data_response_t;
+struct rc_api_fetch_game_sets_response_t;
 
 namespace ra {
 namespace services {
@@ -283,7 +283,7 @@ private:
     static void ChangeMediaCallback(int nResult, const char* sErrorMessage, rc_client_t*, void* pUserdata);
 
     static void PostProcessGameDataResponse(const rc_api_server_response_t* server_response,
-                                            struct rc_api_fetch_game_data_response_t* game_data_response,
+                                            struct rc_api_fetch_game_sets_response_t* game_data_response,
                                             rc_client_t* client, void* pUserdata);
 };
 
diff --git a/src/services/ILocalStorage.hh b/src/services/ILocalStorage.hh
index 9ec06c79..536847ed 100644
--- a/src/services/ILocalStorage.hh
+++ b/src/services/ILocalStorage.hh
@@ -59,6 +59,14 @@ public:
     /// </returns>
     virtual std::unique_ptr<TextWriter> AppendText(StorageItemType nType, const std::wstring& sKey) = 0;
 
+    /// <summary>
+    ///   Deletes the stored data for the specified <paramref name="nType" /> and <paramref name="sKey" />.
+    /// </summary>
+    /// <returns>
+    ///   <c>true</c> if data was deleted, <c>false</c> if the data didn't exist.
+    /// </returns>
+    virtual bool Delete(StorageItemType nType, const std::wstring& sKey) = 0;
+
 protected:
     ILocalStorage() noexcept = default;
 };
diff --git a/src/services/impl/FileLocalStorage.cpp b/src/services/impl/FileLocalStorage.cpp
index 1b1c7feb..fd81e550 100644
--- a/src/services/impl/FileLocalStorage.cpp
+++ b/src/services/impl/FileLocalStorage.cpp
@@ -158,6 +158,11 @@ std::unique_ptr<TextWriter> FileLocalStorage::AppendText(StorageItemType nType,
     return m_pFileSystem.AppendTextFile(GetPath(nType, sKey));
 }
 
+bool FileLocalStorage::Delete(StorageItemType nType, const std::wstring& sKey)
+{
+    return m_pFileSystem.DeleteFile(GetPath(nType, sKey));
+}
+
 } // namespace impl
 } // namespace services
 } // namespace ra
diff --git a/src/services/impl/FileLocalStorage.hh b/src/services/impl/FileLocalStorage.hh
index 81dec3ea..a92d2c08 100644
--- a/src/services/impl/FileLocalStorage.hh
+++ b/src/services/impl/FileLocalStorage.hh
@@ -19,6 +19,7 @@ public:
     std::unique_ptr<TextReader> ReadText(StorageItemType nType, const std::wstring& sKey) override;
     std::unique_ptr<TextWriter> WriteText(StorageItemType nType, const std::wstring& sKey) override;
     std::unique_ptr<TextWriter> AppendText(StorageItemType nType, const std::wstring& sKey) override;
+    bool Delete(StorageItemType nType, const std::wstring& sKey) override;
 
     std::wstring GetPath(StorageItemType nType, const std::wstring& sKey) const;
 
diff --git a/src/ui/viewmodels/AssetListViewModel.cpp b/src/ui/viewmodels/AssetListViewModel.cpp
index 1d407e31..3db8dff9 100644
--- a/src/ui/viewmodels/AssetListViewModel.cpp
+++ b/src/ui/viewmodels/AssetListViewModel.cpp
@@ -125,22 +125,8 @@ void AssetListViewModel::OnActiveGameChanged()
     m_vSubsets.BeginUpdate();
     m_vSubsets.Clear();
 
-    if (pGameContext.GameId() != 0 && ra::services::ServiceLocator::Exists<ra::services::AchievementRuntime>())
-    {
-        const auto& pAchievementRuntime = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
-
-        std::vector<std::pair<uint32_t, std::wstring>> vSubsets;
-        pAchievementRuntime.GetSubsets(vSubsets);
-
-        if (!vSubsets.empty())
-        {
-            // Core asset have SubsetId of 0, even though core subset is the game id.
-            vSubsets.at(0).first = 0;
-
-            for (const auto& pair : vSubsets)
-                m_vSubsets.Add(pair.first, pair.second);
-        }
-    }
+    for (const auto& pSubset : pGameContext.Subsets())
+        m_vSubsets.Add(pSubset.ID(), pSubset.Title());
 
     m_vSubsets.EndUpdate();
 
diff --git a/src/ui/viewmodels/AssetUploadViewModel.cpp b/src/ui/viewmodels/AssetUploadViewModel.cpp
index 14e8494b..ae76fe44 100644
--- a/src/ui/viewmodels/AssetUploadViewModel.cpp
+++ b/src/ui/viewmodels/AssetUploadViewModel.cpp
@@ -236,8 +236,10 @@ void AssetUploadViewModel::UploadBadge(const std::wstring& sBadge)
 
 void AssetUploadViewModel::UploadAchievement(ra::data::models::AchievementModel& pAchievement)
 {
+    const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
+
     ra::api::UpdateAchievement::Request request;
-    request.GameId = ra::services::ServiceLocator::Get<ra::data::context::GameContext>().GameId();
+    request.GameId = pGameContext.GetGameId(pAchievement.GetSubsetID());
     request.Title = pAchievement.GetName();
     request.Description = pAchievement.GetDescription();
     request.Trigger = pAchievement.GetTrigger();
@@ -321,8 +323,10 @@ void AssetUploadViewModel::UploadAchievement(ra::data::models::AchievementModel&
 
 void AssetUploadViewModel::UploadLeaderboard(ra::data::models::LeaderboardModel& pLeaderboard)
 {
+    const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
+
     ra::api::UpdateLeaderboard::Request request;
-    request.GameId = ra::services::ServiceLocator::Get<ra::data::context::GameContext>().GameId();
+    request.GameId = pGameContext.GetGameId(pLeaderboard.GetSubsetID());
     request.Title = pLeaderboard.GetName();
     request.Description = pLeaderboard.GetDescription();
     request.StartTrigger = pLeaderboard.GetStartTrigger();
diff --git a/src/ui/viewmodels/OverlayRecentGamesPageViewModel.cpp b/src/ui/viewmodels/OverlayRecentGamesPageViewModel.cpp
index 25a375ad..92df6f1d 100644
--- a/src/ui/viewmodels/OverlayRecentGamesPageViewModel.cpp
+++ b/src/ui/viewmodels/OverlayRecentGamesPageViewModel.cpp
@@ -72,7 +72,8 @@ void OverlayRecentGamesPageViewModel::Refresh()
             auto& pLocalStorage = ra::services::ServiceLocator::GetMutable<ra::services::ILocalStorage>();
             for (const auto nGameId : vPendingGames)
             {
-                auto pData = pLocalStorage.ReadText(ra::services::StorageItemType::GameData, std::to_wstring(nGameId));
+                const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
+                auto pData = pLocalStorage.ReadText(ra::services::StorageItemType::GameData, std::to_wstring(pGameContext.ActiveGameId()));
                 if (pData != nullptr)
                 {
                     // found patchdata. extract title and badge
diff --git a/src/ui/win32/Desktop.cpp b/src/ui/win32/Desktop.cpp
index bc09d2a9..177974e4 100644
--- a/src/ui/win32/Desktop.cpp
+++ b/src/ui/win32/Desktop.cpp
@@ -62,7 +62,7 @@ Desktop::Desktop() noexcept
 void Desktop::ShowWindow(WindowViewModelBase& vmViewModel) const
 {
     if (m_pWindowBinding)
-        m_pWindowBinding->EnableInvokeOnUIThread();
+        ra::ui::win32::bindings::WindowBinding::EnableInvokeOnUIThread();
 
     auto* pPresenter = GetDialogPresenter(vmViewModel);
     if (pPresenter != nullptr)
@@ -87,7 +87,7 @@ ra::ui::DialogResult Desktop::ShowModal(WindowViewModelBase& vmViewModel) const
 ra::ui::DialogResult Desktop::ShowModal(WindowViewModelBase& vmViewModel, const WindowViewModelBase& vmParentViewModel) const
 {
     if (m_pWindowBinding)
-        m_pWindowBinding->EnableInvokeOnUIThread();
+        ra::ui::win32::bindings::WindowBinding::EnableInvokeOnUIThread();
 
     auto* pPresenter = GetDialogPresenter(vmViewModel);
     if (pPresenter != nullptr)
@@ -187,8 +187,8 @@ void Desktop::SetMainHWnd(HWND hWnd)
     }
 
     m_pWindowBinding->SetHWND(nullptr, hWnd);
-    m_pWindowBinding->SetUIThread(::GetWindowThreadProcessId(hWnd, nullptr));
-    m_pWindowBinding->EnableInvokeOnUIThread();
+    ra::ui::win32::bindings::WindowBinding::SetUIThread(::GetWindowThreadProcessId(hWnd, nullptr));
+    ra::ui::win32::bindings::WindowBinding::EnableInvokeOnUIThread();
 
     g_RAMainWnd = hWnd;
 }


Test patch:
diff --git a/tests/data/DataAsserts.hh b/tests/data/DataAsserts.hh
index ee0e9221..5d073ab2 100644
--- a/tests/data/DataAsserts.hh
+++ b/tests/data/DataAsserts.hh
@@ -28,6 +28,24 @@ std::wstring ToString<ra::data::context::GameContext::Mode>(const ra::data::cont
     }
 }
 
+template<>
+std::wstring ToString<ra::data::context::GameContext::SubsetType>(const ra::data::context::GameContext::SubsetType& nSubsetType)
+{
+    switch (nSubsetType)
+    {
+        case ra::data::context::GameContext::SubsetType::Core:
+            return L"Core";
+        case ra::data::context::GameContext::SubsetType::Bonus:
+            return L"Bonus";
+        case ra::data::context::GameContext::SubsetType::Specialty:
+            return L"Specialty";
+        case ra::data::context::GameContext::SubsetType::Exclusive:
+            return L"Exclusive";
+        default:
+            return std::to_wstring(static_cast<int>(nSubsetType));
+    }
+}
+
 template<>
 std::wstring ToString<ra::data::models::AssetType>(
     const ra::data::models::AssetType& nAssetType)
diff --git a/tests/data/context/GameAssets_Tests.cpp b/tests/data/context/GameAssets_Tests.cpp
index 74a10d1d..acf58739 100644
--- a/tests/data/context/GameAssets_Tests.cpp
+++ b/tests/data/context/GameAssets_Tests.cpp
@@ -210,6 +210,18 @@ public:
         Assert::AreEqual(sExpected, gameAssets.GetUserFile());
     }
 
+    TEST_METHOD(TestSaveLocalSubset)
+    {
+        GameAssetsHarness gameAssets;
+        auto& pAch = gameAssets.AddAchievement(AssetCategory::Local, 5, L"Ach2", L"Desc2", L"22222", "2=2");
+        pAch.SetSubsetID(77U);
+
+        gameAssets.SaveAllAssets();
+
+        const auto& sExpected = ra::StringPrintf("0.0.0.0\nGameName\n%u|77:\"2=2\":Ach2:Desc2::::Auth2:5:::::22222\n", GameAssets::FirstLocalId);
+        Assert::AreEqual(sExpected, gameAssets.GetUserFile());
+    }
+
     TEST_METHOD(TestSaveLocalBadges)
     {
         GameAssetsHarness gameAssets;
@@ -438,6 +450,35 @@ public:
         Assert::AreEqual(1, pLocalBadges.GetReferenceCount(L"local\\22-A.png", true));
     }
 
+    TEST_METHOD(TestMergeLocalAssetsTwoLocalAchievementsWithSubsetInfo)
+    {
+        GameAssetsHarness gameAssets;
+        gameAssets.AddThreeAchievements();
+        gameAssets.MockUserFileContents(
+            "111000001:\"0xH1234=0\":Test:::::User:0:0:0:::00000\n"
+            "111000002|2223:\"0xH2345=0\":Test2:::::User:0:0:0:::00000\n");
+
+        gameAssets.ReloadAllAssets();
+
+        Assert::AreEqual({5U}, gameAssets.Count());
+
+        const auto* pAsset = gameAssets.FindAchievement({111000001U});
+        Assert::IsNotNull(pAsset);
+        Ensures(pAsset != nullptr);
+        Assert::AreEqual(std::string("0xH1234=0"), pAsset->GetTrigger());
+        Assert::AreEqual(AssetCategory::Local, pAsset->GetCategory());
+        Assert::AreEqual(0U, pAsset->GetSubsetID());
+        Assert::AreEqual(AssetChanges::Unpublished, pAsset->GetChanges());
+
+        const auto* pAsset2 = gameAssets.FindAchievement({111000002U});
+        Assert::IsNotNull(pAsset2);
+        Ensures(pAsset2 != nullptr);
+        Assert::AreEqual(std::string("0xH2345=0"), pAsset2->GetTrigger());
+        Assert::AreEqual(AssetCategory::Local, pAsset2->GetCategory());
+        Assert::AreEqual(2223U, pAsset2->GetSubsetID());
+        Assert::AreEqual(AssetChanges::Unpublished, pAsset2->GetChanges());
+    }
+
     TEST_METHOD(TestReloadModifiedFromFile)
     {
         GameAssetsHarness gameAssets;
diff --git a/tests/data/context/GameContext_Tests.cpp b/tests/data/context/GameContext_Tests.cpp
index 06f1c3be..80190ca4 100644
--- a/tests/data/context/GameContext_Tests.cpp
+++ b/tests/data/context/GameContext_Tests.cpp
@@ -9,6 +9,7 @@
 #include "ui\viewmodels\MessageBoxViewModel.hh"
 
 #include <rcheevos\src\rc_version.h>
+#include <rcheevos\include\rc_api_runtime.h>
 
 #include "tests\RA_UnitTestHelpers.h"
 #include "tests\data\DataAsserts.hh"
@@ -156,16 +157,21 @@ public:
 
             mockAchievementRuntime.MockUser("Username", "ApiToken");
             mockAchievementRuntime.MockResponse(
-                "r=patch&u=Username&t=ApiToken&m=" + sHash,
-                "{\"Success\":true,\"PatchData\":{"
-                    "\"ID\":" + std::to_string(nGameID) + ","
-                    "\"Title\":\"GameTitle\","
-                    "\"ConsoleID\":" + std::to_string(ra::etoi(nConsoleID)) + ","
-                    "\"ImageIcon\":\"/Images/9743.png\","
+                "r=achievementsets&u=Username&t=ApiToken&m=" + sHash,
+                "{\"Success\":true,"
+                  "\"GameId\":" + std::to_string(nGameID) + ","
+                  "\"Title\":\"GameTitle\","
+                  "\"ConsoleId\":" + std::to_string(ra::etoi(nConsoleID)) + ","
+                  "\"ImageIconUrl\":\"http://server/Images/9743.png\","
+                  "\"RichPresencePatch\":\"" + sRichPresence + "\","
+                  "\"Sets\":[{"
+                    "\"AchievementSetId\":1111,"
+                    "\"GameId\":" + std::to_string(nGameID) + ","
+                    "\"Title\":null,\"Type\":\"core\","
+                    "\"ImageIconUrl\":\"http://server/Images/9743.png\","
                     "\"Achievements\":[" + sAchievements + "],"
-                    "\"Leaderboards\":[" + sLeaderboards + "],"
-                    "\"RichPresencePatch\":\"" + sRichPresence + "\""
-                "}}");
+                    "\"Leaderboards\":[" + sLeaderboards + "]"
+                "}]}");
             mockAchievementRuntime.MockResponse(
                 "r=startsession&u=Username&t=ApiToken&g=" + std::to_string(nGameID) +
                     "&h=1&m=" + sHash + "&l=" RCHEEVOS_VERSION_STRING,
@@ -706,7 +712,7 @@ public:
         Assert::IsFalse(vmAch2->IsModified());
 
         const auto pPatchData = game.mockStorage.GetStoredData(ra::services::StorageItemType::GameData, L"1");
-        Assert::AreNotEqual(pPatchData.find("\"ID\":1,"), std::string::npos);
+        Assert::AreNotEqual(pPatchData.find("\"GameId\":1,"), std::string::npos);
         Assert::AreEqual(pPatchData.find("\"PatchData\":"), std::string::npos); /* PatchData should be stripped out */
     }
 
@@ -890,6 +896,140 @@ public:
         Assert::AreEqual(999000004U, pAch2.GetID());
     }
 
+    TEST_METHOD(TestLoadGameMergeLocalAchievementsFromSubsets)
+    {
+        GameContextHarness game;
+        // MockLoadGameAPIs appends request handlers, so provide our override first so it's given priority
+        game.mockAchievementRuntime.MockResponse(
+                "r=achievementsets&u=Username&t=ApiToken&m=0123456789abcdeffedcba987654321",
+                "{\"Success\":true,"
+                 "\"GameId\":1,"
+                 "\"Title\":\"GameTitle\","
+                 "\"ConsoleId\":1,"
+                 "\"ImageIconUrl\":\"http://server/Images/9743.png\","
+                 "\"RichPresenceGameId\":1,"
+                 "\"RichPresencePatch\":\"\","
+                 "\"Sets\":[{"
+                    "\"AchievementSetId\":5,\"GameId\":1,\"Title\":null,\"Type\":\"core\","
+                    "\"ImageIconUrl\":\"http://server/Images/9743.png\","
+                    "\"Achievements\":["
+                       "{\"ID\":7,\"Title\":\"Ach2\",\"Description\":\"Desc2\",\"Flags\":5,"
+                        "\"Points\":15,\"MemAddr\":\"1=1\",\"Author\":\"Auth2\",\"BadgeName\":\"12345\","
+                        "\"Created\":1234567890,\"Modified\":123459999}"
+                    "],"
+                    "\"Leaderboards\":[]"
+                 "},{"
+                    "\"AchievementSetId\":3,\"GameId\":2,\"Title\":\"SetTitle\",\"Type\":\"bonus\","
+                    "\"ImageIconUrl\":\"\",\"Achievements\":["
+                       "{\"ID\":5,\"Title\":\"Ach1\",\"Description\":\"Desc1\",\"Flags\":3,"
+                        "\"Points\":5,\"MemAddr\":\"1=1\",\"Author\":\"Auth1\",\"BadgeName\":\"12345\","
+                        "\"Created\":1234567890,\"Modified\":123459999}"
+                    "],\"Leaderboards\":[]"
+                 "}]}");
+        game.MockLoadGameAPIs(1U, "0123456789abcdeffedcba987654321");
+        game.mockStorage.MockStoredData(ra::services::StorageItemType::UserAchievements, L"1",
+                                        "Version\n"
+                                        "Game\n"
+                                        "7:1=2:Ach2b:Desc2b::::Auth2b:25:1234554321:1234555555:::54321\n"
+                                        "111000002:\"1=1\":\"Ach3\":\"Desc3\"::::Auth3:20:1234511111:1234500000:::555\n"
+                                        "111000003:\"R:1=1\":Ach4:Desc4::::Auth4:10:1234511111:1234500000:::556\n");
+        game.mockStorage.MockStoredData(ra::services::StorageItemType::UserAchievements, L"2",
+                                        "Version2\n"
+                                        "Subset\n"
+                                        "5:1=2:Ach1b:Desc1b::::Auth1b:25:1234554321:1234555555:::54321\n"
+                                        "111000001:\"1=1\":\"Ach5\":\"Desc5\"::::Auth5:20:1234511111:1234500000:::555\n"
+                                        "111000002:\"R:1=1\":Ach6:Desc6::::Auth6:10:1234511111:1234500000:::556\n");
+
+        game.LoadGame(1U, "0123456789abcdeffedcba987654321");
+        Assert::AreEqual({2U}, game.Subsets().size());
+        Assert::AreEqual(2U, game.GetGameId(3U));
+
+        // local achievement data for 5 should be merged with server achievement data
+        auto* pAch = game.Assets().FindAchievement(5U);
+        Assert::IsNotNull(pAch);
+        Ensures(pAch != nullptr);
+        Assert::AreEqual(std::wstring(L"Ach1b"), pAch->GetName());
+        Assert::AreEqual(std::wstring(L"Desc1b"), pAch->GetDescription());
+        Assert::AreEqual(std::wstring(L"Auth1"), pAch->GetAuthor()); // author not merged
+        Assert::AreEqual(std::wstring(L"54321"), pAch->GetBadge());
+        Assert::AreEqual(ra::data::models::AssetCategory::Core, pAch->GetCategory()); // category not merged
+        Assert::AreEqual(25, pAch->GetPoints());
+        Assert::AreEqual(std::string("1=2"), pAch->GetTrigger());
+
+        // local achievement data for 7 should be merged with server achievement data
+        pAch = game.Assets().FindAchievement(7U);
+        Assert::IsNotNull(pAch);
+        Ensures(pAch != nullptr);
+        Assert::AreEqual(std::wstring(L"Ach2b"), pAch->GetName());
+        Assert::AreEqual(std::wstring(L"Desc2b"), pAch->GetDescription());
+        Assert::AreEqual(std::wstring(L"Auth2"), pAch->GetAuthor()); // author not merged
+        Assert::AreEqual(std::wstring(L"54321"), pAch->GetBadge());
+        Assert::AreEqual(ra::data::models::AssetCategory::Unofficial, pAch->GetCategory()); // category not merged
+        Assert::AreEqual(25, pAch->GetPoints());
+        Assert::AreEqual(std::string("1=2"), pAch->GetTrigger());
+
+        // IDs from core file are kept
+        pAch = game.Assets().FindAchievement(111000002U);
+        Assert::IsNotNull(pAch);
+        Ensures(pAch != nullptr);
+        Assert::AreEqual(std::wstring(L"Ach3"), pAch->GetName());
+        Assert::AreEqual(std::wstring(L"Desc3"), pAch->GetDescription());
+        Assert::AreEqual(std::wstring(L"Auth3"), pAch->GetAuthor());
+        Assert::AreEqual(std::wstring(L"00555"), pAch->GetBadge());
+        Assert::AreEqual(ra::data::models::AssetCategory::Local, pAch->GetCategory());
+        Assert::AreEqual(20, pAch->GetPoints());
+        Assert::AreEqual(std::string("1=1"), pAch->GetTrigger());
+
+        // IDs from core file are kept
+        pAch = game.Assets().FindAchievement(111000003U);
+        Assert::IsNotNull(pAch);
+        Ensures(pAch != nullptr);
+        Assert::AreEqual(std::wstring(L"Ach4"), pAch->GetName());
+        Assert::AreEqual(std::wstring(L"Desc4"), pAch->GetDescription());
+        Assert::AreEqual(std::wstring(L"Auth4"), pAch->GetAuthor());
+        Assert::AreEqual(std::wstring(L"00556"), pAch->GetBadge());
+        Assert::AreEqual(ra::data::models::AssetCategory::Local, pAch->GetCategory());
+        Assert::AreEqual(10, pAch->GetPoints());
+        Assert::AreEqual(std::string("R:1=1"), pAch->GetTrigger());
+
+        // IDs from subset file are incrementally assigned after last core ID
+        pAch = game.Assets().FindAchievement(111000004U);
+        Assert::IsNotNull(pAch);
+        Ensures(pAch != nullptr);
+        Assert::AreEqual(std::wstring(L"Ach5"), pAch->GetName());
+        Assert::AreEqual(std::wstring(L"Desc5"), pAch->GetDescription());
+        Assert::AreEqual(std::wstring(L"Auth5"), pAch->GetAuthor());
+        Assert::AreEqual(std::wstring(L"00555"), pAch->GetBadge());
+        Assert::AreEqual(ra::data::models::AssetCategory::Local, pAch->GetCategory());
+        Assert::AreEqual(20, pAch->GetPoints());
+        Assert::AreEqual(std::string("1=1"), pAch->GetTrigger());
+
+        // IDs from subset file are incrementally assigned after last core ID
+        pAch = game.Assets().FindAchievement(111000005U);
+        Assert::IsNotNull(pAch);
+        Ensures(pAch != nullptr);
+        Assert::AreEqual(std::wstring(L"Ach6"), pAch->GetName());
+        Assert::AreEqual(std::wstring(L"Desc6"), pAch->GetDescription());
+        Assert::AreEqual(std::wstring(L"Auth6"), pAch->GetAuthor());
+        Assert::AreEqual(std::wstring(L"00556"), pAch->GetBadge());
+        Assert::AreEqual(ra::data::models::AssetCategory::Local, pAch->GetCategory());
+        Assert::AreEqual(10, pAch->GetPoints());
+        Assert::AreEqual(std::string("R:1=1"), pAch->GetTrigger());
+
+        // migration should have moved the records in 2-User.txt into 1-User.txt and deleted 2-User.txt
+        Assert::IsFalse(game.mockStorage.HasStoredData(StorageItemType::UserAchievements, L"2"));
+        Assert::AreEqual(std::string(
+                "Version\n"
+                "Game\n"
+                "7:1=2:Ach2b:Desc2b::::Auth2b:25:1234554321:1234555555:::54321\n"
+                "111000002:\"1=1\":\"Ach3\":\"Desc3\"::::Auth3:20:1234511111:1234500000:::555\n"
+                "111000003:\"R:1=1\":Ach4:Desc4::::Auth4:10:1234511111:1234500000:::556\n"
+                "5:1=2:Ach1b:Desc1b::::Auth1b:25:1234554321:1234555555:::54321\n"
+                "0|3:\"1=1\":\"Ach5\":\"Desc5\"::::Auth5:20:1234511111:1234500000:::555\n"
+                "0|3:\"R:1=1\":Ach6:Desc6::::Auth6:10:1234511111:1234500000:::556\n"
+            ), game.mockStorage.GetStoredData(StorageItemType::UserAchievements, L"1"));
+    }
+
     TEST_METHOD(TestLoadGameLeaderboards)
     {
         GameContextHarness game;
@@ -1160,7 +1300,7 @@ public:
         game.MockLoadGameAPIs(1U, "0123456789abcdeffedcba987654321");
 
         bool bBeforeResponseCalled = false;
-        game.mockAchievementRuntime.OnBeforeResponse("r=patch&u=Username&t=ApiToken&m=0123456789abcdeffedcba987654321",
+        game.mockAchievementRuntime.OnBeforeResponse("r=achievementsets&u=Username&t=ApiToken&m=0123456789abcdeffedcba987654321",
             [&game, &bBeforeResponseCalled]() {
                 bBeforeResponseCalled = true;
                 Assert::IsTrue(game.mockAchievementRuntime.IsPaused());
@@ -1178,7 +1318,7 @@ public:
         game.MockLoadGameAPIs(1U, "0123456789abcdeffedcba987654321");
 
         bool bBeforeResponseCalled = false;
-        game.mockAchievementRuntime.OnBeforeResponse("r=patch&u=Username&t=ApiToken&m=0123456789abcdeffedcba987654321",
+        game.mockAchievementRuntime.OnBeforeResponse("r=achievementsets&u=Username&t=ApiToken&m=0123456789abcdeffedcba987654321",
             [&game, &bBeforeResponseCalled]() {
                 bBeforeResponseCalled = true;
                 Assert::IsTrue(game.mockAchievementRuntime.IsPaused());
@@ -1507,6 +1647,188 @@ public:
         // hardcore should remain disabled
         Assert::IsFalse(game.mockConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore));
     }
+
+    TEST_METHOD(TestInitializeSubsetsCoreOnly)
+    {
+        GameContextHarness game;
+        rc_api_achievement_set_definition_t subsets[1];
+        rc_api_fetch_game_sets_response_t sets;
+
+        memset(&subsets, 0, sizeof(subsets));
+        memset(&sets, 0, sizeof(sets));
+
+        subsets[0].id = 11111;
+        subsets[0].game_id = 2222;
+        subsets[0].title = "Game Title";
+        subsets[0].type = RC_ACHIEVEMENT_SET_TYPE_CORE;
+
+        sets.id = sets.session_game_id = subsets[0].game_id;
+        sets.sets = subsets;
+        sets.num_sets = sizeof(subsets) / sizeof(subsets[0]);
+
+        game.InitializeSubsets(&sets);
+
+        Assert::AreEqual(2222U, game.GameId());
+        Assert::AreEqual(2222U, game.ActiveGameId());
+
+        Assert::AreEqual({1}, game.Subsets().size());
+
+        const auto& pSubset = game.Subsets().front();
+        Assert::AreEqual(0U, pSubset.ID());
+        Assert::AreEqual(11111U, pSubset.AchievementSetID());
+        Assert::AreEqual(2222U, pSubset.GameID());
+        Assert::AreEqual(GameContext::SubsetType::Core, pSubset.Type());
+        Assert::AreEqual(std::wstring(L"Game Title"), pSubset.Title());
+    }
+
+    TEST_METHOD(TestInitializeSubsetsCoreWithBonus)
+    {
+        GameContextHarness game;
+        rc_api_achievement_set_definition_t subsets[3];
+        rc_api_fetch_game_sets_response_t sets;
+
+        memset(&subsets, 0, sizeof(subsets));
+        memset(&sets, 0, sizeof(sets));
+
+        subsets[0].id = 11111;
+        subsets[0].game_id = 2222;
+        subsets[0].title = "Game Title";
+        subsets[0].type = RC_ACHIEVEMENT_SET_TYPE_CORE;
+
+        subsets[1].id = 4444;
+        subsets[1].game_id = 3333;
+        subsets[1].title = "Bonus";
+        subsets[1].type = RC_ACHIEVEMENT_SET_TYPE_BONUS;
+
+        subsets[2].id = 6666;
+        subsets[2].game_id = 5555;
+        subsets[2].title = "Perfect Play";
+        subsets[2].type = RC_ACHIEVEMENT_SET_TYPE_BONUS;
+
+        sets.id = sets.session_game_id = subsets[0].game_id;
+        sets.sets = subsets;
+        sets.num_sets = sizeof(subsets) / sizeof(subsets[0]);
+
+        game.InitializeSubsets(&sets);
+
+        Assert::AreEqual(2222U, game.GameId());
+        Assert::AreEqual(2222U, game.ActiveGameId());
+
+        Assert::AreEqual({3}, game.Subsets().size());
+
+        const auto& pSubset = game.Subsets().at(0);
+        Assert::AreEqual(0U, pSubset.ID());
+        Assert::AreEqual(11111U, pSubset.AchievementSetID());
+        Assert::AreEqual(2222U, pSubset.GameID());
+        Assert::AreEqual(GameContext::SubsetType::Core, pSubset.Type());
+        Assert::AreEqual(std::wstring(L"Game Title"), pSubset.Title());
+
+        const auto& pSubset2 = game.Subsets().at(1);
+        Assert::AreEqual(4444U, pSubset2.ID());
+        Assert::AreEqual(4444U, pSubset2.AchievementSetID());
+        Assert::AreEqual(3333U, pSubset2.GameID());
+        Assert::AreEqual(GameContext::SubsetType::Bonus, pSubset2.Type());
+        Assert::AreEqual(std::wstring(L"Bonus"), pSubset2.Title());
+
+        const auto& pSubset3 = game.Subsets().at(2);
+        Assert::AreEqual(6666U, pSubset3.ID());
+        Assert::AreEqual(6666U, pSubset3.AchievementSetID());
+        Assert::AreEqual(5555U, pSubset3.GameID());
+        Assert::AreEqual(GameContext::SubsetType::Bonus, pSubset3.Type());
+        Assert::AreEqual(std::wstring(L"Perfect Play"), pSubset3.Title());
+    }
+
+    TEST_METHOD(TestInitializeSubsetsExclusive)
+    {
+        GameContextHarness game;
+        rc_api_achievement_set_definition_t subsets[1];
+        rc_api_fetch_game_sets_response_t sets;
+
+        memset(&subsets, 0, sizeof(subsets));
+        memset(&sets, 0, sizeof(sets));
+
+        subsets[0].id = 1111;
+        subsets[0].game_id = 2222;
+        subsets[0].title = "Game Title";
+        subsets[0].type = RC_ACHIEVEMENT_SET_TYPE_EXCLUSIVE;
+
+        sets.id = subsets[0].game_id;
+        sets.session_game_id = 2222;
+        sets.sets = subsets;
+        sets.num_sets = sizeof(subsets) / sizeof(subsets[0]);
+
+        game.InitializeSubsets(&sets);
+
+        Assert::AreEqual(2222U, game.GameId());
+        Assert::AreEqual(2222U, game.ActiveGameId());
+
+        Assert::AreEqual({1}, game.Subsets().size());
+
+        const auto& pSubset = game.Subsets().at(0);
+        Assert::AreEqual(1111U, pSubset.ID());
+        Assert::AreEqual(1111U, pSubset.AchievementSetID());
+        Assert::AreEqual(2222U, pSubset.GameID());
+        Assert::AreEqual(GameContext::SubsetType::Exclusive, pSubset.Type());
+        Assert::AreEqual(std::wstring(L"Game Title"), pSubset.Title());
+    }
+
+    TEST_METHOD(TestInitializeSubsetsSpecialtyWithBonus)
+    {
+        GameContextHarness game;
+        rc_api_achievement_set_definition_t subsets[3];
+        rc_api_fetch_game_sets_response_t sets;
+
+        memset(&subsets, 0, sizeof(subsets));
+        memset(&sets, 0, sizeof(sets));
+
+        subsets[0].id = 6666;
+        subsets[0].game_id = 2222;
+        subsets[0].title = "Game Title";
+        subsets[0].type = RC_ACHIEVEMENT_SET_TYPE_CORE;
+
+        subsets[1].id = 7777;
+        subsets[1].game_id = 5555;
+        subsets[1].title = "Perfect Play";
+        subsets[1].type = RC_ACHIEVEMENT_SET_TYPE_SPECIALTY;
+
+        subsets[2].id = 4444;
+        subsets[2].game_id = 3333;
+        subsets[2].title = "Bonus";
+        subsets[2].type = RC_ACHIEVEMENT_SET_TYPE_BONUS;
+
+        sets.id = subsets[0].game_id;
+        sets.session_game_id = subsets[1].game_id;
+        sets.sets = subsets;
+        sets.num_sets = sizeof(subsets) / sizeof(subsets[0]);
+
+        game.InitializeSubsets(&sets);
+
+        Assert::AreEqual(2222U, game.GameId());
+        Assert::AreEqual(5555U, game.ActiveGameId());
+
+        Assert::AreEqual({3}, game.Subsets().size());
+
+        const auto& pSubset = game.Subsets().at(0);
+        Assert::AreEqual(0U, pSubset.ID());
+        Assert::AreEqual(6666U, pSubset.AchievementSetID());
+        Assert::AreEqual(2222U, pSubset.GameID());
+        Assert::AreEqual(GameContext::SubsetType::Core, pSubset.Type());
+        Assert::AreEqual(std::wstring(L"Game Title"), pSubset.Title());
+
+        const auto& pSubset2 = game.Subsets().at(1);
+        Assert::AreEqual(7777U, pSubset2.ID());
+        Assert::AreEqual(7777U, pSubset2.AchievementSetID());
+        Assert::AreEqual(5555U, pSubset2.GameID());
+        Assert::AreEqual(GameContext::SubsetType::Specialty, pSubset2.Type());
+        Assert::AreEqual(std::wstring(L"Perfect Play"), pSubset2.Title());
+
+        const auto& pSubset3 = game.Subsets().at(2);
+        Assert::AreEqual(4444U, pSubset3.ID());
+        Assert::AreEqual(4444U, pSubset3.AchievementSetID());
+        Assert::AreEqual(3333U, pSubset3.GameID());
+        Assert::AreEqual(GameContext::SubsetType::Bonus, pSubset3.Type());
+        Assert::AreEqual(std::wstring(L"Bonus"), pSubset3.Title());
+    }
 };
 
 } // namespace tests
diff --git a/tests/mocks/MockAchievementRuntime.cpp b/tests/mocks/MockAchievementRuntime.cpp
index 17bda9a4..7cbe3f0f 100644
--- a/tests/mocks/MockAchievementRuntime.cpp
+++ b/tests/mocks/MockAchievementRuntime.cpp
@@ -55,6 +55,14 @@ void MockAchievementRuntime::MockGame()
     game->public_.title = "Game Title";
 
     GetClient()->game = game;
+
+    if (ra::services::ServiceLocator::Exists<ra::data::context::GameContext>())
+    {
+        auto* mockGameContext = dynamic_cast<ra::data::context::mocks::MockGameContext*>(
+            &ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>());
+        if (mockGameContext != nullptr)
+            mockGameContext->MockSubset(0, game->public_.title, ra::data::context::GameContext::SubsetType::Core);
+    }
 }
 
 static rc_client_subset_info_t* GetSubset(rc_client_game_info_t* game, uint32_t subset_id, const char* name)
@@ -141,6 +149,14 @@ void MockAchievementRuntime::MockSubset(uint32_t nSubsetId, const std::string& s
     auto* pSubset = GetSubset(game, nSubsetId, sName.c_str());
     // create a copy of the name in case it goes out of scope
     pSubset->public_.title = rc_buffer_strcpy(&game->buffer, sName.c_str());
+
+    if (ra::services::ServiceLocator::Exists<ra::data::context::GameContext>())
+    {
+        auto* mockGameContext = dynamic_cast<ra::data::context::mocks::MockGameContext*>(
+            &ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>());
+        if (mockGameContext != nullptr)
+            mockGameContext->MockSubset(nSubsetId, sName);
+    }
 }
 
 rc_client_achievement_info_t* MockAchievementRuntime::MockAchievement(uint32_t nId, const char* sTitle)
diff --git a/tests/mocks/MockGameContext.hh b/tests/mocks/MockGameContext.hh
index a346263c..1dd51b09 100644
--- a/tests/mocks/MockGameContext.hh
+++ b/tests/mocks/MockGameContext.hh
@@ -32,7 +32,7 @@ public:
     /// <summary>
     /// Sets the unique identifier of the currently loaded game.
     /// </summary>
-    void SetGameId(unsigned int nGameId) noexcept { m_nGameId = nGameId; }
+    void SetGameId(unsigned int nGameId) noexcept { m_nGameId = m_nActiveGameId = nGameId; }
 
     void NotifyActiveGameChanged() { OnActiveGameChanged(); }
 
@@ -117,6 +117,16 @@ public:
 
     void InitializeFromAchievementRuntime();
 
+    void MockSubset(uint32_t nSubsetId, const std::string& sName, SubsetType nType = SubsetType::Bonus)
+    {
+        MockSubset(nSubsetId, nSubsetId, sName, nType);
+    }
+
+    void MockSubset(uint32_t nGameId, uint32_t nAchievementSetId, const std::string& sName, SubsetType nType = SubsetType::Bonus)
+    {
+        m_vSubsets.emplace_back(nAchievementSetId, nGameId, ra::Widen(sName), nType);
+    }
+
 private:
     class MockCodeNotesModel : public ra::data::models::CodeNotesModel
     {
diff --git a/tests/mocks/MockLocalStorage.hh b/tests/mocks/MockLocalStorage.hh
index 9d270eaf..7595d1f9 100644
--- a/tests/mocks/MockLocalStorage.hh
+++ b/tests/mocks/MockLocalStorage.hh
@@ -37,11 +37,16 @@ public:
         return sEmpty;
     }
 
-    void DeleteStoredData(ra::services::StorageItemType nType, const std::wstring& sKey)
+    bool Delete(ra::services::StorageItemType nType, const std::wstring& sKey) override
     {
         const auto pMap = m_mStoredData.find(nType);
         if (pMap != m_mStoredData.end())
+        {
             pMap->second.erase(sKey);
+            return true;
+        }
+
+        return false;
     }
 
     void MockLastModified(StorageItemType nType, const std::wstring& sKey,
diff --git a/tests/ui/viewmodels/AssetUploadViewModel_Tests.cpp b/tests/ui/viewmodels/AssetUploadViewModel_Tests.cpp
index b3f99d06..9dc675fd 100644
--- a/tests/ui/viewmodels/AssetUploadViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/AssetUploadViewModel_Tests.cpp
@@ -380,6 +380,48 @@ public:
         vmUpload.AssertSuccess(1);
     }
 
+    TEST_METHOD(TestSingleLocalAchievementSubset)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        vmUpload.mockGameContext.MockSubset(33, 22, "Subset");
+        auto& pAchievement = vmUpload.AddAchievement(AssetCategory::Local, 5, L"Title1", L"Desc1", L"12345", "0xH1234=1");
+        pAchievement.SetSubsetID(22U);
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement.GetChanges());
+
+        vmUpload.QueueAsset(pAchievement);
+        Assert::AreEqual({1U}, vmUpload.TaskCount());
+
+        bool bApiCalled = false;
+        vmUpload.mockServer.HandleRequest<ra::api::UpdateAchievement>(
+            [&bApiCalled](const ra::api::UpdateAchievement::Request& pRequest,
+                          ra::api::UpdateAchievement::Response& pResponse) {
+                bApiCalled = true;
+                Assert::AreEqual(33U, pRequest.GameId);
+                Assert::AreEqual(std::wstring(L"Title1"), pRequest.Title);
+                Assert::AreEqual(std::wstring(L"Desc1"), pRequest.Description);
+                Assert::AreEqual(std::string("0xH1234=1"), pRequest.Trigger);
+                Assert::AreEqual(5U, pRequest.Points);
+                Assert::AreEqual(std::string("12345"), pRequest.Badge);
+                Assert::AreEqual(5U, pRequest.Category);
+                Assert::AreEqual(0U, pRequest.AchievementId);
+
+                pResponse.AchievementId = 7716U;
+                pResponse.Result = ra::api::ApiResult::Success;
+                return true;
+            });
+
+        vmUpload.DoUpload();
+
+        Assert::IsTrue(bApiCalled);
+
+        // published local achievement should be changed to unofficial and have it's ID updated
+        Assert::AreEqual(AssetCategory::Unofficial, pAchievement.GetCategory());
+        Assert::AreEqual(7716U, pAchievement.GetID());
+        Assert::AreEqual(AssetChanges::None, pAchievement.GetChanges());
+
+        vmUpload.AssertSuccess(1);
+    }
+
     TEST_METHOD(TestSingleCoreAchievementError)
     {
         AssetUploadViewModelHarness vmUpload;
@@ -1045,6 +1087,51 @@ public:
         vmUpload.AssertSuccess(1);
     }
 
+    TEST_METHOD(TestSingleLocalLeaderboardSubset)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        vmUpload.mockGameContext.MockSubset(33, 22, "Subset");
+        auto& pLeaderboard = vmUpload.AddLeaderboard(AssetCategory::Local, L"Title1", L"Desc1", "0xH1234=1",
+                                                     "0xH1234=2", "0xH1234=3", "0xH2345", ra::data::ValueFormat::Score);
+        pLeaderboard.SetSubsetID(22U);
+        Assert::AreEqual(AssetChanges::Unpublished, pLeaderboard.GetChanges());
+
+        vmUpload.QueueAsset(pLeaderboard);
+        Assert::AreEqual({1U}, vmUpload.TaskCount());
+
+        bool bApiCalled = false;
+        vmUpload.mockServer.HandleRequest<ra::api::UpdateLeaderboard>(
+            [&bApiCalled](const ra::api::UpdateLeaderboard::Request& pRequest,
+                          ra::api::UpdateLeaderboard::Response& pResponse) {
+                bApiCalled = true;
+                Assert::AreEqual(33U, pRequest.GameId);
+                Assert::AreEqual(std::wstring(L"Title1"), pRequest.Title);
+                Assert::AreEqual(std::wstring(L"Desc1"), pRequest.Description);
+                Assert::AreEqual(std::string("0xH1234=1"), pRequest.StartTrigger);
+                Assert::AreEqual(std::string("0xH1234=2"), pRequest.SubmitTrigger);
+                Assert::AreEqual(std::string("0xH1234=3"), pRequest.CancelTrigger);
+                Assert::AreEqual(std::string("0xH2345"), pRequest.ValueDefinition);
+                Assert::AreEqual(ra::data::ValueFormat::Score, pRequest.Format);
+                Assert::IsFalse(pRequest.LowerIsBetter);
+                Assert::AreEqual(0U, pRequest.LeaderboardId);
+
+                pResponse.LeaderboardId = 7716U;
+                pResponse.Result = ra::api::ApiResult::Success;
+                return true;
+            });
+
+        vmUpload.DoUpload();
+
+        Assert::IsTrue(bApiCalled);
+
+        // published local leaderboard should be changed to core and have it's ID updated
+        Assert::AreEqual(AssetCategory::Core, pLeaderboard.GetCategory());
+        Assert::AreEqual(7716U, pLeaderboard.GetID());
+        Assert::AreEqual(AssetChanges::None, pLeaderboard.GetChanges());
+
+        vmUpload.AssertSuccess(1);
+    }
+
     TEST_METHOD(TestSingleCodeNoteNew)
     {
         AssetUploadViewModelHarness vmUpload;
@@ -1076,6 +1163,38 @@ public:
         vmUpload.AssertSuccess(1);
     }
 
+    TEST_METHOD(TestSingleCodeNoteNewSubset)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        vmUpload.mockGameContext.MockSubset(33, 22, "Subset");
+        vmUpload.CodeNotes().SetCodeNote(0x1234, L"This is a note.");
+        Assert::AreEqual(AssetChanges::Unpublished, vmUpload.CodeNotes().GetChanges());
+
+        vmUpload.QueueAsset(vmUpload.CodeNotes());
+        Assert::AreEqual({1U}, vmUpload.TaskCount());
+        Assert::IsFalse(vmUpload.mockDesktop.WasDialogShown());
+
+        bool bApiCalled = false;
+        vmUpload.mockServer.HandleRequest<ra::api::UpdateCodeNote>(
+            [&bApiCalled](const ra::api::UpdateCodeNote::Request& pRequest,
+                          ra::api::UpdateCodeNote::Response& pResponse) {
+                bApiCalled = true;
+                Assert::AreEqual(AssetUploadViewModelHarness::GameId, pRequest.GameId);
+                Assert::AreEqual(0x1234U, pRequest.Address);
+                Assert::AreEqual(std::wstring(L"This is a note."), pRequest.Note);
+
+                pResponse.Result = ra::api::ApiResult::Success;
+                return true;
+            });
+
+        vmUpload.DoUpload();
+
+        Assert::IsTrue(bApiCalled);
+        Assert::AreEqual(AssetChanges::None, vmUpload.CodeNotes().GetChanges());
+
+        vmUpload.AssertSuccess(1);
+    }
+
     TEST_METHOD(TestSingleCodeNoteDeleted)
     {
         AssetUploadViewModelHarness vmUpload;
diff --git a/tests/ui/viewmodels/OverlayRecentGamesPageViewModel_Tests.cpp b/tests/ui/viewmodels/OverlayRecentGamesPageViewModel_Tests.cpp
index c8ceb781..f7476b73 100644
--- a/tests/ui/viewmodels/OverlayRecentGamesPageViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/OverlayRecentGamesPageViewModel_Tests.cpp
@@ -4,6 +4,7 @@
 
 #include "tests\mocks\MockAchievementRuntime.hh"
 #include "tests\mocks\MockConfiguration.hh"
+#include "tests\mocks\MockGameContext.hh"
 #include "tests\mocks\MockHttpRequester.hh"
 #include "tests\mocks\MockImageRepository.hh"
 #include "tests\mocks\MockLocalStorage.hh"
@@ -29,6 +30,7 @@ private:
     public:
         ra::api::mocks::MockServer mockServer;
         ra::data::context::mocks::MockSessionTracker mockSessions;
+        ra::data::context::mocks::MockGameContext mockGameContext;
         ra::data::context::mocks::MockUserContext mockUserContext;
         ra::services::mocks::MockConfiguration mockConfiguration;
         ra::services::mocks::MockLocalStorage mockLocalStorage;
@@ -152,6 +154,7 @@ public:
     TEST_METHOD(TestRefreshOneGameDataCached)
     {
         OverlayRecentGamesPageViewModelHarness gamesPage;
+        gamesPage.mockGameContext.SetGameId(3U);
         gamesPage.mockSessions.MockSession(3U, 1234567890U, std::chrono::seconds(5000));
 
         bool bHttpRequesterCalled = false;