Repository: RetroAchievements/RAIntegration
Pull request #842 (2021-12-06 02:14:59)

attempt to validate image format before attaching to achievement
The server relies on the file extension to determine how to process an image. If an image is uploaded with the wrong extension, it will end up being converted to a blank image.

This attempts to detect incorrectly named files (i.e. bmp masquerading as a png) and prevent attaching it to the achievement.


Patch:
diff --git a/rcheevos b/rcheevos
index 4c85f985..d59bb27b 160000
--- a/rcheevos
+++ b/rcheevos
@@ -1,1 +1,1 @@
-Subproject commit 4c85f985e3a3dfa9f38c52299afc905c9194d17a
+Subproject commit d59bb27be1b71a49210a0477e4b36e6f9c605a2a
diff --git a/src/RA_Integration.vcxproj b/src/RA_Integration.vcxproj
index ee8f9c4e..4bf578a8 100644
--- a/src/RA_Integration.vcxproj
+++ b/src/RA_Integration.vcxproj
@@ -187,6 +187,7 @@
     <ClInclude Include="api\SubmitTicket.hh" />
     <ClInclude Include="api\UpdateAchievement.hh" />
     <ClInclude Include="api\UpdateCodeNote.hh" />
+    <ClInclude Include="api\UpdateLeaderboard.hh" />
     <ClInclude Include="api\UploadBadge.hh" />
     <ClInclude Include="data\AsyncObject.hh" />
     <ClInclude Include="data\context\ConsoleContext.hh" />
diff --git a/src/RA_Integration.vcxproj.filters b/src/RA_Integration.vcxproj.filters
index cf02b798..06a02ad6 100644
--- a/src/RA_Integration.vcxproj.filters
+++ b/src/RA_Integration.vcxproj.filters
@@ -959,6 +959,9 @@
     <ClInclude Include="ui\viewmodels\NewAssetViewModel.hh">
       <Filter>UI\ViewModels</Filter>
     </ClInclude>
+    <ClInclude Include="api\UpdateLeaderboard.hh">
+      <Filter>API</Filter>
+    </ClInclude>
   </ItemGroup>
   <ItemGroup>
     <ResourceCompile Include="RA_Shared.rc">
diff --git a/src/api/ApiCall.cpp b/src/api/ApiCall.cpp
index 0221a691..c9dd6f74 100644
--- a/src/api/ApiCall.cpp
+++ b/src/api/ApiCall.cpp
@@ -29,6 +29,7 @@ UpdateCodeNote::Response UpdateCodeNote::Request::Call() const { return Server()
 DeleteCodeNote::Response DeleteCodeNote::Request::Call() const { return Server().DeleteCodeNote(*this); }
 UpdateAchievement::Response UpdateAchievement::Request::Call() const { return Server().UpdateAchievement(*this); }
 FetchAchievementInfo::Response FetchAchievementInfo::Request::Call() const { return Server().FetchAchievementInfo(*this); }
+UpdateLeaderboard::Response UpdateLeaderboard::Request::Call() const { return Server().UpdateLeaderboard(*this); }
 FetchLeaderboardInfo::Response FetchLeaderboardInfo::Request::Call() const { return Server().FetchLeaderboardInfo(*this); }
 LatestClient::Response LatestClient::Request::Call() const { return Server().LatestClient(*this); }
 FetchGamesList::Response FetchGamesList::Request::Call() const { return Server().FetchGamesList(*this); }
diff --git a/src/api/IServer.hh b/src/api/IServer.hh
index 4fad07d2..e32f3436 100644
--- a/src/api/IServer.hh
+++ b/src/api/IServer.hh
@@ -23,6 +23,7 @@
 #include "api/SubmitTicket.hh"
 #include "api/UpdateAchievement.hh"
 #include "api/UpdateCodeNote.hh"
+#include "api/UpdateLeaderboard.hh"
 #include "api/UploadBadge.hh"
 
 namespace ra {
@@ -51,6 +52,7 @@ public:
     virtual DeleteCodeNote::Response DeleteCodeNote(const DeleteCodeNote::Request& request) = 0;
     virtual UpdateAchievement::Response UpdateAchievement(const UpdateAchievement::Request& request) = 0;
     virtual FetchAchievementInfo::Response FetchAchievementInfo(const FetchAchievementInfo::Request& request) = 0;
+    virtual UpdateLeaderboard::Response UpdateLeaderboard(const UpdateLeaderboard::Request& request) = 0;
     virtual FetchLeaderboardInfo::Response FetchLeaderboardInfo(const FetchLeaderboardInfo::Request& request) = 0;
 
     // === other functions ===
diff --git a/src/api/UpdateLeaderboard.hh b/src/api/UpdateLeaderboard.hh
new file mode 100644
index 00000000..323395ce
--- /dev/null
+++ b/src/api/UpdateLeaderboard.hh
@@ -0,0 +1,49 @@
+#ifndef RA_API_UPDATE_LEADERBOARD_HH
+#define RA_API_UPDATE_LEADERBOARD_HH
+#pragma once
+
+#include "ApiCall.hh"
+
+#include "data\Types.hh"
+
+namespace ra {
+namespace api {
+
+class UpdateLeaderboard
+{
+public:
+    static constexpr const char* const Name() noexcept { return "UpdateLeaderboard"; }
+
+    struct Response : ApiResponseBase
+    {
+        unsigned int LeaderboardId{ 0U };
+    };
+
+    struct Request : ApiRequestBase
+    {
+        unsigned int LeaderboardId{ 0U };
+        unsigned int GameId{ 0U };
+        std::wstring Title;
+        std::wstring Description;
+        std::string StartTrigger;
+        std::string SubmitTrigger;
+        std::string CancelTrigger;
+        std::string ValueDefinition;
+        ra::data::ValueFormat Format{ ra::data::ValueFormat::Value };
+        bool LowerIsBetter{ false };
+
+        using Callback = std::function<void(const Response& response)>;
+
+        Response Call() const;
+
+        void CallAsync(Callback&& callback) const
+        {
+            ApiRequestBase::CallAsync<Request, Callback>(*this, std::move(callback));
+        }
+    };
+};
+
+} // namespace api
+} // namespace ra
+
+#endif // !RA_API_UPDATE_LEADERBOARD_HH
diff --git a/src/api/impl/ConnectedServer.cpp b/src/api/impl/ConnectedServer.cpp
index 4ef49fda..113f9548 100644
--- a/src/api/impl/ConnectedServer.cpp
+++ b/src/api/impl/ConnectedServer.cpp
@@ -393,13 +393,8 @@ static bool DoUpload(const std::string& sHost, const char* restrict sApiName, co
         return false;
     }
 
-    std::string sExt = "png";
-    const auto nIndex = sFilePath.find_last_of('.');
-    if (nIndex != std::string::npos)
-    {
-        sExt = ra::Narrow(&sFilePath.at(nIndex + 1));
-        ra::StringMakeLowercase(sExt);
-    }
+    std::string sExt = ra::Narrow(pFileSystem.GetExtension(sFilePath));
+    ra::StringMakeLowercase(sExt);
 
     const auto& pUserContext = ra::services::ServiceLocator::Get<ra::data::context::UserContext>();
     RA_LOG_INFO("%s Request: file=%s (%zu bytes)", sApiName, sFilePath, nFileSize);
@@ -1095,6 +1090,63 @@ FetchAchievementInfo::Response ConnectedServer::FetchAchievementInfo(const Fetch
     return response;
 }
 
+UpdateLeaderboard::Response ConnectedServer::UpdateLeaderboard(const UpdateLeaderboard::Request& request)
+{
+    UpdateLeaderboard::Response response;
+
+    rc_api_update_leaderboard_request_t api_params;
+    memset(&api_params, 0, sizeof(api_params));
+
+    const auto& pUserContext = ra::services::ServiceLocator::Get<ra::data::context::UserContext>();
+    api_params.username = pUserContext.GetUsername().c_str();
+    api_params.api_token = pUserContext.GetApiToken().c_str();
+
+    const std::string sTitle = ra::Narrow(request.Title);
+    const std::string sDescription = ra::Narrow(request.Description);
+
+    api_params.leaderboard_id = request.LeaderboardId;
+    api_params.game_id = request.GameId;
+    api_params.title = sTitle.c_str();
+    api_params.description = sDescription.c_str();
+    api_params.start_trigger = request.StartTrigger.c_str();
+    api_params.submit_trigger = request.SubmitTrigger.c_str();
+    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;
+    }
+
+    rc_api_request_t api_request;
+    if (rc_api_init_update_leaderboard_request(&api_request, &api_params) == RC_OK)
+    {
+        ra::services::Http::Response httpResponse;
+        if (DoRequest(api_request, UpdateLeaderboard::Name(), httpResponse, response))
+        {
+            rc_api_update_leaderboard_response_t api_response;
+            const auto nResult = rc_api_process_update_leaderboard_response(&api_response, httpResponse.Content().c_str());
+
+            if (ValidateResponse(nResult, api_response.response, UpdateLeaderboard::Name(), httpResponse.StatusCode(), response))
+            {
+                response.Result = ApiResult::Success;
+                response.LeaderboardId = api_response.leaderboard_id;
+            }
+
+            rc_api_destroy_update_leaderboard_response(&api_response);
+        }
+    }
+
+    rc_api_destroy_request(&api_request);
+    return response;
+}
+
 FetchLeaderboardInfo::Response ConnectedServer::FetchLeaderboardInfo(const FetchLeaderboardInfo::Request& request)
 {
     FetchLeaderboardInfo::Response response;
diff --git a/src/api/impl/ConnectedServer.hh b/src/api/impl/ConnectedServer.hh
index fc09db3f..28b53517 100644
--- a/src/api/impl/ConnectedServer.hh
+++ b/src/api/impl/ConnectedServer.hh
@@ -30,6 +30,7 @@ public:
     DeleteCodeNote::Response DeleteCodeNote(const DeleteCodeNote::Request& request) override;
     UpdateAchievement::Response UpdateAchievement(const UpdateAchievement::Request& request) override;
     FetchAchievementInfo::Response FetchAchievementInfo(const FetchAchievementInfo::Request& request) override;
+    UpdateLeaderboard::Response UpdateLeaderboard(const UpdateLeaderboard::Request& request) override;
     FetchLeaderboardInfo::Response FetchLeaderboardInfo(const FetchLeaderboardInfo::Request& request) override;
     LatestClient::Response LatestClient(const LatestClient::Request& request) override;
     FetchGamesList::Response FetchGamesList(const FetchGamesList::Request& request) override;
diff --git a/src/api/impl/ServerBase.hh b/src/api/impl/ServerBase.hh
index da5586d7..d5ae1cdc 100644
--- a/src/api/impl/ServerBase.hh
+++ b/src/api/impl/ServerBase.hh
@@ -94,6 +94,11 @@ public:
         return UnsupportedApi<FetchAchievementInfo::Response>(FetchAchievementInfo::Name());
     }
 
+    UpdateLeaderboard::Response UpdateLeaderboard(const UpdateLeaderboard::Request&) override
+    {
+        return UnsupportedApi<UpdateLeaderboard::Response>(UpdateLeaderboard::Name());
+    }
+
     FetchLeaderboardInfo::Response FetchLeaderboardInfo(const FetchLeaderboardInfo::Request&) override
     {
         return UnsupportedApi<FetchLeaderboardInfo::Response>(FetchLeaderboardInfo::Name());
diff --git a/src/data/context/EmulatorContext.cpp b/src/data/context/EmulatorContext.cpp
index 2097ee9c..89b15e32 100644
--- a/src/data/context/EmulatorContext.cpp
+++ b/src/data/context/EmulatorContext.cpp
@@ -107,7 +107,7 @@ void EmulatorContext::Initialize(EmulatorID nEmulatorId, const char* sClientName
                 const auto& pDesktop = ra::services::ServiceLocator::Get<ra::ui::IDesktop>();
                 const auto sFileName = pDesktop.GetRunningExecutable();
                 const auto& pFileSystem = ra::services::ServiceLocator::Get<ra::services::IFileSystem>();
-                m_sClientName = pFileSystem.RemoveExtension(pFileSystem.GetFileName(sFileName));
+                m_sClientName = ra::Narrow(pFileSystem.RemoveExtension(pFileSystem.GetFileName(sFileName)));
             }
             m_nEmulatorId = EmulatorID::UnknownEmulator;
             break;
diff --git a/src/data/context/GameContext.cpp b/src/data/context/GameContext.cpp
index a1aad025..b30c58dd 100644
--- a/src/data/context/GameContext.cpp
+++ b/src/data/context/GameContext.cpp
@@ -313,7 +313,9 @@ void GameContext::UpdateUnlocks(const std::set<unsigned int>& vUnlockedAchieveme
     {
         vLockedAchievements.erase(nUnlockedAchievement);
         auto* pAchievement = Assets().FindAchievement(nUnlockedAchievement);
-        if (pAchievement != nullptr)
+
+        // only core achievements will be automatically activated. also, they're all we want to count
+        if (pAchievement != nullptr && pAchievement->GetCategory() == ra::data::models::AssetCategory::Core)
         {
             pAchievement->SetState(ra::data::models::AssetState::Inactive);
             ++nUnlockedCoreAchievements;
diff --git a/src/data/models/LeaderboardModel.cpp b/src/data/models/LeaderboardModel.cpp
index 9ee2f48d..0f14d723 100644
--- a/src/data/models/LeaderboardModel.cpp
+++ b/src/data/models/LeaderboardModel.cpp
@@ -216,8 +216,8 @@ std::string LeaderboardModel::GetDefinition() const
 void LeaderboardModel::Serialize(ra::services::TextWriter& pWriter) const
 {
     WriteQuoted(pWriter, GetLocalAssetDefinition(m_pStartTrigger));
-    WriteQuoted(pWriter, GetLocalAssetDefinition(m_pSubmitTrigger));
     WriteQuoted(pWriter, GetLocalAssetDefinition(m_pCancelTrigger));
+    WriteQuoted(pWriter, GetLocalAssetDefinition(m_pSubmitTrigger));
     WriteQuoted(pWriter, GetLocalAssetDefinition(m_pValueDefinition));
 
     switch (GetValueFormat())
@@ -243,13 +243,13 @@ bool LeaderboardModel::Deserialize(ra::Tokenizer& pTokenizer)
     if (!pTokenizer.Consume(':'))
         return false;
 
-    // field 3: submit trigger
-    std::string sSubmitTrigger = pTokenizer.ReadQuotedString();
+    // field 3: cancel trigger
+    std::string sCancelTrigger = pTokenizer.ReadQuotedString();
     if (!pTokenizer.Consume(':'))
         return false;
 
-    // field 4: cancel trigger
-    std::string sCancelTrigger = pTokenizer.ReadQuotedString();
+    // field 4: submit trigger
+    std::string sSubmitTrigger = pTokenizer.ReadQuotedString();
     if (!pTokenizer.Consume(':'))
         return false;
 
diff --git a/src/data/models/TriggerValidation.cpp b/src/data/models/TriggerValidation.cpp
index 537741d3..39c8e014 100644
--- a/src/data/models/TriggerValidation.cpp
+++ b/src/data/models/TriggerValidation.cpp
@@ -44,6 +44,9 @@ MemSize TriggerValidation::MapRcheevosMemSize(char nSize) noexcept
 
 static bool ValidateLeaderboardCondSet(const rc_condset_t* pCondSet, std::wstring& sError)
 {
+    if (!pCondSet)
+        return true;
+
     const auto* pCondition = pCondSet->conditions;
     for (; pCondition; pCondition = pCondition->next)
     {
diff --git a/src/services/IFileSystem.hh b/src/services/IFileSystem.hh
index 734773e2..7fd7b130 100644
--- a/src/services/IFileSystem.hh
+++ b/src/services/IFileSystem.hh
@@ -99,12 +99,17 @@ public:
     /// <summary>
     /// Gets the filename portion of a path.
     /// </summary>
-    virtual std::string GetFileName(const std::string& sPath) const = 0;
+    virtual std::wstring GetFileName(const std::wstring& sPath) const = 0;
+
+    /// <summary>
+    /// Gets the extension portion of a path.
+    /// </summary>
+    virtual std::wstring GetExtension(const std::wstring& sPath) const = 0;
 
     /// <summary>
     /// Removes the extension from a filename.
     /// </summary>
-    virtual std::string RemoveExtension(const std::string& sPath) const = 0;
+    virtual std::wstring RemoveExtension(const std::wstring& sPath) const = 0;
 
 protected:
     IFileSystem() noexcept = default;
diff --git a/src/services/impl/WindowsFileSystem.cpp b/src/services/impl/WindowsFileSystem.cpp
index 7e9db36a..b23d3085 100644
--- a/src/services/impl/WindowsFileSystem.cpp
+++ b/src/services/impl/WindowsFileSystem.cpp
@@ -38,20 +38,29 @@ const std::wstring& WindowsFileSystem::MakeAbsolute(std::wstring& sBuffer, const
     return sBuffer;
 }
 
-std::string WindowsFileSystem::GetFileName(const std::string& sPath) const
+std::wstring WindowsFileSystem::GetFileName(const std::wstring& sPath) const
 {
-    const auto nIndex = sPath.find_last_of("/\\");
+    const auto nIndex = sPath.find_last_of(L"/\\");
     if (nIndex != std::string::npos)
-        return std::string(sPath, nIndex + 1);
+        return std::wstring(sPath, nIndex + 1);
 
     return sPath;
 }
 
-std::string WindowsFileSystem::RemoveExtension(const std::string& sPath) const
+std::wstring WindowsFileSystem::GetExtension(const std::wstring& sPath) const
 {
-    const auto nIndex = sPath.find_last_of("/\\.");
+    const auto nIndex = sPath.find_last_of(L"/\\.");
     if (nIndex != std::string::npos && sPath.at(nIndex) == '.')
-        return std::string(sPath, 0, nIndex);
+        return std::wstring(sPath, nIndex + 1);
+
+    return L"";
+}
+
+std::wstring WindowsFileSystem::RemoveExtension(const std::wstring& sPath) const
+{
+    const auto nIndex = sPath.find_last_of(L"/\\.");
+    if (nIndex != std::string::npos && sPath.at(nIndex) == '.')
+        return std::wstring(sPath, 0, nIndex);
 
     return sPath;
 }
diff --git a/src/services/impl/WindowsFileSystem.hh b/src/services/impl/WindowsFileSystem.hh
index d9a5d807..ed87efcc 100644
--- a/src/services/impl/WindowsFileSystem.hh
+++ b/src/services/impl/WindowsFileSystem.hh
@@ -34,8 +34,9 @@ public:
     std::unique_ptr<TextWriter> CreateTextFile(const std::wstring& sPath) const override;
     std::unique_ptr<TextWriter> AppendTextFile(const std::wstring& sPath) const override;
 
-    std::string GetFileName(const std::string& sPath) const override;
-    std::string RemoveExtension(const std::string& sPath) const override;
+    std::wstring GetFileName(const std::wstring& sPath) const override;
+    std::wstring GetExtension(const std::wstring& sPath) const override;
+    std::wstring RemoveExtension(const std::wstring& sPath) const override;
 
 private:
     const std::wstring& MakeAbsolute(std::wstring& sBuffer, const std::wstring& sPath) const noexcept;
diff --git a/src/ui/IDesktop.hh b/src/ui/IDesktop.hh
index b62c90d6..30c7c2a8 100644
--- a/src/ui/IDesktop.hh
+++ b/src/ui/IDesktop.hh
@@ -51,7 +51,7 @@ public:
     /// <summary>
     /// Gets the path/filename of the currently running executable.
     /// </summary>
-    virtual std::string GetRunningExecutable() const = 0;
+    virtual std::wstring GetRunningExecutable() const = 0;
 
     /// <summary>
     /// Gets a string representing the current operating system and its version.
diff --git a/src/ui/viewmodels/AssetEditorViewModel.cpp b/src/ui/viewmodels/AssetEditorViewModel.cpp
index a25705b4..0f172a15 100644
--- a/src/ui/viewmodels/AssetEditorViewModel.cpp
+++ b/src/ui/viewmodels/AssetEditorViewModel.cpp
@@ -5,6 +5,7 @@
 
 #include "services\AchievementRuntime.hh"
 #include "services\IConfiguration.hh"
+#include "services\IFileSystem.hh"
 #include "services\ServiceLocator.hh"
 
 #include "ui\EditorTheme.hh"
@@ -83,11 +84,38 @@ void AssetEditorViewModel::SelectBadgeFile()
     if (vmFile.ShowOpenFileDialog(*this) != DialogResult::OK)
         return;
 
+    const auto& pFileName = vmFile.GetFileName();
+    auto& pFileSystemService = ra::services::ServiceLocator::GetMutable<ra::services::IFileSystem>();
+    const auto pFile = pFileSystemService.OpenTextFile(pFileName);
+    if (!pFile)
+    {
+        ui::viewmodels::MessageBoxViewModel::ShowErrorMessage(ra::StringPrintf(L"Could not read %s", pFileName));
+        return;
+    }
+    byte pHeader[16]{};
+    pFile->GetBytes(pHeader, sizeof(pHeader));
+
+    auto sExtension = ra::Narrow(pFileSystemService.GetExtension(pFileName));
+    ra::StringMakeLowercase(sExtension);
+    bool bValid = false;
+    if (sExtension == "png")
+        bValid = (memcmp(&pHeader[1], "PNG", 3) == 0);
+    else if (sExtension == "gif")
+        bValid = (memcmp(&pHeader[0], "GIF8", 4) == 0);
+    else /* jpg/jpeg */
+        bValid = (memcmp(&pHeader[6], "JFIF", 5) == 0);
+
+    if (!bValid)
+    {
+        ui::viewmodels::MessageBoxViewModel::ShowErrorMessage(ra::StringPrintf(L"File does not appear to be a valid %s image.", sExtension));
+        return;
+    }
+
     auto& pImageRepository = ra::services::ServiceLocator::GetMutable<ra::ui::IImageRepository>();
-    const auto sBadgeName = pImageRepository.StoreImage(ImageType::Badge, vmFile.GetFileName());
+    const auto sBadgeName = pImageRepository.StoreImage(ImageType::Badge, pFileName);
     if (sBadgeName.empty())
     {
-        ui::viewmodels::MessageBoxViewModel::ShowErrorMessage(ra::StringPrintf(L"Error processing %s", vmFile.GetFileName()));
+        ui::viewmodels::MessageBoxViewModel::ShowErrorMessage(ra::StringPrintf(L"Error processing %s", pFileName));
         return;
     }
 
diff --git a/src/ui/viewmodels/AssetListViewModel.cpp b/src/ui/viewmodels/AssetListViewModel.cpp
index 678b8be5..ccc6e83c 100644
--- a/src/ui/viewmodels/AssetListViewModel.cpp
+++ b/src/ui/viewmodels/AssetListViewModel.cpp
@@ -1045,6 +1045,15 @@ void AssetListViewModel::SaveSelected()
         if (vSelectedAssets.empty())
             return;
 
+        for (const auto* pAsset : vSelectedAssets)
+        {
+            if (pAsset->GetType() == ra::data::models::AssetType::Leaderboard)
+            {
+                ra::ui::viewmodels::MessageBoxViewModel::ShowWarningMessage(L"Leaderboards cannot be demoted.");
+                return;
+            }
+        }
+
         ra::ui::viewmodels::MessageBoxViewModel vmMessageBox;
         vmMessageBox.SetIcon(ra::ui::viewmodels::MessageBoxViewModel::Icon::Warning);
         vmMessageBox.SetHeader(ra::StringPrintf(L"Are you sure you want to demote %d items to unofficial?", vSelectedAssets.size()));
@@ -1172,8 +1181,6 @@ void AssetListViewModel::ValidateLeaderboardForCore(std::wstring& sError, const
         if (!sValueError.empty())
             sError.append(ra::StringPrintf(L"\n* %s: %s: %s", pLeaderboard.GetName(), L"Value", sValueError));
     }
-
-    sError.append(ra::StringPrintf(L"\n* %s: %s", pLeaderboard.GetName(), L"Leaderboards cannot be published at this time."));
 }
 #else
 void AssetListViewModel::ValidateAchievementForCore(_UNUSED std::wstring& sError, _UNUSED const ra::data::models::AchievementModel& pAchievement) const
diff --git a/src/ui/viewmodels/AssetUploadViewModel.cpp b/src/ui/viewmodels/AssetUploadViewModel.cpp
index 47e11d10..e6f855b7 100644
--- a/src/ui/viewmodels/AssetUploadViewModel.cpp
+++ b/src/ui/viewmodels/AssetUploadViewModel.cpp
@@ -1,6 +1,7 @@
 #include "AssetUploadViewModel.hh"
 
 #include "api\UpdateAchievement.hh"
+#include "api\UpdateLeaderboard.hh"
 #include "api\UploadBadge.hh"
 
 #include "data\context\GameContext.hh"
@@ -27,6 +28,10 @@ void AssetUploadViewModel::QueueAsset(ra::data::models::AssetModelBase& pAsset)
             QueueAchievement(*(dynamic_cast<ra::data::models::AchievementModel*>(&pAsset)));
             break;
 
+        case ra::data::models::AssetType::Leaderboard:
+            QueueLeaderboard(*(dynamic_cast<ra::data::models::LeaderboardModel*>(&pAsset)));
+            break;
+
         default:
             Expects(!"Unsupported asset type");
             break;
@@ -67,6 +72,17 @@ void AssetUploadViewModel::QueueAchievement(ra::data::models::AchievementModel&
     });
 }
 
+void AssetUploadViewModel::QueueLeaderboard(ra::data::models::LeaderboardModel& pLeaderboard)
+{
+    auto& pItem = m_vUploadQueue.emplace_back();
+    pItem.pAsset = &pLeaderboard;
+
+    QueueTask([this, pLeaderboard = &pLeaderboard]()
+    {
+        UploadLeaderboard(*pLeaderboard);
+    });
+}
+
 void AssetUploadViewModel::OnBegin()
 {
     SetMessage(ra::StringPrintf(L"Uploading %d items...", TaskCount()));
@@ -170,6 +186,50 @@ void AssetUploadViewModel::UploadAchievement(ra::data::models::AchievementModel&
     }
 }
 
+void AssetUploadViewModel::UploadLeaderboard(ra::data::models::LeaderboardModel& pLeaderboard)
+{
+    ra::api::UpdateLeaderboard::Request request;
+    request.GameId = ra::services::ServiceLocator::Get<ra::data::context::GameContext>().GameId();
+    request.Title = pLeaderboard.GetName();
+    request.Description = pLeaderboard.GetDescription();
+    request.StartTrigger = pLeaderboard.GetStartTrigger();
+    request.SubmitTrigger = pLeaderboard.GetSubmitTrigger();
+    request.CancelTrigger = pLeaderboard.GetCancelTrigger();
+    request.ValueDefinition = pLeaderboard.GetValueDefinition();
+    request.Format = pLeaderboard.GetValueFormat();
+    request.LowerIsBetter = pLeaderboard.IsLowerBetter();
+
+    if (pLeaderboard.GetCategory() != ra::data::models::AssetCategory::Local)
+        request.LeaderboardId = pLeaderboard.GetID();
+
+    const auto& response = request.Call();
+
+    // update the achievement
+    if (response.Succeeded())
+    {
+        if (pLeaderboard.GetCategory() == ra::data::models::AssetCategory::Local)
+        {
+            pLeaderboard.SetCategory(ra::data::models::AssetCategory::Core);
+            pLeaderboard.SetID(response.LeaderboardId);
+        }
+
+        pLeaderboard.UpdateLocalCheckpoint();
+        pLeaderboard.UpdateServerCheckpoint();
+    }
+
+    // update the queue
+    std::lock_guard<std::mutex> pLock(m_pMutex);
+    for (auto& pScan : m_vUploadQueue)
+    {
+        if (pScan.pAsset == &pLeaderboard)
+        {
+            pScan.sErrorMessage = response.ErrorMessage;
+            pScan.nState = response.Succeeded() ? UploadState::Success : UploadState::Failed;
+            break;
+        }
+    }
+}
+
 void AssetUploadViewModel::ShowResults() const
 {
     int nSuccessful = 0;
diff --git a/src/ui/viewmodels/AssetUploadViewModel.hh b/src/ui/viewmodels/AssetUploadViewModel.hh
index 00bf4f86..6c8027dc 100644
--- a/src/ui/viewmodels/AssetUploadViewModel.hh
+++ b/src/ui/viewmodels/AssetUploadViewModel.hh
@@ -5,6 +5,7 @@
 #include "ProgressViewModel.hh"
 
 #include "data\models\AchievementModel.hh"
+#include "data\models\LeaderboardModel.hh"
 
 namespace ra {
 namespace ui {
@@ -17,6 +18,7 @@ public:
 
     void QueueAsset(ra::data::models::AssetModelBase& pAsset);
     void QueueAchievement(ra::data::models::AchievementModel& pAchievement);
+    void QueueLeaderboard(ra::data::models::LeaderboardModel& pLeaderboard);
 
     void ShowResults() const;
 
@@ -42,6 +44,7 @@ private:
 
     void UploadBadge(const std::wstring& sBadge);
     void UploadAchievement(ra::data::models::AchievementModel& pAchievement);
+    void UploadLeaderboard(ra::data::models::LeaderboardModel& pLeaderboard);
 
     std::vector<UploadItem> m_vUploadQueue;
     std::mutex m_pMutex;
diff --git a/src/ui/win32/Desktop.cpp b/src/ui/win32/Desktop.cpp
index b07892c7..2a6ec42f 100644
--- a/src/ui/win32/Desktop.cpp
+++ b/src/ui/win32/Desktop.cpp
@@ -167,11 +167,11 @@ void Desktop::SetMainHWnd(HWND hWnd)
     g_RAMainWnd = hWnd;
 }
 
-std::string Desktop::GetRunningExecutable() const
+std::wstring Desktop::GetRunningExecutable() const
 {
-    char buffer[MAX_PATH];
-    GetModuleFileNameA(nullptr, buffer, sizeof(buffer));
-    return std::string(buffer);
+    wchar_t buffer[MAX_PATH];
+    GetModuleFileNameW(nullptr, buffer, sizeof(buffer)/sizeof(buffer[0]));
+    return std::wstring(buffer);
 }
 
 std::string Desktop::GetWindowsVersionString()
diff --git a/src/ui/win32/Desktop.hh b/src/ui/win32/Desktop.hh
index 53b9011c..c32102eb 100644
--- a/src/ui/win32/Desktop.hh
+++ b/src/ui/win32/Desktop.hh
@@ -28,7 +28,7 @@ public:
 
     HWND GetMainHWnd() const noexcept;
     void SetMainHWnd(HWND hWnd);
-    std::string GetRunningExecutable() const override;
+    std::wstring GetRunningExecutable() const override;
     std::string GetOSVersionString() const override { return GetWindowsVersionString(); }
     static std::string GetWindowsVersionString();
 


Test patch:
diff --git a/tests/data/context/EmulatorContext_Tests.cpp b/tests/data/context/EmulatorContext_Tests.cpp
index 1657dc38..df15e4f8 100644
--- a/tests/data/context/EmulatorContext_Tests.cpp
+++ b/tests/data/context/EmulatorContext_Tests.cpp
@@ -1017,7 +1017,7 @@ public:
     TEST_METHOD(TestInitializeUnknownEmulator)
     {
         EmulatorContextHarness emulator;
-        emulator.mockDesktop.SetRunningExecutable("C:\\Games\\Emulator\\RATest.exe");
+        emulator.mockDesktop.SetRunningExecutable(L"C:\\Games\\Emulator\\RATest.exe");
         emulator.Initialize(ra::itoe<EmulatorID>(9999), nullptr);
         Assert::AreEqual(ra::etoi(EmulatorID::UnknownEmulator), ra::etoi(emulator.GetEmulatorId()));
         Assert::AreEqual(std::string("RATest"), emulator.GetClientName());
diff --git a/tests/data/context/GameAssets_Tests.cpp b/tests/data/context/GameAssets_Tests.cpp
index 02e98f5e..554d0057 100644
--- a/tests/data/context/GameAssets_Tests.cpp
+++ b/tests/data/context/GameAssets_Tests.cpp
@@ -108,7 +108,7 @@ public:
 
         ra::data::models::LeaderboardModel& AddLeaderboard(AssetCategory nCategory,
             const std::wstring& sTitle, const std::wstring& sDescription, const std::string& sStart,
-            const std::string& sSubmit, const std::string& sCancel, const std::string& sValue, ValueFormat nFormat)
+            const std::string& sCancel, const std::string& sSubmit, const std::string& sValue, ValueFormat nFormat)
         {
             auto& pLeaderboard = NewLeaderboard();
             pLeaderboard.SetCategory(nCategory);
@@ -650,8 +650,8 @@ public:
         Assert::IsNotNull(pAsset2);
         Ensures(pAsset2 != nullptr);
         Assert::AreEqual(std::string("0xH2234=1"), pAsset2->GetStartTrigger());
-        Assert::AreEqual(std::string("0xH2234=2"), pAsset2->GetSubmitTrigger());
-        Assert::AreEqual(std::string("0xH2234=3"), pAsset2->GetCancelTrigger());
+        Assert::AreEqual(std::string("0xH2234=2"), pAsset2->GetCancelTrigger());
+        Assert::AreEqual(std::string("0xH2234=3"), pAsset2->GetSubmitTrigger());
         Assert::AreEqual(std::string("M:0xH2235"), pAsset2->GetValueDefinition());
         Assert::AreEqual(ValueFormat::Minutes, pAsset2->GetValueFormat());
         Assert::AreEqual(AssetCategory::Local, pAsset->GetCategory());
diff --git a/tests/data/context/GameContext_Tests.cpp b/tests/data/context/GameContext_Tests.cpp
index af70f8ef..2b82964a 100644
--- a/tests/data/context/GameContext_Tests.cpp
+++ b/tests/data/context/GameContext_Tests.cpp
@@ -763,12 +763,17 @@ public:
         GameContextHarness game;
         game.mockServer.HandleRequest<ra::api::FetchGameData>([](const ra::api::FetchGameData::Request&, ra::api::FetchGameData::Response& response)
         {
+            response.Title = L"My Game";
+            response.ImageIcon = "3333";
+
             auto& ach1 = response.Achievements.emplace_back();
             ach1.Id = 5;
+            ach1.Points = 5;
             ach1.CategoryId = ra::etoi(ra::data::models::AssetCategory::Core);
 
             auto& ach2 = response.Achievements.emplace_back();
             ach2.Id = 7;
+            ach2.Points = 10;
             ach2.CategoryId = ra::etoi(ra::data::models::AssetCategory::Core);
             return true;
         });
@@ -800,6 +805,95 @@ public:
         Assert::IsNotNull(pAch2);
         Ensures(pAch2 != nullptr);
         Assert::IsFalse(pAch2->IsActive());
+
+        const auto* pPopup = game.mockOverlayManager.GetMessage(1);
+        Expects(pPopup != nullptr);
+        Assert::IsNotNull(pPopup);
+        Assert::AreEqual(std::wstring(L"Loaded My Game"), pPopup->GetTitle());
+        Assert::AreEqual(std::wstring(L"2 achievements, 15 points"), pPopup->GetDescription());
+        Assert::AreEqual(std::wstring(L"You have earned 1 achievements"), pPopup->GetDetail());
+        Assert::AreEqual(std::string("3333"), pPopup->GetImage().Name());
+    }
+
+    TEST_METHOD(TestLoadGameUserUnlocksUnofficial)
+    {
+        GameContextHarness game;
+        game.mockServer.HandleRequest<ra::api::FetchGameData>([](const ra::api::FetchGameData::Request&, ra::api::FetchGameData::Response& response)
+        {
+            response.Title = L"My Game";
+            response.ImageIcon = "3333";
+
+            auto& ach1 = response.Achievements.emplace_back();
+            ach1.Id = 5;
+            ach1.Points = 5;
+            ach1.CategoryId = ra::etoi(ra::data::models::AssetCategory::Core);
+
+            auto& ach2 = response.Achievements.emplace_back();
+            ach2.Id = 7;
+            ach2.Points = 10;
+            ach2.CategoryId = ra::etoi(ra::data::models::AssetCategory::Core);
+
+            auto& ach3 = response.Achievements.emplace_back();
+            ach3.Id = 9;
+            ach3.Points = 25;
+            ach3.CategoryId = ra::etoi(ra::data::models::AssetCategory::Unofficial);
+
+            auto& ach4 = response.Achievements.emplace_back();
+            ach4.Id = 11;
+            ach4.Points = 50;
+            ach4.CategoryId = ra::etoi(ra::data::models::AssetCategory::Unofficial);
+            return true;
+        });
+
+        game.mockServer.HandleRequest<ra::api::FetchUserUnlocks>([](const ra::api::FetchUserUnlocks::Request& request, ra::api::FetchUserUnlocks::Response& response)
+        {
+            Assert::AreEqual(1U, request.GameId);
+            Assert::IsFalse(request.Hardcore);
+
+            response.UnlockedAchievements.insert(7U); // core achievement
+            response.UnlockedAchievements.insert(9U); // unofficial achievement
+            return true;
+        });
+
+        game.mockServer.HandleRequest<ra::api::FetchCodeNotes>([](const ra::api::FetchCodeNotes::Request&, ra::api::FetchCodeNotes::Response&)
+        {
+            return true;
+        });
+
+        game.LoadGame(1U);
+        game.mockThreadPool.ExecuteNextTask(); // FetchUserUnlocks and FetchCodeNotes are async
+        game.mockThreadPool.ExecuteNextTask();
+
+        const auto* pAch1 = game.Assets().FindAchievement(5U);
+        Assert::IsNotNull(pAch1);
+        Ensures(pAch1 != nullptr);
+        Assert::IsTrue(pAch1->IsActive());
+
+        const auto* pAch2 = game.Assets().FindAchievement(7U);
+        Assert::IsNotNull(pAch2);
+        Ensures(pAch2 != nullptr);
+        Assert::IsFalse(pAch2->IsActive());
+
+        const auto* pAch3 = game.Assets().FindAchievement(9U);
+        Assert::IsNotNull(pAch3);
+        Ensures(pAch3 != nullptr);
+        Assert::IsFalse(pAch3->IsActive());
+
+        // unofficial achievement should not be activated even if it wasn't unlocked
+        // (expect unofficial achievements to not be unlocked)
+        const auto* pAch4 = game.Assets().FindAchievement(11U);
+        Assert::IsNotNull(pAch4);
+        Ensures(pAch4 != nullptr);
+        Assert::IsFalse(pAch4->IsActive());
+
+        // only core achievements should be tallied for the popup
+        const auto* pPopup = game.mockOverlayManager.GetMessage(1);
+        Expects(pPopup != nullptr);
+        Assert::IsNotNull(pPopup);
+        Assert::AreEqual(std::wstring(L"Loaded My Game"), pPopup->GetTitle());
+        Assert::AreEqual(std::wstring(L"2 achievements, 15 points"), pPopup->GetDescription());
+        Assert::AreEqual(std::wstring(L"You have earned 1 achievements"), pPopup->GetDetail());
+        Assert::AreEqual(std::string("3333"), pPopup->GetImage().Name());
     }
 
     TEST_METHOD(TestLoadGameUserUnlocksCompatibilityMode)
diff --git a/tests/data/models/LeaderboardModel_Tests.cpp b/tests/data/models/LeaderboardModel_Tests.cpp
index d7845fba..21917ea4 100644
--- a/tests/data/models/LeaderboardModel_Tests.cpp
+++ b/tests/data/models/LeaderboardModel_Tests.cpp
@@ -198,7 +198,7 @@ public:
         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.SetDefinition("STA:0xH1234=1::CAN:0xH1234=2::SUB:0xH1234=3::VAL:0xH1234");
         leaderboard.SetValueFormat(nFormat);
         leaderboard.CreateServerCheckpoint();
         leaderboard.CreateLocalCheckpoint();
diff --git a/tests/mocks/MockDesktop.hh b/tests/mocks/MockDesktop.hh
index 457fb182..a9a899c8 100644
--- a/tests/mocks/MockDesktop.hh
+++ b/tests/mocks/MockDesktop.hh
@@ -86,8 +86,8 @@ public:
 
     void ResetExpectedWindows() noexcept { m_bDialogShown = false; m_vHandlers.clear(); }
 
-    std::string GetRunningExecutable() const override { return m_sExecutable; }
-    void SetRunningExecutable(const std::string& sExecutable) { m_sExecutable = sExecutable; }
+    std::wstring GetRunningExecutable() const override { return m_sExecutable; }
+    void SetRunningExecutable(const std::wstring& sExecutable) { m_sExecutable = sExecutable; }
 
     std::string GetOSVersionString() const override { return "UnitTests"; }
 
@@ -112,7 +112,7 @@ private:
     std::vector<DialogHandler> m_vHandlers;
     mutable bool m_bDialogShown = false;
     mutable std::string m_sLastOpenedUrl;
-    std::string m_sExecutable;
+    std::wstring m_sExecutable;
     bool m_bDebuggerPresent = false;
 };
 
diff --git a/tests/mocks/MockFileSystem.hh b/tests/mocks/MockFileSystem.hh
index 71b90d2e..40521dc2 100644
--- a/tests/mocks/MockFileSystem.hh
+++ b/tests/mocks/MockFileSystem.hh
@@ -187,20 +187,29 @@ public:
         return std::unique_ptr<TextWriter>(pWriter.release());
     }
 
-    std::string GetFileName(const std::string& sPath) const override
+    std::wstring GetFileName(const std::wstring& sPath) const override
     {
-        const auto nIndex = sPath.find_last_of("/\\");
+        const auto nIndex = sPath.find_last_of(L"/\\");
         if (nIndex != std::string::npos)
-            return std::string(sPath, nIndex + 1);
+            return std::wstring(sPath, nIndex + 1);
 
         return sPath;
     }
 
-    std::string RemoveExtension(const std::string& sPath) const override
+    std::wstring GetExtension(const std::wstring& sPath) const override
     {
-        const auto nIndex = sPath.find_last_of("/\\.");
+        const auto nIndex = sPath.find_last_of(L"/\\.");
         if (nIndex != std::string::npos && sPath.at(nIndex) == '.')
-            return std::string(sPath, 0, nIndex);
+            return std::wstring(sPath, nIndex + 1);
+
+        return L"";
+    }
+
+    std::wstring RemoveExtension(const std::wstring& sPath) const override
+    {
+        const auto nIndex = sPath.find_last_of(L"/\\.");
+        if (nIndex != std::string::npos && sPath.at(nIndex) == '.')
+            return std::wstring(sPath, 0, nIndex);
 
         return sPath;
     }
diff --git a/tests/mocks/MockImageRepository.hh b/tests/mocks/MockImageRepository.hh
index e10573e4..3b44aa07 100644
--- a/tests/mocks/MockImageRepository.hh
+++ b/tests/mocks/MockImageRepository.hh
@@ -46,7 +46,7 @@ public:
 
     std::string StoreImage(_UNUSED ImageType nType, _UNUSED const std::wstring& sPath) override
     {
-        return ra::Narrow(sPath);
+        return "REPO:" + ra::Narrow(sPath);
     }
 
     std::wstring GetFilename(_UNUSED ImageType nType, const std::string& sName) const override
diff --git a/tests/mocks/MockServer.hh b/tests/mocks/MockServer.hh
index 3ea1343c..41427806 100644
--- a/tests/mocks/MockServer.hh
+++ b/tests/mocks/MockServer.hh
@@ -134,6 +134,11 @@ public:
         return HandleRequest<ra::api::FetchAchievementInfo>(request);
     }
 
+    UpdateLeaderboard::Response UpdateLeaderboard(const UpdateLeaderboard::Request& request) override
+    {
+        return HandleRequest<ra::api::UpdateLeaderboard>(request);
+    }
+
     FetchLeaderboardInfo::Response FetchLeaderboardInfo(const FetchLeaderboardInfo::Request& request) override
     {
         return HandleRequest<ra::api::FetchLeaderboardInfo>(request);
diff --git a/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp b/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp
index f4c6d079..a3aa7139 100644
--- a/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp
@@ -3,6 +3,7 @@
 #include "ui\EditorTheme.hh"
 
 #include "ui\viewmodels\AssetEditorViewModel.hh"
+#include "ui\viewmodels\FileDialogViewModel.hh"
 #include "ui\viewmodels\MessageBoxViewModel.hh"
 
 #include "tests\RA_UnitTestHelpers.h"
@@ -12,8 +13,10 @@
 #include "tests\mocks\MockAchievementRuntime.hh"
 #include "tests\mocks\MockClock.hh"
 #include "tests\mocks\MockConfiguration.hh"
-#include "tests\mocks\MockGameContext.hh"
 #include "tests\mocks\MockDesktop.hh"
+#include "tests\mocks\MockFileSystem.hh"
+#include "tests\mocks\MockGameContext.hh"
+#include "tests\mocks\MockImageRepository.hh"
 
 using namespace Microsoft::VisualStudio::CppUnitTestFramework;
 
@@ -56,8 +59,10 @@ private:
         ra::services::mocks::MockAchievementRuntime mockRuntime;
         ra::services::mocks::MockClock mockClock;
         ra::services::mocks::MockConfiguration mockConfiguration;
+        ra::services::mocks::MockFileSystem mockFileSystem;
         ra::data::context::mocks::MockGameContext mockGameContext;
         ra::ui::mocks::MockDesktop mockDesktop;
+        ra::ui::mocks::MockImageRepository mockImageRepository;
         ra::ui::EditorTheme mockTheme;
 
         const std::wstring& GetWaitingLabel() const
@@ -1727,6 +1732,253 @@ public:
         Assert::IsTrue(editor.IsTrigger());
         Assert::IsFalse(leaderboard.IsModified());
     }
+
+    TEST_METHOD(TestSelectBadgeFileCancel)
+    {
+        AssetEditorViewModelHarness editor;
+        AchievementModel achievement;
+        editor.LoadAsset(&achievement);
+        const std::wstring sBadge = achievement.GetBadge();
+
+        bool bDialogSeen = false;
+        editor.mockDesktop.ExpectWindow<ra::ui::viewmodels::FileDialogViewModel>(
+            [&bDialogSeen](ra::ui::viewmodels::FileDialogViewModel&)
+        {
+            bDialogSeen = true;
+
+            return DialogResult::Cancel;
+        });
+
+        std::wstring sMessage;
+        editor.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>(
+            [&sMessage](ra::ui::viewmodels::MessageBoxViewModel& vmMessage)
+        {
+            sMessage = vmMessage.GetMessage();
+            return DialogResult::OK;
+        });
+
+        editor.SelectBadgeFile();
+
+        Assert::AreEqual(sBadge, achievement.GetBadge());
+        Assert::IsTrue(bDialogSeen);
+        Assert::AreEqual(std::wstring(), sMessage);
+    }
+
+    TEST_METHOD(TestSelectBadgeFilePNG)
+    {
+        AssetEditorViewModelHarness editor;
+        AchievementModel achievement;
+        editor.LoadAsset(&achievement);
+
+        bool bDialogSeen = false;
+        editor.mockDesktop.ExpectWindow<ra::ui::viewmodels::FileDialogViewModel>(
+            [&bDialogSeen](ra::ui::viewmodels::FileDialogViewModel& vmFileDialog)
+        {
+            bDialogSeen = true;
+
+            vmFileDialog.SetFileName(L"C:\\image.png");
+            return DialogResult::OK;
+        });
+
+        std::wstring sMessage;
+        editor.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>(
+            [&sMessage](ra::ui::viewmodels::MessageBoxViewModel& vmMessage)
+        {
+            sMessage = vmMessage.GetMessage();
+            return DialogResult::OK;
+        });
+
+        const std::string sFileContents("\x89PNG\x0D\x0A\x1A\x0D\x00\x00\x00\x0DIHDR", 16);
+
+        editor.mockFileSystem.MockFile(L"C:\\image.png", sFileContents);
+
+        editor.SelectBadgeFile();
+
+        Assert::AreEqual(std::wstring(L"REPO:C:\\image.png"), achievement.GetBadge());
+        Assert::IsTrue(bDialogSeen);
+        Assert::AreEqual(std::wstring(), sMessage);
+    }
+
+    TEST_METHOD(TestSelectBadgeFilePNGInvalid)
+    {
+        AssetEditorViewModelHarness editor;
+        AchievementModel achievement;
+        editor.LoadAsset(&achievement);
+        const std::wstring sBadge = achievement.GetBadge();
+
+        bool bDialogSeen = false;
+        editor.mockDesktop.ExpectWindow<ra::ui::viewmodels::FileDialogViewModel>(
+            [&bDialogSeen](ra::ui::viewmodels::FileDialogViewModel& vmFileDialog)
+        {
+            bDialogSeen = true;
+
+            vmFileDialog.SetFileName(L"C:\\image.png");
+            return DialogResult::OK;
+        });
+
+        std::wstring sMessage;
+        editor.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>(
+            [&sMessage](ra::ui::viewmodels::MessageBoxViewModel& vmMessage)
+        {
+            sMessage = vmMessage.GetMessage();
+            return DialogResult::OK;
+        });
+
+        // this is from a BMP file, which the server doesn't support
+        const std::string sFileContents("BM\x36\xC4\x12\x00\x00\x00\x00\x00\x36\x04\x00\x00\x28\x00", 16);
+
+        editor.mockFileSystem.MockFile(L"C:\\image.png", sFileContents);
+
+        editor.SelectBadgeFile();
+
+        Assert::AreEqual(sBadge, achievement.GetBadge());
+        Assert::IsTrue(bDialogSeen);
+        Assert::AreEqual(std::wstring(L"File does not appear to be a valid png image."), sMessage);
+    }
+
+    TEST_METHOD(TestSelectBadgeFileGIF)
+    {
+        AssetEditorViewModelHarness editor;
+        AchievementModel achievement;
+        editor.LoadAsset(&achievement);
+
+        bool bDialogSeen = false;
+        editor.mockDesktop.ExpectWindow<ra::ui::viewmodels::FileDialogViewModel>(
+            [&bDialogSeen](ra::ui::viewmodels::FileDialogViewModel& vmFileDialog)
+        {
+            bDialogSeen = true;
+
+            vmFileDialog.SetFileName(L"C:\\image.gif");
+            return DialogResult::OK;
+        });
+
+        std::wstring sMessage;
+        editor.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>(
+            [&sMessage](ra::ui::viewmodels::MessageBoxViewModel& vmMessage)
+        {
+            sMessage = vmMessage.GetMessage();
+            return DialogResult::OK;
+        });
+
+        const std::string sFileContents("GIF89a\x39\x61\x64\x00\x37\x00\xc7\x00\x00\x00\x00\x00", 16);
+
+        editor.mockFileSystem.MockFile(L"C:\\image.gif", sFileContents);
+
+        editor.SelectBadgeFile();
+
+        Assert::AreEqual(std::wstring(L"REPO:C:\\image.gif"), achievement.GetBadge());
+        Assert::IsTrue(bDialogSeen);
+        Assert::AreEqual(std::wstring(), sMessage);
+    }
+
+    TEST_METHOD(TestSelectBadgeFileGIFInvalid)
+    {
+        AssetEditorViewModelHarness editor;
+        AchievementModel achievement;
+        editor.LoadAsset(&achievement);
+        const std::wstring sBadge = achievement.GetBadge();
+
+        bool bDialogSeen = false;
+        editor.mockDesktop.ExpectWindow<ra::ui::viewmodels::FileDialogViewModel>(
+            [&bDialogSeen](ra::ui::viewmodels::FileDialogViewModel& vmFileDialog)
+        {
+            bDialogSeen = true;
+
+            vmFileDialog.SetFileName(L"C:\\image.gif");
+            return DialogResult::OK;
+        });
+
+        std::wstring sMessage;
+        editor.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>(
+            [&sMessage](ra::ui::viewmodels::MessageBoxViewModel& vmMessage)
+        {
+            sMessage = vmMessage.GetMessage();
+            return DialogResult::OK;
+        });
+
+        // this is from a PNG file
+        const std::string sFileContents("\x89PNG\x0D\x0A\x1A\x0D\x00\x00\x00\x0DIHDR", 16);
+
+        editor.mockFileSystem.MockFile(L"C:\\image.gif", sFileContents);
+
+        editor.SelectBadgeFile();
+
+        Assert::AreEqual(sBadge, achievement.GetBadge());
+        Assert::IsTrue(bDialogSeen);
+        Assert::AreEqual(std::wstring(L"File does not appear to be a valid gif image."), sMessage);
+    }
+
+    TEST_METHOD(TestSelectBadgeFileJPG)
+    {
+        AssetEditorViewModelHarness editor;
+        AchievementModel achievement;
+        editor.LoadAsset(&achievement);
+
+        bool bDialogSeen = false;
+        editor.mockDesktop.ExpectWindow<ra::ui::viewmodels::FileDialogViewModel>(
+            [&bDialogSeen](ra::ui::viewmodels::FileDialogViewModel& vmFileDialog)
+        {
+            bDialogSeen = true;
+
+            vmFileDialog.SetFileName(L"C:\\image.jpg");
+            return DialogResult::OK;
+        });
+
+        std::wstring sMessage;
+        editor.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>(
+            [&sMessage](ra::ui::viewmodels::MessageBoxViewModel& vmMessage)
+        {
+            sMessage = vmMessage.GetMessage();
+            return DialogResult::OK;
+        });
+
+        const std::string sFileContents("\xFF\xD8\xFF\xE0\x00\x10JFIF\x00\x01\x00\x01\x00\x96", 16);
+
+        editor.mockFileSystem.MockFile(L"C:\\image.jpg", sFileContents);
+
+        editor.SelectBadgeFile();
+
+        Assert::AreEqual(std::wstring(L"REPO:C:\\image.jpg"), achievement.GetBadge());
+        Assert::IsTrue(bDialogSeen);
+        Assert::AreEqual(std::wstring(), sMessage);
+    }
+
+    TEST_METHOD(TestSelectBadgeFileJPGInvalid)
+    {
+        AssetEditorViewModelHarness editor;
+        AchievementModel achievement;
+        editor.LoadAsset(&achievement);
+        const std::wstring sBadge = achievement.GetBadge();
+
+        bool bDialogSeen = false;
+        editor.mockDesktop.ExpectWindow<ra::ui::viewmodels::FileDialogViewModel>(
+            [&bDialogSeen](ra::ui::viewmodels::FileDialogViewModel& vmFileDialog)
+        {
+            bDialogSeen = true;
+
+            vmFileDialog.SetFileName(L"C:\\image.jpg");
+            return DialogResult::OK;
+        });
+
+        std::wstring sMessage;
+        editor.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>(
+            [&sMessage](ra::ui::viewmodels::MessageBoxViewModel& vmMessage)
+        {
+            sMessage = vmMessage.GetMessage();
+            return DialogResult::OK;
+        });
+
+        // this is from a PNG file
+        const std::string sFileContents("\x89PNG\x0D\x0A\x1A\x0D\x00\x00\x00\x0DIHDR", 16);
+
+        editor.mockFileSystem.MockFile(L"C:\\image.jpg", sFileContents);
+
+        editor.SelectBadgeFile();
+
+        Assert::AreEqual(sBadge, achievement.GetBadge());
+        Assert::IsTrue(bDialogSeen);
+        Assert::AreEqual(std::wstring(L"File does not appear to be a valid jpg image."), sMessage);
+    }
 };
 
 } // namespace tests
diff --git a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
index c257d3d6..94b1b4f2 100644
--- a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
@@ -2742,6 +2742,48 @@ public:
         vmAssetList.AssertButtonState(SaveButtonState::SaveAllDisabled);
     }
 
+    TEST_METHOD(TestSaveSelectedDemoteLeaderboardToUnofficial)
+    {
+        AssetListViewModelHarness vmAssetList;
+        vmAssetList.MockGameId(22U);
+        vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
+        vmAssetList.AddLeaderboard(ra::data::models::AssetCategory::Core, L"Leaderboard1");
+        vmAssetList.SetAssetTypeFilter(ra::data::models::AssetType::None);
+
+        bool bDialogSeen = false;
+        vmAssetList.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bDialogSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bDialogSeen = true;
+            Assert::AreEqual(std::wstring(L"Leaderboards cannot be demoted."), vmMessageBox.GetMessage());
+            return DialogResult::OK;
+        });
+
+        const auto* pItem1 = vmAssetList.mockGameContext.Assets().GetItemAt(0);
+        Expects(pItem1 != nullptr);
+        Assert::AreEqual(AssetChanges::None, pItem1->GetChanges());
+
+        const auto* pItem2 = vmAssetList.mockGameContext.Assets().GetItemAt(1);
+        Expects(pItem2 != nullptr);
+        Assert::AreEqual(AssetChanges::None, pItem2->GetChanges());
+
+        vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
+        vmAssetList.FilteredAssets().GetItemAt(1)->SetSelected(true);
+        vmAssetList.ForceUpdateButtons();
+        vmAssetList.AssertButtonState(SaveButtonState::Demote);
+        vmAssetList.SaveSelected();
+
+        Assert::IsTrue(bDialogSeen);
+
+        Assert::AreEqual(AssetChanges::None, pItem1->GetChanges());
+        Assert::AreEqual(AssetCategory::Core, pItem1->GetCategory());
+        Assert::AreEqual(AssetChanges::None, pItem2->GetChanges());
+        Assert::AreEqual(AssetCategory::Core, pItem2->GetCategory());
+
+        // item will have been moved to core, so nothing will be selected
+        vmAssetList.ForceUpdateButtons();
+        vmAssetList.AssertButtonState(SaveButtonState::Demote);
+    }
+
     TEST_METHOD(TestSaveSelectedPublishLocal)
     {
         AssetListViewModelHarness vmAssetList;
diff --git a/tests/ui/viewmodels/AssetUploadViewModel_Tests.cpp b/tests/ui/viewmodels/AssetUploadViewModel_Tests.cpp
index aec02b02..d736a1eb 100644
--- a/tests/ui/viewmodels/AssetUploadViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/AssetUploadViewModel_Tests.cpp
@@ -4,6 +4,7 @@
 
 #include "data\context\GameAssets.hh"
 #include "data\models\AchievementModel.hh"
+#include "data\models\LeaderboardModel.hh"
 
 #include "tests\data\DataAsserts.hh"
 #include "tests\ui\UIAsserts.hh"
@@ -23,6 +24,7 @@ namespace tests {
 using ra::data::models::AchievementModel;
 using ra::data::models::AssetCategory;
 using ra::data::models::AssetChanges;
+using ra::data::models::LeaderboardModel;
 
 TEST_CLASS(AssetUploadViewModel_Tests)
 {
@@ -99,6 +101,7 @@ private:
             if (nCategory != AssetCategory::Local)
                 vmAchievement->CreateServerCheckpoint();
 
+            // set name after creating the server checkpoint so the model appears Unpublished
             vmAchievement->SetName(sTitle);
 
             vmAchievement->CreateLocalCheckpoint();
@@ -106,6 +109,38 @@ private:
             return dynamic_cast<AchievementModel&>(m_pAssets.Append(std::move(vmAchievement)));
         }
 
+        LeaderboardModel& AddLeaderboard(AssetCategory nCategory, const std::wstring& sTitle,
+            const std::wstring& sDescription, const std::string& sStartTrigger, const std::string& sSubmitTrigger,
+            const std::string& sCancelTrigger, const std::string& sValueDefinition, ra::data::ValueFormat nFormat)
+        {
+            auto vmLeaderboard = std::make_unique<LeaderboardModel>();
+            if (nCategory == AssetCategory::Local)
+            {
+                vmLeaderboard->SetID(gsl::narrow_cast<unsigned>(m_pAssets.Count()) + ra::data::context::GameAssets::FirstLocalId);
+                vmLeaderboard->CreateServerCheckpoint();
+            }
+            else
+            {
+                vmLeaderboard->SetID(gsl::narrow_cast<unsigned>(m_pAssets.Count()) + 1);
+            }
+
+            vmLeaderboard->SetCategory(nCategory);
+            vmLeaderboard->SetName(sTitle);
+            vmLeaderboard->SetDescription(sDescription);
+            vmLeaderboard->SetStartTrigger(sStartTrigger);
+            vmLeaderboard->SetSubmitTrigger(sSubmitTrigger);
+            vmLeaderboard->SetCancelTrigger(sCancelTrigger);
+            vmLeaderboard->SetValueDefinition(sValueDefinition);
+            vmLeaderboard->SetValueFormat(nFormat);
+
+            if (nCategory != AssetCategory::Local)
+                vmLeaderboard->CreateServerCheckpoint();
+
+            vmLeaderboard->CreateLocalCheckpoint();
+
+            return dynamic_cast<LeaderboardModel&>(m_pAssets.Append(std::move(vmLeaderboard)));
+        }
+
         void AssertSuccess(int nItems)
         {
             bool bDialogSeen = false;
@@ -776,6 +811,48 @@ public:
 
         vmUpload.AssertFailed(1, 1, L"* Title2: Timeout");
     }
+
+    TEST_METHOD(TestSingleLocalLeaderboard)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        auto& pLeaderboard = vmUpload.AddLeaderboard(AssetCategory::Local, L"Title1", L"Desc1", "0xH1234=1", "0xH1234=2", "0xH1234=3", "0xH2345", ra::data::ValueFormat::Score);
+        Assert::AreEqual(AssetChanges::Unpublished, pLeaderboard.GetChanges());
+
+        vmUpload.QueueAsset(pLeaderboard);
+        Assert::AreEqual({ 1U }, vmUpload.TaskCount());
+
+        bool bApiCalled = false;
+        vmUpload.mockServer.HandleRequest<ra::api::UpdateLeaderboard>([&bApiCalled]
+        (const ra::api::UpdateLeaderboard::Request& pRequest, ra::api::UpdateLeaderboard::Response& pResponse)
+        {
+            bApiCalled = true;
+            Assert::AreEqual(AssetUploadViewModelHarness::GameId, pRequest.GameId);
+            Assert::AreEqual(std::wstring(L"Title1"), pRequest.Title);
+            Assert::AreEqual(std::wstring(L"Desc1"), pRequest.Description);
+            Assert::AreEqual(std::string("0xH1234=1"), pRequest.StartTrigger);
+            Assert::AreEqual(std::string("0xH1234=2"), pRequest.SubmitTrigger);
+            Assert::AreEqual(std::string("0xH1234=3"), pRequest.CancelTrigger);
+            Assert::AreEqual(std::string("0xH2345"), pRequest.ValueDefinition);
+            Assert::AreEqual(ra::data::ValueFormat::Score, pRequest.Format);
+            Assert::IsFalse(pRequest.LowerIsBetter);
+            Assert::AreEqual(0U, pRequest.LeaderboardId);
+
+            pResponse.LeaderboardId = 7716U;
+            pResponse.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        vmUpload.DoUpload();
+
+        Assert::IsTrue(bApiCalled);
+
+        // published local leaderboard should be changed to core and have it's ID updated
+        Assert::AreEqual(AssetCategory::Core, pLeaderboard.GetCategory());
+        Assert::AreEqual(7716U, pLeaderboard.GetID());
+        Assert::AreEqual(AssetChanges::None, pLeaderboard.GetChanges());
+
+        vmUpload.AssertSuccess(1);
+    }
 };
 
 } // namespace tests