Repository: RetroAchievements/RAIntegration
Pull request #810 (2021-09-27 03:13:34)

show formatted value in editor


Patch:
diff --git a/rcheevos b/rcheevos
index ee0ab901..d8431e92 160000
--- a/rcheevos
+++ b/rcheevos
@@ -1,1 +1,1 @@
-Subproject commit ee0ab901bbb5e93490e2c7587798eeacb15b986f
+Subproject commit d8431e92ea5305d244c0afccb0d69504893d6133
diff --git a/src/RA_Resource.h b/src/RA_Resource.h
index a0aab7d7..c159ff3b 100644
--- a/src/RA_Resource.h
+++ b/src/RA_Resource.h
@@ -101,6 +101,12 @@
 #define IDC_RA_COPY_ALL                 1196
 #define IDC_RA_CHK_HIGHLIGHTS           1197
 #define IDC_RA_CHK_AS_PERCENT           1198
+#define IDC_RA_LBL_FORMAT               1199
+#define IDC_RA_FORMAT                   1200
+#define IDC_RA_CHK_LOWER_IS_BETTER      1201
+#define IDC_RA_LBL_DISPLAY              1202
+#define IDC_RA_DISPLAY                  1203
+#define IDC_RA_LBX_LBOARD_PARTS         1204
 #define IDD_RA_MEMORY                   1501
 #define IDD_RA_ACHIEVEMENTS             1502
 #define IDD_RA_ACHIEVEMENTEDITOR        1503
@@ -183,7 +189,7 @@
 #ifndef APSTUDIO_READONLY_SYMBOLS
 #define _APS_NEXT_RESOURCE_VALUE        122
 #define _APS_NEXT_COMMAND_VALUE         40001
-#define _APS_NEXT_CONTROL_VALUE         1199
+#define _APS_NEXT_CONTROL_VALUE         1205
 #define _APS_NEXT_SYMED_VALUE           101
 #endif
 #endif
diff --git a/src/RA_Shared.rc b/src/RA_Shared.rc
index 221b9b7f..e7c3f0ed 100644
--- a/src/RA_Shared.rc
+++ b/src/RA_Shared.rc
@@ -117,21 +117,27 @@ FONT 8, "MS Shell Dlg", 400, 0, 0x1
 BEGIN
     LTEXT           "Title:",IDC_STATIC,4,7,42,8
     EDITTEXT        IDC_RA_TITLE,50,4,214,13,ES_AUTOHSCROLL
-    LTEXT           "ID:",IDC_RA_LBL_ID,280,7,11,8
+    LTEXT           "ID:",IDC_RA_LBL_ID,282,7,11,8
     EDITTEXT        IDC_RA_ID,293,5,44,13,ES_AUTOHSCROLL | WS_DISABLED,WS_EX_RIGHT
-    CONTROL         "Active",IDC_RA_CHK_ACTIVE,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,347,6,40,10
+    CONTROL         "Active",IDC_RA_CHK_ACTIVE,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,345,6,40,10
     LTEXT           "Description:",IDC_STATIC,4,23,42,8
     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           "Format:",IDC_RA_LBL_FORMAT,4,40,23,8
+    COMBOBOX        IDC_RA_FORMAT,50,38,42,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
     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           "Display:",IDC_RA_LBL_DISPLAY,196,40,24,8
+    RTEXT           "123456",IDC_RA_DISPLAY,230,40,32,8
+    CONTROL         "Lower is Better",IDC_RA_CHK_LOWER_IS_BETTER,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,282,38,60,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
     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
+    CONTROL         "",IDC_RA_LBX_LBOARD_PARTS,"SysListView32",LVS_REPORT | LVS_SHOWSELALWAYS | LVS_ALIGNLEFT | LVS_NOCOLUMNHEADER | WS_BORDER | WS_TABSTOP,388,4,53,48
     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,95
     PUSHBUTTON      "+",IDC_RA_ADD_GROUP,3,162,15,13
diff --git a/src/data/models/LeaderboardModel.cpp b/src/data/models/LeaderboardModel.cpp
index 2d200e7e..91ddf2ed 100644
--- a/src/data/models/LeaderboardModel.cpp
+++ b/src/data/models/LeaderboardModel.cpp
@@ -16,6 +16,7 @@ const IntModelProperty LeaderboardModel::SubmitTriggerProperty("LeaderboardModel
 const IntModelProperty LeaderboardModel::CancelTriggerProperty("LeaderboardModel", "CancelTrigger", 0);
 const IntModelProperty LeaderboardModel::ValueDefinitionProperty("LeaderboardModel", "ValueDefinition", 0);
 const IntModelProperty LeaderboardModel::ValueFormatProperty("LeaderboardModel", "ValueFormat", ra::etoi(ValueFormat::Value));
+const BoolModelProperty LeaderboardModel::LowerIsBetterProperty("LeaderboardModel", "LowerIsBetter", false);
 
 LeaderboardModel::LeaderboardModel() noexcept
 {
@@ -25,6 +26,9 @@ LeaderboardModel::LeaderboardModel() noexcept
     GSL_SUPPRESS_F6 AddAssetDefinition(m_pSubmitTrigger, SubmitTriggerProperty);
     GSL_SUPPRESS_F6 AddAssetDefinition(m_pCancelTrigger, CancelTriggerProperty);
     GSL_SUPPRESS_F6 AddAssetDefinition(m_pValueDefinition, ValueDefinitionProperty);
+
+    SetTransactional(ValueFormatProperty);
+    SetTransactional(LowerIsBetterProperty);
 }
 
 void LeaderboardModel::Activate()
diff --git a/src/data/models/LeaderboardModel.hh b/src/data/models/LeaderboardModel.hh
index d69a77c8..42fb5548 100644
--- a/src/data/models/LeaderboardModel.hh
+++ b/src/data/models/LeaderboardModel.hh
@@ -122,6 +122,21 @@ public:
     /// </summary>
     void SetValueFormat(ValueFormat nValue) { SetValue(ValueFormatProperty, ra::etoi(nValue)); }
 
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for whether or not lower values are better.
+    /// </summary>
+    static const BoolModelProperty LowerIsBetterProperty;
+
+    /// <summary>
+    /// Gets whether or not lower values are better.
+    /// </summary>
+    bool IsLowerBetter() const { return GetValue(LowerIsBetterProperty); }
+
+    /// <summary>
+    /// Sets whether or not lower values are better.
+    /// </summary>
+    void SetLowerIsBetter(bool nValue) { SetValue(LowerIsBetterProperty, nValue); }
+
     /// <summary>
     /// Populates the start/submit/cancel/value fields from a compound definition.
     /// </summary>
@@ -135,8 +150,17 @@ public:
     void Serialize(ra::services::TextWriter& pWriter) const override;
     bool Deserialize(ra::Tokenizer& pTokenizer) override;
 
-    //const CapturedTriggerHits& GetCapturedHits() const noexcept { return m_pCapturedTriggerHits; }
-    //void ResetCapturedHits() noexcept { m_pCapturedTriggerHits.Reset(); }
+    const CapturedTriggerHits& GetStartCapturedHits() const noexcept { return m_pCapturedStartTriggerHits; }
+    const CapturedTriggerHits& GetSubmitCapturedHits() const noexcept { return m_pCapturedSubmitTriggerHits; }
+    const CapturedTriggerHits& GetCancelCapturedHits() const noexcept { return m_pCapturedCancelTriggerHits; }
+    const CapturedTriggerHits& GetValueCapturedHits() const noexcept { return m_pCapturedValueDefinitionHits; }
+    void ResetCapturedHits() noexcept 
+    {
+        m_pCapturedStartTriggerHits.Reset();
+        m_pCapturedSubmitTriggerHits.Reset();
+        m_pCapturedCancelTriggerHits.Reset();
+        m_pCapturedValueDefinitionHits.Reset();
+    }
 
 protected:
     void OnValueChanged(const IntModelProperty::ChangeArgs& args) override;
diff --git a/src/data/models/TriggerValidation.cpp b/src/data/models/TriggerValidation.cpp
index 6e2fb192..03d2cda1 100644
--- a/src/data/models/TriggerValidation.cpp
+++ b/src/data/models/TriggerValidation.cpp
@@ -8,25 +8,143 @@ namespace ra {
 namespace data {
 namespace models {
 
+MemSize TriggerValidation::MapRcheevosMemSize(char nSize) noexcept
+{
+    switch (nSize)
+    {
+        case RC_MEMSIZE_BIT_0: return MemSize::Bit_0;
+        case RC_MEMSIZE_BIT_1: return MemSize::Bit_1;
+        case RC_MEMSIZE_BIT_2: return MemSize::Bit_2;
+        case RC_MEMSIZE_BIT_3: return MemSize::Bit_3;
+        case RC_MEMSIZE_BIT_4: return MemSize::Bit_4;
+        case RC_MEMSIZE_BIT_5: return MemSize::Bit_5;
+        case RC_MEMSIZE_BIT_6: return MemSize::Bit_6;
+        case RC_MEMSIZE_BIT_7: return MemSize::Bit_7;
+        case RC_MEMSIZE_LOW: return MemSize::Nibble_Lower;
+        case RC_MEMSIZE_HIGH: return MemSize::Nibble_Upper;
+        case RC_MEMSIZE_8_BITS: return MemSize::EightBit;
+        case RC_MEMSIZE_16_BITS: return MemSize::SixteenBit;
+        case RC_MEMSIZE_24_BITS: return MemSize::TwentyFourBit;
+        case RC_MEMSIZE_32_BITS: return MemSize::ThirtyTwoBit;
+        case RC_MEMSIZE_BITCOUNT: return MemSize::BitCount;
+        case RC_MEMSIZE_16_BITS_BE: return MemSize::SixteenBitBigEndian;
+        case RC_MEMSIZE_24_BITS_BE: return MemSize::TwentyFourBitBigEndian;
+        case RC_MEMSIZE_32_BITS_BE: return MemSize::ThirtyTwoBitBigEndian;
+        default:
+            assert(!"Unsupported operand size");
+            return MemSize::EightBit;
+    }
+}
+
+static unsigned MaxValue(const rc_operand_t* pOperand)
+{
+    if (pOperand->type == RC_OPERAND_CONST)
+        return pOperand->value.num;
+
+    if (!rc_operand_is_memref(pOperand))
+        return 0xFFFFFFFF;
+
+    return MemSizeMax(TriggerValidation::MapRcheevosMemSize(pOperand->size));
+}
+
+static int ValidateRange(unsigned nMinValue, unsigned nMaxValue, char nOperator, unsigned nMax, std::wstring& sError) {
+    switch (nOperator) {
+        case RC_OPERATOR_AND:
+            if (nMinValue > nMax) {
+                sError = L"Mask has more bits than source";
+                return false;
+            }
+            else if (nMinValue == 0 && nMaxValue == 0) {
+                sError = L"Result of mask is always 0";
+                return false;
+            }
+            break;
+
+        case RC_OPERATOR_EQ:
+            if (nMinValue > nMax) {
+                sError = L"Comparison is never true";
+                return false;
+            }
+            break;
+
+        case RC_OPERATOR_NE:
+            if (nMinValue > nMax) {
+                sError = L"Comparison is always true";
+                return false;
+            }
+            break;
+
+        case RC_OPERATOR_GE:
+            if (nMinValue > nMax) {
+                sError = L"Comparison is never true";
+                return false;
+            }
+            if (nMaxValue == 0) {
+                sError = L"Comparison is always true";
+                return false;
+            }
+            break;
+
+        case RC_OPERATOR_GT:
+            if (nMinValue >= nMax) {
+                sError = L"Comparison is never true";
+                return false;
+            }
+            break;
+
+        case RC_OPERATOR_LE:
+            if (nMinValue >= nMax) {
+                sError = L"Comparison is always true";
+                return false;
+            }
+            break;
+
+        case RC_OPERATOR_LT:
+            if (nMinValue > nMax) {
+                sError = L"Comparison is always true";
+                return false;
+            }
+            if (nMaxValue == 0) {
+                sError = L"Comparison is never true";
+                return false;
+            }
+            break;
+    }
+
+    return true;
+}
+
 static bool ValidateCondSet(const rc_condset_t* pCondSet, std::wstring& sError)
 {
     bool bInAddHits = false;
     bool bIsCombining = false;
+    unsigned long long nAddSourceMax = 0;
 
     int nIndex = 1;
     const rc_condition_t* pCondition = pCondSet->conditions;
-    while (pCondition != nullptr)
+    for (; pCondition != nullptr; ++nIndex, pCondition = pCondition->next)
     {
+        unsigned nMax = MaxValue(&pCondition->operand1); // maximum possible calculated value for comparison
+
         switch (pCondition->type)
         {
+            case RC_CONDITION_ADD_SOURCE:
+                nAddSourceMax += nMax;
+                bIsCombining = true;
+                continue;
+
+            case RC_CONDITION_SUB_SOURCE:
+                if (nAddSourceMax < nMax) // ignore potential underflow - may be expected
+                    nAddSourceMax = 0xFFFFFFFF;
+                bIsCombining = true;
+                continue;
+
             case RC_CONDITION_ADD_HITS:
             case RC_CONDITION_SUB_HITS:
                 bIsCombining = true;
                 bInAddHits = true;
                 break;
 
-            case RC_CONDITION_ADD_SOURCE:
-            case RC_CONDITION_SUB_SOURCE:
             case RC_CONDITION_ADD_ADDRESS:
             case RC_CONDITION_AND_NEXT:
             case RC_CONDITION_OR_NEXT:
@@ -50,8 +168,43 @@ static bool ValidateCondSet(const rc_condset_t* pCondSet, std::wstring& sError)
                 break;
         }
 
-        ++nIndex;
-        pCondition = pCondition->next;
+        if (nAddSourceMax)
+        {
+            const unsigned long long overflow = nAddSourceMax + nMax;
+            if (overflow > 0xFFFFFFFFUL)
+                nMax = 0xFFFFFFFF;
+            else
+                nMax = gsl::narrow_cast<unsigned>(overflow);
+        }
+
+        // maximum comparison value
+        const unsigned nMaxValue = MaxValue(&pCondition->operand2);
+
+        // minimum comparison value
+        const unsigned nMinValue = (pCondition->operand2.type == RC_OPERAND_CONST) ? pCondition->operand2.value.num : 0;
+
+        // if comparing two memrefs outside of an AddSource chain, make sure their sizes match
+        const bool bIsMemRef = rc_operand_is_memref(&pCondition->operand1);
+        if (bIsMemRef && nMaxValue != nMax && nAddSourceMax == 0 && rc_operand_is_memref(&pCondition->operand2))
+        {
+            sError = ra::StringPrintf(L"Condition %u: Comparing different memory sizes", nIndex);
+            return false;
+        }
+
+        // if nMax is from a memory reference or AddSource chain, or the right side is a memory reference,
+        // validate that nMax can fall between bMinValue and nMaxValue
+        if (bIsMemRef || nMinValue == 0 || nAddSourceMax)
+        {
+            if (!ValidateRange(nMinValue, nMaxValue, pCondition->oper, nMax, sError))
+            {
+                sError.insert(0, L"Condition : ");
+                sError.insert(10, std::to_wstring(nIndex));
+                return false;
+            }
+        }
+
+        // reset AddSource chain
+        nAddSourceMax = 0;
     }
 
     if (bIsCombining)
diff --git a/src/data/models/TriggerValidation.hh b/src/data/models/TriggerValidation.hh
index 677719c8..6b2f9b45 100644
--- a/src/data/models/TriggerValidation.hh
+++ b/src/data/models/TriggerValidation.hh
@@ -2,6 +2,8 @@
 #define RA_DATA_TRIGGER_VALIDATION_H
 #pragma once
 
+#include "data\Types.hh"
+
 namespace ra {
 namespace data {
 namespace models {
@@ -10,6 +12,7 @@ class TriggerValidation
 {
 public:
     static bool Validate(const std::string& sTrigger, std::wstring& sError);
+    static MemSize MapRcheevosMemSize(char nSize) noexcept;
 };
 
 } // namespace models
diff --git a/src/services/AchievementRuntime.cpp b/src/services/AchievementRuntime.cpp
index c9f72e2f..e4526e81 100644
--- a/src/services/AchievementRuntime.cpp
+++ b/src/services/AchievementRuntime.cpp
@@ -662,9 +662,28 @@ bool AchievementRuntime::LoadProgressFromFile(const char* sLoadStateFilename)
     auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
     for (gsl::index nIndex = 0; nIndex < gsl::narrow_cast<gsl::index>(pGameContext.Assets().Count()); ++nIndex)
     {
-        auto* pAchievement = dynamic_cast<ra::data::models::AchievementModel*>(pGameContext.Assets().GetItemAt(nIndex));
-        if (pAchievement != nullptr && pAchievement->IsActive())
-            pAchievement->ResetCapturedHits();
+        auto* pAsset = pGameContext.Assets().GetItemAt(nIndex);
+        switch (pAsset->GetType())
+        {
+            case ra::data::models::AssetType::Achievement:
+            {
+                auto* pAchievement = dynamic_cast<ra::data::models::AchievementModel*>(pAsset);
+                if (pAchievement != nullptr && pAchievement->IsActive())
+                    pAchievement->ResetCapturedHits();
+                break;
+            }
+
+            case ra::data::models::AssetType::Leaderboard:
+            {
+                auto* pLeaderboard = dynamic_cast<ra::data::models::LeaderboardModel*>(pAsset);
+                if (pLeaderboard != nullptr && pLeaderboard->IsActive())
+                    pLeaderboard->ResetCapturedHits();
+                break;
+            }
+
+            default:
+                break;
+        }
     }
 
     // reset the runtime state, then apply state from file
diff --git a/src/services/GameIdentifier.cpp b/src/services/GameIdentifier.cpp
index 3e20aebc..12625c6f 100644
--- a/src/services/GameIdentifier.cpp
+++ b/src/services/GameIdentifier.cpp
@@ -1,7 +1,6 @@
 #include "GameIdentifier.hh"
 
 #include "RA_Log.h"
-#include "RA_md5factory.h"
 #include "RA_StringUtils.h"
 
 #include <rc_hash.h>
@@ -39,7 +38,7 @@ unsigned int GameIdentifier::IdentifyGame(const BYTE* pROM, size_t nROMSize)
 
     if (pROM == nullptr || nROMSize == 0)
     {
-        m_sPendingMD5.clear();
+        m_sPendingHash.clear();
         m_nPendingGameId = 0U;
         return 0U;
     }
@@ -50,7 +49,7 @@ unsigned int GameIdentifier::IdentifyGame(const BYTE* pROM, size_t nROMSize)
     return IdentifyHash(hash);
 }
 
-unsigned int GameIdentifier::IdentifyHash(const std::string& sMD5)
+unsigned int GameIdentifier::IdentifyHash(const std::string& sHash)
 {
     if (!ra::services::ServiceLocator::Get<ra::data::context::UserContext>().IsLoggedIn())
     {
@@ -62,53 +61,63 @@ unsigned int GameIdentifier::IdentifyHash(const std::string& sMD5)
     unsigned int nGameId = 0U;
     m_nPendingMode = ra::data::context::GameContext::Mode::Normal;
 
-    ra::api::ResolveHash::Request request;
-    request.Hash = sMD5;
-
-    const auto response = request.Call();
-    if (response.Succeeded())
+    const auto pIter = m_mKnownHashes.find(sHash);
+    if (pIter != m_mKnownHashes.end())
     {
-        nGameId = response.GameId;
-        if (nGameId == 0) // Unknown
+        RA_LOG_INFO("Using previously looked up game ID %u for hash %s", nGameId, sHash);
+        nGameId = pIter->second;
+    }
+    else
+    {
+        ra::api::ResolveHash::Request request;
+        request.Hash = sHash;
+
+        const auto response = request.Call();
+        if (response.Succeeded())
         {
-            RA_LOG_INFO("Could not identify game with hash %s", sMD5);
+            nGameId = response.GameId;
+            if (nGameId == 0) // Unknown
+            {
+                RA_LOG_INFO("Could not identify game with hash %s", sHash);
 
-            auto sEstimatedGameTitle = ra::services::ServiceLocator::Get<ra::data::context::EmulatorContext>().GetGameTitle();
+                auto sEstimatedGameTitle = ra::services::ServiceLocator::Get<ra::data::context::EmulatorContext>().GetGameTitle();
 
-            ra::ui::viewmodels::UnknownGameViewModel vmUnknownGame;
-            vmUnknownGame.InitializeGameTitles();
-            vmUnknownGame.SetSystemName(ra::services::ServiceLocator::Get<ra::data::context::ConsoleContext>().Name());
-            vmUnknownGame.SetChecksum(ra::Widen(sMD5));
-            vmUnknownGame.SetEstimatedGameName(ra::Widen(sEstimatedGameTitle));
-            vmUnknownGame.SetNewGameName(vmUnknownGame.GetEstimatedGameName());
+                ra::ui::viewmodels::UnknownGameViewModel vmUnknownGame;
+                vmUnknownGame.InitializeGameTitles();
+                vmUnknownGame.SetSystemName(ra::services::ServiceLocator::Get<ra::data::context::ConsoleContext>().Name());
+                vmUnknownGame.SetChecksum(ra::Widen(sHash));
+                vmUnknownGame.SetEstimatedGameName(ra::Widen(sEstimatedGameTitle));
+                vmUnknownGame.SetNewGameName(vmUnknownGame.GetEstimatedGameName());
 
-            if (vmUnknownGame.ShowModal() == ra::ui::DialogResult::OK)
-            {
-                nGameId = vmUnknownGame.GetSelectedGameId();
+                if (vmUnknownGame.ShowModal() == ra::ui::DialogResult::OK)
+                {
+                    nGameId = vmUnknownGame.GetSelectedGameId();
 
-                if (vmUnknownGame.GetTestMode())
-                    m_nPendingMode = ra::data::context::GameContext::Mode::CompatibilityTest;
+                    if (vmUnknownGame.GetTestMode())
+                        m_nPendingMode = ra::data::context::GameContext::Mode::CompatibilityTest;
+                }
+            }
+            else
+            {
+                RA_LOG_INFO("Successfully looked up game with ID %u", nGameId);
+                m_mKnownHashes.insert_or_assign(sHash, nGameId);
             }
         }
         else
         {
-            RA_LOG_INFO("Successfully looked up game with ID %u", nGameId);
-        }
-    }
-    else
-    {
-        std::wstring sErrorMessage = ra::Widen(response.ErrorMessage);
-        if (sErrorMessage.empty())
-        {
-            auto& pConfiguration = ra::services::ServiceLocator::Get<ra::services::IConfiguration>();
-            sErrorMessage = ra::StringPrintf(L"Error from %s", pConfiguration.GetHostName());
-        }
+            std::wstring sErrorMessage = ra::Widen(response.ErrorMessage);
+            if (sErrorMessage.empty())
+            {
+                auto& pConfiguration = ra::services::ServiceLocator::Get<ra::services::IConfiguration>();
+                sErrorMessage = ra::StringPrintf(L"Error from %s", pConfiguration.GetHostName());
+            }
 
-        ra::ui::viewmodels::MessageBoxViewModel::ShowErrorMessage(L"Could not identify game.", sErrorMessage);
+            ra::ui::viewmodels::MessageBoxViewModel::ShowErrorMessage(L"Could not identify game.", sErrorMessage);
+        }
     }
 
     // store the hash and game id - will be used by _RA_ActivateGame (if called)
-    m_sPendingMD5 = sMD5;
+    m_sPendingHash = sHash;
     m_nPendingGameId = nGameId;
 
     auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
@@ -116,7 +125,7 @@ unsigned int GameIdentifier::IdentifyHash(const std::string& sMD5)
     {
         // same as currently loaded rom. assume user is switching disks and _RA_ActivateGame won't be called.
         // update the hash now. if it does get called, this is redundant.
-        pGameContext.SetGameHash(sMD5);
+        pGameContext.SetGameHash(sHash);
         pGameContext.SetMode(m_nPendingMode);
     }
 
@@ -141,7 +150,7 @@ void GameIdentifier::ActivateGame(unsigned int nGameId)
 
         auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
         pGameContext.LoadGame(nGameId, m_nPendingMode);
-        pGameContext.SetGameHash((nGameId == m_nPendingGameId) ? m_sPendingMD5 : "");
+        pGameContext.SetGameHash((nGameId == m_nPendingGameId) ? m_sPendingHash : "");
 
         ra::services::ServiceLocator::GetMutable<ra::data::context::SessionTracker>().BeginSession(nGameId);
 
@@ -182,7 +191,7 @@ void GameIdentifier::ActivateGame(unsigned int nGameId)
 
         auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
         pGameContext.LoadGame(0U, m_nPendingMode);
-        pGameContext.SetGameHash((m_nPendingGameId == 0) ? m_sPendingMD5 : "");
+        pGameContext.SetGameHash((m_nPendingGameId == 0) ? m_sPendingHash : "");
     }
 
     ra::services::ServiceLocator::GetMutable<ra::data::context::EmulatorContext>().ResetMemoryModified();
diff --git a/src/services/GameIdentifier.hh b/src/services/GameIdentifier.hh
index 51039757..6f009554 100644
--- a/src/services/GameIdentifier.hh
+++ b/src/services/GameIdentifier.hh
@@ -27,8 +27,8 @@ public:
     /// <summary>
     /// Identifies a game.
     /// </summary>
-    /// <param name="sMD5">The unique identifier of the game.</param>
-    unsigned int IdentifyHash(const std::string& sMD5);
+    /// <param name="sHash">The unique identifier of the game.</param>
+    unsigned int IdentifyHash(const std::string& sHash);
 
     /// <summary>
     /// Activates a game.
@@ -37,9 +37,10 @@ public:
     void ActivateGame(unsigned int nGameId);
 
 private:
-    std::string m_sPendingMD5;
+    std::string m_sPendingHash;
     unsigned int m_nPendingGameId{};
     ra::data::context::GameContext::Mode m_nPendingMode{};
+    std::map<std::string, unsigned> m_mKnownHashes;
 };
 
 } // namespace services
diff --git a/src/services/SearchResults.cpp b/src/services/SearchResults.cpp
index 45620638..f2cd8d3d 100644
--- a/src/services/SearchResults.cpp
+++ b/src/services/SearchResults.cpp
@@ -500,12 +500,20 @@ public:
     // populates a vector of addresses that match the specified filter when applied to a previous search result
     void ApplyFilter(SearchResults& srNew, const SearchResults& srPrevious) const override
     {
+        size_t nCompareLength = 16; // maximum length for generic search
         std::vector<unsigned char> vSearchText;
         GetSearchText(vSearchText, srNew.GetFilterString());
 
-        // can't match 0 characters!
         if (vSearchText.empty())
-            return;
+        {
+            // can't match 0 characters!
+            if (srNew.GetFilterType() == SearchFilterType::Constant)
+                return;
+        }
+        else
+        {
+            nCompareLength = vSearchText.size();
+        }
 
         unsigned int nLargestBlock = 0U;
         for (auto& block : GetBlocks(srPrevious))
@@ -530,7 +538,7 @@ public:
                 case SearchFilterType::Constant:
                     for (unsigned int i = 0; i < nStop; ++i)
                     {
-                        if (CompareMemory(&vMemory.at(i), &vSearchText.at(0), vSearchText.size(), srNew.GetFilterComparison()))
+                        if (CompareMemory(&vMemory.at(i), &vSearchText.at(0), nCompareLength, srNew.GetFilterComparison()))
                         {
                             const unsigned int nAddress = block.GetAddress() + i;
                             AddMatch(vMatches, srPrevious, nAddress);
@@ -542,7 +550,7 @@ public:
                 case SearchFilterType::LastKnownValue:
                     for (unsigned int i = 0; i < nStop; ++i)
                     {
-                        if (CompareMemory(&vMemory.at(i), block.GetBytes() + i, vSearchText.size(), srNew.GetFilterComparison()))
+                        if (CompareMemory(&vMemory.at(i), block.GetBytes() + i, nCompareLength, srNew.GetFilterComparison()))
                         {
                             const unsigned int nAddress = block.GetAddress() + i;
                             AddMatch(vMatches, srPrevious, nAddress);
@@ -556,10 +564,12 @@ public:
                 auto& vMatchingAddresses = GetMatchingAddresses(srNew);
                 vMatchingAddresses.insert(vMatchingAddresses.end(), vMatches.begin(), vMatches.end());
 
-                // adjust the last entry to account for the length of the string to ensure
+                // adjust the block size to account for the length of the string to ensure
                 // the block contains the whole string
-                vMatches.back() += gsl::narrow_cast<ra::ByteAddress>(vSearchText.size() - 1);
-                AddBlock(srNew, vMatches, vMemory, block.GetAddress());
+                // (duplicates the AddBlock function, but replaces "GetPadding()-1" with "nCompareLength")
+                const size_t nBlockSize = vMatches.back() - vMatches.front() + nCompareLength;
+                MemBlock& newBlock = AddBlock(srNew, vMatches.front(), gsl::narrow_cast<unsigned int>(nBlockSize));
+                memcpy(newBlock.GetBytes(), &vMemory.at(vMatches.front() - block.GetAddress()), nBlockSize);
 
                 vMatches.clear();
             }
diff --git a/src/ui/viewmodels/AssetEditorViewModel.cpp b/src/ui/viewmodels/AssetEditorViewModel.cpp
index 2c20d8c4..176f661b 100644
--- a/src/ui/viewmodels/AssetEditorViewModel.cpp
+++ b/src/ui/viewmodels/AssetEditorViewModel.cpp
@@ -1,5 +1,8 @@
 #include "AssetEditorViewModel.hh"
 
+#include "data\models\AchievementModel.hh"
+#include "data\models\LeaderboardModel.hh"
+
 #include "services\AchievementRuntime.hh"
 #include "services\IConfiguration.hh"
 #include "services\ServiceLocator.hh"
@@ -17,8 +20,14 @@ const StringModelProperty AssetEditorViewModel::NameProperty("AssetEditorViewMod
 const StringModelProperty AssetEditorViewModel::DescriptionProperty("AssetEditorViewModel", "Description", L"Open an achievement from the Achievements List");
 const IntModelProperty AssetEditorViewModel::CategoryProperty("AssetEditorViewModel", "Category", ra::etoi(ra::data::models::AssetCategory::Core));
 const IntModelProperty AssetEditorViewModel::StateProperty("AssetEditorViewModel", "State", ra::etoi(ra::data::models::AssetState::Inactive));
+const BoolModelProperty AssetEditorViewModel::IsAchievementProperty("AssetEditorViewModel", "IsAchievement", true);
 const IntModelProperty AssetEditorViewModel::PointsProperty("AssetEditorViewModel", "Points", 0);
 const StringModelProperty AssetEditorViewModel::BadgeProperty("AssetEditorViewModel", "Badge", L"00000");
+const BoolModelProperty AssetEditorViewModel::IsLeaderboardProperty("AssetEditorViewModel", "IsLeaderboard", false);
+const IntModelProperty AssetEditorViewModel::SelectedLeaderboardPartProperty("AssetEditorViewModel", "SelectedLeaderboardPart", ra::etoi(AssetEditorViewModel::LeaderboardPart::Start));
+const IntModelProperty AssetEditorViewModel::ValueFormatProperty("AssetEditorViewModel", "ValueFormat", ra::etoi(ra::data::ValueFormat::Value));
+const BoolModelProperty AssetEditorViewModel::LowerIsBetterProperty("AssetEditorViewModel", "LowerIsBetter", false);
+const StringModelProperty AssetEditorViewModel::FormattedValueProperty("AssetEditorViewModel", "FormattedValue", L"0");
 const BoolModelProperty AssetEditorViewModel::PauseOnResetProperty("AssetEditorViewModel", "PauseOnReset", false);
 const BoolModelProperty AssetEditorViewModel::PauseOnTriggerProperty("AssetEditorViewModel", "PauseOnTrigger", false);
 const BoolModelProperty AssetEditorViewModel::DebugHighlightsEnabledProperty("AssetEditorViewModel", "DebugHighlightsEnabled", false);
@@ -37,6 +46,20 @@ AssetEditorViewModel::AssetEditorViewModel() noexcept
     SetWindowTitle(L"Achievement Editor");
 
     m_vmTrigger.AddNotifyTarget(*this);
+
+    m_vFormats.Add(ra::etoi(ra::data::ValueFormat::Value), L"Value");
+    m_vFormats.Add(ra::etoi(ra::data::ValueFormat::Score), L"Score");
+    m_vFormats.Add(ra::etoi(ra::data::ValueFormat::Frames), L"Frames");
+    m_vFormats.Add(ra::etoi(ra::data::ValueFormat::Centiseconds), L"Centiseconds");
+    m_vFormats.Add(ra::etoi(ra::data::ValueFormat::Seconds), L"Seconds");
+    m_vFormats.Add(ra::etoi(ra::data::ValueFormat::Minutes), L"Minutes");
+
+    m_vLeaderboardParts.Add(ra::etoi(LeaderboardPart::Start), L"Start");
+    m_vLeaderboardParts.Add(ra::etoi(LeaderboardPart::Submit), L"Submit");
+    m_vLeaderboardParts.Add(ra::etoi(LeaderboardPart::Cancel), L"Cancel");
+    m_vLeaderboardParts.Add(ra::etoi(LeaderboardPart::Value), L"Value");
+    m_vLeaderboardParts.GetItemAt(0)->SetSelected(true);
+    m_vLeaderboardParts.AddNotifyTarget(*this);
 }
 
 AssetEditorViewModel::~AssetEditorViewModel()
@@ -116,33 +139,55 @@ void AssetEditorViewModel::LoadAsset(ra::data::models::AssetModelBase* pAsset)
         else
             SetValue(IDProperty, pAsset->GetID());
 
-        const auto* pAchievement = dynamic_cast<ra::data::models::AchievementModel*>(pAsset);
-        if (pAchievement != nullptr)
+        const auto* pLeaderboard = dynamic_cast<ra::data::models::LeaderboardModel*>(pAsset);
+        if (pLeaderboard != nullptr)
+        {
+            SetWindowTitle(L"Leaderboard Editor");
+            SetValue(IsAchievementProperty, false);
+            SetValue(IsLeaderboardProperty, true);
+            SetValue(HasMeasuredProperty, true);
+
+            SetValueFormat(pLeaderboard->GetValueFormat());
+            SetLowerIsBetter(pLeaderboard->IsLowerBetter());
+
+            m_pAsset = pAsset; // must be set before calling UpdateLeaderboardTrigger
+            UpdateLeaderboardTrigger();
+        }
+        else
         {
-            SetPoints(pAchievement->GetPoints());
-            SetBadge(pAchievement->GetBadge());
-            SetPauseOnReset(pAchievement->IsPauseOnReset());
-            SetPauseOnTrigger(pAchievement->IsPauseOnTrigger());
             SetWindowTitle(L"Achievement Editor");
+            SetValue(IsLeaderboardProperty, false);
+            SetValue(IsAchievementProperty, true);
 
-            auto* pTrigger = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>().GetAchievementTrigger(pAsset->GetID());
-            if (pTrigger != nullptr)
+            const auto* pAchievement = dynamic_cast<ra::data::models::AchievementModel*>(pAsset);
+            if (pAchievement != nullptr)
             {
-                Trigger().InitializeFrom(*pTrigger);
+                SetPoints(pAchievement->GetPoints());
+                SetBadge(pAchievement->GetBadge());
+                SetPauseOnReset(pAchievement->IsPauseOnReset());
+                SetPauseOnTrigger(pAchievement->IsPauseOnTrigger());
+
+                auto* pTrigger = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>().GetAchievementTrigger(pAsset->GetID());
+                if (pTrigger != nullptr)
+                {
+                    Trigger().InitializeFrom(*pTrigger);
+                }
+                else
+                {
+                    Trigger().InitializeFrom(pAchievement->GetTrigger(), pAchievement->GetCapturedHits());
+                    pTrigger = Trigger().GetTriggerFromString();
+                }
+
+                SetValue(HasMeasuredProperty, (pTrigger != nullptr && pTrigger->measured_target != 0));
             }
             else
             {
-                Trigger().InitializeFrom(pAchievement->GetTrigger(), pAchievement->GetCapturedHits());
-                pTrigger = Trigger().GetTriggerFromString();
+                ra::data::models::CapturedTriggerHits pCapturedHits;
+                Trigger().InitializeFrom("", pCapturedHits);
+                SetPauseOnReset(PauseOnResetProperty.GetDefaultValue());
+                SetPauseOnTrigger(PauseOnTriggerProperty.GetDefaultValue());
+                SetValue(HasMeasuredProperty, false);
             }
-
-            SetValue(HasMeasuredProperty, (pTrigger != nullptr && pTrigger->measured_target != 0));
-        }
-        else
-        {
-            ra::data::models::CapturedTriggerHits pCapturedHits;
-            Trigger().InitializeFrom("", pCapturedHits);
-            SetValue(HasMeasuredProperty, false);
         }
 
         m_pAsset = pAsset;
@@ -173,12 +218,85 @@ void AssetEditorViewModel::LoadAsset(ra::data::models::AssetModelBase* pAsset)
     }
 }
 
+void AssetEditorViewModel::UpdateLeaderboardTrigger()
+{
+    const auto* pLeaderboardDefinition = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>().GetLeaderboardDefinition(m_pAsset->GetID());
+    if (pLeaderboardDefinition != nullptr)
+    {
+        switch (GetSelectedLeaderboardPart())
+        {
+            case LeaderboardPart::Start:
+                Trigger().InitializeFrom(pLeaderboardDefinition->start);
+                return;
+            case LeaderboardPart::Submit:
+                Trigger().InitializeFrom(pLeaderboardDefinition->submit);
+                return;
+            case LeaderboardPart::Cancel:
+                Trigger().InitializeFrom(pLeaderboardDefinition->cancel);
+                return;
+            case LeaderboardPart::Value:
+                //Trigger().InitializeFrom(pLeaderboardDefinition->value.conditions);
+                return;
+            default:
+                break;
+        }
+    }
+    else
+    {
+        const auto* pLeaderboard = dynamic_cast<ra::data::models::LeaderboardModel*>(m_pAsset);
+        if (pLeaderboard != nullptr)
+        {
+            switch (GetSelectedLeaderboardPart())
+            {
+                case LeaderboardPart::Start:
+                    Trigger().InitializeFrom(pLeaderboard->GetStartTrigger(), pLeaderboard->GetStartCapturedHits());
+                    return;
+                case LeaderboardPart::Submit:
+                    Trigger().InitializeFrom(pLeaderboard->GetSubmitTrigger(), pLeaderboard->GetSubmitCapturedHits());
+                    return;
+                case LeaderboardPart::Cancel:
+                    Trigger().InitializeFrom(pLeaderboard->GetCancelTrigger(), pLeaderboard->GetCancelCapturedHits());
+                    return;
+                case LeaderboardPart::Value:
+                    Trigger().InitializeFrom(pLeaderboard->GetValueDefinition(), pLeaderboard->GetValueCapturedHits());
+                    return;
+                default:
+                    break;
+            }
+        }
+    }
+
+    ra::data::models::CapturedTriggerHits pCapturedHits;
+    Trigger().InitializeFrom("", pCapturedHits);
+}
+
+void AssetEditorViewModel::OnViewModelBoolValueChanged(gsl::index, const BoolModelProperty::ChangeArgs& args)
+{
+    if (args.Property == LookupItemViewModel::IsSelectedProperty)
+    {
+        for (gsl::index i = 0; i < ra::to_signed(m_vLeaderboardParts.Count()); ++i)
+        {
+            const auto* pItem = m_vLeaderboardParts.GetItemAt(i);
+            Expects(pItem != nullptr);
+            if (pItem->IsSelected())
+            {
+                SetValue(SelectedLeaderboardPartProperty, pItem->GetId());
+                return;
+            }
+        }
+
+        SetSelectedLeaderboardPart(LeaderboardPart::None);
+    }
+}
+
 void AssetEditorViewModel::OnDataModelBoolValueChanged(const BoolModelProperty::ChangeArgs& args)
 {
     if (args.Property == ra::data::models::AchievementModel::PauseOnResetProperty)
         SetPauseOnReset(args.tNewValue);
     else if (args.Property == ra::data::models::AchievementModel::PauseOnTriggerProperty)
         SetPauseOnTrigger(args.tNewValue);
+    else if (args.Property == ra::data::models::LeaderboardModel::LowerIsBetterProperty)
+        SetLowerIsBetter(args.tNewValue);
 }
 
 void AssetEditorViewModel::OnDataModelStringValueChanged(const StringModelProperty::ChangeArgs& args)
@@ -203,6 +321,8 @@ void AssetEditorViewModel::OnDataModelIntValueChanged(const IntModelProperty::Ch
         SetCategory(ra::itoe<ra::data::models::AssetCategory>(args.tNewValue));
     else if (args.Property == ra::data::models::AchievementModel::PointsProperty)
         SetPoints(args.tNewValue);
+    else if (args.Property == ra::data::models::LeaderboardModel::ValueFormatProperty)
+        SetValueFormat(ra::itoe<ra::data::ValueFormat>(args.tNewValue));
     else if (args.Property == ra::data::models::AssetModelBase::IDProperty)
         SetValue(IDProperty, args.tNewValue);
 }
@@ -223,6 +343,15 @@ void AssetEditorViewModel::OnValueChanged(const BoolModelProperty::ChangeArgs& a
         else if (args.Property == PauseOnTriggerProperty)
             pAchievement->SetPauseOnTrigger(args.tNewValue);
     }
+    else
+    {
+        auto* pLeaderboard = dynamic_cast<ra::data::models::LeaderboardModel*>(m_pAsset);
+        if (pLeaderboard != nullptr)
+        {
+            if (args.Property == LowerIsBetterProperty)
+                pLeaderboard->SetLowerIsBetter(args.tNewValue);
+        }
+    }
 
     if (args.Property == DebugHighlightsEnabledProperty)
     {
@@ -325,6 +454,34 @@ void AssetEditorViewModel::OnValueChanged(const IntModelProperty::ChangeArgs& ar
                 if (args.Property == PointsProperty)
                     pAchievement->SetPoints(args.tNewValue);
             }
+            else
+            {
+                auto* pLeaderboard = dynamic_cast<ra::data::models::LeaderboardModel*>(m_pAsset);
+                if (pLeaderboard != nullptr)
+                {
+                    if (args.Property == SelectedLeaderboardPartProperty)
+                    {
+                        // disable notifications while updating the selected group to prevent re-entrancy
+                        m_vLeaderboardParts.RemoveNotifyTarget(*this);
+
+                        for (gsl::index nIndex = 0; nIndex < gsl::narrow_cast<gsl::index>(m_vLeaderboardParts.Count()); ++nIndex)
+                        {
+                            auto* pItem = m_vLeaderboardParts.GetItemAt(nIndex);
+                            if (pItem != nullptr)
+                                pItem->SetSelected(pItem->GetId() == args.tNewValue);
+                        }
+
+                        m_vLeaderboardParts.AddNotifyTarget(*this);
+
+                        UpdateLeaderboardTrigger();
+                    }
+                    else if (args.Property == ValueFormatProperty)
+                    {
+                        pLeaderboard->SetValueFormat(ra::itoe<ra::data::ValueFormat>(args.tNewValue));
+                        UpdateMeasuredValue();
+                    }
+                }
+            }
         }
     }
 
@@ -418,12 +575,46 @@ void AssetEditorViewModel::UpdateAssetFrameValues()
 
 void AssetEditorViewModel::UpdateDebugHighlights()
 {
-    const auto* pAchievement = dynamic_cast<ra::data::models::AchievementModel*>(m_pAsset);
-    if (pAchievement != nullptr)
+    const auto& pRuntime = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+    const rc_trigger_t* pTrigger = nullptr;
+
+    switch (m_pAsset->GetType())
     {
-        const rc_trigger_t* pTrigger = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>().GetAchievementTrigger(pAchievement->GetID());
-        m_vmTrigger.UpdateColors(pTrigger);
+        case ra::data::models::AssetType::Achievement:
+            pTrigger = pRuntime.GetAchievementTrigger(m_pAsset->GetID());
+            break;
+
+        case ra::data::models::AssetType::Leaderboard:
+        {
+            const auto* pLeaderboardDefinition = pRuntime.GetLeaderboardDefinition(m_pAsset->GetID());
+            if (pLeaderboardDefinition == nullptr)
+                break;
+
+            switch (GetSelectedLeaderboardPart())
+            {
+                case LeaderboardPart::Start:
+                    pTrigger = &pLeaderboardDefinition->start;
+                    break;
+                case LeaderboardPart::Submit:
+                    pTrigger = &pLeaderboardDefinition->submit;
+                    break;
+                case LeaderboardPart::Cancel:
+                    pTrigger = &pLeaderboardDefinition->cancel;
+                    break;
+                case LeaderboardPart::Value:
+                    // pTrigger = pLeaderboardDefinition->value.conditions;
+                    break;
+                default:
+                    break;
+            }
+            break;
+        }
+
+        default:
+            break;
     }
+
+    m_vmTrigger.UpdateColors(pTrigger);
 }
 
 void AssetEditorViewModel::UpdateMeasuredValue()
@@ -449,6 +640,23 @@ void AssetEditorViewModel::UpdateMeasuredValue()
 
                 SetValue(MeasuredValueProperty, ra::StringPrintf(L"%d/%d", pTrigger->measured_value, pTrigger->measured_target));
             }
+            else
+            {
+                const auto* pLeaderboard = dynamic_cast<ra::data::models::LeaderboardModel*>(m_pAsset);
+                if (pLeaderboard != nullptr)
+                {
+                    auto* pLeaderboardDefinition = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>().GetLeaderboardDefinition(pLeaderboard->GetID());
+                    if (pLeaderboardDefinition)
+                    {
+                        const unsigned nValue = pLeaderboardDefinition->value.value.value;
+                        SetValue(MeasuredValueProperty, std::to_wstring(nValue));
+
+                        char buffer[16];
+                        rc_format_value(buffer, sizeof(buffer), nValue, ra::etoi(GetValueFormat()));
+                        SetValue(FormattedValueProperty, ra::Widen(buffer));
+                    }
+                }
+            }
         }
     }
 }
diff --git a/src/ui/viewmodels/AssetEditorViewModel.hh b/src/ui/viewmodels/AssetEditorViewModel.hh
index bc487a7c..22d3a4ef 100644
--- a/src/ui/viewmodels/AssetEditorViewModel.hh
+++ b/src/ui/viewmodels/AssetEditorViewModel.hh
@@ -5,7 +5,6 @@
 #include "TriggerViewModel.hh"
 
 #include "data\Types.hh"
-#include "data\models\AchievementModel.hh"
 #include "data\models\AssetModelBase.hh"
 
 #include "ui\WindowViewModelBase.hh"
@@ -16,6 +15,7 @@ namespace viewmodels {
 
 class AssetEditorViewModel : public WindowViewModelBase, 
     protected ViewModelBase::NotifyTarget,
+    protected ViewModelCollectionBase::NotifyTarget,
     protected ra::data::DataModelBase::NotifyTarget
 {
 public:
@@ -157,6 +157,16 @@ public:
 
     // ===== Achievement Specific =====
 
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for whether or not an asset is an achievement.
+    /// </summary>
+    static const BoolModelProperty IsAchievementProperty;
+
+    /// <summary>
+    /// Gets whether or not an asset is an achievement.
+    /// </summary>
+    bool IsAchievement() const { return GetValue(IsAchievementProperty); }
+
     /// <summary>
     /// The <see cref="ModelProperty" /> for the achievement points.
     /// </summary>
@@ -192,6 +202,96 @@ public:
     /// </summary>
     void SelectBadgeFile();
 
+    // ===== Leaderboard Specific =====
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for whether or not an asset is a leaderboard.
+    /// </summary>
+    static const BoolModelProperty IsLeaderboardProperty;
+
+    /// <summary>
+    /// Gets whether or not an asset is a leaderboard.
+    /// </summary>
+    bool IsLeaderboard() const { return GetValue(IsLeaderboardProperty); }
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for the asset value format.
+    /// </summary>
+    static const IntModelProperty ValueFormatProperty;
+
+    /// <summary>
+    /// Gets the asset value format.
+    /// </summary>
+    ra::data::ValueFormat GetValueFormat() const { return ra::itoe<ra::data::ValueFormat>(GetValue(ValueFormatProperty)); }
+
+    /// <summary>
+    /// Sets the asset value format.
+    /// </summary>
+    void SetValueFormat(ra::data::ValueFormat nValue) { SetValue(ValueFormatProperty, ra::etoi(nValue)); }
+
+    /// <summary>
+    /// Gets the list of leaderboard formats.
+    /// </summary>
+    const LookupItemViewModelCollection& Formats() const noexcept
+    {
+        return m_vFormats;
+    }
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for the formatted value.
+    /// </summary>
+    static const StringModelProperty FormattedValueProperty;
+
+    /// <summary>
+    /// Gets the formatted value.
+    /// </summary>
+    const std::wstring& GetFormattedValue() const { return GetValue(FormattedValueProperty); }
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for whether or not lower values are better.
+    /// </summary>
+    static const BoolModelProperty LowerIsBetterProperty;
+
+    /// <summary>
+    /// Gets whether or not lower values are better.
+    /// </summary>
+    bool IsLowerBetter() const { return GetValue(LowerIsBetterProperty); }
+
+    /// <summary>
+    /// Sets whether or not lower values are better.
+    /// </summary>
+    void SetLowerIsBetter(bool nValue) { SetValue(LowerIsBetterProperty, nValue); }
+
+    enum class LeaderboardPart
+    {
+        None = 0,
+        Start,
+        Submit,
+        Cancel,
+        Value
+    };
+
+    /// <summary>
+    /// Gets the list of leaderboard parts.
+    /// </summary>
+    const LookupItemViewModelCollection& LeaderboardParts() const noexcept { return m_vLeaderboardParts; }
+    LookupItemViewModelCollection& LeaderboardParts() noexcept { return m_vLeaderboardParts; }
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for the selected leaderboard part.
+    /// </summary>
+    static const IntModelProperty SelectedLeaderboardPartProperty;
+
+    /// <summary>
+    /// Gets the selected leaderboard part.
+    /// </summary>
+    LeaderboardPart GetSelectedLeaderboardPart() const { return ra::itoe<LeaderboardPart>(GetValue(SelectedLeaderboardPartProperty)); }
+
+    /// <summary>
+    /// Sets the selected leaderboard part.
+    /// </summary>
+    void SetSelectedLeaderboardPart(LeaderboardPart nValue) { SetValue(SelectedLeaderboardPartProperty, ra::etoi(nValue)); }
+
     // ===== Trigger =====
 
     /// <summary>
@@ -293,6 +393,9 @@ protected:
     // ViewModelBase::NotifyTarget
     void OnViewModelIntValueChanged(const IntModelProperty::ChangeArgs& args) override;
 
+    // ViewModelCollectionBase::NotifyTarget
+    void OnViewModelBoolValueChanged(gsl::index, const BoolModelProperty::ChangeArgs& args) override;
+
     // DataModelBase::NotifyTarget
     void OnDataModelBoolValueChanged(const BoolModelProperty::ChangeArgs& args) override;
     void OnDataModelStringValueChanged(const StringModelProperty::ChangeArgs& args) override;
@@ -308,11 +411,15 @@ protected:
     void UpdateAssetFrameValues();
     void UpdateDebugHighlights();
     void UpdateMeasuredValue();
+    void UpdateLeaderboardTrigger();
 
     TriggerViewModel m_vmTrigger;
 
     ra::data::models::AssetModelBase* m_pAsset = nullptr;
     bool m_bIgnoreTriggerUpdate = false;
+
+    LookupItemViewModelCollection m_vFormats;
+    LookupItemViewModelCollection m_vLeaderboardParts;
 };
 
 } // namespace viewmodels
diff --git a/src/ui/viewmodels/MemoryBookmarksViewModel.cpp b/src/ui/viewmodels/MemoryBookmarksViewModel.cpp
index 2e57cec4..e26263d8 100644
--- a/src/ui/viewmodels/MemoryBookmarksViewModel.cpp
+++ b/src/ui/viewmodels/MemoryBookmarksViewModel.cpp
@@ -5,6 +5,7 @@
 #include "RA_StringUtils.h"
 
 #include "data\context\EmulatorContext.hh"
+#include "data\models\TriggerValidation.hh"
 
 #include "services\FrameEventQueue.hh"
 #include "services\IConfiguration.hh"
@@ -246,7 +247,7 @@ void MemoryBookmarksViewModel::LoadBookmarks(ra::services::TextReader& sBookmark
                     if (rc_parse_memref(&memaddr, &size, &address) == RC_OK)
                     {
                         vmBookmark->SetAddress(address);
-                        vmBookmark->SetSize(TriggerConditionViewModel::MapRcheevosMemSize(size));
+                        vmBookmark->SetSize(ra::data::models::TriggerValidation::MapRcheevosMemSize(size));
                     }
                 }
                 else
diff --git a/src/ui/viewmodels/MemorySearchViewModel.cpp b/src/ui/viewmodels/MemorySearchViewModel.cpp
index cf73d2f0..4f00355d 100644
--- a/src/ui/viewmodels/MemorySearchViewModel.cpp
+++ b/src/ui/viewmodels/MemorySearchViewModel.cpp
@@ -824,6 +824,22 @@ void MemorySearchViewModel::UpdateResults()
     m_vResults.AddNotifyTarget(*this);
 }
 
+static void GetASCIIText(std::wstring& sText, const unsigned char* pBytes, size_t nBytes)
+{
+    sText.clear();
+    for (size_t i = 0; i < nBytes; ++i)
+    {
+        wchar_t c = gsl::narrow_cast<wchar_t>(*pBytes++);
+        if (c == 0)
+            break;
+
+        if (c < 0x20 || c > 0x7E)
+            c = L'\xFFFD';
+
+        sText.push_back(c);
+    }
+}
+
 void MemorySearchViewModel::UpdateResult(SearchResultViewModel& pRow, ra::services::SearchResults::Result& pResult,
     const ra::data::context::EmulatorContext& pEmulatorContext, bool bCapturePrevious)
 {
@@ -838,20 +854,16 @@ void MemorySearchViewModel::UpdateResult(SearchResultViewModel& pRow, ra::servic
             std::array<unsigned char, 16> pBuffer;
             pEmulatorContext.ReadMemory(pResult.nAddress, &pBuffer.at(0), pBuffer.size());
 
-            std::string sText;
-            for (size_t i = 0; i < pBuffer.size() && pBuffer.at(i); ++i)
-                sText.push_back(static_cast<char>(pBuffer.at(i)));
+            std::wstring sText;
+            GetASCIIText(sText, pBuffer.data(), pBuffer.size());
 
-            pRow.SetCurrentValue(ra::Widen(sText));
+            pRow.SetCurrentValue(sText);
 
             if (bCapturePrevious)
             {
                 pCompareResults.GetBytes(pResult.nAddress, &pBuffer.at(0), pBuffer.size());
-                sText.clear();
-                for (size_t i = 0; i < pBuffer.size() && pBuffer.at(i); ++i)
-                    sText.push_back(static_cast<char>(pBuffer.at(i)));
-
-                pRow.SetPreviousValue(ra::Widen(sText));
+                GetASCIIText(sText, pBuffer.data(), pBuffer.size());
+                pRow.SetPreviousValue(sText);
             }
             else if (!pRow.bHasBeenModified && pRow.GetCurrentValue() != pRow.GetPreviousValue())
             {
diff --git a/src/ui/viewmodels/TriggerConditionViewModel.cpp b/src/ui/viewmodels/TriggerConditionViewModel.cpp
index 769dce3f..69f88b7a 100644
--- a/src/ui/viewmodels/TriggerConditionViewModel.cpp
+++ b/src/ui/viewmodels/TriggerConditionViewModel.cpp
@@ -4,6 +4,7 @@
 #include "RA_StringUtils.h"
 
 #include "data\context\GameContext.hh"
+#include "data\models\TriggerValidation.hh"
 
 #include "services\AchievementRuntime.hh"
 #include "services\IConfiguration.hh"
@@ -202,40 +203,6 @@ void TriggerConditionViewModel::SerializeAppendOperand(std::string& sBuffer, Tri
     sBuffer.append(ra::ByteAddressToString(nValue), 2);
 }
 
-static constexpr MemSize MapMemSize(char nSize) noexcept
-{
-    switch (nSize)
-    {
-        case RC_MEMSIZE_BIT_0: return MemSize::Bit_0;
-        case RC_MEMSIZE_BIT_1: return MemSize::Bit_1;
-        case RC_MEMSIZE_BIT_2: return MemSize::Bit_2;
-        case RC_MEMSIZE_BIT_3: return MemSize::Bit_3;
-        case RC_MEMSIZE_BIT_4: return MemSize::Bit_4;
-        case RC_MEMSIZE_BIT_5: return MemSize::Bit_5;
-        case RC_MEMSIZE_BIT_6: return MemSize::Bit_6;
-        case RC_MEMSIZE_BIT_7: return MemSize::Bit_7;
-        case RC_MEMSIZE_LOW: return MemSize::Nibble_Lower;
-        case RC_MEMSIZE_HIGH: return MemSize::Nibble_Upper;
-        case RC_MEMSIZE_8_BITS: return MemSize::EightBit;
-        case RC_MEMSIZE_16_BITS: return MemSize::SixteenBit;
-        case RC_MEMSIZE_24_BITS: return MemSize::TwentyFourBit;
-        case RC_MEMSIZE_32_BITS: return MemSize::ThirtyTwoBit;
-        case RC_MEMSIZE_BITCOUNT: return MemSize::BitCount;
-        case RC_MEMSIZE_16_BITS_BE: return MemSize::SixteenBitBigEndian;
-        case RC_MEMSIZE_24_BITS_BE: return MemSize::TwentyFourBitBigEndian;
-        case RC_MEMSIZE_32_BITS_BE: return MemSize::ThirtyTwoBitBigEndian;
-        default:
-            assert(!"Unsupported operand size");
-            return MemSize::EightBit;
-    }
-}
-
-GSL_SUPPRESS_F4
-MemSize TriggerConditionViewModel::MapRcheevosMemSize(char nSize) noexcept
-{
-    return MapMemSize(nSize);
-}
-
 void TriggerConditionViewModel::SetOperand(const IntModelProperty& pTypeProperty,
     const IntModelProperty& pSizeProperty, const IntModelProperty& pValueProperty, const rc_operand_t& operand)
 {
@@ -254,7 +221,7 @@ void TriggerConditionViewModel::SetOperand(const IntModelProperty& pTypeProperty
         case TriggerOperandType::Prior:
         case TriggerOperandType::BCD:
         {
-            const auto nSize = MapMemSize(operand.size);
+            const auto nSize = ra::data::models::TriggerValidation::MapRcheevosMemSize(operand.size);
             SetValue(pSizeProperty, ra::etoi(nSize));
             SetValue(pValueProperty, operand.value.memref->address);
             break;
@@ -380,7 +347,7 @@ std::wstring TriggerConditionViewModel::GetValueTooltip(unsigned int nValue)
     return std::to_wstring(nValue);
 }
 
-static bool IsIndirectMemref(rc_operand_t& operand) noexcept
+static bool IsIndirectMemref(const rc_operand_t& operand) noexcept
 {
     return rc_operand_is_memref(&operand) && operand.value.memref->value.is_indirect;
 }
@@ -405,6 +372,7 @@ unsigned int TriggerConditionViewModel::GetIndirectAddress(unsigned int nAddress
     bool bIsIndirect = false;
     rc_eval_state_t oEvalState;
     memset(&oEvalState, 0, sizeof(oEvalState));
+    rc_typed_value_t value = {};
     oEvalState.peek = rc_peek_callback;
 
     gsl::index nConditionIndex = 0;
@@ -440,11 +408,15 @@ unsigned int TriggerConditionViewModel::GetIndirectAddress(unsigned int nAddress
                     oCondition.operand2.value.memref = &oTarget;
                 }
 
-                oEvalState.add_address = rc_evaluate_condition_value(&oCondition, &oEvalState);
+                rc_evaluate_condition_value(&value, &oCondition, &oEvalState);
+                rc_typed_value_convert(&value, RC_VALUE_TYPE_UNSIGNED);
+                oEvalState.add_address = value.value.u32;
             }
             else
             {
-                oEvalState.add_address = rc_evaluate_condition_value(pCondition, &oEvalState);
+                rc_evaluate_condition_value(&value, pCondition, &oEvalState);
+                rc_typed_value_convert(&value, RC_VALUE_TYPE_UNSIGNED);
+                oEvalState.add_address = value.value.u32;
                 bIsIndirect = true;
             }
         }
diff --git a/src/ui/viewmodels/TriggerConditionViewModel.hh b/src/ui/viewmodels/TriggerConditionViewModel.hh
index 00b2ab5f..83a760d0 100644
--- a/src/ui/viewmodels/TriggerConditionViewModel.hh
+++ b/src/ui/viewmodels/TriggerConditionViewModel.hh
@@ -137,8 +137,6 @@ public:
     void SetRowColor(Color value) { SetValue(RowColorProperty, ra::to_signed(value.ARGB)); }
     void UpdateRowColor(const rc_condition_t* pCondition);
 
-    static MemSize MapRcheevosMemSize(char nSize) noexcept;
-
 private:
     void OnValueChanged(const IntModelProperty::ChangeArgs& args) override;
     void OnValueChanged(const BoolModelProperty::ChangeArgs& args) override;
diff --git a/src/ui/win32/AssetEditorDialog.cpp b/src/ui/win32/AssetEditorDialog.cpp
index bf4cc0b6..bdcbd2ba 100644
--- a/src/ui/win32/AssetEditorDialog.cpp
+++ b/src/ui/win32/AssetEditorDialog.cpp
@@ -603,6 +603,9 @@ AssetEditorDialog::AssetEditorDialog(AssetEditorViewModel& vmAssetEditor)
     m_bindBadge(vmAssetEditor),
     m_bindBadgeImage(vmAssetEditor),
     m_bindPoints(vmAssetEditor),
+    m_bindFormats(vmAssetEditor),
+    m_bindLeaderboardParts(vmAssetEditor),
+    m_bindLowerIsBetter(vmAssetEditor),
     m_bindErrorIcon(vmAssetEditor),
     m_bindMeasuredAsPercent(vmAssetEditor.Trigger()),
     m_bindDebugHighlights(vmAssetEditor),
@@ -619,10 +622,7 @@ AssetEditorDialog::AssetEditorDialog(AssetEditorViewModel& vmAssetEditor)
     m_bindID.BindValue(AssetEditorViewModel::IDProperty);
     m_bindName.BindText(AssetEditorViewModel::NameProperty);
     m_bindDescription.BindText(AssetEditorViewModel::DescriptionProperty);
-    m_bindBadge.BindText(AssetEditorViewModel::BadgeProperty);
-    m_bindBadge.SetWrapAround(true);
-    m_bindBadgeImage.BindImage(AssetEditorViewModel::BadgeProperty, ra::ui::ImageType::Badge);
-    m_bindPoints.BindValue(AssetEditorViewModel::PointsProperty);
+
     m_bindWindow.BindEnabled(IDC_RA_TITLE, AssetEditorViewModel::IsAssetLoadedProperty);
     m_bindWindow.BindEnabled(IDC_RA_DESCRIPTION, AssetEditorViewModel::IsAssetLoadedProperty);
     m_bindWindow.BindEnabled(IDC_RA_CHK_ACTIVE, AssetEditorViewModel::IsAssetLoadedProperty);
@@ -647,9 +647,42 @@ 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);
+
+    // achievement only fields
+    m_bindBadge.BindText(AssetEditorViewModel::BadgeProperty);
+    m_bindBadge.SetWrapAround(true);
+    m_bindBadgeImage.BindImage(AssetEditorViewModel::BadgeProperty, ra::ui::ImageType::Badge);
+    m_bindPoints.BindValue(AssetEditorViewModel::PointsProperty);
+    m_bindWindow.BindVisible(IDC_RA_LBL_POINTS, AssetEditorViewModel::IsAchievementProperty);
+    m_bindWindow.BindVisible(IDC_RA_POINTS, AssetEditorViewModel::IsAchievementProperty);
+    m_bindWindow.BindVisible(IDC_RA_CHK_AS_PERCENT, AssetEditorViewModel::IsAchievementProperty);
     m_bindWindow.BindVisible(IDC_RA_CHK_AS_PERCENT, AssetEditorViewModel::HasMeasuredProperty);
+    m_bindWindow.BindVisible(IDC_RA_LBL_BADGENAME, AssetEditorViewModel::IsAchievementProperty);
+    m_bindWindow.BindVisible(IDC_RA_BADGENAME, AssetEditorViewModel::IsAchievementProperty);
+    m_bindWindow.BindVisible(IDC_RA_BADGE_SPIN, AssetEditorViewModel::IsAchievementProperty);
+    m_bindWindow.BindVisible(IDC_RA_UPLOAD_BADGE, AssetEditorViewModel::IsAchievementProperty);
+    m_bindWindow.BindVisible(IDC_RA_BADGEPIC, AssetEditorViewModel::IsAchievementProperty);
+
+    // leaderboard only fields
+    m_bindFormats.BindItems(vmAssetEditor.Formats());
+    m_bindFormats.BindSelectedItem(AssetEditorViewModel::ValueFormatProperty);
+    m_bindLowerIsBetter.BindCheck(AssetEditorViewModel::LowerIsBetterProperty);
+    m_bindWindow.BindLabel(IDC_RA_DISPLAY, AssetEditorViewModel::FormattedValueProperty);
+    m_bindWindow.BindVisible(IDC_RA_LBL_FORMAT, AssetEditorViewModel::IsLeaderboardProperty);
+    m_bindWindow.BindVisible(IDC_RA_FORMAT, AssetEditorViewModel::IsLeaderboardProperty);
+    m_bindWindow.BindVisible(IDC_RA_CHK_LOWER_IS_BETTER, AssetEditorViewModel::IsLeaderboardProperty);
+    m_bindWindow.BindVisible(IDC_RA_LBL_DISPLAY, AssetEditorViewModel::IsLeaderboardProperty);
+    m_bindWindow.BindVisible(IDC_RA_DISPLAY, AssetEditorViewModel::IsLeaderboardProperty);
+    m_bindWindow.BindVisible(IDC_RA_LBX_LBOARD_PARTS, AssetEditorViewModel::IsLeaderboardProperty);
+
+    auto pPartColumn = std::make_unique<ra::ui::win32::bindings::GridTextColumnBinding>(
+        ra::ui::viewmodels::LookupItemViewModel::LabelProperty);
+    pPartColumn->SetHeader(L"Part");
+    pPartColumn->SetWidth(ra::ui::win32::bindings::GridColumnBinding::WidthType::Fill, 50);
+    m_bindLeaderboardParts.BindColumn(0, std::move(pPartColumn));
+    m_bindLeaderboardParts.BindIsSelected(ra::ui::viewmodels::LookupItemViewModel::IsSelectedProperty);
+    m_bindLeaderboardParts.BindItems(vmAssetEditor.LeaderboardParts());
 
-    m_bindMeasuredAsPercent.BindCheck(TriggerViewModel::IsMeasuredTrackedAsPercentProperty);
     m_bindDebugHighlights.BindCheck(AssetEditorViewModel::DebugHighlightsEnabledProperty);
     m_bindPauseOnReset.BindCheck(AssetEditorViewModel::PauseOnResetProperty);
     m_bindPauseOnTrigger.BindCheck(AssetEditorViewModel::PauseOnTriggerProperty);
@@ -751,6 +784,11 @@ 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_FORMAT, Anchor::Top | Anchor::Left);
+    SetAnchor(IDC_RA_FORMAT, Anchor::Top | Anchor::Left);
+    SetAnchor(IDC_RA_LBL_DISPLAY, Anchor::Top | Anchor::Left);
+    SetAnchor(IDC_RA_DISPLAY, Anchor::Top | Anchor::Left);
+    SetAnchor(IDC_RA_CHK_LOWER_IS_BETTER, 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);
@@ -759,6 +797,7 @@ AssetEditorDialog::AssetEditorDialog(AssetEditorViewModel& vmAssetEditor)
     SetAnchor(IDC_RA_BADGE_SPIN, Anchor::Top | Anchor::Right);
     SetAnchor(IDC_RA_UPLOAD_BADGE, Anchor::Top | Anchor::Right);
     SetAnchor(IDC_RA_BADGEPIC, Anchor::Top | Anchor::Right);
+    SetAnchor(IDC_RA_LBX_LBOARD_PARTS, Anchor::Top | Anchor::Right);
     SetAnchor(IDC_RA_LBX_GROUPS, Anchor::Top | Anchor::Left | Anchor::Bottom);
     SetAnchor(IDC_RA_ADD_GROUP, Anchor::Bottom | Anchor::Left);
     SetAnchor(IDC_RA_DELETE_GROUP, Anchor::Bottom | Anchor::Left);
@@ -788,6 +827,8 @@ BOOL AssetEditorDialog::OnInitDialog()
     m_bindBadge.SetSpinnerControl(*this, IDC_RA_BADGE_SPIN);
     m_bindBadgeImage.SetControl(*this, IDC_RA_BADGEPIC);
     m_bindPoints.SetControl(*this, IDC_RA_POINTS);
+    m_bindFormats.SetControl(*this, IDC_RA_FORMAT);
+    m_bindLeaderboardParts.SetControl(*this, IDC_RA_LBX_LBOARD_PARTS);
 
     m_bindGroups.SetControl(*this, IDC_RA_LBX_GROUPS);
     m_bindConditions.SetControl(*this, IDC_RA_LBX_CONDITIONS);
diff --git a/src/ui/win32/AssetEditorDialog.hh b/src/ui/win32/AssetEditorDialog.hh
index aff9896c..33680bbe 100644
--- a/src/ui/win32/AssetEditorDialog.hh
+++ b/src/ui/win32/AssetEditorDialog.hh
@@ -5,6 +5,7 @@
 #include "ui/viewmodels/AssetEditorViewModel.hh"
 
 #include "ui/win32/bindings/CheckBoxBinding.hh"
+#include "ui/win32/bindings/ComboBoxBinding.hh"
 #include "ui/win32/bindings/GridBinding.hh"
 #include "ui/win32/bindings/ImageBinding.hh"
 #include "ui/win32/bindings/NumericTextBoxBinding.hh"
@@ -133,6 +134,9 @@ private:
     BadgeNameBinding m_bindBadge;
     ra::ui::win32::bindings::ImageBinding m_bindBadgeImage;
     ra::ui::win32::bindings::NumericTextBoxBinding m_bindPoints;
+    ra::ui::win32::bindings::ComboBoxBinding m_bindFormats;
+    ra::ui::win32::bindings::GridBinding m_bindLeaderboardParts;
+    ra::ui::win32::bindings::CheckBoxBinding m_bindLowerIsBetter;
 
     ErrorIconBinding m_bindErrorIcon;
     ra::ui::win32::bindings::CheckBoxBinding m_bindMeasuredAsPercent;
diff --git a/src/ui/win32/bindings/WindowBinding.cpp b/src/ui/win32/bindings/WindowBinding.cpp
index 39aaad8b..3985dc9f 100644
--- a/src/ui/win32/bindings/WindowBinding.cpp
+++ b/src/ui/win32/bindings/WindowBinding.cpp
@@ -71,6 +71,9 @@ void WindowBinding::SetHWND(DialogBase* pDialog, HWND hWnd)
             {
                 for (const auto nDlgItemId : pIter.second)
                 {
+                    if (m_vMultipleVisibilityBoundControls.find(nDlgItemId) != m_vMultipleVisibilityBoundControls.end())
+                        continue;
+
                     auto hControl = GetDlgItem(m_hWnd, nDlgItemId);
                     if (hControl)
                         ShowWindow(hControl, GetValueFromAny(*pProperty) ? SW_SHOW : SW_HIDE);
@@ -78,6 +81,16 @@ void WindowBinding::SetHWND(DialogBase* pDialog, HWND hWnd)
             }
         }
 
+        for (const auto nDlgItemId : m_vMultipleVisibilityBoundControls)
+        {
+            auto hControl = GetDlgItem(m_hWnd, nDlgItemId);
+            if (hControl)
+            {
+                const bool bVisible = CheckMultiBoundVisibility(nDlgItemId);
+                ShowWindow(hControl, bVisible ? SW_SHOW : SW_HIDE);
+            }
+        }
+
         RestoreSizeAndPosition();
     }
 }
@@ -336,7 +349,11 @@ void WindowBinding::OnViewModelBoolValueChanged(const BoolModelProperty::ChangeA
             auto hControl = GetDlgItem(m_hWnd, nDlgItemId);
             if (hControl)
             {
-                ShowWindow(hControl, args.tNewValue ? SW_SHOW : SW_HIDE);
+                bool bVisible = args.tNewValue;
+                if (bVisible && m_vMultipleVisibilityBoundControls.find(nDlgItemId) != m_vMultipleVisibilityBoundControls.end())
+                    bVisible = CheckMultiBoundVisibility(nDlgItemId);
+
+                ShowWindow(hControl, bVisible ? SW_SHOW : SW_HIDE);
                 bRepaint = true;
             }
         }
@@ -361,6 +378,17 @@ void WindowBinding::BindEnabled(int nDlgItemId, const BoolModelProperty& pSource
 
 void WindowBinding::BindVisible(int nDlgItemId, const BoolModelProperty& pSourceProperty)
 {
+    bool bMultipleBindings = false;
+    for (const auto& pPair : m_mVisibilityBindings)
+    {
+        if (pPair.second.find(nDlgItemId) != pPair.second.end())
+        {
+            m_vMultipleVisibilityBoundControls.insert(nDlgItemId);
+            bMultipleBindings = true;
+            break;
+        }
+    }
+
     m_mVisibilityBindings[pSourceProperty.GetKey()].insert(nDlgItemId);
 
     if (m_hWnd)
@@ -368,8 +396,33 @@ void WindowBinding::BindVisible(int nDlgItemId, const BoolModelProperty& pSource
         // immediately push values from the viewmodel to the UI
         auto hControl = GetDlgItem(m_hWnd, nDlgItemId);
         if (hControl)
-            ShowWindow(hControl, GetValueFromAny(pSourceProperty) ? SW_SHOW : SW_HIDE);
+        {
+            const bool bVisible = bMultipleBindings ?
+                CheckMultiBoundVisibility(nDlgItemId) : GetValueFromAny(pSourceProperty);
+            ShowWindow(hControl, bVisible ? SW_SHOW : SW_HIDE);
+        }
+    }
+}
+
+bool WindowBinding::CheckMultiBoundVisibility(int nDlgItemId) const
+{
+    bool bVisible = true;
+
+    for (const auto& pPair : m_mVisibilityBindings)
+    {
+        if (pPair.second.find(nDlgItemId) != pPair.second.end())
+        {
+            const auto* pProperty = dynamic_cast<const BoolModelProperty*>(ra::data::ModelPropertyBase::GetPropertyForKey(pPair.first));
+            if (pProperty != nullptr)
+            {
+                bVisible &= GetValueFromAny(*pProperty);
+                if (!bVisible)
+                    break;
+            }
+        }
     }
+
+    return bVisible;
 }
 
 const std::wstring& WindowBinding::GetValueFromAny(const StringModelProperty& pProperty) const
diff --git a/src/ui/win32/bindings/WindowBinding.hh b/src/ui/win32/bindings/WindowBinding.hh
index b5da620e..5fe09544 100644
--- a/src/ui/win32/bindings/WindowBinding.hh
+++ b/src/ui/win32/bindings/WindowBinding.hh
@@ -137,6 +137,7 @@ private:
     std::unordered_map<int, int> m_mLabelBindings;
     std::unordered_map<int, std::set<int>> m_mEnabledBindings;
     std::unordered_map<int, std::set<int>> m_mVisibilityBindings;
+    std::set<int> m_vMultipleVisibilityBoundControls;
 
     class ChildBinding : public BindingBase
     {
@@ -173,6 +174,7 @@ private:
     const std::wstring& GetValueFromAny(const StringModelProperty& pProperty) const;
     int GetValueFromAny(const IntModelProperty& pProperty) const;
     bool GetValueFromAny(const BoolModelProperty& pProperty) const;
+    bool CheckMultiBoundVisibility(int nDlgItemId) const;
 
     std::string m_sSizeAndPositionKey;
     RelativePosition m_nDefaultHorizontalLocation{ RelativePosition::None };


Test patch:
diff --git a/tests/data/models/LeaderboardModel_Tests.cpp b/tests/data/models/LeaderboardModel_Tests.cpp
index 44bedc66..48bae8e6 100644
--- a/tests/data/models/LeaderboardModel_Tests.cpp
+++ b/tests/data/models/LeaderboardModel_Tests.cpp
@@ -228,6 +228,33 @@ public:
         TestSerializeValueFormat(ValueFormat::Minutes, "MINUTES");
         TestSerializeValueFormat(ValueFormat::SecondsAsMinutes, "SECS_AS_MINS");
     }
+
+    TEST_METHOD(TestTransactionalProperties)
+    {
+        LeaderboardModelHarness leaderboard;
+        leaderboard.SetID(1U);
+        leaderboard.SetName(L"Title");
+        leaderboard.SetDescription(L"Desc");
+        leaderboard.SetDefinition("STA:0xH1234=1::SUB:0xH1234=2::CAN:0xH1234=3::VAL:0xH1234");
+        leaderboard.SetValueFormat(ValueFormat::Value);
+        leaderboard.SetLowerIsBetter(true);
+        leaderboard.CreateServerCheckpoint();
+        leaderboard.CreateLocalCheckpoint();
+
+        Assert::AreEqual(AssetChanges::None, leaderboard.GetChanges());
+
+        leaderboard.SetValueFormat(ValueFormat::Score);
+        Assert::AreEqual(AssetChanges::Modified, leaderboard.GetChanges());
+
+        leaderboard.SetValueFormat(ValueFormat::Value);
+        Assert::AreEqual(AssetChanges::None, leaderboard.GetChanges());
+
+        leaderboard.SetLowerIsBetter(false);
+        Assert::AreEqual(AssetChanges::Modified, leaderboard.GetChanges());
+
+        leaderboard.SetLowerIsBetter(true);
+        Assert::AreEqual(AssetChanges::None, leaderboard.GetChanges());
+    }
 };
 
 
diff --git a/tests/data/models/TriggerValidation_Tests.cpp b/tests/data/models/TriggerValidation_Tests.cpp
index c441e2b7..378f6432 100644
--- a/tests/data/models/TriggerValidation_Tests.cpp
+++ b/tests/data/models/TriggerValidation_Tests.cpp
@@ -61,6 +61,59 @@ public:
         AssertValidation("C:0xH1234=1_0xH2345=2.1.", L"");
         AssertValidation("D:0xH1234=1_0xH2345=2.1.", L"");
     }
+
+    TEST_METHOD(TestRangeComparisons)
+    {
+        AssertValidation("0xH1234>1", L"");
+
+        AssertValidation("0xH1234=255", L"");
+        AssertValidation("0xH1234!=255", L"");
+        AssertValidation("0xH1234>255", L"Condition 1: Comparison is never true");
+        AssertValidation("0xH1234>=255", L"");
+        AssertValidation("0xH1234<255", L"");
+        AssertValidation("0xH1234<=255", L"Condition 1: Comparison is always true");
+
+        AssertValidation("R:0xH1234<255", L"");
+        AssertValidation("R:0xH1234<=255", L"Condition 1: Comparison is always true");
+
+        AssertValidation("0xH1234=256", L"Condition 1: Comparison is never true");
+        AssertValidation("0xH1234!=256", L"Condition 1: Comparison is always true");
+        AssertValidation("0xH1234>256", L"Condition 1: Comparison is never true");
+        AssertValidation("0xH1234>=256", L"Condition 1: Comparison is never true");
+        AssertValidation("0xH1234<256", L"Condition 1: Comparison is always true");
+        AssertValidation("0xH1234<=256", L"Condition 1: Comparison is always true");
+
+        AssertValidation("0x 1234>=65535", L"");
+        AssertValidation("0x 1234>=65536", L"Condition 1: Comparison is never true");
+
+        AssertValidation("0xW1234>=16777215", L"");
+        AssertValidation("0xW1234>=16777216", L"Condition 1: Comparison is never true");
+
+        AssertValidation("0xX1234>=4294967295", L"");
+        AssertValidation("0xX1234>4294967295", L"Condition 1: Comparison is never true");
+
+        AssertValidation("0xT1234>=1", L"");
+        AssertValidation("0xT1234>1", L"Condition 1: Comparison is never true");
+
+        // AddSource max is the sum of all parts (255+255=510)
+        AssertValidation("A:0xH1234_0xH1235<510", L"");
+        AssertValidation("A:0xH1234_0xH1235<=510", L"Condition 2: Comparison is always true");
+
+        // SubSource max is always 0xFFFFFFFF
+        AssertValidation("B:0xH1234_0xH1235<510", L"");
+        AssertValidation("B:0xH1234_0xH1235<=510", L"");
+    }
+
+    TEST_METHOD(TestSizeComparisons)
+    {
+        AssertValidation("0xH1234>0xH1235", L"");
+        AssertValidation("0xH1234>0x 1235", L"Condition 1: Comparing different memory sizes");
+
+        // AddSource chain may compare different sizes without warning as the chain changes the
+        // size of the final result.
+        AssertValidation("A:0xH1234_0xH1235=0xH2345", L"");
+        AssertValidation("A:0xH1234_0xH1235=0x 2345", L"");
+    }
 };
 
 
diff --git a/tests/services/GameIdentifier_Tests.cpp b/tests/services/GameIdentifier_Tests.cpp
index 0fc0cc04..cc1d9fd6 100644
--- a/tests/services/GameIdentifier_Tests.cpp
+++ b/tests/services/GameIdentifier_Tests.cpp
@@ -26,8 +26,9 @@ namespace ra {
 namespace services {
 namespace tests {
 
-std::array<BYTE, 16> ROM = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 };
-std::string ROM_HASH = "190c4c105786a2121d85018939108a6c";
+static std::array<BYTE, 16> ROM = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 };
+static std::array<BYTE, 16> ROM2 = { 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17 };
+static std::string ROM_HASH = "190c4c105786a2121d85018939108a6c";
 
 class GameIdentifierHarness : public GameIdentifier
 {
@@ -226,6 +227,85 @@ public:
         Assert::AreEqual(ROM_HASH, identifier.mockGameContext.GameHash());
     }
 
+    TEST_METHOD(TestIdentifyGameCached)
+    {
+        GameIdentifierHarness identifier;
+        identifier.mockUserContext.Initialize("User", "ApiToken");
+        identifier.MockResolveHashResponse(23U);
+
+        Assert::AreEqual(23U, identifier.IdentifyGame(&ROM.at(0), ROM.size()));
+
+        identifier.mockServer.HandleRequest<ra::api::ResolveHash>(
+            [](const ra::api::ResolveHash::Request&, ra::api::ResolveHash::Response& response)
+        {
+            response.Result = ra::api::ApiResult::Success;
+            response.GameId = 32U;
+            return true;
+        });
+        Assert::AreEqual(32U, identifier.IdentifyGame(&ROM2.at(0), ROM2.size()));
+
+        // switching back and forth between hashes should avoid the server request
+        identifier.mockServer.HandleRequest<ra::api::ResolveHash>(
+            [](const ra::api::ResolveHash::Request&, ra::api::ResolveHash::Response& response)
+        {
+            response.Result = ra::api::ApiResult::Success;
+            response.GameId = 99U;
+            return true;
+        });
+        Assert::AreEqual(23U, identifier.IdentifyGame(&ROM.at(0), ROM.size()));
+        Assert::AreEqual(32U, identifier.IdentifyGame(&ROM2.at(0), ROM2.size()));
+    }
+
+    TEST_METHOD(TestIdentifyGameUnknownNotCached)
+    {
+        GameIdentifierHarness identifier;
+        identifier.mockEmulatorContext.MockGameTitle("TestGame");
+        identifier.mockUserContext.Initialize("User", "ApiToken");
+
+        identifier.mockServer.HandleRequest<ra::api::ResolveHash>(
+            [](const ra::api::ResolveHash::Request&, ra::api::ResolveHash::Response& response)
+        {
+            response.Result = ra::api::ApiResult::Success;
+            response.GameId = 0U;
+            return true;
+        });
+
+        bool bDialogShown = false;
+        identifier.mockDesktop.ExpectWindow<ra::ui::viewmodels::UnknownGameViewModel>(
+            [&bDialogShown](ra::ui::viewmodels::UnknownGameViewModel& vmUnknownGame)
+        {
+            bDialogShown = true;
+            vmUnknownGame.SetSelectedGameId(23);
+            return ra::ui::DialogResult::OK;
+        });
+        Assert::AreEqual(23U, identifier.IdentifyGame(&ROM.at(0), ROM.size()));
+        Assert::IsTrue(bDialogShown);
+
+        bDialogShown = false;
+        identifier.mockDesktop.ResetExpectedWindows();
+        identifier.mockDesktop.ExpectWindow<ra::ui::viewmodels::UnknownGameViewModel>(
+            [&bDialogShown](ra::ui::viewmodels::UnknownGameViewModel& vmUnknownGame)
+        {
+            bDialogShown = true;
+            vmUnknownGame.SetSelectedGameId(32);
+            return ra::ui::DialogResult::OK;
+        });
+        Assert::AreEqual(32U, identifier.IdentifyGame(&ROM2.at(0), ROM2.size()));
+        Assert::IsTrue(bDialogShown);
+
+        // switching back to the first unidentified game will show the unknown game dialog again
+        bDialogShown = false;
+        identifier.mockDesktop.ResetExpectedWindows();
+        identifier.mockDesktop.ExpectWindow<ra::ui::viewmodels::UnknownGameViewModel>(
+            [&bDialogShown](ra::ui::viewmodels::UnknownGameViewModel& vmUnknownGame)
+        {
+            bDialogShown = true;
+            vmUnknownGame.SetSelectedGameId(99);
+            return ra::ui::DialogResult::OK;
+        });
+        Assert::AreEqual(99U, identifier.IdentifyGame(&ROM.at(0), ROM.size()));
+    }
+
     TEST_METHOD(TestActivateGameZeroGameNotLoaded)
     {
         GameIdentifierHarness identifier;
diff --git a/tests/services/SearchResults_Tests.cpp b/tests/services/SearchResults_Tests.cpp
index 09b4313b..2d64f4f3 100644
--- a/tests/services/SearchResults_Tests.cpp
+++ b/tests/services/SearchResults_Tests.cpp
@@ -1240,6 +1240,30 @@ public:
         Assert::AreEqual(24U, result.nAddress);
     }
 
+    TEST_METHOD(TestInitializeFromResultsAsciiTextSingleMatch)
+    {
+        std::array<unsigned char, 36> memory{
+            'S', 'h', 'e', ' ', 's', 'e', 'l', 'l', 's', ' ', 's', 'e', 'a', 's', 'h', 'e',
+            'l', 'l', 's', ' ', 'b', 'y', ' ', 't', 'h', 'e', ' ', 's', 'e', 'a', 's', 'h',
+            'o', 'r', 'e', '.'
+        };
+        ra::data::context::mocks::MockEmulatorContext mockEmulatorContext;
+        mockEmulatorContext.MockMemory(memory);
+
+        SearchResults results1;
+        results1.Initialize(0U, memory.size(), ra::services::SearchType::AsciiText);
+        Assert::AreEqual(memory.size(), results1.MatchingAddressCount());
+
+        SearchResults results2;
+        results2.Initialize(results1, ComparisonType::Equals, ra::services::SearchFilterType::Constant, L"by");
+        Assert::AreEqual({ 1U }, results2.MatchingAddressCount());
+        Assert::IsTrue(results2.ContainsAddress(20U));
+
+        SearchResults::Result result;
+        Assert::IsTrue(results2.GetMatchingAddress(0U, result));
+        Assert::AreEqual(20U, result.nAddress);
+    }
+
     TEST_METHOD(TestInitializeFromResultsAsciiTextComparison)
     {
         std::array<unsigned char, 36> memory{
diff --git a/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp b/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp
index 26cf2b59..764944b6 100644
--- a/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp
@@ -22,6 +22,7 @@ using ra::data::models::AssetCategory;
 using ra::data::models::AssetChanges;
 using ra::data::models::AssetState;
 using ra::data::models::AssetType;
+using ra::data::models::LeaderboardModel;
 
 namespace ra {
 namespace ui {
@@ -95,6 +96,34 @@ public:
         Assert::AreEqual(std::wstring(), editor.GetAssetValidationError());
         Assert::IsFalse(editor.HasMeasured());
         Assert::AreEqual(std::wstring(L"[Not Active]"), editor.GetMeasuredValue());
+        Assert::IsTrue(editor.IsAchievement());
+        Assert::IsFalse(editor.IsLeaderboard());
+        Assert::AreEqual((int)AssetEditorViewModel::LeaderboardPart::Start, (int)editor.GetSelectedLeaderboardPart());
+        Assert::AreEqual(ra::data::ValueFormat::Value, editor.GetValueFormat());
+
+        Assert::AreEqual({ 6U }, editor.Formats().Count());
+        Assert::AreEqual((int)ra::data::ValueFormat::Value, editor.Formats().GetItemAt(0)->GetId());
+        Assert::AreEqual(std::wstring(L"Value"), editor.Formats().GetItemAt(0)->GetLabel());
+        Assert::AreEqual((int)ra::data::ValueFormat::Score, editor.Formats().GetItemAt(1)->GetId());
+        Assert::AreEqual(std::wstring(L"Score"), editor.Formats().GetItemAt(1)->GetLabel());
+        Assert::AreEqual((int)ra::data::ValueFormat::Frames, editor.Formats().GetItemAt(2)->GetId());
+        Assert::AreEqual(std::wstring(L"Frames"), editor.Formats().GetItemAt(2)->GetLabel());
+        Assert::AreEqual((int)ra::data::ValueFormat::Centiseconds, editor.Formats().GetItemAt(3)->GetId());
+        Assert::AreEqual(std::wstring(L"Centiseconds"), editor.Formats().GetItemAt(3)->GetLabel());
+        Assert::AreEqual((int)ra::data::ValueFormat::Seconds, editor.Formats().GetItemAt(4)->GetId());
+        Assert::AreEqual(std::wstring(L"Seconds"), editor.Formats().GetItemAt(4)->GetLabel());
+        Assert::AreEqual((int)ra::data::ValueFormat::Minutes, editor.Formats().GetItemAt(5)->GetId());
+        Assert::AreEqual(std::wstring(L"Minutes"), editor.Formats().GetItemAt(5)->GetLabel());
+
+        Assert::AreEqual({ 4U }, editor.LeaderboardParts().Count());
+        Assert::AreEqual((int)AssetEditorViewModel::LeaderboardPart::Start, editor.LeaderboardParts().GetItemAt(0)->GetId());
+        Assert::AreEqual(std::wstring(L"Start"), editor.LeaderboardParts().GetItemAt(0)->GetLabel());
+        Assert::AreEqual((int)AssetEditorViewModel::LeaderboardPart::Submit, editor.LeaderboardParts().GetItemAt(1)->GetId());
+        Assert::AreEqual(std::wstring(L"Submit"), editor.LeaderboardParts().GetItemAt(1)->GetLabel());
+        Assert::AreEqual((int)AssetEditorViewModel::LeaderboardPart::Cancel, editor.LeaderboardParts().GetItemAt(2)->GetId());
+        Assert::AreEqual(std::wstring(L"Cancel"), editor.LeaderboardParts().GetItemAt(2)->GetLabel());
+        Assert::AreEqual((int)AssetEditorViewModel::LeaderboardPart::Value, editor.LeaderboardParts().GetItemAt(3)->GetId());
+        Assert::AreEqual(std::wstring(L"Value"), editor.LeaderboardParts().GetItemAt(3)->GetLabel());
     }
 
     TEST_METHOD(TestLoadAchievement)
@@ -415,6 +444,69 @@ public:
         Assert::AreEqual(std::wstring(), editor.GetAssetValidationWarning());
     }
 
+    TEST_METHOD(TestLoadLeaderboard)
+    {
+        AssetEditorViewModelHarness editor;
+        LeaderboardModel leaderboard;
+        leaderboard.SetName(L"Test Leaderboard");
+        leaderboard.SetID(1234U);
+        leaderboard.SetState(AssetState::Active);
+        leaderboard.SetDescription(L"Do something cool");
+        leaderboard.SetCategory(AssetCategory::Unofficial);
+        leaderboard.SetValueFormat(ra::data::ValueFormat::Centiseconds);
+        leaderboard.SetLowerIsBetter(false);
+        leaderboard.CreateServerCheckpoint();
+        leaderboard.CreateLocalCheckpoint();
+
+        editor.LoadAsset(&leaderboard);
+
+        Assert::AreEqual(std::wstring(L"Leaderboard Editor"), editor.GetWindowTitle());
+        Assert::AreEqual(1234U, editor.GetID());
+        Assert::AreEqual(std::wstring(L"Test Leaderboard"), editor.GetName());
+        Assert::AreEqual(std::wstring(L"Do something cool"), editor.GetDescription());
+        Assert::AreEqual(AssetCategory::Unofficial, editor.GetCategory());
+        Assert::AreEqual(AssetState::Active, editor.GetState());
+        Assert::AreEqual(ra::data::ValueFormat::Centiseconds, editor.GetValueFormat());
+        Assert::IsFalse(editor.IsLowerBetter());
+        Assert::IsFalse(editor.IsPauseOnReset());
+        Assert::IsFalse(editor.IsPauseOnTrigger());
+        Assert::IsTrue(editor.IsAssetLoaded());
+        Assert::IsTrue(editor.HasMeasured());
+        Assert::IsFalse(editor.HasAssetValidationError());
+        Assert::IsFalse(editor.HasAssetValidationWarning());
+
+        editor.LoadAsset(nullptr);
+
+        Assert::AreEqual(std::wstring(L"Achievement Editor"), editor.GetWindowTitle());
+        Assert::AreEqual(0U, editor.GetID());
+        Assert::AreEqual(std::wstring(L"[No Achievement Loaded]"), editor.GetName());
+        Assert::AreEqual(std::wstring(L"Open an achievement from the Achievements List"), editor.GetDescription());
+        Assert::AreEqual(AssetCategory::Core, editor.GetCategory());
+        Assert::AreEqual(AssetState::Inactive, editor.GetState());
+        Assert::AreEqual(0, editor.GetPoints());
+        Assert::AreEqual(std::wstring(L"00000"), editor.GetBadge());
+        Assert::IsFalse(editor.IsPauseOnReset());
+        Assert::IsFalse(editor.IsPauseOnTrigger());
+        Assert::IsFalse(editor.IsAssetLoaded());
+        Assert::IsFalse(editor.HasMeasured());
+        Assert::IsFalse(editor.HasAssetValidationError());
+        Assert::IsFalse(editor.HasAssetValidationWarning());
+    }
+
+    TEST_METHOD(TestLoadLeaderboardTrigger)
+    {
+        AssetEditorViewModelHarness editor;
+        LeaderboardModel leaderboard;
+        leaderboard.SetStartTrigger("0xH1234=6.1.");
+        leaderboard.CreateServerCheckpoint();
+        leaderboard.CreateLocalCheckpoint();
+
+        editor.LoadAsset(&leaderboard);
+
+        Assert::AreEqual(std::string("0xH1234=6.1."), editor.Trigger().Serialize());
+        Assert::IsFalse(leaderboard.IsModified());
+    }
+
     TEST_METHOD(TestSyncId)
     {
         AssetEditorViewModelHarness editor;
@@ -658,6 +750,48 @@ public:
         Assert::AreEqual(std::wstring(L"67890"), achievement.GetBadge());
     }
 
+    TEST_METHOD(TestSyncValueFormat)
+    {
+        AssetEditorViewModelHarness editor;
+        LeaderboardModel leaderboard;
+        leaderboard.SetValueFormat(ra::data::ValueFormat::Score);
+        leaderboard.CreateServerCheckpoint();
+        leaderboard.CreateLocalCheckpoint();
+
+        editor.LoadAsset(&leaderboard);
+        Assert::AreEqual(ra::data::ValueFormat::Score, editor.GetValueFormat());
+        Assert::AreEqual(ra::data::ValueFormat::Score, leaderboard.GetValueFormat());
+
+        editor.SetValueFormat(ra::data::ValueFormat::Frames);
+        Assert::AreEqual(ra::data::ValueFormat::Frames, editor.GetValueFormat());
+        Assert::AreEqual(ra::data::ValueFormat::Frames, leaderboard.GetValueFormat());
+
+        leaderboard.SetValueFormat(ra::data::ValueFormat::Centiseconds);
+        Assert::AreEqual(ra::data::ValueFormat::Centiseconds, editor.GetValueFormat());
+        Assert::AreEqual(ra::data::ValueFormat::Centiseconds, leaderboard.GetValueFormat());
+    }
+
+    TEST_METHOD(TestSyncLowerIsBetter)
+    {
+        AssetEditorViewModelHarness editor;
+        LeaderboardModel leaderboard;
+        leaderboard.SetLowerIsBetter(true);
+        leaderboard.CreateServerCheckpoint();
+        leaderboard.CreateLocalCheckpoint();
+
+        editor.LoadAsset(&leaderboard);
+        Assert::IsTrue(editor.IsLowerBetter());
+        Assert::IsTrue(leaderboard.IsLowerBetter());
+
+        editor.SetLowerIsBetter(false);
+        Assert::IsFalse(editor.IsLowerBetter());
+        Assert::IsFalse(leaderboard.IsLowerBetter());
+
+        leaderboard.SetLowerIsBetter(true);
+        Assert::IsTrue(editor.IsLowerBetter());
+        Assert::IsTrue(leaderboard.IsLowerBetter());
+    }
+
     TEST_METHOD(TestSyncPauseOnReset)
     {
         AssetEditorViewModelHarness editor;
@@ -1040,6 +1174,38 @@ public:
         Assert::AreEqual(std::wstring(L"6/99"), editor.GetMeasuredValue());
     }
 
+    TEST_METHOD(TestDoFrameUpdatesMeasuredFromActiveLeaderboard)
+    {
+        AssetEditorViewModelHarness editor;
+        LeaderboardModel leaderboard;
+        leaderboard.SetID(1234U);
+        leaderboard.SetStartTrigger("1=1");
+        leaderboard.SetSubmitTrigger("1=0");
+        leaderboard.SetCancelTrigger("0=1");
+        leaderboard.SetValueDefinition("M:0xH1234");
+        leaderboard.SetValueFormat(ra::data::ValueFormat::Centiseconds);
+        leaderboard.CreateServerCheckpoint();
+        leaderboard.CreateLocalCheckpoint();
+        leaderboard.Activate();
+
+        editor.LoadAsset(&leaderboard);
+
+        auto* pDefinition = editor.mockRuntime.GetLeaderboardDefinition(1234U);
+        Expects(pDefinition != nullptr);
+        pDefinition->value.value.value = 6U;
+        Assert::AreEqual(std::wstring(L"0"), editor.GetMeasuredValue());
+        Assert::AreEqual(std::wstring(L"0:00.00"), editor.GetFormattedValue());
+
+        editor.DoFrame();
+        Assert::AreEqual(std::wstring(L"6"), editor.GetMeasuredValue());
+        Assert::AreEqual(std::wstring(L"0:00.06"), editor.GetFormattedValue());
+
+        pDefinition->value.value.value = 12345U;
+        editor.DoFrame();
+        Assert::AreEqual(std::wstring(L"12345"), editor.GetMeasuredValue());
+        Assert::AreEqual(std::wstring(L"2:03.45"), editor.GetFormattedValue());
+    }
+
     TEST_METHOD(TestDecimalPreferredOnVisible)
     {
         AssetEditorViewModelHarness editor;
@@ -1198,6 +1364,29 @@ public:
         Assert::AreEqual(0U, pCondition->GetCurrentHits());
     }
 
+    TEST_METHOD(TestSelectedLeaderboardPart)
+    {
+        AssetEditorViewModelHarness editor;
+        LeaderboardModel leaderboard;
+        leaderboard.SetStartTrigger("0xH1234=6.1.");
+        leaderboard.SetSubmitTrigger("0xH2345!=99");
+        leaderboard.SetCancelTrigger("0xH3456>3");
+        leaderboard.CreateServerCheckpoint();
+        leaderboard.CreateLocalCheckpoint();
+
+        editor.LoadAsset(&leaderboard);
+        Assert::AreEqual(std::string("0xH1234=6.1."), editor.Trigger().Serialize());
+        Assert::IsFalse(leaderboard.IsModified());
+
+        editor.SetSelectedLeaderboardPart(AssetEditorViewModel::LeaderboardPart::Submit);
+        Assert::AreEqual(std::string("0xH2345!=99"), editor.Trigger().Serialize());
+        Assert::IsFalse(leaderboard.IsModified());
+
+        editor.SetSelectedLeaderboardPart(AssetEditorViewModel::LeaderboardPart::Cancel);
+        Assert::AreEqual(std::string("0xH3456>3"), editor.Trigger().Serialize());
+        Assert::IsFalse(leaderboard.IsModified());
+    }
+
 };
 
 } // namespace tests