Repository: RetroAchievements/RAIntegration
Pull request #640 (2020-11-30 15:19:59)

play achievement unlock sound even if popups are disabled


Patch:
diff --git a/src/RA_Integration.vcxproj b/src/RA_Integration.vcxproj
index 8baa035f..5415120c 100644
--- a/src/RA_Integration.vcxproj
+++ b/src/RA_Integration.vcxproj
@@ -85,6 +85,7 @@
     <ClCompile Include="data\ModelPropertyContainer.cpp" />
     <ClCompile Include="data\models\AchievementModel.cpp" />
     <ClCompile Include="data\models\AssetModelBase.cpp" />
+    <ClCompile Include="data\models\LocalBadgesModel.cpp" />
     <ClCompile Include="pch.cpp">
       <PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='DebugUnicode|Win32'">Create</PrecompiledHeader>
       <PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='DebugUnicode|x64'">Create</PrecompiledHeader>
@@ -238,6 +239,7 @@
     <ClInclude Include="data\ModelPropertyContainer.hh" />
     <ClInclude Include="data\models\AchievementModel.hh" />
     <ClInclude Include="data\models\AssetModelBase.hh" />
+    <ClInclude Include="data\models\LocalBadgesModel.hh" />
     <ClInclude Include="data\Types.hh" />
     <ClInclude Include="Exports.hh" />
     <ClInclude Include="pch.h" />
diff --git a/src/RA_Integration.vcxproj.filters b/src/RA_Integration.vcxproj.filters
index 433cc115..77836d41 100644
--- a/src/RA_Integration.vcxproj.filters
+++ b/src/RA_Integration.vcxproj.filters
@@ -372,6 +372,9 @@
     <ClCompile Include="data\context\GameAssets.cpp">
       <Filter>Data\Context</Filter>
     </ClCompile>
+    <ClCompile Include="data\models\LocalBadgesModel.cpp">
+      <Filter>Data\Models</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <ClInclude Include="RA_Achievement.h">
@@ -923,6 +926,9 @@
     <ClInclude Include="data\context\GameAssets.hh">
       <Filter>Data\Context</Filter>
     </ClInclude>
+    <ClInclude Include="data\models\LocalBadgesModel.hh">
+      <Filter>Data\Models</Filter>
+    </ClInclude>
   </ItemGroup>
   <ItemGroup>
     <ResourceCompile Include="RA_Shared.rc">
diff --git a/src/RA_md5factory.cpp b/src/RA_md5factory.cpp
index e7149c33..325c8d5a 100644
--- a/src/RA_md5factory.cpp
+++ b/src/RA_md5factory.cpp
@@ -2,6 +2,9 @@
 
 #include "RA_Defs.h"
 
+#include "services\IFileSystem.hh"
+#include "services\ServiceLocator.hh"
+
 #include <rcheevos/src/rhash/md5.h>
 
 namespace {
@@ -57,3 +60,31 @@ std::string RAGenerateMD5(const std::vector<BYTE> DataIn)
 {
     return RAGenerateMD5(DataIn.data(), DataIn.size());
 }
+
+std::string RAGenerateFileMD5(const std::wstring& sPath)
+{
+    md5_state_t pms{};
+    md5_byte_t digest[16]{};
+
+    md5_byte_t bytes[4096]{};
+    static_assert(sizeof(md5_byte_t) == sizeof(uint8_t), "Must be equivalent for the MD5 to work!");
+
+    auto& pFileSystem = ra::services::ServiceLocator::Get<ra::services::IFileSystem>();
+    auto pFile = pFileSystem.OpenTextFile(sPath);
+    if (pFile == nullptr)
+        return "";
+
+    md5_init(&pms);
+    do
+    {
+        size_t nBytes = pFile->GetBytes(static_cast<uint8_t*>(bytes), sizeof(bytes));
+        if (nBytes == 0)
+            break;
+
+        md5_append(&pms, bytes, gsl::narrow_cast<int>(nBytes));
+    } while (true);
+    md5_finish(&pms, digest);
+
+    return RAFormatMD5(digest);
+}
+
diff --git a/src/RA_md5factory.h b/src/RA_md5factory.h
index f3ad59bd..b3225742 100644
--- a/src/RA_md5factory.h
+++ b/src/RA_md5factory.h
@@ -6,6 +6,8 @@ extern std::string RAGenerateMD5(const std::string& sStringToMD5);
 extern std::string RAGenerateMD5(const BYTE* pIn, size_t nLen);
 extern std::string RAGenerateMD5(const std::vector<BYTE> DataIn);
 
+extern std::string RAGenerateFileMD5(const std::wstring& sPath);
+
 extern std::string RAFormatMD5(const BYTE* digest);
 
 #endif // !RA_MD5FACTORY_H
diff --git a/src/data/context/GameAssets.cpp b/src/data/context/GameAssets.cpp
index d2604490..8bab390f 100644
--- a/src/data/context/GameAssets.cpp
+++ b/src/data/context/GameAssets.cpp
@@ -5,6 +5,8 @@
 
 #include "GameContext.hh"
 
+#include "data\models\LocalBadgesModel.hh"
+
 #include "services\ILocalStorage.hh"
 #include "services\ServiceLocator.hh"
 
@@ -97,6 +99,12 @@ void GameAssets::ReloadAssets(const std::vector<ra::data::models::AssetModelBase
                 nType = ra::data::models::AssetType::Achievement;
                 nId = pTokenizer.ReadNumber();
                 break;
+
+            case 'b': case 'B':
+                nType = ra::data::models::AssetType::LocalBadges;
+                nId = 0;
+                pTokenizer.Advance();
+                break;
         }
 
         if (nType == ra::data::models::AssetType::None)
@@ -150,6 +158,14 @@ void GameAssets::ReloadAssets(const std::vector<ra::data::models::AssetModelBase
                     pAsset = &Append(std::move(vmAchievement));
                     break;
                 }
+
+                case ra::data::models::AssetType::LocalBadges:
+                {
+                    auto* pLocalBadges = FindAsset(ra::data::models::AssetType::LocalBadges, 0);
+                    if (pLocalBadges)
+                        pLocalBadges->Deserialize(pTokenizer);
+                    break;
+                }
             }
 
             if (pAsset)
@@ -273,6 +289,13 @@ void GameAssets::SaveAssets(const std::vector<ra::data::models::AssetModelBase*>
         pData->WriteLine();
     }
 
+    const auto* pLocalBadges = dynamic_cast<ra::data::models::LocalBadgesModel*>(FindAsset(ra::data::models::AssetType::LocalBadges, 0));
+    if (pLocalBadges != nullptr && pLocalBadges->NeedsSerialized())
+    {
+        pLocalBadges->Serialize(*pData);
+        pData->WriteLine();
+    }
+
     RA_LOG_INFO("Wrote user assets file");
 }
 
diff --git a/src/data/context/GameContext.cpp b/src/data/context/GameContext.cpp
index 49432c56..3b6d6d4e 100644
--- a/src/data/context/GameContext.cpp
+++ b/src/data/context/GameContext.cpp
@@ -21,6 +21,7 @@
 #include "data\context\UserContext.hh"
 
 #include "data\models\AchievementModel.hh"
+#include "data\models\LocalBadgesModel.hh"
 
 #include "services\AchievementRuntime.hh"
 #include "services\IAudioSystem.hh"
@@ -56,9 +57,15 @@ static void CopyAchievementData(Achievement& pAchievement, ra::data::models::Ach
 
 void GameContext::LoadGame(unsigned int nGameId, Mode nMode)
 {
+    // remove the current asset from the asset editor
+    auto& vmWindowManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::WindowManager>();
+    vmWindowManager.AssetEditor.LoadAsset(nullptr);
+
+    // reset the runtime
     auto& pRuntime = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
     pRuntime.ResetRuntime();
 
+    // reset the GameContext
     m_nMode = nMode;
     m_sGameTitle.clear();
     m_bRichPresenceFromFile = false;
@@ -68,15 +75,11 @@ void GameContext::LoadGame(unsigned int nGameId, Mode nMode)
     m_vAchievements.clear();
     m_vLeaderboards.clear();
 
-    auto& vmWindowManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::WindowManager>();
-    vmWindowManager.AssetEditor.LoadAsset(nullptr);
-
-    auto& vmAssets = vmWindowManager.AssetList;
-    vmAssets.SetGameId(nGameId);
     m_vAssets.BeginUpdate();
 
     m_vAssets.Clear();
 
+    // if not loading a game, finish up and return
     if (nGameId == 0)
     {
         m_vAssets.EndUpdate();
@@ -91,9 +94,17 @@ void GameContext::LoadGame(unsigned int nGameId, Mode nMode)
         return;
     }
 
+    // start the load process
     BeginLoad();
     m_nGameId = nGameId;
 
+    // create a model for managing badges
+    auto pLocalBadges = std::make_unique<ra::data::models::LocalBadgesModel>();
+    pLocalBadges->CreateServerCheckpoint();
+    pLocalBadges->CreateLocalCheckpoint();
+    m_vAssets.Append(std::move(pLocalBadges));
+
+    // download the game data
     ra::api::FetchGameData::Request request;
     request.GameId = nGameId;
 
@@ -108,12 +119,9 @@ void GameContext::LoadGame(unsigned int nGameId, Mode nMode)
         return;
     }
 
+    // start fetching the code notes
     RefreshCodeNotes();
 
-#ifndef RA_UTEST
-    auto& pImageRepository = ra::services::ServiceLocator::GetMutable<ra::ui::IImageRepository>();
-#endif
-
     // game properties
     m_sGameTitle = response.Title;
     m_sGameImage = response.ImageIcon;
@@ -143,6 +151,10 @@ void GameContext::LoadGame(unsigned int nGameId, Mode nMode)
     const bool bWasPaused = pRuntime.IsPaused();
     pRuntime.SetPaused(true);
 
+#ifndef RA_UTEST
+    auto& pImageRepository = ra::services::ServiceLocator::GetMutable<ra::ui::IImageRepository>();
+#endif
+
     unsigned int nNumCoreAchievements = 0;
     unsigned int nTotalCoreAchievementPoints = 0;
     for (const auto& pAchievementData : response.Achievements)
@@ -188,7 +200,7 @@ void GameContext::LoadGame(unsigned int nGameId, Mode nMode)
 
     ActivateLeaderboards();
 
-    // merge local achievements
+    // merge local assets
     std::vector<ra::data::models::AssetModelBase*> vEmptyAssetsList;
     m_vAssets.ReloadAssets(vEmptyAssetsList);
 
@@ -218,6 +230,7 @@ void GameContext::LoadGame(unsigned int nGameId, Mode nMode)
     // get user unlocks asynchronously
     RefreshUnlocks(!bWasPaused, nPopup);
 
+    // finish up
     m_vAssets.EndUpdate();
 
     EndLoad();
@@ -530,11 +543,11 @@ void GameContext::AwardAchievement(ra::AchievementID nAchievementId) const
 
     int nPopupId = -1;
 
+    ra::services::ServiceLocator::Get<ra::services::IAudioSystem>().PlayAudioFile(
+        bIsError ? L"Overlay\\acherror.wav" : L"Overlay\\unlock.wav");
+
     if (pConfiguration.GetPopupLocation(ra::ui::viewmodels::Popup::AchievementTriggered) != ra::ui::viewmodels::PopupLocation::None)
     {
-        ra::services::ServiceLocator::Get<ra::services::IAudioSystem>().PlayAudioFile(
-            bIsError ? L"Overlay\\acherror.wav" : L"Overlay\\unlock.wav");
-
         auto& pOverlayManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>();
         nPopupId = pOverlayManager.QueueMessage(vmPopup);
 
diff --git a/src/data/models/AchievementModel.cpp b/src/data/models/AchievementModel.cpp
index 9f1bfdbd..0dd0ff78 100644
--- a/src/data/models/AchievementModel.cpp
+++ b/src/data/models/AchievementModel.cpp
@@ -2,6 +2,8 @@
 
 #include "data\context\GameContext.hh"
 
+#include "data\models\LocalBadgesModel.hh"
+
 #include "services\AchievementRuntime.hh"
 #include "services\IClock.hh"
 #include "services\ServiceLocator.hh"
@@ -66,6 +68,56 @@ void AchievementModel::OnValueChanged(const IntModelProperty::ChangeArgs& args)
     AssetModelBase::OnValueChanged(args);
 }
 
+void AchievementModel::OnValueChanged(const StringModelProperty::ChangeArgs& args)
+{
+    // call base first so the transaction gets updated, updating the LocalBadgesModel is just bookkeeping.
+    AssetModelBase::OnValueChanged(args);
+
+    if (args.Property == BadgeProperty && m_pTransaction && m_pTransaction->m_pNext) /* ignore until we've created a checkpoint */
+    {
+        if (ra::StringStartsWith(args.tOldValue, L"local\\"))
+        {
+            auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
+            auto* pLocalBadges = dynamic_cast<LocalBadgesModel*>(pGameContext.Assets().FindAsset(ra::data::models::AssetType::LocalBadges, 0));
+            if (pLocalBadges)
+            {
+                const bool bCommitted = (GetLocalValue(BadgeProperty) == args.tOldValue);
+                pLocalBadges->RemoveReference(args.tOldValue, bCommitted);
+            }
+        }
+
+        if (ra::StringStartsWith(args.tNewValue, L"local\\"))
+        {
+            auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
+            auto* pLocalBadges = dynamic_cast<LocalBadgesModel*>(pGameContext.Assets().FindAsset(ra::data::models::AssetType::LocalBadges, 0));
+            if (pLocalBadges)
+            {
+                const bool bCommitted = (GetLocalValue(BadgeProperty) == args.tNewValue);
+                pLocalBadges->AddReference(args.tNewValue, bCommitted);
+            }
+        }
+    }
+}
+
+void AchievementModel::CommitTransaction()
+{
+    Expects(m_pTransaction != nullptr);
+    const auto* pPreviousBadgeName = m_pTransaction->GetPreviousValue(BadgeProperty);
+    if (pPreviousBadgeName != nullptr)
+    {
+        const auto& pBadgeName = GetBadge();
+        if (*pPreviousBadgeName != pBadgeName)
+        {
+            auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
+            auto* pLocalBadges = dynamic_cast<LocalBadgesModel*>(pGameContext.Assets().FindAsset(ra::data::models::AssetType::LocalBadges, 0));
+            if (pLocalBadges)
+                pLocalBadges->Commit(*pPreviousBadgeName, pBadgeName);
+        }
+    }
+
+    AssetModelBase::CommitTransaction();
+}
+
 void AchievementModel::DoFrame()
 {
     const auto& pRuntime = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
diff --git a/src/data/models/AchievementModel.hh b/src/data/models/AchievementModel.hh
index 0de14b0b..99d160d8 100644
--- a/src/data/models/AchievementModel.hh
+++ b/src/data/models/AchievementModel.hh
@@ -103,6 +103,9 @@ public:
 
 protected:
     void OnValueChanged(const IntModelProperty::ChangeArgs& args) override;
+    void OnValueChanged(const StringModelProperty::ChangeArgs& args) override;
+
+    void CommitTransaction() override;
 
 private:
     AssetDefinition m_pTrigger;
diff --git a/src/data/models/AssetModelBase.hh b/src/data/models/AssetModelBase.hh
index 72983268..2b55fd85 100644
--- a/src/data/models/AssetModelBase.hh
+++ b/src/data/models/AssetModelBase.hh
@@ -15,9 +15,10 @@ namespace models {
 
 enum class AssetType
 {
-	None = 0,
-	Achievement,
-	Leaderboard,
+    None = 0,
+    Achievement,
+    Leaderboard,
+    LocalBadges,
 };
 
 enum class AssetCategory
diff --git a/src/data/models/LocalBadgesModel.cpp b/src/data/models/LocalBadgesModel.cpp
new file mode 100644
index 00000000..fa5da4a9
--- /dev/null
+++ b/src/data/models/LocalBadgesModel.cpp
@@ -0,0 +1,174 @@
+#include "LocalBadgesModel.hh"
+
+#include "data\context\GameContext.hh"
+
+#include "services\IFileSystem.hh"
+#include "services\ServiceLocator.hh"
+
+namespace ra {
+namespace data {
+namespace models {
+
+LocalBadgesModel::LocalBadgesModel() noexcept
+{
+    GSL_SUPPRESS_F6 SetValue(TypeProperty, ra::etoi(AssetType::LocalBadges));
+    GSL_SUPPRESS_F6 SetName(L"Local Badges");
+}
+
+LocalBadgesModel::~LocalBadgesModel()
+{
+    if (ra::services::ServiceLocator::Exists<ra::services::IFileSystem>())
+    {
+        GSL_SUPPRESS_F6 DeleteUncommittedBadges();
+    }
+}
+
+void LocalBadgesModel::DeleteUncommittedBadges()
+{
+    std::vector<std::wstring> vToDelete;
+
+    for (const auto& pIter : m_mReferences)
+    {
+        if (pIter.second.nCommittedCount == 0)
+            vToDelete.push_back(pIter.first);
+    }
+
+    if (!vToDelete.empty())
+    {
+        const auto& pFileSystem = ra::services::ServiceLocator::Get<ra::services::IFileSystem>();
+        for (const auto& pFile : vToDelete)
+            pFileSystem.DeleteFile(pFile);
+    }
+}
+
+void LocalBadgesModel::AddReference(const std::wstring& sBadgeName, bool bCommitted)
+{
+    const auto pIter = m_mReferences.find(sBadgeName);
+    if (pIter != m_mReferences.end())
+    {
+        if (bCommitted)
+            ++pIter->second.nCommittedCount;
+        else
+            ++pIter->second.nUncommittedCount;
+    }
+    else
+    {
+        // a committed entry should exist in m_mReferences from the Deserialize call
+        // or from a previously uncommitted entry
+        Expects(!bCommitted); 
+
+        BadgeReferenceCount pCount;
+        pCount.nCommittedCount = 0;
+        pCount.nUncommittedCount = 1;
+
+        m_mReferences.insert_or_assign(sBadgeName, pCount);
+    }
+}
+
+void LocalBadgesModel::RemoveReference(const std::wstring& sBadgeName, bool bCommitted)
+{
+    const auto pIter = m_mReferences.find(sBadgeName);
+    if (pIter != m_mReferences.end())
+    {
+        if (bCommitted)
+        {
+            if (pIter->second.nCommittedCount > 0)
+                --pIter->second.nCommittedCount;
+        }
+        else
+        {
+            if (pIter->second.nUncommittedCount > 0)
+                --pIter->second.nUncommittedCount;
+        }
+    }
+}
+
+int LocalBadgesModel::GetReferenceCount(const std::wstring& sBadgeName) const
+{
+    const auto pIter = m_mReferences.find(sBadgeName);
+    if (pIter != m_mReferences.end())
+        return pIter->second.nCommittedCount + pIter->second.nUncommittedCount;
+
+    return 0;
+}
+
+void LocalBadgesModel::Commit(const std::wstring& sPreviousBadgeName, const std::wstring& sNewBadgeName)
+{
+    auto pIter = m_mReferences.find(sPreviousBadgeName);
+    if (pIter != m_mReferences.end())
+    {
+        Expects(pIter->second.nCommittedCount > 0);
+        if (--pIter->second.nCommittedCount == 0 && pIter->second.nUncommittedCount == 0)
+        {
+            const auto& pFileSystem = ra::services::ServiceLocator::Get<ra::services::IFileSystem>();
+            pFileSystem.DeleteFile(pIter->first);
+            m_mReferences.erase(pIter);
+        }
+    }
+
+    pIter = m_mReferences.find(sNewBadgeName);
+    if (pIter != m_mReferences.end())
+    {
+        Expects(pIter->second.nUncommittedCount > 0);
+        --pIter->second.nUncommittedCount;
+        ++pIter->second.nCommittedCount;
+    }
+}
+
+bool LocalBadgesModel::NeedsSerialized() const noexcept
+{
+    // ASSERT: NeedsSerialized and Serialize are called after each asset has had a chance to call Commit
+    for (const auto& pIter : m_mReferences)
+    {
+        if (pIter.second.nCommittedCount != 0)
+            return true;
+    }
+
+    return false;
+}
+
+void LocalBadgesModel::Serialize(ra::services::TextWriter& pWriter) const
+{
+    pWriter.Write("b");
+    for (const auto& pIter : m_mReferences)
+    {
+        if (pIter.second.nCommittedCount > 0)
+        {
+            const auto nIndex = pIter.first.find('-');
+            pWriter.Write(":");
+            pWriter.Write(pIter.first.substr(nIndex + 1));
+            pWriter.Write("=");
+            pWriter.Write(std::to_string(pIter.second.nCommittedCount));
+        }
+    }
+}
+
+bool LocalBadgesModel::Deserialize(ra::Tokenizer& pTokenizer)
+{
+    const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
+    const auto sPrefix = ra::StringPrintf(L"local\\%u-", pGameContext.GameId());
+
+    m_mReferences.clear();
+
+    do
+    {
+        const auto sFilename = pTokenizer.ReadTo('=');
+        pTokenizer.Advance();
+
+        const auto nCount = pTokenizer.ReadNumber();
+        if (nCount > 0)
+        {
+            BadgeReferenceCount pCount;
+            pCount.nCommittedCount = nCount;
+            pCount.nUncommittedCount = 0;
+
+            m_mReferences.insert_or_assign(sPrefix + ra::Widen(sFilename), pCount);
+        }
+    } while (pTokenizer.Consume(':'));
+
+    return true;
+}
+
+} // namespace models
+} // namespace data
+} // namespace ra
diff --git a/src/data/models/LocalBadgesModel.hh b/src/data/models/LocalBadgesModel.hh
new file mode 100644
index 00000000..3fd5a418
--- /dev/null
+++ b/src/data/models/LocalBadgesModel.hh
@@ -0,0 +1,47 @@
+#ifndef RA_DATA_LOCAL_BADGES_MODEL_H
+#define RA_DATA_LOCAL_BADGES_MODEL_H
+#pragma once
+
+#include "AssetModelBase.hh"
+
+namespace ra {
+namespace data {
+namespace models {
+
+class LocalBadgesModel : public AssetModelBase
+{
+public:
+	LocalBadgesModel() noexcept;
+	~LocalBadgesModel();
+	LocalBadgesModel(const LocalBadgesModel&) noexcept = delete;
+	LocalBadgesModel& operator=(const LocalBadgesModel&) noexcept = delete;
+	LocalBadgesModel(LocalBadgesModel&&) noexcept = delete;
+	LocalBadgesModel& operator=(LocalBadgesModel&&) noexcept = delete;
+
+	void AddReference(const std::wstring& sBadgeName, bool bCommitted);
+	void RemoveReference(const std::wstring& sBadgeName, bool bCommitted);
+
+	void Commit(const std::wstring& sPreviousBadgeName, const std::wstring& sNewBadgeName);
+	bool NeedsSerialized() const noexcept;
+
+	void Serialize(ra::services::TextWriter& pWriter) const override;
+	bool Deserialize(ra::Tokenizer& pTokenizer) override;
+
+	int GetReferenceCount(const std::wstring& sBadgeName) const;
+	void DeleteUncommittedBadges();
+
+private:
+	struct BadgeReferenceCount
+	{
+		int nUncommittedCount;
+		int nCommittedCount;
+	};
+
+	mutable std::map<std::wstring, BadgeReferenceCount> m_mReferences;
+};
+
+} // namespace models
+} // namespace data
+} // namespace ra
+
+#endif RA_DATA_LOCAL_BADGES_MODEL_H
diff --git a/src/services/IFileSystem.hh b/src/services/IFileSystem.hh
index c4fc8ca2..734773e2 100644
--- a/src/services/IFileSystem.hh
+++ b/src/services/IFileSystem.hh
@@ -5,8 +5,11 @@
 #include "services/TextReader.hh"
 #include "services/TextWriter.hh"
 
+#undef CreateDirectory
 #undef DeleteFile
 #undef MoveFile
+#undef CopyFile
+
 namespace ra {
 namespace services {
 
@@ -68,6 +71,12 @@ public:
     /// <returns><c>true</c> if successful, <c>false</c> if not.</returns>
     virtual bool MoveFile(const std::wstring& sOldPath, const std::wstring& sNewPath) const = 0;
 
+    /// <summary>
+    /// Copies a file from one location to another.
+    /// </summary>
+    /// <returns><c>true</c> if successful, <c>false</c> if not.</returns>
+    virtual bool CopyFile(const std::wstring& sSourcePath, const std::wstring& sNewPath) const = 0;
+
     /// <summary>
     /// Opens the specified file.
     /// </summary>
diff --git a/src/services/impl/WindowsFileSystem.cpp b/src/services/impl/WindowsFileSystem.cpp
index 65046bab..7e9db36a 100644
--- a/src/services/impl/WindowsFileSystem.cpp
+++ b/src/services/impl/WindowsFileSystem.cpp
@@ -108,6 +108,14 @@ bool WindowsFileSystem::MoveFile(const std::wstring& sOldPath, const std::wstrin
     return (MoveFileW(sAbsolutePathOld.c_str(), sAbsolutePathNew.c_str()) != 0);
 }
 
+bool WindowsFileSystem::CopyFile(const std::wstring& sOldPath, const std::wstring& sNewPath) const noexcept
+{
+    std::wstring sBufferNew, sBufferOld;
+    const auto& sAbsolutePathNew = MakeAbsolute(sBufferNew, sNewPath);
+    const auto& sAbsolutePathOld = MakeAbsolute(sBufferOld, sOldPath);
+    return (CopyFileW(sAbsolutePathOld.c_str(), sAbsolutePathNew.c_str(), FALSE) != 0);
+}
+
 int64_t WindowsFileSystem::GetFileSize(const std::wstring& sPath) const
 {
     std::wstring sBuffer;
diff --git a/src/services/impl/WindowsFileSystem.hh b/src/services/impl/WindowsFileSystem.hh
index 2f09585e..d9a5d807 100644
--- a/src/services/impl/WindowsFileSystem.hh
+++ b/src/services/impl/WindowsFileSystem.hh
@@ -13,6 +13,7 @@ namespace impl {
 #undef CreateDirectory
 #undef DeleteFile
 #undef MoveFile
+#undef CopyFile
 
 class WindowsFileSystem : public IFileSystem
 {
@@ -26,6 +27,7 @@ public:
                                _Inout_ std::vector<std::wstring>& vResults) const override;
     bool DeleteFile(const std::wstring& sPath) const noexcept override;
     bool MoveFile(const std::wstring& sOldPath, const std::wstring& sNewPath) const noexcept override;
+    bool CopyFile(const std::wstring& sSourcePath, const std::wstring& sNewPath) const noexcept override;
     int64_t GetFileSize(const std::wstring& sPath) const override;
     std::chrono::system_clock::time_point GetLastModified(const std::wstring& sPath) const override;
     std::unique_ptr<TextReader> OpenTextFile(const std::wstring& sPath) const override;
diff --git a/src/ui/ImageReference.hh b/src/ui/ImageReference.hh
index ca953be7..f890cb21 100644
--- a/src/ui/ImageReference.hh
+++ b/src/ui/ImageReference.hh
@@ -49,6 +49,14 @@ public:
     /// <param name="sName">Name of the image.</param>
     virtual void FetchImage(ImageType nType, const std::string& sName) = 0;
 
+    /// <summary>
+    /// Store an image locally.
+    /// </summary>
+    /// <param name="nType">Type of the image.</param>
+    /// <param name="sName">Path to image.</param>
+    /// <returns>Generated name for referencing the image.</param>
+    virtual std::string StoreImage(ImageType nType, const std::wstring& sPath) = 0;
+
     /// <summary>Adds a reference to an image.</summary>
     virtual void AddReference(const ImageReference& pImage) = 0;
 
diff --git a/src/ui/drawing/gdi/ImageRepository.cpp b/src/ui/drawing/gdi/ImageRepository.cpp
index 293750df..71b61b6b 100644
--- a/src/ui/drawing/gdi/ImageRepository.cpp
+++ b/src/ui/drawing/gdi/ImageRepository.cpp
@@ -3,6 +3,9 @@
 #include "GDIBitmapSurface.hh"
 
 #include "RA_Defs.h"
+#include "RA_md5factory.h"
+
+#include "data\context\GameContext.hh"
 
 #include "services\Http.hh"
 #include "services\IConfiguration.hh"
@@ -198,6 +201,48 @@ void ImageRepository::FetchImage(ImageType nType, const std::string& sName)
     });
 }
 
+std::string ImageRepository::StoreImage(ImageType nType, const std::wstring& sPath)
+{
+    auto sFileMD5 = RAGenerateFileMD5(sPath);
+    if (sFileMD5.empty())
+        return "";
+
+    const auto& pFileSystem = ra::services::ServiceLocator::Get<ra::services::IFileSystem>();
+    const auto sDirectory = pFileSystem.BaseDirectory() + RA_DIR_BADGE + L"local\\";
+    if (!pFileSystem.DirectoryExists(sDirectory))
+        pFileSystem.CreateDirectory(sDirectory);
+
+    const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
+    sFileMD5 = ra::StringPrintf("local\\%u-%s", pGameContext.GameId(), sFileMD5);
+
+    std::wstring sFilename = GetFilename(nType, sFileMD5);
+
+    // sFilename will always be a .png - switch to correct extension
+    const auto nIndex = sPath.rfind('.');
+    auto sExtension = sPath.substr(nIndex);
+    std::transform(sExtension.begin(), sExtension.end(), sExtension.begin(), [](wchar_t c) noexcept
+    {
+        return gsl::narrow_cast<wchar_t>(std::tolower(c));
+    });
+    if (sExtension != L".png")
+    {
+        sFilename.pop_back();
+        sFilename.pop_back();
+        sFilename.pop_back();
+        sFilename.pop_back();
+        sFilename.append(sExtension);
+        sFileMD5.append(ra::Narrow(sExtension));
+    }
+
+    if (pFileSystem.GetFileSize(sFilename) < 0)
+    {
+        if (!pFileSystem.CopyFile(sPath, sFilename))
+            return "";
+    }
+
+    return sFileMD5;
+}
+
 HBITMAP ImageRepository::GetDefaultImage(ImageType nType)
 {
     switch (nType)
@@ -412,6 +457,35 @@ ImageRepository::HBitmapMap* ImageRepository::GetBitmapMap(ImageType nType) noex
     }
 }
 
+static void RemoveRedundantFileExtension(std::wstring& sFilename, const std::string& sName)
+{
+    const auto nIndex = sName.rfind('.');
+    if (nIndex == std::string::npos)
+        return;
+
+    auto sExtension = sName.substr(nIndex);
+    std::transform(sExtension.begin(), sExtension.end(), sExtension.begin(), [](wchar_t c) noexcept
+    {
+        return gsl::narrow_cast<char>(std::tolower(c));
+    });
+
+    if (sExtension == ".jpg" || sExtension == ".gif")
+    {
+        sFilename.pop_back();
+        sFilename.pop_back();
+        sFilename.pop_back();
+        sFilename.pop_back();
+    }
+    else if (sExtension == ".jpeg")
+    {
+        sFilename.pop_back();
+        sFilename.pop_back();
+        sFilename.pop_back();
+        sFilename.pop_back();
+        sFilename.pop_back();
+    }
+}
+
 HBITMAP ImageRepository::GetImage(ImageType nType, const std::string& sName)
 {
     if (sName.empty())
@@ -431,6 +505,9 @@ HBITMAP ImageRepository::GetImage(ImageType nType, const std::string& sName)
 
     std::wstring sFilename = GetFilename(nType, sName);
 
+    // GetFilename automatically appends a ".png". if sName contains a supported file format extension, remove the ".png"
+    RemoveRedundantFileExtension(sFilename, sName);
+
     const auto& pFileSystem = ra::services::ServiceLocator::Get<ra::services::IFileSystem>();
     if (pFileSystem.GetFileSize(sFilename) <= 0)
     {
diff --git a/src/ui/drawing/gdi/ImageRepository.hh b/src/ui/drawing/gdi/ImageRepository.hh
index 98ab71d0..b63d652c 100644
--- a/src/ui/drawing/gdi/ImageRepository.hh
+++ b/src/ui/drawing/gdi/ImageRepository.hh
@@ -40,6 +40,7 @@ public:
     bool IsImageAvailable(ImageType nType, const std::string& sName) const override;
 
     void FetchImage(ImageType nType, const std::string& sName) override;
+    std::string StoreImage(ImageType nType, const std::wstring& sPath) override;
 
     void AddReference(const ImageReference& pImage) override;
     GSL_SUPPRESS_F6 void ReleaseReference(ImageReference& pImage) noexcept override;
diff --git a/src/ui/viewmodels/AssetEditorViewModel.cpp b/src/ui/viewmodels/AssetEditorViewModel.cpp
index 54a91ea6..f9660dff 100644
--- a/src/ui/viewmodels/AssetEditorViewModel.cpp
+++ b/src/ui/viewmodels/AssetEditorViewModel.cpp
@@ -4,6 +4,8 @@
 #include "services\IConfiguration.hh"
 #include "services\ServiceLocator.hh"
 
+#include "ui\ImageReference.hh"
+#include "ui\viewmodels\FileDialogViewModel.hh"
 #include "ui\viewmodels\MessageBoxViewModel.hh"
 
 namespace ra {
@@ -40,7 +42,21 @@ AssetEditorViewModel::~AssetEditorViewModel()
 
 void AssetEditorViewModel::SelectBadgeFile()
 {
-    ra::ui::viewmodels::MessageBoxViewModel::ShowWarningMessage(L"Not implemented");
+    ui::viewmodels::FileDialogViewModel vmFile;
+    vmFile.AddFileType(L"Image Files", L"*.png;*.gif;*.jpg;*.jpeg");
+    vmFile.SetDefaultExtension(L"png");
+    if (vmFile.ShowOpenFileDialog(*this) != DialogResult::OK)
+        return;
+
+    auto& pImageRepository = ra::services::ServiceLocator::GetMutable<ra::ui::IImageRepository>();
+    const auto sBadgeName = pImageRepository.StoreImage(ImageType::Badge, vmFile.GetFileName());
+    if (sBadgeName.empty())
+    {
+        ui::viewmodels::MessageBoxViewModel::ShowErrorMessage(ra::StringPrintf(L"Error processing %s", vmFile.GetFileName()));
+        return;
+    }
+
+    SetBadge(ra::Widen(sBadgeName));
 }
 
 void AssetEditorViewModel::LoadAsset(ra::data::models::AssetModelBase* pAsset)
diff --git a/src/ui/viewmodels/AssetListViewModel.cpp b/src/ui/viewmodels/AssetListViewModel.cpp
index 9cd73ffe..ebd220f7 100644
--- a/src/ui/viewmodels/AssetListViewModel.cpp
+++ b/src/ui/viewmodels/AssetListViewModel.cpp
@@ -289,6 +289,9 @@ bool AssetListViewModel::MatchesFilter(const ra::data::models::AssetModelBase& p
     if (pAsset.GetCategory() != GetFilterCategory())
         return false;
 
+    if (pAsset.GetType() == ra::data::models::AssetType::LocalBadges)
+        return false;
+
     return true;
 }
 
diff --git a/src/ui/win32/AssetEditorDialog.cpp b/src/ui/win32/AssetEditorDialog.cpp
index 799bcffa..e54429bb 100644
--- a/src/ui/win32/AssetEditorDialog.cpp
+++ b/src/ui/win32/AssetEditorDialog.cpp
@@ -236,6 +236,14 @@ void AssetEditorDialog::BadgeNameBinding::UpdateSourceFromText(const std::wstrin
     }
 }
 
+void AssetEditorDialog::BadgeNameBinding::UpdateTextFromSource(const std::wstring& sText) noexcept
+{
+    if (ra::StringStartsWith(sText, L"local\\"))
+        SetWindowTextW(m_hWnd, L"[local]");
+    else
+        SetWindowTextW(m_hWnd, sText.c_str());
+}
+
 void AssetEditorDialog::DecimalPreferredBinding::OnValueChanged()
 {
     m_pOwner->m_bindConditions.RefreshColumn(4); // source value
@@ -442,6 +450,15 @@ BOOL AssetEditorDialog::OnCommand(WORD nCommand)
 
             return TRUE;
         }
+
+        case IDC_RA_UPLOAD_BADGE:
+        {
+            auto* vmAssetEditor = dynamic_cast<AssetEditorViewModel*>(&m_vmWindow);
+            if (vmAssetEditor)
+                vmAssetEditor->SelectBadgeFile();
+
+            return TRUE;
+        }
     }
 
     return DialogBase::OnCommand(nCommand);
diff --git a/src/ui/win32/AssetEditorDialog.hh b/src/ui/win32/AssetEditorDialog.hh
index fce40646..45442a2f 100644
--- a/src/ui/win32/AssetEditorDialog.hh
+++ b/src/ui/win32/AssetEditorDialog.hh
@@ -52,6 +52,7 @@ private:
 
     protected:
         void UpdateSourceFromText(const std::wstring& sText) override;
+        void UpdateTextFromSource(const std::wstring& sText) noexcept override;
     };
 
     class DecimalPreferredBinding : public ra::ui::win32::bindings::CheckBoxBinding
diff --git a/src/ui/win32/bindings/TextBoxBinding.hh b/src/ui/win32/bindings/TextBoxBinding.hh
index b5237515..dd3ff3b1 100644
--- a/src/ui/win32/bindings/TextBoxBinding.hh
+++ b/src/ui/win32/bindings/TextBoxBinding.hh
@@ -100,7 +100,12 @@ protected:
     virtual void UpdateBoundText()
     {
         if (m_hWnd && m_pTextBoundProperty)
-            SetWindowTextW(m_hWnd, GetValue(*m_pTextBoundProperty).c_str());
+            UpdateTextFromSource(GetValue(*m_pTextBoundProperty));
+    }
+
+    virtual void UpdateTextFromSource(const std::wstring& sText) noexcept
+    {
+        SetWindowTextW(m_hWnd, sText.c_str());
     }
 
     virtual void UpdateSourceFromText(const std::wstring& sText)


Test patch:
diff --git a/tests/RA_Integration.Tests.vcxproj b/tests/RA_Integration.Tests.vcxproj
index e4ead563..4c71160b 100644
--- a/tests/RA_Integration.Tests.vcxproj
+++ b/tests/RA_Integration.Tests.vcxproj
@@ -298,6 +298,7 @@
     <ClCompile Include="..\src\data\DataModelBase.cpp" />
     <ClCompile Include="..\src\data\models\AchievementModel.cpp" />
     <ClCompile Include="..\src\data\models\AssetModelBase.cpp" />
+    <ClCompile Include="..\src\data\models\LocalBadgesModel.cpp" />
     <ClCompile Include="..\src\pch.cpp">
       <PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">Create</PrecompiledHeader>
       <PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">Create</PrecompiledHeader>
@@ -366,6 +367,7 @@
     <ClCompile Include="data\ModelProperty_Tests.cpp" />
     <ClCompile Include="data\models\AchievementModel_Tests.cpp" />
     <ClCompile Include="data\models\AssetModelBase_Tests.cpp" />
+    <ClCompile Include="data\models\LocalBadgesModel_Tests.cpp" />
     <ClCompile Include="Exports_Tests.cpp" />
     <ClCompile Include="services\AchievementRuntime_Tests.cpp" />
     <ClCompile Include="services\FileLocalStorage_Tests.cpp" />
diff --git a/tests/RA_Integration.Tests.vcxproj.filters b/tests/RA_Integration.Tests.vcxproj.filters
index a54c5317..3815694a 100644
--- a/tests/RA_Integration.Tests.vcxproj.filters
+++ b/tests/RA_Integration.Tests.vcxproj.filters
@@ -411,6 +411,12 @@
     <ClCompile Include="data\context\GameAssets_Tests.cpp">
       <Filter>Tests\Data\Context</Filter>
     </ClCompile>
+    <ClCompile Include="data\models\LocalBadgesModel_Tests.cpp">
+      <Filter>Tests\Data\Models</Filter>
+    </ClCompile>
+    <ClCompile Include="..\src\data\models\LocalBadgesModel.cpp">
+      <Filter>Code</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <None Include="base.props" />
diff --git a/tests/data/context/GameAssets_Tests.cpp b/tests/data/context/GameAssets_Tests.cpp
index 2a25d73c..0dcc03f4 100644
--- a/tests/data/context/GameAssets_Tests.cpp
+++ b/tests/data/context/GameAssets_Tests.cpp
@@ -3,6 +3,7 @@
 #include "data\context\GameAssets.hh"
 
 #include "data\models\AchievementModel.hh"
+#include "data\models\LocalBadgesModel.hh"
 
 #include "tests\RA_UnitTestHelpers.h"
 #include "tests\data\DataAsserts.hh"
@@ -119,6 +120,19 @@ public:
         {
             return mockLocalStorage.GetStoredData(ra::services::StorageItemType::UserAchievements, std::to_wstring(mockGameContext.GameId()));
         }
+
+        void AddLocalBadgesModel()
+        {
+            auto pLocalBadges = std::make_unique<ra::data::models::LocalBadgesModel>();
+            pLocalBadges->CreateServerCheckpoint();
+            pLocalBadges->CreateLocalCheckpoint();
+            Append(std::move(pLocalBadges));
+        }
+
+        ra::data::models::LocalBadgesModel& GetLocalBadgesModel()
+        {
+            return *(dynamic_cast<ra::data::models::LocalBadgesModel*>(FindAsset(ra::data::models::AssetType::LocalBadges, 0)));
+        }
     };
 
     TEST_METHOD(TestSaveLocalEmpty)
@@ -153,6 +167,31 @@ public:
         Assert::AreEqual(sExpected, gameAssets.GetUserFile());
     }
 
+    TEST_METHOD(TestSaveLocalBadges)
+    {
+        GameAssetsHarness gameAssets;
+        gameAssets.mockGameContext.SetGameId(22);
+        gameAssets.AddLocalBadgesModel();
+        gameAssets.AddNewAchievement(10, L"Temp", L"Temp", L"local\\22-ABCDE.png", "1=1");
+
+        // gameAssets is not the same instance as mockGameContext.Assets, so manually update the badge reference count
+        auto& pLocalBadges = gameAssets.GetLocalBadgesModel();
+        pLocalBadges.AddReference(L"local\\22-ABCDE.png", false);
+
+        // simulate the pre-commit of the achievement by converting the reference from uncommitted to committed
+        pLocalBadges.RemoveReference(L"local\\22-ABCDE.png", false);
+        pLocalBadges.AddReference(L"local\\22-ABCDE.png", true);
+
+        gameAssets.SaveAllAssets();
+
+        gameAssets.ReloadAsset(AssetType::Achievement, GameAssets::FirstLocalId);
+
+        const auto& sExpected = ra::StringPrintf("0.0.0.0\nGameName\n"
+            "%u:\"1=1\":Temp:Temp::::Authl:10:::::\"local\\\\22-ABCDE.png\"\n"
+            "b:ABCDE.png=1\n", GameAssets::FirstLocalId);
+        Assert::AreEqual(sExpected, gameAssets.GetUserFile());
+    }
+
     TEST_METHOD(TestMergeLocalAssetsNoFile)
     {
         GameAssetsHarness gameAssets;
@@ -303,6 +342,26 @@ public:
         Assert::AreEqual(AssetChanges::Unpublished, pAsset2->GetChanges());
     }
 
+    TEST_METHOD(TestMergeLocalAssetsLocalBadges)
+    {
+        GameAssetsHarness gameAssets;
+        gameAssets.mockGameContext.SetGameId(22);
+        gameAssets.AddLocalBadgesModel();
+        gameAssets.MockUserFileContents(
+            "111000001:\"0xH2345=0\":Test2:::::User:0:0:0:::local\\22-A.png\n"
+            "b:A.png=1\n");
+
+        gameAssets.ReloadAllAssets();
+
+        const auto* pAsset = gameAssets.FindAchievement({ 111000001U });
+        Assert::IsNotNull(pAsset);
+        Ensures(pAsset != nullptr);
+        Assert::AreEqual(std::wstring(L"local\\22-A.png"), pAsset->GetBadge());
+
+        const auto& pLocalBadges = gameAssets.GetLocalBadgesModel();
+        Assert::AreEqual(1, pLocalBadges.GetReferenceCount(L"local\\22-A.png"));
+    }
+
     TEST_METHOD(TestReloadModifiedFromFile)
     {
         GameAssetsHarness gameAssets;
diff --git a/tests/data/context/GameContext_Tests.cpp b/tests/data/context/GameContext_Tests.cpp
index 1e45cf17..0fba699c 100644
--- a/tests/data/context/GameContext_Tests.cpp
+++ b/tests/data/context/GameContext_Tests.cpp
@@ -99,6 +99,16 @@ public:
             return pIter->second.Bytes;
         }
 
+        void RemoveNonAchievementAssets()
+        {
+            for (gsl::index nIndex = Assets().Count() - 1; nIndex >= 0; nIndex--)
+            {
+                auto* pAsset = Assets().GetItemAt(nIndex);
+                if (!pAsset || pAsset->GetType() != ra::data::models::AssetType::Achievement)
+                    Assets().RemoveAt(nIndex);
+            }
+        }
+
     private:
         ra::services::ServiceLocator::ServiceOverride<ra::data::context::GameContext> m_OverrideGameContext;
         ra::services::ServiceLocator::ServiceOverride<ra::services::AchievementRuntime> m_OverrideRuntime;
@@ -326,6 +336,7 @@ public:
         Assert::AreEqual(15U, pAch2->Points());
         Assert::AreEqual(std::string("1=1"), pAch2->GetTrigger());
 
+        game.RemoveNonAchievementAssets();
         Assert::AreEqual({ 2U }, game.Assets().Count());
 
         const auto* vmAch1 = dynamic_cast<ra::data::models::AchievementModel*>(game.Assets().GetItemAt(0));
@@ -402,6 +413,7 @@ public:
         Assert::AreEqual(15U, pAch2->Points());
         Assert::AreEqual(std::string("1=1"), pAch2->GetTrigger());
 
+        game.RemoveNonAchievementAssets();
         Assert::AreEqual({ 1U }, game.Assets().Count());
 
         const auto* vmAch2 = dynamic_cast<ra::data::models::AchievementModel*>(game.Assets().GetItemAt(0));
@@ -558,7 +570,7 @@ public:
         Assert::AreEqual(std::string("1=1"), pAch->GetTrigger());
 
 
-
+        game.RemoveNonAchievementAssets();
         Assert::AreEqual({ 3U }, game.Assets().Count());
 
         // 7 is not a known ID for this game, it should be loaded into a local achievement
@@ -791,6 +803,7 @@ public:
         Assert::AreEqual(11U, pAch2->Points());
         Assert::AreEqual(std::string("1=1"), pAch2->GetTrigger());
 
+        game.RemoveNonAchievementAssets();
         Assert::AreEqual({ 2U }, game.Assets().Count());
         Assert::IsFalse(game.Assets().IsUpdating());
 
@@ -852,6 +865,7 @@ public:
 
         game.LoadGame(1U);
 
+        game.RemoveNonAchievementAssets();
         Assert::AreEqual({ 2U }, game.Assets().Count());
 
         game.LoadGame(0U);
@@ -1153,6 +1167,37 @@ public:
         Assert::AreEqual(125U, game.mockUser.GetScore());
     }
 
+    TEST_METHOD(TestAwardAchievementNoPopup)
+    {
+        GameContextHarness game;
+        game.mockConfiguration.SetPopupLocation(ra::ui::viewmodels::Popup::AchievementTriggered, ra::ui::viewmodels::PopupLocation::None);
+        game.SetGameHash("hash");
+        game.mockServer.HandleRequest<ra::api::AwardAchievement>([](const ra::api::AwardAchievement::Request& request, ra::api::AwardAchievement::Response& response)
+        {
+            Assert::AreEqual(1U, request.AchievementId);
+            Assert::AreEqual(false, request.Hardcore);
+            Assert::AreEqual(std::string("hash"), request.GameHash);
+
+            response.NewPlayerScore = 125U;
+            response.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        game.MockAchievement();
+        game.AwardAchievement(1U);
+
+        // sound should still be played
+        Assert::IsTrue(game.mockAudioSystem.WasAudioFilePlayed(L"Overlay\\unlock.wav"));
+
+        // popup should not be displayed
+        const auto* pPopup = game.mockOverlayManager.GetMessage(1);
+        Assert::IsNull(pPopup);
+
+        // API call should still occur
+        game.mockThreadPool.ExecuteNextTask();
+        Assert::AreEqual(125U, game.mockUser.GetScore());
+    }
+
     TEST_METHOD(TestAwardAchievementLocal)
     {
         GameContextHarness game;
diff --git a/tests/data/models/AchievementModel_Tests.cpp b/tests/data/models/AchievementModel_Tests.cpp
index 80e9abb0..4b5ff98a 100644
--- a/tests/data/models/AchievementModel_Tests.cpp
+++ b/tests/data/models/AchievementModel_Tests.cpp
@@ -1,6 +1,7 @@
 #include "CppUnitTest.h"
 
 #include "data\models\AchievementModel.hh"
+#include "data\models\LocalBadgesModel.hh"
 
 #include "services\impl\StringTextWriter.hh"
 
@@ -9,6 +10,7 @@
 
 #include "tests\mocks\MockAchievementRuntime.hh"
 #include "tests\mocks\MockClock.hh"
+#include "tests\mocks\MockGameContext.hh"
 
 using namespace Microsoft::VisualStudio::CppUnitTestFramework;
 
@@ -23,9 +25,23 @@ private:
     class AchievementModelHarness : public AchievementModel
     {
     public:
+        ra::data::context::mocks::MockGameContext mockGameContext;
         ra::services::impl::StringTextWriter textWriter;
         ra::services::mocks::MockAchievementRuntime mockRuntime;
         ra::services::mocks::MockClock mockClock;
+
+        void AddLocalBadgesModel()
+        {
+            auto pLocalBadges = std::make_unique<LocalBadgesModel>();
+            pLocalBadges->CreateServerCheckpoint();
+            pLocalBadges->CreateLocalCheckpoint();
+            mockGameContext.Assets().Append(std::move(pLocalBadges));
+        }
+
+        ra::data::models::LocalBadgesModel& GetLocalBadgesModel()
+        {
+            return *(dynamic_cast<LocalBadgesModel*>(mockGameContext.Assets().FindAsset(AssetType::LocalBadges, 0)));
+        }
     };
 
 public:
@@ -156,6 +172,55 @@ public:
         Assert::AreEqual((int)RC_TRIGGER_STATE_WAITING, (int)pTrigger->state);
         Assert::AreNotEqual(std::chrono::system_clock::duration::zero().count(), achievement.GetUnlockTime().time_since_epoch().count());
     }
+
+    TEST_METHOD(TestSetBadgeNameLocal)
+    {
+        AchievementModelHarness achievement;
+        achievement.mockGameContext.SetGameId(22);
+        achievement.AddLocalBadgesModel();
+        achievement.CreateServerCheckpoint();
+        achievement.CreateLocalCheckpoint();
+
+        achievement.SetID(1U);
+        achievement.SetTrigger("0xH1234=1");
+        achievement.SetBadge(L"local\\22-ABC.png");
+
+        const auto& pLocalBadges = achievement.GetLocalBadgesModel();
+        Assert::AreEqual(1, pLocalBadges.GetReferenceCount(L"local\\22-ABC.png"));
+        Assert::IsFalse(pLocalBadges.NeedsSerialized());
+
+        // this sets the committed badge reference so it'll show up in the serialized string
+        achievement.UpdateLocalCheckpoint();
+
+        Assert::IsTrue(pLocalBadges.NeedsSerialized());
+        pLocalBadges.Serialize(achievement.textWriter);
+        Assert::AreEqual(std::string("b:ABC.png=1"), achievement.textWriter.GetString());
+    }
+
+    TEST_METHOD(TestSetBadgeNameAndReset)
+    {
+        AchievementModelHarness achievement;
+        achievement.mockGameContext.SetGameId(22);
+        achievement.AddLocalBadgesModel();
+        achievement.SetBadge(L"12345.png");
+        achievement.CreateServerCheckpoint();
+        achievement.CreateLocalCheckpoint();
+
+        achievement.SetID(1U);
+        achievement.SetTrigger("0xH1234=1");
+        achievement.SetBadge(L"local\\22-ABC.png");
+
+        const auto& pLocalBadges = achievement.GetLocalBadgesModel();
+        Assert::AreEqual(1, pLocalBadges.GetReferenceCount(L"local\\22-ABC.png"));
+        Assert::IsFalse(pLocalBadges.NeedsSerialized());
+
+        achievement.SetBadge(L"12345.png");
+
+        // the local badge is no longer referenced, so shouldn't be serialized
+        achievement.UpdateLocalCheckpoint();
+
+        Assert::IsFalse(pLocalBadges.NeedsSerialized());
+    }
 };
 
 
diff --git a/tests/data/models/LocalBadgesModel_Tests.cpp b/tests/data/models/LocalBadgesModel_Tests.cpp
new file mode 100644
index 00000000..062d52a2
--- /dev/null
+++ b/tests/data/models/LocalBadgesModel_Tests.cpp
@@ -0,0 +1,262 @@
+#include "CppUnitTest.h"
+
+#include "data\models\LocalBadgesModel.hh"
+
+#include "services\impl\StringTextWriter.hh"
+
+#include "tests\RA_UnitTestHelpers.h"
+#include "tests\data\DataAsserts.hh"
+
+#include "tests\mocks\MockFileSystem.hh"
+#include "tests\mocks\MockGameContext.hh"
+
+using namespace Microsoft::VisualStudio::CppUnitTestFramework;
+
+namespace ra {
+namespace data {
+namespace models {
+namespace tests {
+
+TEST_CLASS(LocalBadgesModel_Tests)
+{
+private:
+    class LocalBadgesModelHarness : public LocalBadgesModel
+    {
+    public:
+        ra::services::impl::StringTextWriter textWriter;
+
+        ra::data::context::mocks::MockGameContext mockGameContext;
+        ra::services::mocks::MockFileSystem mockFileSystem;
+    };
+
+public:
+    TEST_METHOD(TestInitialValues)
+    {
+        LocalBadgesModelHarness badges;
+
+        Assert::AreEqual(AssetType::LocalBadges, badges.GetType());
+        Assert::AreEqual(0U, badges.GetID());
+        Assert::AreEqual(std::wstring(L"Local Badges"), badges.GetName());
+        Assert::AreEqual(std::wstring(L""), badges.GetDescription());
+        Assert::AreEqual(AssetCategory::Core, badges.GetCategory());
+        Assert::AreEqual(AssetState::Inactive, badges.GetState());
+        Assert::AreEqual(AssetChanges::None, badges.GetChanges());
+        Assert::IsFalse(badges.NeedsSerialized());
+    }
+
+    TEST_METHOD(TestSerializeSingle)
+    {
+        LocalBadgesModelHarness badges;
+
+        badges.AddReference(L"local\\123-A.png", false);
+        Assert::IsFalse(badges.NeedsSerialized());
+        badges.Serialize(badges.textWriter);
+        Assert::AreEqual(std::string("b"), badges.textWriter.GetString());
+        badges.textWriter.GetString().clear();
+
+        badges.Commit(L"", L"local\\123-A.png");
+        Assert::IsTrue(badges.NeedsSerialized());
+        badges.Serialize(badges.textWriter);
+        Assert::AreEqual(std::string("b:A.png=1"), badges.textWriter.GetString());
+    }
+
+    TEST_METHOD(TestSerializeSingleMultipleReference)
+    {
+        LocalBadgesModelHarness badges;
+
+        badges.AddReference(L"local\\123-A.png", false);
+        badges.AddReference(L"local\\123-A.png", false);
+        badges.AddReference(L"local\\123-A.png", false);
+        Assert::IsFalse(badges.NeedsSerialized());
+        badges.Serialize(badges.textWriter);
+        Assert::AreEqual(std::string("b"), badges.textWriter.GetString());
+        badges.textWriter.GetString().clear();
+
+        badges.Commit(L"", L"local\\123-A.png");
+        badges.Commit(L"12345", L"local\\123-A.png");
+        badges.Commit(L"22222", L"local\\123-A.png");
+        Assert::IsTrue(badges.NeedsSerialized());
+        badges.Serialize(badges.textWriter);
+        Assert::AreEqual(std::string("b:A.png=3"), badges.textWriter.GetString());
+    }
+
+    TEST_METHOD(TestSerializeSinglePartialMultipleReference)
+    {
+        LocalBadgesModelHarness badges;
+
+        badges.AddReference(L"local\\123-A.png", false);
+        badges.AddReference(L"local\\123-A.png", false);
+        badges.AddReference(L"local\\123-A.png", false);
+        Assert::IsFalse(badges.NeedsSerialized());
+        badges.Serialize(badges.textWriter);
+        Assert::AreEqual(std::string("b"), badges.textWriter.GetString());
+        badges.textWriter.GetString().clear();
+
+        badges.Commit(L"", L"local\\123-A.png");
+        badges.Commit(L"12345", L"local\\123-A.png");
+        Assert::IsTrue(badges.NeedsSerialized());
+        badges.Serialize(badges.textWriter);
+        Assert::AreEqual(std::string("b:A.png=2"), badges.textWriter.GetString());
+    }
+
+    TEST_METHOD(TestSerializeMultiple)
+    {
+        LocalBadgesModelHarness badges;
+
+        badges.AddReference(L"local\\123-A.png", false);
+        badges.AddReference(L"local\\123-B.png", false);
+        badges.AddReference(L"local\\123-C.png", false);
+        Assert::IsFalse(badges.NeedsSerialized());
+        badges.Serialize(badges.textWriter);
+        Assert::AreEqual(std::string("b"), badges.textWriter.GetString());
+        badges.textWriter.GetString().clear();
+
+        badges.Commit(L"", L"local\\123-A.png");
+        badges.Commit(L"12345", L"local\\123-B.png");
+        badges.Commit(L"22222", L"local\\123-C.png");
+        Assert::IsTrue(badges.NeedsSerialized());
+        badges.Serialize(badges.textWriter);
+        Assert::AreEqual(std::string("b:A.png=1:B.png=1:C.png=1"), badges.textWriter.GetString());
+    }
+
+    TEST_METHOD(TestDeserialize)
+    {
+        LocalBadgesModelHarness badges;
+        std::string sInput = "b:A.png=1:B.png=1";
+        ra::Tokenizer pTokenizer(sInput);
+        badges.mockGameContext.SetGameId(123U);
+
+        pTokenizer.Advance(2); // GameAssets reads "b:"
+        Assert::IsTrue(badges.Deserialize(pTokenizer));
+        Assert::AreEqual(1, badges.GetReferenceCount(L"local\\123-A.png"));
+        Assert::AreEqual(1, badges.GetReferenceCount(L"local\\123-B.png"));
+
+        Assert::IsTrue(badges.NeedsSerialized());
+        badges.Serialize(badges.textWriter);
+        Assert::AreEqual(std::string("b:A.png=1:B.png=1"), badges.textWriter.GetString());
+    }
+
+    TEST_METHOD(TestDeserializeChanged)
+    {
+        LocalBadgesModelHarness badges;
+        std::string sInput = "b:A.png=1:B.png=1";
+        ra::Tokenizer pTokenizer(sInput);
+        badges.mockGameContext.SetGameId(123U);
+        badges.mockFileSystem.MockFileSize(L"local\\123-A.png", 1234);
+        badges.mockFileSystem.MockFileSize(L"local\\123-B.png", 2345);
+
+        pTokenizer.Advance(2); // GameAssets reads "b:"
+        Assert::IsTrue(badges.Deserialize(pTokenizer));
+
+        badges.RemoveReference(L"local\\123-A.png", false);
+        badges.AddReference(L"local\\123-C.png", false);
+        badges.Commit(L"local\\123-A.png", L"local\\123-C.png");
+
+        Assert::IsTrue(badges.NeedsSerialized());
+        badges.Serialize(badges.textWriter);
+        Assert::AreEqual(std::string("b:B.png=1:C.png=1"), badges.textWriter.GetString());
+
+        Assert::AreEqual({ -1 }, badges.mockFileSystem.GetFileSize(L"local\\123-A.png"));
+        Assert::AreEqual({ 2345 }, badges.mockFileSystem.GetFileSize(L"local\\123-B.png"));
+    }
+
+    TEST_METHOD(TestDeserializePublished)
+    {
+        LocalBadgesModelHarness badges;
+        std::string sInput = "b:A.png=1:B.png=1";
+        ra::Tokenizer pTokenizer(sInput);
+        badges.mockGameContext.SetGameId(123U);
+        badges.mockFileSystem.MockFileSize(L"local\\123-A.png", 1234);
+        badges.mockFileSystem.MockFileSize(L"local\\123-B.png", 2345);
+
+        pTokenizer.Advance(2); // GameAssets reads "b:"
+        Assert::IsTrue(badges.Deserialize(pTokenizer));
+
+        badges.RemoveReference(L"local\\123-A.png", false);
+        badges.Commit(L"local\\123-A.png", L"22222");
+
+        Assert::IsTrue(badges.NeedsSerialized());
+        badges.Serialize(badges.textWriter);
+        Assert::AreEqual(std::string("b:B.png=1"), badges.textWriter.GetString());
+
+        // combination of RemoveReference and Commit should have deleted 123-A.png
+        Assert::AreEqual({ -1 }, badges.mockFileSystem.GetFileSize(L"local\\123-A.png"));
+        Assert::AreEqual({ 2345 }, badges.mockFileSystem.GetFileSize(L"local\\123-B.png"));
+    }
+
+    TEST_METHOD(TestDeserializeChangedStillReferenced)
+    {
+        LocalBadgesModelHarness badges;
+        std::string sInput = "b:A.png=2:B.png=1";
+        ra::Tokenizer pTokenizer(sInput);
+        badges.mockGameContext.SetGameId(123U);
+        badges.mockFileSystem.MockFileSize(L"local\\123-A.png", 1234);
+        badges.mockFileSystem.MockFileSize(L"local\\123-B.png", 2345);
+
+        pTokenizer.Advance(2); // GameAssets reads "b:"
+        Assert::IsTrue(badges.Deserialize(pTokenizer));
+
+        badges.RemoveReference(L"local\\123-A.png", false);
+        badges.AddReference(L"local\\123-C.png", false);
+        badges.Commit(L"local\\123-A.png", L"local\\123-C.png");
+
+        Assert::IsTrue(badges.NeedsSerialized());
+        badges.Serialize(badges.textWriter);
+        Assert::AreEqual(std::string("b:A.png=1:B.png=1:C.png=1"), badges.textWriter.GetString());
+
+        Assert::AreEqual({ 1234 }, badges.mockFileSystem.GetFileSize(L"local\\123-A.png"));
+        Assert::AreEqual({ 2345 }, badges.mockFileSystem.GetFileSize(L"local\\123-B.png"));
+    }
+
+    TEST_METHOD(TestDeserializeChangedNotCommitted)
+    {
+        LocalBadgesModelHarness badges;
+        std::string sInput = "b:A.png=1:B.png=1";
+        ra::Tokenizer pTokenizer(sInput);
+        badges.mockGameContext.SetGameId(123U);
+        badges.mockFileSystem.MockFileSize(L"local\\123-A.png", 1234);
+        badges.mockFileSystem.MockFileSize(L"local\\123-B.png", 2345);
+
+        pTokenizer.Advance(2); // GameAssets reads "b:"
+        Assert::IsTrue(badges.Deserialize(pTokenizer));
+
+        badges.RemoveReference(L"local\\123-A.png", false);
+        badges.AddReference(L"local\\123-C.png", false);
+
+        Assert::IsTrue(badges.NeedsSerialized());
+        badges.Serialize(badges.textWriter);
+        Assert::AreEqual(std::string("b:A.png=1:B.png=1"), badges.textWriter.GetString());
+
+        Assert::AreEqual({ 1234 }, badges.mockFileSystem.GetFileSize(L"local\\123-A.png"));
+        Assert::AreEqual({ 2345 }, badges.mockFileSystem.GetFileSize(L"local\\123-B.png"));
+    }
+
+    TEST_METHOD(TestDeleteUncommittedBadges)
+    {
+        LocalBadgesModelHarness badges;
+        std::string sInput = "b:A.png=1:B.png=1";
+        ra::Tokenizer pTokenizer(sInput);
+        badges.mockGameContext.SetGameId(123U);
+        badges.mockFileSystem.MockFileSize(L"local\\123-A.png", 1234);
+        badges.mockFileSystem.MockFileSize(L"local\\123-B.png", 2345);
+
+        pTokenizer.Advance(2); // GameAssets reads "b:"
+        Assert::IsTrue(badges.Deserialize(pTokenizer));
+
+        badges.mockFileSystem.MockFileSize(L"local\\123-C.png", 3456);
+        badges.AddReference(L"local\\123-C.png", false);
+        Assert::AreEqual({ 3456 }, badges.mockFileSystem.GetFileSize(L"local\\123-C.png"));
+
+        badges.DeleteUncommittedBadges();
+
+        Assert::AreEqual({ 1234 }, badges.mockFileSystem.GetFileSize(L"local\\123-A.png"));
+        Assert::AreEqual({ 2345 }, badges.mockFileSystem.GetFileSize(L"local\\123-B.png"));
+        Assert::AreEqual({ -1 }, badges.mockFileSystem.GetFileSize(L"local\\123-C.png"));
+    }
+};
+
+
+} // namespace tests
+} // namespace models
+} // namespace data
+} // namespace ra
diff --git a/tests/mocks/MockFileSystem.hh b/tests/mocks/MockFileSystem.hh
index 1a5cdcbe..71b90d2e 100644
--- a/tests/mocks/MockFileSystem.hh
+++ b/tests/mocks/MockFileSystem.hh
@@ -142,6 +142,21 @@ public:
         return true;
     }
 
+    bool CopyFile(const std::wstring& sOldPath, const std::wstring& sNewPath) const override
+    {
+        const auto hNode = m_mFileContents.find(sOldPath);
+        if (hNode == m_mFileContents.end())
+            return false;
+
+        m_mFileContents.insert_or_assign(sNewPath, hNode->second);
+
+        const auto hNode2 = m_mFileSizes.find(sOldPath);
+        if (hNode2 != m_mFileSizes.end())
+            m_mFileSizes.insert_or_assign(sNewPath, hNode2->second);
+
+        return true;
+    }
+
     std::unique_ptr<TextReader> OpenTextFile(const std::wstring& sPath) const override
     {
         const auto pIter = m_mFileContents.find(sPath);
diff --git a/tests/mocks/MockImageRepository.hh b/tests/mocks/MockImageRepository.hh
index f0138120..48052b3f 100644
--- a/tests/mocks/MockImageRepository.hh
+++ b/tests/mocks/MockImageRepository.hh
@@ -4,6 +4,8 @@
 
 #include "ui\ImageReference.hh"
 
+#include "RA_StringUtils.h"
+
 namespace ra {
 namespace ui {
 namespace mocks {
@@ -42,6 +44,11 @@ public:
 
     }
 
+    std::string StoreImage(_UNUSED ImageType nType, _UNUSED const std::wstring& sPath) override
+    {
+        return ra::Narrow(sPath);
+    }
+
     void AddReference(_UNUSED const ImageReference& pImage) noexcept override
     {