Repository: RetroAchievements/RAIntegration
Pull request #1061 (2024-01-31 15:45:51)

show softcore score when hardcore disabled
Shows the user's softcore points and a "(softcore)" indicator on the login popup when hardcore is disabled. Also shows the softcore points and a "SOFTCORE" indicator on the overlay. The "HARDCORE" indicator on the overlay is no longer bright red, and a hardcore indicator was not added to the login popup.

![image](https://github.com/RetroAchievements/RAIntegration/assets/32680403/4a682c4c-3e35-489e-b02a-310f13fcad54)

Also switches the order of the Messages and Points lines in the login popup.


Patch:
diff --git a/rcheevos b/rcheevos
index ab752f2f..c5f9a641 160000
--- a/rcheevos
+++ b/rcheevos
@@ -1,1 +1,1 @@
-Subproject commit ab752f2fa5d44bdee8bd55af53508ada5f7cb043
+Subproject commit c5f9a6412e55ef489c1769a52ee1d590e4abd64b
diff --git a/src/Exports.cpp b/src/Exports.cpp
index e600b511..3d93f7cb 100644
--- a/src/Exports.cpp
+++ b/src/Exports.cpp
@@ -325,10 +325,18 @@ static void HandleLoginResponse(int nResult, const char* sErrorMessage, rc_clien
             new ra::ui::viewmodels::PopupMessageViewModel);
         vmMessage->SetTitle(ra::StringPrintf(L"Welcome %s%s", pSessionTracker.HasSessionData() ? L"back " : L"",
                                              pUser->display_name));
-        vmMessage->SetDescription((pUser->num_unread_messages == 1)
+
+        const auto& pConfiguration = ra::services::ServiceLocator::Get<ra::services::IConfiguration>();
+        const auto bHardcore = pConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore);
+        if (bHardcore)
+            vmMessage->SetDescription(ra::StringPrintf(L"%u points", pUser->score));
+        else
+            vmMessage->SetDescription(ra::StringPrintf(L"%u points (softcore)", pUser->score_softcore));
+
+        vmMessage->SetDetail((pUser->num_unread_messages == 1)
             ? L"You have 1 new message"
             : ra::StringPrintf(L"You have %u new messages", pUser->num_unread_messages));
-        vmMessage->SetDetail(ra::StringPrintf(L"%u points", pUser->score));
+
         vmMessage->SetImage(ra::ui::ImageType::UserPic, pUser->username);
         ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>().QueueMessage(vmMessage);
     }
diff --git a/src/RA_Defs.cpp b/src/RA_Defs.cpp
index 09693a50..c71f160a 100644
--- a/src/RA_Defs.cpp
+++ b/src/RA_Defs.cpp
@@ -98,6 +98,39 @@ std::wstring MemSizeFormat(unsigned nValue, MemSize nSize, MemFormat nFormat)
     }
 }
 
+const char* ValueFormatToString(ValueFormat nFormat) noexcept
+{
+    switch (nFormat)
+    {
+        case ValueFormat::Centiseconds: return "MILLISECS";
+        case ValueFormat::Frames: return "FRAMES";
+        case ValueFormat::Minutes: return "MINUTES";
+        case ValueFormat::Score: return "SCORE";
+        case ValueFormat::Seconds: return "SECS";
+        case ValueFormat::SecondsAsMinutes: return "SECS_AS_MINS";
+        case ValueFormat::Value: return "VALUE";
+        case ValueFormat::Float1: return "FLOAT1";
+        case ValueFormat::Float2: return "FLOAT2";
+        case ValueFormat::Float3: return "FLOAT3";
+        case ValueFormat::Float4: return "FLOAT4";
+        case ValueFormat::Float5: return "FLOAT5";
+        case ValueFormat::Float6: return "FLOAT6";
+        case ValueFormat::Fixed1: return "FIXED1";
+        case ValueFormat::Fixed2: return "FIXED2";
+        case ValueFormat::Fixed3: return "FIXED3";
+        case ValueFormat::Tens: return "TENS";
+        case ValueFormat::Hundreds: return "HUNDREDS";
+        case ValueFormat::Thousands: return "THOUSANDS";
+        case ValueFormat::UnsignedValue: return "UNSIGNED";
+        default: return "UNKNOWN";
+    }
+}
+
+ValueFormat ValueFormatFromString(const std::string& sFormat) noexcept
+{
+    return ra::itoe<ValueFormat>(rc_parse_format(sFormat.c_str()));
+}
+
 static unsigned ReverseBytes(unsigned nValue) noexcept
 {
     return ((nValue & 0xFF000000) >> 24) |
diff --git a/src/RA_Shared.rc b/src/RA_Shared.rc
index 519671fa..465aa6f2 100644
--- a/src/RA_Shared.rc
+++ b/src/RA_Shared.rc
@@ -125,8 +125,8 @@ BEGIN
     CONTROL         "Active",IDC_RA_CHK_ACTIVE,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,340,6,40,10
     LTEXT           "Description:",IDC_STATIC,4,23,42,8
     EDITTEXT        IDC_RA_DESCRIPTION,50,21,287,13,ES_AUTOHSCROLL
-    LTEXT           "Type:",IDC_RA_LBL_TYPE,328,23,23,8
-    COMBOBOX        IDC_RA_TYPE,348,21,22,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
+    LTEXT           "Type:",IDC_RA_LBL_TYPE,340,23,23,8
+    COMBOBOX        IDC_RA_TYPE,360,21,22,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
     LTEXT           "Points:",IDC_RA_LBL_POINTS,4,40,23,8
     COMBOBOX        IDC_RA_POINTS,50,38,42,13,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
     LTEXT           "Format:",IDC_RA_LBL_FORMAT,4,40,23,8
diff --git a/src/api/impl/ConnectedServer.cpp b/src/api/impl/ConnectedServer.cpp
index 47e8ab55..19b8f473 100644
--- a/src/api/impl/ConnectedServer.cpp
+++ b/src/api/impl/ConnectedServer.cpp
@@ -789,16 +789,7 @@ UpdateLeaderboard::Response ConnectedServer::UpdateLeaderboard(const UpdateLeade
     api_params.cancel_trigger = request.CancelTrigger.c_str();
     api_params.value_definition = request.ValueDefinition.c_str();
     api_params.lower_is_better = request.LowerIsBetter ? 1 : 0;
-    
-    switch (request.Format)
-    {
-        case ra::data::ValueFormat::Centiseconds: api_params.format = "MILLISECS"; break;
-        case ra::data::ValueFormat::Frames: api_params.format = "TIME"; break;
-        case ra::data::ValueFormat::Minutes: api_params.format = "MINUTES"; break;
-        case ra::data::ValueFormat::Score: api_params.format = "SCORE"; break;
-        case ra::data::ValueFormat::Seconds: api_params.format = "TIMESECS"; break;
-        default: api_params.format = "VALUE"; break;
-    }
+    api_params.format = ValueFormatToString(request.Format);
 
     rc_api_request_t api_request;
     if (rc_api_init_update_leaderboard_request(&api_request, &api_params) == RC_OK)
diff --git a/src/data/Types.hh b/src/data/Types.hh
index 331c7293..6eba5bd8 100644
--- a/src/data/Types.hh
+++ b/src/data/Types.hh
@@ -59,15 +59,31 @@ namespace data {
 
 enum class ValueFormat : uint8_t
 {
-    Value = RC_FORMAT_VALUE,
-    Score = RC_FORMAT_SCORE,
     Frames = RC_FORMAT_FRAMES,
-    Centiseconds = RC_FORMAT_CENTISECS,
     Seconds = RC_FORMAT_SECONDS,
+    Centiseconds = RC_FORMAT_CENTISECS,
+    Score = RC_FORMAT_SCORE,
+    Value = RC_FORMAT_VALUE,
     Minutes = RC_FORMAT_MINUTES,
     SecondsAsMinutes = RC_FORMAT_SECONDS_AS_MINUTES,
+    Float1 = RC_FORMAT_FLOAT1,
+    Float2 = RC_FORMAT_FLOAT2,
+    Float3 = RC_FORMAT_FLOAT3,
+    Float4 = RC_FORMAT_FLOAT4,
+    Float5 = RC_FORMAT_FLOAT5,
+    Float6 = RC_FORMAT_FLOAT6,
+    Fixed1 = RC_FORMAT_FIXED1,
+    Fixed2 = RC_FORMAT_FIXED2,
+    Fixed3 = RC_FORMAT_FIXED3,
+    Tens = RC_FORMAT_TENS,
+    Hundreds = RC_FORMAT_HUNDREDS,
+    Thousands = RC_FORMAT_THOUSANDS,
+    UnsignedValue = RC_FORMAT_UNSIGNED_VALUE,
 };
 
+const char* ValueFormatToString(ValueFormat nFormat) noexcept;
+ValueFormat ValueFormatFromString(const std::string& sFormat) noexcept;
+
 constexpr unsigned int MemSizeBits(MemSize nSize)
 {
     switch (nSize)
diff --git a/src/data/context/GameContext.cpp b/src/data/context/GameContext.cpp
index eda226c2..b11577a7 100644
--- a/src/data/context/GameContext.cpp
+++ b/src/data/context/GameContext.cpp
@@ -63,9 +63,11 @@ static bool ValidateConsole(int nServerConsoleId)
                 break;
 
             case RC_CONSOLE_EVENTS:
-                // Hashes for Events are generated by modifying an existing ROM in a very specific manner
-                // To generate the correct hash, Events are loaded through the console associated to the
-                // unmodified ROM and therefore will never match the console context.
+            case RC_CONSOLE_STANDALONE:
+                // Hashes for Events and Standalone games are generated by modifying an existing ROM in
+                // a very specific manner. To generate the correct hash, Events and Standalone games are
+                // loaded through the console associated to the unmodified ROM and therefore will never
+                // match the console context.
                 return true;
 
             default:
diff --git a/src/data/models/AchievementModel.cpp b/src/data/models/AchievementModel.cpp
index 6a5db1b8..244406be 100644
--- a/src/data/models/AchievementModel.cpp
+++ b/src/data/models/AchievementModel.cpp
@@ -222,6 +222,12 @@ void AchievementModel::HandleStateChanged(AssetState nOldState, AssetState nNewS
     {
         Expects(m_pAchievement->trigger != nullptr);
         m_pCapturedTriggerHits.Capture(m_pAchievement->trigger, GetTrigger());
+
+        if (m_pAchievement->trigger->state == RC_TRIGGER_STATE_PRIMED)
+        {
+            auto& pRuntime = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+            pRuntime.RaiseClientEvent(*m_pAchievement, RC_CLIENT_EVENT_ACHIEVEMENT_CHALLENGE_INDICATOR_HIDE);
+        }
     }
     else if (bIsActive && !bWasActive)
     {
diff --git a/src/data/models/AssetModelBase.cpp b/src/data/models/AssetModelBase.cpp
index 0a7d18bd..61616e19 100644
--- a/src/data/models/AssetModelBase.cpp
+++ b/src/data/models/AssetModelBase.cpp
@@ -273,6 +273,9 @@ void AssetModelBase::SetNew()
 
 void AssetModelBase::SetDeleted()
 {
+    if (IsActive())
+        SetState(AssetState::Inactive);
+
     SetValue(ChangesProperty, ra::etoi(AssetChanges::Deleted));
     SetValue(ValidationErrorProperty, ValidationErrorProperty.GetDefaultValue());
 }
diff --git a/src/data/models/LeaderboardModel.cpp b/src/data/models/LeaderboardModel.cpp
index 04ebcd3f..11a9f10b 100644
--- a/src/data/models/LeaderboardModel.cpp
+++ b/src/data/models/LeaderboardModel.cpp
@@ -269,6 +269,15 @@ void LeaderboardModel::SyncValueFormat()
 {
     m_pLeaderboard->format = ra::etoi(GetValueFormat());
     m_pLeaderboard->public_.format = rc_client_map_leaderboard_format(m_pLeaderboard->format);
+
+    if (m_pLeaderboard->tracker)
+    {
+        auto& pRuntime = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+        auto* pGame = pRuntime.GetClient()->game;
+
+        rc_client_release_leaderboard_tracker(pGame, m_pLeaderboard);
+        rc_client_allocate_leaderboard_tracker(pGame, m_pLeaderboard);
+    }
 }
 
 void LeaderboardModel::SyncDefinition()
@@ -419,17 +428,7 @@ void LeaderboardModel::Serialize(ra::services::TextWriter& pWriter) const
     WriteQuoted(pWriter, GetLocalAssetDefinition(m_pSubmitTrigger));
     WriteQuoted(pWriter, GetLocalAssetDefinition(m_pValueDefinition));
 
-    switch (GetValueFormat())
-    {
-        case ValueFormat::Centiseconds: WritePossiblyQuoted(pWriter, "MILLISECS"); break;
-        case ValueFormat::Frames: WritePossiblyQuoted(pWriter, "FRAMES"); break;
-        case ValueFormat::Minutes: WritePossiblyQuoted(pWriter, "MINUTES"); break;
-        case ValueFormat::Score: WritePossiblyQuoted(pWriter, "SCORE"); break;
-        case ValueFormat::Seconds: WritePossiblyQuoted(pWriter, "SECS"); break;
-        case ValueFormat::SecondsAsMinutes: WritePossiblyQuoted(pWriter, "SECS_AS_MINS"); break;
-        case ValueFormat::Value: WritePossiblyQuoted(pWriter, "VALUE"); break;
-        default: WritePossiblyQuoted(pWriter, ""); break;
-    }
+    WritePossiblyQuoted(pWriter, ValueFormatToString(GetValueFormat()));
 
     WritePossiblyQuoted(pWriter, GetLocalValue(NameProperty));
     WritePossiblyQuoted(pWriter, GetLocalValue(DescriptionProperty));
@@ -487,22 +486,7 @@ bool LeaderboardModel::Deserialize(ra::Tokenizer& pTokenizer)
     SetValueDefinition(sValueDefinition);
     SetLowerIsBetter(nLowerIsBetter != 0);
 
-    if (sFormat == "VALUE")
-        SetValueFormat(ValueFormat::Value);
-    else if (sFormat == "MILLISECS")
-        SetValueFormat(ValueFormat::Centiseconds);
-    else if (sFormat == "SCORE")
-        SetValueFormat(ValueFormat::Score);
-    else if (sFormat == "FRAMES")
-        SetValueFormat(ValueFormat::Frames);
-    else if (sFormat == "SECS")
-        SetValueFormat(ValueFormat::Seconds);
-    else if (sFormat == "MINUTES")
-        SetValueFormat(ValueFormat::Minutes);
-    else if (sFormat == "SECS_AS_MINS")
-        SetValueFormat(ValueFormat::SecondsAsMinutes);
-    else
-        SetValueFormat(ra::itoe<ValueFormat>(ValueFormatProperty.GetDefaultValue()));
+    SetValueFormat(ValueFormatFromString(sFormat));
 
     return true;
 }
diff --git a/src/services/AchievementRuntime.cpp b/src/services/AchievementRuntime.cpp
index 2daaac03..2be2b56a 100644
--- a/src/services/AchievementRuntime.cpp
+++ b/src/services/AchievementRuntime.cpp
@@ -720,6 +720,19 @@ std::string AchievementRuntime::GetAchievementBadge(const rc_client_achievement_
     return sBadgeName;
 }
 
+void AchievementRuntime::RaiseClientEvent(rc_client_achievement_info_t& pAchievement, uint32_t nEventType) const noexcept
+{
+    auto* client = GetClient();
+    if (client->game && client->callbacks.event_handler)
+    {
+        rc_client_event_t pEvent;
+        memset(&pEvent, 0, sizeof(pEvent));
+        pEvent.achievement = &pAchievement.public_;
+        pEvent.type = nEventType;
+        client->callbacks.event_handler(&pEvent, client);
+    }
+}
+
 void AchievementRuntime::UpdateActiveAchievements() noexcept
 {
     auto* client = GetClient();
@@ -770,9 +783,27 @@ rc_lboard_t* AchievementRuntime::GetLeaderboardDefinition(ra::LeaderboardID nId)
 
 void AchievementRuntime::ReleaseLeaderboardTracker(ra::LeaderboardID nId) noexcept
 {
-    rc_client_leaderboard_info_t* leaderboard = GetLeaderboardInfo(GetClient(), nId);
+    auto* pClient = GetClient();
+    rc_client_leaderboard_info_t* leaderboard = GetLeaderboardInfo(pClient, nId);
     if (leaderboard)
-        rc_client_release_leaderboard_tracker(GetClient()->game, leaderboard);
+    {
+        rc_client_leaderboard_tracker_info_t* tracker = leaderboard->tracker;
+        if (tracker)
+        {
+            rc_client_release_leaderboard_tracker(pClient->game, leaderboard);
+
+            if (tracker->pending_events & RC_CLIENT_LEADERBOARD_TRACKER_PENDING_EVENT_HIDE)
+            {
+                rc_client_event_t pEvent;
+                memset(&pEvent, 0, sizeof(pEvent));
+                pEvent.leaderboard_tracker = &tracker->public_;
+                pEvent.type = RC_CLIENT_EVENT_LEADERBOARD_TRACKER_HIDE;
+                pClient->callbacks.event_handler(&pEvent, pClient);
+
+                tracker->pending_events = RC_CLIENT_LEADERBOARD_TRACKER_PENDING_EVENT_NONE;
+            }
+        }
+    }
 }
 
 bool AchievementRuntime::HasRichPresence() const noexcept
diff --git a/src/services/AchievementRuntime.hh b/src/services/AchievementRuntime.hh
index fbdcb6bb..9a58cb21 100644
--- a/src/services/AchievementRuntime.hh
+++ b/src/services/AchievementRuntime.hh
@@ -66,6 +66,8 @@ public:
 
     static std::string GetAchievementBadge(const rc_client_achievement_t& pAchievement);
 
+    void RaiseClientEvent(rc_client_achievement_info_t& pAchievement, uint32_t nEventType) const noexcept;
+
     void UpdateActiveAchievements() noexcept;
     void UpdateActiveLeaderboards() noexcept;
 
diff --git a/src/services/GameIdentifier.cpp b/src/services/GameIdentifier.cpp
index f9935ba9..b67d2e7f 100644
--- a/src/services/GameIdentifier.cpp
+++ b/src/services/GameIdentifier.cpp
@@ -58,6 +58,19 @@ unsigned int GameIdentifier::IdentifyGame(const BYTE* pROM, size_t nROMSize)
     return IdentifyHash(hash);
 }
 
+static unsigned int FindCompatibilityMatch(const std::string& sHash)
+{
+    const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
+    if (pGameContext.GetMode() != ra::data::context::GameContext::Mode::CompatibilityTest)
+        return 0;
+
+    const auto nGameId = ra::ui::viewmodels::UnknownGameViewModel::GetPreviousAssociation(ra::Widen(sHash));
+    if (nGameId != pGameContext.GameId())
+        return 0;
+
+    return nGameId;
+}
+
 unsigned int GameIdentifier::IdentifyHash(const std::string& sHash)
 {
     if (!ra::services::ServiceLocator::Get<ra::data::context::UserContext>().IsLoggedIn())
@@ -93,6 +106,11 @@ unsigned int GameIdentifier::IdentifyHash(const std::string& sHash)
         nGameId = pIter->second;
         RA_LOG_INFO("Using previously looked up game ID %u for hash %s", nGameId, sHash);
     }
+    else if ((nGameId = FindCompatibilityMatch(sHash)) != 0)
+    {
+        RA_LOG_INFO("Using previously associated compatibilty test game ID %u for hash %s", nGameId, sHash);
+        m_nPendingMode = ra::data::context::GameContext::Mode::CompatibilityTest;
+    }
     else
     {
         ra::api::ResolveHash::Request request;
diff --git a/src/ui/viewmodels/AssetEditorViewModel.cpp b/src/ui/viewmodels/AssetEditorViewModel.cpp
index aa67c263..ed2000c5 100644
--- a/src/ui/viewmodels/AssetEditorViewModel.cpp
+++ b/src/ui/viewmodels/AssetEditorViewModel.cpp
@@ -66,6 +66,13 @@ AssetEditorViewModel::AssetEditorViewModel() noexcept
     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_vFormats.Add(ra::etoi(ra::data::ValueFormat::Fixed1), L"Fixed1");
+    m_vFormats.Add(ra::etoi(ra::data::ValueFormat::Fixed2), L"Fixed2");
+    m_vFormats.Add(ra::etoi(ra::data::ValueFormat::Fixed3), L"Fixed3");
+    m_vFormats.Add(ra::etoi(ra::data::ValueFormat::Tens), L"Tens");
+    m_vFormats.Add(ra::etoi(ra::data::ValueFormat::Hundreds), L"Hundreds");
+    m_vFormats.Add(ra::etoi(ra::data::ValueFormat::Thousands), L"Thousands");
+    m_vFormats.Add(ra::etoi(ra::data::ValueFormat::UnsignedValue), L"Unsigned Value");
 
     m_vLeaderboardParts.Add(ra::etoi(LeaderboardPart::Start), L"Start");
     m_vLeaderboardParts.Add(ra::etoi(LeaderboardPart::Cancel), L"Cancel");
@@ -499,7 +506,7 @@ void AssetEditorViewModel::OnValueChanged(const BoolModelProperty::ChangeArgs& a
         auto& pConfiguration = ra::services::ServiceLocator::GetMutable<ra::services::IConfiguration>();
         pConfiguration.SetFeatureEnabled(ra::services::Feature::PreferDecimal, args.tNewValue);
 
-        m_vmTrigger.UpdateConditions();
+        m_vmTrigger.ToggleDecimal();
     }
     else if (args.Property == IsVisibleProperty)
     {
diff --git a/src/ui/viewmodels/AssetListViewModel.cpp b/src/ui/viewmodels/AssetListViewModel.cpp
index ea19d15f..84f4cad9 100644
--- a/src/ui/viewmodels/AssetListViewModel.cpp
+++ b/src/ui/viewmodels/AssetListViewModel.cpp
@@ -1629,13 +1629,6 @@ void AssetListViewModel::RevertSelected()
             // reverting a local item deletes it
             pAsset->SetDeleted();
 
-            // when an active achievement is deleted, the challenge indicator needs to be hidden as no event will be raised
-            if (pAsset->IsActive() && pAsset->GetType() == ra::data::models::AssetType::Achievement)
-            {
-                auto& pOverlayManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>();
-                pOverlayManager.RemoveChallengeIndicator(pAsset->GetID());
-            }
-
             // if the asset is open in the editor, select no asset
             auto& pWindowManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::WindowManager>();
             if (pWindowManager.AssetEditor.GetAsset() == pAsset)
diff --git a/src/ui/viewmodels/OverlayViewModel.cpp b/src/ui/viewmodels/OverlayViewModel.cpp
index 47411ada..1ca96540 100644
--- a/src/ui/viewmodels/OverlayViewModel.cpp
+++ b/src/ui/viewmodels/OverlayViewModel.cpp
@@ -6,6 +6,7 @@
 #include "data\context\EmulatorContext.hh"
 #include "data\context\UserContext.hh"
 
+#include "services\AchievementRuntime.hh"
 #include "services\IClock.hh"
 #include "services\IConfiguration.hh"
 
@@ -161,12 +162,14 @@ void OverlayViewModel::CreateRenderImage()
     const auto sUserName = ra::Widen(pUserContext.GetDisplayName());
     const auto szUsername = m_pSurface->MeasureText(nFont, sUserName);
 
-    const auto sPoints = ra::StringPrintf(L"%u Points", pUserContext.GetScore());
-    const auto szPoints = m_pSurface->MeasureText(nSubFont, sPoints);
-
     const auto& pConfiguration = ra::services::ServiceLocator::Get<ra::services::IConfiguration>();
     const auto bHardcore = pConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore);
-    const auto sHardcore = std::wstring(L"HARDCORE");
+    const auto* pClient = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>().GetClient();
+    const auto* pUser = rc_client_get_user_info(pClient);
+    const auto sPoints = ra::StringPrintf(L"%u Points", !pUser ? 0 : bHardcore ? pUser->score : pUser->score_softcore);
+    const auto szPoints = m_pSurface->MeasureText(nSubFont, sPoints);
+
+    const auto sHardcore = bHardcore ? std::wstring(L"HARDCORE") : std::wstring(L"SOFTCORE");
     const auto szHardcore = m_pSurface->MeasureText(nDetailFont, sHardcore);
 
     constexpr auto nImageSize = 64;
@@ -189,14 +192,13 @@ void OverlayViewModel::CreateRenderImage()
         m_pSurface->WriteText(nX + nPadding, nY + nPadding + szUsername.Height, nSubFont,
                               pTheme.ColorOverlaySubText(), sPoints);
 
-        if (bHardcore)
-            m_pSurface->WriteText(nX + nPadding, nY + nUserFrameHeight - nPadding - szHardcore.Height, nDetailFont,
-                                  pTheme.ColorError(), sHardcore);
+        m_pSurface->WriteText(nX + nPadding, nY + nUserFrameHeight - nPadding - szHardcore.Height, nDetailFont,
+                              pTheme.ColorOverlayDisabledText(), sHardcore);
     }
-    else if (bHardcore)
+    else if (!bHardcore)
     {
         m_pSurface->WriteText(nWidth - nMargin - szHardcore.Width, nMargin, nDetailFont,
-                              pTheme.ColorError(), sHardcore);
+                              pTheme.ColorOverlayDisabledText(), sHardcore);
     }
 
     // page header
diff --git a/src/ui/viewmodels/TriggerViewModel.cpp b/src/ui/viewmodels/TriggerViewModel.cpp
index 0be26b42..f493df0e 100644
--- a/src/ui/viewmodels/TriggerViewModel.cpp
+++ b/src/ui/viewmodels/TriggerViewModel.cpp
@@ -879,6 +879,41 @@ void TriggerViewModel::UpdateConditions(const GroupViewModel* pGroup)
     m_vConditions.AddNotifyTarget(m_pConditionsMonitor);
 }
 
+static unsigned int ParseNumeric(const std::wstring& sValue)
+{
+    unsigned int nValue = 0;
+    std::wstring sError;
+
+    if (sValue.length() > 2 && sValue.at(1) == 'x' && ra::ParseHex(sValue, 0xFFFFFFFF, nValue, sError))
+        return nValue;
+
+    if (ra::ParseUnsignedInt(sValue, 0xFFFFFFFF, nValue, sError))
+        return nValue;
+
+    return 0;
+}
+
+void TriggerViewModel::ToggleDecimal()
+{
+    m_vConditions.RemoveNotifyTarget(m_pConditionsMonitor);
+    m_vConditions.BeginUpdate();
+
+    for (gsl::index nIndex = m_vConditions.Count() - 1; nIndex >= 0; --nIndex)
+    {
+        auto* pItem = m_vConditions.GetItemAt(nIndex);
+        if (!pItem)
+            continue;
+
+        if (pItem->GetSourceType() == TriggerOperandType::Value)
+            pItem->SetSourceValue(ParseNumeric(pItem->GetSourceValue()));
+        if (pItem->GetTargetType() == TriggerOperandType::Value)
+            pItem->SetTargetValue(ParseNumeric(pItem->GetTargetValue()));
+    }
+
+    m_vConditions.EndUpdate();
+    m_vConditions.AddNotifyTarget(m_pConditionsMonitor);
+}
+
 void TriggerViewModel::UpdateTotalHits()
 {
     unsigned int nHits = 0;
diff --git a/src/ui/viewmodels/TriggerViewModel.hh b/src/ui/viewmodels/TriggerViewModel.hh
index 9b00b8bb..68ca0742 100644
--- a/src/ui/viewmodels/TriggerViewModel.hh
+++ b/src/ui/viewmodels/TriggerViewModel.hh
@@ -144,6 +144,7 @@ public:
     void DoFrame();
     void UpdateColors(const rc_trigger_t* pTrigger);
     void UpdateConditions();
+    void ToggleDecimal();
 
     static bool BuildHitChainTooltip(std::wstring& sTooltip, const ViewModelCollection<TriggerConditionViewModel>& vmConditions, gsl::index nIndex);
 
diff --git a/src/ui/viewmodels/UnknownGameViewModel.cpp b/src/ui/viewmodels/UnknownGameViewModel.cpp
index 1e9e8144..bb53a075 100644
--- a/src/ui/viewmodels/UnknownGameViewModel.cpp
+++ b/src/ui/viewmodels/UnknownGameViewModel.cpp
@@ -91,20 +91,30 @@ void UnknownGameViewModel::CheckForPreviousAssociation()
     if (sHash.empty())
         return;
 
+    const auto nId = GetPreviousAssociation(sHash);
+    if (nId != 0)
+    {
+        const auto& sGameName = m_vGameTitles.GetLabelForId(nId);
+        if (!sGameName.empty())
+            SetSelectedGameId(nId);
+    }
+}
+
+unsigned int UnknownGameViewModel::GetPreviousAssociation(const std::wstring& sHash)
+{
     auto& pLocalStorage = ra::services::ServiceLocator::GetMutable<ra::services::ILocalStorage>();
     auto sMapping = pLocalStorage.ReadText(ra::services::StorageItemType::HashMapping, sHash);
+
     std::string sLine;
     if (sMapping && sMapping->GetLine(sLine))
     {
         const auto& pConsoleContext = ra::services::ServiceLocator::Get<ra::data::context::ConsoleContext>();
         const unsigned nId = DecodeID(sLine, sHash, pConsoleContext.Id());
         if (nId > 0)
-        {
-            const auto& sGameName = m_vGameTitles.GetLabelForId(nId);
-            if (!sGameName.empty())
-                SetSelectedGameId(nId);
-        }
+            return nId;
     }
+
+    return 0;
 }
 
 static constexpr unsigned GenerateMask(const std::wstring& sHash, ConsoleID nConsoleId)
diff --git a/src/ui/viewmodels/UnknownGameViewModel.hh b/src/ui/viewmodels/UnknownGameViewModel.hh
index 3a682c63..fee43579 100644
--- a/src/ui/viewmodels/UnknownGameViewModel.hh
+++ b/src/ui/viewmodels/UnknownGameViewModel.hh
@@ -176,6 +176,8 @@ public:
     /// <returns><c>true</c> if the dialog should be closed, <c>false</c> if not.</returns>
     bool BeginTest();
 
+    static unsigned int GetPreviousAssociation(const std::wstring& sHash);
+
 protected:
     void OnValueChanged(const StringModelProperty::ChangeArgs& args) override;
     void OnValueChanged(const IntModelProperty::ChangeArgs& args) override;
diff --git a/src/ui/win32/AssetEditorDialog.cpp b/src/ui/win32/AssetEditorDialog.cpp
index d8cd1d1b..b2df8794 100644
--- a/src/ui/win32/AssetEditorDialog.cpp
+++ b/src/ui/win32/AssetEditorDialog.cpp
@@ -898,6 +898,8 @@ AssetEditorDialog::AssetEditorDialog(AssetEditorViewModel& vmAssetEditor)
     SetAnchor(IDC_RA_ID, Anchor::Top | Anchor::Right);
     SetAnchor(IDC_RA_CHK_ACTIVE, Anchor::Top | Anchor::Right);
     SetAnchor(IDC_RA_DESCRIPTION, Anchor::Top | Anchor::Left | Anchor::Right);
+    SetAnchor(IDC_RA_LBL_TYPE, Anchor::Top | Anchor::Right);
+    SetAnchor(IDC_RA_TYPE, Anchor::Top | 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);
@@ -982,6 +984,7 @@ BOOL AssetEditorDialog::OnInitDialog()
     }
 
     SendMessage(::GetDlgItem(GetHWND(), IDC_RA_TYPE), CB_SETDROPPEDWIDTH, 70, 0);
+    SendMessage(::GetDlgItem(GetHWND(), IDC_RA_FORMAT), CB_SETDROPPEDWIDTH, 80, 0);
 
     return DialogBase::OnInitDialog();
 }


Test patch:
diff --git a/tests/Exports_Tests.cpp b/tests/Exports_Tests.cpp
index 4e2fccde..7d9f428b 100644
--- a/tests/Exports_Tests.cpp
+++ b/tests/Exports_Tests.cpp
@@ -143,6 +143,7 @@ public:
         bool bWasMenuRebuilt = false;
         harness.mockEmulatorContext.SetRebuildMenuFunction([&bWasMenuRebuilt]() { bWasMenuRebuilt = true; });
         harness.mockEmulatorContext.MockClient("RATests", "0.1.2.0");
+        harness.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
 
         harness.mockRcheevosClient.MockResponse("r=login2&u=User&t=ApiToken",
             "{\"Success\":true,\"User\":\"User\",\"DisplayName\":\"UserDisplay\","
@@ -172,8 +173,8 @@ public:
         Assert::IsNotNull(pPopup);
         Ensures(pPopup != nullptr);
         Assert::AreEqual(std::wstring(L"Welcome UserDisplay"), pPopup->GetTitle());
-        Assert::AreEqual(std::wstring(L"You have 0 new messages"), pPopup->GetDescription());
-        Assert::AreEqual(std::wstring(L"12345 points"), pPopup->GetDetail());
+        Assert::AreEqual(std::wstring(L"12345 points"), pPopup->GetDescription());
+        Assert::AreEqual(std::wstring(L"You have 0 new messages"), pPopup->GetDetail());
         Assert::AreEqual(ra::ui::ImageType::UserPic, pPopup->GetImage().Type());
         Assert::AreEqual(std::string("User"), pPopup->GetImage().Name());
 
@@ -191,6 +192,7 @@ public:
         bool bWasMenuRebuilt = false;
         harness.mockEmulatorContext.SetRebuildMenuFunction([&bWasMenuRebuilt]() { bWasMenuRebuilt = true; });
         harness.mockEmulatorContext.MockClient("RATests", "0.1.2.0");
+        harness.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
 
         harness.mockRcheevosClient.MockResponse("r=login2&u=User&t=ApiToken",
             "{\"Success\":true,\"User\":\"User\",\"DisplayName\":\"UserDisplay\","
@@ -221,8 +223,8 @@ public:
         Assert::IsNotNull(pPopup);
         Ensures(pPopup != nullptr);
         Assert::AreEqual(std::wstring(L"Welcome UserDisplay"), pPopup->GetTitle());
-        Assert::AreEqual(std::wstring(L"You have 0 new messages"), pPopup->GetDescription());
-        Assert::AreEqual(std::wstring(L"12345 points"), pPopup->GetDetail());
+        Assert::AreEqual(std::wstring(L"12345 points"), pPopup->GetDescription());
+        Assert::AreEqual(std::wstring(L"You have 0 new messages"), pPopup->GetDetail());
         Assert::AreEqual(ra::ui::ImageType::UserPic, pPopup->GetImage().Type());
         Assert::AreEqual(std::string("User"), pPopup->GetImage().Name());
 
@@ -236,6 +238,7 @@ public:
     TEST_METHOD(TestAttemptLoginSuccessWithMessages)
     {
         AttemptLoginHarness harness;
+        harness.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
 
         harness.mockRcheevosClient.MockResponse("r=login2&u=User&t=ApiToken",
             "{\"Success\":true,\"User\":\"User\",\"DisplayName\":\"UserDisplay\","
@@ -251,18 +254,19 @@ public:
         Assert::IsNotNull(pPopup);
         Ensures(pPopup != nullptr);
         Assert::AreEqual(std::wstring(L"Welcome UserDisplay"), pPopup->GetTitle());
-        Assert::AreEqual(std::wstring(L"You have 3 new messages"), pPopup->GetDescription());
-        Assert::AreEqual(std::wstring(L"0 points"), pPopup->GetDetail());
+        Assert::AreEqual(std::wstring(L"0 points"), pPopup->GetDescription());
+        Assert::AreEqual(std::wstring(L"You have 3 new messages"), pPopup->GetDetail());
         Assert::AreEqual(ra::ui::ImageType::UserPic, pPopup->GetImage().Type());
         Assert::AreEqual(std::string("User"), pPopup->GetImage().Name());
     }
 
-    TEST_METHOD(TestAttemptLoginSuccessWithPreviousSessionData)
+    TEST_METHOD(TestAttemptLoginSuccessWithPreviousSessionDataHardcore)
     {
         AttemptLoginHarness harness;
 
         bool bWasMenuRebuilt = false;
         harness.mockEmulatorContext.SetRebuildMenuFunction([&bWasMenuRebuilt]() { bWasMenuRebuilt = true; });
+        harness.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
 
         harness.mockRcheevosClient.MockResponse("r=login2&u=User&t=ApiToken",
             "{\"Success\":true,\"User\":\"User\",\"DisplayName\":\"UserDisplay\","
@@ -280,8 +284,39 @@ public:
         Assert::IsNotNull(pPopup);
         Ensures(pPopup != nullptr);
         Assert::AreEqual(std::wstring(L"Welcome back UserDisplay"), pPopup->GetTitle());
-        Assert::AreEqual(std::wstring(L"You have 0 new messages"), pPopup->GetDescription());
-        Assert::AreEqual(std::wstring(L"12345 points"), pPopup->GetDetail());
+        Assert::AreEqual(std::wstring(L"12345 points"), pPopup->GetDescription());
+        Assert::AreEqual(std::wstring(L"You have 0 new messages"), pPopup->GetDetail());
+        Assert::AreEqual(ra::ui::ImageType::UserPic, pPopup->GetImage().Type());
+        Assert::AreEqual(std::string("User"), pPopup->GetImage().Name());
+        Assert::IsTrue(bWasMenuRebuilt);
+    }
+
+    TEST_METHOD(TestAttemptLoginSuccessWithPreviousSessionDataSoftcore)
+    {
+        AttemptLoginHarness harness;
+
+        bool bWasMenuRebuilt = false;
+        harness.mockEmulatorContext.SetRebuildMenuFunction([&bWasMenuRebuilt]() { bWasMenuRebuilt = true; });
+        harness.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, false);
+
+        harness.mockRcheevosClient.MockResponse("r=login2&u=User&t=ApiToken",
+            "{\"Success\":true,\"User\":\"User\",\"DisplayName\":\"UserDisplay\","
+            "\"Token\":\"ApiToken\",\"Score\":12345,\"SoftcoreScore\":123,"
+            "\"Messages\":0,\"Permissions\":1,\"AccountType\":\"Registered\"}");
+
+        harness.mockConfiguration.SetUsername("User");
+        harness.mockConfiguration.SetApiToken("ApiToken");
+
+        harness.mockSessionTracker.MockSession(6U, 123456789, std::chrono::hours(2));
+
+        _RA_AttemptLogin(true);
+
+        const auto* pPopup = harness.mockOverlayManager.GetMessage(1);
+        Assert::IsNotNull(pPopup);
+        Ensures(pPopup != nullptr);
+        Assert::AreEqual(std::wstring(L"Welcome back UserDisplay"), pPopup->GetTitle());
+        Assert::AreEqual(std::wstring(L"123 points (softcore)"), pPopup->GetDescription());
+        Assert::AreEqual(std::wstring(L"You have 0 new messages"), pPopup->GetDetail());
         Assert::AreEqual(ra::ui::ImageType::UserPic, pPopup->GetImage().Type());
         Assert::AreEqual(std::string("User"), pPopup->GetImage().Name());
         Assert::IsTrue(bWasMenuRebuilt);
diff --git a/tests/data/context/GameContext_Tests.cpp b/tests/data/context/GameContext_Tests.cpp
index 140b5232..33e986ed 100644
--- a/tests/data/context/GameContext_Tests.cpp
+++ b/tests/data/context/GameContext_Tests.cpp
@@ -379,6 +379,36 @@ public:
         Assert::IsFalse(bDialogShown);
     }
 
+    TEST_METHOD(TestLoadGameTitleConsoleMismatchNESvStandalone)
+    {
+        GameContextHarness game;
+        game.MockLoadGameAPIs(1U, "0123456789abcdeffedcba987654321", "", "", "", "",
+                              static_cast<ConsoleID>(RC_CONSOLE_STANDALONE));
+
+        game.mockConsoleContext.SetId(ConsoleID::NES);
+        game.mockConsoleContext.SetName(L"Nintendo");
+
+        bool bDialogShown = false;
+        game.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>(
+            [&bDialogShown](ra::ui::viewmodels::MessageBoxViewModel&) {
+                bDialogShown = true;
+                return ra::ui::DialogResult::OK;
+            });
+
+        game.LoadGame(1U, "0123456789abcdeffedcba987654321");
+
+        Assert::IsTrue(game.mockAudioSystem.WasAudioFilePlayed(std::wstring(L"Overlay\\info.wav")));
+
+        const auto* pPopup = game.mockOverlayManager.GetMessage(1);
+        Expects(pPopup != nullptr);
+        Assert::IsNotNull(pPopup);
+        Assert::AreEqual(std::wstring(L"Loaded GameTitle"), pPopup->GetTitle());
+        Assert::AreEqual(std::wstring(L"0 achievements, 0 points"), pPopup->GetDescription());
+        Assert::AreEqual(std::string("9743"), pPopup->GetImage().Name());
+
+        Assert::IsFalse(bDialogShown);
+    }
+
     TEST_METHOD(TestLoadGameNotify)
     {
         class NotifyHarness : public GameContext::NotifyTarget
diff --git a/tests/data/models/AchievementModel_Tests.cpp b/tests/data/models/AchievementModel_Tests.cpp
index 9e02a616..75801689 100644
--- a/tests/data/models/AchievementModel_Tests.cpp
+++ b/tests/data/models/AchievementModel_Tests.cpp
@@ -19,6 +19,8 @@ namespace data {
 namespace models {
 namespace tests {
 
+static bool g_bEventSeen = false; // must be global because callback cannot accept lambda
+
 TEST_CLASS(AchievementModel_Tests)
 {
 private:
@@ -214,6 +216,75 @@ public:
         Assert::IsFalse(achievement.Validate());
         Assert::AreEqual(std::wstring(L"Serialized length exceeds limit: 65536/65535"), achievement.GetValidationError());
     }
+
+    TEST_METHOD(TestDeactivateHidesIndicator)
+    {
+
+        AchievementModelHarness achievement;
+        achievement.mockGameContext.SetGameId(22);
+        achievement.SetID(53U);
+        achievement.SetBadge(L"12345.png");
+        achievement.SetTrigger("0xH1234=1_T:0xH2345=2");
+        achievement.CreateServerCheckpoint();
+        achievement.CreateLocalCheckpoint();
+
+        achievement.mockRuntime.MockGame();
+        auto* achievement_info = achievement.mockRuntime.MockAchievementWithTrigger(achievement.GetID());
+        achievement.ReplaceAttached(*achievement_info);
+
+        g_bEventSeen = false;
+        achievement.mockRuntime.GetClient()->callbacks.event_handler =
+            [](const rc_client_event_t* pEvent, rc_client_t*)
+            {
+                Assert::AreEqual({RC_CLIENT_EVENT_ACHIEVEMENT_CHALLENGE_INDICATOR_HIDE}, pEvent->type);
+                Assert::AreEqual({53}, pEvent->achievement->id);
+                g_bEventSeen = true;
+            };
+
+        achievement.SetState(AssetState::Primed);
+        Assert::IsTrue(achievement.IsActive());
+        Assert::IsFalse(g_bEventSeen);
+
+        achievement.SetState(AssetState::Inactive);
+        Assert::AreEqual(AssetState::Inactive, achievement.GetState());
+        Assert::IsFalse(achievement.IsActive());
+        Assert::IsTrue(g_bEventSeen);
+    }
+
+    TEST_METHOD(TestDeleteHidesIndicator)
+    {
+        AchievementModelHarness achievement;
+        achievement.mockGameContext.SetGameId(22);
+        achievement.SetID(53U);
+        achievement.SetBadge(L"12345.png");
+        achievement.SetTrigger("0xH1234=1_T:0xH2345=2");
+        achievement.CreateServerCheckpoint();
+        achievement.CreateLocalCheckpoint();
+
+        achievement.mockRuntime.MockGame();
+        auto* achievement_info = achievement.mockRuntime.MockAchievementWithTrigger(achievement.GetID());
+        achievement.ReplaceAttached(*achievement_info);
+
+        g_bEventSeen = false;
+        achievement.mockRuntime.GetClient()->callbacks.event_handler = [](const rc_client_event_t* pEvent,
+                                                                          rc_client_t*) {
+            Assert::AreEqual({RC_CLIENT_EVENT_ACHIEVEMENT_CHALLENGE_INDICATOR_HIDE}, pEvent->type);
+            Assert::AreEqual({53}, pEvent->achievement->id);
+            g_bEventSeen = true;
+        };
+
+        achievement.SetState(AssetState::Primed);
+        Assert::IsTrue(achievement.IsActive());
+        Assert::IsFalse(g_bEventSeen);
+
+        achievement.SetDeleted();
+        Assert::AreEqual(AssetChanges::Deleted, achievement.GetChanges());
+
+        // deleting should deactivate the achievement, which should raise the indicator hide event
+        Assert::AreEqual(AssetState::Inactive, achievement.GetState());
+        Assert::IsFalse(achievement.IsActive());
+        Assert::IsTrue(g_bEventSeen);
+    }
 };
 
 
diff --git a/tests/data/models/LeaderboardModel_Tests.cpp b/tests/data/models/LeaderboardModel_Tests.cpp
index 748976e0..0788505d 100644
--- a/tests/data/models/LeaderboardModel_Tests.cpp
+++ b/tests/data/models/LeaderboardModel_Tests.cpp
@@ -17,6 +17,8 @@ namespace data {
 namespace models {
 namespace tests {
 
+static bool g_bEventSeen = false; // must be global because callback cannot accept lambda
+
 TEST_CLASS(LeaderboardModel_Tests)
 {
 private:
@@ -161,6 +163,13 @@ public:
         TestSerializeValueFormat(ValueFormat::Seconds, "SECS");
         TestSerializeValueFormat(ValueFormat::Minutes, "MINUTES");
         TestSerializeValueFormat(ValueFormat::SecondsAsMinutes, "SECS_AS_MINS");
+        TestSerializeValueFormat(ValueFormat::Fixed1, "FIXED1");
+        TestSerializeValueFormat(ValueFormat::Fixed2, "FIXED2");
+        TestSerializeValueFormat(ValueFormat::Fixed3, "FIXED3");
+        TestSerializeValueFormat(ValueFormat::Tens, "TENS");
+        TestSerializeValueFormat(ValueFormat::Hundreds, "HUNDREDS");
+        TestSerializeValueFormat(ValueFormat::Thousands, "THOUSANDS");
+        TestSerializeValueFormat(ValueFormat::UnsignedValue, "UNSIGNED");
     }
 
     TEST_METHOD(TestTransactionalProperties)
@@ -248,6 +257,118 @@ public:
         Assert::IsTrue(leaderboard.Validate());
         Assert::AreEqual(std::wstring(), leaderboard.GetValidationError());
     }
+
+
+    TEST_METHOD(TestDeactivateHidesTracker)
+    {
+        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();
+
+        leaderboard.mockRuntime.MockGame();
+        auto* leaderboard_info = leaderboard.mockRuntime.MockLeaderboardWithLboard(leaderboard.GetID());
+        leaderboard.ReplaceAttached(*leaderboard_info);
+
+        rc_client_allocate_leaderboard_tracker(leaderboard.mockRuntime.GetClient()->game, leaderboard_info);
+
+        g_bEventSeen = false;
+        leaderboard.mockRuntime.GetClient()->callbacks.event_handler =
+            [](const rc_client_event_t* pEvent, rc_client_t*) {
+                Assert::AreEqual({RC_CLIENT_EVENT_LEADERBOARD_TRACKER_HIDE}, pEvent->type);
+                Assert::AreEqual({1}, pEvent->leaderboard_tracker->id);
+                g_bEventSeen = true;
+            };
+
+        leaderboard.SetState(AssetState::Primed);
+        Assert::IsTrue(leaderboard.IsActive());
+        Assert::IsFalse(g_bEventSeen);
+
+        leaderboard.SetState(AssetState::Inactive);
+        Assert::AreEqual(AssetState::Inactive, leaderboard.GetState());
+        Assert::IsFalse(leaderboard.IsActive());
+        Assert::IsTrue(g_bEventSeen);
+    }
+
+    TEST_METHOD(TestDeactivateDoesntHideSharedTracker)
+    {
+        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();
+
+        leaderboard.mockRuntime.MockGame();
+        auto* leaderboard_info = leaderboard.mockRuntime.MockLeaderboardWithLboard(leaderboard.GetID());
+        leaderboard.ReplaceAttached(*leaderboard_info);
+
+        rc_client_allocate_leaderboard_tracker(leaderboard.mockRuntime.GetClient()->game, leaderboard_info);
+        auto* leaderboard_info2 = leaderboard.mockRuntime.MockLeaderboardWithLboard(99);
+        rc_client_allocate_leaderboard_tracker(leaderboard.mockRuntime.GetClient()->game, leaderboard_info2);
+
+        g_bEventSeen = false;
+        leaderboard.mockRuntime.GetClient()->callbacks.event_handler =
+            [](const rc_client_event_t*, rc_client_t*) {
+                g_bEventSeen = true;
+            };
+
+        leaderboard.SetState(AssetState::Primed);
+        Assert::IsTrue(leaderboard.IsActive());
+        Assert::IsFalse(g_bEventSeen);
+
+        leaderboard.SetState(AssetState::Inactive);
+        Assert::AreEqual(AssetState::Inactive, leaderboard.GetState());
+        Assert::IsFalse(leaderboard.IsActive());
+        Assert::IsFalse(g_bEventSeen);
+    }
+
+    TEST_METHOD(TestDeleteHidesTracker)
+    {
+        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();
+
+        leaderboard.mockRuntime.MockGame();
+        auto* leaderboard_info = leaderboard.mockRuntime.MockLeaderboardWithLboard(leaderboard.GetID());
+        leaderboard.ReplaceAttached(*leaderboard_info);
+
+        rc_client_allocate_leaderboard_tracker(leaderboard.mockRuntime.GetClient()->game, leaderboard_info);
+
+        g_bEventSeen = false;
+        leaderboard.mockRuntime.GetClient()->callbacks.event_handler =
+            [](const rc_client_event_t* pEvent, rc_client_t*) {
+                Assert::AreEqual({RC_CLIENT_EVENT_LEADERBOARD_TRACKER_HIDE}, pEvent->type);
+                Assert::AreEqual({1}, pEvent->leaderboard_tracker->id);
+                g_bEventSeen = true;
+            };
+
+        leaderboard.SetState(AssetState::Primed);
+        Assert::IsTrue(leaderboard.IsActive());
+        Assert::IsFalse(g_bEventSeen);
+
+        leaderboard.SetDeleted();
+        Assert::AreEqual(AssetChanges::Deleted, leaderboard.GetChanges());
+
+        // deleting should deactivate the leaderboard, which should raise the indicator hide event
+        Assert::AreEqual(AssetState::Inactive, leaderboard.GetState());
+        Assert::IsFalse(leaderboard.IsActive());
+        Assert::IsTrue(g_bEventSeen);
+    }
 };
 
 } // namespace tests
diff --git a/tests/mocks/MockAchievementRuntime.cpp b/tests/mocks/MockAchievementRuntime.cpp
index fcab935e..8ba349f8 100644
--- a/tests/mocks/MockAchievementRuntime.cpp
+++ b/tests/mocks/MockAchievementRuntime.cpp
@@ -161,9 +161,12 @@ rc_client_achievement_info_t* MockAchievementRuntime::ActivateAchievement(uint32
     m_mAchievementDefinitions[nId] = sTrigger;
 
     auto nSize = rc_trigger_size(sTrigger.c_str());
-    void* trigger_buffer = rc_buffer_alloc(&game->buffer, nSize);
-    achievement->trigger = rc_parse_trigger(trigger_buffer, sTrigger.c_str(), nullptr, 0);
-    achievement->public_.state = RC_CLIENT_ACHIEVEMENT_STATE_ACTIVE;
+    if (nSize > 0)
+    {
+        void* trigger_buffer = rc_buffer_alloc(&game->buffer, nSize);
+        achievement->trigger = rc_parse_trigger(trigger_buffer, sTrigger.c_str(), nullptr, 0);
+        achievement->public_.state = RC_CLIENT_ACHIEVEMENT_STATE_ACTIVE;
+    }
 
     return achievement;
 }
diff --git a/tests/services/GameIdentifier_Tests.cpp b/tests/services/GameIdentifier_Tests.cpp
index 7641c262..f273ea68 100644
--- a/tests/services/GameIdentifier_Tests.cpp
+++ b/tests/services/GameIdentifier_Tests.cpp
@@ -327,6 +327,66 @@ public:
         Assert::AreEqual(99U, identifier.IdentifyGame(&ROM.at(0), ROM.size()));
     }
 
+    TEST_METHOD(TestIdentifyHashMultiDiscCompatibility)
+    {
+        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;
+            });
+
+        identifier.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>(
+            [](ra::ui::viewmodels::MessageBoxViewModel&) {
+                // Play 'TestGame' in compatibility test mode?
+                return ra::ui::DialogResult::Yes;
+            });
+
+        bool bDialogShown = false;
+        identifier.mockDesktop.ExpectWindow<ra::ui::viewmodels::UnknownGameViewModel>(
+            [&bDialogShown](ra::ui::viewmodels::UnknownGameViewModel& vmUnknownGame) {
+                bDialogShown = true;
+                vmUnknownGame.SetSelectedGameId(23);
+                vmUnknownGame.BeginTest();
+                return ra::ui::DialogResult::OK;
+            });
+
+        const std::string sHash1 = "0123456789abcdef0123456789abcdef";
+        const std::string sHash2 = "abcdef0123456789abcdef0123456789";
+        Assert::AreEqual(23U, identifier.IdentifyHash(sHash1));
+        Assert::IsTrue(bDialogShown);
+
+        // context is not changed by calling IdentifyGame
+        Assert::AreEqual(ra::data::context::GameContext::Mode::Normal, identifier.mockGameContext.GetMode());
+        Assert::AreEqual(0U, identifier.mockGameContext.GameId());
+
+        // context is updated by calling ActivateGame
+        identifier.ActivateGame(23U);
+        Assert::AreEqual(ra::data::context::GameContext::Mode::CompatibilityTest, identifier.mockGameContext.GetMode());
+        Assert::AreEqual(23U, identifier.mockGameContext.GameId());
+        Assert::AreEqual(sHash1, identifier.mockGameContext.GameHash());
+
+        // switching to second unknown disc should prompt to test compatibility
+        bDialogShown = false;
+        Assert::AreEqual(23U, identifier.IdentifyHash(sHash2));
+        Assert::IsTrue(bDialogShown);
+        Assert::AreEqual(ra::data::context::GameContext::Mode::CompatibilityTest, identifier.mockGameContext.GetMode());
+        Assert::AreEqual(23U, identifier.mockGameContext.GameId());
+        Assert::AreEqual(sHash2, identifier.mockGameContext.GameHash());
+
+        // switching back to the first disc should not prompt to test compatibility
+        bDialogShown = false;
+        Assert::AreEqual(23U, identifier.IdentifyHash(sHash1));
+        Assert::IsFalse(bDialogShown);
+        Assert::AreEqual(ra::data::context::GameContext::Mode::CompatibilityTest, identifier.mockGameContext.GetMode());
+        Assert::AreEqual(23U, identifier.mockGameContext.GameId());
+        Assert::AreEqual(sHash1, identifier.mockGameContext.GameHash());
+    }
+
     TEST_METHOD(TestActivateGameZeroGameNotLoaded)
     {
         GameIdentifierHarness identifier;
diff --git a/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp b/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp
index 859a3aca..3ebcd2e5 100644
--- a/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp
@@ -12,12 +12,14 @@
 #include "tests\ui\viewmodels\TriggerConditionAsserts.hh"
 
 #include "tests\mocks\MockAchievementRuntime.hh"
+#include "tests\mocks\MockClipboard.hh"
 #include "tests\mocks\MockClock.hh"
 #include "tests\mocks\MockConfiguration.hh"
 #include "tests\mocks\MockDesktop.hh"
 #include "tests\mocks\MockFileSystem.hh"
 #include "tests\mocks\MockGameContext.hh"
 #include "tests\mocks\MockImageRepository.hh"
+#include "tests\mocks\MockOverlayManager.hh"
 
 using namespace Microsoft::VisualStudio::CppUnitTestFramework;
 
@@ -66,6 +68,7 @@ private:
         ra::data::context::mocks::MockGameContext mockGameContext;
         ra::ui::mocks::MockDesktop mockDesktop;
         ra::ui::mocks::MockImageRepository mockImageRepository;
+        ra::ui::viewmodels::mocks::MockOverlayManager mockOverlayManager;
         ra::ui::EditorTheme mockTheme;
 
         const std::wstring& GetWaitingLabel() const
@@ -141,7 +144,7 @@ public:
         Assert::AreEqual((int)ra::data::models::AchievementType::Win, editor.AchievementTypes().GetItemAt(3)->GetId());
         Assert::AreEqual(std::wstring(L"Win"), editor.AchievementTypes().GetItemAt(3)->GetLabel());
 
-        Assert::AreEqual({ 6U }, editor.Formats().Count());
+        Assert::AreEqual({ 13U }, 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());
@@ -154,6 +157,20 @@ public:
         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((int)ra::data::ValueFormat::Fixed1, editor.Formats().GetItemAt(6)->GetId());
+        Assert::AreEqual(std::wstring(L"Fixed1"), editor.Formats().GetItemAt(6)->GetLabel());
+        Assert::AreEqual((int)ra::data::ValueFormat::Fixed2, editor.Formats().GetItemAt(7)->GetId());
+        Assert::AreEqual(std::wstring(L"Fixed2"), editor.Formats().GetItemAt(7)->GetLabel());
+        Assert::AreEqual((int)ra::data::ValueFormat::Fixed3, editor.Formats().GetItemAt(8)->GetId());
+        Assert::AreEqual(std::wstring(L"Fixed3"), editor.Formats().GetItemAt(8)->GetLabel());
+        Assert::AreEqual((int)ra::data::ValueFormat::Tens, editor.Formats().GetItemAt(9)->GetId());
+        Assert::AreEqual(std::wstring(L"Tens"), editor.Formats().GetItemAt(9)->GetLabel());
+        Assert::AreEqual((int)ra::data::ValueFormat::Hundreds, editor.Formats().GetItemAt(10)->GetId());
+        Assert::AreEqual(std::wstring(L"Hundreds"), editor.Formats().GetItemAt(10)->GetLabel());
+        Assert::AreEqual((int)ra::data::ValueFormat::Thousands, editor.Formats().GetItemAt(11)->GetId());
+        Assert::AreEqual(std::wstring(L"Thousands"), editor.Formats().GetItemAt(11)->GetLabel());
+        Assert::AreEqual((int)ra::data::ValueFormat::UnsignedValue, editor.Formats().GetItemAt(12)->GetId());
+        Assert::AreEqual(std::wstring(L"Unsigned Value"), editor.Formats().GetItemAt(12)->GetLabel());
 
         Assert::AreEqual({ 4U }, editor.LeaderboardParts().Count());
         Assert::AreEqual((int)AssetEditorViewModel::LeaderboardPart::Start, editor.LeaderboardParts().GetItemAt(0)->GetId());
@@ -1862,6 +1879,64 @@ public:
         Assert::AreEqual(std::wstring(L"2.3"), pCondition2->GetTargetValue());
     }
 
+    TEST_METHOD(TestDecimalPreferredUpdatesConditionsWhenError)
+    {
+        AssetEditorViewModelHarness editor;
+        editor.mockRuntime.ActivateAchievement(1234U, "0xH1234=1_M:0xH2345=23");
+        editor.mockGameContext.InitializeFromAchievementRuntime();
+        auto* vmAch = editor.mockGameContext.Assets().FindAchievement(1234U);
+        editor.mockRuntime.SyncAssets();
+
+        const auto* pTrigger = editor.mockRuntime.GetAchievementTrigger(1234U);
+        Expects(pTrigger != nullptr);
+
+        editor.LoadAsset(vmAch);
+        const auto* pCondition1 = editor.Trigger().Conditions().GetItemAt(0);
+        Expects(pCondition1 != nullptr);
+        const auto* pCondition2 = editor.Trigger().Conditions().GetItemAt(1);
+        Expects(pCondition2 != nullptr);
+
+        ra::services::mocks::MockClipboard mockClipboard;
+        mockClipboard.SetText(L"M:0x2345=23");
+        editor.Trigger().PasteFromClipboard();
+        mockClipboard.SetText(L"");
+
+        const auto* pCondition3 = editor.Trigger().Conditions().GetItemAt(2);
+        Expects(pCondition3 != nullptr);
+        Assert::IsTrue(editor.HasAssetValidationError());
+        Assert::AreEqual(std::wstring(L"Multiple measured targets"), editor.GetAssetValidationError());
+
+        Assert::IsFalse(editor.IsDecimalPreferred());
+        Assert::AreEqual(std::wstring(L"0x1234"), pCondition1->GetSourceValue());
+        Assert::AreEqual(std::wstring(L"0x01"), pCondition1->GetTargetValue());
+        Assert::AreEqual(std::wstring(L"0x2345"), pCondition2->GetSourceValue());
+        Assert::AreEqual(std::wstring(L"0x17"), pCondition2->GetTargetValue());
+        Assert::AreEqual(std::wstring(L"0x2345"), pCondition3->GetSourceValue());
+        Assert::AreEqual(std::wstring(L"0x17"), pCondition3->GetTargetValue());
+
+        // when the editor is in an error state, there's no backing trigger.
+        // the columns must be updated without being regenerated.
+        editor.SetDecimalPreferred(true);
+        Assert::IsTrue(editor.IsDecimalPreferred());
+        Assert::AreEqual({3U}, editor.Trigger().Conditions().Count());
+        Assert::AreEqual(std::wstring(L"0x1234"), pCondition1->GetSourceValue());
+        Assert::AreEqual(std::wstring(L"1"), pCondition1->GetTargetValue());
+        Assert::AreEqual(std::wstring(L"0x2345"), pCondition2->GetSourceValue());
+        Assert::AreEqual(std::wstring(L"23"), pCondition2->GetTargetValue());
+        Assert::AreEqual(std::wstring(L"0x2345"), pCondition3->GetSourceValue());
+        Assert::AreEqual(std::wstring(L"23"), pCondition3->GetTargetValue());
+
+        editor.SetDecimalPreferred(false);
+        Assert::IsFalse(editor.IsDecimalPreferred());
+        Assert::AreEqual({3U}, editor.Trigger().Conditions().Count());
+        Assert::AreEqual(std::wstring(L"0x1234"), pCondition1->GetSourceValue());
+        Assert::AreEqual(std::wstring(L"0x01"), pCondition1->GetTargetValue());
+        Assert::AreEqual(std::wstring(L"0x2345"), pCondition2->GetSourceValue());
+        Assert::AreEqual(std::wstring(L"0x17"), pCondition2->GetTargetValue());
+        Assert::AreEqual(std::wstring(L"0x2345"), pCondition3->GetSourceValue());
+        Assert::AreEqual(std::wstring(L"0x17"), pCondition3->GetTargetValue());
+    }
+
     TEST_METHOD(TestCaptureRestoreHits)
     {
         AssetEditorViewModelHarness editor;