Repository: RetroAchievements/RAIntegration
Pull request #833 (2021-11-15 04:54:48)

remove last references to RA_Leaderboard
Fully switches over to the LeaderboardModel data structure (#750)


Patch:
diff --git a/src/Exports.cpp b/src/Exports.cpp
index 5839e202..92439bc0 100644
--- a/src/Exports.cpp
+++ b/src/Exports.cpp
@@ -449,7 +449,7 @@ static void ProcessAchievements()
 
             case ra::services::AchievementRuntime::ChangeType::LeaderboardStarted:
             {
-                const auto* pLeaderboard = pGameContext.FindLeaderboard(pChange.nId);
+                const auto* pLeaderboard = pGameContext.Assets().FindLeaderboard(pChange.nId);
                 if (pLeaderboard)
                 {
                     const auto& pConfiguration = ra::services::ServiceLocator::Get<ra::services::IConfiguration>();
@@ -458,13 +458,13 @@ static void ProcessAchievements()
                         ra::services::ServiceLocator::Get<ra::services::IAudioSystem>().PlayAudioFile(L"Overlay\\lb.wav");
                         auto& pOverlayManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>();
                         const int nPopup = pOverlayManager.QueueMessage(ra::ui::viewmodels::Popup::LeaderboardStarted,
-                            L"Leaderboard attempt started", ra::Widen(pLeaderboard->Title()), ra::Widen(pLeaderboard->Description()));
+                            L"Leaderboard attempt started", pLeaderboard->GetName(), pLeaderboard->GetDescription());
                     }
 
                     if (pConfiguration.GetPopupLocation(ra::ui::viewmodels::Popup::LeaderboardTracker) != ra::ui::viewmodels::PopupLocation::None)
                     {
                         auto& pOverlayManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>();
-                        auto& pScoreTracker = pOverlayManager.AddScoreTracker(pLeaderboard->ID());
+                        auto& pScoreTracker = pOverlayManager.AddScoreTracker(pLeaderboard->GetID());
                         const auto sDisplayText = pLeaderboard->FormatScore(pChange.nValue);
                         pScoreTracker.SetDisplayText(ra::Widen(sDisplayText));
                     }
@@ -475,7 +475,7 @@ static void ProcessAchievements()
 
             case ra::services::AchievementRuntime::ChangeType::LeaderboardUpdated:
             {
-                const auto* pLeaderboard = pGameContext.FindLeaderboard(pChange.nId);
+                const auto* pLeaderboard = pGameContext.Assets().FindLeaderboard(pChange.nId);
                 if (pLeaderboard)
                 {
                     auto& pOverlayManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>();
@@ -492,7 +492,7 @@ static void ProcessAchievements()
 
             case ra::services::AchievementRuntime::ChangeType::LeaderboardCanceled:
             {
-                const auto* pLeaderboard = pGameContext.FindLeaderboard(pChange.nId);
+                const auto* pLeaderboard = pGameContext.Assets().FindLeaderboard(pChange.nId);
                 if (pLeaderboard)
                 {
                     const auto& pConfiguration = ra::services::ServiceLocator::Get<ra::services::IConfiguration>();
@@ -502,11 +502,11 @@ static void ProcessAchievements()
 
                         auto& pOverlayManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>();
                         const int nPopup = pOverlayManager.QueueMessage(ra::ui::viewmodels::Popup::LeaderboardCanceled,
-                            L"Leaderboard attempt failed", ra::Widen(pLeaderboard->Title()), ra::Widen(pLeaderboard->Description()));
+                            L"Leaderboard attempt failed", pLeaderboard->GetName(), pLeaderboard->GetDescription());
                     }
 
                     auto& pOverlayManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>();
-                    pOverlayManager.RemoveScoreTracker(pLeaderboard->ID());
+                    pOverlayManager.RemoveScoreTracker(pLeaderboard->GetID());
                 }
 
                 break;
@@ -628,6 +628,9 @@ static bool CanRestoreState()
 
 static void OnStateRestored()
 {
+    const auto& pRuntime = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+    auto& pConfiguration = ra::services::ServiceLocator::GetMutable<ra::services::IConfiguration>();
+
     auto& pOverlayManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>();
     pOverlayManager.ClearPopups();
 
@@ -635,15 +638,50 @@ static void OnStateRestored()
     pAssets.BeginUpdate();
     for (gsl::index nIndex = 0; nIndex < gsl::narrow_cast<gsl::index>(pAssets.Count()); ++nIndex)
     {
-        auto* pAchievement = dynamic_cast<ra::data::models::AchievementModel*>(pAssets.GetItemAt(nIndex));
-        if (pAchievement != nullptr)
+        auto* pAsset = pAssets.GetItemAt(nIndex);
+        Expects(pAsset != nullptr);
+
+        switch (pAsset->GetType())
         {
-            // synchronize the state
-            pAchievement->DoFrame();
+            case ra::data::models::AssetType::Achievement:
+            {
+                auto* pAchievement = dynamic_cast<ra::data::models::AchievementModel*>(pAsset);
+                Expects(pAchievement != nullptr);
+
+                // synchronize the state
+                pAchievement->DoFrame();
+
+                // if it's Primed, show the indicator
+                if (pAchievement->GetState() == ra::data::models::AssetState::Primed)
+                    pOverlayManager.AddChallengeIndicator(pAchievement->GetID(), ra::ui::ImageType::Badge, ra::Narrow(pAchievement->GetBadge()));
+                break;
+            }
+
+            case ra::data::models::AssetType::Leaderboard:
+            {
+                auto* pLeaderboard = dynamic_cast<ra::data::models::LeaderboardModel*>(pAsset);
+                Expects(pLeaderboard != nullptr);
+
+                // synchronize the state
+                pLeaderboard->DoFrame();
 
-            // if it's Primed, show the indicator
-            if (pAchievement->GetState() == ra::data::models::AssetState::Primed)
-                pOverlayManager.AddChallengeIndicator(pAchievement->GetID(), ra::ui::ImageType::Badge, ra::Narrow(pAchievement->GetBadge()));
+                // if it's Primed, show the tracker
+                if (pLeaderboard->GetState() == ra::data::models::AssetState::Primed)
+                {
+                    if (pConfiguration.GetPopupLocation(ra::ui::viewmodels::Popup::LeaderboardTracker) != ra::ui::viewmodels::PopupLocation::None)
+                    {
+                        auto& pScoreTracker = pOverlayManager.AddScoreTracker(pLeaderboard->GetID());
+
+                        const auto pDefinition = pRuntime.GetLeaderboardDefinition(pLeaderboard->GetID());
+                        if (pDefinition != nullptr)
+                        {
+                            const auto sDisplayText = pLeaderboard->FormatScore(pDefinition->value.value.value);
+                            pScoreTracker.SetDisplayText(ra::Widen(sDisplayText));
+                        }
+                    }
+                }
+                break;
+            }
         }
     }
     pAssets.EndUpdate();
diff --git a/src/RA_Integration.vcxproj b/src/RA_Integration.vcxproj
index 56b06e51..5fcc0524 100644
--- a/src/RA_Integration.vcxproj
+++ b/src/RA_Integration.vcxproj
@@ -72,7 +72,6 @@
     <ClCompile Include="RA_Defs.cpp" />
     <ClCompile Include="RA_ImageFactory.cpp" />
     <ClCompile Include="RA_Json.cpp" />
-    <ClCompile Include="RA_Leaderboard.cpp" />
     <ClCompile Include="RA_md5factory.cpp" />
     <ClCompile Include="RA_StringUtils.cpp" />
     <ClCompile Include="services\AchievementRuntime.cpp" />
@@ -217,7 +216,6 @@
     <ClInclude Include="ra_fwd.h" />
     <ClInclude Include="RA_ImageFactory.h" />
     <ClInclude Include="RA_Json.h" />
-    <ClInclude Include="RA_Leaderboard.h" />
     <ClInclude Include="RA_Log.h" />
     <ClInclude Include="ra_math.h" />
     <ClInclude Include="RA_md5factory.h" />
diff --git a/src/RA_Integration.vcxproj.filters b/src/RA_Integration.vcxproj.filters
index 655c0ec9..76704679 100644
--- a/src/RA_Integration.vcxproj.filters
+++ b/src/RA_Integration.vcxproj.filters
@@ -54,9 +54,6 @@
     </Filter>
   </ItemGroup>
   <ItemGroup>
-    <ClCompile Include="RA_Leaderboard.cpp">
-      <Filter>Data</Filter>
-    </ClCompile>
     <ClCompile Include="RA_md5factory.cpp">
       <Filter>Services</Filter>
     </ClCompile>
@@ -386,9 +383,6 @@
     </ClCompile>
   </ItemGroup>
   <ItemGroup>
-    <ClInclude Include="RA_Leaderboard.h">
-      <Filter>Data</Filter>
-    </ClInclude>
     <ClInclude Include="RA_Resource.h">
       <Filter>Resources</Filter>
     </ClInclude>
diff --git a/src/RA_Leaderboard.cpp b/src/RA_Leaderboard.cpp
deleted file mode 100644
index cafd399a..00000000
--- a/src/RA_Leaderboard.cpp
+++ /dev/null
@@ -1,85 +0,0 @@
-#include "RA_Leaderboard.h"
-
-#include "RA_Defs.h"
-
-#include "services\AchievementRuntime.hh"
-#include "services\ServiceLocator.hh"
-
-#include "ui\viewmodels\MessageBoxViewModel.hh"
-#include "ui\viewmodels\OverlayManager.hh"
-
-#include <ctime>
-
-RA_Leaderboard::~RA_Leaderboard() noexcept
-{
-    if (m_bActive)
-    {
-        if (ra::services::ServiceLocator::Exists<ra::services::AchievementRuntime>())
-        {
-            auto& pRuntime = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-            pRuntime.DeactivateLeaderboard(ID());
-        }
-    }
-}
-
-void RA_Leaderboard::ParseFromString(const char* sBuffer, int nFormat)
-{
-    m_sDefinition = sBuffer;
-    m_nFormat = nFormat;
-}
-
-void RA_Leaderboard::SetActive(bool bActive) noexcept
-{
-    if (m_bActive != bActive)
-    {
-        m_bActive = bActive;
-
-        if (!m_sDefinition.empty() && ra::services::ServiceLocator::Exists<ra::services::AchievementRuntime>())
-        {
-            auto& pRuntime = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
-            if (!m_bActive)
-            {
-                pRuntime.DeactivateLeaderboard(ID());
-
-                ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>().RemoveScoreTracker(ID());
-            }
-            else
-            {
-                int nResult = pRuntime.ActivateLeaderboard(ID(), m_sDefinition.c_str());
-                if (nResult < 0)
-                {
-                    // parse error occurred
-                    RA_LOG_WARN("rc_parse_trigger returned %d for achievement %u", nResult, ID());
-
-                    ra::ui::viewmodels::MessageBoxViewModel::ShowWarningMessage(
-                        ra::StringPrintf(L"Unable to activate leaderboard: %s", Title()),
-                        ra::StringPrintf(L"Parse error %d\n%s", nResult, rc_error_str(nResult)));
-                }
-            }
-        }
-    }
-}
-
-std::string RA_Leaderboard::FormatScore(int nValue) const
-{
-    char buffer[32];
-    rc_format_value(buffer, sizeof(buffer), nValue, m_nFormat);
-    return std::string(buffer);
-}
-
-void RA_Leaderboard::SubmitRankInfo(unsigned int nRank, const std::string& sUsername, int nScore, time_t nAchieved)
-{
-    if (EntryExists(nRank))
-        return;
-
-    // If not found, add new entry.
-    m_RankInfo.emplace_back(nRank, sUsername, nScore, nAchieved);
-}
-
-bool RA_Leaderboard::EntryExists(unsigned int nRank)
-{
-    SortRankInfo();
-    return (std::binary_search(m_RankInfo.begin(), m_RankInfo.end(), nRank));
-}
-
-void RA_Leaderboard::SortRankInfo() { std::sort(m_RankInfo.begin(), m_RankInfo.end()); }
diff --git a/src/RA_Leaderboard.h b/src/RA_Leaderboard.h
deleted file mode 100644
index 5fd25984..00000000
--- a/src/RA_Leaderboard.h
+++ /dev/null
@@ -1,101 +0,0 @@
-#ifndef RA_LEADERBOARD_H
-#define RA_LEADERBOARD_H
-#pragma once
-
-#include "data\Types.hh"
-#include "ra_fwd.h"
-
-class RA_Leaderboard
-{
-public:
-    explicit RA_Leaderboard(_In_ const ra::LeaderboardID nLBID) noexcept : m_nID(nLBID) {};
-    GSL_SUPPRESS_F6 virtual ~RA_Leaderboard() noexcept;
-    RA_Leaderboard(const RA_Leaderboard&) noexcept = delete;
-    RA_Leaderboard& operator=(const RA_Leaderboard&) noexcept = delete;
-    RA_Leaderboard(RA_Leaderboard&&) noexcept = default;
-    RA_Leaderboard& operator=(RA_Leaderboard&&) noexcept = default;
-
-    void ParseFromString(const char* sBuffer, int nFormat);
-
-    ra::LeaderboardID ID() const noexcept { return m_nID; }
-
-    const std::string& Title() const noexcept { return m_sTitle; }
-    void SetTitle(const std::string& sValue) { m_sTitle = sValue; }
-
-    const std::string& Description() const noexcept { return m_sDescription; }
-    void SetDescription(const std::string& sValue) { m_sDescription = sValue; }
-
-    std::string FormatScore(int nValue) const;
-
-    struct Entry
-    {
-        Entry() noexcept = default;
-        explicit Entry(unsigned int nRank, const std::string& sUsername, int nScore, std::time_t nAchieved) :
-            m_nRank{nRank},
-            m_sUsername{sUsername},
-            m_nScore{nScore},
-            m_TimeAchieved{nAchieved}
-        {}
-        ~Entry() noexcept = default;
-        Entry(const Entry&) = delete;
-        Entry& operator=(const Entry&) = delete;
-        Entry(Entry&&) noexcept = default;
-        Entry& operator=(Entry&&) noexcept = default;
-
-        unsigned int m_nRank{};
-        std::string m_sUsername;
-        int m_nScore{};
-        std::time_t m_TimeAchieved{};
-    };
-
-    void SubmitRankInfo(unsigned int nRank, const std::string& sUsername, int nScore, time_t nAchieved);
-    bool EntryExists(unsigned int nRank);
-    void ClearRankInfo() noexcept { m_RankInfo.clear(); }
-    const Entry& GetRankInfo(unsigned int nAt) const { return m_RankInfo.at(nAt); }
-    size_t GetRankInfoCount() const noexcept { return m_RankInfo.size(); }
-    void SortRankInfo();
-
-    GSL_SUPPRESS_F6 void SetActive(bool bActive) noexcept;
-    bool IsActive() const noexcept { return m_bActive; }
-
-protected:
-
-    bool m_bActive = false;
-
-private:
-    const ra::LeaderboardID m_nID = ra::LeaderboardID(); //  DB ID for this LB
-
-    // default to RC_FORMAT_VALUE (4) - use constant as we don't want to expose rcheevos.h to things using this header
-    int m_nFormat = 4; // A format to output. Typically "%d" for score or "%02d:%02d.%02d" for time
-
-    std::string m_sTitle;       //  The title of the leaderboard
-    std::string m_sDescription; //  A brief description of the leaderboard
-    std::string m_sDefinition;  //  The serialized definition
-
-    std::vector<Entry> m_RankInfo; //  Recent users ranks
-};
-
-// Comparison
-_NODISCARD _CONSTANT_FN operator==(const RA_Leaderboard::Entry& a, const RA_Leaderboard::Entry& b) noexcept
-{
-    return (a.m_nRank == b.m_nRank);
-}
-
-// Sorting
-_NODISCARD _CONSTANT_FN operator<(const RA_Leaderboard::Entry& a, const RA_Leaderboard::Entry& b) noexcept
-{
-    return (a.m_nRank < b.m_nRank);
-}
-
-// binary search
-_NODISCARD _CONSTANT_FN operator<(unsigned int key, const RA_Leaderboard::Entry& value) noexcept
-{
-    return key < value.m_nRank;
-}
-
-_NODISCARD _CONSTANT_FN operator<(const RA_Leaderboard::Entry& value, unsigned int key) noexcept
-{
-    return value.m_nRank < key;
-}
-
-#endif // !RA_LEADERBOARD_H
diff --git a/src/data/context/GameContext.cpp b/src/data/context/GameContext.cpp
index f21e93a3..daa7b008 100644
--- a/src/data/context/GameContext.cpp
+++ b/src/data/context/GameContext.cpp
@@ -59,8 +59,6 @@ void GameContext::LoadGame(unsigned int nGameId, Mode nMode)
     m_mCodeNotes.clear();
     m_vAssets.ResetLocalId();
 
-    m_vLeaderboards.clear();
-
     m_vAssets.BeginUpdate();
 
     m_vAssets.Clear();
@@ -196,7 +194,6 @@ void GameContext::LoadGame(unsigned int nGameId, Mode nMode)
     // leaderboards
     for (const auto& pLeaderboardData : response.Leaderboards)
     {
-#ifdef ASSET_ICONS
         auto vmLeaderboard = std::make_unique<ra::data::models::LeaderboardModel>();
         vmLeaderboard->SetID(pLeaderboardData.Id);
         vmLeaderboard->SetName(ra::Widen(pLeaderboardData.Title));
@@ -207,12 +204,6 @@ void GameContext::LoadGame(unsigned int nGameId, Mode nMode)
         vmLeaderboard->CreateServerCheckpoint();
         vmLeaderboard->CreateLocalCheckpoint();
         m_vAssets.Append(std::move(vmLeaderboard));
-#endif
-
-        RA_Leaderboard& pLeaderboard = *m_vLeaderboards.emplace_back(std::make_unique<RA_Leaderboard>(pLeaderboardData.Id));
-        pLeaderboard.SetTitle(pLeaderboardData.Title);
-        pLeaderboard.SetDescription(pLeaderboardData.Description);
-        pLeaderboard.ParseFromString(pLeaderboardData.Definition.c_str(), pLeaderboardData.Format);
     }
 
     ActivateLeaderboards();
@@ -630,10 +621,14 @@ void GameContext::AwardAchievement(ra::AchievementID nAchievementId)
     }
 }
 
-void GameContext::DeactivateLeaderboards() noexcept
+void GameContext::DeactivateLeaderboards()
 {
-    for (auto& pLeaderboard : m_vLeaderboards)
-        pLeaderboard->SetActive(false);
+    for (gsl::index nIndex = 0; nIndex < gsl::narrow_cast<gsl::index>(Assets().Count()); ++nIndex)
+    {
+        auto* pLeaderboard = dynamic_cast<ra::data::models::LeaderboardModel*>(Assets().GetItemAt(nIndex));
+        if (pLeaderboard != nullptr)
+            pLeaderboard->SetState(ra::data::models::AssetState::Inactive);
+    }
 }
 
 void GameContext::ActivateLeaderboards()
@@ -641,8 +636,12 @@ void GameContext::ActivateLeaderboards()
     const auto& pConfiguration = ra::services::ServiceLocator::Get<ra::services::IConfiguration>();
     if (pConfiguration.IsFeatureEnabled(ra::services::Feature::Leaderboards)) // if not, simply ignore them.
     {
-        for (auto& pLeaderboard : m_vLeaderboards)
-            pLeaderboard->SetActive(true);
+        for (gsl::index nIndex = 0; nIndex < gsl::narrow_cast<gsl::index>(Assets().Count()); ++nIndex)
+        {
+            auto* pLeaderboard = dynamic_cast<ra::data::models::LeaderboardModel*>(Assets().GetItemAt(nIndex));
+            if (pLeaderboard != nullptr && pLeaderboard->GetCategory() == ra::data::models::AssetCategory::Core)
+                pLeaderboard->SetState(ra::data::models::AssetState::Waiting);
+        }
     }
 }
 
@@ -652,12 +651,12 @@ void GameContext::ShowSimplifiedScoreboard(ra::LeaderboardID nLeaderboardId, int
     if (pConfiguration.GetPopupLocation(ra::ui::viewmodels::Popup::LeaderboardScoreboard) == ra::ui::viewmodels::PopupLocation::None)
         return;
 
-    const auto* pLeaderboard = FindLeaderboard(nLeaderboardId);
+    const auto* pLeaderboard = Assets().FindLeaderboard(nLeaderboardId);
     if (!pLeaderboard)
         return;
 
     ra::ui::viewmodels::ScoreboardViewModel vmScoreboard;
-    vmScoreboard.SetHeaderText(ra::Widen(pLeaderboard->Title()));
+    vmScoreboard.SetHeaderText(ra::Widen(pLeaderboard->GetName()));
 
     const auto& pUserName = ra::services::ServiceLocator::Get<ra::data::context::UserContext>().GetUsername();
     auto& pEntryViewModel = vmScoreboard.Entries().Add();
@@ -667,63 +666,74 @@ void GameContext::ShowSimplifiedScoreboard(ra::LeaderboardID nLeaderboardId, int
     pEntryViewModel.SetHighlighted(true);
 
     ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>().QueueScoreboard(
-        pLeaderboard->ID(), std::move(vmScoreboard));
+        pLeaderboard->GetID(), std::move(vmScoreboard));
 }
 
 void GameContext::SubmitLeaderboardEntry(ra::LeaderboardID nLeaderboardId, int nScore) const
 {
-    const auto* pLeaderboard = FindLeaderboard(nLeaderboardId);
+    const auto* pLeaderboard = Assets().FindLeaderboard(nLeaderboardId);
     if (pLeaderboard == nullptr)
         return;
 
-    const auto& pEmulatorContext = ra::services::ServiceLocator::Get<ra::data::context::EmulatorContext>();
-    if (pEmulatorContext.WasMemoryModified())
-    {
-        std::unique_ptr<ra::ui::viewmodels::PopupMessageViewModel> vmPopup(new ra::ui::viewmodels::PopupMessageViewModel);
-        vmPopup->SetTitle(L"Leaderboard NOT Submitted");
-        vmPopup->SetDescription(ra::Widen(pLeaderboard->Title()));
-        vmPopup->SetErrorDetail(L"Error: RAM tampered with");
-
-        ra::services::ServiceLocator::Get<ra::services::IAudioSystem>().PlayAudioFile(L"Overlay\\info.wav");
-        ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>().QueueMessage(vmPopup);
-        ShowSimplifiedScoreboard(nLeaderboardId, nScore);
-        return;
-    }
+    std::unique_ptr<ra::ui::viewmodels::PopupMessageViewModel> vmPopup(new ra::ui::viewmodels::PopupMessageViewModel);
+    vmPopup->SetDescription(ra::Widen(pLeaderboard->GetName()));
+    std::wstring sTitle = L"Leaderboard NOT Submitted";
+    bool bSubmit = true;
 
-    const auto& pConfiguration = ra::services::ServiceLocator::Get<ra::services::IConfiguration>();
-    if (!pConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore))
+    switch (pLeaderboard->GetCategory())
     {
-        std::unique_ptr<ra::ui::viewmodels::PopupMessageViewModel> vmPopup(new ra::ui::viewmodels::PopupMessageViewModel);
-        vmPopup->SetTitle(L"Leaderboard NOT Submitted");
-        vmPopup->SetDescription(ra::Widen(pLeaderboard->Title()));
-        vmPopup->SetErrorDetail(L"Submission requires Hardcore mode");
+        case ra::data::models::AssetCategory::Local:
+            sTitle.insert(0, L"Local ");
+            bSubmit = false;
+            break;
 
-        ra::services::ServiceLocator::Get<ra::services::IAudioSystem>().PlayAudioFile(L"Overlay\\info.wav");
-        ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>().QueueMessage(vmPopup);
-        ShowSimplifiedScoreboard(nLeaderboardId, nScore);
-        return;
+        case ra::data::models::AssetCategory::Unofficial:
+            sTitle.insert(0, L"Unofficial ");
+            bSubmit = false;
+            break;
+
+        default:
+            break;
     }
 
     if (m_nMode == Mode::CompatibilityTest)
     {
-        std::unique_ptr<ra::ui::viewmodels::PopupMessageViewModel> vmPopup(new ra::ui::viewmodels::PopupMessageViewModel);
-        vmPopup->SetTitle(L"Leaderboard NOT Submitted");
-        vmPopup->SetDescription(ra::Widen(pLeaderboard->Title()));
         vmPopup->SetDetail(L"Leaderboards are not submitted in test mode.");
+        bSubmit = false;
+    }
 
-        ra::services::ServiceLocator::Get<ra::services::IAudioSystem>().PlayAudioFile(L"Overlay\\info.wav");
-        ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>().QueueMessage(vmPopup);
-        ShowSimplifiedScoreboard(nLeaderboardId, nScore);
-        return;
+    if (pLeaderboard->IsModified() || // actual modifications
+        (bSubmit && pLeaderboard->GetChanges() != ra::data::models::AssetChanges::None)) // unpublished changes
+    {
+        sTitle.insert(0, L"Modified ");
+        bSubmit = false;
     }
 
-    if (pEmulatorContext.IsMemoryInsecure())
+    const auto& pEmulatorContext = ra::services::ServiceLocator::Get<ra::data::context::EmulatorContext>();
+    if (bSubmit && pEmulatorContext.WasMemoryModified())
     {
-        std::unique_ptr<ra::ui::viewmodels::PopupMessageViewModel> vmPopup(new ra::ui::viewmodels::PopupMessageViewModel);
-        vmPopup->SetTitle(L"Leaderboard NOT Submitted");
-        vmPopup->SetDescription(ra::Widen(pLeaderboard->Title()));
-        vmPopup->SetErrorDetail(L"Error: RAM insecure");
+        vmPopup->SetErrorDetail(L"Error: RAM tampered with");
+        bSubmit = false;
+    }
+
+    const auto& pConfiguration = ra::services::ServiceLocator::Get<ra::services::IConfiguration>();
+    if (bSubmit)
+    {
+        if (!pConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore))
+        {
+            vmPopup->SetErrorDetail(L"Submission requires Hardcore mode");
+            bSubmit = false;
+        }
+        else if (pEmulatorContext.IsMemoryInsecure())
+        {
+            vmPopup->SetErrorDetail(L"Error: RAM insecure");
+            bSubmit = false;
+        }
+    }
 
+    if (!bSubmit)
+    {
+        vmPopup->SetTitle(sTitle);
         ra::services::ServiceLocator::Get<ra::services::IAudioSystem>().PlayAudioFile(L"Overlay\\info.wav");
         ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>().QueueMessage(vmPopup);
         ShowSimplifiedScoreboard(nLeaderboardId, nScore);
@@ -731,18 +741,18 @@ void GameContext::SubmitLeaderboardEntry(ra::LeaderboardID nLeaderboardId, int n
     }
 
     ra::api::SubmitLeaderboardEntry::Request request;
-    request.LeaderboardId = pLeaderboard->ID();
+    request.LeaderboardId = pLeaderboard->GetID();
     request.Score = nScore;
     request.GameHash = GameHash();
-    request.CallAsyncWithRetry([this, nLeaderboardId = pLeaderboard->ID()](const ra::api::SubmitLeaderboardEntry::Response& response)
+    request.CallAsyncWithRetry([this, nLeaderboardId = pLeaderboard->GetID()](const ra::api::SubmitLeaderboardEntry::Response& response)
     {
-        const auto* pLeaderboard = FindLeaderboard(nLeaderboardId);
+        const auto* pLeaderboard = Assets().FindLeaderboard(nLeaderboardId);
 
         if (!response.Succeeded())
         {
             std::unique_ptr<ra::ui::viewmodels::PopupMessageViewModel> vmPopup(new ra::ui::viewmodels::PopupMessageViewModel);
             vmPopup->SetTitle(L"Leaderboard NOT Submitted");
-            vmPopup->SetDescription(pLeaderboard ? ra::Widen(pLeaderboard->Title()) : ra::StringPrintf(L"Leaderboard %u", nLeaderboardId));
+            vmPopup->SetDescription(pLeaderboard ? pLeaderboard->GetName() : ra::StringPrintf(L"Leaderboard %u", nLeaderboardId));
             vmPopup->SetDetail(!response.ErrorMessage.empty() ? ra::StringPrintf(L"Error: %s", response.ErrorMessage) : L"Error submitting entry");
 
             ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>().QueueMessage(vmPopup);
@@ -753,7 +763,7 @@ void GameContext::SubmitLeaderboardEntry(ra::LeaderboardID nLeaderboardId, int n
             if (pConfiguration.GetPopupLocation(ra::ui::viewmodels::Popup::LeaderboardScoreboard) != ra::ui::viewmodels::PopupLocation::None)
             {
                 ra::ui::viewmodels::ScoreboardViewModel vmScoreboard;
-                vmScoreboard.SetHeaderText(ra::Widen(pLeaderboard->Title()));
+                vmScoreboard.SetHeaderText(pLeaderboard->GetName());
 
                 const auto& pUserName = ra::services::ServiceLocator::Get<ra::data::context::UserContext>().GetUsername();
                 constexpr int nEntriesDisplayed = 7; // display is currently hard-coded to show 7 entries
@@ -797,7 +807,7 @@ void GameContext::SubmitLeaderboardEntry(ra::LeaderboardID nLeaderboardId, int n
                 }
 
                 ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>().QueueScoreboard(
-                    pLeaderboard->ID(), std::move(vmScoreboard));
+                    pLeaderboard->GetID(), std::move(vmScoreboard));
             }
         }
     });
diff --git a/src/data/context/GameContext.hh b/src/data/context/GameContext.hh
index 8ad9ca15..a14ff4b9 100644
--- a/src/data/context/GameContext.hh
+++ b/src/data/context/GameContext.hh
@@ -2,8 +2,6 @@
 #define RA_DATA_GAMECONTEXT_HH
 #pragma once
 
-#include "RA_Leaderboard.h"
-
 #include "GameAssets.hh"
 
 #include <string>
@@ -91,41 +89,9 @@ public:
     /// </summary>
     void AwardAchievement(ra::AchievementID nAchievementId);
 
-    /// <summary>
-    /// Finds the leaderboard associated to the specified unique identifier.
-    /// </summary>
-    /// <returns>Pointer to leaderboard, <c>nullptr</c> if not found.</returns>
-    RA_Leaderboard* FindLeaderboard(ra::LeaderboardID nLeaderboardId) const noexcept
-    {
-        for (auto& pLeaderboard : m_vLeaderboards)
-        {
-            if (pLeaderboard->ID() == nLeaderboardId)
-                return pLeaderboard.get();
-        }
-
-        return nullptr;
-    }
-
-    void DeactivateLeaderboards() noexcept;
+    void DeactivateLeaderboards();
     void ActivateLeaderboards();
 
-    size_t LeaderboardCount() const noexcept { return m_vLeaderboards.size(); }
-
-    /// <summary>
-    /// Enumerates the leaderboard collection.
-    /// </summary>
-    /// <remarks>
-    /// <paramref name="callback" /> is called for each known leaderboard. If it returns <c>false</c> enumeration stops.
-    /// </remarks>
-    void EnumerateLeaderboards(std::function<bool(const RA_Leaderboard&)> callback) const
-    {
-        for (auto& pLeaderboard : m_vLeaderboards)
-        {
-            if (!callback(*pLeaderboard))
-                break;
-        }
-    }
-
     /// <summary>
     /// Submit a new score for the specified leaderboard.
     /// </summary>
@@ -319,8 +285,6 @@ protected:
     std::string m_sServerRichPresenceMD5;
     bool m_bRichPresenceFromFile = false;
 
-    std::vector<std::unique_ptr<RA_Leaderboard>> m_vLeaderboards;
-
     struct CodeNote
     {
         std::string Author;
diff --git a/src/data/models/LeaderboardModel.cpp b/src/data/models/LeaderboardModel.cpp
index 981dd741..84689dc5 100644
--- a/src/data/models/LeaderboardModel.cpp
+++ b/src/data/models/LeaderboardModel.cpp
@@ -299,6 +299,13 @@ bool LeaderboardModel::Deserialize(ra::Tokenizer& pTokenizer)
     return true;
 }
 
+std::string LeaderboardModel::FormatScore(int nValue) const
+{
+    char buffer[32];
+    rc_format_value(buffer, sizeof(buffer), nValue, ra::etoi(GetValueFormat()));
+    return std::string(buffer);
+}
+
 } // namespace models
 } // namespace data
 } // namespace ra
diff --git a/src/data/models/LeaderboardModel.hh b/src/data/models/LeaderboardModel.hh
index 9a527076..310ffff4 100644
--- a/src/data/models/LeaderboardModel.hh
+++ b/src/data/models/LeaderboardModel.hh
@@ -122,6 +122,11 @@ public:
     /// </summary>
     void SetValueFormat(ValueFormat nValue) { SetValue(ValueFormatProperty, ra::etoi(nValue)); }
 
+    /// <summary>
+    /// Converts a raw score into a display string.
+    /// </summary>
+    std::string FormatScore(int nValue) const;
+
     /// <summary>
     /// The <see cref="ModelProperty" /> for whether or not lower values are better.
     /// </summary>
diff --git a/src/ui/viewmodels/AssetListViewModel.cpp b/src/ui/viewmodels/AssetListViewModel.cpp
index ef8cb8f1..ff8647e6 100644
--- a/src/ui/viewmodels/AssetListViewModel.cpp
+++ b/src/ui/viewmodels/AssetListViewModel.cpp
@@ -44,11 +44,7 @@ const IntModelProperty AssetListViewModel::EnsureVisibleAssetIndexProperty("Asse
 
 AssetListViewModel::AssetListViewModel() noexcept
 {
-#ifdef ASSET_ICONS
     SetWindowTitle(L"Assets List");
-#else
-    SetWindowTitle(L"Achievements List");
-#endif
 
     m_vStates.Add(ra::etoi(ra::data::models::AssetState::Inactive), L"Inactive");
     m_vStates.Add(ra::etoi(ra::data::models::AssetState::Waiting), L"Waiting");
@@ -1540,6 +1536,28 @@ void AssetListViewModel::CloneSelected()
 
             vNewIDs.push_back(vmAchievement.GetID());
         }
+
+        const auto* pSourceLeaderboard = dynamic_cast<const ra::data::models::LeaderboardModel*>(pAsset);
+        if (pSourceLeaderboard != nullptr)
+        {
+            auto& vmLeaderboard = pGameContext.Assets().NewLeaderboard();
+            vmLeaderboard.SetCategory(ra::data::models::AssetCategory::Local);
+            vmLeaderboard.UpdateServerCheckpoint();
+
+            vmLeaderboard.SetName(pSourceLeaderboard->GetName() + L" (copy)");
+            vmLeaderboard.SetDescription(pSourceLeaderboard->GetDescription());
+            vmLeaderboard.SetStartTrigger(pSourceLeaderboard->GetStartTrigger());
+            vmLeaderboard.SetSubmitTrigger(pSourceLeaderboard->GetSubmitTrigger());
+            vmLeaderboard.SetCancelTrigger(pSourceLeaderboard->GetCancelTrigger());
+            vmLeaderboard.SetValueDefinition(pSourceLeaderboard->GetValueDefinition());
+            vmLeaderboard.SetValueFormat(pSourceLeaderboard->GetValueFormat());
+            vmLeaderboard.SetLowerIsBetter(pSourceLeaderboard->IsLowerBetter());
+            vmLeaderboard.SetNew();
+
+            EnsureAppearsInFilteredList(vmLeaderboard);
+
+            vNewIDs.push_back(vmLeaderboard.GetID());
+        }
     }
 
     // select the new items and deselect everything else
@@ -1550,7 +1568,8 @@ void AssetListViewModel::CloneSelected()
         if (pItem != nullptr)
         {
             if (std::find(vNewIDs.begin(), vNewIDs.end(), pItem->GetId()) != vNewIDs.end() &&
-                pItem->GetType() == ra::data::models::AssetType::Achievement)
+                (pItem->GetType() == ra::data::models::AssetType::Achievement ||
+                 pItem->GetType() == ra::data::models::AssetType::Leaderboard))
             {
                 nIndex = i;
                 pItem->SetSelected(true);
diff --git a/src/ui/viewmodels/AssetListViewModel.hh b/src/ui/viewmodels/AssetListViewModel.hh
index bc8d35ec..ff522fd2 100644
--- a/src/ui/viewmodels/AssetListViewModel.hh
+++ b/src/ui/viewmodels/AssetListViewModel.hh
@@ -12,8 +12,6 @@
 #include "data\models\AchievementModel.hh"
 #include "data\models\AssetModelBase.hh"
 
-#define ASSET_ICONS
-
 namespace ra {
 namespace ui {
 namespace viewmodels {
diff --git a/src/ui/viewmodels/IntegrationMenuViewModel.cpp b/src/ui/viewmodels/IntegrationMenuViewModel.cpp
index 8845afef..7dbbf651 100644
--- a/src/ui/viewmodels/IntegrationMenuViewModel.cpp
+++ b/src/ui/viewmodels/IntegrationMenuViewModel.cpp
@@ -52,13 +52,8 @@ void IntegrationMenuViewModel::BuildMenuLoggedIn(LookupItemViewModelCollection&
     vmMenu.Add(IDM_RA_TOGGLELEADERBOARDS, L"Enable &Leaderboards").SetSelected(pConfiguration.IsFeatureEnabled(ra::services::Feature::Leaderboards));
     vmMenu.Add(IDM_RA_OVERLAYSETTINGS, L"O&verlay Settings");
     vmMenu.Add(0, L"-----");
-#ifdef ASSET_ICONS
     vmMenu.Add(IDM_RA_FILES_ACHIEVEMENTS, L"Assets Li&st");
     vmMenu.Add(IDM_RA_FILES_ACHIEVEMENTEDITOR, L"Assets &Editor");
-#else
-    vmMenu.Add(IDM_RA_FILES_ACHIEVEMENTS, L"Achievements Li&st");
-    vmMenu.Add(IDM_RA_FILES_ACHIEVEMENTEDITOR, L"Achievement &Editor");
-#endif
     vmMenu.Add(IDM_RA_FILES_MEMORYFINDER, L"&Memory Inspector");
     vmMenu.Add(IDM_RA_FILES_MEMORYBOOKMARKS, L"Memory &Bookmarks");
     vmMenu.Add(IDM_RA_PARSERICHPRESENCE, L"Rich &Presence Monitor");
diff --git a/src/ui/viewmodels/OverlayLeaderboardsPageViewModel.cpp b/src/ui/viewmodels/OverlayLeaderboardsPageViewModel.cpp
index f5609544..37e75a53 100644
--- a/src/ui/viewmodels/OverlayLeaderboardsPageViewModel.cpp
+++ b/src/ui/viewmodels/OverlayLeaderboardsPageViewModel.cpp
@@ -26,22 +26,24 @@ void OverlayLeaderboardsPageViewModel::Refresh()
 
     // leaderboard list
     size_t nNumberOfLeaderboards = 0;
-
-    pGameContext.EnumerateLeaderboards([this, &nNumberOfLeaderboards](const RA_Leaderboard& pLeaderboard)
+    for (gsl::index nIndex = 0; nIndex < gsl::narrow_cast<gsl::index>(pGameContext.Assets().Count()); ++nIndex)
     {
-        ItemViewModel* pvmLeaderboard = m_vItems.GetItemAt(nNumberOfLeaderboards);
-        if (pvmLeaderboard == nullptr)
+        const auto* pLeaderboard = dynamic_cast<const ra::data::models::LeaderboardModel*>(pGameContext.Assets().GetItemAt(nIndex));
+        if (pLeaderboard != nullptr)
         {
-            pvmLeaderboard = &m_vItems.Add();
-            Ensures(pvmLeaderboard != nullptr);
+            ItemViewModel* pvmLeaderboard = m_vItems.GetItemAt(nNumberOfLeaderboards);
+            if (pvmLeaderboard == nullptr)
+            {
+                pvmLeaderboard = &m_vItems.Add();
+                Ensures(pvmLeaderboard != nullptr);
+            }
+
+            pvmLeaderboard->SetId(pLeaderboard->GetID());
+            pvmLeaderboard->SetLabel(pLeaderboard->GetName());
+            pvmLeaderboard->SetDetail(pLeaderboard->GetDescription());
+            ++nNumberOfLeaderboards;
         }
-
-        pvmLeaderboard->SetId(pLeaderboard.ID());
-        pvmLeaderboard->SetLabel(ra::Widen(pLeaderboard.Title()));
-        pvmLeaderboard->SetDetail(ra::Widen(pLeaderboard.Description()));
-        ++nNumberOfLeaderboards;
-        return true;
-    });
+    }
 
     while (m_vItems.Count() > nNumberOfLeaderboards)
         m_vItems.RemoveAt(m_vItems.Count() - 1);
@@ -94,7 +96,7 @@ void OverlayLeaderboardsPageViewModel::FetchItemDetail(ItemViewModel& vmItem)
         return;
 
     const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
-    const auto* pLeaderboard = pGameContext.FindLeaderboard(vmItem.GetId());
+    const auto* pLeaderboard = pGameContext.Assets().FindLeaderboard(vmItem.GetId());
     if (pLeaderboard == nullptr)
         return;
 
@@ -111,7 +113,7 @@ void OverlayLeaderboardsPageViewModel::FetchItemDetail(ItemViewModel& vmItem)
             return;
 
         const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
-        const RA_Leaderboard* pLeaderboard = pGameContext.FindLeaderboard(nId);
+        const auto* pLeaderboard = pGameContext.Assets().FindLeaderboard(nId);
         if (!pLeaderboard)
             return;
 
diff --git a/src/ui/win32/AssetListDialog.cpp b/src/ui/win32/AssetListDialog.cpp
index 449c889d..4d29280f 100644
--- a/src/ui/win32/AssetListDialog.cpp
+++ b/src/ui/win32/AssetListDialog.cpp
@@ -192,16 +192,12 @@ AssetListDialog::AssetListDialog(AssetListViewModel& vmAssetList)
         ra::ui::viewmodels::LookupItemViewModel::LabelProperty);
     m_bindAssetTypeFilters.BindSelectedItem(AssetListViewModel::AssetTypeFilterProperty);
 
-#ifdef ASSET_ICONS
     constexpr int nOffset = 1;
 
     auto pIconColumn = std::make_unique<IconColumnBinding>(AssetModelBase::TypeProperty);
     pIconColumn->SetHeader(L"Type");
     pIconColumn->SetWidth(GridColumnBinding::WidthType::Pixels, 24);
     m_bindAssets.BindColumn(0, std::move(pIconColumn));
-#else
-    constexpr int nOffset = 0;
-#endif
 
     auto pIdColumn = std::make_unique<IdColumnBinding>(AssetListViewModel::AssetSummaryViewModel::IdProperty);
     pIdColumn->SetHeader(L"ID");
@@ -300,9 +296,7 @@ BOOL AssetListDialog::OnInitDialog()
     m_bindSpecialFilters.SetControl(*this, IDC_RA_SPECIAL_FILTER);
     m_bindAssetTypeFilters.SetControl(*this, IDC_RA_TYPE_FILTER);
 
-#ifdef ASSET_ICONS
     m_bindAssets.InitializeTooltips(std::chrono::milliseconds(500));
-#endif
 
     return DialogBase::OnInitDialog();
 }


Test patch:
diff --git a/tests/Exports_Tests.cpp b/tests/Exports_Tests.cpp
index cd358b74..55369e99 100644
--- a/tests/Exports_Tests.cpp
+++ b/tests/Exports_Tests.cpp
@@ -595,9 +595,10 @@ public:
         DoAchievementsFrameHarness harness;
         harness.mockConfiguration.SetPopupLocation(ra::ui::viewmodels::Popup::LeaderboardStarted, ra::ui::viewmodels::PopupLocation::BottomLeft);
         harness.mockConfiguration.SetPopupLocation(ra::ui::viewmodels::Popup::LeaderboardTracker, ra::ui::viewmodels::PopupLocation::BottomRight);
-        auto& pLeaderboard = harness.mockGameContext.NewLeaderboard(1U);
-        pLeaderboard.SetTitle("Title");
-        pLeaderboard.SetDescription("Description");
+        auto& pLeaderboard = harness.mockGameContext.Assets().NewLeaderboard();
+        pLeaderboard.SetID(1U);
+        pLeaderboard.SetName(L"Title");
+        pLeaderboard.SetDescription(L"Description");
 
         harness.mockRuntime.QueueChange(ra::services::AchievementRuntime::ChangeType::LeaderboardStarted, 1U, 1234U);
         _RA_DoAchievementsFrame();
@@ -621,9 +622,10 @@ public:
         DoAchievementsFrameHarness harness;
         harness.mockConfiguration.SetPopupLocation(ra::ui::viewmodels::Popup::LeaderboardStarted, ra::ui::viewmodels::PopupLocation::None);
         harness.mockConfiguration.SetPopupLocation(ra::ui::viewmodels::Popup::LeaderboardTracker, ra::ui::viewmodels::PopupLocation::None);
-        auto& pLeaderboard = harness.mockGameContext.NewLeaderboard(1U);
-        pLeaderboard.SetTitle("Title");
-        pLeaderboard.SetDescription("Description");
+        auto& pLeaderboard = harness.mockGameContext.Assets().NewLeaderboard();
+        pLeaderboard.SetID(1U);
+        pLeaderboard.SetName(L"Title");
+        pLeaderboard.SetDescription(L"Description");
 
         harness.mockRuntime.QueueChange(ra::services::AchievementRuntime::ChangeType::LeaderboardStarted, 1U, 1234U);
         _RA_DoAchievementsFrame();
@@ -640,7 +642,8 @@ public:
         DoAchievementsFrameHarness harness;
         harness.mockConfiguration.SetPopupLocation(ra::ui::viewmodels::Popup::LeaderboardStarted, ra::ui::viewmodels::PopupLocation::None);
         harness.mockConfiguration.SetPopupLocation(ra::ui::viewmodels::Popup::LeaderboardTracker, ra::ui::viewmodels::PopupLocation::BottomRight);
-        harness.mockGameContext.NewLeaderboard(1U);
+        auto& pLeaderboard = harness.mockGameContext.Assets().NewLeaderboard();
+        pLeaderboard.SetID(1U);
 
         harness.mockRuntime.QueueChange(ra::services::AchievementRuntime::ChangeType::LeaderboardStarted, 1U, 1234U);
         _RA_DoAchievementsFrame();
@@ -659,9 +662,10 @@ public:
         DoAchievementsFrameHarness harness;
         harness.mockConfiguration.SetPopupLocation(ra::ui::viewmodels::Popup::LeaderboardCanceled, ra::ui::viewmodels::PopupLocation::BottomLeft);
         harness.mockConfiguration.SetPopupLocation(ra::ui::viewmodels::Popup::LeaderboardTracker, ra::ui::viewmodels::PopupLocation::BottomRight);
-        auto& pLeaderboard = harness.mockGameContext.NewLeaderboard(1U);
-        pLeaderboard.SetTitle("Title");
-        pLeaderboard.SetDescription("Description");
+        auto& pLeaderboard = harness.mockGameContext.Assets().NewLeaderboard();
+        pLeaderboard.SetID(1U);
+        pLeaderboard.SetName(L"Title");
+        pLeaderboard.SetDescription(L"Description");
 
         harness.mockRuntime.QueueChange(ra::services::AchievementRuntime::ChangeType::LeaderboardCanceled, 1U);
         _RA_DoAchievementsFrame();
@@ -682,9 +686,10 @@ public:
         DoAchievementsFrameHarness harness;
         harness.mockConfiguration.SetPopupLocation(ra::ui::viewmodels::Popup::LeaderboardCanceled, ra::ui::viewmodels::PopupLocation::None);
         harness.mockConfiguration.SetPopupLocation(ra::ui::viewmodels::Popup::LeaderboardTracker, ra::ui::viewmodels::PopupLocation::BottomRight);
-        auto& pLeaderboard = harness.mockGameContext.NewLeaderboard(1U);
-        pLeaderboard.SetTitle("Title");
-        pLeaderboard.SetDescription("Description");
+        auto& pLeaderboard = harness.mockGameContext.Assets().NewLeaderboard();
+        pLeaderboard.SetID(1U);
+        pLeaderboard.SetName(L"Title");
+        pLeaderboard.SetDescription(L"Description");
 
         harness.mockRuntime.QueueChange(ra::services::AchievementRuntime::ChangeType::LeaderboardCanceled, 1U);
         _RA_DoAchievementsFrame();
@@ -699,7 +704,8 @@ public:
     TEST_METHOD(TestDoAchievementsFrameLeaderboardUpdated)
     {
         DoAchievementsFrameHarness harness;
-        harness.mockGameContext.NewLeaderboard(1U);
+        auto& pLeaderboard = harness.mockGameContext.Assets().NewLeaderboard();
+        pLeaderboard.SetID(1U);
         harness.mockConfiguration.SetPopupLocation(ra::ui::viewmodels::Popup::LeaderboardTracker, ra::ui::viewmodels::PopupLocation::BottomRight);
         harness.mockOverlayManager.AddScoreTracker(1U);
 
@@ -717,7 +723,8 @@ public:
         DoAchievementsFrameHarness harness;
         harness.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
         harness.mockConfiguration.SetPopupLocation(ra::ui::viewmodels::Popup::LeaderboardTracker, ra::ui::viewmodels::PopupLocation::BottomRight);
-        harness.mockGameContext.NewLeaderboard(1U);
+        auto& pLeaderboard = harness.mockGameContext.Assets().NewLeaderboard();
+        pLeaderboard.SetID(1U);
 
         harness.mockRuntime.QueueChange(ra::services::AchievementRuntime::ChangeType::LeaderboardTriggered, 1U, 1236U);
         _RA_DoAchievementsFrame();
@@ -733,8 +740,8 @@ public:
         DoAchievementsFrameHarness harness;
         harness.MockAchievement(1U);
         harness.MockAchievement(2U);
-        harness.mockGameContext.NewLeaderboard(3U);
-        harness.mockGameContext.NewLeaderboard(4U);
+        harness.mockGameContext.Assets().NewLeaderboard().SetID(3U);
+        harness.mockGameContext.Assets().NewLeaderboard().SetID(4U);
         harness.mockConfiguration.SetPopupLocation(ra::ui::viewmodels::Popup::LeaderboardTracker, ra::ui::viewmodels::PopupLocation::BottomRight);
 
         harness.mockRuntime.QueueChange(ra::services::AchievementRuntime::ChangeType::LeaderboardStarted, 3U, 1234U);
diff --git a/tests/RA_Integration.Tests.vcxproj b/tests/RA_Integration.Tests.vcxproj
index 2e52eb5f..16ca9746 100644
--- a/tests/RA_Integration.Tests.vcxproj
+++ b/tests/RA_Integration.Tests.vcxproj
@@ -358,7 +358,6 @@
     <ClCompile Include="api\DisconnectedServer_Tests.cpp" />
     <ClInclude Include="..\src\data\models\LeaderboardModel.hh" />
     <ClInclude Include="..\src\RA_Defs.h" />
-    <ClInclude Include="..\src\RA_Leaderboard.h" />
     <ClCompile Include="..\src\ui\ViewModelBase.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\GameChecksumViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\MessageBoxViewModel.cpp" />
@@ -391,7 +390,6 @@
     <ClCompile Include="services\StringTextReader_Tests.cpp" />
     <ClCompile Include="services\StringTextWriter_Tests.cpp" />
     <ClCompile Include="..\src\RA_Defs.cpp" />
-    <ClCompile Include="..\src\RA_Leaderboard.cpp" />
     <ClCompile Include="ui\ViewModelCollection_Tests.cpp" />
     <ClCompile Include="ui\viewmodels\AssetEditorViewModel_Tests.cpp" />
     <ClCompile Include="ui\viewmodels\AssetListViewModel_Tests.cpp" />
diff --git a/tests/RA_Integration.Tests.vcxproj.filters b/tests/RA_Integration.Tests.vcxproj.filters
index e274a4e0..0a07fa1f 100644
--- a/tests/RA_Integration.Tests.vcxproj.filters
+++ b/tests/RA_Integration.Tests.vcxproj.filters
@@ -42,9 +42,6 @@
     <ClInclude Include="..\src\RA_Defs.h">
       <Filter>Code</Filter>
     </ClInclude>
-    <ClCompile Include="..\src\RA_Leaderboard.cpp">
-      <Filter>Code</Filter>
-    </ClCompile>
     <ClCompile Include="..\src\services\SearchResults.cpp">
       <Filter>Code</Filter>
     </ClCompile>
@@ -159,9 +156,6 @@
     <ClInclude Include="RA_UnitTestHelpers.h">
       <Filter>Tests</Filter>
     </ClInclude>
-    <ClInclude Include="..\src\RA_Leaderboard.h">
-      <Filter>Code</Filter>
-    </ClInclude>
     <ClCompile Include="services\Http_Tests.cpp">
       <Filter>Tests\Services</Filter>
     </ClCompile>
diff --git a/tests/data/context/EmulatorContext_Tests.cpp b/tests/data/context/EmulatorContext_Tests.cpp
index 611f3652..1657dc38 100644
--- a/tests/data/context/EmulatorContext_Tests.cpp
+++ b/tests/data/context/EmulatorContext_Tests.cpp
@@ -619,8 +619,9 @@ public:
         bool bWasReset = false;
         emulator.SetResetFunction([&bWasReset]() { bWasReset = true; });
 
-        auto& pLeaderboard = emulator.mockGameContext.NewLeaderboard(32U);
-        pLeaderboard.SetActive(true);
+        auto& pLeaderboard = emulator.mockGameContext.Assets().NewLeaderboard();
+        pLeaderboard.SetID(32U);
+        pLeaderboard.SetState(ra::data::models::AssetState::Active);
         Assert::IsTrue(pLeaderboard.IsActive());
 
         bool bUnlocksRequested = false;
@@ -703,7 +704,8 @@ public:
             return true;
         });
 
-        const auto& pLeaderboard = emulator.mockGameContext.NewLeaderboard(32U);
+        auto& pLeaderboard = emulator.mockGameContext.Assets().NewLeaderboard();
+        pLeaderboard.SetID(32U);
         Assert::IsFalse(pLeaderboard.IsActive());
 
         bool bWasReset = false;
@@ -848,7 +850,8 @@ public:
             return true;
         });
 
-        const auto& pLeaderboard = emulator.mockGameContext.NewLeaderboard(32U);
+        auto& pLeaderboard = emulator.mockGameContext.Assets().NewLeaderboard();
+        pLeaderboard.SetID(32U);
         Assert::IsFalse(pLeaderboard.IsActive());
 
         bool bWasReset = false;
diff --git a/tests/data/context/GameContext_Tests.cpp b/tests/data/context/GameContext_Tests.cpp
index 198a4206..d7cb85f5 100644
--- a/tests/data/context/GameContext_Tests.cpp
+++ b/tests/data/context/GameContext_Tests.cpp
@@ -89,11 +89,13 @@ public:
             return pAch;
         }
 
-        RA_Leaderboard& MockLeaderboard()
+        ra::data::models::LeaderboardModel& MockLeaderboard()
         {
-            auto& pLeaderboard = *m_vLeaderboards.emplace_back(std::make_unique<RA_Leaderboard>(1U));
-            pLeaderboard.SetTitle("LeaderboardTitle");
-            pLeaderboard.SetDescription("LeaderboardDescription");
+            auto& pLeaderboard = Assets().NewLeaderboard();
+            pLeaderboard.SetID(1U);
+            pLeaderboard.SetName(L"LeaderboardTitle");
+            pLeaderboard.SetDescription(L"LeaderboardDescription");
+            pLeaderboard.UpdateServerCheckpoint();
             return pLeaderboard;
         }
 
@@ -603,17 +605,17 @@ public:
 
         game.LoadGame(1U);
 
-        const auto* pLb1 = game.FindLeaderboard(7U);
+        const auto* pLb1 = game.Assets().FindLeaderboard(7U);
         Assert::IsNotNull(pLb1);
         Ensures(pLb1 != nullptr);
-        Assert::AreEqual(std::string("LB1"), pLb1->Title());
-        Assert::AreEqual(std::string("Desc1"), pLb1->Description());
+        Assert::AreEqual(std::wstring(L"LB1"), pLb1->GetName());
+        Assert::AreEqual(std::wstring(L"Desc1"), pLb1->GetDescription());
 
-        const auto* pLb2 = game.FindLeaderboard(8U);
+        const auto* pLb2 = game.Assets().FindLeaderboard(8U);
         Assert::IsNotNull(pLb2);
         Ensures(pLb2 != nullptr);
-        Assert::AreEqual(std::string("LB2"), pLb2->Title());
-        Assert::AreEqual(std::string("Desc2"), pLb2->Description());
+        Assert::AreEqual(std::wstring(L"LB2"), pLb2->GetName());
+        Assert::AreEqual(std::wstring(L"Desc2"), pLb2->GetDescription());
     }
 
     TEST_METHOD(TestLoadGameReplacesAchievements)
@@ -1859,6 +1861,51 @@ public:
         Assert::IsTrue(vmEntry->IsHighlighted());
     }
 
+    TEST_METHOD(TestSubmitLeaderboardEntryModified)
+    {
+        GameContextHarness game;
+        game.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
+        game.mockConfiguration.SetPopupLocation(ra::ui::viewmodels::Popup::LeaderboardScoreboard, ra::ui::viewmodels::PopupLocation::BottomRight);
+        game.mockUser.Initialize("Player", "ApiToken");
+        game.SetMode(ra::data::context::GameContext::Mode::CompatibilityTest);
+        game.SetGameHash("hash");
+
+        game.mockServer.ExpectUncalled<ra::api::SubmitLeaderboardEntry>();
+
+        game.MockLeaderboard();
+        game.Assets().FindLeaderboard(1U)->SetName(L"Name2");
+        game.SubmitLeaderboardEntry(1U, 1234U);
+
+        // SubmitLeaderboardEntry API call is async, try to execute it - expect no tasks queued
+        game.mockThreadPool.ExecuteNextTask();
+
+        Assert::IsTrue(game.mockAudioSystem.WasAudioFilePlayed(L"Overlay\\info.wav"));
+
+        // error message should be reported
+        const auto* pPopup = game.mockOverlayManager.GetMessage(1);
+        Expects(pPopup != nullptr);
+        Assert::IsNotNull(pPopup);
+        Assert::AreEqual(std::wstring(L"Modified Leaderboard NOT Submitted"), pPopup->GetTitle());
+        Assert::AreEqual(std::wstring(L"Name2"), pPopup->GetDescription());
+        Assert::AreEqual(std::wstring(L"Leaderboards are not submitted in test mode."), pPopup->GetDetail());
+        Assert::IsFalse(pPopup->IsDetailError());
+
+        // empty leaderboard should be displayed with the non-submitted score
+        const auto* vmScoreboard = game.mockOverlayManager.GetScoreboard(1U);
+        Assert::IsNotNull(vmScoreboard);
+        Ensures(vmScoreboard != nullptr);
+        Assert::AreEqual(std::wstring(L"Name2"), vmScoreboard->GetHeaderText());
+        Assert::AreEqual({ 1U }, vmScoreboard->Entries().Count());
+
+        const auto* vmEntry = vmScoreboard->Entries().GetItemAt(0);
+        Assert::IsNotNull(vmEntry);
+        Ensures(vmEntry != nullptr);
+        Assert::AreEqual(0, vmEntry->GetRank());
+        Assert::AreEqual(std::wstring(L"Player"), vmEntry->GetUserName());
+        Assert::AreEqual(std::wstring(L"1234"), vmEntry->GetScore());
+        Assert::IsTrue(vmEntry->IsHighlighted());
+    }
+
     TEST_METHOD(TestSubmitLeaderboardEntryCompatibilityMode)
     {
         GameContextHarness game;
diff --git a/tests/mocks/MockGameContext.hh b/tests/mocks/MockGameContext.hh
index 0ee9baca..843316e7 100644
--- a/tests/mocks/MockGameContext.hh
+++ b/tests/mocks/MockGameContext.hh
@@ -49,11 +49,6 @@ public:
 
     void SetRichPresenceFromFile(bool bValue) noexcept { m_bRichPresenceFromFile = bValue; }
 
-    RA_Leaderboard& NewLeaderboard(ra::LeaderboardID nLeaderboardId)
-    {
-        return *m_vLeaderboards.emplace_back(std::make_unique<RA_Leaderboard>(nLeaderboardId));
-    }
-
     bool SetCodeNote(ra::ByteAddress nAddress, const std::wstring& sNote) override
     {
         // non-API part of SetCodeNote
diff --git a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
index f9241f0e..f7cbe6fc 100644
--- a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
@@ -3602,6 +3602,70 @@ public:
         Assert::AreEqual(1, pLocalBadges->GetReferenceCount(sBadge2, true));
     }
 
+    TEST_METHOD(TestCloneLeaderboard)
+    {
+        AssetListViewModelHarness vmAssetList;
+        vmAssetList.SetGameId(22U);
+        vmAssetList.SetAssetTypeFilter(ra::data::models::AssetType::Leaderboard);
+        vmAssetList.AddLeaderboard(ra::data::models::AssetCategory::Core, L"Leaderboard1");
+        auto* vmLeaderboard = dynamic_cast<ra::data::models::LeaderboardModel*>(vmAssetList.mockGameContext.Assets().GetItemAt(0));
+        Assert::IsNotNull(vmLeaderboard);
+        Ensures(vmLeaderboard != nullptr);
+        vmLeaderboard->SetDescription(L"Desc1");
+        vmLeaderboard->SetStartTrigger("0=1");
+        vmLeaderboard->SetSubmitTrigger("0xH1234=1");
+        vmLeaderboard->SetCancelTrigger("0xH1234=2");
+        vmLeaderboard->SetValueDefinition("0xH5555*2");
+        vmLeaderboard->SetValueFormat(ra::data::ValueFormat::Score);
+        vmLeaderboard->SetLowerIsBetter(true);
+
+        Assert::AreEqual({ 1U }, vmAssetList.mockGameContext.Assets().Count());
+        Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
+        Assert::AreEqual(AssetListViewModel::FilterCategory::Core, vmAssetList.GetFilterCategory());
+
+        vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
+        vmAssetList.ForceUpdateButtons();
+
+        bool bEditorShown = false;
+        vmAssetList.mockDesktop.ExpectWindow<AssetEditorViewModel>([&bEditorShown](AssetEditorViewModel&)
+        {
+            bEditorShown = true;
+            return DialogResult::None;
+        });
+
+        vmAssetList.CloneSelected();
+
+        // new Local leaderboard should be created and focused
+        Assert::AreEqual({ 2U }, vmAssetList.mockGameContext.Assets().Count());
+        Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
+        Assert::AreEqual(AssetListViewModel::FilterCategory::Local, vmAssetList.GetFilterCategory());
+
+        const auto* pAsset = vmAssetList.FilteredAssets().GetItemAt(0);
+        Expects(pAsset != nullptr);
+        Assert::IsTrue(pAsset->IsSelected());
+        Assert::AreEqual(std::wstring(L"Leaderboard1 (copy)"), pAsset->GetLabel());
+        Assert::AreEqual(AssetCategory::Local, pAsset->GetCategory());
+        Assert::AreEqual(AssetState::Inactive, pAsset->GetState());
+        Assert::AreEqual(AssetChanges::New, pAsset->GetChanges());
+        Assert::AreEqual({ 111000001U }, pAsset->GetId());
+
+        const auto* pLeaderboard = vmAssetList.mockGameContext.Assets().FindLeaderboard(pAsset->GetId());
+        Expects(pLeaderboard != nullptr);
+        Assert::AreEqual(std::wstring(L"Desc1"), pLeaderboard->GetDescription());
+        Assert::AreEqual(std::string("0=1"), pLeaderboard->GetStartTrigger());
+        Assert::AreEqual(std::string("0xH1234=1"), pLeaderboard->GetSubmitTrigger());
+        Assert::AreEqual(std::string("0xH1234=2"), pLeaderboard->GetCancelTrigger());
+        Assert::AreEqual(std::string("0xH5555*2"), pLeaderboard->GetValueDefinition());
+        Assert::AreEqual(ra::data::ValueFormat::Score, pLeaderboard->GetValueFormat());
+        Assert::IsTrue(pLeaderboard->IsLowerBetter());
+
+        // and loaded in the editor, which should be shown (local achievement will always have ID 0)
+        Assert::AreEqual({ 0U }, vmAssetList.mockWindowManager.AssetEditor.GetID());
+        Assert::IsTrue(vmAssetList.mockWindowManager.AssetEditor.IsAssetLoaded());
+        Assert::IsTrue(bEditorShown);
+        bEditorShown = false;
+    }
+
     TEST_METHOD(TestResetSelectedAllUnmodified)
     {
         AssetListViewModelHarness vmAssetList;
diff --git a/tests/ui/viewmodels/IntegrationMenuViewModel_Tests.cpp b/tests/ui/viewmodels/IntegrationMenuViewModel_Tests.cpp
index c9704ecf..7895c286 100644
--- a/tests/ui/viewmodels/IntegrationMenuViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/IntegrationMenuViewModel_Tests.cpp
@@ -151,13 +151,8 @@ public:
         menu.AssertMenuItem(8, IDM_RA_TOGGLELEADERBOARDS, L"Enable &Leaderboards");
         menu.AssertMenuItem(9, IDM_RA_OVERLAYSETTINGS, L"O&verlay Settings");
         menu.AssertMenuSeparator(10);
-#ifdef ASSET_ICONS
         menu.AssertMenuItem(11, IDM_RA_FILES_ACHIEVEMENTS, L"Assets Li&st");
         menu.AssertMenuItem(12, IDM_RA_FILES_ACHIEVEMENTEDITOR, L"Assets &Editor");
-#else
-        menu.AssertMenuItem(11, IDM_RA_FILES_ACHIEVEMENTS, L"Achievements Li&st");
-        menu.AssertMenuItem(12, IDM_RA_FILES_ACHIEVEMENTEDITOR, L"Achievement &Editor");
-#endif
         menu.AssertMenuItem(13, IDM_RA_FILES_MEMORYFINDER, L"&Memory Inspector");
         menu.AssertMenuItem(14, IDM_RA_FILES_MEMORYBOOKMARKS, L"Memory &Bookmarks");
         menu.AssertMenuItem(15, IDM_RA_PARSERICHPRESENCE, L"Rich &Presence Monitor");
diff --git a/tests/ui/viewmodels/OverlayLeaderboardsPageViewModel_Tests.cpp b/tests/ui/viewmodels/OverlayLeaderboardsPageViewModel_Tests.cpp
index c38b374c..17a4f26b 100644
--- a/tests/ui/viewmodels/OverlayLeaderboardsPageViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/OverlayLeaderboardsPageViewModel_Tests.cpp
@@ -62,12 +62,14 @@ public:
     TEST_METHOD(TestRefreshLeaderboards)
     {
         OverlayLeaderboardsPageViewModelHarness leaderboardsPage;
-        auto& pLB1 = leaderboardsPage.mockGameContext.NewLeaderboard(1U);
-        pLB1.SetTitle("LeaderboardTitle");
-        pLB1.SetDescription("Trigger this");
-        auto& pLB2 = leaderboardsPage.mockGameContext.NewLeaderboard(2U);
-        pLB2.SetTitle("LeaderboardTitle2");
-        pLB2.SetDescription("Trigger this too");
+        auto& pLB1 = leaderboardsPage.mockGameContext.Assets().NewLeaderboard();
+        pLB1.SetID(1U);
+        pLB1.SetName(L"LeaderboardTitle");
+        pLB1.SetDescription(L"Trigger this");
+        auto& pLB2 = leaderboardsPage.mockGameContext.Assets().NewLeaderboard();
+        pLB2.SetID(2U);
+        pLB2.SetName(L"LeaderboardTitle2");
+        pLB2.SetDescription(L"Trigger this too");
         leaderboardsPage.Refresh();
 
         Assert::AreEqual(std::wstring(L"Leaderboards"), leaderboardsPage.GetTitle());
@@ -89,9 +91,10 @@ public:
     TEST_METHOD(TestFetchItemDetail)
     {
         OverlayLeaderboardsPageViewModelHarness leaderboardsPage;
-        auto& pLB1 = leaderboardsPage.mockGameContext.NewLeaderboard(1U);
-        pLB1.SetTitle("LeaderboardTitle");
-        pLB1.SetDescription("Trigger this");
+        auto& pLB1 = leaderboardsPage.mockGameContext.Assets().NewLeaderboard();
+        pLB1.SetID(1U);
+        pLB1.SetName(L"LeaderboardTitle");
+        pLB1.SetDescription(L"Trigger this");
 
         leaderboardsPage.mockUserContext.Initialize("User2", "ApiToken");
         leaderboardsPage.mockServer.HandleRequest<ra::api::FetchLeaderboardInfo>([](const ra::api::FetchLeaderboardInfo::Request& request, ra::api::FetchLeaderboardInfo::Response& response)