Repository: RetroAchievements/RAIntegration (cpp)

Issue:
Wiped local code notes not processed upon re-opening.
If I go to 'erase' a code note by clearing its contents, but do not publish the erasure, an entry is made in the user text for the blank local code note.  If I re-open the emulator/re-load the game, these blank local notes are ignored.

Example:

I created two notes "dummy note" in Game 355. One at Address 0x21ffd and one at 0x21fff, which results in this in the User-355.txt:
1.2.0.0
Legend of Zelda, The: A Link to the Past
N0:0x021ffd:""
N0:0x021fff:""

If I reload the game or if I reload RALibretro and either go to the address in memory inspector or code notes window, those notes show up as "dummy note" rather than a blank local note.

Pull request description:
fix race condition integrating local code notes
fixes #997 

There was a race condition where the main thread was trying to merge local code notes into the code notes list while a background thread was populating the list from the server response. If the code notes window or memory inspector was open, the background thread would block, waiting for the UI thread to become available so the UI could be updated. This would result in the server notes response processing thread waiting indefinitely while the game loaded on the main thread, and all local notes read while loading the game on the main thread would be overwritten by the server notes when the background thread was eventually allowed to continue.

Solution: Keep track of when server notes are being fetched and queue up changes until the fetch is complete, then apply the changes.

Patch:
diff --git a/src/data/models/CapturedTriggerHits.cpp b/src/data/models/CapturedTriggerHits.cpp
index ea149c41..2613ec48 100644
--- a/src/data/models/CapturedTriggerHits.cpp
+++ b/src/data/models/CapturedTriggerHits.cpp
@@ -31,11 +31,23 @@ static void CaptureHitCounts(const rc_trigger_t* pTrigger, std::vector<unsigned>
 
 static void RestoreCondSetHitCounts(rc_condset_t* pCondSet, const std::vector<unsigned>& vCapturedHitCounts, gsl::index& nIndex)
 {
-    rc_condition_t* pCondition = pCondSet->conditions;
-    while (pCondition != nullptr)
+    if (vCapturedHitCounts.empty())
     {
-        pCondition->current_hits = vCapturedHitCounts.at(nIndex++);
-        pCondition = pCondition->next;
+        rc_condition_t* pCondition = pCondSet->conditions;
+        while (pCondition != nullptr)
+        {
+            pCondition->current_hits = 0;
+            pCondition = pCondition->next;
+        }
+    }
+    else
+    {
+        rc_condition_t* pCondition = pCondSet->conditions;
+        while (pCondition != nullptr)
+        {
+            pCondition->current_hits = vCapturedHitCounts.at(nIndex++);
+            pCondition = pCondition->next;
+        }
     }
 }
 
@@ -72,8 +84,8 @@ bool CapturedTriggerHits::Restore(rc_trigger_t* pTrigger, const std::string& sTr
     if (sCapturedHitsMD5 != m_sCapturedHitsMD5)
         return false;
 
-    if (!m_vCapturedHitCounts.empty())
-        RestoreHitCounts(pTrigger, m_vCapturedHitCounts);
+    RestoreHitCounts(pTrigger, m_vCapturedHitCounts);
+    pTrigger->has_hits = !m_vCapturedHitCounts.empty();
 
     return true;
 }
@@ -84,8 +96,8 @@ void CapturedTriggerHits::Capture(const rc_value_t* pValue, const std::string& s
 
     m_sCapturedHitsMD5 = RAGenerateMD5(sValue);
 
-    if (pValue->conditions)
-        CaptureCondSetHitCounts(pValue->conditions, m_vCapturedHitCounts);
+    for (const auto* conditions = pValue->conditions; conditions; conditions = conditions->next)
+        CaptureCondSetHitCounts(conditions, m_vCapturedHitCounts);
 }
 
 bool CapturedTriggerHits::Restore(rc_value_t* pValue, const std::string& sValue) const
@@ -97,11 +109,9 @@ bool CapturedTriggerHits::Restore(rc_value_t* pValue, const std::string& sValue)
     if (sCapturedHitsMD5 != m_sCapturedHitsMD5)
         return false;
 
-    if (!m_vCapturedHitCounts.empty())
-    {
-        gsl::index nIndex = 0;
-        RestoreCondSetHitCounts(pValue->conditions, m_vCapturedHitCounts, nIndex);
-    }
+    gsl::index nIndex = 0;
+    for (auto* conditions = pValue->conditions; conditions; conditions = conditions->next)
+        RestoreCondSetHitCounts(conditions, m_vCapturedHitCounts, nIndex);
 
     return true;
 }
diff --git a/src/data/models/CodeNotesModel.cpp b/src/data/models/CodeNotesModel.cpp
index 5c493cd7..d0fab892 100644
--- a/src/data/models/CodeNotesModel.cpp
+++ b/src/data/models/CodeNotesModel.cpp
@@ -39,6 +39,11 @@ void CodeNotesModel::Refresh(unsigned int nGameId, CodeNoteChangedFunction fCode
     if (callback == nullptr) // unit test workaround to avoid server call
         return;
 
+    {
+        std::unique_lock<std::mutex> lock(m_oMutex);
+        m_bRefreshing = true;
+    }
+
     ra::api::FetchCodeNotes::Request request;
     request.GameId = nGameId;
     request.CallAsync([this, callback](const ra::api::FetchCodeNotes::Response& response)
@@ -67,6 +72,16 @@ void CodeNotesModel::Refresh(unsigned int nGameId, CodeNoteChangedFunction fCode
             }
         }
 
+        std::map<ra::ByteAddress, std::wstring> mPendingCodeNotes;
+        {
+            std::unique_lock<std::mutex> lock(m_oMutex);
+            mPendingCodeNotes.swap(m_mPendingCodeNotes);
+            m_bRefreshing = false;
+        }
+
+        for (const auto pNote : mPendingCodeNotes)
+            SetCodeNote(pNote.first, pNote.second);
+
         callback();
     });
 }
@@ -609,6 +624,12 @@ void CodeNotesModel::SetCodeNote(ra::ByteAddress nAddress, const std::wstring& s
     {
         std::unique_lock<std::mutex> lock(m_oMutex);
 
+        if (m_bRefreshing)
+        {
+            m_mPendingCodeNotes[nAddress] = sNote;
+            return;
+        }
+
         const auto pIter = m_mCodeNotes.find(nAddress);
         if (pIter != m_mCodeNotes.end())
         {
@@ -985,6 +1006,7 @@ bool CodeNotesModel::Deserialize(ra::Tokenizer& pTokenizer)
     if (!ReadQuoted(pTokenizer, sNote))
         return false;
 
+    ra::NormalizeLineEndings(sNote);
     SetCodeNote(nAddress, sNote);
     return true;
 }
diff --git a/src/data/models/CodeNotesModel.hh b/src/data/models/CodeNotesModel.hh
index 3283e5b8..a65e4c6c 100644
--- a/src/data/models/CodeNotesModel.hh
+++ b/src/data/models/CodeNotesModel.hh
@@ -220,11 +220,14 @@ protected:
     std::map<ra::ByteAddress, CodeNote> m_mCodeNotes;
     std::map<ra::ByteAddress, std::pair<std::string, std::wstring>> m_mOriginalCodeNotes;
 
+    std::map<ra::ByteAddress, std::wstring> m_mPendingCodeNotes;
+
     const CodeNote* FindCodeNoteInternal(ra::ByteAddress nAddress) const;
     void EnumerateCodeNotes(std::function<bool(ra::ByteAddress nAddress, const CodeNote& pCodeNote)> callback, bool bIncludeDerived) const;
 
     unsigned int m_nGameId = 0;
     bool m_bHasPointers = false;
+    bool m_bRefreshing = false;
 
     CodeNoteChangedFunction m_fCodeNoteChanged;
 
diff --git a/src/ui/viewmodels/MemoryViewerViewModel.cpp b/src/ui/viewmodels/MemoryViewerViewModel.cpp
index db4e7028..8f3a1439 100644
--- a/src/ui/viewmodels/MemoryViewerViewModel.cpp
+++ b/src/ui/viewmodels/MemoryViewerViewModel.cpp
@@ -205,11 +205,13 @@ void MemoryViewerViewModel::UpdateColors()
     if (pCodeNotes != nullptr)
     {
         const auto nStopAddress = nFirstAddress + nVisibleLines * 16;
-        pCodeNotes->EnumerateCodeNotes([nFirstAddress, nStopAddress, this](ra::ByteAddress nAddress, unsigned nBytes, const std::wstring&) {
-            if (nAddress + nBytes <= nFirstAddress)
+        pCodeNotes->EnumerateCodeNotes([nFirstAddress, nStopAddress, this](ra::ByteAddress nAddress, unsigned nBytes, const std::wstring& sNote) {
+            if (nAddress + nBytes <= nFirstAddress) // not to viewing window yet
                 return true;
-            if (nAddress >= nStopAddress)
+            if (nAddress >= nStopAddress) // past viewing window
                 return false;
+            if (sNote.empty()) // ignore deleted notes
+                return true;
 
             uint8_t* pOffset = m_pColor;
             Expects(pOffset != nullptr);
diff --git a/src/ui/viewmodels/ProgressTrackerViewModel.cpp b/src/ui/viewmodels/ProgressTrackerViewModel.cpp
index af617ebe..2a611ea1 100644
--- a/src/ui/viewmodels/ProgressTrackerViewModel.cpp
+++ b/src/ui/viewmodels/ProgressTrackerViewModel.cpp
@@ -39,7 +39,7 @@ bool ProgressTrackerViewModel::UpdateRenderImage(double fElapsed)
 
     bool bUpdated = false;
 
-    if (m_pSurface == nullptr)
+    if (m_pSurface == nullptr || m_bRegenerate)
     {
         const auto& pTheme = ra::services::ServiceLocator::Get<ra::ui::OverlayTheme>();
         const auto nShadowOffset = pTheme.ShadowOffset();
@@ -79,6 +79,7 @@ bool ProgressTrackerViewModel::UpdateRenderImage(double fElapsed)
         pSurface->WriteText(nX + 2, nY + 2, nFontSubtitle, pOverlayTheme.ColorTextShadow(), m_sProgress);
         pSurface->WriteText(nX, nY, nFontSubtitle, pOverlayTheme.ColorDetail(), m_sProgress);
 
+        m_bRegenerate = false;
         m_pSurface = std::move(pSurface);
 
         bUpdated = true;
diff --git a/src/ui/viewmodels/ProgressTrackerViewModel.hh b/src/ui/viewmodels/ProgressTrackerViewModel.hh
index f7fc28b3..77cd65ba 100644
--- a/src/ui/viewmodels/ProgressTrackerViewModel.hh
+++ b/src/ui/viewmodels/ProgressTrackerViewModel.hh
@@ -19,7 +19,7 @@ public:
     void SetImage(ra::ui::ImageType nImageType, const std::string& sImageName)
     {
         m_hImage.ChangeReference(nImageType, sImageName);
-        m_pSurface.reset();
+        m_bRegenerate = true;
         SetRenderLocationY(-1); // force redraw when position calculated
     }
 
@@ -54,6 +54,7 @@ public:
 private:
     ra::ui::ImageReference m_hImage;
     std::wstring m_sProgress;
+    bool m_bRegenerate = false;
 
     double m_fAnimationProgress = TOTAL_ANIMATION_TIME;
 
diff --git a/src/ui/viewmodels/TriggerViewModel.cpp b/src/ui/viewmodels/TriggerViewModel.cpp
index e8d00c38..22a1029f 100644
--- a/src/ui/viewmodels/TriggerViewModel.cpp
+++ b/src/ui/viewmodels/TriggerViewModel.cpp
@@ -865,6 +865,9 @@ void TriggerViewModel::UpdateConditions(const GroupViewModel* pGroup)
                 m_bHasHitChain |= (pCondition->type == RC_CONDITION_ADD_HITS || pCondition->type == RC_CONDITION_SUB_HITS);
             }
         }
+
+        if (m_bHasHitChain)
+            UpdateTotalHits();
     }
 
     for (gsl::index nScan = m_vConditions.Count() - 1; nScan >= nIndex; --nScan)
@@ -874,6 +877,47 @@ void TriggerViewModel::UpdateConditions(const GroupViewModel* pGroup)
     m_vConditions.AddNotifyTarget(m_pConditionsMonitor);
 }
 
+void TriggerViewModel::UpdateTotalHits()
+{
+    unsigned int nHits = 0;
+    bool bIsHitsChain = false;
+
+    for (size_t nIndex = 0; nIndex < m_vConditions.Count(); ++nIndex)
+    {
+        auto* vmCondition = m_vConditions.GetItemAt(nIndex);
+        switch (vmCondition->GetType())
+        {
+            case ra::ui::viewmodels::TriggerConditionType::AddHits:
+                bIsHitsChain = true;
+                nHits += vmCondition->GetCurrentHits();
+                break;
+
+            case ra::ui::viewmodels::TriggerConditionType::SubHits:
+                bIsHitsChain = true;
+                nHits -= vmCondition->GetCurrentHits();
+                break;
+
+            case ra::ui::viewmodels::TriggerConditionType::AddAddress:
+            case ra::ui::viewmodels::TriggerConditionType::AddSource:
+            case ra::ui::viewmodels::TriggerConditionType::SubSource:
+            case ra::ui::viewmodels::TriggerConditionType::AndNext:
+            case ra::ui::viewmodels::TriggerConditionType::OrNext:
+            case ra::ui::viewmodels::TriggerConditionType::ResetNextIf:
+                break;
+
+            default:
+                if (bIsHitsChain)
+                {
+                    nHits += vmCondition->GetCurrentHits();
+                    vmCondition->SetTotalHits(nHits);
+                    bIsHitsChain = false;
+                }
+                nHits = 0;
+                break;
+        }
+    }
+}
+
 void TriggerViewModel::AddGroup()
 {
     const int nGroups = gsl::narrow_cast<int>(m_vGroups.Count());
@@ -952,9 +996,6 @@ void TriggerViewModel::DoFrame()
         m_vConditions.RemoveNotifyTarget(m_pConditionsMonitor);
         m_vConditions.BeginUpdate();
 
-        unsigned int nHits = 0;
-        bool bIsHitsChain = false;
-
         gsl::index nConditionIndex = 0;
         rc_condition_t* pCondition = pGroup->m_pConditionSet->conditions;
         for (; pCondition != nullptr; pCondition = pCondition->next)
@@ -968,42 +1009,11 @@ void TriggerViewModel::DoFrame()
             }
 
             vmCondition->SetCurrentHits(pCondition->current_hits);
-
-            if (m_bHasHitChain)
-            {
-                switch (vmCondition->GetType())
-                {
-                    case ra::ui::viewmodels::TriggerConditionType::AddHits:
-                        bIsHitsChain = true;
-                        nHits += vmCondition->GetCurrentHits();
-                        break;
-
-                    case ra::ui::viewmodels::TriggerConditionType::SubHits:
-                        bIsHitsChain = true;
-                        nHits -= vmCondition->GetCurrentHits();
-                        break;
-
-                    case ra::ui::viewmodels::TriggerConditionType::AddAddress:
-                    case ra::ui::viewmodels::TriggerConditionType::AddSource:
-                    case ra::ui::viewmodels::TriggerConditionType::SubSource:
-                    case ra::ui::viewmodels::TriggerConditionType::AndNext:
-                    case ra::ui::viewmodels::TriggerConditionType::OrNext:
-                    case ra::ui::viewmodels::TriggerConditionType::ResetNextIf:
-                        break;
-
-                    default:
-                        if (bIsHitsChain)
-                        {
-                            nHits += vmCondition->GetCurrentHits();
-                            vmCondition->SetTotalHits(nHits);
-                            bIsHitsChain = false;
-                        }
-                        nHits = 0;
-                        break;
-                }
-            }
         }
 
+        if (m_bHasHitChain)
+            UpdateTotalHits();
+
         m_vConditions.EndUpdate();
         m_vConditions.AddNotifyTarget(m_pConditionsMonitor);
     }
diff --git a/src/ui/viewmodels/TriggerViewModel.hh b/src/ui/viewmodels/TriggerViewModel.hh
index e2a0328a..9b00b8bb 100644
--- a/src/ui/viewmodels/TriggerViewModel.hh
+++ b/src/ui/viewmodels/TriggerViewModel.hh
@@ -163,6 +163,7 @@ private:
     void InitializeGroups(const rc_trigger_t& pTrigger);
     void UpdateGroups(const rc_trigger_t& pTrigger);
     void UpdateConditions(const GroupViewModel* pGroup);
+    void UpdateTotalHits();
 
     void DeselectAllConditions();
     void UpdateIndicesAndEnsureSelectionVisible();


Test patch:
diff --git a/tests/RA_Integration.Tests.vcxproj b/tests/RA_Integration.Tests.vcxproj
index cd3e9d5c..cac29901 100644
--- a/tests/RA_Integration.Tests.vcxproj
+++ b/tests/RA_Integration.Tests.vcxproj
@@ -375,6 +375,7 @@
     <ClCompile Include="data\ModelProperty_Tests.cpp" />
     <ClCompile Include="data\models\AchievementModel_Tests.cpp" />
     <ClCompile Include="data\models\AssetModelBase_Tests.cpp" />
+    <ClCompile Include="data\models\CapturedTriggerHits_Tests.cpp" />
     <ClCompile Include="data\models\CodeNotesModel_Tests.cpp" />
     <ClCompile Include="data\models\LeaderboardModel_Tests.cpp" />
     <ClCompile Include="data\models\LocalBadgesModel_Tests.cpp" />
diff --git a/tests/RA_Integration.Tests.vcxproj.filters b/tests/RA_Integration.Tests.vcxproj.filters
index c2990fd4..9e1ec7ae 100644
--- a/tests/RA_Integration.Tests.vcxproj.filters
+++ b/tests/RA_Integration.Tests.vcxproj.filters
@@ -459,6 +459,9 @@
     <ClCompile Include="..\src\ui\viewmodels\ProgressTrackerViewModel.cpp">
       <Filter>Code</Filter>
     </ClCompile>
+    <ClCompile Include="data\models\CapturedTriggerHits_Tests.cpp">
+      <Filter>Tests\Data\Models</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <None Include="base.props" />
diff --git a/tests/data/models/CapturedTriggerHits_Tests.cpp b/tests/data/models/CapturedTriggerHits_Tests.cpp
new file mode 100644
index 00000000..dd82d4f4
--- /dev/null
+++ b/tests/data/models/CapturedTriggerHits_Tests.cpp
@@ -0,0 +1,224 @@
+#include "CppUnitTest.h"
+
+#include "data\models\CapturedTriggerHits.hh"
+
+#include "tests\mocks\MockConsoleContext.hh"
+#include "tests\mocks\MockEmulatorContext.hh"
+
+using namespace Microsoft::VisualStudio::CppUnitTestFramework;
+
+namespace ra {
+namespace data {
+namespace models {
+namespace tests {
+
+TEST_CLASS(CapturedTriggerHits_Tests)
+{
+public:
+    TEST_METHOD(TestRestoreTrigger)
+    {
+        const std::string trigger_definition = "0xH1234=1_0xH2345=2";
+        char buffer[512];
+        rc_trigger_t* trigger = rc_parse_trigger(buffer, trigger_definition.c_str(), NULL, 0);
+        CapturedTriggerHits hits;
+
+        trigger->requirement->conditions->current_hits = 6U;
+        trigger->requirement->conditions->next->current_hits = 7U;
+        trigger->has_hits = 1;
+
+        hits.Capture(trigger, trigger_definition);
+
+        trigger->requirement->conditions->current_hits = 8U;
+        trigger->requirement->conditions->next->current_hits = 15U;
+
+        Assert::IsTrue(hits.Restore(trigger, trigger_definition));
+
+        Assert::AreEqual(6U, trigger->requirement->conditions->current_hits);
+        Assert::AreEqual(7U, trigger->requirement->conditions->next->current_hits);
+        Assert::AreEqual({1}, trigger->has_hits);
+    }
+
+    TEST_METHOD(TestRestoreTriggerAlts)
+    {
+        const std::string trigger_definition = "0xH1234=1S0xH2345=2S0xH3456=3";
+        char buffer[512];
+        rc_trigger_t* trigger = rc_parse_trigger(buffer, trigger_definition.c_str(), NULL, 0);
+        CapturedTriggerHits hits;
+
+        trigger->requirement->conditions->current_hits = 6U;
+        trigger->alternative->conditions->current_hits = 7U;
+        trigger->alternative->next->conditions->current_hits = 8U;
+        trigger->has_hits = 1;
+
+        hits.Capture(trigger, trigger_definition);
+
+        trigger->requirement->conditions->current_hits = 8U;
+        trigger->alternative->conditions->current_hits = 10U;
+        trigger->alternative->next->conditions->current_hits = 15U;
+
+        Assert::IsTrue(hits.Restore(trigger, trigger_definition));
+
+        Assert::AreEqual(6U, trigger->requirement->conditions->current_hits);
+        Assert::AreEqual(7U, trigger->alternative->conditions->current_hits);
+        Assert::AreEqual(8U, trigger->alternative->next->conditions->current_hits);
+        Assert::AreEqual({1}, trigger->has_hits);
+    }
+
+    TEST_METHOD(TestRestoreTriggerUncaptured)
+    {
+        const std::string trigger_definition = "0xH1234=1_0xH2345=2";
+        char buffer[512];
+        rc_trigger_t* trigger = rc_parse_trigger(buffer, trigger_definition.c_str(), NULL, 0);
+        CapturedTriggerHits hits;
+
+        trigger->requirement->conditions->current_hits = 6U;
+        trigger->requirement->conditions->next->current_hits = 7U;
+        trigger->has_hits = 1;
+
+        Assert::IsFalse(hits.Restore(trigger, trigger_definition));
+
+        Assert::AreEqual(6U, trigger->requirement->conditions->current_hits);
+        Assert::AreEqual(7U, trigger->requirement->conditions->next->current_hits);
+        Assert::AreEqual({1}, trigger->has_hits);
+    }
+
+    TEST_METHOD(TestRestoreTriggerAltered)
+    {
+        const std::string trigger_definition = "0xH1234=1_0xH2345=2";
+        const std::string trigger_definition2 = "0xH1234=1_0xH2345=3";
+        char buffer[512];
+        rc_trigger_t* trigger = rc_parse_trigger(buffer, trigger_definition.c_str(), NULL, 0);
+        CapturedTriggerHits hits;
+
+        trigger->requirement->conditions->current_hits = 6U;
+        trigger->requirement->conditions->next->current_hits = 7U;
+        trigger->has_hits = 1;
+
+        hits.Capture(trigger, trigger_definition);
+
+        trigger->requirement->conditions->current_hits = 8U;
+        trigger->requirement->conditions->next->current_hits = 10U;
+
+        Assert::IsFalse(hits.Restore(trigger, trigger_definition2));
+
+        Assert::AreEqual(8U, trigger->requirement->conditions->current_hits);
+        Assert::AreEqual(10U, trigger->requirement->conditions->next->current_hits);
+        Assert::AreEqual({1}, trigger->has_hits);
+    }
+
+    TEST_METHOD(TestRestoreTriggerZeroes)
+    {
+        const std::string trigger_definition = "0xH1234=1_0xH2345=2";
+        char buffer[512];
+        rc_trigger_t* trigger = rc_parse_trigger(buffer, trigger_definition.c_str(), NULL, 0);
+        CapturedTriggerHits hits;
+
+        hits.Capture(trigger, trigger_definition);
+
+        trigger->requirement->conditions->current_hits = 8U;
+        trigger->requirement->conditions->next->current_hits = 15U;
+
+        Assert::IsTrue(hits.Restore(trigger, trigger_definition));
+
+        Assert::AreEqual(0U, trigger->requirement->conditions->current_hits);
+        Assert::AreEqual(0U, trigger->requirement->conditions->next->current_hits);
+        Assert::AreEqual({0}, trigger->has_hits);
+    }
+
+    TEST_METHOD(TestRestoreValue)
+    {
+        const std::string value_definition = "M:0xH1234=1";
+        char buffer[512];
+        rc_value_t* value = rc_parse_value(buffer, value_definition.c_str(), NULL, 0);
+        CapturedTriggerHits hits;
+
+        value->conditions->conditions->current_hits = 6U;
+
+        hits.Capture(value, value_definition);
+
+        value->conditions->conditions->current_hits = 8U;
+
+        Assert::IsTrue(hits.Restore(value, value_definition));
+
+        Assert::AreEqual(6U, value->conditions->conditions->current_hits);
+    }
+
+    TEST_METHOD(TestRestoreValueAlts)
+    {
+        const std::string value_definition = "M:0xH1234=1$M:0xH2345=2$M:0xH3456=3";
+        char buffer[512];
+        rc_value_t* value = rc_parse_value(buffer, value_definition.c_str(), NULL, 0);
+        CapturedTriggerHits hits;
+
+        value->conditions->conditions->current_hits = 6U;
+        value->conditions->next->conditions->current_hits = 7U;
+        value->conditions->next->next->conditions->current_hits = 8U;
+
+        hits.Capture(value, value_definition);
+
+        value->conditions->conditions->current_hits = 8U;
+        value->conditions->next->conditions->current_hits = 10U;
+        value->conditions->next->next->conditions->current_hits = 15U;
+
+        Assert::IsTrue(hits.Restore(value, value_definition));
+
+        Assert::AreEqual(6U, value->conditions->conditions->current_hits);
+        Assert::AreEqual(7U, value->conditions->next->conditions->current_hits);
+        Assert::AreEqual(8U, value->conditions->next->next->conditions->current_hits);
+    }
+
+    TEST_METHOD(TestRestoreValueUncaptured)
+    {
+        const std::string value_definition = "M:0xH1234=1";
+        char buffer[512];
+        rc_value_t* value = rc_parse_value(buffer, value_definition.c_str(), NULL, 0);
+        CapturedTriggerHits hits;
+
+        value->conditions->conditions->current_hits = 6U;
+
+        Assert::IsFalse(hits.Restore(value, value_definition));
+
+        Assert::AreEqual(6U, value->conditions->conditions->current_hits);
+    }
+
+    TEST_METHOD(TestRestoreValueAltered)
+    {
+        const std::string value_definition = "M:0xH1234=1";
+        const std::string value_definition2 = "M:0xH1234=2";
+        char buffer[512];
+        rc_value_t* value = rc_parse_value(buffer, value_definition.c_str(), NULL, 0);
+        CapturedTriggerHits hits;
+
+        value->conditions->conditions->current_hits = 6U;
+
+        hits.Capture(value, value_definition);
+
+        value->conditions->conditions->current_hits = 8U;
+
+        Assert::IsFalse(hits.Restore(value, value_definition2));
+
+        Assert::AreEqual(8U, value->conditions->conditions->current_hits);
+    }
+
+    TEST_METHOD(TestRestoreValueZeroes)
+    {
+        const std::string value_definition = "M:0xH1234=1";
+        char buffer[512];
+        rc_value_t* value = rc_parse_value(buffer, value_definition.c_str(), NULL, 0);
+        CapturedTriggerHits hits;
+
+        hits.Capture(value, value_definition);
+
+        value->conditions->conditions->current_hits = 8U;
+
+        Assert::IsTrue(hits.Restore(value, value_definition));
+
+        Assert::AreEqual(0U, value->conditions->conditions->current_hits);
+    }
+
+};
+
+} // namespace tests
+} // namespace models
+} // namespace data
+} // namespace ra
diff --git a/tests/data/models/CodeNotesModel_Tests.cpp b/tests/data/models/CodeNotesModel_Tests.cpp
index bf3eca46..0d41b8fe 100644
--- a/tests/data/models/CodeNotesModel_Tests.cpp
+++ b/tests/data/models/CodeNotesModel_Tests.cpp
@@ -1059,7 +1059,7 @@ public:
 
         Assert::IsTrue(notes.Deserialize(pTokenizer));
         Assert::AreEqual(AssetChanges::Unpublished, notes.GetChanges());
-        notes.AssertNote(0x1234, std::wstring(L"16-bit pointer\n+2:\t\"a\"\n+4:\tb\n"));
+        notes.AssertNote(0x1234, std::wstring(L"16-bit pointer\r\n+2:\t\"a\"\r\n+4:\tb\r\n"));
     }
 
     TEST_METHOD(TestDeserializeUnchanged)
diff --git a/tests/ui/viewmodels/TriggerViewModel_Tests.cpp b/tests/ui/viewmodels/TriggerViewModel_Tests.cpp
index 1960eca8..f2849e38 100644
--- a/tests/ui/viewmodels/TriggerViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/TriggerViewModel_Tests.cpp
@@ -1419,6 +1419,28 @@ public:
         Assert::AreEqual(sTooltip, vmTrigger.GetHitChainTooltip(5)); // non hit modifier
         Assert::AreEqual(sTooltip, vmTrigger.GetHitChainTooltip(6)); // end of hits-chain
     }
+
+    TEST_METHOD(TestAltHitChain)
+    {
+        TriggerViewModelHarness vmTrigger;
+        Parse(vmTrigger, "1=1SC:0xH1234=1_0=1.10.SC:0xH2345=1_0=1.10.");
+        Assert::AreEqual({3U}, vmTrigger.Groups().Count());
+
+        vmTrigger.Groups().GetItemAt(1)->m_pConditionSet->conditions->current_hits = 2;
+        vmTrigger.Groups().GetItemAt(2)->m_pConditionSet->conditions->current_hits = 3;
+
+        vmTrigger.SetSelectedGroupIndex(2);
+        Assert::AreEqual(3U, vmTrigger.Conditions().GetItemAt(0)->GetCurrentHits());
+        Assert::AreEqual(0U, vmTrigger.Conditions().GetItemAt(0)->GetTotalHits());
+        Assert::AreEqual(0U, vmTrigger.Conditions().GetItemAt(1)->GetCurrentHits());
+        Assert::AreEqual(3U, vmTrigger.Conditions().GetItemAt(1)->GetTotalHits());
+
+        vmTrigger.SetSelectedGroupIndex(1);
+        Assert::AreEqual(2U, vmTrigger.Conditions().GetItemAt(0)->GetCurrentHits());
+        Assert::AreEqual(0U, vmTrigger.Conditions().GetItemAt(0)->GetTotalHits());
+        Assert::AreEqual(0U, vmTrigger.Conditions().GetItemAt(1)->GetCurrentHits());
+        Assert::AreEqual(2U, vmTrigger.Conditions().GetItemAt(1)->GetTotalHits());
+    }
 };
 
 } // namespace tests