Repository: RetroAchievements/RAIntegration
Pull request #799 (2021-09-11 22:03:09)

add option to specify how a measured achievement should track progress
UI for https://github.com/RetroAchievements/rcheevos/pull/152

Adds a checkbox to the achievement editor that specifies whether to serialize the Measured flag as an `M` or `G`.
![image](https://user-images.githubusercontent.com/32680403/132962485-f6054453-6793-41e8-b7ce-ef8b6f29fd0f.png)

The default is unchecked and generates a `M`. `M` indicates the progress should be shown as a raw value:
![image](https://user-images.githubusercontent.com/32680403/132962493-b0c80718-0dc8-4c50-94d1-8f8963b9ae7e.png)

When checked, a `G` is generated, suggesting the percentage should be shown:
![image](https://user-images.githubusercontent.com/32680403/132962509-4ae158d5-9cfb-434f-b93f-1f91f129c69a.png)

It's up to the client to call the [new API](https://github.com/RetroAchievements/rcheevos/wiki/rc_runtime_format_achievement_measured) to honor the flag.

As the behavior for `M` is to show the raw percentage, and `M` is the original Measured flag, all existing achievements will automatically show as raw values after this change. A similar change will be made in RetroArch. Duckstation already shows everything as raw values.


Patch:
diff --git a/src/RA_Resource.h b/src/RA_Resource.h
index 8cb4a1e7..a0aab7d7 100644
--- a/src/RA_Resource.h
+++ b/src/RA_Resource.h
@@ -100,6 +100,7 @@
 #define IDC_RA_MEASURED                 1195
 #define IDC_RA_COPY_ALL                 1196
 #define IDC_RA_CHK_HIGHLIGHTS           1197
+#define IDC_RA_CHK_AS_PERCENT           1198
 #define IDD_RA_MEMORY                   1501
 #define IDD_RA_ACHIEVEMENTS             1502
 #define IDD_RA_ACHIEVEMENTEDITOR        1503
@@ -182,7 +183,7 @@
 #ifndef APSTUDIO_READONLY_SYMBOLS
 #define _APS_NEXT_RESOURCE_VALUE        122
 #define _APS_NEXT_COMMAND_VALUE         40001
-#define _APS_NEXT_CONTROL_VALUE         1198
+#define _APS_NEXT_CONTROL_VALUE         1199
 #define _APS_NEXT_SYMED_VALUE           101
 #endif
 #endif
diff --git a/src/RA_Shared.rc b/src/RA_Shared.rc
index e3e4a3d2..221b9b7f 100644
--- a/src/RA_Shared.rc
+++ b/src/RA_Shared.rc
@@ -124,8 +124,9 @@ BEGIN
     EDITTEXT        IDC_RA_DESCRIPTION,50,21,333,13,ES_AUTOHSCROLL
     LTEXT           "Points:",IDC_RA_LBL_POINTS,4,40,23,8
     EDITTEXT        IDC_RA_POINTS,50,38,42,13,ES_AUTOHSCROLL,WS_EX_RIGHT
-    LTEXT           "Measured:",IDC_RA_LBL_MEASURED,176,40,35,8
-    RTEXT           "0/0",IDC_RA_MEASURED,210,40,52,8
+    LTEXT           "Measured:",IDC_RA_LBL_MEASURED,100,40,35,8
+    RTEXT           "0/0",IDC_RA_MEASURED,134,40,52,8
+    CONTROL         "Track as %",IDC_RA_CHK_AS_PERCENT,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,192,38,48,12
     LTEXT           "Badge:",IDC_RA_LBL_BADGENAME,268,40,24,8
     EDITTEXT        IDC_RA_BADGENAME,293,38,44,13,ES_AUTOHSCROLL,WS_EX_RIGHT
     CONTROL         "",IDC_RA_BADGE_SPIN,"msctls_updown32",UDS_ARROWKEYS,338,38,11,13
diff --git a/src/data/context/GameContext.cpp b/src/data/context/GameContext.cpp
index 7a239004..21c806d5 100644
--- a/src/data/context/GameContext.cpp
+++ b/src/data/context/GameContext.cpp
@@ -844,10 +844,11 @@ void GameContext::RefreshCodeNotes()
 static unsigned int ExtractSize(const std::wstring& sNote)
 {
     bool bIsBytes = false;
+    int nBytesFromBits = 0;
 
     // "Nbit" smallest possible note - and that's just the size annotation
     if (sNote.length() < 4)
-        return 1;
+        return 0;
 
     const size_t nStop = sNote.length() - 3;
     for (size_t nIndex = 1; nIndex <= nStop; ++nIndex)
@@ -859,10 +860,22 @@ static unsigned int ExtractSize(const std::wstring& sNote)
         c = sNote.at(nIndex + 1);
         if (c == L'i' || c == L'I')
         {
+            // already found one bit reference, give it precedence
+            if (nBytesFromBits > 0)
+                continue;
+
             c = sNote.at(nIndex + 2);
             if (c != L't' && c != L'T')
                 continue;
 
+            // match "bits", but not "bite" even if there is a numeric prefix
+            if (nIndex < nStop)
+            {
+                c = sNote.at(nIndex + 3);
+                if (isalpha(c) && c != L's' && c != L'S')
+                    continue;
+            }
+
             // found "bit"
             bIsBytes = false;
         }
@@ -915,22 +928,29 @@ static unsigned int ExtractSize(const std::wstring& sNote)
         // if a number was found, return it
         if (nBytes > 0)
         {
+            // if "bytes" were found, we're done. if bits were found, it might be indicating
+            // the size of individual elements. capture the bit value and keep searching.
             if (bIsBytes)
                 return nBytes;
 
             // convert bits to bytes, rounding up
-            return (nBytes + 7) / 8;
+            nBytesFromBits = (nBytes + 7) / 8;
         }
     }
 
-    // could not find annotation, associate note to single address
-    return 1;
+    // did not find any byte reference, return the bit reference (if found)
+    if (nBytesFromBits > 0)
+        return nBytesFromBits;
+
+    // could not find annotation
+    return 0;
 }
 
 void GameContext::AddCodeNote(ra::ByteAddress nAddress, const std::string& sAuthor, const std::wstring& sNote)
 {
-    const unsigned int nBytes = ExtractSize(sNote);
-    m_mCodeNotes.insert_or_assign(nAddress, CodeNote{ sAuthor, sNote, nBytes });
+    const unsigned int nSize = ExtractSize(sNote);
+    const unsigned int nBytes = (nSize == 0) ? 1 : nSize;
+    m_mCodeNotes.insert_or_assign(nAddress, CodeNote{ sAuthor, sNote, nBytes, nSize });
     OnCodeNoteChanged(nAddress, sNote);
 }
 
diff --git a/src/data/context/GameContext.hh b/src/data/context/GameContext.hh
index 766e16e2..5706770c 100644
--- a/src/data/context/GameContext.hh
+++ b/src/data/context/GameContext.hh
@@ -72,7 +72,13 @@ public:
     /// <summary>
     /// Sets the game play mode.
     /// </summary>
-    void SetMode(Mode nMode) noexcept { m_nMode = nMode; }
+    void SetMode(Mode nMode)
+    {
+        m_nMode = nMode;
+
+        if (m_nGameId != 0)
+            OnActiveGameChanged();
+    }
 
     /// <summary>
     /// Gets the assets for the current game.
@@ -202,7 +208,7 @@ public:
     unsigned FindCodeNoteSize(ra::ByteAddress nAddress) const
     {
         const auto pIter = m_mCodeNotes.find(nAddress);
-        return (pIter == m_mCodeNotes.end()) ? 0 : pIter->second.Bytes;
+        return (pIter == m_mCodeNotes.end()) ? 0 : pIter->second.AnnotatedSize;
     }
 
     /// <summary>
@@ -308,6 +314,7 @@ protected:
         std::string Author;
         std::wstring Note;
         unsigned int Bytes;
+        unsigned int AnnotatedSize;
     };
     std::map<ra::ByteAddress, CodeNote> m_mCodeNotes;
 
diff --git a/src/ui/viewmodels/AssetEditorViewModel.cpp b/src/ui/viewmodels/AssetEditorViewModel.cpp
index 07f107af..8120b393 100644
--- a/src/ui/viewmodels/AssetEditorViewModel.cpp
+++ b/src/ui/viewmodels/AssetEditorViewModel.cpp
@@ -225,7 +225,7 @@ void AssetEditorViewModel::OnValueChanged(const BoolModelProperty::ChangeArgs& a
         else
             m_vmTrigger.UpdateColors(nullptr);
     }
-    if (args.Property == DecimalPreferredProperty)
+    else if (args.Property == DecimalPreferredProperty)
     {
         auto& pConfiguration = ra::services::ServiceLocator::GetMutable<ra::services::IConfiguration>();
         pConfiguration.SetFeatureEnabled(ra::services::Feature::PreferDecimal, args.tNewValue);
diff --git a/src/ui/viewmodels/AssetListViewModel.cpp b/src/ui/viewmodels/AssetListViewModel.cpp
index b1cf2b66..eb12846b 100644
--- a/src/ui/viewmodels/AssetListViewModel.cpp
+++ b/src/ui/viewmodels/AssetListViewModel.cpp
@@ -573,6 +573,8 @@ void AssetListViewModel::DoUpdateButtons()
     bool bHasModified = false;
     bool bHasUnpublished = false;
     bool bHasUnofficial = false;
+    bool bHasCore = false;
+    bool bHasLocal = false;
     const bool bHardcore = _RA_HardcoreModeIsActive();
 
     const bool bGameLoaded = (GetGameId() != 0);
@@ -652,9 +654,15 @@ void AssetListViewModel::DoUpdateButtons()
                 {
                     switch (pItem->GetCategory())
                     {
+                        case ra::data::models::AssetCategory::Core:
+                            bHasCore = true;
+                            break;
                         case ra::data::models::AssetCategory::Unofficial:
                             bHasUnofficial = true;
                             break;
+                        case ra::data::models::AssetCategory::Local:
+                            bHasLocal = true;
+                            break;
                         default:
                             break;
                     }
@@ -751,6 +759,8 @@ void AssetListViewModel::DoUpdateButtons()
             SetValue(RevertButtonTextProperty, L"Re&vert");
         else if (bHasLocalSelection)
             SetValue(RevertButtonTextProperty, L"&Delete");
+        else if (bHasLocal && !bHasCore && !bHasUnofficial)
+            SetValue(RevertButtonTextProperty, L"&Delete All");
         else
             SetValue(RevertButtonTextProperty, RevertButtonTextProperty.GetDefaultValue());
 
@@ -1276,9 +1286,9 @@ void AssetListViewModel::RevertSelected()
         vmMessageBox.SetHeader(L"Revert from server?");
 
         if (vAssetsToReset.size() == m_vFilteredAssets.Count())
-            sWarningMessage = L"This will discard all local changes and revert the assets to the last state retrieved from the server.";
+            sWarningMessage = L"This will discard all local work and revert the assets to the last state retrieved from the server.";
         else
-            sWarningMessage = L"This will discard any local changes to the selected assets and revert them to the last state retrieved from the server.";
+            sWarningMessage = L"This will discard any local work for the selected assets and revert them to the last state retrieved from the server.";
 
         if (bLocalAssetSelected)
             sWarningMessage += L"\n\n";
diff --git a/src/ui/viewmodels/IntegrationMenuViewModel.cpp b/src/ui/viewmodels/IntegrationMenuViewModel.cpp
index eac8dba5..023f3512 100644
--- a/src/ui/viewmodels/IntegrationMenuViewModel.cpp
+++ b/src/ui/viewmodels/IntegrationMenuViewModel.cpp
@@ -3,6 +3,8 @@
 #include "RA_Log.h"
 #include "RA_Resource.h"
 
+#include "data/context/ConsoleContext.hh"
+#include "data/context/GameContext.hh"
 #include "data/context/UserContext.hh"
 
 #include "services/IConfiguration.hh"
@@ -15,6 +17,7 @@
 #include "ui/viewmodels/LoginViewModel.hh"
 #include "ui/viewmodels/MessageBoxViewModel.hh"
 #include "ui/viewmodels/OverlaySettingsViewModel.hh"
+#include "ui/viewmodels/UnknownGameViewModel.hh"
 #include "ui/viewmodels/WindowManager.hh"
 
 namespace ra {
@@ -289,8 +292,30 @@ void IntegrationMenuViewModel::ReportBrokenAchievements()
 
 void IntegrationMenuViewModel::ShowGameHash()
 {
-    ra::ui::viewmodels::GameChecksumViewModel vmGameChecksum;
-    vmGameChecksum.ShowModal();
+    auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
+    if (pGameContext.GetMode() == ra::data::context::GameContext::Mode::CompatibilityTest)
+    {
+        ra::ui::viewmodels::UnknownGameViewModel vmUnknownGame;
+        vmUnknownGame.InitializeTestCompatibilityMode();
+
+        auto sEstimatedGameTitle = ra::services::ServiceLocator::Get<ra::data::context::EmulatorContext>().GetGameTitle();
+        vmUnknownGame.SetEstimatedGameName(ra::Widen(sEstimatedGameTitle));
+        vmUnknownGame.SetSystemName(ra::services::ServiceLocator::Get<ra::data::context::ConsoleContext>().Name());
+
+        if (vmUnknownGame.ShowModal() == ra::ui::DialogResult::OK)
+        {
+            // Test button is disabled, can only get here by clicking Link
+            Expects(!vmUnknownGame.GetTestMode());
+
+            pGameContext.SetMode(ra::data::context::GameContext::Mode::Normal);
+            pGameContext.RefreshUnlocks();
+        }
+    }
+    else
+    {
+        ra::ui::viewmodels::GameChecksumViewModel vmGameChecksum;
+        vmGameChecksum.ShowModal();
+    }
 }
 
 } // namespace viewmodels
diff --git a/src/ui/viewmodels/OverlayAchievementsPageViewModel.cpp b/src/ui/viewmodels/OverlayAchievementsPageViewModel.cpp
index 01030de0..9c2a690f 100644
--- a/src/ui/viewmodels/OverlayAchievementsPageViewModel.cpp
+++ b/src/ui/viewmodels/OverlayAchievementsPageViewModel.cpp
@@ -48,6 +48,7 @@ static void SetHeader(OverlayListPageViewModel::ItemViewModel& vmItem, const std
     vmItem.Image.ChangeReference(ra::ui::ImageType::None, "");
     vmItem.SetProgressValue(0U);
     vmItem.SetProgressMaximum(0U);
+    vmItem.SetProgressPercentage(true);
 }
 
 static void SetAchievement(OverlayListPageViewModel::ItemViewModel& vmItem, const ra::data::models::AchievementModel& vmAchievement)
@@ -77,11 +78,13 @@ static void SetAchievement(OverlayListPageViewModel::ItemViewModel& vmItem, cons
         {
             vmItem.SetProgressMaximum(pTrigger->measured_target);
             vmItem.SetProgressValue(pTrigger->measured_value);
+            vmItem.SetProgressPercentage(pTrigger->measured_as_percent);
         }
         else
         {
             vmItem.SetProgressMaximum(0);
             vmItem.SetProgressValue(0);
+            vmItem.SetProgressPercentage(false);
         }
     }
     else if (vmAchievement.GetState() == ra::data::models::AssetState::Disabled)
@@ -90,6 +93,7 @@ static void SetAchievement(OverlayListPageViewModel::ItemViewModel& vmItem, cons
         vmItem.SetDisabled(true);
         vmItem.SetProgressValue(0U);
         vmItem.SetProgressMaximum(0U);
+        vmItem.SetProgressPercentage(false);
     }
     else
     {
@@ -97,6 +101,7 @@ static void SetAchievement(OverlayListPageViewModel::ItemViewModel& vmItem, cons
         vmItem.SetDisabled(false);
         vmItem.SetProgressValue(0U);
         vmItem.SetProgressMaximum(0U);
+        vmItem.SetProgressPercentage(false);
     }
 }
 
diff --git a/src/ui/viewmodels/OverlayListPageViewModel.cpp b/src/ui/viewmodels/OverlayListPageViewModel.cpp
index c2ca99d9..912bf394 100644
--- a/src/ui/viewmodels/OverlayListPageViewModel.cpp
+++ b/src/ui/viewmodels/OverlayListPageViewModel.cpp
@@ -20,6 +20,7 @@ const StringModelProperty OverlayListPageViewModel::ItemViewModel::DetailPropert
 const BoolModelProperty OverlayListPageViewModel::ItemViewModel::IsDisabledProperty("ItemViewModel", "IsDisabled", false);
 const IntModelProperty OverlayListPageViewModel::ItemViewModel::ProgressMaximumProperty("ItemViewModel", "ProgressMaximum", 0);
 const IntModelProperty OverlayListPageViewModel::ItemViewModel::ProgressValueProperty("ItemViewModel", "ProgressValue", 0);
+const BoolModelProperty OverlayListPageViewModel::ItemViewModel::IsProgressPercentageProperty("ItemViewModel", "IsProgressPercentage", false);
 
 void OverlayListPageViewModel::Refresh()
 {
@@ -183,9 +184,11 @@ void OverlayListPageViewModel::RenderList(ra::ui::drawing::ISurface& pSurface, i
                 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 std::wstring sProgress = pItem->IsProgressPercentage() ?
+                    ra::StringPrintf(L"%d%%", nProgressBarPercent) :
+                    ra::StringPrintf(L"%u/%u", nValue, nTarget);
+                const auto szProgress = pSurface.MeasureText(nProgressFont, sProgress);
+                pSurface.WriteText(nTextX + 12 + nProgressBarWidth + 6, nY + 1 + 26 + 25 + 4 - (szProgress.Height / 2) - 1, nProgressFont, nSubTextColor, sProgress);
             }
         }
 
diff --git a/src/ui/viewmodels/OverlayListPageViewModel.hh b/src/ui/viewmodels/OverlayListPageViewModel.hh
index 1d7409c9..df8b9463 100644
--- a/src/ui/viewmodels/OverlayListPageViewModel.hh
+++ b/src/ui/viewmodels/OverlayListPageViewModel.hh
@@ -128,6 +128,21 @@ public:
         /// </summary>
         void SetProgressValue(unsigned int nValue) { SetValue(ProgressValueProperty, ra::to_signed(nValue)); }
 
+        /// <summary>
+        /// The <see cref="ModelProperty" /> for whether the current progress value should be shown as a percentage (or raw).
+        /// </summary>
+        static const BoolModelProperty IsProgressPercentageProperty;
+
+        /// <summary>
+        /// Gets whether the progress value should be shown as a percentage.
+        /// </summary>
+        bool IsProgressPercentage() const { return GetValue(IsProgressPercentageProperty); }
+
+        /// <summary>
+        /// Sets whether the progress value should be shown as a percentage.
+        /// </summary>
+        void SetProgressPercentage(bool bValue) { SetValue(IsProgressPercentageProperty, bValue); }
+
         /// <summary>
         /// Gets whether the current item is a header item.
         /// </summary>
diff --git a/src/ui/viewmodels/TriggerConditionViewModel.cpp b/src/ui/viewmodels/TriggerConditionViewModel.cpp
index c174c752..f7b86403 100644
--- a/src/ui/viewmodels/TriggerConditionViewModel.cpp
+++ b/src/ui/viewmodels/TriggerConditionViewModel.cpp
@@ -60,11 +60,19 @@ void TriggerConditionViewModel::SerializeAppend(std::string& sBuffer) const
             case TriggerConditionType::SubHits:     sBuffer.push_back('D'); break;
             case TriggerConditionType::AndNext:     sBuffer.push_back('N'); break;
             case TriggerConditionType::OrNext:      sBuffer.push_back('O'); break;
-            case TriggerConditionType::Measured:    sBuffer.push_back('M'); break;
             case TriggerConditionType::MeasuredIf:  sBuffer.push_back('Q'); break;
             case TriggerConditionType::AddAddress:  sBuffer.push_back('I'); break;
             case TriggerConditionType::Trigger:     sBuffer.push_back('T'); break;
             case TriggerConditionType::ResetNextIf: sBuffer.push_back('Z'); break;
+            case TriggerConditionType::Measured:
+            {
+                const auto* pTriggerViewModel = dynamic_cast<const TriggerViewModel*>(m_pTriggerViewModel);
+                if (pTriggerViewModel != nullptr && pTriggerViewModel->IsMeasuredTrackedAsPercent())
+                    sBuffer.push_back('G');
+                else
+                    sBuffer.push_back('M');
+                break;
+            }
             default:
                 assert(!"Unknown condition type");
                 break;
diff --git a/src/ui/viewmodels/TriggerViewModel.cpp b/src/ui/viewmodels/TriggerViewModel.cpp
index e484e3ba..c8a62fcf 100644
--- a/src/ui/viewmodels/TriggerViewModel.cpp
+++ b/src/ui/viewmodels/TriggerViewModel.cpp
@@ -20,6 +20,7 @@ const IntModelProperty TriggerViewModel::SelectedGroupIndexProperty("TriggerView
 const IntModelProperty TriggerViewModel::VersionProperty("TriggerViewModel", "Version", 0);
 const IntModelProperty TriggerViewModel::EnsureVisibleConditionIndexProperty("TriggerViewModel", "EnsureVisibleConditionIndex", -1);
 const IntModelProperty TriggerViewModel::EnsureVisibleGroupIndexProperty("TriggerViewModel", "EnsureVisibleGroupIndex", -1);
+const BoolModelProperty TriggerViewModel::IsMeasuredTrackedAsPercentProperty("TriggerViewModel", "IsMeasuredTrackedAsPercent", false);
 const IntModelProperty TriggerViewModel::GroupViewModel::ColorProperty("GroupViewModel", "Color", 0);
 
 TriggerViewModel::TriggerViewModel() noexcept
@@ -111,7 +112,7 @@ void TriggerViewModel::GroupViewModel::UpdateSerialized(std::string& sSerialized
         sSerialized.pop_back();
 }
 
-const std::string& TriggerViewModel::GroupViewModel::GetSerialized() const
+const std::string& TriggerViewModel::GroupViewModel::GetSerialized(const TriggerViewModel& vmTrigger) const
 {
     if (m_sSerialized == TriggerViewModel::GroupViewModel::NOT_SERIALIZED)
     {
@@ -124,6 +125,7 @@ const std::string& TriggerViewModel::GroupViewModel::GetSerialized() const
             for (; pCondition != nullptr; pCondition = pCondition->next)
             {
                 auto& vmCondition = vConditions.Add();
+                vmCondition.SetTriggerViewModel(&vmTrigger);
                 vmCondition.InitializeFrom(*pCondition);
             }
 
@@ -134,6 +136,24 @@ const std::string& TriggerViewModel::GroupViewModel::GetSerialized() const
     return m_sSerialized;
 }
 
+bool TriggerViewModel::GroupViewModel::UpdateMeasuredFlags()
+{
+    if (m_pConditionSet)
+    {
+        rc_condition_t* pCondition = m_pConditionSet->conditions;
+        for (; pCondition != nullptr; pCondition = pCondition->next)
+        {
+            if (pCondition->type == RC_CONDITION_MEASURED)
+            {
+                m_sSerialized = TriggerViewModel::GroupViewModel::NOT_SERIALIZED;
+                return true;
+            }
+        }
+    }
+
+    return false;
+}
+
 void TriggerViewModel::SerializeAppend(std::string& sBuffer) const
 {
     for (gsl::index nGroup = 0; nGroup < gsl::narrow_cast<gsl::index>(m_vGroups.Count()); ++nGroup)
@@ -144,7 +164,7 @@ void TriggerViewModel::SerializeAppend(std::string& sBuffer) const
             if (nGroup != 0)
                 sBuffer.push_back('S');
 
-            sBuffer.append(vmGroup->GetSerialized());
+            sBuffer.append(vmGroup->GetSerialized(*this));
         }
     }
 }
@@ -174,7 +194,7 @@ void TriggerViewModel::CopySelectedConditionsToClipboard()
         // no selection, get the whole group
         const auto* pGroup = m_vGroups.GetItemAt(GetSelectedGroupIndex());
         if (pGroup != nullptr)
-            sSerialized = pGroup->GetSerialized();
+            sSerialized = pGroup->GetSerialized(*this);
     }
     else if (sSerialized.back() == '_')
     {
@@ -330,6 +350,10 @@ void TriggerViewModel::NewCondition()
             nSize = pMemoryInspector.Viewer().GetSize();
             break;
 
+        case 1:
+            nSize = MemSize::EightBit;
+            break;
+
         case 2:
             nSize = MemSize::SixteenBit;
             break;
@@ -376,6 +400,8 @@ void TriggerViewModel::InitializeGroups(const rc_trigger_t& pTrigger)
     m_vGroups.RemoveNotifyTarget(*this);
     m_vGroups.BeginUpdate();
 
+    SetMeasuredTrackedAsPercent(m_pTrigger != nullptr ? m_pTrigger->measured_as_percent : false);
+
     // this will not update the conditions collection because OnValueChange ignores it when m_vGroups.IsUpdating
     SetSelectedGroupIndex(0);
 
@@ -424,7 +450,7 @@ void TriggerViewModel::InitializeFrom(const std::string& sTrigger, const ra::dat
     }
 }
 
-void TriggerViewModel::UpdateFrom(const rc_trigger_t& pTrigger)
+void TriggerViewModel::UpdateGroups(const rc_trigger_t& pTrigger)
 {
     const int nSelectedIndex = GetSelectedGroupIndex();
 
@@ -474,6 +500,13 @@ void TriggerViewModel::UpdateFrom(const rc_trigger_t& pTrigger)
     }
 }
 
+void TriggerViewModel::UpdateFrom(const rc_trigger_t& pTrigger)
+{
+    m_pTrigger = nullptr;
+    m_sTriggerBuffer.clear();
+    UpdateGroups(pTrigger);
+}
+
 void TriggerViewModel::UpdateFrom(const std::string& sTrigger)
 {
     const auto nSize = rc_trigger_size(sTrigger.c_str());
@@ -481,13 +514,13 @@ void TriggerViewModel::UpdateFrom(const std::string& sTrigger)
     {
         m_sTriggerBuffer.resize(nSize);
         m_pTrigger = rc_parse_trigger(m_sTriggerBuffer.data(), sTrigger.c_str(), nullptr, 0);
-        UpdateFrom(*m_pTrigger);
+        UpdateGroups(*m_pTrigger);
     }
     else
     {
         rc_trigger_t pTrigger;
         memset(&pTrigger, 0, sizeof(pTrigger));
-        UpdateFrom(pTrigger);
+        UpdateGroups(pTrigger);
     }
 }
 
@@ -550,6 +583,42 @@ void TriggerViewModel::UpdateVersion()
     SetValue(VersionProperty, GetValue(VersionProperty) + 1);
 }
 
+void TriggerViewModel::UpdateVersionAndRestoreHits()
+{
+    // WARNING: this method should only be called when the number and type of conditions is known to not change
+    std::vector<unsigned> vCurrentHits;
+    for (gsl::index i = 0; i < gsl::narrow_cast<gsl::index>(m_vGroups.Count()); ++i)
+    {
+        auto* pGroup = m_vGroups.GetItemAt(i);
+        if (pGroup != nullptr)
+        {
+            const auto* pCondition = pGroup->m_pConditionSet->conditions;
+            while (pCondition != nullptr)
+            {
+                vCurrentHits.push_back(pCondition->current_hits);
+                pCondition = pCondition->next;
+            }
+        }
+    }
+
+    UpdateVersion();
+
+    gsl::index nHitIndex = 0;
+    for (gsl::index i = 0; i < gsl::narrow_cast<gsl::index>(m_vGroups.Count()); ++i)
+    {
+        auto* pGroup = m_vGroups.GetItemAt(i);
+        if (pGroup != nullptr)
+        {
+            auto* pCondition = pGroup->m_pConditionSet->conditions;
+            while (pCondition != nullptr)
+            {
+                pCondition->current_hits = vCurrentHits.at(nHitIndex++);
+                pCondition = pCondition->next;
+            }
+        }
+    }
+}
+
 void TriggerViewModel::OnValueChanged(const IntModelProperty::ChangeArgs& args)
 {
     if (args.Property == SelectedGroupIndexProperty)
@@ -580,6 +649,29 @@ void TriggerViewModel::OnValueChanged(const IntModelProperty::ChangeArgs& args)
     ViewModelBase::OnValueChanged(args);
 }
 
+void TriggerViewModel::OnValueChanged(const BoolModelProperty::ChangeArgs& args)
+{
+    if (args.Property == IsMeasuredTrackedAsPercentProperty)
+    {
+        // ignore change caused in InitializeFrom
+        if (!m_vGroups.IsUpdating())
+        {
+            bool bChanged = false;
+            for (gsl::index i = 0; i < gsl::narrow_cast<gsl::index>(m_vGroups.Count()); ++i)
+            {
+                auto* pGroup = m_vGroups.GetItemAt(i);
+                if (pGroup != nullptr)
+                    bChanged |= pGroup->UpdateMeasuredFlags();
+            }
+
+            if (bChanged)
+                UpdateVersionAndRestoreHits();
+        }
+    }
+
+    ViewModelBase::OnValueChanged(args);
+}
+
 void TriggerViewModel::UpdateConditions(const GroupViewModel* pGroup)
 {
     m_vConditions.RemoveNotifyTarget(m_pConditionsMonitor);
diff --git a/src/ui/viewmodels/TriggerViewModel.hh b/src/ui/viewmodels/TriggerViewModel.hh
index 9c7c35a2..82acd67f 100644
--- a/src/ui/viewmodels/TriggerViewModel.hh
+++ b/src/ui/viewmodels/TriggerViewModel.hh
@@ -29,6 +29,9 @@ public:
     TriggerViewModel(TriggerViewModel&&) noexcept = delete;
     TriggerViewModel& operator=(TriggerViewModel&&) noexcept = delete;
 
+    static const BoolModelProperty IsMeasuredTrackedAsPercentProperty;
+    bool IsMeasuredTrackedAsPercent() const { return GetValue(IsMeasuredTrackedAsPercentProperty); }
+    void SetMeasuredTrackedAsPercent(bool nValue) { SetValue(IsMeasuredTrackedAsPercentProperty, nValue); }
 
     static const IntModelProperty SelectedGroupIndexProperty;
     int GetSelectedGroupIndex() const { return GetValue(SelectedGroupIndexProperty); }
@@ -41,9 +44,11 @@ public:
         rc_condset_t* m_pConditionSet = nullptr;
 
         bool UpdateSerialized(const ViewModelCollection<TriggerConditionViewModel>& vConditions);
-        const std::string& GetSerialized() const;
+        const std::string& GetSerialized(const TriggerViewModel& vmTrigger) const;
         void ResetSerialized() { m_sSerialized = NOT_SERIALIZED; }
 
+        bool UpdateMeasuredFlags();
+
         static const IntModelProperty ColorProperty;
         Color GetColor() const { return Color(ra::to_unsigned(GetValue(ColorProperty))); }
         void SetColor(Color value) { SetValue(ColorProperty, ra::to_signed(value.ARGB)); }
@@ -140,10 +145,13 @@ protected:
     void OnViewModelBoolValueChanged(gsl::index nIndex, const BoolModelProperty::ChangeArgs& args) override;
 
     void OnValueChanged(const IntModelProperty::ChangeArgs& args) override;
+    void OnValueChanged(const BoolModelProperty::ChangeArgs& args) override;
 
 private:
     void UpdateVersion();
+    void UpdateVersionAndRestoreHits();
     void InitializeGroups(const rc_trigger_t& pTrigger);
+    void UpdateGroups(const rc_trigger_t& pTrigger);
     void UpdateConditions(const GroupViewModel* pGroup);
 
     void DeselectAllConditions();
diff --git a/src/ui/viewmodels/UnknownGameViewModel.cpp b/src/ui/viewmodels/UnknownGameViewModel.cpp
index e7de830b..432bb308 100644
--- a/src/ui/viewmodels/UnknownGameViewModel.cpp
+++ b/src/ui/viewmodels/UnknownGameViewModel.cpp
@@ -6,6 +6,7 @@
 #include "api\SubmitNewTitle.hh"
 
 #include "data\context\ConsoleContext.hh"
+#include "data\context\GameContext.hh"
 
 #include "services\IClipboard.hh"
 
@@ -16,6 +17,8 @@ namespace ui {
 namespace viewmodels {
 
 const IntModelProperty UnknownGameViewModel::SelectedGameIdProperty("UnknownGameViewModel", "SelectedGameId", 0);
+const BoolModelProperty UnknownGameViewModel::IsSelectedGameEnabledProperty("UnknownGameViewModel", "IsSelectedGameEnabled", true);
+const BoolModelProperty UnknownGameViewModel::IsAssociateEnabledProperty("UnknownGameViewModel", "IsAssociateEnabled", true);
 const StringModelProperty UnknownGameViewModel::NewGameNameProperty("UnknownGameViewModel", "NewGameName", L"");
 const StringModelProperty UnknownGameViewModel::ChecksumProperty("UnknownGameViewModel", "Checksum", L"");
 const StringModelProperty UnknownGameViewModel::EstimatedGameNameProperty("UnknownGameViewModel", "EstimatedGameName", L"");
@@ -33,6 +36,9 @@ void UnknownGameViewModel::InitializeGameTitles()
 
     m_vGameTitles.Add(0U, L"<New Title>");
 
+    SetValue(IsSelectedGameEnabledProperty, false);
+    SetValue(IsAssociateEnabledProperty, false);
+
     ra::api::FetchGamesList::Request request;
     request.ConsoleId = pConsoleContext.Id();
 
@@ -49,14 +55,30 @@ void UnknownGameViewModel::InitializeGameTitles()
         }
         else
         {
+            m_vGameTitles.BeginUpdate();
             for (const auto& pGame : response.Games)
                 m_vGameTitles.Add(pGame.Id, pGame.Name);
+            m_vGameTitles.EndUpdate();
         }
 
         m_vGameTitles.Freeze();
+
+        SetValue(IsAssociateEnabledProperty, true);
+        SetValue(IsSelectedGameEnabledProperty, true);
     });
 }
 
+void UnknownGameViewModel::InitializeTestCompatibilityMode()
+{
+    const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
+    m_vGameTitles.Add(pGameContext.GameId(), pGameContext.GameTitle());
+    m_vGameTitles.Freeze();
+    SetSelectedGameId(pGameContext.GameId());
+    SetChecksum(ra::Widen(pGameContext.GameHash()));
+
+    SetValue(IsSelectedGameEnabledProperty, false);
+}
+
 bool UnknownGameViewModel::Associate()
 {
     ra::api::SubmitNewTitle::Request request;
diff --git a/src/ui/viewmodels/UnknownGameViewModel.hh b/src/ui/viewmodels/UnknownGameViewModel.hh
index 29a32eef..94e8f54f 100644
--- a/src/ui/viewmodels/UnknownGameViewModel.hh
+++ b/src/ui/viewmodels/UnknownGameViewModel.hh
@@ -54,6 +54,21 @@ public:
     /// </summary>
     void InitializeGameTitles();
 
+    /// <summary>
+    /// Initializes the <see cref="GameTitles"/> collection (asynchronously).
+    /// </summary>
+    void InitializeTestCompatibilityMode();
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for whether or not a game can be selected.
+    /// </summary>
+    static const BoolModelProperty IsSelectedGameEnabledProperty;
+
+    /// <summary>
+    /// Gets whether or not a game can be selected.
+    /// </summary>
+    bool IsSelectedGameEnabled() const { return GetValue(IsSelectedGameEnabledProperty); }
+
     /// <summary>
     /// The <see cref="ModelProperty" /> for the new game name.
     /// </summary>
@@ -134,6 +149,16 @@ public:
     /// </summary>
     void SetTestMode(bool bValue) { SetValue(TestModeProperty, bValue); }
 
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for whether or not the link button is enabled.
+    /// </summary>
+    static const BoolModelProperty IsAssociateEnabledProperty;
+
+    /// <summary>
+    /// Gets whether or not the link button is enabled.
+    /// </summary>
+    bool IsAssociateEnabled() const { return GetValue(IsAssociateEnabledProperty); }
+
     /// <summary>
     /// Command handler for Associate button.
     /// </summary>    
diff --git a/src/ui/win32/AssetEditorDialog.cpp b/src/ui/win32/AssetEditorDialog.cpp
index 8b325775..2ec42350 100644
--- a/src/ui/win32/AssetEditorDialog.cpp
+++ b/src/ui/win32/AssetEditorDialog.cpp
@@ -516,6 +516,7 @@ AssetEditorDialog::AssetEditorDialog(AssetEditorViewModel& vmAssetEditor)
     m_bindBadge(vmAssetEditor),
     m_bindBadgeImage(vmAssetEditor),
     m_bindPoints(vmAssetEditor),
+    m_bindMeasuredAsPercent(vmAssetEditor.Trigger()),
     m_bindDebugHighlights(vmAssetEditor),
     m_bindPauseOnReset(vmAssetEditor),
     m_bindPauseOnTrigger(vmAssetEditor),
@@ -559,7 +560,9 @@ AssetEditorDialog::AssetEditorDialog(AssetEditorViewModel& vmAssetEditor)
     m_bindWindow.BindLabel(IDC_RA_MEASURED, AssetEditorViewModel::MeasuredValueProperty);
     m_bindWindow.BindVisible(IDC_RA_LBL_MEASURED, AssetEditorViewModel::HasMeasuredProperty);
     m_bindWindow.BindVisible(IDC_RA_MEASURED, AssetEditorViewModel::HasMeasuredProperty);
+    m_bindWindow.BindVisible(IDC_RA_CHK_AS_PERCENT, AssetEditorViewModel::HasMeasuredProperty);
 
+    m_bindMeasuredAsPercent.BindCheck(TriggerViewModel::IsMeasuredTrackedAsPercentProperty);
     m_bindDebugHighlights.BindCheck(AssetEditorViewModel::DebugHighlightsEnabledProperty);
     m_bindPauseOnReset.BindCheck(AssetEditorViewModel::PauseOnResetProperty);
     m_bindPauseOnTrigger.BindCheck(AssetEditorViewModel::PauseOnTriggerProperty);
@@ -661,6 +664,9 @@ AssetEditorDialog::AssetEditorDialog(AssetEditorViewModel& vmAssetEditor)
     SetAnchor(IDC_RA_DESCRIPTION, Anchor::Top | Anchor::Left | Anchor::Right);
     SetAnchor(IDC_RA_LBL_POINTS, Anchor::Top | Anchor::Left);
     SetAnchor(IDC_RA_POINTS, Anchor::Top | Anchor::Left);
+    SetAnchor(IDC_RA_LBL_MEASURED, Anchor::Top | Anchor::Left);
+    SetAnchor(IDC_RA_MEASURED, Anchor::Top | Anchor::Left);
+    SetAnchor(IDC_RA_CHK_AS_PERCENT, Anchor::Top | Anchor::Left);
     SetAnchor(IDC_RA_LBL_BADGENAME, Anchor::Top | Anchor::Right);
     SetAnchor(IDC_RA_BADGENAME, Anchor::Top | Anchor::Right);
     SetAnchor(IDC_RA_BADGE_SPIN, Anchor::Top | Anchor::Right);
@@ -670,6 +676,7 @@ AssetEditorDialog::AssetEditorDialog(AssetEditorViewModel& vmAssetEditor)
     SetAnchor(IDC_RA_ADD_GROUP, Anchor::Bottom | Anchor::Left);
     SetAnchor(IDC_RA_DELETE_GROUP, Anchor::Bottom | Anchor::Left);
     SetAnchor(IDC_RA_COPY_ALL, Anchor::Bottom | Anchor::Left);
+    SetAnchor(IDC_RA_ERROR_INDICATOR, Anchor::Top | Anchor::Left);
     SetAnchor(IDC_RA_CHK_HIGHLIGHTS, Anchor::Top | Anchor::Right);
     SetAnchor(IDC_RA_CHK_PAUSE_ON_RESET, Anchor::Top | Anchor::Right);
     SetAnchor(IDC_RA_CHK_PAUSE_ON_TRIGGER, Anchor::Top | Anchor::Right);
@@ -699,6 +706,7 @@ BOOL AssetEditorDialog::OnInitDialog()
     m_bindConditions.SetControl(*this, IDC_RA_LBX_CONDITIONS);
     m_bindConditions.InitializeTooltips(std::chrono::seconds(30));
 
+    m_bindMeasuredAsPercent.SetControl(*this, IDC_RA_CHK_AS_PERCENT);
     m_bindDebugHighlights.SetControl(*this, IDC_RA_CHK_HIGHLIGHTS);
     m_bindPauseOnReset.SetControl(*this, IDC_RA_CHK_PAUSE_ON_RESET);
     m_bindPauseOnTrigger.SetControl(*this, IDC_RA_CHK_PAUSE_ON_TRIGGER);
diff --git a/src/ui/win32/AssetEditorDialog.hh b/src/ui/win32/AssetEditorDialog.hh
index fe12c34a..b7f1ef96 100644
--- a/src/ui/win32/AssetEditorDialog.hh
+++ b/src/ui/win32/AssetEditorDialog.hh
@@ -109,6 +109,7 @@ private:
     ra::ui::win32::bindings::ImageBinding m_bindBadgeImage;
     ra::ui::win32::bindings::NumericTextBoxBinding m_bindPoints;
 
+    ra::ui::win32::bindings::CheckBoxBinding m_bindMeasuredAsPercent;
     ra::ui::win32::bindings::CheckBoxBinding m_bindDebugHighlights;
     ra::ui::win32::bindings::CheckBoxBinding m_bindPauseOnReset;
     ra::ui::win32::bindings::CheckBoxBinding m_bindPauseOnTrigger;
diff --git a/src/ui/win32/UnknownGameDialog.cpp b/src/ui/win32/UnknownGameDialog.cpp
index edb0db5b..4bdbf914 100644
--- a/src/ui/win32/UnknownGameDialog.cpp
+++ b/src/ui/win32/UnknownGameDialog.cpp
@@ -34,13 +34,18 @@ UnknownGameDialog::UnknownGameDialog(ra::ui::viewmodels::UnknownGameViewModel& v
 
     m_bindExistingTitle.BindItems(vmUnknownGame.GameTitles());
     m_bindExistingTitle.BindSelectedItem(ra::ui::viewmodels::UnknownGameViewModel::SelectedGameIdProperty);
+    m_bindWindow.BindEnabled(IDC_RA_KNOWNGAMES, ra::ui::viewmodels::UnknownGameViewModel::IsSelectedGameEnabledProperty);
 
     m_bindNewTitle.BindText(ra::ui::viewmodels::UnknownGameViewModel::NewGameNameProperty,
         ra::ui::win32::bindings::TextBoxBinding::UpdateMode::KeyPress);
+    m_bindWindow.BindEnabled(IDC_RA_GAMETITLE, ra::ui::viewmodels::UnknownGameViewModel::IsSelectedGameEnabledProperty);
 
     m_bindWindow.BindLabel(IDC_RA_GAMENAME, ra::ui::viewmodels::UnknownGameViewModel::EstimatedGameNameProperty);
     m_bindWindow.BindLabel(IDC_RA_SYSTEMNAME, ra::ui::viewmodels::UnknownGameViewModel::SystemNameProperty);
     m_bindWindow.BindLabel(IDC_RA_CHECKSUM, ra::ui::viewmodels::UnknownGameViewModel::ChecksumProperty);
+
+    m_bindWindow.BindEnabled(IDC_RA_LINK, ra::ui::viewmodels::UnknownGameViewModel::IsAssociateEnabledProperty);
+    m_bindWindow.BindEnabled(IDOK, ra::ui::viewmodels::UnknownGameViewModel::IsSelectedGameEnabledProperty);
 }
 
 BOOL UnknownGameDialog::OnInitDialog()


Test patch:
diff --git a/tests/data/context/GameContext_Tests.cpp b/tests/data/context/GameContext_Tests.cpp
index 7d7910d2..909da08a 100644
--- a/tests/data/context/GameContext_Tests.cpp
+++ b/tests/data/context/GameContext_Tests.cpp
@@ -92,15 +92,6 @@ public:
             return pLeaderboard;
         }
 
-        unsigned int GetCodeNoteSize(ra::ByteAddress nAddress)
-        {
-            const auto pIter = m_mCodeNotes.find(nAddress);
-            if (pIter == m_mCodeNotes.end())
-                return 0U;
-
-            return pIter->second.Bytes;
-        }
-
         void RemoveNonAchievementAssets()
         {
             for (gsl::index nIndex = Assets().Count() - 1; nIndex >= 0; nIndex--)
@@ -2292,16 +2283,16 @@ public:
         game.mockThreadPool.ExecuteNextTask(); // FetchUserUnlocks and FetchCodeNotes are async
         game.mockThreadPool.ExecuteNextTask();
 
-        Assert::AreEqual(nExpectedSize, game.GetCodeNoteSize(1234U), sNote.c_str());
+        Assert::AreEqual(nExpectedSize, game.FindCodeNoteSize(1234U), sNote.c_str());
     }
 
     TEST_METHOD(TestLoadCodeNotesSized)
     {
-        TestCodeNoteSize(L"", 1U);
-        TestCodeNoteSize(L"Test", 1U);
+        TestCodeNoteSize(L"", 0U);
+        TestCodeNoteSize(L"Test", 0U);
         TestCodeNoteSize(L"16-bit Test", 2U);
         TestCodeNoteSize(L"Test 16-bit", 2U);
-        TestCodeNoteSize(L"Test 16-bi", 1U);
+        TestCodeNoteSize(L"Test 16-bi", 0U);
         TestCodeNoteSize(L"[16-bit] Test", 2U);
         TestCodeNoteSize(L"[16 bit] Test", 2U);
         TestCodeNoteSize(L"[16 Bit] Test", 2U);
@@ -2316,15 +2307,18 @@ public:
         TestCodeNoteSize(L"[128-bit] Test", 16U);
         TestCodeNoteSize(L"[17-bit] Test", 3U);
         TestCodeNoteSize(L"[100-bit] Test", 13U);
+        TestCodeNoteSize(L"[0-bit] Test", 0U);
+        TestCodeNoteSize(L"[1-bit] Test", 1U);
         TestCodeNoteSize(L"[4-bit] Test", 1U);
-        TestCodeNoteSize(L"[0-bit] Test", 1U);
-        TestCodeNoteSize(L"bit", 1U);
+        TestCodeNoteSize(L"[8-bit] Test", 1U);
+        TestCodeNoteSize(L"[9-bit] Test", 2U);
+        TestCodeNoteSize(L"bit", 0U);
         TestCodeNoteSize(L"9bit", 2U);
-        TestCodeNoteSize(L"-bit", 1U);
+        TestCodeNoteSize(L"-bit", 0U);
 
         TestCodeNoteSize(L"8 BYTE Test", 8U);
         TestCodeNoteSize(L"Test 8 BYTE", 8U);
-        TestCodeNoteSize(L"Test 8 BYT", 1U);
+        TestCodeNoteSize(L"Test 8 BYT", 0U);
         TestCodeNoteSize(L"[2 Byte] Test", 2U);
         TestCodeNoteSize(L"[4 Byte] Test", 4U);
         TestCodeNoteSize(L"[4 Byte - Float] Test", 4U);
@@ -2335,12 +2329,16 @@ public:
         TestCodeNoteSize(L"Test (6 bytes)", 6U);
         TestCodeNoteSize(L"[2byte] Test", 2U);
 
-        TestCodeNoteSize(L"42=bitten", 1U);
-        TestCodeNoteSize(L"bit by bit", 1U);
-        TestCodeNoteSize(L"bit1=chest", 1U);
+        TestCodeNoteSize(L"42=bitten", 0U);
+        TestCodeNoteSize(L"42-bitten", 0U);
+        TestCodeNoteSize(L"bit by bit", 0U);
+        TestCodeNoteSize(L"bit1=chest", 0U);
 
         TestCodeNoteSize(L"Bite count (16-bit)", 2U);
         TestCodeNoteSize(L"Number of bits collected (32 bits)", 4U);
+
+        TestCodeNoteSize(L"100 32-bit pointers [400 bytes]", 400U);
+        TestCodeNoteSize(L"[400 bytes] 100 32-bit pointers", 400U);
     }
 
     TEST_METHOD(TestFindCodeNoteSized)
@@ -2667,6 +2665,49 @@ public:
         const auto* pNote1 = game.FindCodeNote(1234U);
         Assert::IsNull(pNote1);
     }
+
+    TEST_METHOD(TestSetModeNotify)
+    {
+        class NotifyHarness : public GameContext::NotifyTarget
+        {
+        public:
+            bool m_bNotified = false;
+
+        protected:
+            void OnActiveGameChanged() noexcept override { m_bNotified = true; }
+        };
+        NotifyHarness notifyHarness;
+
+        GameContextHarness game;
+        game.mockServer.HandleRequest<ra::api::FetchGameData>([](const ra::api::FetchGameData::Request&, ra::api::FetchGameData::Response& response)
+        {
+            response.Title = L"GameTitle";
+            response.ImageIcon = "9743";
+            return true;
+        });
+
+        Assert::AreEqual(GameContext::Mode::Normal, game.GetMode());
+
+        game.AddNotifyTarget(notifyHarness);
+        game.LoadGame(0U);
+
+        Assert::AreEqual(0U, game.GameId());
+        Assert::IsFalse(notifyHarness.m_bNotified);
+
+        game.SetMode(GameContext::Mode::CompatibilityTest);
+        Assert::AreEqual(GameContext::Mode::CompatibilityTest, game.GetMode());
+        Assert::IsFalse(notifyHarness.m_bNotified);
+
+        game.LoadGame(1U, GameContext::Mode::CompatibilityTest);
+        Assert::AreEqual(1U, game.GameId());
+        Assert::AreEqual(GameContext::Mode::CompatibilityTest, game.GetMode());
+        Assert::IsTrue(notifyHarness.m_bNotified);
+
+        notifyHarness.m_bNotified = false;
+        game.SetMode(GameContext::Mode::Normal);
+        Assert::AreEqual(GameContext::Mode::Normal, game.GetMode());
+        Assert::IsTrue(notifyHarness.m_bNotified);
+    }
 };
 
 } // namespace tests
diff --git a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
index 42587a55..d1eca8f7 100644
--- a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
@@ -125,6 +125,7 @@ private:
         Revert,
         RevertAll,
         Delete,
+        DeleteAll,
         Disabled
     };
 
@@ -284,6 +285,11 @@ private:
                     Assert::IsTrue(CanRevert());
                     break;
 
+                case RevertButtonState::DeleteAll:
+                    Assert::AreEqual(std::wstring(L"&Delete All"), GetValue(RevertButtonTextProperty));
+                    Assert::IsTrue(CanRevert());
+                    break;
+
                 case RevertButtonState::Disabled:
                     Assert::AreEqual(std::wstring(L"Re&vert All"), GetValue(RevertButtonTextProperty));
                     Assert::IsFalse(CanRevert());
@@ -3664,7 +3670,7 @@ public:
         vmAssetList.mockDesktop.ExpectWindow<MessageBoxViewModel>([&bDialogShown](MessageBoxViewModel& vmMessageBox)
         {
             Assert::AreEqual(std::wstring(L"Revert from server?"), vmMessageBox.GetHeader());
-            Assert::AreEqual(std::wstring(L"This will discard all local changes and revert the assets to the last state retrieved from the server."), vmMessageBox.GetMessage());
+            Assert::AreEqual(std::wstring(L"This will discard all local work and revert the assets to the last state retrieved from the server."), vmMessageBox.GetMessage());
 
             bDialogShown = true;
             return DialogResult::Yes;
@@ -3693,7 +3699,7 @@ public:
         vmAssetList.mockDesktop.ExpectWindow<MessageBoxViewModel>([&bDialogShown](MessageBoxViewModel& vmMessageBox)
         {
             Assert::AreEqual(std::wstring(L"Revert from server?"), vmMessageBox.GetHeader());
-            Assert::AreEqual(std::wstring(L"This will discard all local changes and revert the assets to the last state retrieved from the server."), vmMessageBox.GetMessage());
+            Assert::AreEqual(std::wstring(L"This will discard all local work and revert the assets to the last state retrieved from the server."), vmMessageBox.GetMessage());
 
             bDialogShown = true;
             return DialogResult::Yes;
@@ -3723,7 +3729,7 @@ public:
         vmAssetList.mockDesktop.ExpectWindow<MessageBoxViewModel>([&bDialogShown](MessageBoxViewModel& vmMessageBox)
         {
             Assert::AreEqual(std::wstring(L"Revert from server?"), vmMessageBox.GetHeader());
-            Assert::AreEqual(std::wstring(L"This will discard all local changes and revert the assets to the last state retrieved from the server."), vmMessageBox.GetMessage());
+            Assert::AreEqual(std::wstring(L"This will discard all local work and revert the assets to the last state retrieved from the server."), vmMessageBox.GetMessage());
 
             bDialogShown = true;
             return DialogResult::Yes;
@@ -3750,14 +3756,13 @@ public:
         vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
         vmAssetList.ForceUpdateButtons();
 
-        // when nothing is selected, the button defaults to revert all, even if only local achievements are present
-        vmAssetList.AssertButtonState(RevertButtonState::RevertAll);
+        vmAssetList.AssertButtonState(RevertButtonState::DeleteAll);
 
         bool bDialogShown = false;
         vmAssetList.mockDesktop.ExpectWindow<MessageBoxViewModel>([&bDialogShown](MessageBoxViewModel& vmMessageBox)
         {
-            Assert::AreEqual(std::wstring(L"Revert from server?"), vmMessageBox.GetHeader());
-            Assert::AreEqual(std::wstring(L"This will discard all local changes and revert the assets to the last state retrieved from the server.\n\nAssets not available on the server will be permanently deleted."), vmMessageBox.GetMessage());
+            Assert::AreEqual(std::wstring(L"Permanently delete?"), vmMessageBox.GetHeader());
+            Assert::AreEqual(std::wstring(L"Assets not available on the server will be permanently deleted."), vmMessageBox.GetMessage());
 
             bDialogShown = true;
             return DialogResult::Yes;
@@ -3955,6 +3960,38 @@ public:
         Assert::AreEqual(0, pLocalBadges->GetReferenceCount(sBadge2, true));
     }
 
+    TEST_METHOD(TestRevertSelectedCore)
+    {
+        AssetListViewModelHarness vmAssetList;
+        vmAssetList.MockGameId(22U);
+        vmAssetList.AddThreeAchievements();
+
+        bool bDialogShown = false;
+        vmAssetList.mockDesktop.ExpectWindow<MessageBoxViewModel>([&bDialogShown](MessageBoxViewModel& vmMessageBox)
+        {
+            Assert::AreEqual(std::wstring(L"Revert from server?"), vmMessageBox.GetHeader());
+            Assert::AreEqual(std::wstring(L"This will discard any local work for the selected assets and revert them to the last state retrieved from the server."), vmMessageBox.GetMessage());
+
+            bDialogShown = true;
+            return DialogResult::Yes;
+        });
+
+        auto* pAsset = vmAssetList.mockGameContext.Assets().FindAchievement({ 1U });
+        Assert::IsNotNull(pAsset);
+        Ensures(pAsset != nullptr);
+        pAsset->SetName(L"Apple");
+        Assert::AreEqual(AssetChanges::Modified, pAsset->GetChanges());
+
+        vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
+        vmAssetList.ForceUpdateButtons();
+        vmAssetList.AssertButtonState(RevertButtonState::Revert);
+
+        vmAssetList.RevertSelected();
+
+        Assert::IsTrue(bDialogShown);
+        Assert::AreEqual(AssetChanges::None, pAsset->GetChanges());
+    }
+
     TEST_METHOD(TestRevertSelectedCoreHardcore)
     {
         AssetListViewModelHarness vmAssetList;
@@ -4009,7 +4046,7 @@ public:
         vmAssetList.AddAchievement(AssetCategory::Local, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
         vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
         vmAssetList.ForceUpdateButtons();
-        vmAssetList.AssertButtonState(RevertButtonState::RevertAll);
+        vmAssetList.AssertButtonState(RevertButtonState::DeleteAll);
 
         auto* pAsset = vmAssetList.mockGameContext.Assets().FindAchievement({ 1U });
         Assert::IsNotNull(pAsset);
diff --git a/tests/ui/viewmodels/IntegrationMenuViewModel_Tests.cpp b/tests/ui/viewmodels/IntegrationMenuViewModel_Tests.cpp
index 3dd42a70..8dd397d8 100644
--- a/tests/ui/viewmodels/IntegrationMenuViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/IntegrationMenuViewModel_Tests.cpp
@@ -13,10 +13,13 @@
 #include "ui\viewmodels\MemoryInspectorViewModel.hh"
 #include "ui\viewmodels\OverlaySettingsViewModel.hh"
 #include "ui\viewmodels\RichPresenceMonitorViewModel.hh"
+#include "ui\viewmodels\UnknownGameViewModel.hh"
 
+#include "tests\data\DataAsserts.hh"
 #include "tests\ui\UIAsserts.hh"
 #include "tests\mocks\MockAchievementRuntime.hh"
 #include "tests\mocks\MockConfiguration.hh"
+#include "tests\mocks\MockConsoleContext.hh"
 #include "tests\mocks\MockDesktop.hh"
 #include "tests\mocks\MockEmulatorContext.hh"
 #include "tests\mocks\MockGameContext.hh"
@@ -40,6 +43,7 @@ private:
     {
     public:
         ra::api::mocks::MockServer mockServer;
+        ra::data::context::mocks::MockConsoleContext mockConsoleContext;
         ra::data::context::mocks::MockEmulatorContext mockEmulatorContext;
         ra::data::context::mocks::MockGameContext mockGameContext;
         ra::data::context::mocks::MockUserContext mockUserContext;
@@ -530,6 +534,47 @@ public:
         menu.AssertShowWindow<ra::ui::viewmodels::GameChecksumViewModel>(IDM_RA_GETROMCHECKSUM, true, "", DialogResult::None);
     }
 
+    TEST_METHOD(TestShowGameHashTestCompatibilityModeCancel)
+    {
+        IntegrationMenuViewModelHarness menu;
+
+        bool bDialogShown = false;
+        menu.mockDesktop.ExpectWindow<ra::ui::viewmodels::UnknownGameViewModel>([&bDialogShown](ra::ui::viewmodels::UnknownGameViewModel& vmUnknown)
+        {
+            bDialogShown = true;
+
+            Assert::IsFalse(vmUnknown.IsSelectedGameEnabled());
+
+            return DialogResult::Cancel;
+        });
+
+        menu.mockGameContext.SetMode(ra::data::context::GameContext::Mode::CompatibilityTest);
+        menu.ActivateMenuItem(IDM_RA_GETROMCHECKSUM);
+
+        Assert::IsTrue(bDialogShown);
+        Assert::AreEqual(ra::data::context::GameContext::Mode::CompatibilityTest, menu.mockGameContext.GetMode());
+    }
+
+    TEST_METHOD(TestShowGameHashTestCompatibilityModeAssociate)
+    {
+        IntegrationMenuViewModelHarness menu;
+
+        bool bDialogShown = false;
+        menu.mockDesktop.ExpectWindow<ra::ui::viewmodels::UnknownGameViewModel>([&bDialogShown](ra::ui::viewmodels::UnknownGameViewModel& vmUnknown)
+        {
+            bDialogShown = true;
+
+            Assert::IsFalse(vmUnknown.IsSelectedGameEnabled());
+
+            return DialogResult::OK;
+        });
+
+        menu.mockGameContext.SetMode(ra::data::context::GameContext::Mode::CompatibilityTest);
+        menu.ActivateMenuItem(IDM_RA_GETROMCHECKSUM);
+
+        Assert::IsTrue(bDialogShown);
+        Assert::AreEqual(ra::data::context::GameContext::Mode::Normal, menu.mockGameContext.GetMode());
+    }
 };
 
 } // namespace tests
diff --git a/tests/ui/viewmodels/TriggerViewModel_Tests.cpp b/tests/ui/viewmodels/TriggerViewModel_Tests.cpp
index fedefa52..2080532f 100644
--- a/tests/ui/viewmodels/TriggerViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/TriggerViewModel_Tests.cpp
@@ -749,6 +749,37 @@ public:
         Assert::AreEqual(std::string("0xH1234=16_0x 0008=2312_0xX0004=117835012"), vmTrigger.Serialize());
     }
 
+    TEST_METHOD(TestNewConditionCodeNoteSizeViewerSize)
+    {
+        std::array<uint8_t, 10> pMemory = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
+        TriggerViewModelHarness vmTrigger;
+        Parse(vmTrigger, "0xH1234=16");
+        Assert::AreEqual({ 1U }, vmTrigger.Conditions().Count());
+        vmTrigger.mockGameContext.SetCodeNote({ 8U }, L"[16-bit] test");
+        vmTrigger.mockGameContext.SetCodeNote({ 2U }, L"[8-bit] test");
+        vmTrigger.mockGameContext.SetCodeNote({ 4U }, L"test");
+
+        vmTrigger.InitializeMemory(&pMemory.at(0), pMemory.size());
+        vmTrigger.mockWindowManager.MemoryInspector.Viewer().SetAddress(8);
+        vmTrigger.mockWindowManager.MemoryInspector.Viewer().SetSize(MemSize::ThirtyTwoBit);
+        vmTrigger.NewCondition();
+
+        Assert::AreEqual({ 2U }, vmTrigger.Conditions().Count());
+        Assert::AreEqual(std::string("0xH1234=16_0x 0008=2312"), vmTrigger.Serialize());
+
+        vmTrigger.mockWindowManager.MemoryInspector.Viewer().SetAddress(4);
+        vmTrigger.NewCondition();
+
+        Assert::AreEqual({ 3U }, vmTrigger.Conditions().Count());
+        Assert::AreEqual(std::string("0xH1234=16_0x 0008=2312_0xX0004=117835012"), vmTrigger.Serialize());
+
+        vmTrigger.mockWindowManager.MemoryInspector.Viewer().SetAddress(2);
+        vmTrigger.NewCondition();
+
+        Assert::AreEqual({ 4U }, vmTrigger.Conditions().Count());
+        Assert::AreEqual(std::string("0xH1234=16_0x 0008=2312_0xX0004=117835012_0xH0002=2"), vmTrigger.Serialize());
+    }
+
     TEST_METHOD(TestAddGroupNoAlts)
     {
         TriggerViewModelHarness vmTrigger;
diff --git a/tests/ui/viewmodels/UnknownGameViewModel_Tests.cpp b/tests/ui/viewmodels/UnknownGameViewModel_Tests.cpp
index d5be01c6..03bc6dec 100644
--- a/tests/ui/viewmodels/UnknownGameViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/UnknownGameViewModel_Tests.cpp
@@ -7,6 +7,7 @@
 #include "tests\mocks\MockClipboard.hh"
 #include "tests\mocks\MockConsoleContext.hh"
 #include "tests\mocks\MockDesktop.hh"
+#include "tests\mocks\MockGameContext.hh"
 #include "tests\mocks\MockServer.hh"
 #include "tests\mocks\MockThreadPool.hh"
 
@@ -27,6 +28,7 @@ private:
     public:
         ra::api::mocks::MockServer mockServer;
         ra::data::context::mocks::MockConsoleContext mockConsoleContext;
+        ra::data::context::mocks::MockGameContext mockGameContext;
         ra::services::mocks::MockThreadPool mockThreadPool;
         ra::ui::mocks::MockDesktop mockDesktop;
 
@@ -58,6 +60,7 @@ public:
         Assert::AreEqual(std::wstring(L""), vmUnknownGame.GetNewGameName());
         Assert::AreEqual(std::wstring(L""), vmUnknownGame.GetChecksum());
         Assert::AreEqual({ 0U }, vmUnknownGame.GameTitles().Count());
+        Assert::IsTrue(vmUnknownGame.IsSelectedGameEnabled());
     }
 
     TEST_METHOD(TestInitializeGameTitles)
@@ -79,18 +82,22 @@ public:
 
         vmUnknownGame.InitializeGameTitles();
 
-        // <New Title> item should be loaded immediately
+        // <New Title> item should be loaded immediately, but linking should be disabled
         Assert::AreEqual({ 1U }, vmUnknownGame.GameTitles().Count());
         Assert::AreEqual(std::wstring(L"<New Title>"), vmUnknownGame.GameTitles().GetLabelForId(0));
         Assert::IsFalse(vmUnknownGame.GameTitles().IsFrozen());
+        Assert::IsFalse(vmUnknownGame.IsAssociateEnabled());
+        Assert::IsFalse(vmUnknownGame.IsSelectedGameEnabled());
 
-        // after server response, collection should have three items
+        // after server response, collection should have three items and be frozen. linking should be enabled
         vmUnknownGame.mockThreadPool.ExecuteNextTask();
         Assert::AreEqual({ 3U }, vmUnknownGame.GameTitles().Count());
         Assert::AreEqual(std::wstring(L"<New Title>"), vmUnknownGame.GameTitles().GetLabelForId(0));
         Assert::AreEqual(std::wstring(L"Game 33"), vmUnknownGame.GameTitles().GetLabelForId(33));
         Assert::AreEqual(std::wstring(L"Game 37"), vmUnknownGame.GameTitles().GetLabelForId(37));
         Assert::IsTrue(vmUnknownGame.GameTitles().IsFrozen());
+        Assert::IsTrue(vmUnknownGame.IsAssociateEnabled());
+        Assert::IsTrue(vmUnknownGame.IsSelectedGameEnabled());
     }
 
     TEST_METHOD(TestNameAndSelectionInteraction)
@@ -388,7 +395,62 @@ public:
 
         Assert::IsFalse(vmUnknownGame.BeginTest());
     }
-};
+
+    TEST_METHOD(TestInitializeTestCompatibilityMode)
+    {
+        UnknownGameViewModelHarness vmUnknownGame;
+        vmUnknownGame.mockConsoleContext.SetId(ConsoleID::C64);
+        vmUnknownGame.mockGameContext.SetGameId(33U);
+        vmUnknownGame.mockGameContext.SetGameTitle(L"Game 33");
+        vmUnknownGame.mockGameContext.SetGameHash("CHECKSUM");
+
+        vmUnknownGame.InitializeTestCompatibilityMode();
+
+        Assert::AreEqual({ 1U }, vmUnknownGame.GameTitles().Count());
+        Assert::AreEqual(33, vmUnknownGame.GameTitles().GetItemAt(0)->GetId());
+        Assert::AreEqual(std::wstring(L"Game 33"), vmUnknownGame.GameTitles().GetItemAt(0)->GetLabel());
+        Assert::AreEqual(33, vmUnknownGame.GetSelectedGameId());
+        Assert::AreEqual(std::wstring(L"CHECKSUM"), vmUnknownGame.GetChecksum());
+        Assert::IsTrue(vmUnknownGame.GameTitles().IsFrozen());
+
+        Assert::IsFalse(vmUnknownGame.IsSelectedGameEnabled());
+    }
+
+    TEST_METHOD(TestAssociateExistingTestCompatibilityMode)
+    {
+        UnknownGameViewModelHarness vmUnknownGame;
+        vmUnknownGame.mockConsoleContext.SetId(ConsoleID::PSP);
+        vmUnknownGame.mockGameContext.SetGameId(40U);
+        vmUnknownGame.mockGameContext.SetGameTitle(L"Game 40");
+        vmUnknownGame.mockGameContext.SetGameHash("CHECKSUM");
+        vmUnknownGame.SetEstimatedGameName(L"GAME40");
+        vmUnknownGame.InitializeTestCompatibilityMode();
+
+        vmUnknownGame.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            Assert::AreEqual(std::wstring(L"Are you sure you want to add a new hash to 'Game 40'?"), vmMessageBox.GetHeader());
+            return ra::ui::DialogResult::Yes;
+        });
+
+        vmUnknownGame.mockServer.HandleRequest<ra::api::SubmitNewTitle>([]
+        (const ra::api::SubmitNewTitle::Request& request, ra::api::SubmitNewTitle::Response& response)
+        {
+            Assert::AreEqual(41U, request.ConsoleId);
+            Assert::AreEqual(std::string("CHECKSUM"), request.Hash);
+            Assert::AreEqual(std::wstring(L"Game 40"), request.GameName);
+            Assert::AreEqual(std::wstring(L"GAME40"), request.Description);
+            Assert::AreEqual(40U, request.GameId);
+
+            response.Result = ra::api::ApiResult::Success;
+            response.GameId = 40U;
+
+            return true;
+        });
+
+        Assert::IsTrue(vmUnknownGame.Associate());
+        Assert::AreEqual(40, vmUnknownGame.GetSelectedGameId());
+        Assert::IsFalse(vmUnknownGame.GetTestMode());
+    }};
 
 } // namespace tests
 } // namespace viewmodels