Repository: RetroAchievements/RAIntegration
Pull request #624 (2020-11-02 04:51:26)

support for copy/paste/remove conditions in trigger editor


Patch:
diff --git a/src/Exports.cpp b/src/Exports.cpp
index 320b6734..9f1ae39a 100644
--- a/src/Exports.cpp
+++ b/src/Exports.cpp
@@ -14,6 +14,7 @@
 #include "data\UserContext.hh"
 
 #include "services\AchievementRuntime.hh"
+#include "services\FrameEventQueue.hh"
 #include "services\GameIdentifier.hh"
 #include "services\Http.hh"
 #include "services\IAudioSystem.hh"
@@ -330,6 +331,7 @@ static void ProcessAchievements()
 #endif
 
     const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::GameContext>();
+    auto& vmAssetList = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::WindowManager>().AssetList;
 
     TALLY_PERFORMANCE(PerformanceCheckpoint::RuntimeProcess);
     std::vector<ra::services::AchievementRuntime::Change> vChanges;
@@ -345,40 +347,57 @@ static void ProcessAchievements()
                 const auto* pAchievement = pGameContext.FindAchievement(pChange.nId);
                 if (pAchievement && pAchievement->GetPauseOnReset())
                 {
-                    ra::services::ServiceLocator::Get<ra::data::EmulatorContext>().Pause();
+                    auto& pFrameEventQueue = ra::services::ServiceLocator::GetMutable<ra::services::FrameEventQueue>();
+                    pFrameEventQueue.QueuePauseOnReset(ra::Widen(pAchievement->Title()));
+                }
 
-                    std::wstring sMessage = ra::StringPrintf(L"Pause on Reset: %s", pAchievement->Title());
-                    ra::ui::viewmodels::MessageBoxViewModel::ShowMessage(sMessage);
+                const auto* vmAchievement = vmAssetList.FindAchievement(pChange.nId);
+                if (vmAchievement && vmAchievement->IsPauseOnReset())
+                {
+                    auto& pFrameEventQueue = ra::services::ServiceLocator::GetMutable<ra::services::FrameEventQueue>();
+                    pFrameEventQueue.QueuePauseOnReset(vmAchievement->GetName());
                 }
+
                 break;
             }
 
             case ra::services::AchievementRuntime::ChangeType::AchievementTriggered:
             {
+                // AwardAchievement may detach the trigger, which would result in the state changing to Inactive.
+                // explicitly call DoFrame now to set the state to Triggered before the trigger gets detached.
+                auto* vmAchievement = vmAssetList.FindAchievement(pChange.nId);
+                if (vmAchievement)
+                    vmAchievement->DoFrame();
+
                 pGameContext.AwardAchievement(pChange.nId);
 
 #pragma warning(push)
 #pragma warning(disable : 26462)
                 auto* pAchievement = pGameContext.FindAchievement(pChange.nId);
 #pragma warning(pop)
-                if (!pAchievement)
-                    break;
-
-                if (pGameContext.HasRichPresence() && !pGameContext.IsRichPresenceFromFile())
-                    pAchievement->SetUnlockRichPresence(pGameContext.GetRichPresenceDisplayString());
+                if (pAchievement)
+                {
+                    if (pGameContext.HasRichPresence() && !pGameContext.IsRichPresenceFromFile())
+                        pAchievement->SetUnlockRichPresence(pGameContext.GetRichPresenceDisplayString());
 
 #ifndef RA_UTEST
-                g_AchievementsDialog.ReloadLBXData(pAchievement->ID());
+                    g_AchievementsDialog.ReloadLBXData(pAchievement->ID());
 
-                if (g_AchievementEditorDialog.ActiveAchievement() == pAchievement)
-                    g_AchievementEditorDialog.LoadAchievement(pAchievement, TRUE);
+                    if (g_AchievementEditorDialog.ActiveAchievement() == pAchievement)
+                        g_AchievementEditorDialog.LoadAchievement(pAchievement, TRUE);
 #endif
 
-                if (pAchievement->GetPauseOnTrigger())
+                    if (pAchievement->GetPauseOnTrigger())
+                    {
+                        auto& pFrameEventQueue = ra::services::ServiceLocator::GetMutable<ra::services::FrameEventQueue>();
+                        pFrameEventQueue.QueuePauseOnTrigger(ra::Widen(pAchievement->Title()));
+                    }
+                }
+
+                if (vmAchievement && vmAchievement->IsPauseOnTrigger())
                 {
-                    ra::services::ServiceLocator::Get<ra::data::EmulatorContext>().Pause();
-                    std::wstring sMessage = ra::StringPrintf(L"Pause on Trigger: %s", pAchievement->Title());
-                    ra::ui::viewmodels::MessageBoxViewModel::ShowMessage(sMessage);
+                    auto& pFrameEventQueue = ra::services::ServiceLocator::GetMutable<ra::services::FrameEventQueue>();
+                    pFrameEventQueue.QueuePauseOnTrigger(vmAchievement->GetName());
                 }
 
                 break;
@@ -484,6 +503,9 @@ static void UpdateUIForFrameChange()
 
     TALLY_PERFORMANCE(PerformanceCheckpoint::AssetEditorDoFrame);
     pWindowManager.AssetEditor.DoFrame();
+
+    auto& pFrameEventQueue = ra::services::ServiceLocator::GetMutable<ra::services::FrameEventQueue>();
+    pFrameEventQueue.DoFrame();
 }
 
 #endif
diff --git a/src/RA_Integration.vcxproj b/src/RA_Integration.vcxproj
index 1963f580..633cd2cd 100644
--- a/src/RA_Integration.vcxproj
+++ b/src/RA_Integration.vcxproj
@@ -108,6 +108,7 @@
     <ClCompile Include="RA_StringUtils.cpp" />
     <ClCompile Include="data\GameContext.cpp" />
     <ClCompile Include="services\AchievementRuntime.cpp" />
+    <ClCompile Include="services\FrameEventQueue.cpp" />
     <ClCompile Include="services\GameIdentifier.cpp" />
     <ClCompile Include="services\Http.cpp" />
     <ClCompile Include="services\impl\FileLocalStorage.cpp" />
@@ -245,6 +246,7 @@
     <ClInclude Include="RA_Resource.h" />
     <ClInclude Include="RA_StringUtils.h" />
     <ClInclude Include="services\AchievementRuntime.hh" />
+    <ClInclude Include="services\FrameEventQueue.hh" />
     <ClInclude Include="services\GameIdentifier.hh" />
     <ClInclude Include="services\Http.hh" />
     <ClInclude Include="services\IAudioSystem.hh" />
diff --git a/src/RA_Integration.vcxproj.filters b/src/RA_Integration.vcxproj.filters
index 08234180..5e5b0dae 100644
--- a/src/RA_Integration.vcxproj.filters
+++ b/src/RA_Integration.vcxproj.filters
@@ -345,6 +345,9 @@
     <ClCompile Include="ui\win32\AssetEditorDialog.cpp">
       <Filter>UI\Win32</Filter>
     </ClCompile>
+    <ClCompile Include="services\FrameEventQueue.cpp">
+      <Filter>Services</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <ClInclude Include="RA_Achievement.h">
@@ -872,6 +875,9 @@
     <ClInclude Include="ui\win32\bindings\NumericTextBoxBinding.hh">
       <Filter>UI\Win32\Bindings</Filter>
     </ClInclude>
+    <ClInclude Include="services\FrameEventQueue.hh">
+      <Filter>Services</Filter>
+    </ClInclude>
   </ItemGroup>
   <ItemGroup>
     <ResourceCompile Include="RA_Shared.rc">
diff --git a/src/RA_Shared.rc b/src/RA_Shared.rc
index 28043cc6..53794935 100644
--- a/src/RA_Shared.rc
+++ b/src/RA_Shared.rc
@@ -143,7 +143,7 @@ BEGIN
     CONTROL         "Pause on Trigger",IDC_RA_CHK_ACH_PAUSE_ON_TRIGGER,
                     "Button",BS_AUTOCHECKBOX | WS_TABSTOP,336,63,65,10
     CONTROL         "Active",IDC_RA_CHK_ACH_ACTIVE,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,406,63,32,10
-    CONTROL         "",IDC_RA_LBX_CONDITIONS,"SysListView32",LVS_REPORT | LVS_ALIGNLEFT | LVS_NOSORTHEADER | WS_BORDER | WS_TABSTOP,50,74,388,80
+    CONTROL         "",IDC_RA_LBX_CONDITIONS,"SysListView32",LVS_REPORT | LVS_ALIGNLEFT | LVS_NOSORTHEADER | LVS_SHOWSELALWAYS | WS_BORDER | WS_TABSTOP,50,74,388,80
     PUSHBUTTON      "Add New Requirement",IDC_RA_ADDCOND,50,159,48,24,BS_MULTILINE
     PUSHBUTTON      "Delete Selected",IDC_RA_DELETECOND,102,159,48,24,BS_MULTILINE
     PUSHBUTTON      "Copy",IDC_RA_COPYCOND,154,159,32,12,BS_MULTILINE
diff --git a/src/services/FrameEventQueue.cpp b/src/services/FrameEventQueue.cpp
new file mode 100644
index 00000000..567c758d
--- /dev/null
+++ b/src/services/FrameEventQueue.cpp
@@ -0,0 +1,76 @@
+#include "FrameEventQueue.hh"
+
+#include "RA_Defs.h"
+#include "RA_StringUtils.h"
+
+#include "ui\viewmodels\MessageBoxViewModel.hh"
+#include "ui\viewmodels\WindowManager.hh"
+
+namespace ra {
+namespace services {
+
+void FrameEventQueue::QueuePauseOnChange(MemSize nSize, ra::ByteAddress nAddress)
+{
+    for (const auto& pChange : m_vMemChanges)
+    {
+        if (pChange.nAddress == nAddress && pChange.nSize == nSize)
+            return;
+    }
+
+    m_vMemChanges.emplace_back(nAddress, nSize);
+}
+
+void FrameEventQueue::DoFrame()
+{
+    std::wstring sPauseMessage;
+
+    if (!m_vTriggeredTriggers.empty())
+    {
+        sPauseMessage.append(L"The following triggers have triggered:");
+        for (const auto& pTriggerName : m_vTriggeredTriggers)
+            sPauseMessage.append(ra::StringPrintf(L"\n* %s", pTriggerName));
+
+        m_vTriggeredTriggers.clear();
+    }
+
+    if (!m_vResetTriggers.empty())
+    {
+        if (!sPauseMessage.empty())
+            sPauseMessage.append(L"\n");
+
+        sPauseMessage.append(L"The following triggers have been reset:");
+        for (const auto& pTriggerName : m_vResetTriggers)
+            sPauseMessage.append(ra::StringPrintf(L"\n* %s", pTriggerName));
+
+        m_vResetTriggers.clear();
+    }
+
+    if (!m_vMemChanges.empty())
+    {
+        if (!sPauseMessage.empty())
+            sPauseMessage.append(L"\n");
+
+        sPauseMessage.append(L"The following bookmarks have changed:");
+
+        const auto& pAssetEditor = ra::services::ServiceLocator::Get<ra::ui::viewmodels::WindowManager>().AssetEditor;
+        for (const auto& pChange : m_vMemChanges)
+        {
+            sPauseMessage.append(ra::StringPrintf(L"\n* %s %s",
+                pAssetEditor.Trigger().OperandSizes().GetLabelForId(ra::etoi(pChange.nSize)),
+                ra::ByteAddressToString(pChange.nAddress)));
+        }
+
+        m_vMemChanges.clear();
+    }
+
+    if (!sPauseMessage.empty())
+    {
+        const auto& pEmulatorContext = ra::services::ServiceLocator::Get<ra::data::EmulatorContext>();
+        pEmulatorContext.Pause();
+
+        ra::ui::viewmodels::MessageBoxViewModel::ShowWarningMessage(L"The emulator has been paused.", sPauseMessage);
+    }
+}
+
+} // namespace services
+} // namespace ra
diff --git a/src/services/FrameEventQueue.hh b/src/services/FrameEventQueue.hh
new file mode 100644
index 00000000..6d7851c7
--- /dev/null
+++ b/src/services/FrameEventQueue.hh
@@ -0,0 +1,57 @@
+#ifndef RA_SERVICES_FRAME_EVENT_QUEUE_HH
+#define RA_SERVICES_FRAME_EVENT_QUEUE_HH
+#pragma once
+
+#include "ra_fwd.h"
+
+#include "data\Types.hh"
+
+namespace ra {
+namespace services {
+
+class FrameEventQueue
+{
+public:
+    GSL_SUPPRESS_F6 FrameEventQueue() = default;
+    virtual ~FrameEventQueue() = default;
+
+    FrameEventQueue(const FrameEventQueue&) noexcept = delete;
+    FrameEventQueue& operator=(const FrameEventQueue&) noexcept = delete;
+    FrameEventQueue(FrameEventQueue&&) noexcept = delete;
+    FrameEventQueue& operator=(FrameEventQueue&&) noexcept = delete;
+
+    void QueuePauseOnChange(MemSize nSize, ra::ByteAddress nAddress);
+
+    void QueuePauseOnReset(const std::wstring& sTriggerName)
+    {
+        m_vResetTriggers.push_back(sTriggerName);
+    }
+
+    void QueuePauseOnTrigger(const std::wstring& sTriggerName)
+    {
+        m_vTriggeredTriggers.push_back(sTriggerName);
+    }
+
+    void DoFrame();
+
+protected:
+    struct MemChange
+    {
+        MemChange(ra::ByteAddress nAddress, MemSize nSize) noexcept
+            : nAddress(nAddress), nSize(nSize)
+        {
+        }
+
+        ra::ByteAddress nAddress;
+        MemSize nSize;
+    };
+
+    std::vector<MemChange> m_vMemChanges;
+    std::vector<std::wstring> m_vResetTriggers;
+    std::vector<std::wstring> m_vTriggeredTriggers;
+};
+
+} // namespace services
+} // namespace ra
+
+#endif // !RA_SERVICES_FRAME_EVENT_QUEUE_HH
diff --git a/src/services/IClipboard.hh b/src/services/IClipboard.hh
index 77521ebc..d82b6386 100644
--- a/src/services/IClipboard.hh
+++ b/src/services/IClipboard.hh
@@ -21,6 +21,11 @@ public:
     /// </summary>
     virtual void SetText(const std::wstring& sValue) const = 0;
 
+    /// <summary>
+    /// Gets text from the clipboard.
+    /// </summary>
+    virtual std::wstring GetText() const = 0;
+
 protected:
     IClipboard() noexcept = default;
 };
diff --git a/src/services/Initialization.cpp b/src/services/Initialization.cpp
index b16c81bc..ee170103 100644
--- a/src/services/Initialization.cpp
+++ b/src/services/Initialization.cpp
@@ -9,6 +9,7 @@
 #include "data\UserContext.hh"
 
 #include "services\AchievementRuntime.hh"
+#include "services\FrameEventQueue.hh"
 #include "services\GameIdentifier.hh"
 #include "services\PerformanceCounter.hh"
 #include "services\ServiceLocator.hh"
@@ -150,6 +151,9 @@ void Initialization::RegisterServices(EmulatorID nEmulatorId, const char* sClien
     auto pClipboard = std::make_unique<ra::services::impl::WindowsClipboard>();
     ra::services::ServiceLocator::Provide<ra::services::IClipboard>(std::move(pClipboard));
 
+    auto pFrameEventQueue = std::make_unique<ra::services::FrameEventQueue>();
+    ra::services::ServiceLocator::Provide<ra::services::FrameEventQueue>(std::move(pFrameEventQueue));
+
     auto pSurfaceFactory = std::make_unique<ra::ui::drawing::gdi::GDISurfaceFactory>();
     ra::services::ServiceLocator::Provide<ra::ui::drawing::ISurfaceFactory>(std::move(pSurfaceFactory));
 
diff --git a/src/services/impl/WindowsClipboard.hh b/src/services/impl/WindowsClipboard.hh
index ce7f96b1..6e9b93c2 100644
--- a/src/services/impl/WindowsClipboard.hh
+++ b/src/services/impl/WindowsClipboard.hh
@@ -38,6 +38,42 @@ public:
             }
         }
     }
+
+    std::wstring GetText() const override
+    {
+        std::wstring sText;
+
+        if (OpenClipboard(nullptr))
+        {
+            HANDLE hData = GetClipboardData(CF_UNICODETEXT);
+            if (hData != nullptr)
+            {
+                const auto* pszText = static_cast<wchar_t*>(GlobalLock(hData));
+                if (pszText != nullptr)
+                {
+                    sText = pszText;
+                    GlobalUnlock(hData);
+                }
+            }
+            else
+            {
+                hData = GetClipboardData(CF_TEXT);
+                if (hData != nullptr)
+                {
+                    const auto* pszText = static_cast<char*>(GlobalLock(hData));
+                    if (pszText != nullptr)
+                    {
+                        sText = ra::Widen(pszText);
+                        GlobalUnlock(hData);
+                    }
+                }
+            }
+
+            CloseClipboard();
+        }
+
+        return sText;
+    }
 };
 
 } // namespace impl
diff --git a/src/ui/ViewModelCollection.cpp b/src/ui/ViewModelCollection.cpp
index e28da08c..b6616e27 100644
--- a/src/ui/ViewModelCollection.cpp
+++ b/src/ui/ViewModelCollection.cpp
@@ -230,6 +230,14 @@ void ViewModelCollectionBase::StopWatching() noexcept
 void ViewModelCollectionBase::OnViewModelBoolValueChanged(gsl::index nIndex,
                                                           const BoolModelProperty::ChangeArgs& args)
 {
+    // ignore events for items added while updates are suspended
+    if (nIndex < 0)
+        return;
+
+    // Updating property values after inserting or removing items while updates are suspended causes synchronization
+    // errors in the GridBinding. This exception serves as a reminder until the problem can be resolved.
+    Expects(nIndex == m_vItems.at(gsl::narrow_cast<size_t>(nIndex)).Index());
+
     // create a copy of the list of pointers in case it's modified by one of the callbacks
     NotifyTargetSet vNotifyTargets(m_vNotifyTargets);
     for (NotifyTarget* target : vNotifyTargets)
@@ -242,6 +250,14 @@ void ViewModelCollectionBase::OnViewModelBoolValueChanged(gsl::index nIndex,
 void ViewModelCollectionBase::OnViewModelStringValueChanged(gsl::index nIndex,
                                                             const StringModelProperty::ChangeArgs& args)
 {
+    // ignore events for items added while updates are suspended
+    if (nIndex < 0)
+        return;
+
+    // Updating property values after inserting or removing items while updates are suspended causes synchronization
+    // errors in the GridBinding. This exception serves as a reminder until the problem can be resolved.
+    Expects(nIndex == m_vItems.at(gsl::narrow_cast<size_t>(nIndex)).Index());
+
     // create a copy of the list of pointers in case it's modified by one of the callbacks
     NotifyTargetSet vNotifyTargets(m_vNotifyTargets);
     for (NotifyTarget* target : vNotifyTargets)
@@ -254,6 +270,14 @@ void ViewModelCollectionBase::OnViewModelStringValueChanged(gsl::index nIndex,
 void ViewModelCollectionBase::OnViewModelIntValueChanged(gsl::index nIndex,
                                                          const IntModelProperty::ChangeArgs& args)
 {
+    // ignore events for items added while updates are suspended
+    if (nIndex < 0)
+        return;
+
+    // Updating property values after inserting or removing items while updates are suspended causes synchronization
+    // errors in the GridBinding. This exception serves as a reminder until the problem can be resolved.
+    Expects(nIndex == m_vItems.at(gsl::narrow_cast<size_t>(nIndex)).Index());
+
     // create a copy of the list of pointers in case it's modified by one of the callbacks
     NotifyTargetSet vNotifyTargets(m_vNotifyTargets);
     for (NotifyTarget* target : vNotifyTargets)
diff --git a/src/ui/viewmodels/AssetListViewModel.cpp b/src/ui/viewmodels/AssetListViewModel.cpp
index 7089597e..38bca644 100644
--- a/src/ui/viewmodels/AssetListViewModel.cpp
+++ b/src/ui/viewmodels/AssetListViewModel.cpp
@@ -126,6 +126,28 @@ void AssetListViewModel::OnViewModelIntValueChanged(gsl::index nIndex, const Int
             }
         }
     }
+    else if (args.Property == AssetViewModelBase::IDProperty)
+    {
+        auto* pAsset = m_vAssets.GetItemAt(nIndex);
+        if (pAsset != nullptr)
+        {
+            gsl::index nFilteredIndex = -1;
+            const auto nType = pAsset->GetType();
+
+            for (gsl::index nScanIndex = 0; nScanIndex < gsl::narrow_cast<gsl::index>(m_vFilteredAssets.Count()); ++nScanIndex)
+            {
+                auto* pItem = m_vFilteredAssets.GetItemAt(nScanIndex);
+                if (pItem != nullptr && pItem->GetId() == args.tOldValue && pItem->GetType() == nType)
+                {
+                    nFilteredIndex = nScanIndex;
+                    break;
+                }
+            }
+
+            if (nFilteredIndex != -1)
+                m_vFilteredAssets.SetItemValue(nFilteredIndex, AssetSummaryViewModel::IdProperty, args.tNewValue);
+        }
+    }
 }
 
 void AssetListViewModel::OnViewModelAdded(_UNUSED gsl::index nIndex)
diff --git a/src/ui/viewmodels/MemoryBookmarksViewModel.cpp b/src/ui/viewmodels/MemoryBookmarksViewModel.cpp
index 3ea2fe18..eb9417eb 100644
--- a/src/ui/viewmodels/MemoryBookmarksViewModel.cpp
+++ b/src/ui/viewmodels/MemoryBookmarksViewModel.cpp
@@ -6,6 +6,7 @@
 
 #include "data\EmulatorContext.hh"
 
+#include "services\FrameEventQueue.hh"
 #include "services\IConfiguration.hh"
 #include "services\IFileSystem.hh"
 #include "services\ILocalStorage.hh"
@@ -298,7 +299,6 @@ void MemoryBookmarksViewModel::SaveBookmarks(ra::services::TextWriter& sBookmark
 void MemoryBookmarksViewModel::DoFrame()
 {
     const auto& pEmulatorContext = ra::services::ServiceLocator::Get<ra::data::EmulatorContext>();
-    std::wstring sPauseOnChangeAddresses;
 
     for (gsl::index nIndex = 0; ra::to_unsigned(nIndex) < m_vBookmarks.Count(); ++nIndex)
     {
@@ -319,9 +319,8 @@ void MemoryBookmarksViewModel::DoFrame()
                 {
                     pBookmark.SetRowColor(ra::ui::Color(0xFFFFC0C0));
 
-                    sPauseOnChangeAddresses.append(ra::StringPrintf(L"\n* %s %s",
-                        m_vSizes.GetLabelForId(ra::etoi(pBookmark.GetSize())),
-                        ra::ByteAddressToString(pBookmark.GetAddress())));
+                    auto& pFrameEventQueue = ra::services::ServiceLocator::GetMutable<ra::services::FrameEventQueue>();
+                    pFrameEventQueue.QueuePauseOnChange(pBookmark.GetSize(), pBookmark.GetAddress());
                 }
 
                 m_bIgnoreValueChanged = true;
@@ -336,14 +335,6 @@ void MemoryBookmarksViewModel::DoFrame()
             pBookmark.SetRowColor(ra::ui::Color(ra::to_unsigned(MemoryBookmarkViewModel::RowColorProperty.GetDefaultValue())));
         }
     }
-
-    if (!sPauseOnChangeAddresses.empty())
-    {
-        pEmulatorContext.Pause();
-
-        sPauseOnChangeAddresses = L"The following bookmarks have changed:" + sPauseOnChangeAddresses;
-        ra::ui::viewmodels::MessageBoxViewModel::ShowWarningMessage(L"The emulator has been paused.", sPauseOnChangeAddresses);
-    }
 }
 
 bool MemoryBookmarksViewModel::HasBookmark(ra::ByteAddress nAddress) const
diff --git a/src/ui/viewmodels/OverlayAchievementsPageViewModel.cpp b/src/ui/viewmodels/OverlayAchievementsPageViewModel.cpp
index 311af14b..4ad257dc 100644
--- a/src/ui/viewmodels/OverlayAchievementsPageViewModel.cpp
+++ b/src/ui/viewmodels/OverlayAchievementsPageViewModel.cpp
@@ -9,9 +9,12 @@
 #include "data\UserContext.hh"
 
 #include "services\AchievementRuntime.hh"
+#include "services\IClock.hh"
 
 #include "ui\OverlayTheme.hh"
 
+#include "ui\viewmodels\WindowManager.hh"
+
 namespace ra {
 namespace ui {
 namespace viewmodels {
@@ -22,6 +25,90 @@ const StringModelProperty OverlayAchievementsPageViewModel::AchievementViewModel
 const StringModelProperty OverlayAchievementsPageViewModel::AchievementViewModel::ModifiedDateProperty("AchievementViewModel", "ModifiedDate", L"");
 const StringModelProperty OverlayAchievementsPageViewModel::AchievementViewModel::WonByProperty("AchievementViewModel", "WonBy", L"");
 
+OverlayListPageViewModel::ItemViewModel& OverlayAchievementsPageViewModel::GetNextItem(size_t* nIndex)
+{
+    Expects(nIndex != nullptr);
+
+    ItemViewModel* pvmAchievement = m_vItems.GetItemAt((*nIndex)++);
+    if (pvmAchievement == nullptr)
+    {
+        pvmAchievement = &m_vItems.Add();
+        Ensures(pvmAchievement != nullptr);
+    }
+
+    return *pvmAchievement;
+}
+
+static void SetHeader(OverlayListPageViewModel::ItemViewModel& vmItem, const std::wstring& sHeader)
+{
+    vmItem.SetId(0);
+    vmItem.SetLabel(sHeader);
+    vmItem.SetDetail(L"");
+    vmItem.SetDisabled(false);
+    vmItem.Image.ChangeReference(ra::ui::ImageType::None, "");
+    vmItem.SetProgressValue(0U);
+    vmItem.SetProgressMaximum(0U);
+}
+
+static void SetAchievement(OverlayListPageViewModel::ItemViewModel& vmItem, const ra::ui::viewmodels::AchievementViewModel& vmAchievement)
+{
+    vmItem.SetId(vmAchievement.GetID());
+    vmItem.SetLabel(ra::StringPrintf(L"%s (%s %s)", vmAchievement.GetName(),
+        vmAchievement.GetPoints(), (vmAchievement.GetPoints() == 1) ? "point" : "points"));
+    vmItem.SetDetail(vmAchievement.GetDescription());
+
+    if (vmAchievement.IsActive())
+    {
+        if (vmAchievement.GetCategory() == AssetCategory::Local)
+        {
+            // local achievements never appear disabled
+            vmItem.Image.ChangeReference(ra::ui::ImageType::Badge, ra::Narrow(vmAchievement.GetBadge()));
+            vmItem.SetDisabled(false);
+        }
+        else
+        {
+            vmItem.Image.ChangeReference(ra::ui::ImageType::Badge, ra::Narrow(vmAchievement.GetBadge()) + "_lock");
+            vmItem.SetDisabled(true);
+        }
+
+        const auto& pRuntime = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+        const auto* pTrigger = pRuntime.GetAchievementTrigger(vmAchievement.GetID());
+        if (pTrigger != nullptr)
+        {
+            vmItem.SetProgressMaximum(pTrigger->measured_target);
+            vmItem.SetProgressValue(pTrigger->measured_value);
+        }
+        else
+        {
+            vmItem.SetProgressMaximum(0);
+            vmItem.SetProgressValue(0);
+        }
+    }
+    else
+    {
+        vmItem.Image.ChangeReference(ra::ui::ImageType::Badge, ra::Narrow(vmAchievement.GetBadge()));
+        vmItem.SetDisabled(false);
+        vmItem.SetProgressValue(0U);
+        vmItem.SetProgressMaximum(0U);
+    }
+}
+
+static bool AppearsInFilter(const ra::ui::viewmodels::AchievementViewModel* vmAchievement)
+{
+    Expects(vmAchievement != nullptr);
+
+    const auto nId = ra::to_signed(vmAchievement->GetID());
+    const auto& vFilteredAssets = ra::services::ServiceLocator::Get<ra::ui::viewmodels::WindowManager>().AssetList.FilteredAssets();
+    for (gsl::index nIndex = 0; nIndex < ra::to_signed(vFilteredAssets.Count()); ++nIndex)
+    {
+        const auto* pAsset = vFilteredAssets.GetItemAt(nIndex);
+        if (pAsset && pAsset->GetId() == nId && pAsset->GetType() == AssetType::Achievement)
+            return true;
+    }
+
+    return false;
+}
+
 void OverlayAchievementsPageViewModel::Refresh()
 {
     m_sTitle = L"Achievements";
@@ -33,73 +120,216 @@ void OverlayAchievementsPageViewModel::Refresh()
     SetListTitle(pGameContext.GameTitle());
 
     // achievement list
+    std::vector<const ra::ui::viewmodels::AchievementViewModel*> vRecentAchievements;
+    std::vector<const ra::ui::viewmodels::AchievementViewModel*> vAlmostThereAchievements;
+    std::vector<const ra::ui::viewmodels::AchievementViewModel*> vUnofficialAchievements;
+    std::vector<const ra::ui::viewmodels::AchievementViewModel*> vLocalAchievements;
+    std::vector<const ra::ui::viewmodels::AchievementViewModel*> vLockedCoreAchievements;
+    std::vector<const ra::ui::viewmodels::AchievementViewModel*> vUnlockedCoreAchievements;
+
+    const auto& pRuntime = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+    const auto tNow = ra::services::ServiceLocator::Get<ra::services::IClock>().Now();
+
+    auto& vmAssetList = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::WindowManager>().AssetList;
+    for (gsl::index nIndex = 0; nIndex < ra::to_signed(vmAssetList.Assets().Count()); ++nIndex)
+    {
+        const auto* pAsset = vmAssetList.Assets().GetItemAt(nIndex);
+        if (!pAsset || pAsset->GetType() != AssetType::Achievement)
+            continue;
+
+        const auto* vmAchievement = dynamic_cast<const ra::ui::viewmodels::AchievementViewModel*>(pAsset);
+        switch (vmAchievement->GetState())
+        {
+            case AssetState::Inactive:
+                break;
+
+            case AssetState::Triggered:
+            {
+                const auto tUnlock = vmAchievement->GetUnlockTime();
+                const auto tElapsed = tNow - tUnlock;
+                if (tElapsed < std::chrono::minutes(10))
+                {
+                    gsl::index nInsert = 0;
+                    while (nInsert < ra::to_signed(vRecentAchievements.size()) && vRecentAchievements.at(nInsert)->GetUnlockTime() > tUnlock)
+                        nInsert++;
+                    vRecentAchievements.insert(vRecentAchievements.begin() + nInsert, vmAchievement);
+                    continue;
+                }
+                break;
+            }
+
+            default:
+            {
+                const auto* pTrigger = pRuntime.GetAchievementTrigger(vmAchievement->GetID());
+                if (pTrigger != nullptr && pTrigger->measured_target > 0)
+                {
+                    const auto nProgress = pTrigger->measured_value * 100 / pTrigger->measured_target;
+                    if (nProgress >= 80)
+                    {
+                        vAlmostThereAchievements.push_back(vmAchievement);
+                        continue;
+                    }
+                }
+                break;
+            }
+        }
+
+        switch (vmAchievement->GetCategory())
+        {
+            case AssetCategory::Local:
+                if (AppearsInFilter(vmAchievement))
+                    vLocalAchievements.push_back(vmAchievement);
+                break;
+
+            case AssetCategory::Unofficial:
+                if (AppearsInFilter(vmAchievement))
+                    vUnofficialAchievements.push_back(vmAchievement);
+                break;
+
+            default:
+                if (vmAchievement->IsActive())
+                    vLockedCoreAchievements.push_back(vmAchievement);
+                else
+                    vUnlockedCoreAchievements.push_back(vmAchievement);
+                break;
+        }
+    }
+
     unsigned int nMaxPts = 0;
     unsigned int nUserPts = 0;
     unsigned int nUserCompleted = 0;
+    size_t nIndex = 0;
     size_t nNumberOfAchievements = 0;
     size_t nNumberOfCoreAchievements = 0;
 
-    pGameContext.EnumerateFilteredAchievements([this, &nNumberOfAchievements, &nNumberOfCoreAchievements,
-        &nMaxPts, &nUserPts, &nUserCompleted](const Achievement& pAchievement)
-    {
-        ItemViewModel* pvmAchievement = m_vItems.GetItemAt(nNumberOfAchievements);
-        if (pvmAchievement == nullptr)
-        {
-            pvmAchievement = &m_vItems.Add();
-            Ensures(pvmAchievement != nullptr);
-        }
+    m_vItems.BeginUpdate();
 
-        pvmAchievement->SetId(pAchievement.ID());
-        pvmAchievement->SetLabel(ra::StringPrintf(L"%s (%s points)", pAchievement.Title(), pAchievement.Points()));
-        pvmAchievement->SetDetail(ra::Widen(pAchievement.Description()));
-        pvmAchievement->SetDisabled(false);
-        pvmAchievement->Image.ChangeReference(ra::ui::ImageType::Badge, pAchievement.BadgeImageURI());
-        ++nNumberOfAchievements;
+    if (!vRecentAchievements.empty())
+    {
+        auto& pvmHeader = GetNextItem(&nIndex);
+        SetHeader(pvmHeader, L"Recently Unlocked");
 
-        if (pAchievement.GetCategory() == Achievement::Category::Core)
+        for (const auto* vmAchievement : vRecentAchievements)
         {
-            ++nNumberOfCoreAchievements;
+            auto& pvmAchievement = GetNextItem(&nIndex);
+            SetAchievement(pvmAchievement, *vmAchievement);
 
-            nMaxPts += pAchievement.Points();
-            if (pAchievement.Active())
-            {
-                pvmAchievement->Image.ChangeReference(ra::ui::ImageType::Badge, pAchievement.BadgeImageURI() + "_lock");
-                pvmAchievement->SetDisabled(true);
-            }
-            else
+            const auto nPoints = vmAchievement->GetPoints();
+            nMaxPts += nPoints;
+
+            if (vmAchievement->GetCategory() == AssetCategory::Core)
             {
-                nUserPts += pAchievement.Points();
+                nUserPts += nPoints;
                 ++nUserCompleted;
+                ++nNumberOfCoreAchievements;
             }
         }
 
-        if (pAchievement.Active())
+        nNumberOfAchievements += vRecentAchievements.size();
+    }
+
+    if (!vAlmostThereAchievements.empty())
+    {
+        auto& pvmHeader = GetNextItem(&nIndex);
+        SetHeader(pvmHeader, L"Almost There");
+
+        for (const auto* vmAchievement : vAlmostThereAchievements)
         {
-            const auto& pRuntime = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
-            const auto* pTrigger = pRuntime.GetAchievementTrigger(pAchievement.ID());
-            if (pTrigger != nullptr)
-            {
-                pvmAchievement->SetProgressMaximum(pTrigger->measured_target);
-                pvmAchievement->SetProgressValue(pTrigger->measured_value);
-            }
-            else
-            {
-                pvmAchievement->SetProgressMaximum(0);
-                pvmAchievement->SetProgressValue(0);
-            }
+            auto& pvmAchievement = GetNextItem(&nIndex);
+            SetAchievement(pvmAchievement, *vmAchievement);
+            nMaxPts += vmAchievement->GetPoints();
+
+            if (vmAchievement->GetCategory() == AssetCategory::Core)
+                ++nNumberOfCoreAchievements;
         }
-        else
+
+        nNumberOfAchievements += vAlmostThereAchievements.size();
+    }
+
+    if (!vLocalAchievements.empty())
+    {
+        if (nIndex > 0 || !vUnofficialAchievements.empty() || !vLockedCoreAchievements.empty() || !vUnlockedCoreAchievements.empty())
         {
-            pvmAchievement->SetProgressMaximum(0);
-            pvmAchievement->SetProgressValue(0);
+            auto& pvmHeader = GetNextItem(&nIndex);
+            SetHeader(pvmHeader, L"Local");
         }
 
-        return true;
-    });
+        for (const auto* vmAchievement : vLocalAchievements)
+        {
+            auto& pvmAchievement = GetNextItem(&nIndex);
+            SetAchievement(pvmAchievement, *vmAchievement);
+            nMaxPts += vmAchievement->GetPoints();
+        }
+
+        nNumberOfAchievements += vLocalAchievements.size();
+    }
+
+    if (!vUnofficialAchievements.empty())
+    {
+        if (nIndex > 0 || !vLockedCoreAchievements.empty() || !vUnlockedCoreAchievements.empty())
+        {
+            auto& pvmHeader = GetNextItem(&nIndex);
+            SetHeader(pvmHeader, L"Unofficial");
+        }
+
+        for (const auto* vmAchievement : vUnofficialAchievements)
+        {
+            auto& pvmAchievement = GetNextItem(&nIndex);
+            SetAchievement(pvmAchievement, *vmAchievement);
+            nMaxPts += vmAchievement->GetPoints();
+        }
 
-    while (m_vItems.Count() > nNumberOfAchievements)
+        nNumberOfAchievements += vUnofficialAchievements.size();
+    }
+
+    if (!vLockedCoreAchievements.empty())
+    {
+        if (nIndex > 0)
+        {
+            auto& pvmHeader = GetNextItem(&nIndex);
+            SetHeader(pvmHeader, L"Locked");
+        }
+
+        for (const auto* vmAchievement : vLockedCoreAchievements)
+        {
+            auto& pvmAchievement = GetNextItem(&nIndex);
+            SetAchievement(pvmAchievement, *vmAchievement);
+            nMaxPts += vmAchievement->GetPoints();
+        }
+
+        nNumberOfAchievements += vLockedCoreAchievements.size();
+        nNumberOfCoreAchievements += vLockedCoreAchievements.size();
+    }
+
+    if (vUnlockedCoreAchievements.size() > 0)
+    {
+        if (nIndex > 0)
+        {
+            auto& pvmHeader = GetNextItem(&nIndex);
+            SetHeader(pvmHeader, L"Unlocked");
+        }
+
+        for (const auto* vmAchievement : vUnlockedCoreAchievements)
+        {
+            auto& pvmAchievement = GetNextItem(&nIndex);
+            SetAchievement(pvmAchievement, *vmAchievement);
+
+            const auto nPoints = vmAchievement->GetPoints();
+            nMaxPts += nPoints;
+
+            nUserPts += nPoints;
+            ++nUserCompleted;
+        }
+
+        nNumberOfAchievements += vUnlockedCoreAchievements.size();
+        nNumberOfCoreAchievements += vUnlockedCoreAchievements.size();
+    }
+
+    while (m_vItems.Count() > nIndex)
         m_vItems.RemoveAt(m_vItems.Count() - 1);
 
+    m_vItems.EndUpdate();
+
     // summary
     if (nNumberOfAchievements == 0)
         m_sSummary = L"No achievements present";
@@ -120,6 +350,23 @@ void OverlayAchievementsPageViewModel::Refresh()
         SetSummary(ra::StringPrintf(L"%s - %dh%02dm", m_sSummary, nPlayTimeMinutes / 60, nPlayTimeMinutes % 60));
         m_fElapsed = static_cast<double>(nPlayTimeSeconds.count() % 60);
     }
+
+    // ensure selected index is valid
+    auto nSelectedIndex = GetSelectedItemIndex();
+    const auto* vmItem = m_vItems.GetItemAt(nSelectedIndex);
+    while (vmItem && vmItem->IsHeader())
+        vmItem = m_vItems.GetItemAt(++nSelectedIndex);
+
+    if (!vmItem)
+    {
+        nSelectedIndex = 0;
+        vmItem = m_vItems.GetItemAt(nSelectedIndex);
+        while (vmItem && vmItem->IsHeader())
+            vmItem = m_vItems.GetItemAt(++nSelectedIndex);
+    }
+
+    if (nSelectedIndex < ra::to_signed(m_vItems.Count()))
+        SetSelectedItemIndex(nSelectedIndex);
 }
 
 bool OverlayAchievementsPageViewModel::Update(double fElapsed)
diff --git a/src/ui/viewmodels/OverlayAchievementsPageViewModel.hh b/src/ui/viewmodels/OverlayAchievementsPageViewModel.hh
index 7000f347..5b6a13b7 100644
--- a/src/ui/viewmodels/OverlayAchievementsPageViewModel.hh
+++ b/src/ui/viewmodels/OverlayAchievementsPageViewModel.hh
@@ -86,6 +86,8 @@ protected:
     std::map<ra::AchievementID, AchievementViewModel> m_vAchievementDetails;
 
 private:
+    ItemViewModel& GetNextItem(size_t* nIndex);
+
     void RenderDetail(ra::ui::drawing::ISurface& pSurface, int nX, int nY, _UNUSED int nWidth, int nHeight) const override;
 
     std::wstring m_sSummary;
diff --git a/src/ui/viewmodels/OverlayListPageViewModel.cpp b/src/ui/viewmodels/OverlayListPageViewModel.cpp
index 458fcf8a..c9207056 100644
--- a/src/ui/viewmodels/OverlayListPageViewModel.cpp
+++ b/src/ui/viewmodels/OverlayListPageViewModel.cpp
@@ -116,46 +116,56 @@ void OverlayListPageViewModel::RenderList(ra::ui::drawing::ISurface& pSurface, i
         if (!pItem)
             break;
 
-        auto nTextX = nX;
-        if (pItem->Image.Type() != ra::ui::ImageType::None)
+        if (pItem->IsHeader())
         {
-            pSurface.DrawImage(nX, nY, nItemSize, nItemSize, pItem->Image);
-            nTextX += nItemSize;
-        }
-
-        const ra::ui::Color nSubTextColor = pItem->IsDisabled() ? pTheme.ColorOverlayDisabledSubText() : pTheme.ColorOverlaySubText();
-        ra::ui::Color nTextColor = pTheme.ColorOverlayText();
+            const auto sHeader = ra::StringPrintf(L"----- %s -----", pItem->GetLabel());
+            const auto szHeader = pSurface.MeasureText(nFont, sHeader);
 
-        const bool bSelected = (nIndex == ra::to_signed(nSelectedIndex));
-        if (bSelected)
-        {
-            pSurface.FillRectangle(nTextX, nY, nWidth - nTextX, nItemSize, pTheme.ColorOverlaySelectionBackground());
-            nTextColor = pItem->IsDisabled() ?
-                pTheme.ColorOverlaySelectionDisabledText() : pTheme.ColorOverlaySelectionText();
+            pSurface.WriteText((nWidth - szHeader.Width) / 2, nY + nItemSize - szHeader.Height - 4, nFont, pTheme.ColorOverlayText(), sHeader);
         }
-        else if (pItem->IsDisabled())
+        else
         {
-            nTextColor = pTheme.ColorOverlayDisabledText();
-        }
+            auto nTextX = nX;
+            if (pItem->Image.Type() != ra::ui::ImageType::None)
+            {
+                pSurface.DrawImage(nX, nY, nItemSize, nItemSize, pItem->Image);
+                nTextX += nItemSize;
+            }
 
-        pSurface.WriteText(nTextX + 12, nY + 1, nFont, nTextColor, pItem->GetLabel());
-        pSurface.WriteText(nTextX + 12, nY + 1 + 26, nSubFont, nSubTextColor, pItem->GetDetail());
+            const ra::ui::Color nSubTextColor = pItem->IsDisabled() ? pTheme.ColorOverlayDisabledSubText() : pTheme.ColorOverlaySubText();
+            ra::ui::Color nTextColor = pTheme.ColorOverlayText();
 
-        const auto nTarget = pItem->GetProgressMaximum();
-        if (nTarget != 0)
-        {
-            const auto nProgressBarWidth = (nWidth - nTextX - 12) * 2 / 3;
-            const auto nValue = std::min(pItem->GetProgressValue(), nTarget);
-            const auto nProgressBarFillWidth = gsl::narrow_cast<int>(((static_cast<long>(nProgressBarWidth) - 2) * nValue) / nTarget);
-            const auto nProgressBarPercent = gsl::narrow_cast<int>(static_cast<long>(nValue) * 100 / nTarget);
-
-            pSurface.FillRectangle(nTextX + 12, nY + 1 + 26 + 25, nProgressBarWidth, 8, pTheme.ColorOverlayScrollBar());
-            pSurface.FillRectangle(nTextX + 12 + 2, nY + 1 + 26 + 25 + 2, nProgressBarFillWidth - 4, 8 - 4, pTheme.ColorOverlayScrollBarGripper());
-
-            const auto nProgressFont = pSurface.LoadFont(pTheme.FontOverlay(), 14, ra::ui::FontStyles::Normal);
-            const std::wstring sProgressPercent = ra::StringPrintf(L"%d%%", nProgressBarPercent);
-            const auto szProgressPercent = pSurface.MeasureText(nProgressFont, sProgressPercent);
-            pSurface.WriteText(nTextX + 12 + nProgressBarWidth + 6, nY + 1 + 26 + 25 + 4 - (szProgressPercent.Height / 2) - 1, nProgressFont, nSubTextColor, sProgressPercent);
+            const bool bSelected = (nIndex == ra::to_signed(nSelectedIndex));
+            if (bSelected)
+            {
+                pSurface.FillRectangle(nTextX, nY, nWidth - nTextX, nItemSize, pTheme.ColorOverlaySelectionBackground());
+                nTextColor = pItem->IsDisabled() ?
+                    pTheme.ColorOverlaySelectionDisabledText() : pTheme.ColorOverlaySelectionText();
+            }
+            else if (pItem->IsDisabled())
+            {
+                nTextColor = pTheme.ColorOverlayDisabledText();
+            }
+
+            pSurface.WriteText(nTextX + 12, nY + 1, nFont, nTextColor, pItem->GetLabel());
+            pSurface.WriteText(nTextX + 12, nY + 1 + 26, nSubFont, nSubTextColor, pItem->GetDetail());
+
+            const auto nTarget = pItem->GetProgressMaximum();
+            if (nTarget != 0)
+            {
+                const auto nProgressBarWidth = (nWidth - nTextX - 12) * 2 / 3;
+                const auto nValue = std::min(pItem->GetProgressValue(), nTarget);
+                const auto nProgressBarFillWidth = gsl::narrow_cast<int>(((static_cast<long>(nProgressBarWidth) - 2) * nValue) / nTarget);
+                const auto nProgressBarPercent = gsl::narrow_cast<int>(static_cast<long>(nValue) * 100 / nTarget);
+
+                pSurface.FillRectangle(nTextX + 12, nY + 1 + 26 + 25, nProgressBarWidth, 8, pTheme.ColorOverlayScrollBar());
+                pSurface.FillRectangle(nTextX + 12 + 2, nY + 1 + 26 + 25 + 2, nProgressBarFillWidth - 4, 8 - 4, pTheme.ColorOverlayScrollBarGripper());
+
+                const auto nProgressFont = pSurface.LoadFont(pTheme.FontOverlay(), 14, ra::ui::FontStyles::Normal);
+                const std::wstring sProgressPercent = ra::StringPrintf(L"%d%%", nProgressBarPercent);
+                const auto szProgressPercent = pSurface.MeasureText(nProgressFont, sProgressPercent);
+                pSurface.WriteText(nTextX + 12 + nProgressBarWidth + 6, nY + 1 + 26 + 25 + 4 - (szProgressPercent.Height / 2) - 1, nProgressFont, nSubTextColor, sProgressPercent);
+            }
         }
 
         nIndex++;
@@ -168,40 +178,51 @@ bool OverlayListPageViewModel::ProcessInput(const ControllerInput& pInput)
 {
     if (pInput.m_bDownPressed)
     {
-        const auto nSelectedItemIndex = GetSelectedItemIndex();
-        if (nSelectedItemIndex < ra::to_signed(m_vItems.Count()) - 1)
+        size_t nSelectedItemIndex = gsl::narrow_cast<size_t>(GetSelectedItemIndex());
+        if (nSelectedItemIndex < m_vItems.Count() - 1)
         {
-            SetSelectedItemIndex(nSelectedItemIndex + 1);
+            ++nSelectedItemIndex;
+            auto* vmItem = m_vItems.GetItemAt(nSelectedItemIndex);
+
+            // skip over header items
+            while (vmItem && vmItem->IsHeader())
+                vmItem = m_vItems.GetItemAt(++nSelectedItemIndex);
+
+            if (!vmItem)
+                return false;
+                
+            SetSelectedItemIndex(gsl::narrow_cast<int>(nSelectedItemIndex));
 
-            if (gsl::narrow_cast<size_t>(nSelectedItemIndex) + 1 - m_nScrollOffset == gsl::narrow_cast<size_t>(m_nVisibleItems))
-                ++m_nScrollOffset;
+            if (nSelectedItemIndex - m_nScrollOffset >= gsl::narrow_cast<size_t>(m_nVisibleItems))
+                m_nScrollOffset = nSelectedItemIndex - m_nVisibleItems + 1;
 
             if (m_bDetail)
-            {
-                auto* vmItem = m_vItems.GetItemAt(gsl::narrow_cast<size_t>(nSelectedItemIndex) + 1);
-                if (vmItem != nullptr)
-                    FetchItemDetail(*vmItem);
-            }
+                FetchItemDetail(*vmItem);
 
             return true;
         }
     }
     else if (pInput.m_bUpPressed)
     {
-        const auto nSelectedItemIndex = GetSelectedItemIndex();
+        auto nSelectedItemIndex = GetSelectedItemIndex();
         if (nSelectedItemIndex > 0)
         {
-            SetSelectedItemIndex(nSelectedItemIndex - 1);
+            auto* vmItem = m_vItems.GetItemAt(gsl::narrow_cast<size_t>(--nSelectedItemIndex));
 
-            if (nSelectedItemIndex == m_nScrollOffset)
-                --m_nScrollOffset;
+            // skip over header items
+            while (vmItem && vmItem->IsHeader())
+                vmItem = m_vItems.GetItemAt(gsl::narrow_cast<size_t>(--nSelectedItemIndex));
+
+            if (nSelectedItemIndex < m_nScrollOffset)
+                m_nScrollOffset = std::max(nSelectedItemIndex, 0);
+
+            if (!vmItem)
+                return false;
+
+            SetSelectedItemIndex(nSelectedItemIndex);
 
             if (m_bDetail)
-            {
-                auto* vmItem = m_vItems.GetItemAt(gsl::narrow_cast<size_t>(nSelectedItemIndex) - 1);
-                if (vmItem != nullptr)
-                    FetchItemDetail(*vmItem);
-            }
+                FetchItemDetail(*vmItem);
 
             return true;
         }
diff --git a/src/ui/viewmodels/OverlayListPageViewModel.hh b/src/ui/viewmodels/OverlayListPageViewModel.hh
index 18ec21b7..b226fc26 100644
--- a/src/ui/viewmodels/OverlayListPageViewModel.hh
+++ b/src/ui/viewmodels/OverlayListPageViewModel.hh
@@ -127,6 +127,11 @@ public:
         /// Sets the current progress bar value.
         /// </summary>
         void SetProgressValue(unsigned int nValue) { SetValue(ProgressValueProperty, ra::to_signed(nValue)); }
+
+        /// <summary>
+        /// Gets whether the current item is a header item.
+        /// </summary>
+        bool IsHeader() const { return GetId() == 0; }
     };
 
 protected:
diff --git a/src/ui/viewmodels/TriggerConditionViewModel.cpp b/src/ui/viewmodels/TriggerConditionViewModel.cpp
index 8e4b29a6..a504a059 100644
--- a/src/ui/viewmodels/TriggerConditionViewModel.cpp
+++ b/src/ui/viewmodels/TriggerConditionViewModel.cpp
@@ -18,6 +18,7 @@ const IntModelProperty TriggerConditionViewModel::TargetSizeProperty("TriggerCon
 const IntModelProperty TriggerConditionViewModel::TargetValueProperty("TriggerConditionViewModel", "TargetValue", 0);
 const IntModelProperty TriggerConditionViewModel::CurrentHitsProperty("TriggerConditionViewModel", "CurrentHits", 0);
 const IntModelProperty TriggerConditionViewModel::RequiredHitsProperty("TriggerConditionViewModel", "RequiredHits", 0);
+const BoolModelProperty TriggerConditionViewModel::IsSelectedProperty("TriggerConditionViewModel", "IsSelected", false);
 
 std::string TriggerConditionViewModel::Serialize() const
 {
diff --git a/src/ui/viewmodels/TriggerViewModel.cpp b/src/ui/viewmodels/TriggerViewModel.cpp
index 1bf1e43c..5241d742 100644
--- a/src/ui/viewmodels/TriggerViewModel.cpp
+++ b/src/ui/viewmodels/TriggerViewModel.cpp
@@ -3,6 +3,11 @@
 #include "rcheevos.h"
 #include "RA_StringUtils.h"
 
+#include "services\IClipboard.hh"
+#include "services\ServiceLocator.hh"
+
+#include "ui\viewmodels\MessageBoxViewModel.hh"
+
 namespace ra {
 namespace ui {
 namespace viewmodels {
@@ -11,6 +16,7 @@ const IntModelProperty TriggerViewModel::SelectedGroupIndexProperty("TriggerView
 const BoolModelProperty TriggerViewModel::PauseOnResetProperty("TriggerViewModel", "PauseOnReset", false);
 const BoolModelProperty TriggerViewModel::PauseOnTriggerProperty("TriggerViewModel", "PauseOnTrigger", false);
 const IntModelProperty TriggerViewModel::VersionProperty("TriggerViewModel", "Version", 0);
+const IntModelProperty TriggerViewModel::EnsureVisibleConditionIndexProperty("TriggerViewModel", "EnsureVisibleConditionIndex", -1);
 
 TriggerViewModel::TriggerViewModel() noexcept
     : m_pConditionsMonitor(*this)
@@ -132,6 +138,124 @@ void TriggerViewModel::SerializeAppend(std::string& sBuffer) const
     }
 }
 
+void TriggerViewModel::CopySelectedConditionsToClipboard()
+{
+    std::string sSerialized;
+
+    for (gsl::index nIndex = 0; nIndex < gsl::narrow_cast<gsl::index>(m_vConditions.Count()); ++nIndex)
+    {
+        const auto* vmCondition = m_vConditions.GetItemAt(nIndex);
+        if (vmCondition != nullptr && vmCondition->IsSelected())
+        {
+            vmCondition->SerializeAppend(sSerialized);
+            sSerialized.push_back('_');
+        }
+    }
+
+    if (sSerialized.empty())
+    {
+        // no selection, get the whole group
+        const auto* pGroup = m_vGroups.GetItemAt(GetSelectedGroupIndex());
+        if (pGroup != nullptr)
+            sSerialized = pGroup->GetSerialized();
+    }
+    else if (sSerialized.back() == '_')
+    {
+        // remove trailing joiner
+        sSerialized.pop_back();
+    }
+
+    ra::services::ServiceLocator::Get<ra::services::IClipboard>().SetText(ra::Widen(sSerialized));
+}
+
+void TriggerViewModel::PasteFromClipboard()
+{
+    const std::wstring sClipboardText = ra::services::ServiceLocator::Get<ra::services::IClipboard>().GetText();
+    if (sClipboardText.empty())
+    {
+        ra::ui::viewmodels::MessageBoxViewModel::ShowErrorMessage(L"Paste failed.", L"Clipboard did not contain any text.");
+        return;
+    }
+
+    std::string sTrigger = ra::Narrow(sClipboardText);
+    const auto nSize = rc_trigger_size(sTrigger.c_str());
+    if (nSize < 0)
+    {
+        ra::ui::viewmodels::MessageBoxViewModel::ShowErrorMessage(L"Paste failed.", L"Clipboard did not contain valid trigger conditions.");
+        return;
+    }
+
+    std::string sTriggerBuffer;
+    sTriggerBuffer.resize(nSize);
+    const rc_trigger_t* pTrigger = rc_parse_trigger(sTriggerBuffer.data(), sTrigger.c_str(), nullptr, 0);
+    if (pTrigger->alternative)
+    {
+        ra::ui::viewmodels::MessageBoxViewModel::ShowErrorMessage(L"Paste failed.", L"Clipboard contained multiple groups.");
+        return;
+    }
+
+    m_vConditions.BeginUpdate();
+
+    for (gsl::index nIndex = 0; nIndex < ra::to_signed(m_vConditions.Count()); ++nIndex)
+        m_vConditions.SetItemValue(nIndex, TriggerConditionViewModel::IsSelectedProperty, false);
+
+    for (const rc_condition_t* pCondition = pTrigger->requirement->conditions;
+        pCondition != nullptr; pCondition = pCondition->next)
+    {
+        auto& vmCondition = m_vConditions.Add();
+        vmCondition.InitializeFrom(*pCondition);
+        vmCondition.SetSelected(true);
+        vmCondition.SetIndex(gsl::narrow_cast<int>(m_vConditions.Count()));
+    }
+
+    m_vConditions.EndUpdate();
+
+    SetValue(EnsureVisibleConditionIndexProperty, gsl::narrow_cast<int>(m_vConditions.Count()) - 1);
+}
+
+void TriggerViewModel::RemoveSelectedConditions()
+{
+    int nSelected = 0;
+
+    for (gsl::index nIndex = 0; nIndex < gsl::narrow_cast<gsl::index>(m_vConditions.Count()); ++nIndex)
+    {
+        const auto* vmCondition = m_vConditions.GetItemAt(nIndex);
+        if (vmCondition != nullptr && vmCondition->IsSelected())
+            ++nSelected;
+    }
+
+    if (nSelected == 0)
+        return;
+
+    ra::ui::viewmodels::MessageBoxViewModel vmMessageBox;
+    vmMessageBox.SetMessage(ra::StringPrintf(L"Are you sure that you want to delete %d %s?",
+        nSelected, (nSelected == 1) ? "condition" : "conditions"));
+    vmMessageBox.SetButtons(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo);
+    vmMessageBox.SetIcon(ra::ui::viewmodels::MessageBoxViewModel::Icon::Warning);
+
+    if (vmMessageBox.ShowModal() != ra::ui::DialogResult::Yes)
+        return;
+
+    m_vConditions.BeginUpdate();
+
+    for (gsl::index nIndex = gsl::narrow_cast<gsl::index>(m_vConditions.Count()) - 1; nIndex >= 0; --nIndex)
+    {
+        const auto* vmCondition = m_vConditions.GetItemAt(nIndex);
+        if (vmCondition != nullptr && vmCondition->IsSelected())
+            m_vConditions.RemoveAt(nIndex);
+    }
+
+    m_vConditions.EndUpdate();
+
+    // update indices after EndUpdate to address a synchronization issue with the GridBinding
+    for (gsl::index nIndex = 0; nIndex < gsl::narrow_cast<gsl::index>(m_vConditions.Count()); ++nIndex)
+    {
+        auto* vmCondition = m_vConditions.GetItemAt(nIndex);
+        if (vmCondition != nullptr)
+            vmCondition->SetIndex(gsl::narrow_cast<int>(nIndex) + 1);
+    }
+}
+
 static void AddAltGroup(ViewModelCollection<TriggerViewModel::GroupViewModel>& vGroups,
     int nId, rc_condset_t* pConditionSet)
 {
diff --git a/src/ui/viewmodels/TriggerViewModel.hh b/src/ui/viewmodels/TriggerViewModel.hh
index e83a33e3..a434cbcb 100644
--- a/src/ui/viewmodels/TriggerViewModel.hh
+++ b/src/ui/viewmodels/TriggerViewModel.hh
@@ -59,6 +59,14 @@ public:
     ViewModelCollection<TriggerConditionViewModel>& Conditions() noexcept { return m_vConditions; }
     const ViewModelCollection<TriggerConditionViewModel>& Conditions() const noexcept { return m_vConditions; }
 
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for the index of the condition that should be made visible.
+    /// </summary>
+    static const IntModelProperty EnsureVisibleConditionIndexProperty;
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for the number of changes that have been made to the trigger.
+    /// </summary>
     static const IntModelProperty VersionProperty;
 
     std::string Serialize() const;
@@ -69,6 +77,10 @@ public:
     void UpdateFrom(const rc_trigger_t& pTrigger);
     void UpdateFrom(const std::string& sTrigger);
 
+    void RemoveSelectedConditions();
+    void CopySelectedConditionsToClipboard();
+    void PasteFromClipboard();
+
     /// <summary>
     /// Gets the list of condition types.
     /// </summary>
diff --git a/src/ui/win32/AssetEditorDialog.cpp b/src/ui/win32/AssetEditorDialog.cpp
index c3e1c58e..0a11718d 100644
--- a/src/ui/win32/AssetEditorDialog.cpp
+++ b/src/ui/win32/AssetEditorDialog.cpp
@@ -216,7 +216,7 @@ AssetEditorDialog::AssetEditorDialog(AssetEditorViewModel& vmAssetList)
       m_bindPauseOnReset(vmAssetList),
       m_bindPauseOnTrigger(vmAssetList),
       m_bindGroups(vmAssetList),
-      m_bindTrigger(vmAssetList)
+      m_bindConditions(vmAssetList.Trigger())
 {
     m_bindWindow.SetInitialPosition(RelativePosition::After, RelativePosition::After, "Achievement Editor");
     m_bindWindow.AddChildViewModel(vmAssetList.Trigger());
@@ -248,72 +248,76 @@ AssetEditorDialog::AssetEditorDialog(AssetEditorViewModel& vmAssetList)
         TriggerConditionViewModel::IndexProperty);
     pIdColumn->SetHeader(L"ID");
     pIdColumn->SetWidth(ra::ui::win32::bindings::GridColumnBinding::WidthType::Pixels, COLUMN_WIDTH_ID);
-    m_bindTrigger.BindColumn(0, std::move(pIdColumn));
+    m_bindConditions.BindColumn(0, std::move(pIdColumn));
 
     auto pFlagColumn = std::make_unique<ra::ui::win32::bindings::GridLookupColumnBinding>(
         TriggerConditionViewModel::TypeProperty, vmAssetList.Trigger().ConditionTypes());
     pFlagColumn->SetHeader(L"Flag");
     pFlagColumn->SetWidth(ra::ui::win32::bindings::GridColumnBinding::WidthType::Pixels, COLUMN_WIDTH_FLAG);
     pFlagColumn->SetReadOnly(false);
-    m_bindTrigger.BindColumn(1, std::move(pFlagColumn));
+    m_bindConditions.BindColumn(1, std::move(pFlagColumn));
 
     auto pSourceTypeColumn = std::make_unique<ra::ui::win32::bindings::GridLookupColumnBinding>(
         TriggerConditionViewModel::SourceTypeProperty, vmAssetList.Trigger().OperandTypes());
     pSourceTypeColumn->SetHeader(L"Type");
     pSourceTypeColumn->SetWidth(ra::ui::win32::bindings::GridColumnBinding::WidthType::Pixels, COLUMN_WIDTH_TYPE);
     pSourceTypeColumn->SetReadOnly(false);
-    m_bindTrigger.BindColumn(2, std::move(pSourceTypeColumn));
+    m_bindConditions.BindColumn(2, std::move(pSourceTypeColumn));
 
     auto pSourceSizeColumn = std::make_unique<SizeColumnBinding>(TriggerConditionViewModel::SourceSizeProperty,
         TriggerConditionViewModel::SourceTypeProperty, vmAssetList.Trigger().OperandSizes());
     pSourceSizeColumn->SetHeader(L"Size");
     pSourceSizeColumn->SetWidth(ra::ui::win32::bindings::GridColumnBinding::WidthType::Pixels, COLUMN_WIDTH_SIZE);
     pSourceSizeColumn->SetReadOnly(false);
-    m_bindTrigger.BindColumn(3, std::move(pSourceSizeColumn));
+    m_bindConditions.BindColumn(3, std::move(pSourceSizeColumn));
 
     auto pSourceValueColumn = std::make_unique<ValueColumnBinding>(
         TriggerConditionViewModel::SourceValueProperty, TriggerConditionViewModel::SourceTypeProperty);
     pSourceValueColumn->SetHeader(L"Memory");
     pSourceValueColumn->SetWidth(ra::ui::win32::bindings::GridColumnBinding::WidthType::Pixels, COLUMN_WIDTH_VALUE);
     pSourceValueColumn->SetReadOnly(false);
-    m_bindTrigger.BindColumn(4, std::move(pSourceValueColumn));
+    m_bindConditions.BindColumn(4, std::move(pSourceValueColumn));
 
     auto pOperatorColumn = std::make_unique<ra::ui::win32::bindings::GridLookupColumnBinding>(
         TriggerConditionViewModel::OperatorProperty, vmAssetList.Trigger().OperatorTypes());
     pOperatorColumn->SetHeader(L"Cmp");
     pOperatorColumn->SetWidth(ra::ui::win32::bindings::GridColumnBinding::WidthType::Pixels, COLUMN_WIDTH_OPERATOR);
     pOperatorColumn->SetReadOnly(false);
-    m_bindTrigger.BindColumn(5, std::move(pOperatorColumn));
+    m_bindConditions.BindColumn(5, std::move(pOperatorColumn));
 
     auto pTargetTypeColumn = std::make_unique<ra::ui::win32::bindings::GridLookupColumnBinding>(
         TriggerConditionViewModel::TargetTypeProperty, vmAssetList.Trigger().OperandTypes());
     pTargetTypeColumn->SetHeader(L"Type");
     pTargetTypeColumn->SetWidth(ra::ui::win32::bindings::GridColumnBinding::WidthType::Pixels, COLUMN_WIDTH_TYPE);
     pTargetTypeColumn->SetReadOnly(false);
-    m_bindTrigger.BindColumn(6, std::move(pTargetTypeColumn));
+    m_bindConditions.BindColumn(6, std::move(pTargetTypeColumn));
 
     auto pTargetSizeColumn = std::make_unique<SizeColumnBinding>(TriggerConditionViewModel::TargetSizeProperty,
         TriggerConditionViewModel::TargetTypeProperty, vmAssetList.Trigger().OperandSizes());
     pTargetSizeColumn->SetHeader(L"Size");
     pTargetSizeColumn->SetWidth(ra::ui::win32::bindings::GridColumnBinding::WidthType::Pixels, COLUMN_WIDTH_SIZE);
     pTargetSizeColumn->SetReadOnly(false);
-    m_bindTrigger.BindColumn(7, std::move(pTargetSizeColumn));
+    m_bindConditions.BindColumn(7, std::move(pTargetSizeColumn));
 
     auto pTargetValueColumn = std::make_unique<ValueColumnBinding>(
         TriggerConditionViewModel::TargetValueProperty, TriggerConditionViewModel::TargetTypeProperty);
     pTargetValueColumn->SetHeader(L"Mem/Val");
     pTargetValueColumn->SetWidth(ra::ui::win32::bindings::GridColumnBinding::WidthType::Pixels, COLUMN_WIDTH_VALUE);
     pTargetValueColumn->SetReadOnly(false);
-    m_bindTrigger.BindColumn(8, std::move(pTargetValueColumn));
+    m_bindConditions.BindColumn(8, std::move(pTargetValueColumn));
 
     auto pHitsColumn = std::make_unique<HitsColumnBinding>(TriggerConditionViewModel::RequiredHitsProperty);
     pHitsColumn->SetHeader(L"Hits");
     pHitsColumn->SetWidth(ra::ui::win32::bindings::GridColumnBinding::WidthType::Fill, COLUMN_WIDTH_HITS);
     pHitsColumn->SetReadOnly(false);
-    m_bindTrigger.BindColumn(9, std::move(pHitsColumn));
+    m_bindConditions.BindColumn(9, std::move(pHitsColumn));
 
-    m_bindTrigger.BindItems(vmAssetList.Trigger().Conditions());
+    m_bindConditions.BindIsSelected(TriggerConditionViewModel::IsSelectedProperty);
+    m_bindConditions.BindItems(vmAssetList.Trigger().Conditions());
 
+    m_bindConditions.SetCopyHandler([&vmAssetList]() { vmAssetList.Trigger().CopySelectedConditionsToClipboard(); });
+    m_bindConditions.SetPasteHandler([&vmAssetList]() { vmAssetList.Trigger().PasteFromClipboard(); });
+    m_bindConditions.BindEnsureVisible(TriggerViewModel::EnsureVisibleConditionIndexProperty);
 
     using namespace ra::bitwise_ops;
     SetAnchor(IDC_RA_LISTACHIEVEMENTS, Anchor::Top | Anchor::Left | Anchor::Bottom | Anchor::Right);
@@ -330,7 +334,7 @@ BOOL AssetEditorDialog::OnInitDialog()
     m_bindPoints.SetControl(*this, IDC_RA_ACH_POINTS);
 
     m_bindGroups.SetControl(*this, IDC_RA_ACH_GROUP);
-    m_bindTrigger.SetControl(*this, IDC_RA_LBX_CONDITIONS);
+    m_bindConditions.SetControl(*this, IDC_RA_LBX_CONDITIONS);
 
     m_bindPauseOnReset.SetControl(*this, IDC_RA_CHK_ACH_PAUSE_ON_RESET);
     m_bindPauseOnTrigger.SetControl(*this, IDC_RA_CHK_ACH_PAUSE_ON_TRIGGER);
@@ -340,6 +344,36 @@ BOOL AssetEditorDialog::OnInitDialog()
 
 BOOL AssetEditorDialog::OnCommand(WORD nCommand)
 {
+    switch (nCommand)
+    {
+        case IDC_RA_COPYCOND:
+        {
+            auto* vmAssetEditor = dynamic_cast<AssetEditorViewModel*>(&m_vmWindow);
+            if (vmAssetEditor)
+                vmAssetEditor->Trigger().CopySelectedConditionsToClipboard();
+
+            return TRUE;
+        }
+
+        case IDC_RA_PASTECOND:
+        {
+            auto* vmAssetEditor = dynamic_cast<AssetEditorViewModel*>(&m_vmWindow);
+            if (vmAssetEditor)
+                vmAssetEditor->Trigger().PasteFromClipboard();
+
+            return TRUE;
+        }
+
+        case IDC_RA_DELETECOND:
+        {
+            auto* vmAssetEditor = dynamic_cast<AssetEditorViewModel*>(&m_vmWindow);
+            if (vmAssetEditor)
+                vmAssetEditor->Trigger().RemoveSelectedConditions();
+
+            return TRUE;
+        }
+    }
+
     return DialogBase::OnCommand(nCommand);
 }
 
diff --git a/src/ui/win32/AssetEditorDialog.hh b/src/ui/win32/AssetEditorDialog.hh
index 86d5a177..c37adadc 100644
--- a/src/ui/win32/AssetEditorDialog.hh
+++ b/src/ui/win32/AssetEditorDialog.hh
@@ -53,7 +53,7 @@ private:
     ra::ui::win32::bindings::CheckBoxBinding m_bindPauseOnTrigger;
 
     ra::ui::win32::bindings::GridBinding m_bindGroups;
-    ra::ui::win32::bindings::GridBinding m_bindTrigger;
+    ra::ui::win32::bindings::GridBinding m_bindConditions;
 };
 
 } // namespace win32
diff --git a/src/ui/win32/bindings/GridBinding.cpp b/src/ui/win32/bindings/GridBinding.cpp
index a7e56b86..1a03b9df 100644
--- a/src/ui/win32/bindings/GridBinding.cpp
+++ b/src/ui/win32/bindings/GridBinding.cpp
@@ -574,6 +574,16 @@ void GridBinding::SetDoubleClickHandler(std::function<void(gsl::index)> pHandler
     m_pDoubleClickHandler = pHandler;
 }
 
+void GridBinding::SetCopyHandler(std::function<void()> pHandler)
+{
+    m_pCopyHandler = pHandler;
+}
+
+void GridBinding::SetPasteHandler(std::function<void()> pHandler)
+{
+    m_pPasteHandler = pHandler;
+}
+
 void GridBinding::Virtualize(const IntModelProperty& pScrollOffsetProperty, const IntModelProperty& pScrollMaximumProperty,
     std::function<void(gsl::index, gsl::index, bool)> pUpdateSelectedItems)
 {
@@ -1076,7 +1086,7 @@ void GridBinding::OnNmDblClick(const NMITEMACTIVATE* pnmItemActivate)
         m_pDoubleClickHandler(gsl::narrow_cast<gsl::index>(pnmItemActivate->iItem));
 }
 
-void GridBinding::OnLvnKeyDown(const LPNMLVKEYDOWN pnmKeyDown) noexcept
+void GridBinding::OnLvnKeyDown(const LPNMLVKEYDOWN pnmKeyDown)
 {
     // only interested in Ctrl key events
     const bool bControlHeld = (GetKeyState(VK_CONTROL) < 0);
@@ -1103,6 +1113,16 @@ void GridBinding::OnLvnKeyDown(const LPNMLVKEYDOWN pnmKeyDown) noexcept
         case 'D': // Ctrl+D = deselect all
             ListView_SetItemState(m_hWnd, -1, 0, LVIS_SELECTED);
             break;
+
+        case 'C': // Ctrl+C = copy
+            if (m_pCopyHandler)
+                m_pCopyHandler();
+            break;
+
+        case 'V': // Ctrl+V = paste
+            if (m_pPasteHandler)
+                m_pPasteHandler();
+            break;
     }
 
 }
diff --git a/src/ui/win32/bindings/GridBinding.hh b/src/ui/win32/bindings/GridBinding.hh
index 6718a535..f042dd98 100644
--- a/src/ui/win32/bindings/GridBinding.hh
+++ b/src/ui/win32/bindings/GridBinding.hh
@@ -38,12 +38,14 @@ public:
     void BindRowColor(const IntModelProperty& pRowColorProperty) noexcept;
 
     void SetDoubleClickHandler(std::function<void(gsl::index)> pHandler);
+    void SetCopyHandler(std::function<void()> pHandler);
+    void SetPasteHandler(std::function<void()> pHandler);
 
     GSL_SUPPRESS_CON3 virtual LRESULT OnLvnItemChanging(const LPNMLISTVIEW pnmListView);
     GSL_SUPPRESS_CON3 virtual void OnLvnItemChanged(const LPNMLISTVIEW pnmListView);
     GSL_SUPPRESS_CON3 void OnLvnOwnerDrawStateChanged(const LPNMLVODSTATECHANGE pnmStateChanged);
     GSL_SUPPRESS_CON3 void OnLvnColumnClick(const LPNMLISTVIEW pnmListView);
-    GSL_SUPPRESS_CON3 void OnLvnKeyDown(const LPNMLVKEYDOWN pnmKeyDown) noexcept;
+    GSL_SUPPRESS_CON3 void OnLvnKeyDown(const LPNMLVKEYDOWN pnmKeyDown);
     void OnLvnGetDispInfo(NMLVDISPINFO& pnmDispInfo);
     virtual void OnNmClick(const NMITEMACTIVATE* pnmItemActivate);
     virtual void OnNmDblClick(const NMITEMACTIVATE* pnmItemActivate);
@@ -116,6 +118,8 @@ private:
     HWND m_hInPlaceEditor = nullptr;
 
     std::function<void(gsl::index)> m_pDoubleClickHandler = nullptr;
+    std::function<void()> m_pCopyHandler = nullptr;
+    std::function<void()> m_pPasteHandler = nullptr;
 
     gsl::index m_nSortIndex = -1;
 


Test patch:
diff --git a/tests/Exports_Tests.cpp b/tests/Exports_Tests.cpp
index 31cee3fe..a881ad08 100644
--- a/tests/Exports_Tests.cpp
+++ b/tests/Exports_Tests.cpp
@@ -8,6 +8,7 @@
 #include "tests\mocks\MockConfiguration.hh"
 #include "tests\mocks\MockDesktop.hh"
 #include "tests\mocks\MockEmulatorContext.hh"
+#include "tests\mocks\MockFrameEventQueue.hh"
 #include "tests\mocks\MockGameContext.hh"
 #include "tests\mocks\MockOverlayManager.hh"
 #include "tests\mocks\MockOverlayTheme.hh"
@@ -34,6 +35,7 @@ using ra::data::mocks::MockSessionTracker;
 using ra::data::mocks::MockUserContext;
 using ra::services::mocks::MockAudioSystem;
 using ra::services::mocks::MockConfiguration;
+using ra::services::mocks::MockFrameEventQueue;
 using ra::services::mocks::MockThreadPool;
 using ra::ui::mocks::MockDesktop;
 using ra::ui::mocks::MockOverlayTheme;
@@ -384,6 +386,7 @@ private:
         MockEmulatorContext mockEmulatorContext;
         MockSurfaceFactory mockSurfaceFactory;
         MockOverlayTheme mockTheme;
+        MockFrameEventQueue mockFrameEventQueue;
         MockWindowManager mockWindowManager;
 
         DoAchievementsFrameHarness() noexcept
@@ -494,11 +497,12 @@ public:
         harness.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bMessageSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
         {
             bMessageSeen = true;
-            Assert::AreEqual(std::wstring(L"Pause on Trigger: AchievementTitle"), vmMessageBox.GetMessage());
+            Assert::AreEqual(std::wstring(L"The following triggers have triggered:\n* AchievementTitle"), vmMessageBox.GetMessage());
             return ra::ui::DialogResult::OK;
         });
 
         _RA_DoAchievementsFrame();
+        harness.mockFrameEventQueue.DoFrame(); // UpdateUIForFrameChange isn't called in the unit test build
 
         Assert::IsTrue(harness.WasUnlocked(1U));
         Assert::IsTrue(bMessageSeen);
@@ -519,11 +523,12 @@ public:
         harness.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bMessageSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
         {
             bMessageSeen = true;
-            Assert::AreEqual(std::wstring(L"Pause on Reset: AchievementTitle"), vmMessageBox.GetMessage());
+            Assert::AreEqual(std::wstring(L"The following triggers have been reset:\n* AchievementTitle"), vmMessageBox.GetMessage());
             return ra::ui::DialogResult::OK;
         });
 
         _RA_DoAchievementsFrame();
+        harness.mockFrameEventQueue.DoFrame(); // UpdateUIForFrameChange isn't called in the unit test build
 
         Assert::IsTrue(bMessageSeen);
         Assert::IsTrue(bWasPaused);
diff --git a/tests/RA_Integration.Tests.vcxproj b/tests/RA_Integration.Tests.vcxproj
index 888ab564..6dfa1b6a 100644
--- a/tests/RA_Integration.Tests.vcxproj
+++ b/tests/RA_Integration.Tests.vcxproj
@@ -303,6 +303,7 @@
     <ClCompile Include="..\src\RA_md5factory.cpp" />
     <ClCompile Include="..\src\RA_StringUtils.cpp" />
     <ClCompile Include="..\src\services\AchievementRuntime.cpp" />
+    <ClCompile Include="..\src\services\FrameEventQueue.cpp" />
     <ClCompile Include="..\src\services\GameIdentifier.cpp" />
     <ClCompile Include="..\src\services\Http.cpp" />
     <ClCompile Include="..\src\services\impl\FileLocalStorage.cpp" />
@@ -356,6 +357,7 @@
     <ClCompile Include="Exports_Tests.cpp" />
     <ClCompile Include="services\AchievementRuntime_Tests.cpp" />
     <ClCompile Include="services\FileLocalStorage_Tests.cpp" />
+    <ClCompile Include="services\FrameEventQueue_Tests.cpp" />
     <ClCompile Include="services\GameIdentifier_Tests.cpp" />
     <ClCompile Include="services\Http_Tests.cpp" />
     <ClCompile Include="ui\ModelProperty_Tests.cpp" />
@@ -407,6 +409,7 @@
     <ClInclude Include="mocks\MockDesktop.hh" />
     <ClInclude Include="mocks\MockEmulatorContext.hh" />
     <ClInclude Include="mocks\MockFileSystem.hh" />
+    <ClInclude Include="mocks\MockFrameEventQueue.hh" />
     <ClInclude Include="mocks\MockGameContext.hh" />
     <ClInclude Include="mocks\MockHttpRequester.hh" />
     <ClInclude Include="mocks\MockImageRepository.hh" />
diff --git a/tests/RA_Integration.Tests.vcxproj.filters b/tests/RA_Integration.Tests.vcxproj.filters
index d816612a..52dcf79d 100644
--- a/tests/RA_Integration.Tests.vcxproj.filters
+++ b/tests/RA_Integration.Tests.vcxproj.filters
@@ -375,6 +375,12 @@
     <ClCompile Include="ui\viewmodels\AchievementViewModel_Tests.cpp">
       <Filter>Tests\UI\ViewModels</Filter>
     </ClCompile>
+    <ClCompile Include="..\src\services\FrameEventQueue.cpp">
+      <Filter>Code</Filter>
+    </ClCompile>
+    <ClCompile Include="services\FrameEventQueue_Tests.cpp">
+      <Filter>Tests\Services</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <None Include="base.props" />
@@ -446,5 +452,8 @@
     <ClInclude Include="mocks\MockAchievementRuntime.hh">
       <Filter>Mocks</Filter>
     </ClInclude>
+    <ClInclude Include="mocks\MockFrameEventQueue.hh">
+      <Filter>Mocks</Filter>
+    </ClInclude>
   </ItemGroup>
 </Project>
\ No newline at end of file
diff --git a/tests/mocks/MockClipboard.hh b/tests/mocks/MockClipboard.hh
index fb3ad776..f1a700fb 100644
--- a/tests/mocks/MockClipboard.hh
+++ b/tests/mocks/MockClipboard.hh
@@ -18,7 +18,7 @@ public:
     }
 
     void SetText(const std::wstring& sValue) const override { m_sText = sValue; }
-    const std::wstring& GetText() const noexcept { return m_sText; }
+    std::wstring GetText() const override { return m_sText; }
 
 private:
     ra::services::ServiceLocator::ServiceOverride<ra::services::IClipboard> m_Override;
diff --git a/tests/mocks/MockDesktop.hh b/tests/mocks/MockDesktop.hh
index 56e0fd3b..ceba2371 100644
--- a/tests/mocks/MockDesktop.hh
+++ b/tests/mocks/MockDesktop.hh
@@ -84,7 +84,7 @@ public:
         m_vHandlers.emplace_back(pHandler);
     }
 
-    void ResetExpectedWindows() noexcept { m_vHandlers.clear(); }
+    void ResetExpectedWindows() noexcept { m_bDialogShown = false; m_vHandlers.clear(); }
 
     std::string GetRunningExecutable() const override { return m_sExecutable; }
     void SetRunningExecutable(const std::string& sExecutable) { m_sExecutable = sExecutable; }
diff --git a/tests/mocks/MockFrameEventQueue.hh b/tests/mocks/MockFrameEventQueue.hh
new file mode 100644
index 00000000..a4b12040
--- /dev/null
+++ b/tests/mocks/MockFrameEventQueue.hh
@@ -0,0 +1,38 @@
+#ifndef RA_SERVICES_MOCK_FRAME_EVENT_QUEUE_HH
+#define RA_SERVICES_MOCK_FRAME_EVENT_QUEUE_HH
+#pragma once
+
+#include "services\FrameEventQueue.hh"
+#include "services\ServiceLocator.hh"
+
+namespace ra {
+namespace services {
+namespace mocks {
+
+class MockFrameEventQueue : public FrameEventQueue
+{
+public:
+    MockFrameEventQueue() noexcept : m_Override(this)
+    {
+    }
+
+    size_t NumTriggeredTriggers() const noexcept { return m_vTriggeredTriggers.size(); }
+    size_t NumResetTriggers() const noexcept { return m_vResetTriggers.size(); }
+    size_t NumMemoryChanges() const noexcept { return m_vMemChanges.size(); }
+
+    void Reset() noexcept
+    {
+        m_vTriggeredTriggers.clear();
+        m_vResetTriggers.clear();
+        m_vMemChanges.clear();
+    }
+
+private:
+    ra::services::ServiceLocator::ServiceOverride<ra::services::FrameEventQueue> m_Override;
+};
+
+} // namespace mocks
+} // namespace services
+} // namespace ra
+
+#endif // !RA_SERVICES_MOCK_FRAME_EVENT_QUEUE_HH
diff --git a/tests/services/FrameEventQueue_Tests.cpp b/tests/services/FrameEventQueue_Tests.cpp
new file mode 100644
index 00000000..3340d198
--- /dev/null
+++ b/tests/services/FrameEventQueue_Tests.cpp
@@ -0,0 +1,278 @@
+#include "services\FrameEventQueue.hh"
+
+#include "tests\RA_UnitTestHelpers.h"
+#include "tests\mocks\MockDesktop.hh"
+#include "tests\mocks\MockEmulatorContext.hh"
+#include "tests\mocks\MockWindowManager.hh"
+
+using namespace Microsoft::VisualStudio::CppUnitTestFramework;
+
+namespace ra {
+namespace services {
+namespace tests {
+
+TEST_CLASS(FrameEventQueue_Tests)
+{
+private:
+    class FrameEventQueueHarness : public FrameEventQueue
+    {
+    public:
+        ra::data::mocks::MockEmulatorContext mockEmulatorContext;
+        ra::ui::mocks::MockDesktop mockDesktop;
+
+        size_t NumTriggeredTriggers() const noexcept { return m_vTriggeredTriggers.size(); }
+        size_t NumResetTriggers() const noexcept { return m_vResetTriggers.size(); }
+        size_t NumMemoryChanges() const noexcept { return m_vMemChanges.size(); }
+    };
+
+public:
+    TEST_METHOD(TestInitialization)
+    {
+        FrameEventQueueHarness eventQueue;
+        Assert::AreEqual({ 0U }, eventQueue.NumMemoryChanges());
+        Assert::AreEqual({ 0U }, eventQueue.NumResetTriggers());
+        Assert::AreEqual({ 0U }, eventQueue.NumMemoryChanges());
+
+        eventQueue.DoFrame();
+
+        Assert::IsFalse(eventQueue.mockDesktop.WasDialogShown());
+        Assert::AreEqual({ 0U }, eventQueue.NumMemoryChanges());
+        Assert::AreEqual({ 0U }, eventQueue.NumResetTriggers());
+        Assert::AreEqual({ 0U }, eventQueue.NumMemoryChanges());
+    }
+
+    TEST_METHOD(TestPauseOnReset)
+    {
+        FrameEventQueueHarness eventQueue;
+        bool bPaused = false;
+        eventQueue.mockEmulatorContext.SetPauseFunction([&bPaused]() { bPaused = true; });
+
+        bool bSawDialog = false;
+        eventQueue.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bSawDialog](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bSawDialog = true;
+
+            Assert::AreEqual(std::wstring(L"The emulator has been paused."), vmMessageBox.GetHeader());
+            Assert::AreEqual(std::wstring(L"The following triggers have been reset:\n* You did it!"), vmMessageBox.GetMessage());
+
+            return ra::ui::DialogResult::OK;
+        });
+
+        eventQueue.QueuePauseOnReset(L"You did it!");
+        Assert::AreEqual({ 0U }, eventQueue.NumMemoryChanges());
+        Assert::AreEqual({ 1U }, eventQueue.NumResetTriggers());
+        Assert::AreEqual({ 0U }, eventQueue.NumTriggeredTriggers());
+        Assert::IsFalse(bSawDialog);
+        Assert::IsFalse(bPaused);
+
+        eventQueue.DoFrame();
+        Assert::IsTrue(bSawDialog);
+        Assert::IsTrue(bPaused);
+        Assert::AreEqual({ 0U }, eventQueue.NumMemoryChanges());
+        Assert::AreEqual({ 0U }, eventQueue.NumResetTriggers());
+        Assert::AreEqual({ 0U }, eventQueue.NumTriggeredTriggers());
+
+        bSawDialog = false;
+        bPaused = false;
+        eventQueue.DoFrame();
+        Assert::IsFalse(bSawDialog);
+        Assert::IsFalse(bPaused);
+
+        eventQueue.mockDesktop.ResetExpectedWindows();
+        eventQueue.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bSawDialog](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bSawDialog = true;
+
+            Assert::AreEqual(std::wstring(L"The emulator has been paused."), vmMessageBox.GetHeader());
+            Assert::AreEqual(std::wstring(L"The following triggers have been reset:\n* You did it again!\n* And again!\n* And again!"), vmMessageBox.GetMessage());
+
+            return ra::ui::DialogResult::OK;
+        });
+
+        eventQueue.QueuePauseOnReset(L"You did it again!");
+        eventQueue.QueuePauseOnReset(L"And again!");
+        eventQueue.QueuePauseOnReset(L"And again!"); // duplicate titles allowed
+        Assert::AreEqual({ 0U }, eventQueue.NumMemoryChanges());
+        Assert::AreEqual({ 3U }, eventQueue.NumResetTriggers());
+        Assert::AreEqual({ 0U }, eventQueue.NumTriggeredTriggers());
+
+        eventQueue.DoFrame();
+        Assert::IsTrue(bSawDialog);
+        Assert::IsTrue(bPaused);
+        Assert::AreEqual({ 0U }, eventQueue.NumMemoryChanges());
+        Assert::AreEqual({ 0U }, eventQueue.NumResetTriggers());
+        Assert::AreEqual({ 0U }, eventQueue.NumTriggeredTriggers());
+    }
+
+    TEST_METHOD(TestPauseOnTrigger)
+    {
+        FrameEventQueueHarness eventQueue;
+        bool bPaused = false;
+        eventQueue.mockEmulatorContext.SetPauseFunction([&bPaused]() { bPaused = true; });
+
+        bool bSawDialog = false;
+        eventQueue.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bSawDialog](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bSawDialog = true;
+
+            Assert::AreEqual(std::wstring(L"The emulator has been paused."), vmMessageBox.GetHeader());
+            Assert::AreEqual(std::wstring(L"The following triggers have triggered:\n* You did it!"), vmMessageBox.GetMessage());
+
+            return ra::ui::DialogResult::OK;
+        });
+
+        eventQueue.QueuePauseOnTrigger(L"You did it!");
+        Assert::AreEqual({ 0U }, eventQueue.NumMemoryChanges());
+        Assert::AreEqual({ 0U }, eventQueue.NumResetTriggers());
+        Assert::AreEqual({ 1U }, eventQueue.NumTriggeredTriggers());
+        Assert::IsFalse(bSawDialog);
+        Assert::IsFalse(bPaused);
+
+        eventQueue.DoFrame();
+        Assert::IsTrue(bSawDialog);
+        Assert::IsTrue(bPaused);
+        Assert::AreEqual({ 0U }, eventQueue.NumMemoryChanges());
+        Assert::AreEqual({ 0U }, eventQueue.NumResetTriggers());
+        Assert::AreEqual({ 0U }, eventQueue.NumTriggeredTriggers());
+
+        bSawDialog = false;
+        bPaused = false;
+        eventQueue.DoFrame();
+        Assert::IsFalse(bSawDialog);
+        Assert::IsFalse(bPaused);
+
+        eventQueue.mockDesktop.ResetExpectedWindows();
+        eventQueue.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bSawDialog](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bSawDialog = true;
+
+            Assert::AreEqual(std::wstring(L"The emulator has been paused."), vmMessageBox.GetHeader());
+            Assert::AreEqual(std::wstring(L"The following triggers have triggered:\n* You did it again!\n* And again!\n* And again!"), vmMessageBox.GetMessage());
+
+            return ra::ui::DialogResult::OK;
+        });
+
+        eventQueue.QueuePauseOnTrigger(L"You did it again!");
+        eventQueue.QueuePauseOnTrigger(L"And again!");
+        eventQueue.QueuePauseOnTrigger(L"And again!"); // duplicate titles allowed
+        Assert::AreEqual({ 0U }, eventQueue.NumMemoryChanges());
+        Assert::AreEqual({ 0U }, eventQueue.NumResetTriggers());
+        Assert::AreEqual({ 3U }, eventQueue.NumTriggeredTriggers());
+
+        eventQueue.DoFrame();
+        Assert::IsTrue(bSawDialog);
+        Assert::IsTrue(bPaused);
+        Assert::AreEqual({ 0U }, eventQueue.NumMemoryChanges());
+        Assert::AreEqual({ 0U }, eventQueue.NumResetTriggers());
+        Assert::AreEqual({ 0U }, eventQueue.NumTriggeredTriggers());
+    }
+
+    TEST_METHOD(TestPauseOnChange)
+    {
+        FrameEventQueueHarness eventQueue;
+        ra::ui::viewmodels::mocks::MockWindowManager mockWindowManager; // for MemSize->string mapping
+        bool bPaused = false;
+        eventQueue.mockEmulatorContext.SetPauseFunction([&bPaused]() { bPaused = true; });
+
+        bool bSawDialog = false;
+        eventQueue.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bSawDialog](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bSawDialog = true;
+
+            Assert::AreEqual(std::wstring(L"The emulator has been paused."), vmMessageBox.GetHeader());
+            Assert::AreEqual(std::wstring(L"The following bookmarks have changed:\n* 8-bit 0x1234"), vmMessageBox.GetMessage());
+
+            return ra::ui::DialogResult::OK;
+        });
+
+        eventQueue.QueuePauseOnChange(MemSize::EightBit, { 0x1234U });
+        Assert::AreEqual({ 1U }, eventQueue.NumMemoryChanges());
+        Assert::AreEqual({ 0U }, eventQueue.NumResetTriggers());
+        Assert::AreEqual({ 0U }, eventQueue.NumTriggeredTriggers());
+        Assert::IsFalse(bSawDialog);
+        Assert::IsFalse(bPaused);
+
+        eventQueue.DoFrame();
+        Assert::IsTrue(bSawDialog);
+        Assert::IsTrue(bPaused);
+        Assert::AreEqual({ 0U }, eventQueue.NumMemoryChanges());
+        Assert::AreEqual({ 0U }, eventQueue.NumResetTriggers());
+        Assert::AreEqual({ 0U }, eventQueue.NumTriggeredTriggers());
+
+        bSawDialog = false;
+        bPaused = false;
+        eventQueue.DoFrame();
+        Assert::IsFalse(bSawDialog);
+        Assert::IsFalse(bPaused);
+
+        eventQueue.mockDesktop.ResetExpectedWindows();
+        eventQueue.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bSawDialog](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bSawDialog = true;
+
+            Assert::AreEqual(std::wstring(L"The emulator has been paused."), vmMessageBox.GetHeader());
+            Assert::AreEqual(std::wstring(L"The following bookmarks have changed:\n* 16-bit 0x2345\n* 32-bit 0x3456\n* 32-bit 0x2345"), vmMessageBox.GetMessage());
+
+            return ra::ui::DialogResult::OK;
+        });
+
+        eventQueue.QueuePauseOnChange(MemSize::SixteenBit, { 0x2345U });
+        eventQueue.QueuePauseOnChange(MemSize::ThirtyTwoBit, { 0x3456U });
+        eventQueue.QueuePauseOnChange(MemSize::SixteenBit, { 0x2345U }); // duplicate ignored
+        eventQueue.QueuePauseOnChange(MemSize::ThirtyTwoBit, { 0x2345U }); // same address, different size allowed
+        eventQueue.QueuePauseOnChange(MemSize::ThirtyTwoBit, { 0x2345U }); // duplicate ignored
+
+        Assert::AreEqual({ 3U }, eventQueue.NumMemoryChanges());
+        Assert::AreEqual({ 0U }, eventQueue.NumResetTriggers());
+        Assert::AreEqual({ 0U }, eventQueue.NumTriggeredTriggers());
+
+        eventQueue.DoFrame();
+        Assert::IsTrue(bSawDialog);
+        Assert::IsTrue(bPaused);
+        Assert::AreEqual({ 0U }, eventQueue.NumMemoryChanges());
+        Assert::AreEqual({ 0U }, eventQueue.NumResetTriggers());
+        Assert::AreEqual({ 0U }, eventQueue.NumTriggeredTriggers());
+    }
+
+    TEST_METHOD(TestComplexDialog)
+    {
+        FrameEventQueueHarness eventQueue;
+        ra::ui::viewmodels::mocks::MockWindowManager mockWindowManager; // for MemSize->string mapping
+        int nPaused = 0;
+        eventQueue.mockEmulatorContext.SetPauseFunction([&nPaused]() { ++nPaused; });
+
+        bool bSawDialog = false;
+        eventQueue.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bSawDialog](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bSawDialog = true;
+
+            Assert::AreEqual(std::wstring(L"The emulator has been paused."), vmMessageBox.GetHeader());
+            Assert::AreEqual(std::wstring(
+                L"The following triggers have triggered:\n* You did it!"
+                L"\nThe following triggers have been reset:\n* You did it again!"
+                L"\nThe following bookmarks have changed:\n* 8-bit 0x1234"), vmMessageBox.GetMessage());
+
+            return ra::ui::DialogResult::OK;
+        });
+
+        eventQueue.QueuePauseOnChange(MemSize::EightBit, { 0x1234U });
+        eventQueue.QueuePauseOnTrigger(L"You did it!");
+        eventQueue.QueuePauseOnReset(L"You did it again!");
+        Assert::AreEqual({ 1U }, eventQueue.NumMemoryChanges());
+        Assert::AreEqual({ 1U }, eventQueue.NumResetTriggers());
+        Assert::AreEqual({ 1U }, eventQueue.NumTriggeredTriggers());
+        Assert::IsFalse(bSawDialog);
+        Assert::AreEqual(0, nPaused);
+
+        eventQueue.DoFrame();
+        Assert::IsTrue(bSawDialog);
+        Assert::AreEqual(1, nPaused); // should only pause once
+        Assert::AreEqual({ 0U }, eventQueue.NumMemoryChanges());
+        Assert::AreEqual({ 0U }, eventQueue.NumResetTriggers());
+        Assert::AreEqual({ 0U }, eventQueue.NumTriggeredTriggers());
+    }
+};
+
+} // namespace tests
+} // namespace services
+} // namespace ra
diff --git a/tests/ui/viewmodels/MemoryBookmarksViewModel_Tests.cpp b/tests/ui/viewmodels/MemoryBookmarksViewModel_Tests.cpp
index fc21f78f..baef4f77 100644
--- a/tests/ui/viewmodels/MemoryBookmarksViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/MemoryBookmarksViewModel_Tests.cpp
@@ -8,6 +8,7 @@
 #include "tests\mocks\MockDesktop.hh"
 #include "tests\mocks\MockEmulatorContext.hh"
 #include "tests\mocks\MockFileSystem.hh"
+#include "tests\mocks\MockFrameEventQueue.hh"
 #include "tests\mocks\MockGameContext.hh"
 #include "tests\mocks\MockLocalStorage.hh"
 #include "tests\mocks\MockOverlayManager.hh"
@@ -54,6 +55,7 @@ private:
         ra::data::mocks::MockGameContext mockGameContext;
         ra::services::mocks::MockConfiguration mockConfiguration;
         ra::services::mocks::MockFileSystem mockFileSystem;
+        ra::services::mocks::MockFrameEventQueue mockFrameEventQueue;
         ra::services::mocks::MockLocalStorage mockLocalStorage;
         ra::ui::mocks::MockDesktop mockDesktop;
         ra::ui::viewmodels::mocks::MockOverlayManager mockOverlayManager;
@@ -692,73 +694,42 @@ public:
         bookmarks.AddBookmark(5U, MemSize::EightBit);
         auto& bookmark2 = *bookmarks.Bookmarks().GetItemAt(1);
 
-        bool bPaused = false;
-        bookmarks.mockEmulatorContext.SetPauseFunction([&bPaused]() { bPaused = true; });
-
         std::array<uint8_t, 64> memory = {};
         for (uint8_t i = 0; i < memory.size(); ++i)
             memory.at(i) = i;
         bookmarks.mockEmulatorContext.MockMemory(memory);
 
         bookmarks.DoFrame(); // initialize current and previous values
-        Assert::IsFalse(bPaused);
+        Assert::AreEqual({ 0U }, bookmarks.mockFrameEventQueue.NumMemoryChanges());
 
         bookmark1.SetBehavior(MemoryBookmarksViewModel::BookmarkBehavior::PauseOnChange);
         bookmark2.SetBehavior(MemoryBookmarksViewModel::BookmarkBehavior::PauseOnChange);
 
-        bool bSawDialog = false;
-        bookmarks.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bSawDialog](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
-        {
-            bSawDialog = true;
-
-            Assert::AreEqual(std::wstring(L"The emulator has been paused."), vmMessageBox.GetHeader());
-            Assert::AreEqual(std::wstring(L"The following bookmarks have changed:\n*  8-bit 0x0004"), vmMessageBox.GetMessage());
-
-            return ra::ui::DialogResult::OK;
-        });
-
         // no change, dialog should not be shown
         bookmarks.DoFrame();
-        Assert::IsFalse(bSawDialog);
-        Assert::IsFalse(bPaused);
+        Assert::AreEqual({ 0U }, bookmarks.mockFrameEventQueue.NumMemoryChanges());
 
         // change, dialog should be shown and emulator should be paused
         memory.at(4) = 9;
         bookmarks.DoFrame();
-        Assert::IsTrue(bSawDialog);
-        Assert::IsTrue(bPaused);
+        Assert::AreEqual({ 1U }, bookmarks.mockFrameEventQueue.NumMemoryChanges());
+        bookmarks.mockFrameEventQueue.Reset();
 
         // only row that caused the pause should be highlighted
         Assert::AreEqual(0xFFFFC0C0U, bookmark1.GetRowColor().ARGB);
         Assert::AreEqual(0U, bookmark2.GetRowColor().ARGB);
 
         // unpause, dialog should not show, emulator should not pause, row should unhighlight
-        bSawDialog = false;
-        bPaused = false;
         bookmarks.DoFrame();
-        Assert::IsFalse(bSawDialog);
-        Assert::IsFalse(bPaused);
+        Assert::AreEqual({ 0U }, bookmarks.mockFrameEventQueue.NumMemoryChanges());
         Assert::AreEqual(0U, bookmark1.GetRowColor().ARGB);
         Assert::AreEqual(0U, bookmark2.GetRowColor().ARGB);
 
         // both change in one frame, expect a single dialog
-        bool bSawDialog2 = false;
-        bookmarks.mockDesktop.ResetExpectedWindows();
-        bookmarks.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bSawDialog2](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
-        {
-            bSawDialog2 = true;
-
-            Assert::AreEqual(std::wstring(L"The emulator has been paused."), vmMessageBox.GetHeader());
-            Assert::AreEqual(std::wstring(L"The following bookmarks have changed:\n*  8-bit 0x0004\n*  8-bit 0x0005"), vmMessageBox.GetMessage());
-
-            return ra::ui::DialogResult::OK;
-        });
-
         memory.at(4) = 77;
         memory.at(5) = 77;
         bookmarks.DoFrame();
-        Assert::IsTrue(bSawDialog2);
-        Assert::IsTrue(bPaused);
+        Assert::AreEqual({ 2U }, bookmarks.mockFrameEventQueue.NumMemoryChanges());
 
         // both rows that caused the pause should be highlighted
         Assert::AreEqual(0xFFFFC0C0U, bookmark1.GetRowColor().ARGB);
diff --git a/tests/ui/viewmodels/OverlayAchievementsPageViewModel_Tests.cpp b/tests/ui/viewmodels/OverlayAchievementsPageViewModel_Tests.cpp
index 305e5513..c48cd4e5 100644
--- a/tests/ui/viewmodels/OverlayAchievementsPageViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/OverlayAchievementsPageViewModel_Tests.cpp
@@ -3,6 +3,7 @@
 #include "ui\viewmodels\OverlayAchievementsPageViewModel.hh"
 
 #include "tests\mocks\MockAchievementRuntime.hh"
+#include "tests\mocks\MockClock.hh"
 #include "tests\mocks\MockGameContext.hh"
 #include "tests\mocks\MockImageRepository.hh"
 #include "tests\mocks\MockOverlayManager.hh"
@@ -31,6 +32,7 @@ private:
         ra::data::mocks::MockSessionTracker mockSessionTracker;
         ra::data::mocks::MockUserContext mockUserContext;
         ra::services::mocks::MockAchievementRuntime mockAchievementRuntime;
+        ra::services::mocks::MockClock mockClock;
         ra::services::mocks::MockThreadPool mockThreadPool;
         ra::ui::mocks::MockImageRepository mockImageRepository;
         ra::ui::viewmodels::mocks::MockOverlayManager mockOverlayManager;
@@ -54,6 +56,15 @@ private:
             return nullptr;
         }
 
+        ra::ui::viewmodels::AchievementViewModel& NewAchievement(AssetCategory nCategory)
+        {
+            mockGameContext.NewAchievement(ra::itoe<Achievement::Category>(ra::etoi(nCategory)));
+            auto& pAssets = mockWindowManager.AssetList.Assets();
+            auto* pAsset = pAssets.GetItemAt(pAssets.Count() - 1);
+            auto* pAchievement = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(pAsset);
+            return *pAchievement;
+        }
+
         void SetProgress(ra::AchievementID nId, int nValue, int nMax)
         {
             auto* pTrigger = mockAchievementRuntime.GetAchievementTrigger(nId);
@@ -81,12 +92,12 @@ public:
     TEST_METHOD(TestRefreshInactiveAchievement)
     {
         OverlayAchievementsPageViewModelHarness achievementsPage;
-        auto& pAch1 = achievementsPage.mockGameContext.NewAchievement(Achievement::Category::Core);
+        auto& pAch1 = achievementsPage.NewAchievement(AssetCategory::Core);
         pAch1.SetID(1);
-        pAch1.SetTitle("AchievementTitle");
-        pAch1.SetDescription("Trigger this");
+        pAch1.SetName(L"AchievementTitle");
+        pAch1.SetDescription(L"Trigger this");
         pAch1.SetPoints(5U);
-        pAch1.SetBadgeImage("BADGE_URI");
+        pAch1.SetBadge(L"BADGE_URI");
         achievementsPage.Refresh();
 
         Assert::AreEqual(std::wstring(L"Achievements"), achievementsPage.GetTitle());
@@ -104,13 +115,13 @@ public:
     TEST_METHOD(TestRefreshActiveAchievement)
     {
         OverlayAchievementsPageViewModelHarness achievementsPage;
-        auto& pAch1 = achievementsPage.mockGameContext.NewAchievement(Achievement::Category::Core);
+        auto& pAch1 = achievementsPage.NewAchievement(AssetCategory::Core);
         pAch1.SetID(1);
-        pAch1.SetTitle("AchievementTitle");
-        pAch1.SetDescription("Trigger this");
+        pAch1.SetName(L"AchievementTitle");
+        pAch1.SetDescription(L"Trigger this");
         pAch1.SetPoints(5U);
-        pAch1.SetBadgeImage("BADGE_URI");
-        pAch1.SetActive(true);
+        pAch1.SetBadge(L"BADGE_URI");
+        pAch1.SetState(AssetState::Active);
         achievementsPage.Refresh();
 
         Assert::AreEqual(std::wstring(L"Achievements"), achievementsPage.GetTitle());
@@ -125,25 +136,50 @@ public:
         Assert::AreEqual(std::string("BADGE_URI_lock"), pItem->Image.Name());
     }
 
+    TEST_METHOD(TestRefreshOnePointAchievement)
+    {
+        OverlayAchievementsPageViewModelHarness achievementsPage;
+        auto& pAch1 = achievementsPage.NewAchievement(AssetCategory::Core);
+        pAch1.SetID(1);
+        pAch1.SetName(L"AchievementTitle");
+        pAch1.SetDescription(L"Trigger this");
+        pAch1.SetPoints(1U); // should say "1 point" instead of "1 points"
+        pAch1.SetBadge(L"BADGE_URI");
+        pAch1.SetState(AssetState::Active);
+        achievementsPage.Refresh();
+
+        Assert::AreEqual(std::wstring(L"Achievements"), achievementsPage.GetTitle());
+        Assert::AreEqual(std::wstring(L"0 of 1 won (0/1)"), achievementsPage.GetSummary());
+
+        auto const* pItem = achievementsPage.GetItem(0);
+        Expects(pItem != nullptr);
+        Assert::AreEqual(1, pItem->GetId());
+        Assert::AreEqual(std::wstring(L"AchievementTitle (1 point)"), pItem->GetLabel());
+        Assert::AreEqual(std::wstring(L"Trigger this"), pItem->GetDetail());
+        Assert::IsTrue(pItem->IsDisabled());
+        Assert::AreEqual(std::string("BADGE_URI_lock"), pItem->Image.Name());
+    }
+
     TEST_METHOD(TestRefreshActiveAndInactiveAchievements)
     {
         OverlayAchievementsPageViewModelHarness achievementsPage;
-        auto& pAch1 = achievementsPage.mockGameContext.NewAchievement(Achievement::Category::Core);
+        auto& pAch1 = achievementsPage.NewAchievement(AssetCategory::Core);
         pAch1.SetID(1);
         pAch1.SetPoints(1U);
-        pAch1.SetActive(true);
-        auto& pAch2 = achievementsPage.mockGameContext.NewAchievement(Achievement::Category::Core);
+        pAch1.SetState(AssetState::Waiting);
+        auto& pAch2 = achievementsPage.NewAchievement(AssetCategory::Core);
         pAch2.SetID(2);
         pAch2.SetPoints(2U);
-        pAch2.SetActive(false);
-        auto& pAch3 = achievementsPage.mockGameContext.NewAchievement(Achievement::Category::Core);
+        pAch2.SetState(AssetState::Inactive);
+        auto& pAch3 = achievementsPage.NewAchievement(AssetCategory::Core);
         pAch3.SetID(3);
         pAch3.SetPoints(3U);
-        pAch3.SetActive(true);
-        auto& pAch4 = achievementsPage.mockGameContext.NewAchievement(Achievement::Category::Core);
+        pAch3.SetState(AssetState::Active);
+        auto& pAch4 = achievementsPage.NewAchievement(AssetCategory::Core);
         pAch4.SetID(4);
         pAch4.SetPoints(4U);
-        pAch4.SetActive(false);
+        pAch4.SetState(AssetState::Triggered);
+        achievementsPage.mockClock.AdvanceTime(std::chrono::hours(1));
         achievementsPage.Refresh();
 
         Assert::AreEqual(std::wstring(L"Achievements"), achievementsPage.GetTitle());
@@ -156,44 +192,53 @@ public:
 
         pItem = achievementsPage.GetItem(1);
         Expects(pItem != nullptr);
-        Assert::AreEqual(2, pItem->GetId());
-        Assert::IsFalse(pItem->IsDisabled());
+        Assert::AreEqual(3, pItem->GetId());
+        Assert::IsTrue(pItem->IsDisabled());
 
         pItem = achievementsPage.GetItem(2);
         Expects(pItem != nullptr);
-        Assert::AreEqual(3, pItem->GetId());
-        Assert::IsTrue(pItem->IsDisabled());
+        Assert::IsTrue(pItem->IsHeader());
+        Assert::AreEqual(std::wstring(L"Unlocked"), pItem->GetLabel());
+        Assert::IsFalse(pItem->IsDisabled());
 
         pItem = achievementsPage.GetItem(3);
         Expects(pItem != nullptr);
+        Assert::AreEqual(2, pItem->GetId());
+        Assert::IsFalse(pItem->IsDisabled());
+
+        pItem = achievementsPage.GetItem(4);
+        Expects(pItem != nullptr);
         Assert::AreEqual(4, pItem->GetId());
         Assert::IsFalse(pItem->IsDisabled());
+
+        Assert::IsNull(achievementsPage.GetItem(5));
     }
 
     TEST_METHOD(TestRefreshCategoryFilter)
     {
         OverlayAchievementsPageViewModelHarness achievementsPage;
-        auto& pAch1 = achievementsPage.mockGameContext.NewAchievement(Achievement::Category::Core);
+        auto& pAch1 = achievementsPage.NewAchievement(AssetCategory::Core);
         pAch1.SetID(1);
         pAch1.SetPoints(1U);
-        pAch1.SetActive(true);
-        auto& pAch2 = achievementsPage.mockGameContext.NewAchievement(Achievement::Category::Unofficial);
+        pAch1.SetState(AssetState::Active);
+        auto& pAch2 = achievementsPage.NewAchievement(AssetCategory::Unofficial);
         pAch2.SetID(2);
         pAch2.SetPoints(2U);
-        pAch2.SetActive(false);
-        auto& pAch3 = achievementsPage.mockGameContext.NewAchievement(Achievement::Category::Local);
+        pAch2.SetState(AssetState::Inactive);
+        auto& pAch3 = achievementsPage.NewAchievement(AssetCategory::Local);
         pAch3.SetID(3);
         pAch3.SetPoints(3U);
-        pAch3.SetActive(true);
-        auto& pAch4 = achievementsPage.mockGameContext.NewAchievement(Achievement::Category::Core);
+        pAch3.SetState(AssetState::Active);
+        auto& pAch4 = achievementsPage.NewAchievement(AssetCategory::Core);
         pAch4.SetID(4);
         pAch4.SetPoints(4U);
-        pAch4.SetActive(false);
+        pAch4.SetState(AssetState::Inactive);
         achievementsPage.Refresh();
 
         Assert::AreEqual(std::wstring(L"Achievements"), achievementsPage.GetTitle());
         Assert::AreEqual(std::wstring(L"1 of 2 won (4/5)"), achievementsPage.GetSummary());
 
+        // only 1 and 4 will be visible - 2 and 3 are not core, and filtered out by asset list
         auto* pItem = achievementsPage.GetItem(0);
         Expects(pItem != nullptr);
         Assert::AreEqual(1, pItem->GetId());
@@ -201,32 +246,29 @@ public:
 
         pItem = achievementsPage.GetItem(1);
         Expects(pItem != nullptr);
-        Assert::AreEqual(2, pItem->GetId());
+        Assert::IsTrue(pItem->IsHeader());
+        Assert::AreEqual(std::wstring(L"Unlocked"), pItem->GetLabel());
         Assert::IsFalse(pItem->IsDisabled());
 
         pItem = achievementsPage.GetItem(2);
         Expects(pItem != nullptr);
-        Assert::AreEqual(3, pItem->GetId());
-        Assert::IsFalse(pItem->IsDisabled());
-
-        pItem = achievementsPage.GetItem(3);
-        Expects(pItem != nullptr);
         Assert::AreEqual(4, pItem->GetId());
         Assert::IsFalse(pItem->IsDisabled());
 
-        Assert::IsNull(achievementsPage.GetItem(5));
+        Assert::IsNull(achievementsPage.GetItem(3));
     }
 
     TEST_METHOD(TestRefreshLocalAchievement)
     {
         OverlayAchievementsPageViewModelHarness achievementsPage;
-        auto& pAch1 = achievementsPage.mockGameContext.NewAchievement(Achievement::Category::Local);
+        achievementsPage.mockWindowManager.AssetList.SetFilterCategory(AssetCategory::Local);
+        auto& pAch1 = achievementsPage.NewAchievement(AssetCategory::Local);
         pAch1.SetID(1);
-        pAch1.SetTitle("AchievementTitle");
-        pAch1.SetDescription("Trigger this");
+        pAch1.SetName(L"AchievementTitle");
+        pAch1.SetDescription(L"Trigger this");
         pAch1.SetPoints(5U);
-        pAch1.SetBadgeImage("BADGE_URI");
-        pAch1.SetActive(true);
+        pAch1.SetBadge(L"BADGE_URI");
+        pAch1.SetState(AssetState::Active);
         achievementsPage.Refresh();
 
         Assert::AreEqual(std::wstring(L"Achievements"), achievementsPage.GetTitle());
@@ -244,25 +286,25 @@ public:
     TEST_METHOD(TestRefreshProgressAchievements)
     {
         OverlayAchievementsPageViewModelHarness achievementsPage;
-        auto& pAch1 = achievementsPage.mockGameContext.NewAchievement(Achievement::Category::Core);
+        auto& pAch1 = achievementsPage.NewAchievement(AssetCategory::Core);
         pAch1.SetID(1);
         pAch1.SetPoints(1U);
-        pAch1.SetActive(true);
+        pAch1.SetState(AssetState::Active);
         achievementsPage.SetProgress(1U, 1, 10);
-        auto& pAch2 = achievementsPage.mockGameContext.NewAchievement(Achievement::Category::Core);
+        auto& pAch2 = achievementsPage.NewAchievement(AssetCategory::Core);
         pAch2.SetID(2);
         pAch2.SetPoints(2U);
-        pAch2.SetActive(false);
+        pAch2.SetState(AssetState::Inactive);
         achievementsPage.SetProgress(2U, 1, 10);
-        auto& pAch3 = achievementsPage.mockGameContext.NewAchievement(Achievement::Category::Core);
+        auto& pAch3 = achievementsPage.NewAchievement(AssetCategory::Core);
         pAch3.SetID(3);
         pAch3.SetPoints(3U);
-        pAch3.SetActive(true);
+        pAch3.SetState(AssetState::Active);
         achievementsPage.SetProgress(3U, 0, 0);
-        auto& pAch4 = achievementsPage.mockGameContext.NewAchievement(Achievement::Category::Core);
+        auto& pAch4 = achievementsPage.NewAchievement(AssetCategory::Core);
         pAch4.SetID(4);
         pAch4.SetPoints(4U);
-        pAch4.SetActive(false);
+        pAch4.SetState(AssetState::Inactive);
         achievementsPage.SetProgress(4U, 0, 0);
 
         achievementsPage.Refresh();
@@ -271,23 +313,108 @@ public:
 
         auto const* pItem = achievementsPage.GetItem(0);
         Expects(pItem != nullptr);
+        Assert::AreEqual(1, pItem->GetId());
         Assert::AreEqual(10U, pItem->GetProgressMaximum()); // active item with progress
         Assert::AreEqual(1U, pItem->GetProgressValue());
 
         pItem = achievementsPage.GetItem(1);
         Expects(pItem != nullptr);
-        Assert::AreEqual(0U, pItem->GetProgressMaximum()); // inactive item with progress should not display it
+        Assert::AreEqual(3, pItem->GetId());
+        Assert::AreEqual(0U, pItem->GetProgressMaximum()); // active item without progress
         Assert::AreEqual(0U, pItem->GetProgressValue());
 
         pItem = achievementsPage.GetItem(2);
         Expects(pItem != nullptr);
-        Assert::AreEqual(0U, pItem->GetProgressMaximum()); // active item without progress
+        Assert::IsTrue(pItem->IsHeader());
+        Assert::AreEqual(std::wstring(L"Unlocked"), pItem->GetLabel());
+        Assert::IsFalse(pItem->IsDisabled());
+
+        pItem = achievementsPage.GetItem(3);
+        Expects(pItem != nullptr);
+        Assert::AreEqual(2, pItem->GetId());
+        Assert::AreEqual(0U, pItem->GetProgressMaximum()); // inactive item with progress should not display it
         Assert::AreEqual(0U, pItem->GetProgressValue());
 
+        pItem = achievementsPage.GetItem(4);
+        Expects(pItem != nullptr);
+        Assert::AreEqual(4, pItem->GetId());
+        Assert::AreEqual(0U, pItem->GetProgressMaximum()); // inactive item without progress
+        Assert::AreEqual(0U, pItem->GetProgressValue());
+
+        Assert::IsNull(achievementsPage.GetItem(5));
+    }
+
+    TEST_METHOD(TestRefreshAlmostThereAchievements)
+    {
+        OverlayAchievementsPageViewModelHarness achievementsPage;
+        auto& pAch1 = achievementsPage.NewAchievement(AssetCategory::Core);
+        pAch1.SetID(1);
+        pAch1.SetPoints(1U);
+        pAch1.SetState(AssetState::Active);
+        achievementsPage.SetProgress(1U, 1, 10);
+        auto& pAch2 = achievementsPage.NewAchievement(AssetCategory::Core);
+        pAch2.SetID(2);
+        pAch2.SetPoints(2U);
+        pAch2.SetState(AssetState::Inactive);
+        achievementsPage.SetProgress(2U, 1, 10);
+        auto& pAch3 = achievementsPage.NewAchievement(AssetCategory::Core);
+        pAch3.SetID(3);
+        pAch3.SetPoints(3U);
+        pAch3.SetState(AssetState::Active);
+        achievementsPage.SetProgress(3U, 9, 10);
+        auto& pAch4 = achievementsPage.NewAchievement(AssetCategory::Core);
+        pAch4.SetID(4);
+        pAch4.SetPoints(4U);
+        pAch4.SetState(AssetState::Inactive);
+        achievementsPage.SetProgress(4U, 0, 0);
+
+        achievementsPage.Refresh();
+        Assert::AreEqual(std::wstring(L"Achievements"), achievementsPage.GetTitle());
+        Assert::AreEqual(std::wstring(L"2 of 4 won (6/10)"), achievementsPage.GetSummary());
+
+        auto const* pItem = achievementsPage.GetItem(0);
+        Expects(pItem != nullptr);
+        Assert::IsTrue(pItem->IsHeader());
+        Assert::AreEqual(std::wstring(L"Almost There"), pItem->GetLabel());
+        Assert::IsFalse(pItem->IsDisabled());
+
+        pItem = achievementsPage.GetItem(1);
+        Expects(pItem != nullptr);
+        Assert::AreEqual(3, pItem->GetId());
+        Assert::AreEqual(10U, pItem->GetProgressMaximum()); // active item with >= 80% progress
+        Assert::AreEqual(9U, pItem->GetProgressValue());
+
+        pItem = achievementsPage.GetItem(2);
+        Expects(pItem != nullptr);
+        Assert::IsTrue(pItem->IsHeader());
+        Assert::AreEqual(std::wstring(L"Locked"), pItem->GetLabel());
+        Assert::IsFalse(pItem->IsDisabled());
+
         pItem = achievementsPage.GetItem(3);
         Expects(pItem != nullptr);
+        Assert::AreEqual(1, pItem->GetId());
+        Assert::AreEqual(10U, pItem->GetProgressMaximum()); // active item with < 80% progress
+        Assert::AreEqual(1U, pItem->GetProgressValue());
+
+        pItem = achievementsPage.GetItem(4);
+        Expects(pItem != nullptr);
+        Assert::IsTrue(pItem->IsHeader());
+        Assert::AreEqual(std::wstring(L"Unlocked"), pItem->GetLabel());
+        Assert::IsFalse(pItem->IsDisabled());
+
+        pItem = achievementsPage.GetItem(5);
+        Expects(pItem != nullptr);
+        Assert::AreEqual(2, pItem->GetId());
+        Assert::AreEqual(0U, pItem->GetProgressMaximum()); // inactive item with progress should not display it
+        Assert::AreEqual(0U, pItem->GetProgressValue());
+
+        pItem = achievementsPage.GetItem(6);
+        Expects(pItem != nullptr);
+        Assert::AreEqual(4, pItem->GetId());
         Assert::AreEqual(0U, pItem->GetProgressMaximum()); // inactive item without progress
         Assert::AreEqual(0U, pItem->GetProgressValue());
+
+        Assert::IsNull(achievementsPage.GetItem(7));
     }
 
     TEST_METHOD(TestRefreshSession)
@@ -296,7 +423,7 @@ public:
         achievementsPage.mockGameContext.SetGameId(1U);
         achievementsPage.mockSessionTracker.MockSession(1U, 1234567879, std::chrono::minutes(347));
 
-        auto& pAch1 = achievementsPage.mockGameContext.NewAchievement(Achievement::Category::Core);
+        auto& pAch1 = achievementsPage.NewAchievement(AssetCategory::Core);
         pAch1.SetID(1);
         pAch1.SetPoints(5U);
         achievementsPage.Refresh();
@@ -311,7 +438,7 @@ public:
         achievementsPage.mockGameContext.SetGameId(1U);
         achievementsPage.mockSessionTracker.MockSession(1U, 1234567879, std::chrono::seconds(17 * 60 + 12));
 
-        auto& pAch1 = achievementsPage.mockGameContext.NewAchievement(Achievement::Category::Core);
+        auto& pAch1 = achievementsPage.NewAchievement(AssetCategory::Core);
         pAch1.SetID(1);
         pAch1.SetPoints(5U);
         achievementsPage.Refresh();
@@ -334,13 +461,15 @@ public:
     TEST_METHOD(TestFetchItemDetailLocal)
     {
         OverlayAchievementsPageViewModelHarness achievementsPage;
-        auto& pAch1 = achievementsPage.mockGameContext.NewAchievement(Achievement::Category::Local);
+        achievementsPage.mockWindowManager.AssetList.SetFilterCategory(AssetCategory::Local);
+        auto& pAch1 = achievementsPage.NewAchievement(AssetCategory::Local);
+        achievementsPage.mockGameContext.FindAchievement(pAch1.GetID())->SetID(1);
         pAch1.SetID(1);
-        pAch1.SetTitle("AchievementTitle");
-        pAch1.SetDescription("Trigger this");
+        pAch1.SetName(L"AchievementTitle");
+        pAch1.SetDescription(L"Trigger this");
         pAch1.SetPoints(5U);
-        pAch1.SetBadgeImage("BADGE_URI");
-        pAch1.SetActive(true);
+        pAch1.SetBadge(L"BADGE_URI");
+        pAch1.SetState(AssetState::Active);
 
         achievementsPage.mockServer.ExpectUncalled<ra::api::FetchAchievementInfo>();
 
@@ -357,13 +486,14 @@ public:
     TEST_METHOD(TestFetchItemDetail)
     {
         OverlayAchievementsPageViewModelHarness achievementsPage;
-        auto& pAch1 = achievementsPage.mockGameContext.NewAchievement(Achievement::Category::Core);
+        auto& pAch1 = achievementsPage.NewAchievement(AssetCategory::Core);
+        achievementsPage.mockGameContext.FindAchievement(pAch1.GetID())->SetID(1);
         pAch1.SetID(1);
-        pAch1.SetTitle("AchievementTitle");
-        pAch1.SetDescription("Trigger this");
+        pAch1.SetName(L"AchievementTitle");
+        pAch1.SetDescription(L"Trigger this");
         pAch1.SetPoints(5U);
-        pAch1.SetBadgeImage("BADGE_URI");
-        pAch1.SetActive(true);
+        pAch1.SetBadge(L"BADGE_URI");
+        pAch1.SetState(AssetState::Active);
 
         achievementsPage.mockUserContext.Initialize("User2", "ApiToken");
         achievementsPage.mockServer.HandleRequest<ra::api::FetchAchievementInfo>([](const ra::api::FetchAchievementInfo::Request& request, ra::api::FetchAchievementInfo::Response& response)
diff --git a/tests/ui/viewmodels/OverlayManager_Tests.cpp b/tests/ui/viewmodels/OverlayManager_Tests.cpp
index 5e17a2a9..608c0344 100644
--- a/tests/ui/viewmodels/OverlayManager_Tests.cpp
+++ b/tests/ui/viewmodels/OverlayManager_Tests.cpp
@@ -4,6 +4,7 @@
 
 #include "ui\OverlayTheme.hh"
 
+#include "tests\mocks\MockAchievementRuntime.hh"
 #include "tests\mocks\MockClock.hh"
 #include "tests\mocks\MockConfiguration.hh"
 #include "tests\mocks\MockDesktop.hh"
@@ -34,6 +35,7 @@ private:
         ra::data::mocks::MockEmulatorContext mockEmulatorContext;
         ra::data::mocks::MockGameContext mockGameContext;
         ra::data::mocks::MockUserContext mockUserContext;
+        ra::services::mocks::MockAchievementRuntime mockAchievementRuntime;
         ra::services::mocks::MockClock mockClock;
         ra::services::mocks::MockConfiguration mockConfiguration;
         ra::services::mocks::MockThreadPool mockThreadPool;
diff --git a/tests/ui/viewmodels/TriggerViewModel_Tests.cpp b/tests/ui/viewmodels/TriggerViewModel_Tests.cpp
index 65b030db..96e635d8 100644
--- a/tests/ui/viewmodels/TriggerViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/TriggerViewModel_Tests.cpp
@@ -3,6 +3,8 @@
 #include "ui\viewmodels\TriggerViewModel.hh"
 
 #include "tests\RA_UnitTestHelpers.h"
+#include "tests\mocks\MockClipboard.hh"
+#include "tests\mocks\MockDesktop.hh"
 
 using namespace Microsoft::VisualStudio::CppUnitTestFramework;
 
@@ -14,6 +16,13 @@ namespace tests {
 TEST_CLASS(TriggerViewModel_Tests)
 {
 private:
+    class TriggerViewModelHarness : public TriggerViewModel
+    {
+    public:
+        ra::services::mocks::MockClipboard mockClipboard;
+        ra::ui::mocks::MockDesktop mockDesktop;
+    };
+
     void Parse(TriggerViewModel& vmTrigger, const std::string& sInput)
     {
         vmTrigger.InitializeFrom(sInput);
@@ -216,6 +225,231 @@ public:
         vmTrigger.Conditions().GetItemAt(1)->SetSourceValue(1234U);
         Assert::AreEqual(std::string("0xH1234=0xH2345_0xH04d2=0S0xX5555=1"), pMonitor.sNewTrigger);
     }
+
+    TEST_METHOD(TestCopySelectedConditionsToClipboard)
+    {
+        TriggerViewModelHarness vmTrigger;
+        Parse(vmTrigger, "0xH1234=16_0xL65FF=11.1._R:0xT3333=1_0xW5555=16");
+
+        Assert::AreEqual({ 4U }, vmTrigger.Conditions().Count());
+        vmTrigger.Conditions().GetItemAt(1)->SetSelected(true);
+        vmTrigger.Conditions().GetItemAt(2)->SetSelected(true);
+
+        vmTrigger.CopySelectedConditionsToClipboard();
+        Assert::AreEqual(std::wstring(L"0xL65ff=11.1._R:0xT3333=1"), vmTrigger.mockClipboard.GetText());
+
+        vmTrigger.Conditions().GetItemAt(3)->SetSelected(true);
+        vmTrigger.CopySelectedConditionsToClipboard();
+        Assert::AreEqual(std::wstring(L"0xL65ff=11.1._R:0xT3333=1_0xW5555=16"), vmTrigger.mockClipboard.GetText());
+
+        vmTrigger.Conditions().GetItemAt(2)->SetSelected(false);
+        vmTrigger.CopySelectedConditionsToClipboard();
+        Assert::AreEqual(std::wstring(L"0xL65ff=11.1._0xW5555=16"), vmTrigger.mockClipboard.GetText());
+    }
+
+    TEST_METHOD(TestCopySelectedConditionsToClipboardNoSelection)
+    {
+        TriggerViewModelHarness vmTrigger;
+        Parse(vmTrigger, "0xH1234=16_0xL65FF=11.1._R:0xT3333=1_0xW5555=16");
+
+        Assert::AreEqual({ 4U }, vmTrigger.Conditions().Count());
+
+        vmTrigger.CopySelectedConditionsToClipboard();
+        Assert::AreEqual(std::wstring(L"0xH1234=16_0xL65ff=11.1._R:0xT3333=1_0xW5555=16"), vmTrigger.mockClipboard.GetText());
+
+        vmTrigger.Conditions().GetItemAt(2)->SetSelected(true);
+        vmTrigger.CopySelectedConditionsToClipboard();
+        Assert::AreEqual(std::wstring(L"R:0xT3333=1"), vmTrigger.mockClipboard.GetText());
+
+        vmTrigger.Conditions().GetItemAt(2)->SetSelected(false);
+        vmTrigger.CopySelectedConditionsToClipboard();
+        Assert::AreEqual(std::wstring(L"0xH1234=16_0xL65ff=11.1._R:0xT3333=1_0xW5555=16"), vmTrigger.mockClipboard.GetText());
+    }
+
+    TEST_METHOD(TestPasteFromClipboardEmpty)
+    {
+        TriggerViewModelHarness vmTrigger;
+        Parse(vmTrigger, "0xH1234=16");
+        Assert::AreEqual({ 1U }, vmTrigger.Conditions().Count());
+
+        bool bDialogShown = false;
+        vmTrigger.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>(
+            [&bDialogShown](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bDialogShown = true;
+
+            Assert::AreEqual(std::wstring(L"Paste failed."), vmMessageBox.GetHeader());
+            Assert::AreEqual(std::wstring(L"Clipboard did not contain any text."), vmMessageBox.GetMessage());
+
+            return DialogResult::OK;
+        });
+
+        vmTrigger.mockClipboard.SetText(L"");
+        vmTrigger.PasteFromClipboard();
+
+        Assert::IsTrue(bDialogShown);
+    }
+
+    TEST_METHOD(TestPasteFromClipboardNonTrigger)
+    {
+        TriggerViewModelHarness vmTrigger;
+        Parse(vmTrigger, "0xH1234=16");
+        Assert::AreEqual({ 1U }, vmTrigger.Conditions().Count());
+
+        bool bDialogShown = false;
+        vmTrigger.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>(
+            [&bDialogShown](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bDialogShown = true;
+
+            Assert::AreEqual(std::wstring(L"Paste failed."), vmMessageBox.GetHeader());
+            Assert::AreEqual(std::wstring(L"Clipboard did not contain valid trigger conditions."), vmMessageBox.GetMessage());
+
+            return DialogResult::OK;
+        });
+
+        vmTrigger.mockClipboard.SetText(L"Hello, world!");
+        vmTrigger.PasteFromClipboard();
+
+        Assert::IsTrue(bDialogShown);
+    }
+
+    TEST_METHOD(TestPasteFromClipboardMultipleGroups)
+    {
+        TriggerViewModelHarness vmTrigger;
+        Parse(vmTrigger, "0xH1234=16");
+        Assert::AreEqual({ 1U }, vmTrigger.Conditions().Count());
+
+        bool bDialogShown = false;
+        vmTrigger.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>(
+            [&bDialogShown](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bDialogShown = true;
+
+            Assert::AreEqual(std::wstring(L"Paste failed."), vmMessageBox.GetHeader());
+            Assert::AreEqual(std::wstring(L"Clipboard contained multiple groups."), vmMessageBox.GetMessage());
+
+            return DialogResult::OK;
+        });
+
+        vmTrigger.mockClipboard.SetText(L"0xL65FF=11S0xT3333=1");
+        vmTrigger.PasteFromClipboard();
+
+        Assert::IsTrue(bDialogShown);
+    }
+
+    TEST_METHOD(TestPasteFromClipboard)
+    {
+        TriggerViewModelHarness vmTrigger;
+        Parse(vmTrigger, "0xH1234=16");
+        Assert::AreEqual({ 1U }, vmTrigger.Conditions().Count());
+        vmTrigger.Conditions().GetItemAt(0)->SetSelected(true);
+
+        vmTrigger.mockClipboard.SetText(L"0xL65FF=11.1._R:0xT3333=1");
+        vmTrigger.PasteFromClipboard();
+
+        Assert::IsFalse(vmTrigger.mockDesktop.WasDialogShown());
+        Assert::AreEqual({ 3U }, vmTrigger.Conditions().Count());
+
+        // newly pasted conditions should be selected, any previously selected items should be deselected
+        Assert::IsFalse(vmTrigger.Conditions().GetItemAt(0)->IsSelected());
+        Assert::IsTrue(vmTrigger.Conditions().GetItemAt(1)->IsSelected());
+        Assert::IsTrue(vmTrigger.Conditions().GetItemAt(2)->IsSelected());
+
+        Assert::AreEqual(std::string("0xH1234=16_0xL65ff=11.1._R:0xT3333=1"), vmTrigger.Serialize());
+    }
+
+    TEST_METHOD(TestRemoveSelectedConditions)
+    {
+        TriggerViewModelHarness vmTrigger;
+        Parse(vmTrigger, "0xH1234=16_0xL65FF=11.1._R:0xT3333=1_0xW5555=16");
+
+        Assert::AreEqual({ 4U }, vmTrigger.Conditions().Count());
+        vmTrigger.Conditions().GetItemAt(1)->SetSelected(true);
+        vmTrigger.Conditions().GetItemAt(2)->SetSelected(true);
+
+        // two items selected
+        bool bDialogShown = false;
+        vmTrigger.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>(
+            [&bDialogShown](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bDialogShown = true;
+
+            Assert::AreEqual(std::wstring(L"Are you sure that you want to delete 2 conditions?"), vmMessageBox.GetMessage());
+
+            return DialogResult::Yes;
+        });
+
+        vmTrigger.RemoveSelectedConditions();
+        Assert::IsTrue(bDialogShown);
+        Assert::AreEqual({ 2U }, vmTrigger.Conditions().Count());
+        Assert::AreEqual(std::string("0xH1234=16_0xW5555=16"), vmTrigger.Serialize());
+        Assert::AreEqual(1, vmTrigger.Conditions().GetItemAt(0)->GetIndex());
+        Assert::AreEqual(2, vmTrigger.Conditions().GetItemAt(1)->GetIndex());
+
+        // one item selected
+        bDialogShown = false;
+        vmTrigger.mockDesktop.ResetExpectedWindows();
+        vmTrigger.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>(
+            [&bDialogShown](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bDialogShown = true;
+
+            Assert::AreEqual(std::wstring(L"Are you sure that you want to delete 1 condition?"), vmMessageBox.GetMessage());
+
+            return DialogResult::Yes;
+        });
+
+        vmTrigger.Conditions().GetItemAt(0)->SetSelected(true);
+        vmTrigger.RemoveSelectedConditions();
+        Assert::IsTrue(bDialogShown);
+        Assert::AreEqual({ 1U }, vmTrigger.Conditions().Count());
+        Assert::AreEqual(std::string("0xW5555=16"), vmTrigger.Serialize());
+        Assert::AreEqual(1, vmTrigger.Conditions().GetItemAt(0)->GetIndex());
+
+        // no selection
+        bDialogShown = false;
+        vmTrigger.mockDesktop.ResetExpectedWindows();
+        vmTrigger.RemoveSelectedConditions();
+        Assert::IsFalse(bDialogShown);
+        Assert::IsFalse(vmTrigger.mockDesktop.WasDialogShown());
+        Assert::AreEqual({ 1U }, vmTrigger.Conditions().Count());
+        Assert::AreEqual(std::string("0xW5555=16"), vmTrigger.Serialize());
+        Assert::AreEqual(1, vmTrigger.Conditions().GetItemAt(0)->GetIndex());
+    }
+
+    TEST_METHOD(TestRemoveSelectedConditionsCancel)
+    {
+        TriggerViewModelHarness vmTrigger;
+        Parse(vmTrigger, "0xH1234=16_0xL65FF=11.1._R:0xT3333=1_0xW5555=16");
+
+        Assert::AreEqual({ 4U }, vmTrigger.Conditions().Count());
+        vmTrigger.Conditions().GetItemAt(1)->SetSelected(true);
+        vmTrigger.Conditions().GetItemAt(2)->SetSelected(true);
+
+        // two items selected
+        bool bDialogShown = false;
+        vmTrigger.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>(
+            [&bDialogShown](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bDialogShown = true;
+
+            Assert::AreEqual(std::wstring(L"Are you sure that you want to delete 2 conditions?"), vmMessageBox.GetMessage());
+
+            return DialogResult::No;
+        });
+
+        vmTrigger.RemoveSelectedConditions();
+        Assert::IsTrue(bDialogShown);
+
+        Assert::AreEqual({ 4U }, vmTrigger.Conditions().Count());
+        Assert::IsFalse(vmTrigger.Conditions().GetItemAt(0)->IsSelected());
+        Assert::IsTrue(vmTrigger.Conditions().GetItemAt(1)->IsSelected());
+        Assert::IsTrue(vmTrigger.Conditions().GetItemAt(2)->IsSelected());
+        Assert::IsFalse(vmTrigger.Conditions().GetItemAt(3)->IsSelected());
+
+        Assert::AreEqual(std::string("0xH1234=16_0xL65ff=11.1._R:0xT3333=1_0xW5555=16"), vmTrigger.Serialize());
+    }
 };
 
 } // namespace tests