Repository: RetroAchievements/RAIntegration
Pull request #1155 (2025-04-01 21:02:00)

prevent exception processing nested pointer without annotation
https://discord.com/channels/310192285306454017/1149693430306447380/1356718618209157351


Patch:
diff --git a/src/RA_Shared.rc b/src/RA_Shared.rc
index 2b2dde7b..3c4a0f92 100644
--- a/src/RA_Shared.rc
+++ b/src/RA_Shared.rc
@@ -72,29 +72,30 @@ BEGIN
     CONTROL         "Viewer",IDC_RA_MEMVIEWER,"MemoryViewerControl",WS_TABSTOP,4,218,324,88
 END
 
-IDD_RA_ACHIEVEMENTS DIALOGEX 0, 0, 439, 165
+IDD_RA_ACHIEVEMENTS DIALOGEX 0, 0, 368, 180
 STYLE DS_SETFONT | DS_FIXEDSYS | WS_POPUP | WS_CAPTION | WS_SYSMENU | WS_THICKFRAME
 CAPTION "Achievements"
 FONT 8, "MS Shell Dlg", 400, 0, 0x1
 BEGIN
-    COMBOBOX        IDC_RA_SPECIAL_FILTER,4,5,54,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
-    COMBOBOX        IDC_RA_CATEGORY,60,5,45,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
-    COMBOBOX        IDC_RA_TYPE_FILTER,107,5,60,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
-    LTEXT           "Game ID:",IDC_RA_LBL_ID,193,7,31,8
-    RTEXT           "999999",IDC_RA_ID,225,7,24,11
-    LTEXT           "Achievements:",IDC_RA_ACHIEVEMENTS,256,7,48,8
-    RTEXT           "999",IDC_RA_NUMACH,304,7,12,11
-    LTEXT           "Points:",IDC_RA_POINTS,322,7,23,8
-    RTEXT           "9999",IDC_RA_POINT_TOTAL,345,7,16,11
-    CONTROL         "Processing Active",IDC_RA_CHKACHPROCESSINGACTIVE,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,367,6,68,10
-    CONTROL         "",IDC_RA_LISTACHIEVEMENTS,"SysListView32",LVS_REPORT | LVS_SHOWSELALWAYS | LVS_ALIGNLEFT | WS_BORDER | WS_TABSTOP,4,20,360,141
-    PUSHBUTTON      "&Activate All",IDC_RA_RESET_ACH,367,20,68,16,BS_MULTILINE
-    CONTROL         "&Keep Active",IDC_RA_CHK_ACTIVE,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,369,37,68,10
-    PUSHBUTTON      "&Save All",IDC_RA_COMMIT_ACH,367,57,68,16,BS_MULTILINE
-    PUSHBUTTON      "&Reset All",IDC_RA_DOWNLOAD_ACH,367,76,68,16,BS_MULTILINE | WS_DISABLED
-    PUSHBUTTON      "Re&vert All",IDC_RA_REVERTSELECTED,367,95,68,16,BS_MULTILINE
-    PUSHBUTTON      "&New",IDC_RA_NEW_ASSET,367,126,68,16,BS_MULTILINE | WS_DISABLED
-    PUSHBUTTON      "&Clone",IDC_RA_CLONE_ASSET,367,145,68,16,BS_MULTILINE
+    COMBOBOX        IDC_RA_GAMENAME,4,4,175,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
+    LTEXT           "Game ID:",IDC_RA_LBL_ID,187,6,31,8
+    RTEXT           "999999",IDC_RA_ID,219,6,24,11
+    LTEXT           "Achievements:",IDC_RA_ACHIEVEMENTS,253,6,48,8
+    RTEXT           "999",IDC_RA_NUMACH,301,6,12,11
+    LTEXT           "Points:",IDC_RA_POINTS,322,6,23,8
+    RTEXT           "9999",IDC_RA_POINT_TOTAL,345,6,16,11
+    COMBOBOX        IDC_RA_SPECIAL_FILTER,4,20,58,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
+    COMBOBOX        IDC_RA_CATEGORY,64,20,49,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
+    COMBOBOX        IDC_RA_TYPE_FILTER,115,20,64,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
+    CONTROL         "&Processing Active",IDC_RA_CHKACHPROCESSINGACTIVE,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,187,22,68,10
+    CONTROL         "&Keep Active",IDC_RA_CHK_ACTIVE,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,261,22,54,10
+    PUSHBUTTON      "&Activate All",IDC_RA_RESET_ACH,315,19,50,14,BS_MULTILINE
+    CONTROL         "",IDC_RA_LISTACHIEVEMENTS,"SysListView32",LVS_REPORT | LVS_SHOWSELALWAYS | LVS_ALIGNLEFT | WS_BORDER | WS_TABSTOP,4,36,360,125
+    PUSHBUTTON      "&New",IDC_RA_NEW_ASSET,3,163,50,14,BS_MULTILINE | WS_DISABLED
+    PUSHBUTTON      "&Clone",IDC_RA_CLONE_ASSET,55,163,50,14,BS_MULTILINE
+    PUSHBUTTON      "&Save All",IDC_RA_COMMIT_ACH,211,163,50,14,BS_MULTILINE
+    PUSHBUTTON      "&Reset All",IDC_RA_DOWNLOAD_ACH,263,163,50,14,BS_MULTILINE | WS_DISABLED
+    PUSHBUTTON      "Re&vert All",IDC_RA_REVERTSELECTED,315,163,50,14,BS_MULTILINE
 END
 
 IDD_RA_LOGIN DIALOGEX 0, 0, 188, 132
diff --git a/src/data/models/AchievementModel.cpp b/src/data/models/AchievementModel.cpp
index 5954d1d6..a029cb69 100644
--- a/src/data/models/AchievementModel.cpp
+++ b/src/data/models/AchievementModel.cpp
@@ -437,9 +437,9 @@ void AchievementModel::SyncTrigger()
                 rc_reset_parse_state(&preparse.parse, trigger_buffer, nullptr, 0);
                 trigger = RC_ALLOC(rc_trigger_with_memrefs_t, &preparse.parse);
                 rc_preparse_alloc_memrefs(&trigger->memrefs, &preparse);
+                Expects(preparse.parse.memrefs == &trigger->memrefs);
 
                 preparse.parse.existing_memrefs = pGame->runtime.memrefs;
-                preparse.parse.memrefs = &trigger->memrefs;
 
                 sMemaddr = sTrigger.c_str();
                 m_pAchievement->trigger = &trigger->trigger;
diff --git a/src/data/models/CodeNoteModel.cpp b/src/data/models/CodeNoteModel.cpp
index 345b0d48..3762c1de 100644
--- a/src/data/models/CodeNoteModel.cpp
+++ b/src/data/models/CodeNoteModel.cpp
@@ -342,8 +342,11 @@ void CodeNoteModel::SetNote(const std::wstring& sNote, bool bImpliedPointer)
                 // found a line starting with a plus sign, bit no pointer annotation. bImpliedPointer
                 // must be true. assume the parent note is not described. pass -1 as the note size
                 // because we already skipped over the newline character
-                ProcessIndirectNotes(sNote, gsl::narrow_cast<size_t>(-1));
-                m_pPointerData->HeaderLength = 0;
+                ProcessIndirectNotes(sNote, nIndex - 1);
+
+                // if nIndex is 0, HeaderLength may get set to -1, force it back to 0.
+                if (m_pPointerData && nIndex == 0)
+                    m_pPointerData->HeaderLength = 0;
                 break;
             }
 
diff --git a/src/data/models/LeaderboardModel.cpp b/src/data/models/LeaderboardModel.cpp
index fbdd70fb..e6dab73d 100644
--- a/src/data/models/LeaderboardModel.cpp
+++ b/src/data/models/LeaderboardModel.cpp
@@ -198,17 +198,18 @@ void LeaderboardModel::HandleStateChanged(AssetState nOldState, AssetState nNewS
     if (!bIsActive && bWasActive)
     {
         const auto* pLeaderboard = m_pLeaderboard->lboard;
-        Expects(pLeaderboard != nullptr);
-
-        m_pCapturedStartTriggerHits.Capture(&pLeaderboard->start, GetStartTrigger());
-        m_pCapturedSubmitTriggerHits.Capture(&pLeaderboard->submit, GetSubmitTrigger());
-        m_pCapturedCancelTriggerHits.Capture(&pLeaderboard->cancel, GetCancelTrigger());
-        m_pCapturedValueDefinitionHits.Capture(&pLeaderboard->value, GetValueDefinition());
-
-        if (pLeaderboard->state == RC_LBOARD_STATE_STARTED)
+        if (pLeaderboard != nullptr)
         {
-            auto& pRuntime = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-            pRuntime.ReleaseLeaderboardTracker(m_pLeaderboard->public_.id);
+            m_pCapturedStartTriggerHits.Capture(&pLeaderboard->start, GetStartTrigger());
+            m_pCapturedSubmitTriggerHits.Capture(&pLeaderboard->submit, GetSubmitTrigger());
+            m_pCapturedCancelTriggerHits.Capture(&pLeaderboard->cancel, GetCancelTrigger());
+            m_pCapturedValueDefinitionHits.Capture(&pLeaderboard->value, GetValueDefinition());
+
+            if (pLeaderboard->state == RC_LBOARD_STATE_STARTED)
+            {
+                auto& pRuntime = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+                pRuntime.ReleaseLeaderboardTracker(m_pLeaderboard->public_.id);
+            }
         }
     }
     else if (bIsActive && !bWasActive)
diff --git a/src/services/AchievementRuntime.cpp b/src/services/AchievementRuntime.cpp
index 122fae70..1896c96f 100644
--- a/src/services/AchievementRuntime.cpp
+++ b/src/services/AchievementRuntime.cpp
@@ -197,6 +197,9 @@ AchievementRuntime::AchievementRuntime()
     rc_client_enable_logging(m_pClient.get(), RC_CLIENT_LOG_LEVEL_VERBOSE, AchievementRuntime::LogMessage);
 
     const auto& pConfiguration = ra::services::ServiceLocator::Get<ra::services::IConfiguration>();
+    if (pConfiguration.IsCustomHost())
+        rc_client_set_host(m_pClient.get(), pConfiguration.GetHostUrl().c_str());
+
     const auto bHardcore = pConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore);
     rc_client_set_hardcore_enabled(m_pClient.get(), bHardcore ? 1 : 0);
 #endif
@@ -805,6 +808,13 @@ 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)
@@ -853,6 +863,12 @@ 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)
diff --git a/src/services/AchievementRuntime.hh b/src/services/AchievementRuntime.hh
index bc84b0f1..edd2e077 100644
--- a/src/services/AchievementRuntime.hh
+++ b/src/services/AchievementRuntime.hh
@@ -67,6 +67,8 @@ public:
 
     static std::string GetAchievementBadge(const rc_client_achievement_t& pAchievement);
 
+    void GetSubsets(std::vector<std::pair<uint32_t, std::wstring>>& vSubsets) const;
+
     void RaiseClientEvent(rc_client_achievement_info_t& pAchievement, uint32_t nEventType) const noexcept;
 
     void UpdateActiveAchievements() noexcept;
diff --git a/src/ui/viewmodels/AssetEditorViewModel.cpp b/src/ui/viewmodels/AssetEditorViewModel.cpp
index 6c2e8474..4e91caa1 100644
--- a/src/ui/viewmodels/AssetEditorViewModel.cpp
+++ b/src/ui/viewmodels/AssetEditorViewModel.cpp
@@ -276,6 +276,8 @@ void AssetEditorViewModel::LoadAsset(ra::data::models::AssetModelBase* pAsset, b
         ra::data::models::CapturedTriggerHits pCapturedHits;
         Trigger().InitializeFrom("", pCapturedHits);
     }
+
+    OnTriggerChanged();
 }
 
 static constexpr ra::data::models::LeaderboardModel::LeaderboardParts LeaderboardPartToParts(AssetEditorViewModel::LeaderboardPart nPart) noexcept
@@ -626,8 +628,25 @@ void AssetEditorViewModel::HandleStateChanged(ra::data::models::AssetState nOldS
     const bool bIsActive = ra::data::models::AssetModelBase::IsActive(nNewState);
     const bool bWasActive = ra::data::models::AssetModelBase::IsActive(nOldState);
     if (bIsActive && !bWasActive)
+    {
         UpdateTriggerBinding();
 
+        const auto& pRuntime = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+        const auto bActivateSucceeded = IsAchievement()
+            ? (pRuntime.GetAchievementTrigger(m_pAsset->GetID()) != nullptr)
+            : (pRuntime.GetLeaderboardDefinition(m_pAsset->GetID()) != nullptr);
+
+        if (!bActivateSucceeded)
+        {
+            MessageBoxViewModel::ShowErrorMessage(*this, L"Activation failed.");
+            // NOTE: cannot set State back to Inactive here as the this code is triggered
+            // by the binding and the binding disables watching the model while it's firing
+            // its notifications. Instead, let Asset.DoFrame() deactivate the asset and
+            // that will fire events that will cause the binding to uncheck itself.
+            return;
+        }
+    }
+
     // if the state changed to/from Waiting, update the Waiting label
     if (nNewState == ra::data::models::AssetState::Waiting)
         SetValue(WaitingLabelProperty, L"Waiting");
@@ -679,25 +698,25 @@ void AssetEditorViewModel::OnTriggerChanged()
 
         std::string sDefinition = "STA:";
         if (nSelectedPart == LeaderboardPart::Start)
-            sDefinition += sTrigger;
+            AppendTrigger(sDefinition, sTrigger);
         else
             AppendTrigger(sDefinition, pLeaderboard->GetStartTrigger());
 
         sDefinition.append("::SUB:");
         if (nSelectedPart == LeaderboardPart::Submit)
-            sDefinition += sTrigger;
+            AppendTrigger(sDefinition, sTrigger);
         else
             AppendTrigger(sDefinition, pLeaderboard->GetSubmitTrigger());
 
         sDefinition.append("::CAN:");
         if (nSelectedPart == LeaderboardPart::Cancel)
-            sDefinition += sTrigger;
+            AppendTrigger(sDefinition, sTrigger);
         else
             AppendTrigger(sDefinition, pLeaderboard->GetCancelTrigger());
 
         sDefinition.append("::VAL:");
         if (nSelectedPart == LeaderboardPart::Value)
-            sDefinition += sTrigger;
+            AppendTrigger(sDefinition, sTrigger);
         else
             AppendTrigger(sDefinition, pLeaderboard->GetValueDefinition());
 
diff --git a/src/ui/viewmodels/AssetListViewModel.cpp b/src/ui/viewmodels/AssetListViewModel.cpp
index f69737f7..bda5f757 100644
--- a/src/ui/viewmodels/AssetListViewModel.cpp
+++ b/src/ui/viewmodels/AssetListViewModel.cpp
@@ -17,6 +17,8 @@
 #include "ui\viewmodels\OverlayManager.hh"
 #include "ui\viewmodels\WindowManager.hh"
 
+#include "rcheevos\src\rc_client_internal.h"
+
 #include "Exports.hh"
 #include "RA_BuildVer.h"
 #include "RA_Log.h"
@@ -40,7 +42,8 @@ const BoolModelProperty AssetListViewModel::CanResetProperty("AssetListViewModel
 const BoolModelProperty AssetListViewModel::CanRevertProperty("AssetListViewModel", "CanRevert", false);
 const BoolModelProperty AssetListViewModel::CanCreateProperty("AssetListViewModel", "CanCreate", false);
 const BoolModelProperty AssetListViewModel::CanCloneProperty("AssetListViewModel", "CanClone", false);
-const IntModelProperty AssetListViewModel::FilterCategoryProperty("AssetListViewModel", "FilterCategory", ra::etoi(AssetListViewModel::FilterCategory::Core));
+const IntModelProperty AssetListViewModel::SubsetFilterProperty("AssetListViewModel", "SubsetFilter", 0);
+const IntModelProperty AssetListViewModel::CategoryFilterProperty("AssetListViewModel", "FilterCategory", ra::etoi(AssetListViewModel::CategoryFilter::Core));
 const IntModelProperty AssetListViewModel::SpecialFilterProperty("AssetListViewModel", "SpecialFilter", ra::etoi(AssetListViewModel::SpecialFilter::All));
 const IntModelProperty AssetListViewModel::AssetTypeFilterProperty("AssetListViewModel", "AssetTypeFilter", ra::etoi(ra::data::models::AssetType::Achievement));
 const IntModelProperty AssetListViewModel::EnsureVisibleAssetIndexProperty("AssetListViewModel", "EnsureVisibleAssetIndex", -1);
@@ -57,10 +60,10 @@ AssetListViewModel::AssetListViewModel() noexcept
     m_vStates.Add(ra::etoi(ra::data::models::AssetState::Triggered), L"Triggered");
     m_vStates.Add(ra::etoi(ra::data::models::AssetState::Disabled), L"Disabled");
 
-    m_vCategories.Add(ra::etoi(FilterCategory::All), L"All");
-    m_vCategories.Add(ra::etoi(FilterCategory::Core), L"Core");
-    m_vCategories.Add(ra::etoi(FilterCategory::Unofficial), L"Unofficial");
-    m_vCategories.Add(ra::etoi(FilterCategory::Local), L"Local");
+    m_vCategories.Add(ra::etoi(CategoryFilter::All), L"All");
+    m_vCategories.Add(ra::etoi(CategoryFilter::Core), L"Core");
+    m_vCategories.Add(ra::etoi(CategoryFilter::Unofficial), L"Unofficial");
+    m_vCategories.Add(ra::etoi(CategoryFilter::Local), L"Local");
 
     m_vSpecialFilters.Add(ra::etoi(SpecialFilter::All), L"All");
     m_vSpecialFilters.Add(ra::etoi(SpecialFilter::Active), L"Active");
@@ -102,21 +105,46 @@ void AssetListViewModel::OnActiveGameChanged()
 {
     const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
     SetGameId(pGameContext.GameId());
+    SetSubsetFilter(0);
 
     switch (pGameContext.Assets().MostPublishedAssetCategory())
     {
         case ra::data::models::AssetCategory::Core:
-            SetFilterCategory(ra::ui::viewmodels::AssetListViewModel::FilterCategory::Core);
+            SetCategoryFilter(ra::ui::viewmodels::AssetListViewModel::CategoryFilter::Core);
             break;
 
         case ra::data::models::AssetCategory::Unofficial:
-            SetFilterCategory(ra::ui::viewmodels::AssetListViewModel::FilterCategory::Unofficial);
+            SetCategoryFilter(ra::ui::viewmodels::AssetListViewModel::CategoryFilter::Unofficial);
             break;
 
         case ra::data::models::AssetCategory::Local:
-            SetFilterCategory(ra::ui::viewmodels::AssetListViewModel::FilterCategory::Local);
+            SetCategoryFilter(ra::ui::viewmodels::AssetListViewModel::CategoryFilter::Local);
             break;
     }
+
+    m_vSubsets.BeginUpdate();
+    m_vSubsets.Clear();
+
+    if (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);
+        }
+    }
+
+    m_vSubsets.EndUpdate();
+
+    ApplyFilter();
 }
 
 void AssetListViewModel::OnDataModelStringValueChanged(gsl::index nIndex, const StringModelProperty::ChangeArgs& args)
@@ -206,7 +234,7 @@ void AssetListViewModel::OnDataModelIntValueChanged(gsl::index nIndex, const Int
 
         UpdateTotals();
 
-        if (GetFilterCategory() != FilterCategory::All)
+        if (GetCategoryFilter() != CategoryFilter::All)
             AddOrRemoveFilteredItem(nIndex);
     }
     else if (args.Property == ra::data::models::AssetModelBase::StateProperty ||
@@ -336,9 +364,10 @@ void AssetListViewModel::UpdateTotals()
 
 void AssetListViewModel::OnValueChanged(const IntModelProperty::ChangeArgs& args)
 {
-    if (args.Property == FilterCategoryProperty ||
+    if (args.Property == CategoryFilterProperty ||
         args.Property == SpecialFilterProperty ||
-        args.Property == AssetTypeFilterProperty)
+        args.Property == AssetTypeFilterProperty ||
+        args.Property == SubsetFilterProperty)
     {
         ApplyFilter();
     }
@@ -397,12 +426,12 @@ void AssetListViewModel::ApplyFilter()
 void AssetListViewModel::EnsureAppearsInFilteredList(const ra::data::models::AssetModelBase& pAsset)
 {
     // if the filter category is not all, ensure it matches the asset
-    const auto nFilterCategory = GetFilterCategory();
-    if (nFilterCategory != FilterCategory::All)
+    const auto nCategoryFilter = GetCategoryFilter();
+    if (nCategoryFilter != CategoryFilter::All)
     {
-        const auto nAssetCategory = ra::itoe<ra::data::models::AssetCategory>(ra::etoi(nFilterCategory));
+        const auto nAssetCategory = ra::itoe<ra::data::models::AssetCategory>(ra::etoi(nCategoryFilter));
         if (pAsset.GetCategory() != nAssetCategory)
-            SetValue(FilterCategoryProperty, ra::etoi(pAsset.GetCategory()));
+            SetValue(CategoryFilterProperty, ra::etoi(pAsset.GetCategory()));
     }
 
     // if there's a special filter and it doesn't match, clear the special filter
@@ -412,14 +441,17 @@ void AssetListViewModel::EnsureAppearsInFilteredList(const ra::data::models::Ass
 
 bool AssetListViewModel::MatchesFilter(const ra::data::models::AssetModelBase& pAsset) const
 {
-    const auto nFilterCategory = GetFilterCategory();
-    if (nFilterCategory != FilterCategory::All)
+    const auto nCategoryFilter = GetCategoryFilter();
+    if (nCategoryFilter != CategoryFilter::All)
     {
-        const auto nAssetCategory = ra::itoe<ra::data::models::AssetCategory>(ra::etoi(nFilterCategory));
+        const auto nAssetCategory = ra::itoe<ra::data::models::AssetCategory>(ra::etoi(nCategoryFilter));
         if (pAsset.GetCategory() != nAssetCategory)
             return false;
     }
 
+    if (pAsset.GetSubsetID() != GetSubsetFilter())
+        return false;
+
     const auto nAssetTypeFilter = GetAssetTypeFilter();
     if (nAssetTypeFilter != ra::data::models::AssetType::None)
     {
@@ -1699,11 +1731,13 @@ void AssetListViewModel::CreateNew()
         case ra::data::models::AssetType::Achievement:
             RA_LOG_INFO("Creating new achievement");
             pNewAsset = &pGameContext.Assets().NewAchievement();
+            pNewAsset->SetSubsetID(GetSubsetFilter());
             break;
 
         case ra::data::models::AssetType::Leaderboard:
             RA_LOG_INFO("Creating new leaderboard");
             pNewAsset = &pGameContext.Assets().NewLeaderboard();
+            pNewAsset->SetSubsetID(GetSubsetFilter());
             break;
 
         case ra::data::models::AssetType::RichPresence:
diff --git a/src/ui/viewmodels/AssetListViewModel.hh b/src/ui/viewmodels/AssetListViewModel.hh
index d307aa54..ec2a5687 100644
--- a/src/ui/viewmodels/AssetListViewModel.hh
+++ b/src/ui/viewmodels/AssetListViewModel.hh
@@ -96,6 +96,14 @@ public:
     /// </summary>
     void SetKeepActive(bool bValue) { SetValue(KeepActiveProperty, bValue); }
 
+    /// <summary>
+    /// Gets the list of asset subsets.
+    /// </summary>
+    const LookupItemViewModelCollection& Subsets() const noexcept 
+    {
+        return m_vSubsets;
+    }
+
     /// <summary>
     /// Gets the list of asset states.
     /// </summary>
@@ -168,7 +176,23 @@ public:
     bool CanClone() const { return GetValue(CanCloneProperty); }
     void CloneSelected();
 
-    enum class FilterCategory
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for the subset filter.
+    /// </summary>
+    static const IntModelProperty SubsetFilterProperty;
+
+    /// <summary>
+    /// Gets the subset filter.
+    /// </summary>
+    uint32_t GetSubsetFilter() const { return ra::to_unsigned(GetValue(SubsetFilterProperty)); }
+
+    /// <summary>
+    /// Sets the subset filter.
+    /// </summary>
+    void SetSubsetFilter(uint32_t nValue) { SetValue(SubsetFilterProperty, ra::to_signed(nValue)); }
+
+
+    enum class CategoryFilter
     {
         Local = ra::etoi(ra::data::models::AssetCategory::Local),
         All = -1,
@@ -177,19 +201,19 @@ public:
     };
 
     /// <summary>
-    /// The <see cref="ModelProperty" /> for the filter category.
+    /// The <see cref="ModelProperty" /> for the category filter.
     /// </summary>
-    static const IntModelProperty FilterCategoryProperty;
+    static const IntModelProperty CategoryFilterProperty;
 
     /// <summary>
     /// Gets the filter category.
     /// </summary>
-    FilterCategory GetFilterCategory() const { return ra::itoe<FilterCategory>(GetValue(FilterCategoryProperty)); }
+    CategoryFilter GetCategoryFilter() const { return ra::itoe<CategoryFilter>(GetValue(CategoryFilterProperty)); }
 
     /// <summary>
     /// Sets the filter category.
     /// </summary>
-    void SetFilterCategory(FilterCategory nValue) { SetValue(FilterCategoryProperty, ra::etoi(nValue)); }
+    void SetCategoryFilter(CategoryFilter nValue) { SetValue(CategoryFilterProperty, ra::etoi(nValue)); }
 
     enum class SpecialFilter
     {
@@ -269,8 +293,8 @@ public:
 protected:
     virtual void Publish(std::vector<ra::data::models::AssetModelBase*>& vAssets);
     bool ValidateAssetsForCore(std::vector<ra::data::models::AssetModelBase*>& vAssets, bool bCoreOnly);
-    virtual void ValidateAchievementForCore(_UNUSED std::wstring& sError, _UNUSED const ra::data::models::AchievementModel& pAchievement) const noexcept(false);
-    virtual void ValidateLeaderboardForCore(_UNUSED std::wstring& sError, _UNUSED const ra::data::models::LeaderboardModel& pAchievement) const noexcept(false);
+    virtual void ValidateAchievementForCore(std::wstring& sError, const ra::data::models::AchievementModel& pAchievement) const noexcept(false);
+    virtual void ValidateLeaderboardForCore(std::wstring& sError, const ra::data::models::LeaderboardModel& pAchievement) const noexcept(false);
     virtual bool SelectionContainsInvalidAsset(const std::vector<ra::data::models::AssetModelBase*>& vSelectedAssets, _Out_ std::wstring& sErrorMessage) const;
 
 private:
@@ -320,6 +344,7 @@ private:
 
     ra::AchievementID m_nNextLocalId = FirstLocalId;
 
+    LookupItemViewModelCollection m_vSubsets;
     LookupItemViewModelCollection m_vStates;
     LookupItemViewModelCollection m_vCategories;
     LookupItemViewModelCollection m_vSpecialFilters;
diff --git a/src/ui/viewmodels/MemoryBookmarksViewModel.cpp b/src/ui/viewmodels/MemoryBookmarksViewModel.cpp
index 190ff378..6ace9f86 100644
--- a/src/ui/viewmodels/MemoryBookmarksViewModel.cpp
+++ b/src/ui/viewmodels/MemoryBookmarksViewModel.cpp
@@ -8,6 +8,7 @@
 #include "data\context\EmulatorContext.hh"
 #include "data\models\TriggerValidation.hh"
 
+#include "services\AchievementRuntime.hh"
 #include "services\FrameEventQueue.hh"
 #include "services\IConfiguration.hh"
 #include "services\IFileSystem.hh"
@@ -21,6 +22,7 @@
 #include "ui\viewmodels\WindowManager.hh"
 
 #include <rcheevos/src/rcheevos/rc_internal.h>
+#include <rcheevos/src/rc_client_internal.h>
 
 #ifdef RA_UTEST
 // awkward workaround to allow individual bookmarks access to the bookmarks view model being tested
@@ -193,8 +195,28 @@ void MemoryBookmarksViewModel::MemoryBookmarkViewModel::OnValueChanged(const Str
     LookupItemViewModel::OnValueChanged(args);
 }
 
+static const rc_operand_t* FindMeasuredOperand(const rc_value_t* pValue) noexcept
+{
+    const rc_condition_t* condition = pValue->conditions->conditions;
+    for (; condition; condition = condition->next)
+    {
+        if (condition->type == RC_CONDITION_MEASURED && rc_operand_is_memref(&condition->operand1))
+            return &condition->operand1;
+    }
+
+    return nullptr;
+}
+
 unsigned MemoryBookmarksViewModel::MemoryBookmarkViewModel::ReadValue() const
 {
+    if (m_pValue)
+    {
+        rc_typed_value_t value;
+        rc_evaluate_value_typed(m_pValue, &value, rc_peek_callback, nullptr, nullptr);
+        rc_typed_value_convert(&value, RC_VALUE_TYPE_UNSIGNED);
+        return value.value.u32;
+    }
+
     const auto& pEmulatorContext = ra::services::ServiceLocator::Get<ra::data::context::EmulatorContext>();
     if (m_nSize == MemSize::Text)
     {
@@ -231,6 +253,10 @@ bool MemoryBookmarksViewModel::MemoryBookmarkViewModel::MemoryChanged()
 {
     const auto& pEmulatorContext = ra::services::ServiceLocator::Get<ra::data::context::EmulatorContext>();
     const auto nValue = ReadValue();
+
+    if (HasIndirectAddress()) // address must be updated after calling ReadValue as ReadValue updates local memrefs
+        UpdateCurrentAddress();
+
     if (nValue == m_nValue)
         return false;
 
@@ -302,6 +328,13 @@ bool MemoryBookmarksViewModel::MemoryBookmarkViewModel::SetCurrentValue(const st
     pEmulatorContext.WriteMemory(nAddress, m_nSize, nValue);
     vmBookmarks.EndWritingMemory();
 
+#ifndef RA_UTEST
+    // memory inspector does not automatically redraw if the emulator is paused. force it to redraw.
+    // will be a no-op if the modified memory is not in the visible addresses.
+    auto& vmMemoryInspector = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::WindowManager>().MemoryInspector;
+    vmMemoryInspector.Viewer().Redraw();
+#endif
+
     return true;
 }
 
@@ -327,6 +360,79 @@ std::wstring MemoryBookmarksViewModel::MemoryBookmarkViewModel::BuildCurrentValu
     return ra::data::MemSizeFormat(m_nValue, m_nSize, GetFormat());
 }
 
+void MemoryBookmarksViewModel::MemoryBookmarkViewModel::UpdateCurrentAddress()
+{
+    if (m_pValue)
+    {
+        const auto* pOperand = FindMeasuredOperand(m_pValue);
+        if (pOperand != nullptr && pOperand->value.memref->value.memref_type == RC_MEMREF_TYPE_MODIFIED_MEMREF)
+        {
+            GSL_SUPPRESS_TYPE1 const auto* pModifiedMemref =
+                reinterpret_cast<rc_modified_memref_t*>(pOperand->value.memref);
+            if (pModifiedMemref->modifier_type == RC_OPERATOR_INDIRECT_READ)
+            {
+                rc_typed_value_t address, offset;
+                rc_evaluate_operand(&address, &pModifiedMemref->parent, nullptr);
+                rc_evaluate_operand(&offset, &pModifiedMemref->modifier, nullptr);
+                rc_typed_value_add(&address, &offset);
+                rc_typed_value_convert(&address, RC_VALUE_TYPE_UNSIGNED);
+                const auto nNewAddress = gsl::narrow_cast<ra::ByteAddress>(address.value.u32);
+
+                if (m_nAddress != nNewAddress)
+                {
+                    m_nAddress = nNewAddress;
+                    SetAddressWithoutUpdatingValue(nNewAddress);
+                }
+            }
+        }
+    }
+}
+
+void MemoryBookmarksViewModel::MemoryBookmarkViewModel::SetIndirectAddress(const std::string& sSerialized)
+{
+    const auto& pRuntime = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+    auto* pGame = pRuntime.GetClient()->game;
+
+    rc_preparse_state_t preparse;
+    rc_init_preparse_state(&preparse, nullptr, 0);
+    preparse.parse.existing_memrefs = pGame ? pGame->runtime.memrefs : nullptr;
+
+    rc_value_with_memrefs_t* value = RC_ALLOC(rc_value_with_memrefs_t, &preparse.parse);
+    const char* memaddr = sSerialized.c_str();
+    rc_parse_value_internal(&value->value, &memaddr, &preparse.parse);
+    rc_preparse_alloc_memrefs(nullptr, &preparse);
+
+    const auto nSize = preparse.parse.offset;
+    if (nSize < 0)
+        return;
+
+    m_pBuffer.reset(new uint8_t[nSize]);
+    if (!m_pBuffer)
+        return;
+
+    rc_reset_parse_state(&preparse.parse, m_pBuffer.get(), nullptr, 0);
+    value = RC_ALLOC(rc_value_with_memrefs_t, &preparse.parse);
+    rc_preparse_alloc_memrefs(&value->memrefs, &preparse);
+    Expects(preparse.parse.memrefs == &value->memrefs);
+
+    preparse.parse.existing_memrefs = pGame ? pGame->runtime.memrefs : nullptr;
+
+    memaddr = sSerialized.c_str();
+    rc_parse_value_internal(&value->value, &memaddr, &preparse.parse);
+    Expects(preparse.parse.offset == nSize);
+    value->value.has_memrefs = 1;
+
+    m_pValue = &value->value;
+    m_sIndirectAddress = sSerialized;
+
+    const rc_operand_t* pOperand = FindMeasuredOperand(m_pValue);
+    if (pOperand != nullptr)
+        SetSize(ra::data::models::TriggerValidation::MapRcheevosMemSize(pOperand->size));
+
+    UpdateCurrentValue(); // value must be updated first to populate memrefs
+    UpdateCurrentAddress();
+}
+
 bool MemoryBookmarksViewModel::IsModified() const
 {
     if (m_vBookmarks.Count() != m_nUnmodifiedBookmarkCount)
@@ -421,14 +527,8 @@ void MemoryBookmarksViewModel::LoadBookmarks(ra::services::TextReader& sBookmark
                 if (bookmark.HasMember("MemAddr"))
                 {
                     // third bookmark format uses the memref serializer
-                    uint8_t size = 0;
-                    unsigned address = 0;
                     const char* memaddr = bookmark["MemAddr"].GetString();
-                    if (rc_parse_memref(&memaddr, &size, &address) == RC_OK)
-                    {
-                        vmBookmark->SetAddress(address);
-                        vmBookmark->SetSize(ra::data::models::TriggerValidation::MapRcheevosMemSize(size));
-                    }
+                    InitializeBookmark(*vmBookmark, memaddr);
                 }
                 else
                 {
@@ -512,8 +612,7 @@ void MemoryBookmarksViewModel::LoadBookmarks(ra::services::TextReader& sBookmark
 
 void MemoryBookmarksViewModel::SaveBookmarks(ra::services::TextWriter& sBookmarksFile)
 {
-    TriggerConditionViewModel vmCondition;
-    vmCondition.SetOperator(ra::ui::viewmodels::TriggerOperatorType::None);
+    std::string sSerialized;
 
     rapidjson::Document document;
     auto& allocator = document.GetAllocator();
@@ -535,9 +634,17 @@ void MemoryBookmarksViewModel::SaveBookmarks(ra::services::TextWriter& sBookmark
                 break;
 
             default:
-                vmCondition.SetSourceSize(nSize);
-                vmCondition.SetSourceValue(vmBookmark.GetAddress());
-                item.AddMember("MemAddr", vmCondition.Serialize(), allocator);
+                if (vmBookmark.HasIndirectAddress())
+                {
+                    item.AddMember("MemAddr", vmBookmark.GetIndirectAddress(), allocator);
+                }
+                else
+                {
+                    sSerialized.clear();
+                    ra::services::AchievementLogicSerializer::AppendOperand(
+                        sSerialized, ra::services::TriggerOperandType::Address, nSize, vmBookmark.GetAddress());
+                    item.AddMember("MemAddr", sSerialized, allocator);
+                }
                 break;
         }
 
@@ -675,27 +782,68 @@ void MemoryBookmarksViewModel::AddBookmark(ra::ByteAddress nAddress, MemSize nSi
 {
     auto vmBookmark = std::make_unique<MemoryBookmarkViewModel>();
     vmBookmark->BeginInitialization();
+
     vmBookmark->SetAddress(nAddress);
     vmBookmark->SetSize(nSize);
+    InitializeBookmark(*vmBookmark);
+
+    vmBookmark->EndInitialization();
 
+    m_vBookmarks.Append(std::move(vmBookmark));
+}
+
+void MemoryBookmarksViewModel::AddBookmark(const std::string& sSerialized)
+{
+    auto vmBookmark = std::make_unique<MemoryBookmarkViewModel>();
+    vmBookmark->BeginInitialization();
+
+    InitializeBookmark(*vmBookmark, sSerialized);
+    InitializeBookmark(*vmBookmark);
+
+    vmBookmark->EndInitialization();
+
+    m_vBookmarks.Append(std::move(vmBookmark));
+}
+
+void MemoryBookmarksViewModel::InitializeBookmark(MemoryBookmarksViewModel::MemoryBookmarkViewModel& vmBookmark)
+{
     const auto& pConfiguration = ra::services::ServiceLocator::Get<ra::services::IConfiguration>();
-    vmBookmark->SetFormat(pConfiguration.IsFeatureEnabled(ra::services::Feature::PreferDecimal) ? MemFormat::Dec : MemFormat::Hex);
+    const auto bPreferDecimal = pConfiguration.IsFeatureEnabled(ra::services::Feature::PreferDecimal);
+    vmBookmark.SetFormat(bPreferDecimal ? MemFormat::Dec : MemFormat::Hex);
 
     const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
     const auto* pCodeNotes = pGameContext.Assets().FindCodeNotes();
+    const auto nAddress = vmBookmark.GetAddress();
     const auto* pNote = (pCodeNotes != nullptr) ? pCodeNotes->FindCodeNote(nAddress) : nullptr;
     if (pNote)
     {
-        vmBookmark->SetDescription(*pNote);
+        vmBookmark.SetDescription(*pNote);
 
         // if bookmarking an 8-byte double, automatically adjust the bookmark for the significant bytes
-        if (nSize == MemSize::Double32 && pCodeNotes->GetCodeNoteBytes(nAddress) == 8)
-            vmBookmark->SetAddress(nAddress + 4);
+        if (vmBookmark.GetSize() == MemSize::Double32 && pCodeNotes->GetCodeNoteBytes(nAddress) == 8)
+            vmBookmark.SetAddress(nAddress + 4);
     }
+}
 
-    vmBookmark->EndInitialization();
+void MemoryBookmarksViewModel::InitializeBookmark(MemoryBookmarksViewModel::MemoryBookmarkViewModel& vmBookmark, const std::string& sSerialized)
+{
+    // if there's no condition separator, it's a simple memref
+    if (sSerialized.find('_') == std::string::npos)
+    {
+        uint8_t size = 0;
+        uint32_t address = 0;
+        const char* memaddr = sSerialized.c_str();
+        if (rc_parse_memref(&memaddr, &size, &address) == RC_OK)
+        {
+            vmBookmark.SetAddress(address);
+            vmBookmark.SetSize(ra::data::models::TriggerValidation::MapRcheevosMemSize(size));
+        }
 
-    m_vBookmarks.Append(std::move(vmBookmark));
+        return;
+    }
+
+    // complex memref.
+    vmBookmark.SetIndirectAddress(sSerialized);
 }
 
 int MemoryBookmarksViewModel::RemoveSelectedBookmarks()
diff --git a/src/ui/viewmodels/MemoryBookmarksViewModel.hh b/src/ui/viewmodels/MemoryBookmarksViewModel.hh
index 87aada9b..197fa90e 100644
--- a/src/ui/viewmodels/MemoryBookmarksViewModel.hh
+++ b/src/ui/viewmodels/MemoryBookmarksViewModel.hh
@@ -90,6 +90,27 @@ public:
         /// </summary>
         void SetAddress(ByteAddress value) { SetValue(AddressProperty, value); }
 
+        /// <summary>
+        /// Sets an indirect bookmark address.
+        /// </summary>
+        void SetIndirectAddress(const std::string& sSerialized);
+
+        /// <summary>
+        /// Gets the indirect bookmark address.
+        /// </summary>
+        /// <returns></returns>
+        const std::string& GetIndirectAddress() const noexcept { return m_sIndirectAddress; }
+
+        /// <summary>
+        /// Gets whether or not the bookmark has an indirect bookmark address.
+        /// </summary>
+        bool HasIndirectAddress() const noexcept { return !m_sIndirectAddress.empty(); }
+
+        /// <summary>
+        /// Updates the current address from the indirect address chain.
+        /// </summary>
+        void UpdateCurrentAddress();
+
         /// <summary>
         /// The <see cref="ModelProperty" /> for the bookmark size.
         /// </summary>
@@ -280,6 +301,10 @@ public:
         MemSize m_nSize = MemSize::EightBit;
         bool m_bModified = false;
         bool m_bInitialized = false;
+
+        std::string m_sIndirectAddress;
+        std::unique_ptr<uint8_t[]> m_pBuffer;
+        rc_value_t* m_pValue = nullptr;
     };
 
     /// <summary>
@@ -337,6 +362,11 @@ public:
     /// </summary>
     void AddBookmark(ra::ByteAddress nAddress, MemSize nSize);
 
+    /// <summary>
+    /// Adds a bookmark to the list.
+    /// </summary>
+    void AddBookmark(const std::string& sSerialized);
+
     /// <summary>
     /// Removes any bookmarks that are currently selected.
     /// </summary>
@@ -421,6 +451,9 @@ private:
     bool ShouldPause() const;
     void UpdatePauseButtonText();
 
+    static void InitializeBookmark(MemoryBookmarkViewModel& vmBookmark, const std::string& sSerialized);
+    static void InitializeBookmark(MemoryBookmarkViewModel& vmBookmark);
+
     void UpdateHasSelection();
 
     ViewModelCollection<MemoryBookmarkViewModel> m_vBookmarks;
diff --git a/src/ui/viewmodels/MemoryInspectorViewModel.cpp b/src/ui/viewmodels/MemoryInspectorViewModel.cpp
index de7061d0..91dadfd6 100644
--- a/src/ui/viewmodels/MemoryInspectorViewModel.cpp
+++ b/src/ui/viewmodels/MemoryInspectorViewModel.cpp
@@ -300,10 +300,35 @@ void MemoryInspectorViewModel::BookmarkCurrentAddress() const
     // if the code note specifies an explicit size, use it. otherwise, use the selected viewer mode size.
     const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
     const auto* pCodeNotes = pGameContext.Assets().FindCodeNotes();
-    auto nSize = (pCodeNotes != nullptr) ? pCodeNotes->GetCodeNoteMemSize(nAddress) : MemSize::Unknown;
+    const auto* pNote = pCodeNotes ? pCodeNotes->FindCodeNoteModel(nAddress) : nullptr;
+    auto nSize = pNote ? pNote->GetMemSize() : MemSize::Unknown;
     if (nSize >= MemSize::Unknown)
         nSize = Viewer().GetSize();
 
+    if (m_bNoteIsIndirect && pNote) // pNote being not null implies pCodeNotes is not null
+    {
+        const auto nIndirectSource = pCodeNotes->GetIndirectSource(nAddress);
+        if (nIndirectSource != 0xFFFFFFFF)
+        {
+            const auto* pRootNote = pCodeNotes->FindCodeNoteModel(nIndirectSource);
+            Expects(pRootNote != nullptr);
+
+            std::string sIndirectAddress;
+            ra::services::AchievementLogicSerializer::AppendConditionType(
+                sIndirectAddress, ra::services::TriggerConditionType::AddAddress);
+            ra::services::AchievementLogicSerializer::AppendOperand(
+                sIndirectAddress, ra::services::TriggerOperandType::Address, pRootNote->GetMemSize(), nIndirectSource);
+            ra::services::AchievementLogicSerializer::AppendConditionSeparator(sIndirectAddress);
+            ra::services::AchievementLogicSerializer::AppendConditionType(
+                sIndirectAddress, ra::services::TriggerConditionType::Measured);
+            ra::services::AchievementLogicSerializer::AppendOperand(
+                sIndirectAddress, ra::services::TriggerOperandType::Address, nSize, pNote->GetAddress());
+
+            pBookmarks.AddBookmark(sIndirectAddress);
+            return;
+        }
+    }
+
     pBookmarks.AddBookmark(nAddress, nSize);
 }
 
diff --git a/src/ui/viewmodels/MemoryViewerViewModel.cpp b/src/ui/viewmodels/MemoryViewerViewModel.cpp
index 5082e048..be26d51d 100644
--- a/src/ui/viewmodels/MemoryViewerViewModel.cpp
+++ b/src/ui/viewmodels/MemoryViewerViewModel.cpp
@@ -1091,6 +1091,11 @@ void MemoryViewerViewModel::DoFrame()
         m_nNeedsRedraw |= REDRAW_MEMORY;
     }
 
+    Redraw();
+}
+
+void MemoryViewerViewModel::Redraw()
+{
     if (m_nNeedsRedraw)
     {
         if (m_pRepaintNotifyTarget != nullptr)
diff --git a/src/ui/viewmodels/MemoryViewerViewModel.hh b/src/ui/viewmodels/MemoryViewerViewModel.hh
index 98a13c03..da5bb2e1 100644
--- a/src/ui/viewmodels/MemoryViewerViewModel.hh
+++ b/src/ui/viewmodels/MemoryViewerViewModel.hh
@@ -36,6 +36,7 @@ public:
 
     void DoFrame();
 
+    void Redraw();
     bool NeedsRedraw() const noexcept { return (m_nNeedsRedraw != 0); }
 
     class RepaintNotifyTarget
diff --git a/src/ui/viewmodels/OverlayAchievementsPageViewModel.cpp b/src/ui/viewmodels/OverlayAchievementsPageViewModel.cpp
index 4dde3a00..c9d10b78 100644
--- a/src/ui/viewmodels/OverlayAchievementsPageViewModel.cpp
+++ b/src/ui/viewmodels/OverlayAchievementsPageViewModel.cpp
@@ -116,7 +116,7 @@ void OverlayAchievementsPageViewModel::Refresh()
     std::vector<rc_client_subset_info_t*> vDeactivatedSubsets;
     int nCategory = RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE_AND_UNOFFICIAL;
     const auto& pAssetList = ra::services::ServiceLocator::Get<ra::ui::viewmodels::WindowManager>().AssetList;
-    if (pAssetList.GetFilterCategory() == ra::ui::viewmodels::AssetListViewModel::FilterCategory::Core)
+    if (pAssetList.GetCategoryFilter() == ra::ui::viewmodels::AssetListViewModel::CategoryFilter::Core)
     {
         nCategory = RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE;
 
diff --git a/src/ui/viewmodels/OverlayLeaderboardsPageViewModel.cpp b/src/ui/viewmodels/OverlayLeaderboardsPageViewModel.cpp
index 90fc0aa0..33755d57 100644
--- a/src/ui/viewmodels/OverlayLeaderboardsPageViewModel.cpp
+++ b/src/ui/viewmodels/OverlayLeaderboardsPageViewModel.cpp
@@ -69,7 +69,7 @@ void OverlayLeaderboardsPageViewModel::Refresh()
 
     std::vector<rc_client_subset_info_t*> vDeactivatedSubsets;
     const auto& pAssetList = ra::services::ServiceLocator::Get<ra::ui::viewmodels::WindowManager>().AssetList;
-    if (pAssetList.GetFilterCategory() == ra::ui::viewmodels::AssetListViewModel::FilterCategory::Core)
+    if (pAssetList.GetCategoryFilter() == ra::ui::viewmodels::AssetListViewModel::CategoryFilter::Core)
     {
         // disable local subset while we build the list
         auto* pSubset = pClient->game ? pClient->game->subsets : nullptr;
diff --git a/src/ui/viewmodels/PointerInspectorViewModel.cpp b/src/ui/viewmodels/PointerInspectorViewModel.cpp
index c7d99d0c..91103254 100644
--- a/src/ui/viewmodels/PointerInspectorViewModel.cpp
+++ b/src/ui/viewmodels/PointerInspectorViewModel.cpp
@@ -8,6 +8,8 @@
 #include "services\IClipboard.hh"
 #include "services\ServiceLocator.hh"
 
+#include "ui\viewmodels\WindowManager.hh"
+
 namespace ra {
 namespace ui {
 namespace viewmodels {
@@ -681,6 +683,24 @@ std::string PointerInspectorViewModel::GetDefinition() const
     return sBuffer;
 }
 
+void PointerInspectorViewModel::BookmarkCurrentField() const
+{
+    // change "I:0xX1234_0xH0010=0" to "I:0xX1234_M:0xH0010"
+    auto sDefinition = GetDefinition();
+    auto nIndex = sDefinition.find_last_of('=');
+    Expects(nIndex != std::string::npos);
+    sDefinition.erase(nIndex);
+    nIndex = sDefinition.find_last_of('_');
+    Expects(nIndex != std::string::npos);
+    sDefinition.insert(nIndex + 1, "M:");
+
+    auto& pWindowManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::WindowManager>();
+    pWindowManager.MemoryBookmarks.AddBookmark(sDefinition);
+
+    if (!pWindowManager.MemoryBookmarks.IsVisible())
+        pWindowManager.MemoryBookmarks.Show();
+}
+
 } // namespace viewmodels
 } // namespace ui
 } // namespace ra
diff --git a/src/ui/viewmodels/PointerInspectorViewModel.hh b/src/ui/viewmodels/PointerInspectorViewModel.hh
index 678ef43c..4f98dc58 100644
--- a/src/ui/viewmodels/PointerInspectorViewModel.hh
+++ b/src/ui/viewmodels/PointerInspectorViewModel.hh
@@ -189,6 +189,7 @@ public:
     void DoFrame() override;
 
     void CopyDefinition() const;
+    void BookmarkCurrentField() const;
 
 protected:
     void OnValueChanged(const IntModelProperty::ChangeArgs& args) override;
diff --git a/src/ui/win32/AssetListDialog.cpp b/src/ui/win32/AssetListDialog.cpp
index 85925d80..9eac77e5 100644
--- a/src/ui/win32/AssetListDialog.cpp
+++ b/src/ui/win32/AssetListDialog.cpp
@@ -178,6 +178,7 @@ public:
 AssetListDialog::AssetListDialog(AssetListViewModel& vmAssetList)
     : DialogBase(vmAssetList),
       m_bindAssets(vmAssetList),
+      m_bindSubsets(vmAssetList),
       m_bindCategories(vmAssetList),
       m_bindSpecialFilters(vmAssetList),
       m_bindAssetTypeFilters(vmAssetList),
@@ -186,9 +187,13 @@ AssetListDialog::AssetListDialog(AssetListViewModel& vmAssetList)
 {
     m_bindWindow.SetInitialPosition(RelativePosition::Near, RelativePosition::After, "Achievements");
 
+    m_bindSubsets.BindItems(vmAssetList.Subsets(), ra::ui::viewmodels::LookupItemViewModel::IdProperty,
+        ra::ui::viewmodels::LookupItemViewModel::LabelProperty);
+    m_bindSubsets.BindSelectedItem(AssetListViewModel::SubsetFilterProperty);
+
     m_bindCategories.BindItems(vmAssetList.Categories(), ra::ui::viewmodels::LookupItemViewModel::IdProperty,
         ra::ui::viewmodels::LookupItemViewModel::LabelProperty);
-    m_bindCategories.BindSelectedItem(AssetListViewModel::FilterCategoryProperty);
+    m_bindCategories.BindSelectedItem(AssetListViewModel::CategoryFilterProperty);
 
     m_bindSpecialFilters.BindItems(vmAssetList.SpecialFilters(), ra::ui::viewmodels::LookupItemViewModel::IdProperty,
        ra::ui::viewmodels::LookupItemViewModel::LabelProperty);
@@ -271,6 +276,7 @@ AssetListDialog::AssetListDialog(AssetListViewModel& vmAssetList)
     m_bindKeepActive.BindCheck(AssetListViewModel::KeepActiveProperty);
 
     using namespace ra::bitwise_ops;
+    SetAnchor(IDC_RA_GAMENAME, Anchor::Top | Anchor::Left);
     SetAnchor(IDC_RA_CATEGORY, Anchor::Top | Anchor::Left);
     SetAnchor(IDC_RA_SPECIAL_FILTER, Anchor::Top | Anchor::Left);
     SetAnchor(IDC_RA_LBL_ID, Anchor::Top | Anchor::Right);
@@ -283,13 +289,13 @@ AssetListDialog::AssetListDialog(AssetListViewModel& vmAssetList)
     SetAnchor(IDC_RA_LISTACHIEVEMENTS, Anchor::Top | Anchor::Left | Anchor::Bottom | Anchor::Right);
     SetAnchor(IDC_RA_RESET_ACH, Anchor::Top | Anchor::Right);
     SetAnchor(IDC_RA_CHK_ACTIVE, Anchor::Top | Anchor::Right);
-    SetAnchor(IDC_RA_COMMIT_ACH, Anchor::Top | Anchor::Right);
-    SetAnchor(IDC_RA_DOWNLOAD_ACH, Anchor::Top | Anchor::Right);
-    SetAnchor(IDC_RA_REVERTSELECTED, Anchor::Top | Anchor::Right);
-    SetAnchor(IDC_RA_NEW_ASSET, Anchor::Bottom | Anchor::Right);
-    SetAnchor(IDC_RA_CLONE_ASSET, Anchor::Bottom | Anchor::Right);
+    SetAnchor(IDC_RA_NEW_ASSET, Anchor::Bottom | Anchor::Left);
+    SetAnchor(IDC_RA_CLONE_ASSET, Anchor::Bottom | Anchor::Left);
+    SetAnchor(IDC_RA_COMMIT_ACH, Anchor::Bottom | Anchor::Right);
+    SetAnchor(IDC_RA_DOWNLOAD_ACH, Anchor::Bottom | Anchor::Right);
+    SetAnchor(IDC_RA_REVERTSELECTED, Anchor::Bottom | Anchor::Right);
 
-    SetMinimumSize(640, 293);
+    SetMinimumSize(570, 293);
 }
 
 BOOL AssetListDialog::OnInitDialog()
@@ -298,6 +304,7 @@ BOOL AssetListDialog::OnInitDialog()
     m_bindProcessingActive.SetControl(*this, IDC_RA_CHKACHPROCESSINGACTIVE);
     m_bindKeepActive.SetControl(*this, IDC_RA_CHK_ACTIVE);
 
+    m_bindSubsets.SetControl(*this, IDC_RA_GAMENAME);
     m_bindCategories.SetControl(*this, IDC_RA_CATEGORY);
     m_bindSpecialFilters.SetControl(*this, IDC_RA_SPECIAL_FILTER);
     m_bindAssetTypeFilters.SetControl(*this, IDC_RA_TYPE_FILTER);
diff --git a/src/ui/win32/AssetListDialog.hh b/src/ui/win32/AssetListDialog.hh
index f2965b2d..3a15ab98 100644
--- a/src/ui/win32/AssetListDialog.hh
+++ b/src/ui/win32/AssetListDialog.hh
@@ -43,6 +43,7 @@ protected:
 
 private:
     ra::ui::win32::bindings::GridBinding m_bindAssets;
+    ra::ui::win32::bindings::ComboBoxBinding m_bindSubsets;
     ra::ui::win32::bindings::ComboBoxBinding m_bindCategories;
     ra::ui::win32::bindings::ComboBoxBinding m_bindSpecialFilters;
     ra::ui::win32::bindings::ComboBoxBinding m_bindAssetTypeFilters;
diff --git a/src/ui/win32/MemoryBookmarksDialog.cpp b/src/ui/win32/MemoryBookmarksDialog.cpp
index 9188fd28..f0ed0d69 100644
--- a/src/ui/win32/MemoryBookmarksDialog.cpp
+++ b/src/ui/win32/MemoryBookmarksDialog.cpp
@@ -84,6 +84,28 @@ void MemoryBookmarksDialog::BookmarksGridBinding::OnTotalMemorySizeChanged()
 
 // ------------------------------------
 
+class GridIndirectAddressColumnBinding : public ra::ui::win32::bindings::GridAddressColumnBinding
+{
+public:
+    GridIndirectAddressColumnBinding(const IntModelProperty& pBoundProperty) :
+        ra::ui::win32::bindings::GridAddressColumnBinding(pBoundProperty)
+    {
+    }
+
+    std::wstring GetText(const ra::ui::ViewModelCollectionBase& vmItems, gsl::index nIndex) const override
+    {
+        const auto* pItem = dynamic_cast<const MemoryBookmarksViewModel::MemoryBookmarkViewModel*>(vmItems.GetViewModelAt(nIndex));
+        if (!pItem)
+            return ra::ui::win32::bindings::GridAddressColumnBinding::GetText(vmItems, nIndex);
+
+        const auto nValue = vmItems.GetItemValue(nIndex, *m_pBoundProperty);
+        if (pItem->HasIndirectAddress())
+            return ra::StringPrintf(L"(%s)", ra::ByteAddressToString(nValue).substr(2));
+
+        return ra::Widen(ra::ByteAddressToString(nValue));
+    }
+};
+
 MemoryBookmarksDialog::MemoryBookmarksDialog(MemoryBookmarksViewModel& vmMemoryBookmarks)
     : DialogBase(vmMemoryBookmarks),
       m_bindBookmarks(vmMemoryBookmarks)
@@ -108,7 +130,7 @@ MemoryBookmarksDialog::MemoryBookmarksDialog(MemoryBookmarksViewModel& vmMemoryB
     pDescriptionColumn->SetReadOnly(false);
     m_bindBookmarks.BindColumn(0, std::move(pDescriptionColumn));
 
-    auto pAddressColumn = std::make_unique<ra::ui::win32::bindings::GridAddressColumnBinding>(
+    auto pAddressColumn = std::make_unique<GridIndirectAddressColumnBinding>(
         MemoryBookmarksViewModel::MemoryBookmarkViewModel::AddressProperty);
     pAddressColumn->SetHeader(L"Address");
     pAddressColumn->UpdateWidth();
diff --git a/src/ui/win32/PointerInspectorDialog.cpp b/src/ui/win32/PointerInspectorDialog.cpp
index 8a07401a..8ecf5412 100644
--- a/src/ui/win32/PointerInspectorDialog.cpp
+++ b/src/ui/win32/PointerInspectorDialog.cpp
@@ -181,13 +181,13 @@ BOOL PointerInspectorDialog::OnCommand(WORD nCommand)
 {
     switch (nCommand)
     {
-        //case IDC_RA_ADDBOOKMARK: {
-        //    auto* vmPointerInspector = dynamic_cast<PointerInspectorViewModel*>(&m_vmWindow);
-        //    if (vmPointerInspector)
-        //        vmPointerInspector->AddBookmark();
+        case IDC_RA_ADDBOOKMARK: {
+            const auto* vmPointerInspector = dynamic_cast<PointerInspectorViewModel*>(&m_vmWindow);
+            if (vmPointerInspector)
+                vmPointerInspector->BookmarkCurrentField();
 
-        //    return TRUE;
-        //}
+            return TRUE;
+        }
 
         case IDC_RA_COPY_ALL: {
             const auto* vmPointerInspector = dynamic_cast<PointerInspectorViewModel*>(&m_vmWindow);


Test patch:
diff --git a/tests/data/models/CodeNoteModel_Tests.cpp b/tests/data/models/CodeNoteModel_Tests.cpp
index 1e8fba45..065e358f 100644
--- a/tests/data/models/CodeNoteModel_Tests.cpp
+++ b/tests/data/models/CodeNoteModel_Tests.cpp
@@ -284,6 +284,31 @@ public:
         AssertIndirectNote(*offsetNote, 0x24C, L"Flag", MemSize::Unknown, 1);
     }
 
+    TEST_METHOD(TestCodeNoteUnannotatedPointerChain)
+    {
+        CodeNoteModelHarness note;
+        const std::wstring sNote =
+            L"Pointer [32bit]\n"
+            L"+0x428 | Award - Tee Hee Two\n"
+            L"++0x24C | Flag\n"
+            L"+0x438 | Award - Pretty Woman\n"
+            L"++0x24C | Flag";
+        note.SetNote(sNote);
+
+        Assert::AreEqual(MemSize::ThirtyTwoBit, note.GetMemSize());
+        Assert::AreEqual(sNote, note.GetNote()); // full note for pointer address
+
+        const auto* offsetNote = AssertIndirectNote(
+            note, 0x428, L"Award - Tee Hee Two\n+0x24C | Flag", MemSize::ThirtyTwoBit, 4);
+        Assert::AreEqual(std::wstring(L"Award - Tee Hee Two"), offsetNote->GetPointerDescription());
+        AssertIndirectNote(*offsetNote, 0x24C, L"Flag", MemSize::Unknown, 1);
+
+        offsetNote = AssertIndirectNote(note, 0x438, L"Award - Pretty Woman\n+0x24C | Flag",
+                                        MemSize::ThirtyTwoBit, 4);
+        Assert::AreEqual(std::wstring(L"Award - Pretty Woman"), offsetNote->GetPointerDescription());
+        AssertIndirectNote(*offsetNote, 0x24C, L"Flag", MemSize::Unknown, 1);
+    }
+
     TEST_METHOD(TestCodeNoteNestedAlternateFormat)
     {
         CodeNoteModelHarness note;
@@ -353,8 +378,10 @@ public:
 
         const auto* nestedNote = AssertIndirectNote(note, 0x8318,
             L"+0x8014\n++0x8004 = First [32-bits]\n++0x8008 = Second [32-bits]", MemSize::ThirtyTwoBit, 4);
+        Assert::AreEqual(std::wstring(L""), nestedNote->GetPointerDescription());
         const auto* nestedNote2 = AssertIndirectNote(*nestedNote, 0x8014,
             L"+0x8004 = First [32-bits]\n+0x8008 = Second [32-bits]", MemSize::ThirtyTwoBit, 4);
+        Assert::AreEqual(std::wstring(L""), nestedNote2->GetPointerDescription());
         AssertIndirectNote(*nestedNote2, 0x8004, L"First [32-bits]", MemSize::ThirtyTwoBit, 4);
         AssertIndirectNote(*nestedNote2, 0x8008, L"Second [32-bits]", MemSize::ThirtyTwoBit, 4);
     }
diff --git a/tests/mocks/MockAchievementRuntime.cpp b/tests/mocks/MockAchievementRuntime.cpp
index 8ba349f8..17bda9a4 100644
--- a/tests/mocks/MockAchievementRuntime.cpp
+++ b/tests/mocks/MockAchievementRuntime.cpp
@@ -135,12 +135,26 @@ static rc_client_achievement_info_t* AddAchievement(rc_client_game_info_t* game,
     return achievement;
 }
 
+void MockAchievementRuntime::MockSubset(uint32_t nSubsetId, const std::string& sName)
+{
+    rc_client_game_info_t* game = GetClient()->game;
+    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());
+}
+
 rc_client_achievement_info_t* MockAchievementRuntime::MockAchievement(uint32_t nId, const char* sTitle)
 {
     rc_client_game_info_t* game = GetClient()->game;
     return AddAchievement(game, GetCoreSubset(game), nId, sTitle);
 }
 
+rc_client_achievement_info_t* MockAchievementRuntime::MockSubsetAchievement(uint32_t nSubsetId, uint32_t nId, const char* sTitle)
+{
+    rc_client_game_info_t* game = GetClient()->game;
+    return AddAchievement(game, GetSubset(game, nSubsetId, sTitle), nId, sTitle);
+}
+
 rc_client_achievement_info_t* MockAchievementRuntime::MockAchievementWithTrigger(uint32_t nId, const char* sTitle)
 {
     rc_client_game_info_t* game = GetClient()->game;
diff --git a/tests/mocks/MockAchievementRuntime.hh b/tests/mocks/MockAchievementRuntime.hh
index 70e0ce75..21426a82 100644
--- a/tests/mocks/MockAchievementRuntime.hh
+++ b/tests/mocks/MockAchievementRuntime.hh
@@ -43,10 +43,13 @@ public:
 
     void MockGame();
 
+    void MockSubset(uint32_t nSubsetId, const std::string& sName);
+
     rc_client_achievement_info_t* MockAchievement(uint32_t nId, const char* sTitle = nullptr);
     rc_client_achievement_info_t* MockAchievementWithTrigger(uint32_t nId, const char* sTitle = nullptr);
     rc_client_achievement_info_t* MockUnofficialAchievement(uint32_t nId, const char* sTitle = nullptr);
     rc_client_achievement_info_t* MockLocalAchievement(uint32_t nId, const char* sTitle = nullptr);
+    rc_client_achievement_info_t* MockSubsetAchievement(uint32_t nSubsetId, uint32_t nId, const char* sTitle = nullptr);
 
     rc_client_achievement_info_t* ActivateAchievement(uint32_t nId, const std::string& sTrigger);
     void UnlockAchievement(rc_client_achievement_info_t* pAchievement, int nMode);
diff --git a/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp b/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp
index d52a7b0e..b73f376f 100644
--- a/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp
@@ -775,6 +775,53 @@ public:
         Assert::AreEqual(std::wstring(L"No Cancel condition"), editor.GetAssetValidationWarning());
     }
 
+    TEST_METHOD(TestActivateNewLeaderboard)
+    {
+        AssetEditorViewModelHarness editor;
+
+        editor.mockRuntime.MockLocalLeaderboard(111000001U, "Leaderboard");
+        editor.mockGameContext.InitializeFromAchievementRuntime();
+        auto* leaderboard = editor.mockGameContext.Assets().FindLeaderboard(111000001U);
+        Expects(leaderboard != nullptr);
+
+        editor.LoadAsset(leaderboard);
+
+        Assert::IsFalse(editor.HasAssetValidationError());
+        Assert::IsTrue(editor.HasAssetValidationWarning());
+        Assert::AreEqual(std::wstring(L"No Start condition"), editor.GetAssetValidationWarning());
+
+        // attempt to start incomplete leaderboard should fail
+        bool bDialogSeen = false;
+        editor.mockDesktop.ExpectWindow<MessageBoxViewModel>([&bDialogSeen](MessageBoxViewModel& vmMessageBox) {
+            Assert::AreEqual(std::wstring(L"Activation failed."), vmMessageBox.GetMessage());
+            bDialogSeen = true;
+            return DialogResult::OK;
+        });
+
+        editor.SetState(AssetState::Waiting);
+        Assert::IsTrue(bDialogSeen);
+
+        // rely on leaderboard to deactivate itself
+        leaderboard->DoFrame();
+        Assert::AreEqual(AssetState::Inactive, editor.GetState());
+
+        // set additional properties so leaderboard is valid and attempt to activate again
+        leaderboard->SetStartTrigger("0=1");
+        leaderboard->SetCancelTrigger("0=1");
+        leaderboard->SetSubmitTrigger("0=1");
+        leaderboard->SetValueDefinition("0=1");
+        leaderboard->Validate();
+        Assert::IsFalse(editor.HasAssetValidationError());
+        Assert::IsFalse(editor.HasAssetValidationWarning());
+
+        bDialogSeen = false;
+        editor.SetState(AssetState::Waiting);
+        Assert::IsFalse(bDialogSeen);
+
+        leaderboard->DoFrame();
+        Assert::AreEqual(AssetState::Waiting, editor.GetState());
+    }
+
     TEST_METHOD(TestSyncId)
     {
         AssetEditorViewModelHarness editor;
diff --git a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
index 729bf24f..3817c922 100644
--- a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
@@ -42,18 +42,18 @@ namespace VisualStudio {
 namespace CppUnitTestFramework {
 
 template<>
-std::wstring ToString<ra::ui::viewmodels::AssetListViewModel::FilterCategory>(
-    const ra::ui::viewmodels::AssetListViewModel::FilterCategory& category)
+std::wstring ToString<ra::ui::viewmodels::AssetListViewModel::CategoryFilter>(
+    const ra::ui::viewmodels::AssetListViewModel::CategoryFilter& category)
 {
     switch (category)
     {
-        case ra::ui::viewmodels::AssetListViewModel::FilterCategory::Core:
+        case ra::ui::viewmodels::AssetListViewModel::CategoryFilter::Core:
             return L"Core";
-        case ra::ui::viewmodels::AssetListViewModel::FilterCategory::Unofficial:
+        case ra::ui::viewmodels::AssetListViewModel::CategoryFilter::Unofficial:
             return L"Unofficial";
-        case ra::ui::viewmodels::AssetListViewModel::FilterCategory::Local:
+        case ra::ui::viewmodels::AssetListViewModel::CategoryFilter::Local:
             return L"Local";
-        case ra::ui::viewmodels::AssetListViewModel::FilterCategory::All:
+        case ra::ui::viewmodels::AssetListViewModel::CategoryFilter::All:
             return L"All";
         default:
             return std::to_wstring(ra::etoi(category));
@@ -639,15 +639,15 @@ public:
         Assert::AreEqual(std::wstring(L"Disabled"), vmAssetList.States().GetItemAt(6)->GetLabel());
 
         Assert::AreEqual({ 4U }, vmAssetList.Categories().Count());
-        Assert::AreEqual((int)AssetListViewModel::FilterCategory::All, vmAssetList.Categories().GetItemAt(0)->GetId());
+        Assert::AreEqual((int)AssetListViewModel::CategoryFilter::All, vmAssetList.Categories().GetItemAt(0)->GetId());
         Assert::AreEqual(std::wstring(L"All"), vmAssetList.Categories().GetItemAt(0)->GetLabel());
-        Assert::AreEqual((int)AssetListViewModel::FilterCategory::Core, vmAssetList.Categories().GetItemAt(1)->GetId());
+        Assert::AreEqual((int)AssetListViewModel::CategoryFilter::Core, vmAssetList.Categories().GetItemAt(1)->GetId());
         Assert::AreEqual(std::wstring(L"Core"), vmAssetList.Categories().GetItemAt(1)->GetLabel());
-        Assert::AreEqual((int)AssetListViewModel::FilterCategory::Unofficial, vmAssetList.Categories().GetItemAt(2)->GetId());
+        Assert::AreEqual((int)AssetListViewModel::CategoryFilter::Unofficial, vmAssetList.Categories().GetItemAt(2)->GetId());
         Assert::AreEqual(std::wstring(L"Unofficial"), vmAssetList.Categories().GetItemAt(2)->GetLabel());
-        Assert::AreEqual((int)AssetListViewModel::FilterCategory::Local, vmAssetList.Categories().GetItemAt(3)->GetId());
+        Assert::AreEqual((int)AssetListViewModel::CategoryFilter::Local, vmAssetList.Categories().GetItemAt(3)->GetId());
         Assert::AreEqual(std::wstring(L"Local"), vmAssetList.Categories().GetItemAt(3)->GetLabel());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Core, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Core, vmAssetList.GetCategoryFilter());
 
         Assert::AreEqual({ 6U }, vmAssetList.SpecialFilters().Count());
         Assert::AreEqual((int)AssetListViewModel::SpecialFilter::All, vmAssetList.SpecialFilters().GetItemAt(0)->GetId());
@@ -694,12 +694,12 @@ public:
         vmAssetList.MockGameId(1U);
 
         Assert::AreEqual(1U, vmAssetList.GetGameId());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Core, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Core, vmAssetList.GetCategoryFilter());
         Assert::AreEqual({0U}, vmAssetList.FilteredAssets().Count());
 
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Local);
         vmAssetList.MockGameId(2U);
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Local, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Local, vmAssetList.GetCategoryFilter());
         Assert::AreEqual({0U}, vmAssetList.FilteredAssets().Count());
     }
 
@@ -710,7 +710,7 @@ public:
         vmAssetList.MockGameId(1U);
 
         Assert::AreEqual(1U, vmAssetList.GetGameId());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Core, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Core, vmAssetList.GetCategoryFilter());
         Assert::AreEqual({1U}, vmAssetList.FilteredAssets().Count());
     }
 
@@ -721,7 +721,7 @@ public:
         vmAssetList.MockGameId(1U);
 
         Assert::AreEqual(1U, vmAssetList.GetGameId());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Unofficial, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Unofficial, vmAssetList.GetCategoryFilter());
         Assert::AreEqual({1U}, vmAssetList.FilteredAssets().Count());
     }
 
@@ -732,7 +732,7 @@ public:
         vmAssetList.MockGameId(1U);
 
         Assert::AreEqual(1U, vmAssetList.GetGameId());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Local, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Local, vmAssetList.GetCategoryFilter());
         Assert::AreEqual({1U}, vmAssetList.FilteredAssets().Count());
     }
 
@@ -880,7 +880,7 @@ public:
     TEST_METHOD(TestAddItemWithFilter)
     {
         AssetListViewModelHarness vmAssetList;
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
 
         vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Ach1");
 
@@ -907,7 +907,7 @@ public:
     TEST_METHOD(TestAddItemWithFilterUpdateSuspended)
     {
         AssetListViewModelHarness vmAssetList;
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.mockGameContext.Assets().BeginUpdate();
 
         vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Ach1");
@@ -940,7 +940,7 @@ public:
     TEST_METHOD(TestRemoveItemWithFilter)
     {
         AssetListViewModelHarness vmAssetList;
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
 
         Assert::AreEqual({ 3U }, vmAssetList.mockGameContext.Assets().Count());
@@ -973,7 +973,7 @@ public:
     TEST_METHOD(TestRemoveItemWithFilterUpdateSuspended)
     {
         AssetListViewModelHarness vmAssetList;
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
 
         Assert::AreEqual({ 3U }, vmAssetList.mockGameContext.Assets().Count());
@@ -1011,7 +1011,7 @@ public:
     TEST_METHOD(TestChangeItemForFilter)
     {
         AssetListViewModelHarness vmAssetList;
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
 
         Assert::AreEqual({ 3U }, vmAssetList.mockGameContext.Assets().Count());
@@ -1048,7 +1048,7 @@ public:
     TEST_METHOD(TestChangeFilter)
     {
         AssetListViewModelHarness vmAssetList;
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
 
         vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Ach1");
         vmAssetList.AddAchievement(AssetCategory::Unofficial, 10, L"Ach2");
@@ -1060,7 +1060,7 @@ public:
         Assert::AreEqual(1, vmAssetList.FilteredAssets().GetItemAt(0)->GetId());
         Assert::AreEqual(3, vmAssetList.FilteredAssets().GetItemAt(1)->GetId());
 
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Unofficial);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Unofficial);
 
         Assert::AreEqual({ 3U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
@@ -1071,7 +1071,7 @@ public:
     TEST_METHOD(TestChangeAssetTypeFilter)
     {
         AssetListViewModelHarness vmAssetList;
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.SetAssetTypeFilter(AssetType::None);
 
         vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Ach1");
@@ -1104,7 +1104,7 @@ public:
     TEST_METHOD(TestSpecialFilterActive)
     {
         AssetListViewModelHarness vmAssetList;
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
 
         vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Ach1");
         Assert::AreEqual({1U}, vmAssetList.mockGameContext.Assets().Count());
@@ -1125,7 +1125,7 @@ public:
     TEST_METHOD(TestSpecialFilterInactive)
     {
         AssetListViewModelHarness vmAssetList;
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
 
         vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Ach1");
         Assert::AreEqual({1U}, vmAssetList.mockGameContext.Assets().Count());
@@ -1146,7 +1146,7 @@ public:
     TEST_METHOD(TestSpecialFilterModified)
     {
         AssetListViewModelHarness vmAssetList;
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
 
         vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Ach1");
         Assert::AreEqual({1U}, vmAssetList.mockGameContext.Assets().Count());
@@ -1167,7 +1167,7 @@ public:
     TEST_METHOD(TestSpecialFilterUnpublished)
     {
         AssetListViewModelHarness vmAssetList;
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
 
         vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Ach1");
         Assert::AreEqual({ 1U }, vmAssetList.mockGameContext.Assets().Count());
@@ -1190,7 +1190,7 @@ public:
     TEST_METHOD(TestSpecialFilterAuthored)
     {
         AssetListViewModelHarness vmAssetList;
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.mockUserContext.Initialize("me", "Me", "ABCDEFGH");
 
         vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Ach1");
@@ -1210,10 +1210,140 @@ public:
         Assert::AreEqual(std::wstring(L"Ach1"), vmAssetList.FilteredAssets().GetItemAt(0)->GetLabel());
     }
 
+    TEST_METHOD(TestSubsetFilter)
+    {
+        AssetListViewModelHarness vmAssetList;
+        vmAssetList.SetSubsetFilter(0);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
+
+        // create core achievements first so core subset gets created
+        vmAssetList.mockRuntime.MockAchievement(1, "Ach1")->public_.points = 5;
+        vmAssetList.mockRuntime.MockAchievement(2, "Ach2")->public_.points = 10;
+        // create subset and subset achievements
+        vmAssetList.mockRuntime.MockSubset(555, "Bonus");
+        vmAssetList.mockRuntime.MockSubsetAchievement(555, 3, "Ach3")->public_.points = 25;
+        auto* pAch4 = vmAssetList.mockRuntime.MockSubsetAchievement(555, 4, "Ach4");
+        pAch4->public_.points = 50;
+        pAch4->public_.category = RC_CLIENT_ACHIEVEMENT_CATEGORY_UNOFFICIAL;
+
+        vmAssetList.mockGameContext.InitializeFromAchievementRuntime();
+        vmAssetList.mockRuntime.SyncAssets();
+        vmAssetList.mockGameContext.NotifyActiveGameChanged();
+
+        Assert::AreEqual({2U}, vmAssetList.Subsets().Count());
+        Assert::AreEqual({0U}, vmAssetList.Subsets().GetItemAt(0)->GetId());
+        Assert::AreEqual(std::wstring(L"Game Title"), vmAssetList.Subsets().GetItemAt(0)->GetLabel());
+        Assert::AreEqual({555U}, vmAssetList.Subsets().GetItemAt(1)->GetId());
+        Assert::AreEqual(std::wstring(L"Bonus"), vmAssetList.Subsets().GetItemAt(1)->GetLabel());
+
+        Assert::AreEqual({2U}, vmAssetList.FilteredAssets().Count());
+        Assert::AreEqual({1U}, vmAssetList.FilteredAssets().GetItemAt(0)->GetId());
+        Assert::AreEqual({2U}, vmAssetList.FilteredAssets().GetItemAt(1)->GetId());
+        Assert::AreEqual({2U}, vmAssetList.GetAchievementCount());
+        Assert::AreEqual({15U}, vmAssetList.GetTotalPoints());
+
+        vmAssetList.SetSubsetFilter(555);
+        Assert::AreEqual({1U}, vmAssetList.FilteredAssets().Count());
+        Assert::AreEqual({3U}, vmAssetList.FilteredAssets().GetItemAt(0)->GetId());
+        Assert::AreEqual({1U}, vmAssetList.GetAchievementCount());
+        Assert::AreEqual({25U}, vmAssetList.GetTotalPoints());
+
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Unofficial);
+        Assert::AreEqual({1U}, vmAssetList.FilteredAssets().Count());
+        Assert::AreEqual({4U}, vmAssetList.FilteredAssets().GetItemAt(0)->GetId());
+        Assert::AreEqual({1U}, vmAssetList.GetAchievementCount());
+        Assert::AreEqual({50U}, vmAssetList.GetTotalPoints());
+
+        vmAssetList.SetSubsetFilter(0);
+        Assert::AreEqual({0U}, vmAssetList.FilteredAssets().Count());
+    }
+
+    TEST_METHOD(TestCreateNewAchievementSubset)
+    {
+        AssetListViewModelHarness vmAssetList;
+        vmAssetList.mockUserContext.Initialize("User1", "FOO");
+        vmAssetList.SetSubsetFilter(0);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
+
+        vmAssetList.mockRuntime.MockAchievement(1, "Ach1")->public_.points = 5;
+        vmAssetList.mockRuntime.MockSubset(555, "Bonus");
+        vmAssetList.mockRuntime.MockSubsetAchievement(555, 3, "Ach3")->public_.points = 25;
+
+        vmAssetList.mockGameContext.InitializeFromAchievementRuntime();
+        vmAssetList.mockRuntime.SyncAssets();
+        vmAssetList.mockGameContext.NotifyActiveGameChanged();
+        vmAssetList.ForceUpdateButtons();
+
+        Assert::AreEqual({2U}, vmAssetList.mockGameContext.Assets().Count());
+        Assert::AreEqual({1U}, vmAssetList.FilteredAssets().Count());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Core, vmAssetList.GetCategoryFilter());
+
+        bool bEditorShown = false;
+        vmAssetList.mockDesktop.ExpectWindow<AssetEditorViewModel>([&bEditorShown](AssetEditorViewModel&) {
+            bEditorShown = true;
+            return DialogResult::None;
+        });
+
+        Assert::IsTrue(vmAssetList.CanCreate());
+        vmAssetList.CreateNew();
+
+        // new Local achievement should be created and focused
+        Assert::AreEqual({3U}, vmAssetList.mockGameContext.Assets().Count());
+        Assert::AreEqual({1U}, vmAssetList.FilteredAssets().Count());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Local, vmAssetList.GetCategoryFilter());
+        Assert::AreEqual(0U, vmAssetList.GetSubsetFilter());
+
+        const auto* pAsset = vmAssetList.FilteredAssets().GetItemAt(0);
+        Expects(pAsset != nullptr);
+        Assert::IsTrue(pAsset->IsSelected());
+        Assert::AreEqual(std::wstring(), pAsset->GetLabel());
+        Assert::AreEqual(AssetCategory::Local, pAsset->GetCategory());
+        Assert::AreEqual(AssetState::Inactive, pAsset->GetState());
+        Assert::AreEqual(AssetChanges::New, pAsset->GetChanges());
+        Assert::AreEqual({111000001U}, pAsset->GetId());
+        Assert::AreEqual(0, pAsset->GetPoints());
+
+        // and loaded in the editor, which should be shown (local achievement will always have ID 0)
+        Assert::AreEqual({0U}, vmAssetList.mockWindowManager.AssetEditor.GetID());
+        Assert::IsTrue(vmAssetList.mockWindowManager.AssetEditor.IsAssetLoaded());
+        Assert::IsTrue(bEditorShown);
+        bEditorShown = false;
+
+        // achievement should be associated to core subset
+        const auto* pAch = vmAssetList.mockGameContext.Assets().FindAchievement({111000001U});
+        Expects(pAch != nullptr);
+        Assert::AreEqual(0U, pAch->GetSubsetID());
+
+        // reset the filter for the subset
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
+        vmAssetList.SetSubsetFilter(555U);
+
+        Assert::AreEqual({1U}, vmAssetList.FilteredAssets().Count());
+        Assert::AreEqual({3U}, vmAssetList.FilteredAssets().GetItemAt(0)->GetId());
+
+        Assert::IsTrue(vmAssetList.CanCreate());
+        vmAssetList.CreateNew();
+
+        // achievement should be associated to bonus subset
+        pAch = vmAssetList.mockGameContext.Assets().FindAchievement({111000002U});
+        Expects(pAch != nullptr);
+        Assert::AreEqual(555U, pAch->GetSubsetID());
+
+        // new Local achievement should be created and focused
+        Assert::AreEqual({4U}, vmAssetList.mockGameContext.Assets().Count());
+        Assert::AreEqual({1U}, vmAssetList.FilteredAssets().Count());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Local, vmAssetList.GetCategoryFilter());
+        Assert::AreEqual(555U, vmAssetList.GetSubsetFilter());
+
+        pAsset = vmAssetList.FilteredAssets().GetItemAt(0);
+        Expects(pAsset != nullptr);
+        Assert::IsTrue(pAsset->IsSelected());
+    }
+
     TEST_METHOD(TestSyncFilteredItem)
     {
         AssetListViewModelHarness vmAssetList;
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
 
         auto pAchievement = std::make_unique<ra::data::models::AchievementModel>();
         pAchievement->SetID(1U);
@@ -1259,7 +1389,7 @@ public:
     TEST_METHOD(TestSyncAddItem)
     {
         AssetListViewModelHarness vmAssetList;
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
 
         // between this test and TestSyncFilteredItem, we can validate each of the synced properties are
         // correctly handled when an item is added to the list. TestSyncFilteredItem will also test to make
@@ -1290,7 +1420,7 @@ public:
     TEST_METHOD(TestUpdateButtonsNoGame)
     {
         AssetListViewModelHarness vmAssetList;
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
 
         Assert::AreEqual({ 0U }, vmAssetList.FilteredAssets().Count());
         vmAssetList.ForceUpdateButtons();
@@ -1317,7 +1447,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
 
         Assert::AreEqual({ 0U }, vmAssetList.FilteredAssets().Count());
         vmAssetList.ForceUpdateButtons();
@@ -1333,7 +1463,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
 
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
@@ -1352,7 +1482,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
 
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
@@ -1373,7 +1503,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
         vmAssetList.mockGameContext.Assets().GetItemAt(2)->SetState(AssetState::Active);
 
@@ -1395,7 +1525,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
         vmAssetList.mockGameContext.Assets().GetItemAt(2)->SetState(AssetState::Active);
 
@@ -1419,7 +1549,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Unofficial);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Unofficial);
         vmAssetList.AddThreeAchievements();
 
         Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
@@ -1439,7 +1569,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
         vmAssetList.mockGameContext.Assets().GetItemAt(2)->SetState(AssetState::Active);
 
@@ -1497,7 +1627,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
         vmAssetList.mockGameContext.Assets().GetItemAt(2)->SetState(AssetState::Active);
 
@@ -1550,7 +1680,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
         vmAssetList.mockGameContext.Assets().GetItemAt(2)->SetState(AssetState::Active);
 
@@ -1567,7 +1697,7 @@ public:
             CreateButtonState::Enabled, CloneButtonState::Enabled
         );
 
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Unofficial);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Unofficial);
         Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
         Assert::IsFalse(vmAssetList.FilteredAssets().GetItemAt(0)->IsSelected());
         vmAssetList.ForceUpdateButtons();
@@ -1583,7 +1713,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
         vmAssetList.mockGameContext.Assets().GetItemAt(2)->SetName(L"Modified");       
 
@@ -1605,7 +1735,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
         vmAssetList.mockGameContext.Assets().GetItemAt(0)->SetName(L"Modified");
 
@@ -1628,7 +1758,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
 
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
@@ -1666,7 +1796,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Local);
         vmAssetList.AddNewAchievement(5, L"Ach1", L"Test1", L"12345", "0xH1234=1");
         vmAssetList.AddNewAchievement(10, L"Ach2", L"Test2", L"12345", "0xH1234=2");
 
@@ -1688,7 +1818,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
         vmAssetList.mockGameContext.Assets().GetItemAt(2)->SetName(L"Modified");
         vmAssetList.mockGameContext.Assets().GetItemAt(2)->UpdateLocalCheckpoint();
@@ -1711,7 +1841,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
         vmAssetList.mockGameContext.Assets().GetItemAt(0)->SetName(L"Modified");
         vmAssetList.mockGameContext.Assets().GetItemAt(0)->UpdateLocalCheckpoint();
@@ -1734,7 +1864,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
 
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
@@ -1774,7 +1904,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
         vmAssetList.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Offline, true);
 
@@ -1796,7 +1926,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
         vmAssetList.mockGameContext.Assets().GetItemAt(2)->SetName(L"Modified");
         vmAssetList.mockGameContext.Assets().GetItemAt(2)->UpdateLocalCheckpoint();
@@ -1820,7 +1950,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Unofficial);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Unofficial);
         vmAssetList.AddThreeAchievements();
         vmAssetList.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Offline, true);
 
@@ -1841,7 +1971,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
 
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
@@ -1868,7 +1998,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
         vmAssetList.mockGameContext.Assets().GetItemAt(2)->SetState(AssetState::Active);
 
@@ -1900,7 +2030,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
 
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
@@ -1929,7 +2059,7 @@ public:
         AssetListViewModelHarness vmAssetList;
         vmAssetList.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
         vmAssetList.mockGameContext.Assets().GetItemAt(2)->SetState(AssetState::Active);
 
@@ -1965,7 +2095,7 @@ public:
         vmAssetList.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
         vmAssetList.AddAchievement(AssetCategory::Local, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Local);
         vmAssetList.AddThreeAchievements();
         vmAssetList.mockGameContext.Assets().GetItemAt(0)->SetState(AssetState::Active);
 
@@ -1990,7 +2120,7 @@ public:
         AssetListViewModelHarness vmAssetList;
         vmAssetList.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
 
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
@@ -2018,7 +2148,7 @@ public:
         AssetListViewModelHarness vmAssetList;
         vmAssetList.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
 
         auto* pAsset = vmAssetList.mockGameContext.Assets().GetItemAt(2); // second achievement is unofficial
@@ -2059,7 +2189,7 @@ public:
         AssetListViewModelHarness vmAssetList;
         vmAssetList.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
 
         auto* pAsset = vmAssetList.mockGameContext.Assets().GetItemAt(2); // second achievement is unofficial
@@ -2101,7 +2231,7 @@ public:
         AssetListViewModelHarness vmAssetList;
         vmAssetList.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
 
         auto* pAsset = vmAssetList.mockGameContext.Assets().GetItemAt(2); // second achievement is unofficial
@@ -2142,7 +2272,7 @@ public:
         AssetListViewModelHarness vmAssetList;
         vmAssetList.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, false);
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.AddThreeAchievements();
         vmAssetList.mockGameContext.Assets().GetItemAt(2)->SetState(AssetState::Primed);
         const auto nAchievementId = vmAssetList.mockGameContext.Assets().GetItemAt(2)->GetID();
@@ -2178,7 +2308,7 @@ public:
         AssetListViewModelHarness vmAssetList;
         vmAssetList.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, false);
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.SetAssetTypeFilter(AssetType::Leaderboard);
         vmAssetList.AddLeaderboard();
         vmAssetList.mockGameContext.Assets().GetItemAt(0)->SetState(AssetState::Primed);
@@ -2223,7 +2353,7 @@ public:
     TEST_METHOD(TestSaveSelectedLocalModified)
     {
         AssetListViewModelHarness vmAssetList;
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Local);
         vmAssetList.MockGameId(22U);
         vmAssetList.AddNewAchievement(5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
         vmAssetList.AddNewAchievement(7, L"Test2", L"Desc2", L"11111", "0xH1111=1");
@@ -2289,7 +2419,7 @@ public:
     TEST_METHOD(TestSaveSelectedUnofficialModified)
     {
         AssetListViewModelHarness vmAssetList;
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Unofficial);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Unofficial);
         vmAssetList.MockGameId(22U);
         vmAssetList.AddAchievement(AssetCategory::Unofficial, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
         vmAssetList.AddAchievement(AssetCategory::Unofficial, 7, L"Test2", L"Desc2", L"11111", "0xH1111=1");
@@ -2711,7 +2841,7 @@ public:
         AssetListViewModelHarness vmAssetList;
         vmAssetList.MockGameId(22U);
         vmAssetList.AddAchievement(AssetCategory::Unofficial, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Unofficial);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Unofficial);
 
         bool bDialogSeen = false;
         vmAssetList.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bDialogSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
@@ -2751,7 +2881,7 @@ public:
         vmAssetList.AddAchievement(AssetCategory::Unofficial, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
         vmAssetList.AddAchievement(AssetCategory::Unofficial, 6, L"Test2", L"Desc2", L"12345", "0xH1234=2");
         vmAssetList.AddAchievement(AssetCategory::Unofficial, 7, L"Test3", L"Desc3", L"12345", "0xH1234=3");
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Unofficial);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Unofficial);
 
         bool bDialogSeen = false;
         vmAssetList.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bDialogSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
@@ -2800,7 +2930,7 @@ public:
         AssetListViewModelHarness vmAssetList;
         vmAssetList.MockGameId(22U);
         vmAssetList.AddAchievement(AssetCategory::Unofficial, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Unofficial);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Unofficial);
         vmAssetList.SetValidationError(1U, L"Test excuse");
 
         bool bDialogSeen = false;
@@ -2833,7 +2963,7 @@ public:
         AssetListViewModelHarness vmAssetList;
         vmAssetList.MockGameId(22U);
         vmAssetList.AddAchievement(AssetCategory::Unofficial, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Unofficial);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Unofficial);
         vmAssetList.SetPublishServerError(1U, "Not allowed.");
 
         bool bDialogSeen = false;
@@ -2867,7 +2997,7 @@ public:
         AssetListViewModelHarness vmAssetList;
         vmAssetList.MockGameId(22U);
         vmAssetList.AddAchievement(AssetCategory::Unofficial, 5, L"Test1", L"Desc1", L"12345", "P:P:0xH1234=1");
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Unofficial);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Unofficial);
 
         bool bDialogSeen = false;
         vmAssetList.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bDialogSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
@@ -2936,7 +3066,7 @@ public:
         AssetListViewModelHarness vmAssetList;
         vmAssetList.MockGameId(22U);
         vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.SetPublishServerError(1U, "Not allowed.");
 
         bool bDialogSeen = false;
@@ -3069,7 +3199,7 @@ public:
         AssetListViewModelHarness vmAssetList;
         vmAssetList.MockGameId(22U);
         vmAssetList.mockGameContext.SetGameTitle(L"GameName");
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Local);
 
         vmAssetList.MockUserFileContents("111000001:\"0xH2345=0\":Test2:::::User:0:0:0:::00000\n");
 
@@ -3141,7 +3271,7 @@ public:
 
         Assert::AreEqual({ 2U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Core, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Core, vmAssetList.GetCategoryFilter());
 
         bool bEditorShown = false;
         vmAssetList.mockDesktop.ExpectWindow<AssetEditorViewModel>([&bEditorShown](AssetEditorViewModel&)
@@ -3156,7 +3286,7 @@ public:
         // new Local achievement should be created and focused
         Assert::AreEqual({ 3U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Local, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Local, vmAssetList.GetCategoryFilter());
 
         const auto* pAsset = vmAssetList.FilteredAssets().GetItemAt(0);
         Expects(pAsset != nullptr);
@@ -3178,7 +3308,7 @@ public:
         vmAssetList.CreateNew();
         Assert::AreEqual({ 4U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Local, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Local, vmAssetList.GetCategoryFilter());
 
         pAsset = vmAssetList.FilteredAssets().GetItemAt(0);
         Expects(pAsset != nullptr);
@@ -3213,7 +3343,7 @@ public:
 
         Assert::AreEqual({ 2U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Core, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Core, vmAssetList.GetCategoryFilter());
 
         bool bEditorShown = false;
         vmAssetList.mockDesktop.ExpectWindow<AssetEditorViewModel>([&bEditorShown](AssetEditorViewModel&)
@@ -3242,7 +3372,7 @@ public:
         // new Local achievement should be created and focused
         Assert::AreEqual({ 3U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Local, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Local, vmAssetList.GetCategoryFilter());
 
         const auto* pAsset = vmAssetList.FilteredAssets().GetItemAt(0);
         Expects(pAsset != nullptr);
@@ -3268,19 +3398,19 @@ public:
         vmAssetList.MockGameId(22U);
         vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
         vmAssetList.AddAchievement(AssetCategory::Core, 7, L"Test2", L"Desc2", L"11111", "0xH1111=1");
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::All);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::All);
         vmAssetList.ForceUpdateButtons();
 
         Assert::AreEqual({ 2U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::All, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::All, vmAssetList.GetCategoryFilter());
 
         vmAssetList.CreateNew();
 
         // new Local achievement should be created and focused
         Assert::AreEqual({ 3U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 3U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::All, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::All, vmAssetList.GetCategoryFilter());
 
         const auto* pAsset = vmAssetList.FilteredAssets().GetItemAt(2);
         Expects(pAsset != nullptr);
@@ -3353,7 +3483,7 @@ public:
         // new Local achievement should be created and focused
         Assert::AreEqual({ 3U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Local, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Local, vmAssetList.GetCategoryFilter());
         Assert::AreEqual(ra::data::models::AssetType::Achievement, vmAssetList.GetAssetTypeFilter());
 
         const auto* pAsset = vmAssetList.FilteredAssets().GetItemAt(0);
@@ -3400,7 +3530,7 @@ public:
         // new Local leaderboard should be created and focused
         Assert::AreEqual({ 3U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Local, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Local, vmAssetList.GetCategoryFilter());
         Assert::AreEqual(ra::data::models::AssetType::Leaderboard, vmAssetList.GetAssetTypeFilter());
 
         const auto* pAsset = vmAssetList.FilteredAssets().GetItemAt(0);
@@ -3457,7 +3587,7 @@ public:
         // new Local achievement should be created and focused
         Assert::AreEqual({ 3U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Local, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Local, vmAssetList.GetCategoryFilter());
         Assert::AreEqual(ra::data::models::AssetType::None, vmAssetList.GetAssetTypeFilter());
 
         // and loaded in the editor, which should be shown (local achievement will always have ID 0)
@@ -3504,7 +3634,7 @@ public:
         // new Local leaderboard should be created and focused
         Assert::AreEqual({ 3U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Local, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Local, vmAssetList.GetCategoryFilter());
         Assert::AreEqual(ra::data::models::AssetType::None, vmAssetList.GetAssetTypeFilter());
 
         // and loaded in the editor, which should be shown (local achievement will always have ID 0)
@@ -3550,7 +3680,7 @@ public:
         // no new item should have been created. existing filters should be maintained
         Assert::AreEqual({ 2U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Core, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Core, vmAssetList.GetCategoryFilter());
         Assert::AreEqual(ra::data::models::AssetType::None, vmAssetList.GetAssetTypeFilter());
 
         // editor should not be opened
@@ -3580,7 +3710,7 @@ public:
         // new Local rich presence should be created and focused
         Assert::AreEqual({ 3U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Local, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Local, vmAssetList.GetCategoryFilter());
         Assert::AreEqual(ra::data::models::AssetType::RichPresence, vmAssetList.GetAssetTypeFilter());
 
         const auto* pAsset = vmAssetList.FilteredAssets().GetItemAt(0);
@@ -3622,7 +3752,7 @@ public:
         // existing Local rich presence should be focused
         Assert::AreEqual({ 3U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Core, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Core, vmAssetList.GetCategoryFilter());
         Assert::AreEqual(ra::data::models::AssetType::RichPresence, vmAssetList.GetAssetTypeFilter());
 
         const auto* pAsset = vmAssetList.FilteredAssets().GetItemAt(0);
@@ -3649,7 +3779,7 @@ public:
 
         Assert::AreEqual({ 2U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Core, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Core, vmAssetList.GetCategoryFilter());
 
         vmAssetList.FilteredAssets().GetItemAt(1)->SetSelected(true);
         vmAssetList.ForceUpdateButtons();
@@ -3666,7 +3796,7 @@ public:
         // new Local achievement should be created and focused
         Assert::AreEqual({ 3U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Local, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Local, vmAssetList.GetCategoryFilter());
 
         const auto* pAsset = vmAssetList.FilteredAssets().GetItemAt(0);
         Expects(pAsset != nullptr);
@@ -3694,7 +3824,7 @@ public:
         vmAssetList.CloneSelected();
         Assert::AreEqual({ 4U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Local, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Local, vmAssetList.GetCategoryFilter());
 
         pAsset = vmAssetList.FilteredAssets().GetItemAt(0);
         Expects(pAsset != nullptr);
@@ -3715,7 +3845,7 @@ public:
 
         Assert::AreEqual({ 2U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Core, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Core, vmAssetList.GetCategoryFilter());
 
         vmAssetList.FilteredAssets().GetItemAt(1)->SetSelected(true);
         vmAssetList.ForceUpdateButtons();
@@ -3747,7 +3877,7 @@ public:
         // new Local achievement should be created and focused
         Assert::AreEqual({ 3U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Local, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Local, vmAssetList.GetCategoryFilter());
 
         const auto* pAsset = vmAssetList.FilteredAssets().GetItemAt(0);
         Expects(pAsset != nullptr);
@@ -3780,7 +3910,7 @@ public:
         const std::wstring sLocalBadgeName = L"local\\1234.png";
 
         vmAssetList.SetGameId(22U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Local);
         vmAssetList.AddAchievement(AssetCategory::Local, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
         auto* pSourceAchievement = vmAssetList.mockGameContext.Assets().FindAchievement(2);
         Expects(pSourceAchievement != nullptr);
@@ -3804,7 +3934,7 @@ public:
         // new Local achievement should be created and focused
         Assert::AreEqual({ 3U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Local, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Local, vmAssetList.GetCategoryFilter());
 
         const auto* pAsset = vmAssetList.FilteredAssets().GetItemAt(1);
         Expects(pAsset != nullptr);
@@ -3837,11 +3967,11 @@ public:
         vmAssetList.SetGameId(22U);
         vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
         vmAssetList.AddAchievement(AssetCategory::Core, 7, L"Test2", L"Desc2", L"11111", "0xH1111=1");
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::All);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::All);
 
         Assert::AreEqual({ 2U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::All, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::All, vmAssetList.GetCategoryFilter());
 
         vmAssetList.FilteredAssets().GetItemAt(1)->SetSelected(true);
         vmAssetList.ForceUpdateButtons();
@@ -3851,7 +3981,7 @@ public:
         // new Local achievement should be created and focused without changing the filter category
         Assert::AreEqual({ 3U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 3U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::All, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::All, vmAssetList.GetCategoryFilter());
 
         const auto* pAsset = vmAssetList.FilteredAssets().GetItemAt(0);
         Expects(pAsset != nullptr);
@@ -3870,7 +4000,7 @@ public:
         AssetListViewModelHarness vmAssetList;
 
         vmAssetList.SetGameId(22U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Local);
         vmAssetList.AddAchievement(AssetCategory::Local, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
         auto* pSourceAchievement = vmAssetList.mockGameContext.Assets().FindAchievement(1);
         Expects(pSourceAchievement != nullptr);
@@ -3889,7 +4019,7 @@ public:
         // new Local achievement should be created and focused
         Assert::AreEqual({ 2U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Local, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Local, vmAssetList.GetCategoryFilter());
         Assert::AreEqual(AssetListViewModel::SpecialFilter::All, vmAssetList.GetSpecialFilter());
 
         const auto* pAsset = vmAssetList.FilteredAssets().GetItemAt(0);
@@ -3917,7 +4047,7 @@ public:
 
         Assert::AreEqual({ 4U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 4U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Core, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Core, vmAssetList.GetCategoryFilter());
 
         vmAssetList.FilteredAssets().GetItemAt(3)->SetSelected(true);
         vmAssetList.FilteredAssets().GetItemAt(1)->SetSelected(true);
@@ -3928,7 +4058,7 @@ public:
         // new Local achievements should be created and focused
         Assert::AreEqual({ 6U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Local, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Local, vmAssetList.GetCategoryFilter());
 
         auto* pAsset = vmAssetList.FilteredAssets().GetItemAt(0);
         Expects(pAsset != nullptr);
@@ -3979,7 +4109,7 @@ public:
 
         Assert::AreEqual({ 2U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Core, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Core, vmAssetList.GetCategoryFilter());
 
         vmAssetList.FilteredAssets().GetItemAt(1)->SetSelected(true);
         vmAssetList.SetValidationError(vmAssetList.FilteredAssets().GetItemAt(1)->GetId(), L"Error message goes here.");
@@ -4011,7 +4141,7 @@ public:
 
         Assert::AreEqual({ 2U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Core, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Core, vmAssetList.GetCategoryFilter());
 
         vmAssetList.FilteredAssets().GetItemAt(1)->SetSelected(true);
         vmAssetList.ForceUpdateButtons();
@@ -4028,7 +4158,7 @@ public:
         // new Local achievement should be created and focused
         Assert::AreEqual({ 3U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Local, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Local, vmAssetList.GetCategoryFilter());
 
         auto* pAsset = vmAssetList.FilteredAssets().GetItemAt(0);
         Expects(pAsset != nullptr);
@@ -4089,7 +4219,7 @@ public:
         vmAssetList.CloneSelected();
 
         // new Local achievement should be created and focused
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Local, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Local, vmAssetList.GetCategoryFilter());
         Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
 
         // new achievement should have updated uncommitted reference count to badge
@@ -4181,7 +4311,7 @@ public:
         vmAssetList.CloneSelected();
 
         // new Local achievement should be created and focused
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Local, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Local, vmAssetList.GetCategoryFilter());
         Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
 
         // new achievement should have updated uncommitted reference count to badge
@@ -4273,7 +4403,7 @@ public:
 
         Assert::AreEqual({ 1U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Core, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Core, vmAssetList.GetCategoryFilter());
 
         vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
         vmAssetList.ForceUpdateButtons();
@@ -4290,7 +4420,7 @@ public:
         // new Local leaderboard should be created and focused
         Assert::AreEqual({ 2U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Local, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Local, vmAssetList.GetCategoryFilter());
 
         const auto* pAsset = vmAssetList.FilteredAssets().GetItemAt(0);
         Expects(pAsset != nullptr);
@@ -4564,7 +4694,7 @@ public:
         AssetListViewModelHarness vmAssetList;
         vmAssetList.MockGameId(22U);
         vmAssetList.AddAchievement(AssetCategory::Local, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Local);
         vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
         vmAssetList.ForceUpdateButtons();
         vmAssetList.AssertButtonState(ResetButtonState::Reset);
@@ -4591,7 +4721,7 @@ public:
         AssetListViewModelHarness vmAssetList;
         vmAssetList.MockGameId(22U);
         vmAssetList.AddAchievement(AssetCategory::Local, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Local);
         vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
         vmAssetList.ForceUpdateButtons();
         vmAssetList.AssertButtonState(ResetButtonState::Reset);
@@ -4627,7 +4757,7 @@ public:
         AssetListViewModelHarness vmAssetList;
         vmAssetList.MockGameId(22U);
         vmAssetList.AddAchievement(AssetCategory::Local, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Local);
         vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
         vmAssetList.ForceUpdateButtons();
         vmAssetList.AssertButtonState(ResetButtonState::Reset);
@@ -4666,7 +4796,7 @@ public:
         AssetListViewModelHarness vmAssetList;
         vmAssetList.MockGameId(22U);
         vmAssetList.AddLeaderboard(ra::data::models::AssetCategory::Local, L"Leaderboard1");
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Local);
         vmAssetList.SetAssetTypeFilter(ra::data::models::AssetType::Leaderboard);
         vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
         vmAssetList.ForceUpdateButtons();
@@ -4707,7 +4837,7 @@ public:
         vmAssetList.MockGameId(22U);
         vmAssetList.AddNewAchievement(5, L"Ach1", L"Test1", L"12345", "0xH1234=1");
         vmAssetList.AddAchievement(AssetCategory::Local, 5, L"Test2", L"Desc2", L"12345", "0xH1234=1");
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Local);
         vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
         vmAssetList.FilteredAssets().GetItemAt(1)->SetSelected(true);
 
@@ -4743,7 +4873,7 @@ public:
         vmAssetList.MockGameId(22U);
         vmAssetList.AddNewAchievement(5, L"Ach1", L"Test1", L"12345", "0xH1234=1");
         vmAssetList.AddAchievement(AssetCategory::Local, 5, L"Test2", L"Desc2", L"12345", "0xH1234=1");
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Local);
 
         // if new and non-new items are selected, allow the user to reset. new items will be discarded
         vmAssetList.ForceUpdateButtons();
@@ -4962,7 +5092,7 @@ public:
         AssetListViewModelHarness vmAssetList;
         vmAssetList.MockGameId(22U);
         vmAssetList.AddAchievement(AssetCategory::Local, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Local);
         vmAssetList.ForceUpdateButtons();
 
         vmAssetList.AssertButtonState(RevertButtonState::DeleteAll);
@@ -4997,7 +5127,7 @@ public:
         AssetListViewModelHarness vmAssetList;
         vmAssetList.MockGameId(22U);
         vmAssetList.AddAchievement(AssetCategory::Local, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Local);
         vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
         vmAssetList.ForceUpdateButtons();
         vmAssetList.AssertButtonState(RevertButtonState::Delete);
@@ -5032,7 +5162,7 @@ public:
         AssetListViewModelHarness vmAssetList;
         vmAssetList.MockGameId(22U);
         vmAssetList.AddAchievement(AssetCategory::Local, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Local);
         vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
         vmAssetList.ForceUpdateButtons();
         vmAssetList.AssertButtonState(RevertButtonState::Delete);
@@ -5064,7 +5194,7 @@ public:
         AssetListViewModelHarness vmAssetList;
         vmAssetList.MockGameId(22U);
         vmAssetList.AddAchievement(AssetCategory::Local, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Local);
         vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
 
         auto* vmAsset = vmAssetList.mockGameContext.Assets().GetItemAt(0);
@@ -5126,7 +5256,7 @@ public:
         });
 
         // deleting first asset should update badge reference counts
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Local);
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
         auto* pAsset = vmAssetList.FilteredAssets().GetItemAt(0);
         Expects(pAsset != nullptr);
@@ -5254,7 +5384,7 @@ public:
         vmAssetList.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
         vmAssetList.MockGameId(22U);
         vmAssetList.AddAchievement(AssetCategory::Local, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Local);
         vmAssetList.ForceUpdateButtons();
         vmAssetList.AssertButtonState(RevertButtonState::DeleteAll);
 
@@ -5302,7 +5432,7 @@ public:
 
         Assert::AreEqual({ 2U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Core, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Core, vmAssetList.GetCategoryFilter());
 
         auto* pItem = vmAssetList.FilteredAssets().GetItemAt(1);
         Expects(pItem != nullptr);
@@ -5328,7 +5458,7 @@ public:
 
         Assert::AreEqual({ 2U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Core, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Core, vmAssetList.GetCategoryFilter());
 
         auto* pItem = vmAssetList.FilteredAssets().GetItemAt(1);
         Expects(pItem != nullptr);
@@ -5355,7 +5485,7 @@ public:
 
         Assert::AreEqual({ 2U }, vmAssetList.mockGameContext.Assets().Count());
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
-        Assert::AreEqual(AssetListViewModel::FilterCategory::Core, vmAssetList.GetFilterCategory());
+        Assert::AreEqual(AssetListViewModel::CategoryFilter::Core, vmAssetList.GetCategoryFilter());
 
         auto* pItem = vmAssetList.FilteredAssets().GetItemAt(1);
         Expects(pItem != nullptr);
@@ -5402,7 +5532,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.SetAssetTypeFilter(AssetType::RichPresence);
         vmAssetList.AddRichPresence("Display:\nTest\n");
 
@@ -5421,7 +5551,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.SetAssetTypeFilter(AssetType::None);
         vmAssetList.AddRichPresence("Display:\nTest\n");
         vmAssetList.AddThreeAchievements();
@@ -5442,7 +5572,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Local);
         vmAssetList.SetAssetTypeFilter(AssetType::RichPresence);
         vmAssetList.AddRichPresence("Display:\nTest\n");
         vmAssetList.mockGameContext.Assets().FindRichPresence()->SetCategory(AssetCategory::Local);
@@ -5462,7 +5592,7 @@ public:
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.SetGameId(1U);
-        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Core);
         vmAssetList.SetAssetTypeFilter(AssetType::RichPresence);
 
         vmAssetList.AddRichPresence("Display:\nTest\n");
diff --git a/tests/ui/viewmodels/BrokenAchievementsViewModel_Tests.cpp b/tests/ui/viewmodels/BrokenAchievementsViewModel_Tests.cpp
index 1159f953..d2afbfd4 100644
--- a/tests/ui/viewmodels/BrokenAchievementsViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/BrokenAchievementsViewModel_Tests.cpp
@@ -107,7 +107,7 @@ public:
         BrokenAchievementsViewModelHarness vmBrokenAchievements;
         vmBrokenAchievements.mockGameContext.SetGameId(1U);
         vmBrokenAchievements.mockGameContext.Assets().NewAchievement().SetCategory(ra::data::models::AssetCategory::Local);
-        vmBrokenAchievements.mockWindowManager.AssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmBrokenAchievements.mockWindowManager.AssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Local);
 
         bool bDialogSeen = false;
         vmBrokenAchievements.mockDesktop.ExpectWindow<MessageBoxViewModel>([&bDialogSeen](const MessageBoxViewModel& vmMessageBox)
diff --git a/tests/ui/viewmodels/IntegrationMenuViewModel_Tests.cpp b/tests/ui/viewmodels/IntegrationMenuViewModel_Tests.cpp
index 5606e883..f4b380e4 100644
--- a/tests/ui/viewmodels/IntegrationMenuViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/IntegrationMenuViewModel_Tests.cpp
@@ -788,7 +788,7 @@ public:
         menu.mockGameContext.SetGameId(1234);
         auto& pAch = menu.mockGameContext.Assets().NewAchievement();
         pAch.SetCategory(ra::data::models::AssetCategory::Core);
-        menu.mockWindowManager.AssetList.SetFilterCategory(ra::ui::viewmodels::AssetListViewModel::FilterCategory::All);
+        menu.mockWindowManager.AssetList.SetCategoryFilter(ra::ui::viewmodels::AssetListViewModel::CategoryFilter::All);
 
         menu.AssertShowWindow<ra::ui::viewmodels::BrokenAchievementsViewModel>(IDM_RA_REPORTBROKENACHIEVEMENTS, false, "", DialogResult::None);
     }
diff --git a/tests/ui/viewmodels/MemoryBookmarksViewModel_Tests.cpp b/tests/ui/viewmodels/MemoryBookmarksViewModel_Tests.cpp
index 181bd6f3..783a368c 100644
--- a/tests/ui/viewmodels/MemoryBookmarksViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/MemoryBookmarksViewModel_Tests.cpp
@@ -1560,6 +1560,153 @@ public:
 
         g_pMemoryBookmarksViewModel = nullptr;
     }
+
+    TEST_METHOD(TestAddBookmarkIndirect)
+    {
+        MemoryBookmarksViewModelHarness bookmarks;
+        std::array<uint8_t, 64> memory = {};
+        for (uint8_t i = 0; i < memory.size(); i += 2)
+            memory.at(i) = i;
+        bookmarks.mockEmulatorContext.MockMemory(memory);
+
+        bookmarks.SetIsVisible(true);
+        bookmarks.mockGameContext.SetGameId(3U);
+        bookmarks.mockGameContext.SetCodeNote(0x0020U, L"[16-bit pointer]\n+8: data here");
+
+        bookmarks.AddBookmark("I:0x 0020_M:0xW0008");
+
+        Assert::AreEqual({1U}, bookmarks.Bookmarks().Count());
+        const auto& bookmark = *bookmarks.Bookmarks().GetItemAt(0);
+        Assert::AreEqual(std::wstring(L"data here"), bookmark.GetDescription());
+        Assert::AreEqual(0x28U, bookmark.GetAddress()); // $0020=0x20, 0x20+8 = 0x28
+        Assert::AreEqual(MemSize::TwentyFourBit, bookmark.GetSize());
+        Assert::AreEqual((int)MemFormat::Hex, (int)bookmark.GetFormat());
+        Assert::AreEqual(std::wstring(L"2a0028"), bookmark.GetCurrentValue());
+        Assert::IsTrue(bookmark.HasIndirectAddress());
+        Assert::AreEqual(std::string("I:0x 0020_M:0xW0008"), bookmark.GetIndirectAddress());
+
+        memory.at(0x20) = 0x10;
+        memory.at(0x18) = 0xAB;
+        bookmarks.DoFrame();
+
+        Assert::AreEqual(std::wstring(L"data here"), bookmark.GetDescription());
+        Assert::AreEqual(0x18U, bookmark.GetAddress()); // $0020=0x10, 0x10+8 = 0x18
+        Assert::AreEqual(std::wstring(L"1a00ab"), bookmark.GetCurrentValue());
+    }
+
+    TEST_METHOD(TestLoadBookmarkFileIndirect)
+    {
+        MemoryBookmarksViewModelHarness bookmarks;
+        bookmarks.SetIsVisible(true);
+        bookmarks.mockGameContext.SetGameId(3U);
+        bookmarks.mockGameContext.SetCodeNote(0x0020U, L"[16-bit pointer]\n+8: data here");
+        bookmarks.mockFileSystem.MockFile(L"E:\\Data\\3-Bookmarks.json",
+                                          "{\"Bookmarks\":[{\"MemAddr\":\"I:0x 0020_M:0xW0008\"}]}");
+
+        bool bDialogSeen = false;
+        bookmarks.mockDesktop.ExpectWindow<ra::ui::viewmodels::FileDialogViewModel>(
+            [&bDialogSeen](ra::ui::viewmodels::FileDialogViewModel& vmFileDialog) {
+                bDialogSeen = true;
+
+                Assert::AreEqual(std::wstring(L"Import Bookmark File"), vmFileDialog.GetWindowTitle());
+                Assert::AreEqual({2U}, vmFileDialog.GetFileTypes().size());
+                Assert::AreEqual(std::wstring(L"json"), vmFileDialog.GetDefaultExtension());
+                Assert::AreEqual(std::wstring(L"3-Bookmarks.json"), vmFileDialog.GetFileName());
+
+                vmFileDialog.SetFileName(L"E:\\Data\\3-Bookmarks.json");
+
+                return DialogResult::OK;
+            });
+
+        bookmarks.LoadBookmarkFile();
+
+        Assert::IsTrue(bDialogSeen);
+        Assert::AreEqual({1U}, bookmarks.Bookmarks().Count());
+        const auto& bookmark = *bookmarks.Bookmarks().GetItemAt(0);
+        Assert::AreEqual(std::wstring(L"data here"), bookmark.GetDescription());
+        Assert::AreEqual(8U, bookmark.GetAddress());
+        Assert::AreEqual(MemSize::TwentyFourBit, bookmark.GetSize());
+        Assert::AreEqual((int)MemFormat::Hex, (int)bookmark.GetFormat());
+        Assert::IsTrue(bookmark.HasIndirectAddress());
+        Assert::AreEqual(std::string("I:0x 0020_M:0xW0008"), bookmark.GetIndirectAddress());
+    }
+
+    TEST_METHOD(TestSaveBookmarkFileIndirect)
+    {
+        MemoryBookmarksViewModelHarness bookmarks;
+        bookmarks.SetIsVisible(true);
+        bookmarks.mockGameContext.SetGameId(3U);
+        bookmarks.mockGameContext.SetCodeNote(1234U, L"Note description");
+        bookmarks.mockLocalStorage.MockStoredData(ra::services::StorageItemType::Bookmarks, L"3",
+                                                  "{\"Bookmarks\":[{\"MemAddr\":\"0xH04d2\"}]}");
+
+        bookmarks.mockGameContext.NotifyActiveGameChanged();
+
+        bookmarks.AddBookmark("I:0x 0020_M:0xW0008");
+        Assert::IsTrue(bookmarks.IsModified());
+
+        bool bDialogSeen = false;
+        bookmarks.mockDesktop.ExpectWindow<ra::ui::viewmodels::FileDialogViewModel>(
+            [&bDialogSeen](ra::ui::viewmodels::FileDialogViewModel& vmFileDialog) {
+                bDialogSeen = true;
+
+                Assert::AreEqual(std::wstring(L"Export Bookmark File"), vmFileDialog.GetWindowTitle());
+                Assert::AreEqual({1U}, vmFileDialog.GetFileTypes().size());
+                Assert::AreEqual(std::wstring(L"json"), vmFileDialog.GetDefaultExtension());
+                Assert::AreEqual(std::wstring(L"3-Bookmarks.json"), vmFileDialog.GetFileName());
+
+                vmFileDialog.SetFileName(L"E:\\Data\\3-Bookmarks.json");
+
+                return DialogResult::OK;
+            });
+
+        bookmarks.SaveBookmarkFile();
+
+        Assert::IsTrue(bDialogSeen);
+        Assert::IsTrue(bookmarks.IsModified()); // export does not update modified flag
+        const std::string& sContents = bookmarks.mockFileSystem.GetFileContents(L"E:\\Data\\3-Bookmarks.json");
+        Assert::AreEqual(std::string("{\"Bookmarks\":[{\"MemAddr\":\"0xH04d2\"},{\"MemAddr\":\"I:0x 0020_M:0xW0008\"}]}"), sContents);
+    }
+
+    TEST_METHOD(TestPauseOnChangeIndirect)
+    {
+        MemoryBookmarksViewModelHarness bookmarks;
+        std::array<uint8_t, 64> memory = {};
+        for (uint8_t i = 0; i < memory.size(); i += 2)
+            memory.at(i) = i;
+        bookmarks.mockEmulatorContext.MockMemory(memory);
+
+        bookmarks.AddBookmark("I:0x 0020_M:0xH0008");
+        bookmarks.DoFrame(); // initialize current and previous values
+        Assert::AreEqual({0U}, bookmarks.mockFrameEventQueue.NumMemoryChanges());
+
+        auto& bookmark = *bookmarks.Bookmarks().GetItemAt(0);
+        bookmark.SetBehavior(MemoryBookmarksViewModel::BookmarkBehavior::PauseOnChange);
+
+        // no change, dialog should not be shown
+        bookmarks.DoFrame();
+        Assert::AreEqual({0U}, bookmarks.mockFrameEventQueue.NumMemoryChanges());
+
+        // value change, dialog should be shown and emulator should be paused
+        memory.at(0x28) = 0x10;
+        bookmarks.DoFrame();
+        Assert::AreEqual({1U}, bookmarks.mockFrameEventQueue.NumMemoryChanges());
+        Assert::IsTrue(bookmarks.mockFrameEventQueue.ContainsMemoryChange(L"8-bit 0x0028"));
+        bookmarks.mockFrameEventQueue.Reset();
+
+        // pointer change, dialog should be shown
+        memory.at(0x20) = 0x10;
+        bookmarks.DoFrame();
+        Assert::AreEqual({1U}, bookmarks.mockFrameEventQueue.NumMemoryChanges());
+        Assert::IsTrue(bookmarks.mockFrameEventQueue.ContainsMemoryChange(L"8-bit 0x0018"));
+        bookmarks.mockFrameEventQueue.Reset();
+
+        // pointer change, but value doesn't change, dialog should not be shown
+        memory.at(0x20) = 0x00;
+        memory.at(0x08) = 0x18;
+        bookmarks.DoFrame();
+        Assert::AreEqual({0U}, bookmarks.mockFrameEventQueue.NumMemoryChanges());
+    }
 };
 
 } // namespace tests
diff --git a/tests/ui/viewmodels/MemoryInspectorViewModel_Tests.cpp b/tests/ui/viewmodels/MemoryInspectorViewModel_Tests.cpp
index c49d19d9..43e3a8bf 100644
--- a/tests/ui/viewmodels/MemoryInspectorViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/MemoryInspectorViewModel_Tests.cpp
@@ -8,6 +8,7 @@
 #include "tests\ui\UIAsserts.hh"
 #include "tests\RA_UnitTestHelpers.h"
 
+#include "tests\mocks\MockAchievementRuntime.hh"
 #include "tests\mocks\MockConfiguration.hh"
 #include "tests\mocks\MockConsoleContext.hh"
 #include "tests\mocks\MockDesktop.hh"
@@ -722,6 +723,33 @@ public:
         Assert::AreEqual({12U}, pBookmarks.GetItemAt(2)->GetAddress());
         Assert::AreEqual(MemSize::ThirtyTwoBitBigEndian, pBookmarks.GetItemAt(2)->GetSize());
     }
+
+    TEST_METHOD(TestBookmarkCurrentAddressIndirect)
+    {
+        MemoryInspectorViewModelHarness inspector;
+        ra::services::mocks::MockAchievementRuntime mockAchievementRuntime;
+
+        std::array<uint8_t, 64> memory = {};
+        for (uint8_t i = 8; i < memory.size(); i += 4)
+            memory.at(i) = i;
+        inspector.mockEmulatorContext.MockMemory(memory);
+        memory.at(4) = 12;
+
+        inspector.mockGameContext.Assets().FindCodeNotes()->SetCodeNote({4U},
+            L"[32-bit pointer] Player data\n"
+            L"+4: [32-bit] Current HP\n"
+            L"+8: [32-bit] Max HP");
+
+        inspector.SetCurrentAddress(16U);
+        inspector.BookmarkCurrentAddress();
+
+        const auto& pBookmarks = inspector.mockWindowManager.MemoryBookmarks.Bookmarks();
+        Assert::AreEqual({1U}, pBookmarks.Count());
+        Assert::AreEqual({16U}, pBookmarks.GetItemAt(0)->GetAddress());
+        Assert::AreEqual(MemSize::ThirtyTwoBit, pBookmarks.GetItemAt(0)->GetSize());
+        Assert::AreEqual(std::string("I:0xX0004_M:0xX0004"), pBookmarks.GetItemAt(0)->GetIndirectAddress());
+        Assert::AreEqual(std::wstring(L"[32-bit] Current HP"), pBookmarks.GetItemAt(0)->GetDescription());
+    }
 };
 
 } // namespace tests
diff --git a/tests/ui/viewmodels/OverlayAchievementsPageViewModel_Tests.cpp b/tests/ui/viewmodels/OverlayAchievementsPageViewModel_Tests.cpp
index 70b67dc5..3a917c53 100644
--- a/tests/ui/viewmodels/OverlayAchievementsPageViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/OverlayAchievementsPageViewModel_Tests.cpp
@@ -497,8 +497,8 @@ public:
         pAch4->public_.state = RC_CLIENT_ACHIEVEMENT_STATE_UNLOCKED;
         pAch4->public_.unlocked = RC_CLIENT_ACHIEVEMENT_UNLOCKED_BOTH;
 
-        achievementsPage.mockWindowManager.AssetList.SetFilterCategory(
-            ra::ui::viewmodels::AssetListViewModel::FilterCategory::Core);
+        achievementsPage.mockWindowManager.AssetList.SetCategoryFilter(
+            ra::ui::viewmodels::AssetListViewModel::CategoryFilter::Core);
         achievementsPage.SetCanCollapseHeaders(false);
         achievementsPage.Refresh();
 
@@ -541,8 +541,8 @@ public:
         pAch4->public_.state = RC_CLIENT_ACHIEVEMENT_STATE_UNLOCKED;
         pAch4->public_.unlocked = RC_CLIENT_ACHIEVEMENT_UNLOCKED_BOTH;
 
-        achievementsPage.mockWindowManager.AssetList.SetFilterCategory(
-            ra::ui::viewmodels::AssetListViewModel::FilterCategory::Local);
+        achievementsPage.mockWindowManager.AssetList.SetCategoryFilter(
+            ra::ui::viewmodels::AssetListViewModel::CategoryFilter::Local);
         achievementsPage.SetCanCollapseHeaders(false);
         achievementsPage.Refresh();
 
@@ -768,7 +768,7 @@ public:
     TEST_METHOD(TestFetchItemDetailLocal)
     {
         OverlayAchievementsPageViewModelHarness achievementsPage;
-        achievementsPage.mockWindowManager.AssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        achievementsPage.mockWindowManager.AssetList.SetCategoryFilter(AssetListViewModel::CategoryFilter::Local);
         achievementsPage.mockAchievementRuntime.MockGame();
         const auto* pAch1 = achievementsPage.mockAchievementRuntime.MockAchievement(ra::data::context::GameAssets::FirstLocalId);
 
diff --git a/tests/ui/viewmodels/OverlayLeaderboardsPageViewModel_Tests.cpp b/tests/ui/viewmodels/OverlayLeaderboardsPageViewModel_Tests.cpp
index afe71d26..8352b52d 100644
--- a/tests/ui/viewmodels/OverlayLeaderboardsPageViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/OverlayLeaderboardsPageViewModel_Tests.cpp
@@ -57,11 +57,11 @@ private:
             return nullptr;
         }
 
-        void SetAssetFilter(ra::ui::viewmodels::AssetListViewModel::FilterCategory nCategory)
+        void SetAssetFilter(ra::ui::viewmodels::AssetListViewModel::CategoryFilter nCategory)
         {
             auto& pAssetList = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::WindowManager>().AssetList;
             pAssetList.SetAssetTypeFilter(ra::data::models::AssetType::Leaderboard);
-            pAssetList.SetFilterCategory(nCategory);
+            pAssetList.SetCategoryFilter(nCategory);
         }
 
         void AssertHeader(gsl::index nIndex, const std::wstring& sExpectedLabel)
@@ -157,7 +157,7 @@ public:
         auto* pLbd2 = leaderboardsPage.mockAchievementRuntime.MockLocalLeaderboard(2);
         auto* pLbd3 = leaderboardsPage.mockAchievementRuntime.MockLeaderboard(3);
 
-        leaderboardsPage.SetAssetFilter(ra::ui::viewmodels::AssetListViewModel::FilterCategory::Local);
+        leaderboardsPage.SetAssetFilter(ra::ui::viewmodels::AssetListViewModel::CategoryFilter::Local);
         leaderboardsPage.SetCanCollapseHeaders(false);
         leaderboardsPage.Refresh();
 
@@ -259,7 +259,7 @@ public:
         pLbd2->public_.state = RC_CLIENT_LEADERBOARD_STATE_DISABLED;
         auto* pLbd3 = leaderboardsPage.mockAchievementRuntime.MockLeaderboard(3);
 
-        leaderboardsPage.SetAssetFilter(ra::ui::viewmodels::AssetListViewModel::FilterCategory::Local);
+        leaderboardsPage.SetAssetFilter(ra::ui::viewmodels::AssetListViewModel::CategoryFilter::Local);
         leaderboardsPage.SetCanCollapseHeaders(false);
         leaderboardsPage.Refresh();