Repository: RetroAchievements/RAIntegration
Pull request #696 (2021-03-28 18:30:28)

add Copy Def button to asset editor
Copies the entire trigger definition to the clipboard. The existing Copy button will continue to only copy the selected conditions (or all conditions in the selected group if no conditions are selected).


Patch:
diff --git a/src/RA_Resource.h b/src/RA_Resource.h
index 5ee96bad..6dae7537 100644
--- a/src/RA_Resource.h
+++ b/src/RA_Resource.h
@@ -98,6 +98,7 @@
 #define IDC_RA_ERROR_INDICATOR          1193
 #define IDC_RA_LBL_MEASURED             1194
 #define IDC_RA_MEASURED                 1195
+#define IDC_RA_COPY_ALL                 1196
 #define IDD_RA_MEMORY                   1501
 #define IDD_RA_ACHIEVEMENTS             1502
 #define IDD_RA_ACHIEVEMENTEDITOR        1503
@@ -180,7 +181,7 @@
 #ifndef APSTUDIO_READONLY_SYMBOLS
 #define _APS_NEXT_RESOURCE_VALUE        122
 #define _APS_NEXT_COMMAND_VALUE         40001
-#define _APS_NEXT_CONTROL_VALUE         1196
+#define _APS_NEXT_CONTROL_VALUE         1197
 #define _APS_NEXT_SYMED_VALUE           101
 #endif
 #endif
diff --git a/src/RA_Shared.rc b/src/RA_Shared.rc
index 7b9c504a..dd687f6e 100644
--- a/src/RA_Shared.rc
+++ b/src/RA_Shared.rc
@@ -85,9 +85,10 @@ BEGIN
     CONTROL         "Processing Active",IDC_RA_CHKACHPROCESSINGACTIVE,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,367,6,68,10
     CONTROL         "",IDC_RA_LISTACHIEVEMENTS,"SysListView32",LVS_REPORT | LVS_SHOWSELALWAYS | LVS_ALIGNLEFT | WS_BORDER | WS_TABSTOP,4,20,360,141
     PUSHBUTTON      "&Activate All",IDC_RA_RESET_ACH,367,20,68,16,BS_MULTILINE
-    PUSHBUTTON      "&Save All",IDC_RA_COMMIT_ACH,367,53,68,16,BS_MULTILINE
-    PUSHBUTTON      "&Reset All",IDC_RA_DOWNLOAD_ACH,367,72,68,16,BS_MULTILINE | WS_DISABLED
-    PUSHBUTTON      "Re&vert All",IDC_RA_REVERTSELECTED,367,91,68,16,BS_MULTILINE
+    CONTROL         "&Keep Active",IDC_RA_CHK_ACTIVE,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,369,37,68,10
+    PUSHBUTTON      "&Save All",IDC_RA_COMMIT_ACH,367,57,68,16,BS_MULTILINE
+    PUSHBUTTON      "&Reset All",IDC_RA_DOWNLOAD_ACH,367,76,68,16,BS_MULTILINE | WS_DISABLED
+    PUSHBUTTON      "Re&vert All",IDC_RA_REVERTSELECTED,367,95,68,16,BS_MULTILINE
     DEFPUSHBUTTON   "&New",IDC_RA_ADD_ACH,367,126,68,16,BS_MULTILINE | WS_DISABLED
     PUSHBUTTON      "&Clone",IDC_RA_CLONE_ACH,367,145,68,16,BS_MULTILINE
 END
@@ -131,9 +132,10 @@ BEGIN
     PUSHBUTTON      "Upload",IDC_RA_UPLOAD_BADGE,350,38,33,13
     CONTROL         "",IDC_RA_BADGEPIC,"Static",SS_BITMAP | SS_CENTERIMAGE,388,4,53,48,WS_EX_CLIENTEDGE
     LTEXT           "Group:",IDC_STATIC,4,55,23,8
-    CONTROL         "",IDC_RA_LBX_GROUPS,"SysListView32",LVS_REPORT | LVS_SHOWSELALWAYS | LVS_ALIGNLEFT | LVS_NOCOLUMNHEADER | WS_BORDER | WS_TABSTOP,4,66,42,106
-    PUSHBUTTON      "+",IDC_RA_ADD_GROUP,4,176,15,13
-    PUSHBUTTON      "-",IDC_RA_DELETE_GROUP,31,176,15,13
+    CONTROL         "",IDC_RA_LBX_GROUPS,"SysListView32",LVS_REPORT | LVS_SHOWSELALWAYS | LVS_ALIGNLEFT | LVS_NOCOLUMNHEADER | WS_BORDER | WS_TABSTOP,4,66,42,92
+    PUSHBUTTON      "+",IDC_RA_ADD_GROUP,3,159,15,13
+    PUSHBUTTON      "-",IDC_RA_DELETE_GROUP,31,159,15,13
+    PUSHBUTTON      "Copy Def",IDC_RA_COPY_ALL,3,175,44,13
     LTEXT           "Requirements:",IDC_STATIC,50,55,48,8
     ICON            "",IDC_RA_ERROR_INDICATOR,98,53,13,13,SS_NOTIFY | SS_CENTERIMAGE
     CONTROL         "",IDC_RA_LBX_CONDITIONS,"SysListView32",LVS_REPORT | LVS_SHOWSELALWAYS | LVS_ALIGNLEFT | LVS_NOSORTHEADER | WS_BORDER | WS_TABSTOP,50,66,391,106
diff --git a/src/data/context/GameContext.cpp b/src/data/context/GameContext.cpp
index 2b36a3df..cf41afee 100644
--- a/src/data/context/GameContext.cpp
+++ b/src/data/context/GameContext.cpp
@@ -896,6 +896,27 @@ void GameContext::OnCodeNoteChanged(ra::ByteAddress nAddress, const std::wstring
     }
 }
 
+ra::ByteAddress GameContext::FindCodeNoteStart(ra::ByteAddress nAddress) const
+{
+    auto pIter = m_mCodeNotes.lower_bound(nAddress);
+
+    // exact match, return it
+    if (pIter != m_mCodeNotes.end() && pIter->first == nAddress)
+        return nAddress;
+
+    // lower_bound returns the first item _after_ the search value. we want to look at the item _before_.
+    if (pIter != m_mCodeNotes.begin())
+    {
+        --pIter;
+
+        // check to see if the item before the search value contains the search value
+        if (pIter->first + pIter->second.Bytes > nAddress)
+            return pIter->first;
+    }
+
+    return 0xFFFFFFFF;
+}
+
 std::wstring GameContext::FindCodeNote(ra::ByteAddress nAddress, MemSize nSize) const
 {
     unsigned int nCheckSize = 0;
@@ -924,8 +945,13 @@ std::wstring GameContext::FindCodeNote(ra::ByteAddress nAddress, MemSize nSize)
 
         const auto iNewLine = sNote.find('\n');
         if (iNewLine != std::string::npos)
+        {
             sNote.resize(iNewLine);
 
+            if (sNote.back() == '\r')
+                sNote.pop_back();
+        }
+
         // exact match
         if (nAddress == nNoteAddress && nNoteSize == nCheckSize)
             return sNote;
@@ -955,8 +981,13 @@ std::wstring GameContext::FindCodeNote(ra::ByteAddress nAddress, MemSize nSize)
 
             const auto iNewLine = sNote.find('\n');
             if (iNewLine != std::string::npos)
+            {
                 sNote.resize(iNewLine);
 
+                if (sNote.back() == '\r')
+                    sNote.pop_back();
+            }
+
             if (nCheckSize == 1)
                 sNote.append(ra::StringPrintf(L" [%d/%d]", nAddress - nNoteAddress + 1, nNoteSize));
             else
diff --git a/src/data/context/GameContext.hh b/src/data/context/GameContext.hh
index fecb65b2..766e16e2 100644
--- a/src/data/context/GameContext.hh
+++ b/src/data/context/GameContext.hh
@@ -160,6 +160,14 @@ public:
         return (pIter != m_mCodeNotes.end()) ? &pIter->second.Note : nullptr;
     }
 
+    /// <summary>
+    /// Returns the address of the first byte containing the specified code note.
+    /// </summary>
+    /// <returns>
+    ///  Returns 0xFFFFFFFF if not found.
+    /// </returns>
+    ra::ByteAddress FindCodeNoteStart(ra::ByteAddress nAddress) const;
+
     /// <summary>
     /// Returns the note associated with the specified address.
     /// </summary>
diff --git a/src/ui/viewmodels/AssetListViewModel.cpp b/src/ui/viewmodels/AssetListViewModel.cpp
index e5e967fd..0675a5d5 100644
--- a/src/ui/viewmodels/AssetListViewModel.cpp
+++ b/src/ui/viewmodels/AssetListViewModel.cpp
@@ -25,6 +25,7 @@ const IntModelProperty AssetListViewModel::GameIdProperty("AssetListViewModel",
 const IntModelProperty AssetListViewModel::AchievementCountProperty("AssetListViewModel", "AchievementCount", 0);
 const IntModelProperty AssetListViewModel::TotalPointsProperty("AssetListViewModel", "TotalPoints", 0);
 const BoolModelProperty AssetListViewModel::IsProcessingActiveProperty("AssetListViewModel", "IsProcessingActive", true);
+const BoolModelProperty AssetListViewModel::KeepActiveProperty("AssetListViewModel", "KeepActive", false);
 const StringModelProperty AssetListViewModel::ActivateButtonTextProperty("AssetListViewModel", "ActivateButtonText", L"&Activate All");
 const StringModelProperty AssetListViewModel::SaveButtonTextProperty("AssetListViewModel", "SaveButtonText", L"&Save All");
 const StringModelProperty AssetListViewModel::ResetButtonTextProperty("AssetListViewModel", "ResetButtonText", L"&Reset All");
@@ -128,7 +129,23 @@ void AssetListViewModel::OnDataModelIntValueChanged(gsl::index nIndex, const Int
             SetValue(TotalPointsProperty, nPoints);
         }
     }
-    else if (args.Property == ra::data::models::AssetModelBase::CategoryProperty)
+    else if (args.Property == ra::data::models::AssetModelBase::StateProperty)
+    {
+        const auto nNewState = ra::itoe<ra::data::models::AssetState>(args.tNewValue);
+        if (nNewState == ra::data::models::AssetState::Triggered && KeepActive())
+        {
+            // if KeepActive is selected, set a Triggered achievement back to Waiting
+            auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
+            auto* pAsset = pGameContext.Assets().GetItemAt(nIndex);
+            pAsset->SetState(ra::data::models::AssetState::Waiting);
+
+            // SetState is re-entrant - we don't want to do any further processing with the previous new value
+            return;
+        }
+    }
+
+    // these properties potentially affect visibility
+    if (args.Property == ra::data::models::AssetModelBase::CategoryProperty)
     {
         UpdateTotals();
 
diff --git a/src/ui/viewmodels/AssetListViewModel.hh b/src/ui/viewmodels/AssetListViewModel.hh
index 27f8a313..11411bb7 100644
--- a/src/ui/viewmodels/AssetListViewModel.hh
+++ b/src/ui/viewmodels/AssetListViewModel.hh
@@ -81,6 +81,21 @@ public:
     /// </summary>
     void SetProcessingActive(bool bValue) { SetValue(IsProcessingActiveProperty, bValue); }
 
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for the whether triggered achievements should be reactivated.
+    /// </summary>
+    static const BoolModelProperty KeepActiveProperty;
+
+    /// <summary>
+    /// Gets whether triggered achievements should be reactivated.
+    /// </summary>
+    bool KeepActive() const { return GetValue(KeepActiveProperty); }
+
+    /// <summary>
+    /// Sets whether triggered achievements should be reactivated.
+    /// </summary>
+    void SetKeepActive(bool bValue) { SetValue(KeepActiveProperty, bValue); }
+
     /// <summary>
     /// Gets the list of asset states.
     /// </summary>
diff --git a/src/ui/viewmodels/TriggerConditionViewModel.cpp b/src/ui/viewmodels/TriggerConditionViewModel.cpp
index 40ccb7c0..f08021c2 100644
--- a/src/ui/viewmodels/TriggerConditionViewModel.cpp
+++ b/src/ui/viewmodels/TriggerConditionViewModel.cpp
@@ -415,12 +415,24 @@ unsigned int TriggerConditionViewModel::GetIndirectAddress(unsigned int nAddress
 
 std::wstring TriggerConditionViewModel::GetAddressTooltip(unsigned int nAddress, bool bIsIndirect)
 {
-    auto sAddress = ra::ByteAddressToString(nAddress);
+    const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
+    std::wstring sAddress;
+
+    const auto nStartAddress = pGameContext.FindCodeNoteStart(nAddress);
+    if (nStartAddress != nAddress && nStartAddress != 0xFFFFFFFF)
+    {
+        sAddress = ra::StringPrintf(L"%s [%s+%d]", ra::ByteAddressToString(nAddress),
+            ra::ByteAddressToString(nStartAddress), nAddress - nStartAddress);
+    }
+    else
+    {
+        sAddress = ra::Widen(ra::ByteAddressToString(nAddress));
+    }
+
     if (bIsIndirect)
-        sAddress.append(" (indirect)");
+        sAddress.append(L" (indirect)");
 
-    const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
-    const auto* pNote = pGameContext.FindCodeNote(nAddress);
+    const auto* pNote = (nStartAddress != 0xFFFFFFFF) ? pGameContext.FindCodeNote(nStartAddress) : nullptr;
     if (!pNote)
         return ra::StringPrintf(L"%s\r\n[No code note]", sAddress);
 
diff --git a/src/ui/viewmodels/TriggerViewModel.cpp b/src/ui/viewmodels/TriggerViewModel.cpp
index 4aa8ebe0..f160ae1a 100644
--- a/src/ui/viewmodels/TriggerViewModel.cpp
+++ b/src/ui/viewmodels/TriggerViewModel.cpp
@@ -143,6 +143,12 @@ void TriggerViewModel::SerializeAppend(std::string& sBuffer) const
     }
 }
 
+void TriggerViewModel::CopyToClipboard()
+{
+    const auto sSerialized = Serialize();
+    ra::services::ServiceLocator::Get<ra::services::IClipboard>().SetText(ra::Widen(sSerialized));
+}
+
 void TriggerViewModel::CopySelectedConditionsToClipboard()
 {
     std::string sSerialized;
@@ -663,7 +669,7 @@ void TriggerViewModel::RemoveGroup()
 void TriggerViewModel::DoFrame()
 {
     auto* pGroup = m_vGroups.GetItemAt(GetSelectedGroupIndex());
-    if (pGroup != nullptr)
+    if (pGroup != nullptr && pGroup->m_pConditionSet)
     {
         m_vConditions.RemoveNotifyTarget(m_pConditionsMonitor);
         m_vConditions.BeginUpdate();
diff --git a/src/ui/viewmodels/TriggerViewModel.hh b/src/ui/viewmodels/TriggerViewModel.hh
index 64d654e9..622056b4 100644
--- a/src/ui/viewmodels/TriggerViewModel.hh
+++ b/src/ui/viewmodels/TriggerViewModel.hh
@@ -86,6 +86,8 @@ public:
     void UpdateFrom(const std::string& sTrigger);
     rc_trigger_t* GetTriggerFromString() const noexcept { return m_pTrigger; }
 
+    void CopyToClipboard();
+
     void RemoveSelectedConditions();
     void CopySelectedConditionsToClipboard();
     void PasteFromClipboard();
diff --git a/src/ui/win32/AssetEditorDialog.cpp b/src/ui/win32/AssetEditorDialog.cpp
index 0e01d890..5efb5a3e 100644
--- a/src/ui/win32/AssetEditorDialog.cpp
+++ b/src/ui/win32/AssetEditorDialog.cpp
@@ -797,6 +797,15 @@ BOOL AssetEditorDialog::OnCommand(WORD nCommand)
 
             return TRUE;
         }
+
+        case IDC_RA_COPY_ALL:
+        {
+            auto* vmAssetEditor = dynamic_cast<AssetEditorViewModel*>(&m_vmWindow);
+            if (vmAssetEditor)
+                vmAssetEditor->Trigger().CopyToClipboard();
+
+            return TRUE;
+        }
     }
 
     return DialogBase::OnCommand(nCommand);
diff --git a/src/ui/win32/AssetListDialog.cpp b/src/ui/win32/AssetListDialog.cpp
index 244fc13d..036893fe 100644
--- a/src/ui/win32/AssetListDialog.cpp
+++ b/src/ui/win32/AssetListDialog.cpp
@@ -74,7 +74,8 @@ AssetListDialog::AssetListDialog(AssetListViewModel& vmAssetList)
       m_bindAssets(vmAssetList),
       m_bindCategories(vmAssetList),
       m_bindSpecialFilters(vmAssetList),
-      m_bindProcessingActive(vmAssetList)
+      m_bindProcessingActive(vmAssetList),
+      m_bindKeepActive(vmAssetList)
 {
     m_bindWindow.SetInitialPosition(RelativePosition::Near, RelativePosition::After, "Achievements");
 
@@ -150,6 +151,7 @@ AssetListDialog::AssetListDialog(AssetListViewModel& vmAssetList)
     m_bindWindow.BindEnabled(IDC_RA_CLONE_ACH, AssetListViewModel::CanCloneProperty);
 
     m_bindProcessingActive.BindCheck(AssetListViewModel::IsProcessingActiveProperty);
+    m_bindKeepActive.BindCheck(AssetListViewModel::KeepActiveProperty);
 
     using namespace ra::bitwise_ops;
     SetAnchor(IDC_RA_CATEGORY, Anchor::Top | Anchor::Left);
@@ -176,6 +178,7 @@ BOOL AssetListDialog::OnInitDialog()
 {
     m_bindAssets.SetControl(*this, IDC_RA_LISTACHIEVEMENTS);
     m_bindProcessingActive.SetControl(*this, IDC_RA_CHKACHPROCESSINGACTIVE);
+    m_bindKeepActive.SetControl(*this, IDC_RA_CHK_ACTIVE);
 
     m_bindCategories.SetControl(*this, IDC_RA_CATEGORY);
     m_bindSpecialFilters.SetControl(*this, IDC_RA_SPECIAL_FILTER);
diff --git a/src/ui/win32/AssetListDialog.hh b/src/ui/win32/AssetListDialog.hh
index 2e11f780..63eb9bbf 100644
--- a/src/ui/win32/AssetListDialog.hh
+++ b/src/ui/win32/AssetListDialog.hh
@@ -46,6 +46,7 @@ private:
     ra::ui::win32::bindings::ComboBoxBinding m_bindCategories;
     ra::ui::win32::bindings::ComboBoxBinding m_bindSpecialFilters;
     ra::ui::win32::bindings::CheckBoxBinding m_bindProcessingActive;
+    ra::ui::win32::bindings::CheckBoxBinding m_bindKeepActive;
 };
 
 } // namespace win32


Test patch:
diff --git a/tests/data/context/GameContext_Tests.cpp b/tests/data/context/GameContext_Tests.cpp
index c517c392..ac154c17 100644
--- a/tests/data/context/GameContext_Tests.cpp
+++ b/tests/data/context/GameContext_Tests.cpp
@@ -2181,7 +2181,7 @@ public:
             response.Notes.emplace_back(ra::api::FetchCodeNotes::Response::CodeNote{ 1100, L"Level", "Author" });
             response.Notes.emplace_back(ra::api::FetchCodeNotes::Response::CodeNote{ 1110, L"[16-bit] Strength", "Author" });
             response.Notes.emplace_back(ra::api::FetchCodeNotes::Response::CodeNote{ 1120, L"[8 byte] Exp", "Author" });
-            response.Notes.emplace_back(ra::api::FetchCodeNotes::Response::CodeNote{ 1200, L"[20 bytes] Items", "Author" });
+            response.Notes.emplace_back(ra::api::FetchCodeNotes::Response::CodeNote{ 1200, L"[20 bytes] Items\r\nMultiline ignored", "Author" });
             return true;
         });
 
@@ -2243,6 +2243,64 @@ public:
         Assert::AreEqual(std::wstring(L""), game.FindCodeNote(1112, MemSize::ThirtyTwoBit));
     }
 
+    TEST_METHOD(TestFindCodeNoteStart)
+    {
+        GameContextHarness game;
+        game.mockServer.HandleRequest<ra::api::FetchGameData>([](const ra::api::FetchGameData::Request&, ra::api::FetchGameData::Response&)
+        {
+            return true;
+        });
+
+        game.mockServer.HandleRequest<ra::api::FetchUserUnlocks>([](const ra::api::FetchUserUnlocks::Request&, ra::api::FetchUserUnlocks::Response&)
+        {
+            return true;
+        });
+
+        game.mockServer.HandleRequest<ra::api::FetchCodeNotes>([](const ra::api::FetchCodeNotes::Request& request, ra::api::FetchCodeNotes::Response& response)
+        {
+            Assert::AreEqual(1U, request.GameId);
+
+            response.Notes.emplace_back(ra::api::FetchCodeNotes::Response::CodeNote{ 1000, L"[32-bit] Location", "Author" });
+            response.Notes.emplace_back(ra::api::FetchCodeNotes::Response::CodeNote{ 1100, L"Level", "Author" });
+            response.Notes.emplace_back(ra::api::FetchCodeNotes::Response::CodeNote{ 1110, L"[16-bit] Strength", "Author" });
+            response.Notes.emplace_back(ra::api::FetchCodeNotes::Response::CodeNote{ 1120, L"[8 byte] Exp", "Author" });
+            response.Notes.emplace_back(ra::api::FetchCodeNotes::Response::CodeNote{ 1200, L"[20 bytes] Items", "Author" });
+            return true;
+        });
+
+        game.LoadGame(1U);
+        game.mockThreadPool.ExecuteNextTask(); // FetchUserUnlocks and FetchCodeNotes are async
+        game.mockThreadPool.ExecuteNextTask();
+
+        Assert::AreEqual(0xFFFFFFFF, game.FindCodeNoteStart(100));
+
+        Assert::AreEqual(0xFFFFFFFF, game.FindCodeNoteStart(999));
+        Assert::AreEqual(1000U, game.FindCodeNoteStart(1000));
+        Assert::AreEqual(1000U, game.FindCodeNoteStart(1001));
+        Assert::AreEqual(1000U, game.FindCodeNoteStart(1002));
+        Assert::AreEqual(1000U, game.FindCodeNoteStart(1003));
+        Assert::AreEqual(0xFFFFFFFF, game.FindCodeNoteStart(1004));
+
+        Assert::AreEqual(0xFFFFFFFF, game.FindCodeNoteStart(1099));
+        Assert::AreEqual(1100U, game.FindCodeNoteStart(1100));
+        Assert::AreEqual(0xFFFFFFFF, game.FindCodeNoteStart(1101));
+
+        Assert::AreEqual(0xFFFFFFFF, game.FindCodeNoteStart(1109));
+        Assert::AreEqual(1110U, game.FindCodeNoteStart(1110));
+        Assert::AreEqual(1110U, game.FindCodeNoteStart(1111));
+        Assert::AreEqual(0xFFFFFFFF, game.FindCodeNoteStart(1112));
+
+        Assert::AreEqual(0xFFFFFFFF, game.FindCodeNoteStart(1119));
+        Assert::AreEqual(1120U, game.FindCodeNoteStart(1120));
+        Assert::AreEqual(1120U, game.FindCodeNoteStart(1127));
+        Assert::AreEqual(0xFFFFFFFF, game.FindCodeNoteStart(1128));
+
+        Assert::AreEqual(0xFFFFFFFF, game.FindCodeNoteStart(1199));
+        Assert::AreEqual(1200U, game.FindCodeNoteStart(1200));
+        Assert::AreEqual(1200U, game.FindCodeNoteStart(1219));
+        Assert::AreEqual(0xFFFFFFFF, game.FindCodeNoteStart(1220));
+    }
+
     TEST_METHOD(TestSetCodeNote)
     {
         GameContextNotifyTarget notifyTarget;
diff --git a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
index 9910c4e5..158fe3cd 100644
--- a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
@@ -9,6 +9,7 @@
 #include "tests\ui\UIAsserts.hh"
 
 #include "tests\mocks\MockAchievementRuntime.hh"
+#include "tests\mocks\MockClock.hh"
 #include "tests\mocks\MockConfiguration.hh"
 #include "tests\mocks\MockDesktop.hh"
 #include "tests\mocks\MockEmulatorContext.hh"
@@ -114,6 +115,7 @@ private:
     {
     public:
         ra::services::mocks::MockAchievementRuntime mockRuntime;
+        ra::services::mocks::MockClock mockClock;
         ra::services::mocks::MockConfiguration mockConfiguration;
         ra::services::mocks::MockThreadPool mockThreadPool;
         ra::services::mocks::MockLocalStorage mockLocalStorage;
@@ -968,6 +970,36 @@ public:
         Assert::AreEqual(AssetState::Active, pItem->GetState());
     }
 
+    TEST_METHOD(TestKeepActive)
+    {
+        AssetListViewModelHarness vmAssetList;
+        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetSpecialFilter(AssetListViewModel::SpecialFilter::Active);
+
+        vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Ach1");
+        Assert::AreEqual({ 1U }, vmAssetList.mockGameContext.Assets().Count());
+        auto* pAsset = dynamic_cast<ra::data::models::AchievementModel*>(vmAssetList.mockGameContext.Assets().GetItemAt(0));
+        Expects(pAsset != nullptr);
+        pAsset->SetState(AssetState::Active);
+        Assert::AreEqual(AssetState::Active, pAsset->GetState());
+        Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
+
+        vmAssetList.SetKeepActive(true);
+        pAsset->SetState(AssetState::Triggered);
+        Assert::AreEqual(AssetState::Waiting, pAsset->GetState());
+        Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count()); // should remain visible
+        Assert::IsTrue(vmAssetList.mockClock.Now() == pAsset->GetUnlockTime()); // should be marked as unlocked
+
+        pAsset->SetState(AssetState::Active);
+        Assert::AreEqual(AssetState::Active, pAsset->GetState());
+        Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
+
+        vmAssetList.SetKeepActive(false);
+        pAsset->SetState(AssetState::Triggered);
+        Assert::AreEqual(AssetState::Triggered, pAsset->GetState());
+        Assert::AreEqual({ 0U }, vmAssetList.FilteredAssets().Count());
+    }
+
     TEST_METHOD(TestUpdateButtonsNoGame)
     {
         AssetListViewModelHarness vmAssetList;
diff --git a/tests/ui/viewmodels/TriggerConditionViewModel_Tests.cpp b/tests/ui/viewmodels/TriggerConditionViewModel_Tests.cpp
index 7f62198a..02bbd8bd 100644
--- a/tests/ui/viewmodels/TriggerConditionViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/TriggerConditionViewModel_Tests.cpp
@@ -392,6 +392,18 @@ public:
         Assert::AreEqual(std::wstring(L"0x0009\r\nThis is another note."), condition.GetTooltip(TriggerConditionViewModel::TargetValueProperty));
     }
 
+    TEST_METHOD(TestTooltipAddressMultiByteNote)
+    {
+        TriggerConditionViewModelHarness condition;
+        condition.mockGameContext.SetCodeNote({ 8U }, L"[8 bytes] This is a note.");
+
+        condition.Parse("0xH0008=3");
+        Assert::AreEqual(std::wstring(L"0x0008\r\n[8 bytes] This is a note."), condition.GetTooltip(TriggerConditionViewModel::SourceValueProperty));
+
+        condition.Parse("0xH000C=3");
+        Assert::AreEqual(std::wstring(L"0x000c [0x0008+4]\r\n[8 bytes] This is a note."), condition.GetTooltip(TriggerConditionViewModel::SourceValueProperty));
+    }
+
     TEST_METHOD(TestTooltipValueDecimal)
     {
         TriggerConditionViewModelHarness condition;
@@ -500,6 +512,22 @@ public:
         Assert::AreEqual(std::wstring(L"0x0007 (indirect)\r\n[No code note]"), pCondition->GetTooltip(TriggerConditionViewModel::TargetValueProperty));
     }
 
+    TEST_METHOD(TestTooltipIndirectAddressMultiByteNote)
+    {
+        IndirectAddressTriggerViewModelHarness vmTrigger;
+        vmTrigger.Parse("I:0xH0001_0xH0002=0xH0004");
+        vmTrigger.mockConfiguration.SetFeatureEnabled(ra::services::Feature::PreferDecimal, true);
+        vmTrigger.mockGameContext.SetCodeNote({ 4U }, L"[8 bytes] This is a note.");
+
+        const auto* pCondition = vmTrigger.Conditions().GetItemAt(1);
+        Expects(pCondition != nullptr);
+        Assert::IsTrue(pCondition->IsIndirect());
+
+        // $0001 = 1, 1+2 = $0003, 1+4 = $0005
+        Assert::AreEqual(std::wstring(L"0x0003 (indirect)\r\n[No code note]"), pCondition->GetTooltip(TriggerConditionViewModel::SourceValueProperty));
+        Assert::AreEqual(std::wstring(L"0x0005 [0x0004+1] (indirect)\r\n[8 bytes] This is a note."), pCondition->GetTooltip(TriggerConditionViewModel::TargetValueProperty));
+    }
+
     TEST_METHOD(TestTooltipIndirectAddressMultiplyNoCodeNote)
     {
         IndirectAddressTriggerViewModelHarness vmTrigger;
diff --git a/tests/ui/viewmodels/TriggerViewModel_Tests.cpp b/tests/ui/viewmodels/TriggerViewModel_Tests.cpp
index 5bf85f2d..1a072405 100644
--- a/tests/ui/viewmodels/TriggerViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/TriggerViewModel_Tests.cpp
@@ -279,6 +279,25 @@ public:
         Assert::AreEqual(std::string("0xH1234=1_0xH0000=0"), vmTrigger.Serialize());
     }
 
+    TEST_METHOD(TestCopyToClipboard)
+    {
+        TriggerViewModelHarness vmTrigger;
+        Parse(vmTrigger, "0xH1234=16_0xL65FF=11.1.SR:0xT3333=1S0xW5555=16");
+
+        Assert::AreEqual({ 2U }, vmTrigger.Conditions().Count());
+        Assert::AreEqual({ 3U }, vmTrigger.Groups().Count());
+        vmTrigger.CopyToClipboard();
+        Assert::AreEqual(std::wstring(L"0xH1234=16_0xL65ff=11.1.SR:0xT3333=1S0xW5555=16"), vmTrigger.mockClipboard.GetText());
+
+        vmTrigger.Conditions().GetItemAt(1)->SetSelected(true);
+        vmTrigger.CopyToClipboard();
+        Assert::AreEqual(std::wstring(L"0xH1234=16_0xL65ff=11.1.SR:0xT3333=1S0xW5555=16"), vmTrigger.mockClipboard.GetText());
+
+        vmTrigger.Groups().GetItemAt(2)->SetSelected(true);
+        vmTrigger.CopyToClipboard();
+        Assert::AreEqual(std::wstring(L"0xH1234=16_0xL65ff=11.1.SR:0xT3333=1S0xW5555=16"), vmTrigger.mockClipboard.GetText());
+    }
+
     TEST_METHOD(TestCopySelectedConditionsToClipboard)
     {
         TriggerViewModelHarness vmTrigger;